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 { docsRoutes } from "./routes/docs.js";
|
||||||
import { registerFeatureRoutes } from "./routes/features.js";
|
import { registerFeatureRoutes } from "./routes/features.js";
|
||||||
import { fileRoutes } from "./routes/files.js";
|
import { fileRoutes } from "./routes/files.js";
|
||||||
|
import { registerMemeTemplates } from "./routes/meme-templates.js";
|
||||||
import { registerPipelineRoutes } from "./routes/pipeline.js";
|
import { registerPipelineRoutes } from "./routes/pipeline.js";
|
||||||
import { recoverStaleJobs, registerProgressRoutes } from "./routes/progress.js";
|
import { recoverStaleJobs, registerProgressRoutes } from "./routes/progress.js";
|
||||||
import { rolesRoutes } from "./routes/roles.js";
|
import { rolesRoutes } from "./routes/roles.js";
|
||||||
@@ -155,6 +156,9 @@ await fileRoutes(app);
|
|||||||
// User file library routes (persistent file management with versioning)
|
// User file library routes (persistent file management with versioning)
|
||||||
await userFileRoutes(app);
|
await userFileRoutes(app);
|
||||||
|
|
||||||
|
// Meme template listing and static serving (before tool routes which have catch-all)
|
||||||
|
await registerMemeTemplates(app);
|
||||||
|
|
||||||
// Tool routes (generic factory-based)
|
// Tool routes (generic factory-based)
|
||||||
await registerToolRoutes(app);
|
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