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:
@@ -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 });
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user