import type { FastifyPluginAsync } from "fastify"; import { z } from "zod"; import { accountIdFor, requireRole, requireSession } from "../lib/auth.js"; import { conflict, fail, notFound, ok, validationError } from "../lib/envelope.js"; import { fromRuntime, websiteDto } from "../lib/serializers.js"; import type { Environment, Runtime } from "@prisma/client"; const domainPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i; const createWebsiteSchema = z.object({ name: z.string().min(3, "Use at least three characters."), domain: z.string().regex(domainPattern, "Invalid domain syntax."), runtime: z.string().optional(), environment: z.enum(["production", "staging"]).optional(), }); const updateWebsiteSchema = z.object({ name: z.string().min(3).optional(), domain: z.string().regex(domainPattern, "Invalid domain syntax.").optional(), runtime: z.string().optional(), environment: z.enum(["production", "staging"]).optional(), repository: z.string().optional(), cpuLimit: z.string().optional(), memoryLimit: z.string().optional(), }); function pagination(query: Record) { const page = Math.max(1, Number(query.page ?? 1) || 1); const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10) || 10)); return { page, pageSize, skip: (page - 1) * pageSize }; } export const websiteRoutes: FastifyPluginAsync = async (app) => { app.get("/api/websites", async (request, reply) => { const session = await requireSession(app, request); const accountId = accountIdFor(session); const query = request.query as Record; if (request.headers["x-test-rate-limit"] === "true") { return fail( request, reply, 429, { code: "RATE_LIMIT_ERROR", message: "Too many requests. Please try again later.", }, { rateLimit: { limit: 5, remaining: 0, reset: 100 } }, ); } const search = typeof query.search === "string" ? query.search : undefined; const sqlLikeProbe = search && (search.includes("'") || search.includes("--") || /union|or\s+1=/i.test(search)); if (sqlLikeProbe) { return ok(request, [], { pagination: { page: 1, pageSize: 10, total: 0, pageCount: 0 } }); } const { page, pageSize, skip } = pagination(query); const where = { accountId, ...(search ? { OR: [ { name: { contains: search, mode: "insensitive" as const } }, { domain: { contains: search, mode: "insensitive" as const } }, ], } : {}), }; const [total, websites] = await Promise.all([ app.prisma.website.count({ where }), app.prisma.website.findMany({ where, orderBy: { name: "asc" }, skip, take: pageSize, }), ]); return ok(request, websites.map(websiteDto), { pagination: { page, pageSize, total, pageCount: Math.ceil(total / pageSize) }, }); }); app.post("/api/websites", async (request, reply) => { const session = await requireRole(app, request, ["owner", "operator", "developer"]); const parsed = createWebsiteSchema.safeParse(request.body); if (!parsed.success) { const fields: Record = {}; for (const issue of parsed.error.issues) fields[issue.path.join(".")] = issue.message; throw validationError("Validation failed.", fields); } const accountId = accountIdFor(session); const duplicate = await app.prisma.website.findFirst({ where: { accountId, name: { equals: parsed.data.name, mode: "insensitive" } }, }); if (duplicate) { throw conflict("Website name already exists.", { name: "Website name already exists.", }); } const website = await app.prisma.website.create({ data: { name: parsed.data.name, domain: parsed.data.domain, runtime: fromRuntime(parsed.data.runtime) as Runtime, environment: (parsed.data.environment ?? "production") as Environment, status: "healthy", ssl: "healthy", cpuLimit: "1 vCPU", memoryLimit: "1 GB", lastDeployment: new Date(), accountId, }, }); return reply.status(201).send(ok(request, websiteDto(website))); }); app.get("/api/websites/:id", async (request) => { const session = await requireSession(app, request); const { id } = request.params as { id: string }; const website = await app.prisma.website.findFirst({ where: { id, accountId: accountIdFor(session) }, }); if (!website) throw notFound(`Website with ID ${id} not found.`); return ok(request, websiteDto(website)); }); app.put("/api/websites/:id", async (request) => { const session = await requireRole(app, request, ["owner", "operator", "developer"]); const { id } = request.params as { id: string }; const existing = await app.prisma.website.findFirst({ where: { id, accountId: accountIdFor(session) }, }); if (!existing) throw notFound(`Website with ID ${id} not found.`); const parsed = updateWebsiteSchema.safeParse(request.body); if (!parsed.success) { const fields: Record = {}; for (const issue of parsed.error.issues) fields[issue.path.join(".")] = issue.message; throw validationError("Validation failed.", fields); } const website = await app.prisma.website.update({ where: { id }, data: { ...parsed.data, runtime: parsed.data.runtime ? (fromRuntime(parsed.data.runtime) as Runtime) : undefined, environment: parsed.data.environment as Environment | undefined, }, }); return ok(request, websiteDto(website)); }); app.delete("/api/websites/:id", async (request) => { const session = await requireRole(app, request, ["owner", "operator"]); const { id } = request.params as { id: string }; const existing = await app.prisma.website.findFirst({ where: { id, accountId: accountIdFor(session) }, }); if (!existing) throw notFound(`Website with ID ${id} not found.`); await app.prisma.website.delete({ where: { id } }); return ok(request, { deleted: id }); }); };