282 lines
9.1 KiB
TypeScript
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
|
|
);
|
|
}
|
|
}
|