101 lines
2.7 KiB
TypeScript
101 lines
2.7 KiB
TypeScript
/**
|
|
* Checking uploaded images and serving uploaded files back.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import type { Context } from "@oak/oak";
|
|
import { APIErrorCode } from "../../shared/api.ts";
|
|
import { APIException } from "./api-exception.ts";
|
|
import { serveFile } from "./serve-file.ts";
|
|
import {
|
|
ALLOWED_IMAGE_MIMES,
|
|
ATTACHMENTS_DIR,
|
|
AVATARS_DIR,
|
|
DUMPS_DIR,
|
|
MAX_IMAGE_SIZE_BYTES,
|
|
PLAYLIST_IMAGES_DIR,
|
|
UPLOADS_DIR,
|
|
} from "../config.ts";
|
|
|
|
export {
|
|
ALLOWED_IMAGE_MIMES,
|
|
ATTACHMENTS_DIR,
|
|
AVATARS_DIR,
|
|
DUMPS_DIR,
|
|
PLAYLIST_IMAGES_DIR,
|
|
UPLOADS_DIR,
|
|
};
|
|
|
|
/**
|
|
* Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic
|
|
* bytes, ignoring the type the browser declared; null for anything else.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Checks an uploaded image's size and content, and returns its MIME type.
|
|
*
|
|
* @throws {APIException} 400 when it exceeds {@link MAX_IMAGE_SIZE_BYTES} or
|
|
* isn't a JPEG, PNG, GIF or WebP image.
|
|
*/
|
|
export function validateImageUpload(data: Uint8Array): string {
|
|
if (data.length > MAX_IMAGE_SIZE_BYTES) {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Serves an uploaded file inline as `mime`, cached for `cacheMaxAge` seconds
|
|
* (privately when `isPrivate`). Answers 404 and resolves false when the file
|
|
* is missing.
|
|
*/
|
|
export async function serveUploadedFile(
|
|
ctx: Context,
|
|
filePath: string,
|
|
mime: string,
|
|
cacheMaxAge = 3600,
|
|
isPrivate = false,
|
|
): Promise<boolean> {
|
|
ctx.response.headers.set("Content-Disposition", "inline");
|
|
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
|
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
|
const served = await serveFile(ctx, filePath, {
|
|
mime,
|
|
cacheControl: `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
|
|
});
|
|
if (!served) ctx.response.status = 404;
|
|
return served;
|
|
}
|