import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { extname, join } from "node:path"; import { createAsset } from "@/lib/server/data-store"; import { createId } from "@/lib/server/ids"; import { resultDir, toAbsoluteUrl, uploadDir } from "@/lib/server/runtime"; import type { Asset, AssetKind } from "@/lib/types"; type StoredObject = { url: string; storagePath: string; }; export async function saveUploadAsset(input: { ownerId: string; bytes: Buffer; fileName: string; contentType: string; origin: string; kind?: AssetKind; tags?: string[]; }): Promise { const stored = await storeBuffer({ bytes: input.bytes, fileName: input.fileName, contentType: input.contentType, folder: "uploads", origin: input.origin }); return createAsset({ ownerId: input.ownerId, kind: input.kind || inferKind(input.contentType), name: input.fileName, url: stored.url, storagePath: stored.storagePath, source: "upload", tags: input.tags || [], metadata: { contentType: input.contentType, size: input.bytes.length } }); } export async function saveGeneratedAsset(input: { ownerId: string; bytes: Buffer; fileName: string; contentType: string; origin: string; source: Asset["source"]; capability: string; kind?: AssetKind; jobId?: string; tags?: string[]; metadata?: Record; }): Promise { const stored = await storeBuffer({ bytes: input.bytes, fileName: input.fileName, contentType: input.contentType, folder: "generated-results", origin: input.origin }); return createAsset({ ownerId: input.ownerId, kind: input.kind || inferKind(input.contentType), name: input.fileName, url: stored.url, storagePath: stored.storagePath, source: input.source, tags: input.tags || [input.capability], metadata: { contentType: input.contentType, size: input.bytes.length, capability: input.capability, jobId: input.jobId, ...(input.metadata || {}) } }); } export async function importRemoteImageAsAsset(input: { ownerId: string; url: string; origin: string; source: Asset["source"]; capability: string; jobId: string; index: number; tags?: string[]; }): Promise { const response = await fetch(input.url); if (!response.ok) { throw new Error(`Failed to download generated image: ${response.status} ${response.statusText}`); } const bytes = Buffer.from(await response.arrayBuffer()); const contentType = response.headers.get("content-type") || "image/png"; const extension = extensionForContentType(contentType) || extname(new URL(input.url).pathname) || ".png"; return saveGeneratedAsset({ ownerId: input.ownerId, bytes, fileName: `${input.capability.replace(/\W+/g, "-")}-${input.jobId}-${input.index}${extension}`, contentType, origin: input.origin, source: input.source, capability: input.capability, jobId: input.jobId, tags: input.tags, metadata: { importedFrom: input.url } }); } export async function importRemoteAssetAsAsset(input: { ownerId: string; url: string; origin: string; source: Asset["source"]; capability: string; jobId: string; index: number; fallbackContentType?: string; tags?: string[]; }): Promise { const response = await fetch(input.url); if (!response.ok) { throw new Error(`Failed to download generated asset: ${response.status} ${response.statusText}`); } const bytes = Buffer.from(await response.arrayBuffer()); const contentType = response.headers.get("content-type") || input.fallbackContentType || contentTypeForPath(new URL(input.url).pathname); const extension = extensionForContentType(contentType) || extname(new URL(input.url).pathname) || ".bin"; return saveGeneratedAsset({ ownerId: input.ownerId, bytes, fileName: `${input.capability.replace(/\W+/g, "-")}-${input.jobId}-${input.index}${extension}`, contentType, origin: input.origin, source: input.source, capability: input.capability, jobId: input.jobId, tags: input.tags, metadata: { importedFrom: input.url } }); } export async function saveMaskDataUrl(input: { ownerId: string; dataUrl: string; origin: string; jobHint?: string; }): Promise { const parsed = parseDataUrl(input.dataUrl); return saveGeneratedAsset({ ownerId: input.ownerId, bytes: parsed.bytes, fileName: `mask-${input.jobHint || createId("brush")}.png`, contentType: parsed.contentType, origin: input.origin, source: "edited", capability: "image.inpaint", kind: "mask", tags: ["mask"], metadata: { maskRule: "black keeps original pixels, white repaints selected pixels" } }); } export async function readLocalServedFile(area: "uploads" | "generated-results", pathParts: string[]): Promise<{ bytes: Buffer; contentType: string; } | null> { const base = area === "uploads" ? uploadDir() : resultDir(); const filePath = join(base, ...pathParts); try { return { bytes: await readFile(filePath), contentType: contentTypeForPath(filePath) }; } catch { return null; } } export async function readAssetForDownload(asset: Asset): Promise<{ bytes: Buffer; contentType: string; } | null> { const local = await readLocalAsset(asset); if (local) return local; if (!/^https?:\/\//i.test(asset.url)) return null; const response = await fetch(asset.url); if (!response.ok) return null; return { bytes: Buffer.from(await response.arrayBuffer()), contentType: response.headers.get("content-type") || contentTypeForPath(new URL(asset.url).pathname) }; } export async function deleteStoredAsset(asset: Asset): Promise { if (!asset.storagePath) return; if (asset.storagePath.startsWith("uploads/") || asset.storagePath.startsWith("generated-results/")) { await deleteLocalStoredPath(asset.storagePath); return; } const oss = getOssConfig(); if (!oss) return; const client = await createOssClient(oss); try { await (client as unknown as { delete: (name: string) => Promise }).delete(asset.storagePath); } catch (error) { if (isMissingOssObject(error)) return; if (oss.fallbackEndpoint && isOssNetworkError(error)) { const fallbackClient = await createOssClient(oss, oss.fallbackEndpoint); await (fallbackClient as unknown as { delete: (name: string) => Promise }).delete(asset.storagePath); return; } throw error; } } async function readLocalAsset(asset: Asset): Promise<{ bytes: Buffer; contentType: string; } | null> { const localPath = localServedPathParts(asset.storagePath) || localServedPathParts(asset.url); if (!localPath) return null; return readLocalServedFile(localPath.area, localPath.pathParts); } function localServedPathParts(value?: string): { area: "uploads" | "generated-results"; pathParts: string[]; } | null { if (!value) return null; const clean = value.replace(/^\/+/, ""); if (clean.startsWith("uploads/")) { return { area: "uploads", pathParts: clean.slice("uploads/".length).split("/").filter(Boolean) }; } if (clean.startsWith("generated-results/")) { return { area: "generated-results", pathParts: clean.slice("generated-results/".length).split("/").filter(Boolean) }; } return null; } async function storeBuffer(input: { bytes: Buffer; fileName: string; contentType: string; folder: "uploads" | "generated-results"; origin: string; }): Promise { const oss = getOssConfig(); const cleanName = sanitizeFileName(input.fileName); const objectKey = `${oss?.prefix || "zhinian"}/${input.folder}/${new Date().toISOString().slice(0, 10)}/${createId("file")}-${cleanName}`; if (oss) { const client = await createOssClient(oss); try { await client.put(objectKey, input.bytes, { mime: input.contentType }); } catch (error) { if (!oss.fallbackEndpoint || !isOssNetworkError(error)) throw error; const fallbackClient = await createOssClient(oss, oss.fallbackEndpoint); await fallbackClient.put(objectKey, input.bytes, { mime: input.contentType }); } await makeOssObjectPublic(client, objectKey, oss); const baseUrl = oss.publicBaseUrl.replace(/\/$/, ""); return { url: `${baseUrl}/${objectKey}`, storagePath: objectKey }; } const base = input.folder === "uploads" ? uploadDir() : resultDir(); const relativePath = join(new Date().toISOString().slice(0, 10), `${createId("file")}-${cleanName}`); const absolutePath = join(base, relativePath); await mkdir(join(base, new Date().toISOString().slice(0, 10)), { recursive: true }); await writeFile(absolutePath, input.bytes); const publicPath = `/${input.folder}/${relativePath.split(/[\\/]/).map(encodeURIComponent).join("/")}`; return { url: toAbsoluteUrl(publicPath, input.origin), storagePath: `${input.folder}/${relativePath}` }; } function getOssConfig() { const endpoint = process.env.ALI_OSS_ENDPOINT; const bucket = process.env.ALI_OSS_BUCKET; const accessKeyId = process.env.ALI_OSS_ACCESS_KEY_ID; const accessKeySecret = process.env.ALI_OSS_ACCESS_KEY_SECRET; const publicBaseUrl = process.env.ALI_OSS_PUBLIC_BASE_URL; if (!endpoint || !bucket || !accessKeyId || !accessKeySecret || !publicBaseUrl) return null; const publicEndpoint = inferPublicEndpoint(publicBaseUrl); const preferredEndpoint = preferPublicOssEndpoint(endpoint, publicEndpoint) ? publicEndpoint : endpoint; const fallbackEndpoint = preferredEndpoint === endpoint ? publicEndpoint : endpoint; return { endpoint: preferredEndpoint || endpoint, bucket, accessKeyId, accessKeySecret, publicBaseUrl: normalizeOssPublicBaseUrl(publicBaseUrl, bucket), fallbackEndpoint: fallbackEndpoint === preferredEndpoint ? undefined : fallbackEndpoint, prefix: process.env.ALI_OSS_PREFIX || "zhinian", region: inferOssRegion(endpoint) }; } async function createOssClient(oss: NonNullable>, endpoint = oss.endpoint) { const { default: OSS } = await import("ali-oss"); return new OSS({ region: oss.region, endpoint, bucket: oss.bucket, accessKeyId: oss.accessKeyId, accessKeySecret: oss.accessKeySecret, timeout: 8000 }); } async function makeOssObjectPublic(client: Awaited>, objectKey: string, oss: NonNullable>) { try { await (client as unknown as { putACL: (name: string, acl: string) => Promise }).putACL(objectKey, "public-read"); } catch (error) { if (!oss.fallbackEndpoint || !isOssNetworkError(error)) throw error; const fallbackClient = await createOssClient(oss, oss.fallbackEndpoint); await (fallbackClient as unknown as { putACL: (name: string, acl: string) => Promise }).putACL(objectKey, "public-read"); } } async function deleteLocalStoredPath(storagePath: string) { const [area, ...parts] = storagePath.split(/[\\/]/); if (!parts.length) return; const base = area === "uploads" ? uploadDir() : area === "generated-results" ? resultDir() : null; if (!base) return; try { await unlink(join(base, ...parts)); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } function inferOssRegion(endpoint: string) { try { const host = new URL(endpoint).hostname; const match = host.match(/^oss-([a-z0-9-]+?)(?:-internal)?\./); return match ? `oss-${match[1]}` : undefined; } catch { const match = endpoint.match(/oss-([a-z0-9-]+?)(?:-internal)?(?:\.|$)/); return match ? `oss-${match[1]}` : undefined; } } function normalizeOssPublicBaseUrl(publicBaseUrl: string, bucket: string) { try { const url = new URL(publicBaseUrl); if (url.hostname.startsWith("oss-") && url.pathname === "/") { url.hostname = `${bucket}.${url.hostname}`; } return url.toString().replace(/\/$/, ""); } catch { return publicBaseUrl.replace(/\/$/, ""); } } function inferPublicEndpoint(publicBaseUrl: string) { try { const url = new URL(publicBaseUrl); if (url.hostname.startsWith("oss-")) return url.origin; const match = url.hostname.match(/^[^.]+\.(oss-[^.]+\.aliyuncs\.com)$/); return match ? `${url.protocol}//${match[1]}` : undefined; } catch { return undefined; } } function preferPublicOssEndpoint(endpoint: string, publicEndpoint?: string) { if (!publicEndpoint || process.env.ALI_OSS_USE_INTERNAL_ENDPOINT === "1") return false; try { return new URL(endpoint).hostname.includes("-internal."); } catch { return endpoint.includes("-internal."); } } function isMissingOssObject(error: unknown) { return typeof error === "object" && error !== null && ( "code" in error && String((error as { code?: unknown }).code) === "NoSuchKey" || "status" in error && Number((error as { status?: unknown }).status) === 404 ); } function isOssNetworkError(error: unknown) { if (typeof error !== "object" || error === null) return false; const code = "code" in error ? String((error as { code?: unknown }).code) : ""; const message = "message" in error ? String((error as { message?: unknown }).message) : ""; return ["ENOTFOUND", "ETIMEDOUT", "ECONNREFUSED", "EAI_AGAIN"].includes(code) || /network|timeout|ENOTFOUND|ETIMEDOUT/i.test(message); } function sanitizeFileName(fileName: string): string { const fallback = "asset.bin"; const clean = fileName.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); return clean || fallback; } function inferKind(contentType: string): AssetKind { if (contentType.startsWith("image/")) return "image"; if (contentType.startsWith("video/")) return "video"; return "other"; } function parseDataUrl(dataUrl: string): { contentType: string; bytes: Buffer } { const match = dataUrl.match(/^data:([^;,]+);base64,(.+)$/); if (!match) throw new Error("Invalid mask data URL."); return { contentType: match[1], bytes: Buffer.from(match[2], "base64") }; } function extensionForContentType(contentType: string): string { if (contentType.includes("jpeg") || contentType.includes("jpg")) return ".jpg"; if (contentType.includes("webp")) return ".webp"; if (contentType.includes("svg")) return ".svg"; if (contentType.includes("mp4")) return ".mp4"; if (contentType.includes("quicktime")) return ".mov"; if (contentType.includes("webm")) return ".webm"; return ".png"; } function contentTypeForPath(filePath: string): string { const ext = extname(filePath).toLowerCase(); if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; if (ext === ".png") return "image/png"; if (ext === ".webp") return "image/webp"; if (ext === ".svg") return "image/svg+xml"; if (ext === ".mp4") return "video/mp4"; return "application/octet-stream"; }