import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { HttpError } from "./errors"; import { IMG_ROOT, safeJoin } from "./paths"; import { SLUG_RE } from "./validate"; import type { BlogPost, ImageFile } from "./types"; /* Uploaded media lives in portfolio/public/img/blog// and is referenced * from posts as "img/blog//" (no leading slash, as the site's * card and gallery components prepend "/"). */ export const ALLOWED_EXT = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".mp4"]; export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; export function postImageDir(slug: string): string { if (!SLUG_RE.test(slug)) throw new HttpError(400, "invalid slug"); return safeJoin(IMG_ROOT, slug); } export function imageRelSrc(slug: string, name: string): string { return `img/blog/${slug}/${name}`; } async function exists(p: string): Promise { return stat(p).then(() => true, () => false); } export async function listImages(slug: string): Promise { const dir = postImageDir(slug); let names: string[]; try { names = await readdir(dir); } catch { return []; } const files: ImageFile[] = []; for (const name of names.sort()) { if (!ALLOWED_EXT.includes(path.extname(name).toLowerCase())) continue; const info = await stat(path.join(dir, name)); if (info.isFile()) files.push({ name, src: imageRelSrc(slug, name), size: info.size }); } return files; } /** "My Screenshot (2).PNG" -> "my-screenshot-2.png" */ function sanitizeName(original: string): string { const ext = path.extname(original).toLowerCase(); const base = path .basename(original, path.extname(original)) .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/^[-.]+|-+$/g, ""); return (base || "image") + ext; } export async function saveUpload(slug: string, file: File): Promise { const name = sanitizeName(file.name); const ext = path.extname(name); if (!ALLOWED_EXT.includes(ext)) { throw new HttpError(400, `${file.name}: only ${ALLOWED_EXT.join(", ")} files are allowed`); } if (file.size > MAX_UPLOAD_BYTES) { throw new HttpError(413, `${file.name}: larger than ${MAX_UPLOAD_BYTES / 1024 / 1024} MB`); } const dir = postImageDir(slug); await mkdir(dir, { recursive: true }); // Never overwrite: name.png, name-1.png, name-2.png, ... const stem = path.basename(name, ext); let finalName = name; for (let i = 1; await exists(path.join(dir, finalName)); i++) finalName = `${stem}-${i}${ext}`; await writeFile(safeJoin(dir, finalName), Buffer.from(await file.arrayBuffer())); return { name: finalName, src: imageRelSrc(slug, finalName), size: file.size }; } export async function deleteImage(slug: string, name: string): Promise { const target = safeJoin(postImageDir(slug), name); if (path.dirname(target) !== postImageDir(slug)) throw new HttpError(400, "invalid file name"); await rm(target, { force: true }); } export async function deleteImageDir(slug: string): Promise { await rm(postImageDir(slug), { recursive: true, force: true }); } /** * Moves a post's media folder on slug rename and rewrites every reference to * it in the post (gallery entries and inline body links). */ export async function renameImageDir(post: BlogPost, from: string, to: string): Promise { const src = postImageDir(from); const dest = postImageDir(to); if (await exists(src)) { if (await exists(dest)) throw new HttpError(409, `image folder for "${to}" already exists`); await rename(src, dest); } const oldPrefix = `img/blog/${from}/`; const newPrefix = `img/blog/${to}/`; post.details = post.details.split(oldPrefix).join(newPrefix); post.images = post.images?.map((img) => typeof img === "string" ? img.replace(oldPrefix, newPrefix) : { ...img, src: img.src.replace(oldPrefix, newPrefix) } ); }