feat: full site build — Project/Melody schema (Option A), admin CRUD, public sections, uploads, email+SMTP, internal analytics, legal pages, docs
This commit is contained in:
+156
@@ -0,0 +1,156 @@
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
import {
|
||||
isAllowedUploadMimeType,
|
||||
uploadRequestSchema,
|
||||
UPLOAD_RULES,
|
||||
type UploadKind,
|
||||
type UploadedFileMetadata,
|
||||
} from "@/lib/validations/upload";
|
||||
|
||||
const imageExtension = ".webp";
|
||||
|
||||
const audioExtensions: Record<string, string> = {
|
||||
"audio/aac": ".aac",
|
||||
"audio/flac": ".flac",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/wav": ".wav",
|
||||
"audio/webm": ".webm",
|
||||
"audio/x-wav": ".wav",
|
||||
};
|
||||
|
||||
const mimeTypesByExtension: Record<string, string> = {
|
||||
".aac": "audio/aac",
|
||||
".flac": "audio/flac",
|
||||
".m4a": "audio/mp4",
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
".wav": "audio/wav",
|
||||
".webm": "audio/webm",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
export class UploadError extends Error {
|
||||
statusCode: 400 | 404 | 413 | 500;
|
||||
|
||||
constructor(message: string, statusCode: 400 | 404 | 413 | 500 = 400) {
|
||||
super(message);
|
||||
this.name = "UploadError";
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export function getUploadRoot() {
|
||||
return path.resolve(process.env.UPLOAD_DIR ?? path.join(process.cwd(), "uploads"));
|
||||
}
|
||||
|
||||
function getKindDirectory(kind: UploadKind) {
|
||||
return path.join(getUploadRoot(), `${kind}s`);
|
||||
}
|
||||
|
||||
function getPublicUrl(kind: UploadKind, fileName: string) {
|
||||
return `/api/uploads/${kind}s/${fileName}`;
|
||||
}
|
||||
|
||||
function validateUpload(kind: UploadKind, file: File) {
|
||||
const parsed = uploadRequestSchema.safeParse({
|
||||
kind,
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new UploadError("Invalid upload metadata.");
|
||||
}
|
||||
|
||||
const rules = UPLOAD_RULES[kind];
|
||||
if (!isAllowedUploadMimeType(kind, parsed.data.mimeType)) {
|
||||
throw new UploadError(`Unsupported ${kind} file type.`);
|
||||
}
|
||||
|
||||
if (parsed.data.size > rules.maxBytes) {
|
||||
throw new UploadError(`The ${kind} file is too large.`, 413);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export async function saveUploadedFile(file: File, kind: UploadKind): Promise<UploadedFileMetadata> {
|
||||
const metadata = validateUpload(kind, file);
|
||||
const source = Buffer.from(await file.arrayBuffer());
|
||||
const id = randomUUID();
|
||||
|
||||
if (kind === "image") {
|
||||
const fileName = `${id}${imageExtension}`;
|
||||
const output = await sharp(source)
|
||||
.rotate()
|
||||
.resize({ width: 2400, height: 2400, fit: "inside", withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toBuffer()
|
||||
.catch(() => {
|
||||
throw new UploadError("The image could not be processed.");
|
||||
});
|
||||
|
||||
await mkdir(getKindDirectory(kind), { recursive: true });
|
||||
await writeFile(path.join(getKindDirectory(kind), fileName), output, { flag: "wx" });
|
||||
|
||||
return {
|
||||
kind,
|
||||
url: getPublicUrl(kind, fileName),
|
||||
fileName,
|
||||
mimeType: "image/webp",
|
||||
size: output.byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
const extension = audioExtensions[metadata.mimeType.toLowerCase()];
|
||||
if (!extension) {
|
||||
throw new UploadError("Unsupported audio file type.");
|
||||
}
|
||||
|
||||
const fileName = `${id}${extension}`;
|
||||
await mkdir(getKindDirectory(kind), { recursive: true });
|
||||
await writeFile(path.join(getKindDirectory(kind), fileName), source, { flag: "wx" });
|
||||
|
||||
return {
|
||||
kind,
|
||||
url: getPublicUrl(kind, fileName),
|
||||
fileName,
|
||||
mimeType: metadata.mimeType,
|
||||
size: source.byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readUploadedFile(segments: string[]) {
|
||||
const root = getUploadRoot();
|
||||
const filePath = path.resolve(root, ...segments);
|
||||
const rootPrefix = `${root}${path.sep}`;
|
||||
|
||||
if (!filePath.startsWith(rootPrefix)) {
|
||||
throw new UploadError("File not found.", 404);
|
||||
}
|
||||
|
||||
try {
|
||||
const fileInfo = await stat(filePath);
|
||||
if (!fileInfo.isFile()) {
|
||||
throw new UploadError("File not found.", 404);
|
||||
}
|
||||
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
return {
|
||||
data: await readFile(filePath),
|
||||
mimeType: mimeTypesByExtension[extension] ?? "application/octet-stream",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UploadError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new UploadError("File not found.", 404);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user