mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(meme-generator): add template listing and static serving endpoints
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
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<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");
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user