initial commit
This commit is contained in:
107
lib/images.ts
Normal file
107
lib/images.ts
Normal 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) }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user