148 lines
4.1 KiB
TypeScript
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,
|
||
|
|
};
|
||
|
|
}
|