Import source: backend/src/routes/websites
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -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<string, unknown>) {
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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 });
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user