Files
jmartgraphix.com/server/src/index.ts
T
jmartin 43928f7a49 Initial portfolio CMS: Fastify API + Vite SPA
Single-container app with Postgres catalog, Typesense search,
Authelia Remote-User admin, portfolio views, resume/PDF, media
uploads, and ArtStation import tooling.
2026-07-23 17:01:34 -04:00

177 lines
5.5 KiB
TypeScript

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<void> {
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<void> {
// 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);
});