220 lines
5.6 KiB
TypeScript
220 lines
5.6 KiB
TypeScript
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<OssUploadResult> {
|
|
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),
|
|
};
|
|
}
|
|
}
|