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.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user