import fs from "node:fs"; import path from "node:path"; import Fastify from "fastify"; import cors from "@fastify/cors"; import multipart from "@fastify/multipart"; import fastifyStatic from "@fastify/static"; import swagger from "@fastify/swagger"; import swaggerUi from "@fastify/swagger-ui"; import { config } from "./config.js"; import { prisma } from "./lib/prisma.js"; import { ensureUploadDirs } from "./lib/media.js"; import { reindexAllProjects } from "./lib/typesense.js"; import { healthRoutes } from "./routes/health.js"; import { publicRoutes } from "./routes/public.js"; import { adminProjectRoutes } from "./routes/admin-projects.js"; import { adminMetaRoutes } from "./routes/admin-meta.js"; import { resumePdfRoutes } from "./routes/resume-pdf.js"; async function waitForDb(retries = 30): Promise { for (let i = 0; i < retries; i++) { try { await prisma.$queryRaw`SELECT 1`; return; } catch (err) { console.warn(`Database not ready (${i + 1}/${retries})…`); await new Promise((r) => setTimeout(r, 2000)); } } throw new Error("Database connection failed"); } async function runMigrations(): Promise { // Prefer prisma migrate deploy when migrations exist const { execSync } = await import("node:child_process"); try { execSync("npx prisma migrate deploy", { stdio: "inherit", env: { ...process.env, DATABASE_URL: config.databaseUrl }, cwd: path.resolve(import.meta.dirname, ".."), }); console.log("Database migrations applied"); } catch { console.warn("migrate deploy failed, falling back to db push"); execSync("npx prisma db push --skip-generate", { stdio: "inherit", env: { ...process.env, DATABASE_URL: config.databaseUrl }, cwd: path.resolve(import.meta.dirname, ".."), }); console.log("Database schema pushed"); } try { execSync("npx tsx prisma/seed.ts", { stdio: "inherit", env: { ...process.env, DATABASE_URL: config.databaseUrl }, cwd: path.resolve(import.meta.dirname, ".."), }); } catch (err) { console.warn("Seed skipped or failed:", err); } } async function main() { process.env.DATABASE_URL = config.databaseUrl; await ensureUploadDirs(); await waitForDb(); await runMigrations(); try { const n = await reindexAllProjects(); console.log(`Typesense index ready (${n} projects)`); } catch (err) { console.warn("Typesense index failed (search may be degraded):", err); } const app = Fastify({ logger: true, trustProxy: true, bodyLimit: config.maxUploadMb * 1024 * 1024, }); await app.register(cors, { origin: true, credentials: true }); await app.register(multipart, { limits: { fileSize: config.maxUploadMb * 1024 * 1024 }, }); await app.register(swagger, { openapi: { info: { title: "jmartgraphix Portfolio API", description: "Public and admin API for the jmartgraphix portfolio CMS", version: "1.0.0", }, servers: [{ url: config.publicUrl }], tags: [ { name: "public", description: "Public read endpoints" }, { name: "admin", description: "Admin endpoints (Authelia Remote-User)" }, ], }, }); await app.register(swaggerUi, { routePrefix: "/api/docs", uiConfig: { docExpansion: "list" }, }); await healthRoutes(app); await publicRoutes(app); await adminProjectRoutes(app); await adminMetaRoutes(app); await resumePdfRoutes(app); // Uploads await app.register(fastifyStatic, { root: config.uploadDir, prefix: "/uploads/", decorateReply: false, }); // .well-known (matrix, webfinger) + built SPA const publicRoot = config.clientDist; if (fs.existsSync(publicRoot)) { // Explicit .well-known with correct content types app.get("/.well-known/*", async (req, reply) => { const sub = (req.params as { "*": string })["*"]; const filePath = path.join(publicRoot, ".well-known", sub); if (!filePath.startsWith(path.join(publicRoot, ".well-known"))) { return reply.code(403).send("Forbidden"); } try { const data = await fs.promises.readFile(filePath); // matrix & webfinger are JSON without extension often const isJson = sub.includes("webfinger") || sub.includes("matrix") || filePath.endsWith(".json"); return reply .type(isJson ? "application/json" : "application/octet-stream") .header("Access-Control-Allow-Origin", "*") .send(data); } catch { return reply.code(404).send({ error: "Not found" }); } }); await app.register(fastifyStatic, { root: publicRoot, prefix: "/", wildcard: false, decorateReply: false, }); // SPA fallback for client routes (not API, not uploads, not well-known) app.setNotFoundHandler(async (req, reply) => { if ( req.url.startsWith("/api/") || req.url.startsWith("/uploads/") || req.url.startsWith("/.well-known/") ) { return reply.code(404).send({ error: "Not found" }); } const indexPath = path.join(publicRoot, "index.html"); if (fs.existsSync(indexPath)) { return reply.type("text/html").send(await fs.promises.readFile(indexPath, "utf8")); } return reply.code(404).send("Not found"); }); } await app.listen({ host: config.host, port: config.port }); console.log(`Server listening on http://${config.host}:${config.port}`); } main().catch((err) => { console.error(err); process.exit(1); });