import "server-only"; import { randomBytes } from "crypto"; import { mkdir, readdir, readFile, unlink, writeFile } from "fs/promises"; import path from "path"; import { draftImageApiUrl as buildDraftImageApiUrl, MAX_DRAFT_IMAGES } from "./constants"; export { MAX_DRAFT_IMAGES }; const IMAGE_DIR = path.join(process.cwd(), "data", "draft-images"); const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); export function draftImageAbsolutePath(imagePath: string): string { return path.join(IMAGE_DIR, path.basename(imagePath)); } export function draftImageApiUrl(draftId: string, imagePath: string): string { return buildDraftImageApiUrl(draftId, imagePath); } export function draftImagePublicUrl(baseUrl: string, draftId: string, imagePath: string): string { return `${baseUrl.replace(/\/$/, "")}${buildDraftImageApiUrl(draftId, imagePath)}`; } export async function ensureDraftImageDir() { await mkdir(IMAGE_DIR, { recursive: true }); } export function extensionForMime(mimeType: string): string { switch (mimeType) { case "image/png": return "png"; case "image/webp": return "webp"; case "image/gif": return "gif"; default: return "jpg"; } } export function validateDraftImageFile(file: File): string | null { if (!ALLOWED_MIME.has(file.type)) { return "僅支援 JPG、PNG、WebP、GIF"; } if (file.size > 10 * 1024 * 1024) { return "圖片不得超過 10MB"; } return null; } export function isDraftOwnedImagePath(draftId: string, imagePath: string): boolean { const base = path.basename(imagePath); return base.startsWith(`${draftId}-`) || base.startsWith(`${draftId}.`); } export function parseDraftImagePaths(draft: { imagePath?: string | null; imagePaths?: string | null; }): string[] { if (draft.imagePaths) { try { const parsed = JSON.parse(draft.imagePaths) as unknown; if (Array.isArray(parsed)) { return parsed.filter((item): item is string => typeof item === "string"); } } catch { // fall through } } if (draft.imagePath) return [draft.imagePath]; return []; } export function serializeDraftImagePaths(paths: string[]): string | null { if (paths.length === 0) return null; return JSON.stringify(paths); } export function primaryDraftImagePath(draft: { imagePath?: string | null; imagePaths?: string | null; }): string | null { const paths = parseDraftImagePaths(draft); return paths[0] ?? null; } export async function saveDraftImage( draftId: string, bytes: Uint8Array, mimeType: string ): Promise { await ensureDraftImageDir(); const filename = `${draftId}-${Date.now()}-${randomBytes(4).toString("hex")}.${extensionForMime(mimeType)}`; await writeFile(path.join(IMAGE_DIR, filename), bytes); return filename; } export async function readDraftImage(imagePath: string): Promise<{ bytes: Buffer; mimeType: string; } | null> { try { const bytes = await readFile(draftImageAbsolutePath(imagePath)); const ext = path.extname(imagePath).slice(1).toLowerCase(); const mimeType = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : ext === "gif" ? "image/gif" : "image/jpeg"; return { bytes, mimeType }; } catch { return null; } } export async function deleteDraftImageFile(imagePath: string | null | undefined) { if (!imagePath) return; try { await unlink(draftImageAbsolutePath(imagePath)); } catch { // ignore missing file } } export async function deleteAllDraftImages(draftId: string) { await ensureDraftImageDir(); const files = await readdir(IMAGE_DIR); await Promise.all( files .filter((file) => file.startsWith(`${draftId}-`) || file.startsWith(`${draftId}.`)) .map((file) => unlink(path.join(IMAGE_DIR, file)).catch(() => undefined)) ); } export async function deleteDraftImages(paths: string[]) { await Promise.all(paths.map((imagePath) => deleteDraftImageFile(imagePath))); } export function syncDraftImageFields(paths: string[]) { return { imagePaths: serializeDraftImagePaths(paths), imagePath: paths[0] ?? null, }; }