Files
NianAIGC/lib/server/storage.ts

446 lines
15 KiB
TypeScript

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<Asset> {
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<string, unknown>;
}): Promise<Asset> {
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<Asset> {
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<Asset> {
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<Asset> {
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<void> {
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<unknown> }).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<unknown> }).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<StoredObject> {
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<ReturnType<typeof getOssConfig>>, 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<ReturnType<typeof createOssClient>>, objectKey: string, oss: NonNullable<ReturnType<typeof getOssConfig>>) {
try {
await (client as unknown as { putACL: (name: string, acl: string) => Promise<unknown> }).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<unknown> }).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";
}