mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
- Fix SSE write-after-end crash in progress.ts (remove callback before ending stream) - Fix blob URL memory leaks: revoke processedPreviewUrl and old HEIC preview URLs - Add AbortController to batch fetch in use-tool-processor and use-pipeline-processor - Fix TGA format misidentified as CUR (extension overrides magic bytes) - Add libheif-plugin-libde265 to Docker for HEIC/HEIF decode support - Remove unused imports and state (AppLayout, setSampledColor, useEffect) - Fix non-null assertions in meme-text-renderer and meme-generator - Fix confusing void type in meme-templates - Remove unnecessary useEffect deps in adjustments-panel - Fix Playwright strict mode violations in 5 E2E tests
125 lines
3.8 KiB
TypeScript
125 lines
3.8 KiB
TypeScript
import { createReadStream, existsSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
|
|
const STATIC_DIR = join(import.meta.dirname, "../../static");
|
|
const TEMPLATES_DIR = join(STATIC_DIR, "meme-templates");
|
|
const FONTS_DIR = join(STATIC_DIR, "fonts");
|
|
|
|
const CONTENT_TYPES: Record<string, string> = {
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".png": "image/png",
|
|
".webp": "image/webp",
|
|
".ttf": "font/ttf",
|
|
};
|
|
|
|
/** Cached manifest (read once at first request, served from memory). */
|
|
let manifestCache: string | null = null;
|
|
|
|
let parsedManifestCache: unknown = null;
|
|
|
|
function getManifest(): unknown {
|
|
if (parsedManifestCache === null) {
|
|
manifestCache = readFileSync(join(TEMPLATES_DIR, "meme-templates.json"), "utf-8");
|
|
parsedManifestCache = JSON.parse(manifestCache);
|
|
}
|
|
return parsedManifestCache;
|
|
}
|
|
|
|
function hasPathTraversal(filename: string): boolean {
|
|
return (
|
|
filename.includes("..") ||
|
|
filename.includes("/") ||
|
|
filename.includes("\\") ||
|
|
filename.includes("\0")
|
|
);
|
|
}
|
|
|
|
function getContentType(filename: string): string | undefined {
|
|
const dot = filename.lastIndexOf(".");
|
|
if (dot === -1) return undefined;
|
|
return CONTENT_TYPES[filename.slice(dot).toLowerCase()];
|
|
}
|
|
|
|
function serveStaticFile(
|
|
dir: string,
|
|
filename: string,
|
|
reply: FastifyReply,
|
|
cacheControl: string,
|
|
): FastifyReply | undefined {
|
|
if (hasPathTraversal(filename)) {
|
|
return reply.status(400).send({ error: "Invalid filename" });
|
|
}
|
|
|
|
const contentType = getContentType(filename);
|
|
if (!contentType) {
|
|
return reply.status(400).send({ error: "Unsupported file type" });
|
|
}
|
|
|
|
const filePath = join(dir, filename);
|
|
if (!existsSync(filePath)) {
|
|
return reply.status(404).send({ error: "File not found" });
|
|
}
|
|
|
|
const stream = createReadStream(filePath);
|
|
stream.on("error", () => {
|
|
if (!reply.raw.headersSent) {
|
|
reply.status(404).send({ error: "File not found" });
|
|
}
|
|
});
|
|
|
|
return reply
|
|
.header("Content-Type", contentType)
|
|
.header("Cache-Control", cacheControl)
|
|
.send(stream);
|
|
}
|
|
|
|
export async function registerMemeTemplates(app: FastifyInstance): Promise<void> {
|
|
// GET /api/v1/meme-templates -- Return the full manifest JSON
|
|
app.get("/api/v1/meme-templates", async (_request: FastifyRequest, reply: FastifyReply) => {
|
|
const manifest = getManifest();
|
|
return reply
|
|
.header("Content-Type", "application/json")
|
|
.header("Cache-Control", "public, max-age=3600")
|
|
.send(manifest);
|
|
});
|
|
|
|
// GET /api/v1/meme-templates/full/:filename -- Serve full-size template images
|
|
app.get(
|
|
"/api/v1/meme-templates/full/:filename",
|
|
async (request: FastifyRequest<{ Params: { filename: string } }>, reply: FastifyReply) => {
|
|
const { filename } = request.params;
|
|
return serveStaticFile(
|
|
join(TEMPLATES_DIR, "full"),
|
|
filename,
|
|
reply,
|
|
"public, max-age=31536000, immutable",
|
|
);
|
|
},
|
|
);
|
|
|
|
// GET /api/v1/meme-templates/thumbs/:filename -- Serve thumbnail images
|
|
app.get(
|
|
"/api/v1/meme-templates/thumbs/:filename",
|
|
async (request: FastifyRequest<{ Params: { filename: string } }>, reply: FastifyReply) => {
|
|
const { filename } = request.params;
|
|
return serveStaticFile(
|
|
join(TEMPLATES_DIR, "thumbs"),
|
|
filename,
|
|
reply,
|
|
"public, max-age=31536000, immutable",
|
|
);
|
|
},
|
|
);
|
|
|
|
// GET /api/v1/meme-templates/fonts/:filename -- Serve font files
|
|
app.get(
|
|
"/api/v1/meme-templates/fonts/:filename",
|
|
async (request: FastifyRequest<{ Params: { filename: string } }>, reply: FastifyReply) => {
|
|
const { filename } = request.params;
|
|
return serveStaticFile(FONTS_DIR, filename, reply, "public, max-age=31536000, immutable");
|
|
},
|
|
);
|
|
}
|