first commit
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user