v3: performance pass, bundle size pass, i18n pass, docker pass
This commit is contained in:
@@ -32,6 +32,7 @@ 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 THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`;
|
||||
|
||||
export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
export const ALLOWED_IMAGE_MIMES = new Set([
|
||||
|
||||
@@ -4,6 +4,7 @@ import { oakCors } from "@tajpouria/cors";
|
||||
import attachmentsRouter from "./routes/attachments.ts";
|
||||
import dumpsRouter from "./routes/dumps.ts";
|
||||
import filesRouter from "./routes/files.ts";
|
||||
import thumbnailsRouter from "./routes/thumbnails.ts";
|
||||
import usersRouter from "./routes/users.ts";
|
||||
import avatarsRouter from "./routes/avatars.ts";
|
||||
import wsRouter from "./routes/ws.ts";
|
||||
@@ -44,6 +45,10 @@ app.use(
|
||||
filesRouter.routes(),
|
||||
filesRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
thumbnailsRouter.routes(),
|
||||
thumbnailsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
attachmentsRouter.routes(),
|
||||
attachmentsRouter.allowedMethods(),
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
export const db = new DatabaseSync(DB_PATH);
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
// Purge expired/used password reset tokens on startup
|
||||
db.prepare(
|
||||
`DELETE FROM password_reset_tokens WHERE expires_at < datetime('now') OR used_at IS NOT NULL;`,
|
||||
).run();
|
||||
|
||||
// Purge expired unused invites on startup
|
||||
db.prepare(
|
||||
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`,
|
||||
|
||||
110
api/routes/thumbnails.ts
Normal file
110
api/routes/thumbnails.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import { getDump } from "../services/dump-service.ts";
|
||||
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/thumbnails" });
|
||||
|
||||
async function ffmpegAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const cmd = new Deno.Command("ffmpeg", {
|
||||
args: ["-version"],
|
||||
stderr: "null",
|
||||
stdout: "null",
|
||||
});
|
||||
const { success } = await cmd.output();
|
||||
return success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateThumbnail(
|
||||
srcPath: string,
|
||||
outPath: string,
|
||||
): Promise<void> {
|
||||
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
||||
const cmd = new Deno.Command("ffmpeg", {
|
||||
args: [
|
||||
"-ss",
|
||||
"00:00:01",
|
||||
"-i",
|
||||
srcPath,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
"scale=320:-1",
|
||||
"-f",
|
||||
"image2",
|
||||
"-y",
|
||||
outPath,
|
||||
],
|
||||
stdout: "null",
|
||||
stderr: "null",
|
||||
});
|
||||
const { success } = await cmd.output();
|
||||
if (!success) throw new Error("ffmpeg failed");
|
||||
}
|
||||
|
||||
router.get("/:dumpId", async (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
|
||||
if (!/^[0-9a-f-]{36}$/.test(dumpId)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
|
||||
}
|
||||
|
||||
const dump = getDump(dumpId);
|
||||
|
||||
if (dump.kind !== "file" || !dump.fileMime?.startsWith("video/")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"No video for this dump",
|
||||
);
|
||||
}
|
||||
|
||||
const thumbPath = `${THUMBNAILS_DIR}/${dumpId}.jpg`;
|
||||
|
||||
// Serve cached thumbnail if it exists
|
||||
try {
|
||||
const data = await Deno.readFile(thumbPath);
|
||||
ctx.response.headers.set("Content-Type", "image/jpeg");
|
||||
ctx.response.headers.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
ctx.response.body = data;
|
||||
return;
|
||||
} catch {
|
||||
// Not cached yet — generate it
|
||||
}
|
||||
|
||||
if (!await ffmpegAvailable()) {
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"Thumbnail generation unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
const srcPath = `${DUMPS_DIR}/${dumpId}`;
|
||||
try {
|
||||
await generateThumbnail(srcPath, thumbPath);
|
||||
} catch {
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"Could not generate thumbnail",
|
||||
);
|
||||
}
|
||||
|
||||
const data = await Deno.readFile(thumbPath);
|
||||
ctx.response.headers.set("Content-Type", "image/jpeg");
|
||||
ctx.response.headers.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
ctx.response.body = data;
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user