forked from lunanting/cangku
368 lines
11 KiB
JavaScript
368 lines
11 KiB
JavaScript
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const {
|
|
GetObjectCommand,
|
|
ListObjectsV2Command,
|
|
PutObjectCommand,
|
|
S3Client,
|
|
} = require('@aws-sdk/client-s3');
|
|
const database = require('../config/database');
|
|
|
|
const S3_CONFIG_TABLE = 's3_configs';
|
|
const STORAGE_CLASSES = new Set([
|
|
'STANDARD',
|
|
'LINE',
|
|
'INTELLIGENT_TIERING',
|
|
'GLACIER_IR',
|
|
'GLACIER',
|
|
'DEEP_ARCHIVE',
|
|
]);
|
|
|
|
let schemaReady = false;
|
|
let schemaPromise = null;
|
|
|
|
const IMAGE_EXTENSION_MAP = {
|
|
'image/jpeg': 'jpg',
|
|
'image/jpg': 'jpg',
|
|
'image/png': 'png',
|
|
'image/gif': 'gif',
|
|
'image/webp': 'webp',
|
|
'image/svg+xml': 'svg',
|
|
'image/x-icon': 'ico',
|
|
};
|
|
|
|
function normalizeProtocol(protocol = '', endpoint = '') {
|
|
const normalized = String(protocol || '').trim().toLowerCase();
|
|
if (normalized === 'http' || normalized === 'https') {
|
|
return normalized;
|
|
}
|
|
return String(endpoint || '').trim().toLowerCase().startsWith('http://') ? 'http' : 'https';
|
|
}
|
|
|
|
function normalizeEndpoint(endpoint = '', protocol = '') {
|
|
const normalized = String(endpoint || '').trim().replace(/\/+$/, '');
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
|
|
const resolvedProtocol = normalizeProtocol(protocol, normalized);
|
|
return /^https?:\/\//i.test(normalized)
|
|
? normalized.replace(/^https?/i, resolvedProtocol)
|
|
: `${resolvedProtocol}://${normalized}`;
|
|
}
|
|
|
|
function normalizePublicDomain(domain = '', protocol = '') {
|
|
const normalized = String(domain || '').trim().replace(/\/+$/, '');
|
|
if (!normalized) return '';
|
|
const resolvedProtocol = normalizeProtocol(protocol, normalized);
|
|
return /^https?:\/\//i.test(normalized)
|
|
? normalized.replace(/^https?/i, resolvedProtocol)
|
|
: `${resolvedProtocol}://${normalized}`;
|
|
}
|
|
|
|
function normalizeStorageClass(storageClass = '') {
|
|
const normalized = String(storageClass || 'STANDARD').trim().toUpperCase();
|
|
return STORAGE_CLASSES.has(normalized) ? normalized : 'STANDARD';
|
|
}
|
|
|
|
async function ensureObjectStorageSchema() {
|
|
if (schemaReady) return;
|
|
if (schemaPromise) {
|
|
await schemaPromise;
|
|
return;
|
|
}
|
|
|
|
schemaPromise = (async () => {
|
|
try {
|
|
await database.local.query(
|
|
`CREATE TABLE IF NOT EXISTS ${S3_CONFIG_TABLE} (
|
|
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID',
|
|
provider VARCHAR(20) NOT NULL COMMENT '对象存储提供商',
|
|
endpoint VARCHAR(255) NULL COMMENT 'S3 Endpoint',
|
|
region VARCHAR(50) NULL COMMENT 'S3 Region',
|
|
zone VARCHAR(32) NULL COMMENT '厂商区域短码',
|
|
protocol VARCHAR(10) NOT NULL DEFAULT 'https' COMMENT '访问协议',
|
|
storage_class VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '默认存储类型',
|
|
bucket VARCHAR(100) NOT NULL COMMENT '存储桶名称',
|
|
access_key_id VARCHAR(255) NOT NULL COMMENT 'Access Key ID',
|
|
secret_access_key VARCHAR(255) NOT NULL COMMENT 'Secret Access Key',
|
|
custom_domain VARCHAR(255) NULL COMMENT '公开访问域名',
|
|
cdn_url VARCHAR(255) NULL COMMENT 'CDN地址',
|
|
is_default BOOLEAN DEFAULT FALSE COMMENT '是否默认',
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对象存储配置表'`
|
|
);
|
|
} catch (error) {
|
|
if (error.code !== 'ER_TABLEACCESS_DENIED_ERROR') throw error;
|
|
const existingTables = await database.local.query(
|
|
`SELECT 1
|
|
FROM information_schema.TABLES
|
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
|
LIMIT 1`,
|
|
[S3_CONFIG_TABLE]
|
|
);
|
|
if (!existingTables.length) throw error;
|
|
}
|
|
|
|
const rows = await database.local.query(
|
|
`SELECT COLUMN_NAME AS columnName
|
|
FROM information_schema.COLUMNS
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME = ?`,
|
|
[S3_CONFIG_TABLE]
|
|
);
|
|
const columns = new Set(rows.map((row) => String(row.columnName || '').toLowerCase()));
|
|
const protocolColumnMissing = !columns.has('protocol');
|
|
const missingColumns = [
|
|
['zone', "zone VARCHAR(32) NULL COMMENT '厂商区域短码' AFTER region"],
|
|
['protocol', "protocol VARCHAR(10) NOT NULL DEFAULT 'https' COMMENT '访问协议' AFTER zone"],
|
|
['storage_class', "storage_class VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '默认存储类型' AFTER protocol"],
|
|
];
|
|
|
|
for (const [columnName, definition] of missingColumns) {
|
|
if (!columns.has(columnName)) {
|
|
await database.local.query(`ALTER TABLE ${S3_CONFIG_TABLE} ADD COLUMN ${definition}`);
|
|
}
|
|
}
|
|
|
|
if (protocolColumnMissing) {
|
|
await database.local.query(
|
|
`UPDATE ${S3_CONFIG_TABLE}
|
|
SET protocol = CASE
|
|
WHEN LOWER(COALESCE(endpoint, '')) LIKE 'http://%' THEN 'http'
|
|
ELSE 'https'
|
|
END`
|
|
);
|
|
}
|
|
|
|
schemaReady = true;
|
|
})();
|
|
|
|
try {
|
|
await schemaPromise;
|
|
} finally {
|
|
schemaPromise = null;
|
|
}
|
|
}
|
|
|
|
function encodeObjectKey(key) {
|
|
return String(key)
|
|
.split('/')
|
|
.map((segment) => encodeURIComponent(segment))
|
|
.join('/');
|
|
}
|
|
|
|
function isPathStyleProvider(provider = '') {
|
|
return ['minio', 'custom', 'qiniu'].includes(String(provider).toLowerCase());
|
|
}
|
|
|
|
function buildPublicUrl(config, key) {
|
|
const encodedKey = encodeObjectKey(key);
|
|
const publicBase = normalizePublicDomain(config.customDomain, config.protocol);
|
|
|
|
if (publicBase) {
|
|
return `${publicBase}/${encodedKey}`;
|
|
}
|
|
|
|
const endpoint = normalizeEndpoint(config.endpoint, config.protocol);
|
|
if (!endpoint || !config.bucket) {
|
|
return encodedKey;
|
|
}
|
|
|
|
return `${endpoint}/${encodeURIComponent(config.bucket)}/${encodedKey}`;
|
|
}
|
|
|
|
function parseBase64Image(dataUrl) {
|
|
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl || '');
|
|
if (!match) {
|
|
throw new Error('图片数据格式不正确');
|
|
}
|
|
|
|
const [, mimeType, base64Data] = match;
|
|
const extension = IMAGE_EXTENSION_MAP[mimeType] || 'png';
|
|
|
|
return {
|
|
mimeType,
|
|
extension,
|
|
buffer: Buffer.from(base64Data, 'base64'),
|
|
};
|
|
}
|
|
|
|
async function getDefaultS3Config(includeSecret = false) {
|
|
await ensureObjectStorageSchema();
|
|
const fields = [
|
|
'id',
|
|
'provider',
|
|
'endpoint',
|
|
'region',
|
|
'zone',
|
|
'protocol',
|
|
'storage_class AS storageClass',
|
|
'bucket',
|
|
'access_key_id AS accessKeyId',
|
|
'custom_domain AS customDomain',
|
|
'is_default AS isDefault',
|
|
];
|
|
|
|
if (includeSecret) {
|
|
fields.push('secret_access_key AS secretAccessKey');
|
|
}
|
|
|
|
const rows = await database.local.query(
|
|
`SELECT ${fields.join(', ')} FROM s3_configs WHERE is_default = 1 LIMIT 1`
|
|
);
|
|
|
|
return rows[0] || null;
|
|
}
|
|
|
|
function createS3Client(config) {
|
|
const endpoint = normalizeEndpoint(config.endpoint, config.protocol);
|
|
if (!endpoint || !config.bucket || !config.accessKeyId || !config.secretAccessKey) {
|
|
throw new Error('对象存储配置不完整,请先保存 Endpoint、Bucket 和密钥');
|
|
}
|
|
|
|
return new S3Client({
|
|
region: config.region || 'auto',
|
|
endpoint,
|
|
forcePathStyle: isPathStyleProvider(config.provider),
|
|
credentials: {
|
|
accessKeyId: config.accessKeyId,
|
|
secretAccessKey: config.secretAccessKey,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function testObjectStorageConnection(config) {
|
|
const client = createS3Client(config);
|
|
const result = await client.send(new ListObjectsV2Command({
|
|
Bucket: config.bucket,
|
|
MaxKeys: 1,
|
|
}));
|
|
|
|
return {
|
|
bucket: config.bucket,
|
|
endpoint: normalizeEndpoint(config.endpoint, config.protocol),
|
|
region: config.region || 'auto',
|
|
zone: config.zone || '',
|
|
storageClass: normalizeStorageClass(config.storageClass),
|
|
keyCount: Number(result.KeyCount || 0),
|
|
};
|
|
}
|
|
|
|
async function uploadBase64ImageToDefaultS3(dataUrl, folder = 'images') {
|
|
const config = await getDefaultS3Config(true);
|
|
if (!config) {
|
|
throw new Error('尚未配置默认对象存储');
|
|
}
|
|
|
|
const { buffer, mimeType, extension } = parseBase64Image(dataUrl);
|
|
const client = createS3Client(config);
|
|
const key = `${folder.replace(/^\/+|\/+$/g, '')}/${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${extension}`;
|
|
|
|
await client.send(
|
|
new PutObjectCommand({
|
|
Bucket: config.bucket,
|
|
Key: key,
|
|
Body: buffer,
|
|
ContentType: mimeType,
|
|
StorageClass: normalizeStorageClass(config.storageClass),
|
|
})
|
|
);
|
|
|
|
return {
|
|
key,
|
|
url: buildPublicUrl(config, key),
|
|
};
|
|
}
|
|
|
|
async function uploadBufferToDefaultS3(buffer, mimeType = 'application/octet-stream', folder = 'files', extension = '') {
|
|
const config = await getDefaultS3Config(true);
|
|
if (!config) {
|
|
throw new Error('尚未配置默认对象存储');
|
|
}
|
|
|
|
const client = createS3Client(config);
|
|
const resolvedExtension = String(extension || '').replace(/^\./, '')
|
|
|| IMAGE_EXTENSION_MAP[mimeType]
|
|
|| 'bin';
|
|
const key = `${folder.replace(/^\/+|\/+$/g, '')}/${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${resolvedExtension}`;
|
|
|
|
await client.send(
|
|
new PutObjectCommand({
|
|
Bucket: config.bucket,
|
|
Key: key,
|
|
Body: buffer,
|
|
ContentType: mimeType,
|
|
StorageClass: normalizeStorageClass(config.storageClass),
|
|
})
|
|
);
|
|
|
|
return {
|
|
key,
|
|
url: buildPublicUrl(config, key),
|
|
};
|
|
}
|
|
|
|
async function uploadFileToDefaultS3(filePath, options = {}) {
|
|
const config = await getDefaultS3Config(true);
|
|
if (!config) {
|
|
throw new Error('尚未配置默认对象存储');
|
|
}
|
|
|
|
const stats = await fs.promises.stat(filePath);
|
|
const client = createS3Client(config);
|
|
const folder = String(options.folder || 'files').replace(/^\/+|\/+$/g, '');
|
|
const extension = String(options.extension || path.extname(filePath) || '.bin').replace(/^\./, '');
|
|
const key = String(options.key || `${folder}/${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${extension}`)
|
|
.replace(/^\/+/, '');
|
|
|
|
await client.send(
|
|
new PutObjectCommand({
|
|
Bucket: config.bucket,
|
|
Key: key,
|
|
Body: fs.createReadStream(filePath),
|
|
ContentLength: stats.size,
|
|
ContentType: options.mimeType || 'application/octet-stream',
|
|
StorageClass: normalizeStorageClass(config.storageClass),
|
|
})
|
|
);
|
|
|
|
return {
|
|
key,
|
|
size: stats.size,
|
|
url: buildPublicUrl(config, key),
|
|
};
|
|
}
|
|
|
|
async function getObjectFromDefaultS3(key, options = {}) {
|
|
const config = await getDefaultS3Config(true);
|
|
if (!config) {
|
|
throw new Error('尚未配置默认对象存储');
|
|
}
|
|
|
|
const client = createS3Client(config);
|
|
return client.send(new GetObjectCommand({
|
|
Bucket: config.bucket,
|
|
Key: String(key || '').replace(/^\/+/, ''),
|
|
...(options.range ? { Range: options.range } : {}),
|
|
}));
|
|
}
|
|
|
|
module.exports = {
|
|
buildPublicUrl,
|
|
createS3Client,
|
|
ensureObjectStorageSchema,
|
|
getDefaultS3Config,
|
|
getObjectFromDefaultS3,
|
|
normalizeEndpoint,
|
|
normalizeProtocol,
|
|
normalizePublicDomain,
|
|
normalizeStorageClass,
|
|
testObjectStorageConnection,
|
|
uploadBase64ImageToDefaultS3,
|
|
uploadBufferToDefaultS3,
|
|
uploadFileToDefaultS3,
|
|
};
|