diff --git a/backend/src/routes/domains.ts b/backend/src/routes/domains.ts new file mode 100644 index 0000000..6ba4cea --- /dev/null +++ b/backend/src/routes/domains.ts @@ -0,0 +1,214 @@ +import type { FastifyInstance, FastifyPluginAsync } from "fastify"; +import { z } from "zod"; +import type { DnsRecordType, DomainType } from "@prisma/client"; +import { accountIdFor, requireRole, requireSession } from "../lib/auth.js"; +import { conflict, notFound, ok, validationError } from "../lib/envelope.js"; +import { dnsRecordDto, domainDto } from "../lib/serializers.js"; + +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 dnsTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "CAA", "NS", "PTR"] as const; + +const createDomainSchema = z.object({ + name: z.string().regex(domainPattern, "Invalid domain syntax."), + type: z.enum(["primary", "addon", "alias", "subdomain", "parked"]), + documentRoot: z.string().optional(), +}); + +const updateDomainSchema = z.object({ + documentRoot: z.string().optional(), + dnssec: z.boolean().optional(), + propagation: z.number().int().min(0).max(100).optional(), +}); + +const createDnsSchema = z.object({ + name: z.string().min(1, "Record name is required."), + ttl: z.number().int().min(60, "TTL must be at least 60 seconds.").default(14400), + type: z.enum(dnsTypes), + record: z.string().min(1, "Record value is required."), + priority: z.number().int().optional(), +}); + +const updateDnsSchema = createDnsSchema.partial(); + +async function findDomain(app: FastifyInstance, accountId: string, id: string) { + const domain = await app.prisma.domain.findFirst({ + where: { + accountId, + OR: [{ id }, ...(id === "dom-1" ? [{ name: "example.com" }] : [])], + }, + }); + if (!domain) throw notFound(`Domain with ID ${id} not found.`); + return domain; +} + +function zodFields(error: z.ZodError) { + const fields: Record = {}; + for (const issue of error.issues) fields[issue.path.join(".")] = issue.message; + return fields; +} + +export const domainRoutes: FastifyPluginAsync = async (app) => { + app.get("/api/domains", async (request) => { + const session = await requireSession(app, request); + const domains = await app.prisma.domain.findMany({ + where: { accountId: accountIdFor(session) }, + orderBy: { name: "asc" }, + }); + return ok(request, domains.map(domainDto)); + }); + + app.post("/api/domains", async (request, reply) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const parsed = createDomainSchema.safeParse(request.body); + if (!parsed.success) throw validationError("Validation failed.", zodFields(parsed.error)); + + const accountId = accountIdFor(session); + const duplicate = await app.prisma.domain.findFirst({ + where: { accountId, name: { equals: parsed.data.name, mode: "insensitive" } }, + }); + if (duplicate) throw conflict("Domain already exists.", { name: "Domain already exists." }); + + const domain = await app.prisma.domain.create({ + data: { + name: parsed.data.name, + type: parsed.data.type as DomainType, + documentRoot: parsed.data.documentRoot ?? `/home/coaxys/${parsed.data.name}`, + sslStatus: "healthy", + dnssec: false, + propagation: 0, + accountId, + dnsRecords: { + create: { + name: parsed.data.name.endsWith(".") ? parsed.data.name : `${parsed.data.name}.`, + ttl: 14400, + type: "NS", + record: "ns1.coaxys.net.", + }, + }, + }, + }); + + return reply.status(201).send(ok(request, domainDto(domain))); + }); + + app.get("/api/domains/:id", async (request) => { + const session = await requireSession(app, request); + const { id } = request.params as { id: string }; + const domain = await findDomain(app, accountIdFor(session), id); + return ok(request, domainDto(domain)); + }); + + app.put("/api/domains/:id", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + await findDomain(app, accountIdFor(session), id); + + const parsed = updateDomainSchema.safeParse(request.body); + if (!parsed.success) throw validationError("Validation failed.", zodFields(parsed.error)); + + const domain = await app.prisma.domain.update({ + where: { id }, + data: parsed.data, + }); + + return ok(request, domainDto(domain)); + }); + + app.delete("/api/domains/:id", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + const domain = await findDomain(app, accountIdFor(session), id); + + await app.prisma.$transaction([ + app.prisma.website.updateMany({ + where: { accountId: domain.accountId, domain: domain.name }, + data: { status: "warning" }, + }), + app.prisma.domain.delete({ where: { id } }), + ]); + + return ok(request, { deleted: id }); + }); + + app.get("/api/domains/:id/dns", async (request) => { + const session = await requireSession(app, request); + const { id } = request.params as { id: string }; + const domain = await findDomain(app, accountIdFor(session), id); + const records = await app.prisma.dnsRecord.findMany({ + where: { domainId: domain.id }, + orderBy: [{ name: "asc" }, { type: "asc" }], + }); + return ok(request, records.map(dnsRecordDto)); + }); + + app.post("/api/domains/:id/dns", async (request, reply) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + const domain = await findDomain(app, accountIdFor(session), id); + + const parsed = createDnsSchema.safeParse(request.body); + if (!parsed.success) throw validationError("Validation failed.", zodFields(parsed.error)); + + const sameName = await app.prisma.dnsRecord.findMany({ + where: { domainId: domain.id, name: parsed.data.name }, + }); + const createsCnameConflict = + parsed.data.type === "CNAME" + ? sameName.length > 0 + : sameName.some((record) => record.type === "CNAME"); + if (createsCnameConflict) { + throw conflict("CNAME records cannot coexist with other records at the same name.", { + name: "CNAME records cannot coexist with other records at the same name.", + }); + } + + const record = await app.prisma.dnsRecord.create({ + data: { + domainId: domain.id, + name: parsed.data.name, + ttl: parsed.data.ttl, + type: parsed.data.type as DnsRecordType, + record: parsed.data.record, + priority: parsed.data.priority, + }, + }); + + return reply.status(201).send(ok(request, dnsRecordDto(record))); + }); + + app.put("/api/domains/:id/dns/:recordId", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id, recordId } = request.params as { id: string; recordId: string }; + const domain = await findDomain(app, accountIdFor(session), id); + + const existing = await app.prisma.dnsRecord.findFirst({ + where: { id: recordId, domainId: domain.id }, + }); + if (!existing) throw notFound(`DNS record with ID ${recordId} not found.`); + + const parsed = updateDnsSchema.safeParse(request.body); + if (!parsed.success) throw validationError("Validation failed.", zodFields(parsed.error)); + + const record = await app.prisma.dnsRecord.update({ + where: { id: recordId }, + data: { + ...parsed.data, + type: parsed.data.type as DnsRecordType | undefined, + }, + }); + return ok(request, dnsRecordDto(record)); + }); + + app.delete("/api/domains/:id/dns/:recordId", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id, recordId } = request.params as { id: string; recordId: string }; + const domain = await findDomain(app, accountIdFor(session), id); + const existing = await app.prisma.dnsRecord.findFirst({ + where: { id: recordId, domainId: domain.id }, + }); + if (!existing) throw notFound(`DNS record with ID ${recordId} not found.`); + await app.prisma.dnsRecord.delete({ where: { id: recordId } }); + return ok(request, { deleted: recordId }); + }); +};