initial commit

This commit is contained in:
Hunter
2026-11-01 13:23:44 -05:00
commit e03ea4f770
53 changed files with 10802 additions and 0 deletions

107
lib/images.ts Normal file
View File

@@ -0,0 +1,107 @@
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/<slug>/ and is referenced
* from posts as "img/blog/<slug>/<file>" (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<boolean> {
return stat(p).then(() => true, () => false);
}
export async function listImages(slug: string): Promise<ImageFile[]> {
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<ImageFile> {
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<void> {
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<void> {
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<void> {
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) }
);
}