Import source: backend/src/routes/domains

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:09:26 +03:30
parent 728a8530b7
commit f2370c1570
+214
View File
@@ -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<string, string> = {};
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 });
});
};