From b0cb17ff554c20b0d2242d5fee0e9862d9310e0f Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Fri, 8 May 2026 16:05:17 +0800 Subject: [PATCH] feat(meme-generator): add template listing and static serving endpoints --- apps/api/src/index.ts | 4 + apps/api/src/routes/meme-templates.ts | 121 ++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 apps/api/src/routes/meme-templates.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c75837fb..424276fe 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -24,6 +24,7 @@ import { registerBatchRoutes } from "./routes/batch.js"; import { docsRoutes } from "./routes/docs.js"; import { registerFeatureRoutes } from "./routes/features.js"; import { fileRoutes } from "./routes/files.js"; +import { registerMemeTemplates } from "./routes/meme-templates.js"; import { registerPipelineRoutes } from "./routes/pipeline.js"; import { recoverStaleJobs, registerProgressRoutes } from "./routes/progress.js"; import { rolesRoutes } from "./routes/roles.js"; @@ -155,6 +156,9 @@ await fileRoutes(app); // User file library routes (persistent file management with versioning) await userFileRoutes(app); +// Meme template listing and static serving (before tool routes which have catch-all) +await registerMemeTemplates(app); + // Tool routes (generic factory-based) await registerToolRoutes(app); diff --git a/apps/api/src/routes/meme-templates.ts b/apps/api/src/routes/meme-templates.ts new file mode 100644 index 00000000..52861e72 --- /dev/null +++ b/apps/api/src/routes/meme-templates.ts @@ -0,0 +1,121 @@ +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 = { + ".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; + +function getManifest(): string { + if (manifestCache === null) { + manifestCache = readFileSync(join(TEMPLATES_DIR, "meme-templates.json"), "utf-8"); + } + return manifestCache; +} + +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 | void { + 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 { + // 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"); + }, + ); +}