import Fastify, { type FastifyRequest, type FastifyReply } from "fastify"; import fastifyCookie from "@fastify/cookie"; import fastifyCors from "@fastify/cors"; import fastifyMultipart from "@fastify/multipart"; import fastifyStatic from "@fastify/static"; import { createYoga } from "graphql-yoga"; import { eq } from "drizzle-orm"; import { files } from "./db/schema/index"; import path from "path"; import { existsSync } from "fs"; import sharp from "sharp"; import { schema } from "./graphql/index"; import { buildContext } from "./graphql/context"; import { db } from "./db/connection"; import { redis } from "./lib/auth"; import { logger } from "./lib/logger"; const PORT = parseInt(process.env.PORT || "4000"); const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads"; const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:3000"; async function main() { const fastify = Fastify({ loggerInstance: logger }); await fastify.register(fastifyCookie, { secret: process.env.COOKIE_SECRET || "change-me-in-production", }); await fastify.register(fastifyCors, { origin: CORS_ORIGIN, credentials: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], }); await fastify.register(fastifyMultipart, { limits: { fileSize: 5 * 1024 * 1024 * 1024, // 5 GB }, }); // fastify-static provides reply.sendFile(); files are stored as // await fastify.register(fastifyStatic, { root: path.resolve(UPLOAD_DIR), prefix: "/assets/", serve: false, // disable auto-serving; we use a custom route below decorateReply: true, }); const yoga = createYoga<{ req: FastifyRequest; reply: FastifyReply; db: typeof db; redis: typeof redis; }>({ schema, context: buildContext, graphqlEndpoint: "/graphql", healthCheckEndpoint: "/health", logging: { debug: (...args) => args.forEach((arg) => fastify.log.debug(arg)), info: (...args) => args.forEach((arg) => fastify.log.info(arg)), warn: (...args) => args.forEach((arg) => fastify.log.warn(arg)), error: (...args) => args.forEach((arg) => fastify.log.error(arg)), }, }); fastify.route({ url: "/graphql", method: ["GET", "POST", "OPTIONS"], handler: (req, reply) => yoga.handleNodeRequestAndResponse(req, reply, { req, reply, db, redis }), }); // Transform presets — only banner/thumbnail force a crop; others preserve aspect ratio const TRANSFORMS: Record = { mini: { width: 80, height: 80, fit: "cover" }, thumbnail: { width: 300, height: 300, fit: "cover" }, preview: { width: 800, fit: "inside" }, medium: { width: 1400, fit: "inside" }, banner: { width: 1600, height: 480, fit: "cover" }, }; // Serve uploaded files: GET /assets/:id?transform= // Files are stored as // — look up filename in DB fastify.get("/assets/:id", async (request, reply) => { const { id } = request.params as { id: string }; const { transform } = request.query as { transform?: string }; const result = await db .select({ filename: files.filename, mime_type: files.mime_type }) .from(files) .where(eq(files.id, id)) .limit(1); if (!result[0]) return reply.status(404).send({ error: "File not found" }); const { filename, mime_type } = result[0]; reply.header("Cache-Control", "public, max-age=31536000, immutable"); const preset = transform ? TRANSFORMS[transform] : null; if (preset && mime_type?.startsWith("image/")) { const cacheFile = path.join(UPLOAD_DIR, id, `${transform}.webp`); if (!existsSync(cacheFile)) { const originalPath = path.join(UPLOAD_DIR, id, filename); await sharp(originalPath) .resize({ width: preset.width, height: preset.height, fit: preset.fit ?? "inside", withoutEnlargement: true, }) .webp({ quality: 92 }) .toFile(cacheFile); } reply.header("Content-Type", "image/webp"); return reply.sendFile(path.join(id, `${transform}.webp`)); } reply.header("Content-Type", mime_type); return reply.sendFile(path.join(id, filename)); }); fastify.get("/health", async (_request, reply) => { return reply.send({ status: "ok", timestamp: new Date().toISOString() }); }); try { await fastify.listen({ port: PORT, host: "0.0.0.0" }); fastify.log.info(`Backend running at http://0.0.0.0:${PORT}`); fastify.log.info(`GraphQL at http://0.0.0.0:${PORT}/graphql`); } catch (err) { fastify.log.error(err); process.exit(1); } } main().catch((err) => { console.error("Fatal error:", err); process.exit(1); });