From 0c5c7283cfd77359726f709d6a564aaeb0b35270 Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:09:31 +0330 Subject: [PATCH] Import source: backend/src/routes/websites Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality. --- backend/src/routes/websites.ts | 174 +++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 backend/src/routes/websites.ts diff --git a/backend/src/routes/websites.ts b/backend/src/routes/websites.ts new file mode 100644 index 0000000..69a6fb5 --- /dev/null +++ b/backend/src/routes/websites.ts @@ -0,0 +1,174 @@ +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 }); + }); +};