import Fastify, { FastifyRequest, 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"; 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({ logger: { level: process.env.LOG_LEVEL || "info", }, }); 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: width x height (height optional = keep aspect ratio) const TRANSFORMS: Record = { mini: { width: 64, height: 64 }, thumbnail: { width: 200, height: 200 }, preview: { width: 480, height: 270 }, medium: { width: 960 }, banner: { width: 1280, height: 400 }, }; // 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: "cover", withoutEnlargement: true }) .webp({ quality: 85 }) .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); });