import { createHash, createHmac } from 'node:crypto'; import https from 'node:https'; import type { AppConfig } from './config.js'; const OSS4_ALGORITHM = 'OSS4-HMAC-SHA256'; const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD'; export interface OssPutObjectInput { content: Buffer; contentType: string; contentDisposition?: string; } export interface OssObjectClient { putObject(objectKey: string, input: OssPutObjectInput): Promise; getObject?(objectKey: string): Promise; deleteObject(objectKey: string): Promise; publicUrl(objectKey: string): string; } export class OssClientError extends Error { constructor( message: string, public readonly statusCode?: number, public readonly requestId?: string, options?: { cause?: unknown } ) { super(message, options); this.name = 'OssClientError'; } } function encodeUriComponent(value: string): string { return encodeURIComponent(value).replace(/[!'()*]/g, (character) => ( `%${character.charCodeAt(0).toString(16).toUpperCase()}` )); } function encodePath(value: string): string { return value.split('/').map(encodeUriComponent).join('/'); } function canonicalQuery(query: Record = {}): string { return Object.keys(query) .sort() .map((key) => { const encodedKey = encodeUriComponent(key); const value = query[key]; return value === '' ? encodedKey : `${encodedKey}=${encodeUriComponent(value)}`; }) .join('&'); } function hmac(key: string | Buffer, value: string): Buffer { return createHmac('sha256', key).update(value).digest(); } function isoOssDate(date: Date): string { return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); } export interface OssSignatureInput { accessKeyId: string; accessKeySecret: string; region: string; bucket: string; method: string; objectKey?: string; query?: Record; headers: Record; date?: Date; additionalHeaders?: string[]; } export function buildOssV4Authorization(input: OssSignatureInput): { authorization: string; timestamp: string; canonicalRequest: string; } { const timestamp = isoOssDate(input.date || new Date()); const signDate = timestamp.slice(0, 8); const headers = Object.fromEntries( Object.entries(input.headers).map(([key, value]) => [key.toLowerCase(), String(value).trim()]) ); headers['x-oss-date'] ||= timestamp; headers['x-oss-content-sha256'] ||= UNSIGNED_PAYLOAD; const additionalHeaders = [...new Set((input.additionalHeaders || []).map((key) => key.toLowerCase()))] .filter((key) => key !== 'content-type' && key !== 'content-md5' && !key.startsWith('x-oss-')) .sort(); for (const key of additionalHeaders) { if (!(key in headers)) throw new Error(`Missing OSS additional header: ${key}`); } const signedHeaderNames = new Set(additionalHeaders); for (const key of Object.keys(headers)) { if (key === 'content-type' || key === 'content-md5' || key.startsWith('x-oss-')) { signedHeaderNames.add(key); } } const canonicalHeaders = [...signedHeaderNames] .sort() .map((key) => `${key}:${headers[key]}\n`) .join(''); const canonicalUri = `/${encodePath(input.bucket)}/${encodePath(input.objectKey || '')}`; const canonicalRequest = [ input.method.toUpperCase(), canonicalUri, canonicalQuery(input.query), canonicalHeaders, additionalHeaders.join(';'), headers['x-oss-content-sha256'] ].join('\n'); const scope = `${signDate}/${input.region}/oss/aliyun_v4_request`; const stringToSign = [ OSS4_ALGORITHM, headers['x-oss-date'], scope, createHash('sha256').update(canonicalRequest).digest('hex') ].join('\n'); const dateKey = hmac(`aliyun_v4${input.accessKeySecret}`, signDate); const regionKey = hmac(dateKey, input.region); const ossKey = hmac(regionKey, 'oss'); const signingKey = hmac(ossKey, 'aliyun_v4_request'); const signature = createHmac('sha256', signingKey).update(stringToSign).digest('hex'); const additional = additionalHeaders.length ? `,AdditionalHeaders=${additionalHeaders.join(';')}` : ''; return { authorization: `${OSS4_ALGORITHM} Credential=${input.accessKeyId}/${scope}${additional},Signature=${signature}`, timestamp: headers['x-oss-date'], canonicalRequest }; } function parseEndpoint(endpoint: string): string { return endpoint.replace(/^https?:\/\//i, '').replace(/\/+$/, ''); } function objectUrl(endpoint: string, bucket: string, objectKey: string): string { return `https://${bucket}.${parseEndpoint(endpoint)}/${encodePath(objectKey)}`; } interface OssResponse { statusCode: number; requestId: string; body: Buffer; } export class AliyunOssClient implements OssObjectClient { private readonly endpoint: string; private readonly bucket: string; private readonly region: string; private readonly accessKeyId: string; private readonly accessKeySecret: string; constructor(config: AppConfig) { this.endpoint = parseEndpoint(config.OSS_ENDPOINT || ''); this.bucket = config.OSS_BUCKET_NAME || ''; this.region = config.ossRegion; this.accessKeyId = config.OSS_ACCESS_KEY_ID || ''; this.accessKeySecret = config.OSS_ACCESS_KEY_SECRET || ''; } publicUrl(objectKey: string): string { return objectUrl(this.endpoint, this.bucket, objectKey); } async putObject(objectKey: string, input: OssPutObjectInput): Promise { const headers: Record = { 'content-type': input.contentType, 'x-oss-content-sha256': UNSIGNED_PAYLOAD, 'x-oss-object-acl': 'public-read' }; if (input.contentDisposition) headers['content-disposition'] = input.contentDisposition; const additionalHeaders = input.contentDisposition ? ['content-disposition'] : []; const response = await this.request('PUT', objectKey, {}, headers, input.content, additionalHeaders); if (![200, 201].includes(response.statusCode)) { throw this.errorForResponse('put object', response); } } async getObject(objectKey: string): Promise { const response = await this.request( 'GET', objectKey, {}, { 'x-oss-content-sha256': UNSIGNED_PAYLOAD }, undefined, [], 50_000_000 ); if (response.statusCode !== 200) { throw this.errorForResponse('get object', response); } return response.body; } async deleteObject(objectKey: string): Promise { const response = await this.request( 'DELETE', objectKey, {}, { 'x-oss-content-sha256': UNSIGNED_PAYLOAD }, undefined, [] ); if (![200, 204].includes(response.statusCode) && response.statusCode !== 404) { throw this.errorForResponse('delete object', response); } } private async request( method: string, objectKey: string, query: Record, requestHeaders: Record, body: Buffer | undefined, additionalHeaders: string[], maxResponseBytes = 1_048_576 ): Promise { const headers = { ...requestHeaders, 'x-oss-date': isoOssDate(new Date()) }; const signed = buildOssV4Authorization({ accessKeyId: this.accessKeyId, accessKeySecret: this.accessKeySecret, region: this.region, bucket: this.bucket, method, objectKey, query, headers, additionalHeaders }); const requestPath = `/${encodePath(objectKey)}${canonicalQuery(query) ? `?${canonicalQuery(query)}` : ''}`; const response = await new Promise((resolve, reject) => { const request = https.request({ hostname: `${this.bucket}.${this.endpoint}`, port: 443, method, path: requestPath, headers: { ...headers, Authorization: signed.authorization, ...(body ? { 'Content-Length': String(body.byteLength) } : {}) }, timeout: 20_000, agent: new https.Agent({ keepAlive: true, maxSockets: 10 }) }, (incoming) => { const chunks: Buffer[] = []; let total = 0; incoming.on('data', (chunk: Buffer) => { const remaining = Math.max(0, maxResponseBytes - total); if (!remaining) return; const buffer = Buffer.from(chunk).subarray(0, remaining); chunks.push(buffer); total += buffer.byteLength; }); incoming.on('end', () => resolve({ statusCode: incoming.statusCode || 0, requestId: String(incoming.headers['x-oss-request-id'] || ''), body: Buffer.concat(chunks).subarray(0, maxResponseBytes) })); }); request.on('error', reject); request.on('timeout', () => request.destroy(new Error('OSS request timed out.'))); if (body) request.write(body); request.end(); }); return response; } private errorForResponse(operation: string, response: OssResponse): OssClientError { const code = /([^<]+)<\/Code>/.exec(response.body.toString('utf8'))?.[1] || ''; return new OssClientError( `OSS ${operation} failed${code ? ` (${code})` : ''} with HTTP ${response.statusCode}.`, response.statusCode, response.requestId ); } }