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 }); }); };