Files
LWLT-AIBOT/control-plane/src/oss-client.ts
2026-08-25 10:33:29 +08:00

282 lines
9.1 KiB
TypeScript

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<void>;
getObject?(objectKey: string): Promise<Buffer>;
deleteObject(objectKey: string): Promise<void>;
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, string> = {}): 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<string, string>;
headers: Record<string, string>;
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<void> {
const headers: Record<string, string> = {
'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<Buffer> {
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<void> {
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<string, string>,
requestHeaders: Record<string, string>,
body: Buffer | undefined,
additionalHeaders: string[],
maxResponseBytes = 1_048_576
): Promise<OssResponse> {
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<OssResponse>((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>([^<]+)<\/Code>/.exec(response.body.toString('utf8'))?.[1] || '';
return new OssClientError(
`OSS ${operation} failed${code ? ` (${code})` : ''} with HTTP ${response.statusCode}.`,
response.statusCode,
response.requestId
);
}
}