haixunMaster/lib/drafts/images.ts

148 lines
4.1 KiB
TypeScript

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<string> {
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,
};
}