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