80 lines
2.4 KiB
TypeScript
80 lines
2.4 KiB
TypeScript
import type { Context } from "@oak/oak";
|
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
|
|
|
export const UPLOADS_DIR = "api/uploads";
|
|
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
|
|
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
|
|
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
|
|
export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
|
|
|
|
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
|
|
export const ALLOWED_IMAGE_MIMES = new Set([
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/gif",
|
|
"image/webp",
|
|
]);
|
|
|
|
/** Detect image MIME type from magic bytes, ignoring the browser-declared type. */
|
|
export function detectImageMime(data: Uint8Array): string | null {
|
|
if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) {
|
|
return "image/jpeg";
|
|
}
|
|
if (
|
|
data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4E && data[3] === 0x47
|
|
) return "image/png";
|
|
if (
|
|
data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38
|
|
) return "image/gif";
|
|
// RIFF....WEBP
|
|
if (
|
|
data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 &&
|
|
data[3] === 0x46 &&
|
|
data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 &&
|
|
data[11] === 0x50
|
|
) return "image/webp";
|
|
return null;
|
|
}
|
|
|
|
/** Validates image upload data: checks size and MIME. Returns the detected MIME type or throws APIException. */
|
|
export function validateImageUpload(data: Uint8Array): string {
|
|
if (data.length > MAX_IMAGE_SIZE) {
|
|
throw new APIException(
|
|
APIErrorCode.BAD_REQUEST,
|
|
400,
|
|
"File too large (max 5 MB)",
|
|
);
|
|
}
|
|
const mime = detectImageMime(data);
|
|
if (!mime) {
|
|
throw new APIException(
|
|
APIErrorCode.BAD_REQUEST,
|
|
400,
|
|
"File content is not a recognised image (JPEG, PNG, GIF, WebP)",
|
|
);
|
|
}
|
|
return mime;
|
|
}
|
|
|
|
export async function serveUploadedFile(
|
|
ctx: Context,
|
|
filePath: string,
|
|
mime: string,
|
|
cacheMaxAge = 3600,
|
|
): Promise<boolean> {
|
|
let data: Uint8Array;
|
|
try {
|
|
data = await Deno.readFile(filePath);
|
|
} catch {
|
|
ctx.response.status = 404;
|
|
return false;
|
|
}
|
|
ctx.response.headers.set("Content-Type", mime);
|
|
ctx.response.headers.set("Content-Disposition", "inline");
|
|
ctx.response.headers.set("Cache-Control", `public, max-age=${cacheMaxAge}`);
|
|
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
|
ctx.response.body = data;
|
|
return true;
|
|
}
|