Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
215 lines
7.9 KiB
TypeScript
215 lines
7.9 KiB
TypeScript
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 });
|
|
});
|
|
};
|