funktechnik-schubert-website/lib/admin/media.ts
2026-07-03 23:25:41 +02:00

127 lines
3.8 KiB
TypeScript

import { randomUUID } from "crypto";
import { mkdir, readdir, rm, stat, writeFile } from "fs/promises";
import path from "path";
import { uploadDirectory } from "@/lib/runtime/config";
const maxUploadSize = 5 * 1024 * 1024;
const allowedMimeTypes = new Map([
["image/jpeg", [".jpg", ".jpeg"]],
["image/png", [".png"]],
["image/webp", [".webp"]],
["application/pdf", [".pdf"]],
]);
export type MediaFile = {
name: string;
url: string;
type: "image" | "pdf";
extension: string;
size: number;
uploadedAt: string;
isImage: boolean;
isPdf: boolean;
};
export async function ensureUploadDirectory() {
await mkdir(uploadDirectory, { recursive: true });
const probe = path.join(uploadDirectory, `.write-check-${Date.now()}`);
await writeFile(probe, "ok", "utf8");
await rm(probe, { force: true });
}
export function validateUpload(file: File) {
const extensions = allowedMimeTypes.get(file.type);
const extension = path.extname(file.name).toLowerCase();
if (!extensions || !extensions.includes(extension)) {
return "Bitte eine JPG-, PNG-, WebP- oder PDF-Datei hochladen.";
}
if (file.size <= 0) {
return "Die Datei ist leer.";
}
if (file.size > maxUploadSize) {
return "Die Datei darf maximal 5 MB groß sein.";
}
return "";
}
export function createSafeUploadName(originalName: string) {
const extension = path.extname(originalName).toLowerCase();
const rawBaseName = path.basename(originalName, extension);
const safeBaseName = rawBaseName
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "upload";
return `${Date.now()}-${randomUUID().slice(0, 8)}-${safeBaseName}${extension}`;
}
export function resolveUploadTarget(fileName: string) {
const target = path.resolve(uploadDirectory, fileName);
const uploadRoot = path.resolve(uploadDirectory);
if (!target.startsWith(`${uploadRoot}${path.sep}`)) {
throw new Error("Ungültiger Upload-Pfad.");
}
return target;
}
function isAllowedStoredFile(fileName: string) {
const extension = path.extname(fileName).toLowerCase();
const allowedExtensions = [...allowedMimeTypes.values()].flat();
return !fileName.startsWith(".") && allowedExtensions.includes(extension);
}
export async function listMediaFiles(): Promise<MediaFile[]> {
await mkdir(uploadDirectory, { recursive: true });
const entries = await readdir(uploadDirectory);
const visibleEntries = entries.filter(isAllowedStoredFile);
const files = await Promise.all(visibleEntries.map(async (entry) => {
const fileStat = await stat(path.join(uploadDirectory, entry));
const extension = path.extname(entry).toLowerCase();
const isImage = [".jpg", ".jpeg", ".png", ".webp"].includes(extension);
const isPdf = extension === ".pdf";
return {
name: entry,
url: `/uploads/images/${encodeURIComponent(entry)}`,
type: isPdf ? "pdf" as const : "image" as const,
extension: extension.replace(".", "").toUpperCase() || "Datei",
size: fileStat.size,
uploadedAt: fileStat.birthtime.toISOString(),
isImage,
isPdf,
};
}));
return files.sort((left, right) => right.uploadedAt.localeCompare(left.uploadedAt));
}
export async function saveMediaFile(file: File) {
const error = validateUpload(file);
if (error) return { error };
await ensureUploadDirectory();
const safeName = createSafeUploadName(file.name);
const target = resolveUploadTarget(safeName);
await writeFile(target, Buffer.from(await file.arrayBuffer()));
return { fileName: safeName };
}
export async function deleteMediaFile(fileName: string) {
if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) {
return { error: "Ungültiger Dateiname." };
}
await rm(resolveUploadTarget(fileName), { force: true });
return {};
}