import { createHmac } from "node:crypto"; import { extname } from "node:path"; type OssConfig = { accessKeyId: string; accessKeySecret: string; bucket: string; endpoint: string; publicBaseUrl: string | null; }; export type OssUploadResult = | { ok: true; bucket: string; objectKey: string; url: string; } | { ok: false; error: string; }; function getOssConfig(): OssConfig | null { const accessKeyId = process.env.OSS_ACCESS_KEY_ID; const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET; const bucket = process.env.OSS_BUCKET; const endpoint = getOssEndpoint(); if (!accessKeyId || !accessKeySecret || !bucket || !endpoint) { return null; } return { accessKeyId, accessKeySecret, bucket, endpoint, publicBaseUrl: process.env.OSS_PUBLIC_BASE_URL ?? process.env.OSS_PUBLIC_URL ?? null, }; } function getOssEndpoint() { const endpoint = process.env.OSS_ENDPOINT?.trim(); if (endpoint) { return endpoint.startsWith("http") ? endpoint : `https://${endpoint}`; } const region = process.env.OSS_REGION?.trim(); if (!region) { return null; } if (region.startsWith("http")) { return region; } return region.startsWith("oss-") ? `https://${region}.aliyuncs.com` : `https://oss-${region}.aliyuncs.com`; } function contentTypeToExtension(contentType: string) { if (contentType.includes("png")) { return "png"; } if (contentType.includes("webp")) { return "webp"; } if (contentType.includes("gif")) { return "gif"; } if (contentType.includes("avif")) { return "avif"; } return "jpg"; } function extensionFromUrl(url: string, contentType: string) { const pathname = new URL(url).pathname; const extension = extname(pathname).replace(".", "").toLowerCase(); if (extension && /^[a-z0-9]{2,5}$/.test(extension)) { return extension === "jpeg" ? "jpg" : extension; } return contentTypeToExtension(contentType); } function normalizeBaseUrl(value: string) { return value.replace(/\/+$/, ""); } function getBucketHost(config: OssConfig) { const endpointUrl = new URL(config.endpoint); return `${config.bucket}.${endpointUrl.host}`; } function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : "Unknown OSS error."; } function signPutObject({ config, contentType, date, objectKey, }: { config: OssConfig; contentType: string; date: string; objectKey: string; }) { const canonicalizedResource = `/${config.bucket}/${objectKey}`; const stringToSign = [ "PUT", "", contentType, date, canonicalizedResource, ].join("\n"); const signature = createHmac("sha1", config.accessKeySecret) .update(stringToSign) .digest("base64"); return `OSS ${config.accessKeyId}:${signature}`; } export async function uploadRemoteImageToAliyunOss({ imageUrl, objectKeyPrefix, }: { imageUrl: string; objectKeyPrefix: string; }): Promise { try { const config = getOssConfig(); if (!config) { return { ok: false, error: "Aliyun OSS is not configured.", }; } const imageResponse = await fetch(imageUrl, { headers: { accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "user-agent": "WalloraGalleryMirror/1.0", }, cache: "no-store", }).catch((error: unknown) => { throw new Error(`Image download failed: ${getErrorMessage(error)}`); }); if (!imageResponse.ok) { return { ok: false, error: `Image download failed with ${imageResponse.status}.`, }; } const contentType = imageResponse.headers.get("content-type") ?? "image/jpeg"; const extension = extensionFromUrl(imageUrl, contentType); const objectKey = `${objectKeyPrefix}.${extension}`; const body = Buffer.from(await imageResponse.arrayBuffer()); const date = new Date().toUTCString(); const bucketHost = getBucketHost(config); const protocol = new URL(config.endpoint).protocol; const putUrl = `${protocol}//${bucketHost}/${objectKey}`; const uploadResponse = await fetch(putUrl, { method: "PUT", headers: { authorization: signPutObject({ config, contentType, date, objectKey, }), "content-type": contentType, date, }, body, }).catch((error: unknown) => { throw new Error(`OSS upload failed: ${getErrorMessage(error)}`); }); if (!uploadResponse.ok) { const detail = await uploadResponse.text(); return { ok: false, error: `OSS upload failed with ${uploadResponse.status}: ${detail.slice(0, 240)}`, }; } return { ok: true, bucket: config.bucket, objectKey, url: config.publicBaseUrl ? `${normalizeBaseUrl(config.publicBaseUrl)}/${objectKey}` : putUrl, }; } catch (error) { return { ok: false, error: getErrorMessage(error), }; } }