feat:Update pnpm-lock.yaml to link shared package; modify API routes to utilize new schemas for domain and service management; enhance DNS record handling with CNAME support; refactor service and subdomain routes for improved functionality; implement confirm dialog for domain deletion in the frontend; clean up unused components and improve UI consistency.
Build, Test, and Push CFDM Docker Image / test (push) Failing after 51s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Failing after 51s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
This commit is contained in:
1 parent
4a70cf5854
commit
6d1d8ca4f3
99 files changed
+6692
-1483
No files matched your search
@@ -2,4 +2,4 @@ export * from "./schema.js";
|
||||
export * from "./client.js";
|
||||
export * from "./errors.js";
|
||||
export * as repos from "./repos.js";
|
||||
export type { DnsListFilter } from "./repos.js";
|
||||
export type { DnsListFilter, UpdateSubdomainPatch } from "./repos.js";
|
||||
+209
-20
@@ -12,7 +12,8 @@ import type {
|
||||
Subdomain,
|
||||
SyncJob,
|
||||
} from "@cfdm/shared";
|
||||
import { and, asc, count, eq, isNull, like, or, sql } from "drizzle-orm";
|
||||
import { dnsRecordNamesMatch } from "@cfdm/shared";
|
||||
import { and, asc, count, eq, isNull, like, notInArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "./client.js";
|
||||
import { NotFoundError } from "./errors.js";
|
||||
import {
|
||||
@@ -158,14 +159,24 @@ export function updateDomain(
|
||||
id: number,
|
||||
groupId: number | null,
|
||||
status: string,
|
||||
certMonitoring?: string,
|
||||
): Domain {
|
||||
const updates: {
|
||||
group_id: number | null;
|
||||
status: string;
|
||||
cert_monitoring?: string;
|
||||
updated_at: ReturnType<typeof sql>;
|
||||
} = {
|
||||
group_id: groupId,
|
||||
status,
|
||||
updated_at: sql`datetime('now')`,
|
||||
};
|
||||
if (certMonitoring !== undefined) {
|
||||
updates.cert_monitoring = certMonitoring;
|
||||
}
|
||||
const result = db
|
||||
.update(domains)
|
||||
.set({
|
||||
group_id: groupId,
|
||||
status,
|
||||
updated_at: sql`datetime('now')`,
|
||||
})
|
||||
.set(updates)
|
||||
.where(eq(domains.id, id))
|
||||
.run();
|
||||
if (result.changes === 0) throw new NotFoundError(`domain ${id}`);
|
||||
@@ -208,6 +219,19 @@ export function getSubdomain(db: Db, id: number): Subdomain {
|
||||
return row as Subdomain;
|
||||
}
|
||||
|
||||
export function findSubdomainByDomainAndName(
|
||||
db: Db,
|
||||
domainId: number,
|
||||
name: string,
|
||||
): Subdomain | null {
|
||||
const row = db
|
||||
.select()
|
||||
.from(subdomains)
|
||||
.where(and(eq(subdomains.domain_id, domainId), eq(subdomains.name, name)))
|
||||
.get();
|
||||
return (row as Subdomain) ?? null;
|
||||
}
|
||||
|
||||
export function upsertSubdomain(
|
||||
db: Db,
|
||||
domainId: number,
|
||||
@@ -237,15 +261,36 @@ export function createSubdomain(
|
||||
return getSubdomain(db, id);
|
||||
}
|
||||
|
||||
export interface UpdateSubdomainPatch {
|
||||
name?: string;
|
||||
fqdn?: string;
|
||||
enabled?: boolean;
|
||||
cert_monitoring?: string;
|
||||
}
|
||||
|
||||
export function updateSubdomain(
|
||||
db: Db,
|
||||
id: number,
|
||||
name: string,
|
||||
fqdn: string,
|
||||
patch: UpdateSubdomainPatch,
|
||||
): Subdomain {
|
||||
const updates: {
|
||||
name?: string;
|
||||
fqdn?: string;
|
||||
enabled?: boolean;
|
||||
cert_monitoring?: string;
|
||||
updated_at: ReturnType<typeof sql>;
|
||||
} = { updated_at: sql`datetime('now')` };
|
||||
|
||||
if (patch.name !== undefined) updates.name = patch.name;
|
||||
if (patch.fqdn !== undefined) updates.fqdn = patch.fqdn;
|
||||
if (patch.enabled !== undefined) updates.enabled = patch.enabled;
|
||||
if (patch.cert_monitoring !== undefined) {
|
||||
updates.cert_monitoring = patch.cert_monitoring;
|
||||
}
|
||||
|
||||
const result = db
|
||||
.update(subdomains)
|
||||
.set({ name, fqdn, updated_at: sql`datetime('now')` })
|
||||
.set(updates)
|
||||
.where(eq(subdomains.id, id))
|
||||
.run();
|
||||
if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`);
|
||||
@@ -448,8 +493,25 @@ export function markDnsPendingDelete(db: Db, id: number): void {
|
||||
|
||||
// --- Services ---
|
||||
|
||||
function maxSortOrderInGroup(db: Db, groupId: number | null): number {
|
||||
const condition =
|
||||
groupId === null
|
||||
? isNull(services.service_group_id)
|
||||
: eq(services.service_group_id, groupId);
|
||||
const row = db
|
||||
.select({ maxOrder: sql<number>`coalesce(max(${services.sort_order}), -1)` })
|
||||
.from(services)
|
||||
.where(condition)
|
||||
.get();
|
||||
return row?.maxOrder ?? -1;
|
||||
}
|
||||
|
||||
export function listServices(db: Db): Service[] {
|
||||
return db.select().from(services).orderBy(asc(services.name)).all() as Service[];
|
||||
return db
|
||||
.select()
|
||||
.from(services)
|
||||
.orderBy(asc(services.sort_order), asc(services.name))
|
||||
.all() as Service[];
|
||||
}
|
||||
|
||||
export function listServicesByGroup(db: Db, groupId: number): Service[] {
|
||||
@@ -457,7 +519,7 @@ export function listServicesByGroup(db: Db, groupId: number): Service[] {
|
||||
.select()
|
||||
.from(services)
|
||||
.where(eq(services.service_group_id, groupId))
|
||||
.orderBy(asc(services.name))
|
||||
.orderBy(asc(services.sort_order), asc(services.name))
|
||||
.all() as Service[];
|
||||
}
|
||||
|
||||
@@ -466,7 +528,7 @@ export function listUngroupedServices(db: Db): Service[] {
|
||||
.select()
|
||||
.from(services)
|
||||
.where(isNull(services.service_group_id))
|
||||
.orderBy(asc(services.name))
|
||||
.orderBy(asc(services.sort_order), asc(services.name))
|
||||
.all() as Service[];
|
||||
}
|
||||
|
||||
@@ -477,9 +539,10 @@ export function getService(db: Db, id: number): Service {
|
||||
}
|
||||
|
||||
export function createService(db: Db, name: string, slug: string): Service {
|
||||
const sortOrder = maxSortOrderInGroup(db, null) + 1;
|
||||
const id = db
|
||||
.insert(services)
|
||||
.values({ name, slug, subdomain: slug })
|
||||
.values({ name, slug, subdomain: slug, sort_order: sortOrder })
|
||||
.returning({ id: services.id })
|
||||
.get()!.id;
|
||||
return getService(db, id);
|
||||
@@ -520,12 +583,56 @@ export function setServiceGroup(
|
||||
id: number,
|
||||
groupId: number | null,
|
||||
): void {
|
||||
const sortOrder = maxSortOrderInGroup(db, groupId) + 1;
|
||||
db.update(services)
|
||||
.set({ service_group_id: groupId, updated_at: sql`datetime('now')` })
|
||||
.set({
|
||||
service_group_id: groupId,
|
||||
sort_order: sortOrder,
|
||||
updated_at: sql`datetime('now')`,
|
||||
})
|
||||
.where(eq(services.id, id))
|
||||
.run();
|
||||
}
|
||||
|
||||
export function reorderServices(
|
||||
db: Db,
|
||||
groupId: number | null,
|
||||
orderedIds: number[],
|
||||
): void {
|
||||
const uniqueIds = new Set(orderedIds);
|
||||
if (uniqueIds.size !== orderedIds.length) {
|
||||
throw new Error("duplicate service ids in reorder request");
|
||||
}
|
||||
|
||||
const condition =
|
||||
groupId === null
|
||||
? isNull(services.service_group_id)
|
||||
: eq(services.service_group_id, groupId);
|
||||
|
||||
const existing = db
|
||||
.select({ id: services.id })
|
||||
.from(services)
|
||||
.where(condition)
|
||||
.all()
|
||||
.map((row) => row.id);
|
||||
|
||||
const existingSet = new Set(existing);
|
||||
for (const serviceId of orderedIds) {
|
||||
if (!existingSet.has(serviceId)) {
|
||||
throw new NotFoundError(`service ${serviceId} not in group`);
|
||||
}
|
||||
}
|
||||
|
||||
db.transaction((tx) => {
|
||||
for (let index = 0; index < orderedIds.length; index++) {
|
||||
tx.update(services)
|
||||
.set({ sort_order: index, updated_at: sql`datetime('now')` })
|
||||
.where(eq(services.id, orderedIds[index]!))
|
||||
.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteService(db: Db, id: number): void {
|
||||
const result = db.delete(services).where(eq(services.id, id)).run();
|
||||
if (result.changes === 0) throw new NotFoundError(`service ${id}`);
|
||||
@@ -668,6 +775,20 @@ export function replaceBindingIps(
|
||||
}
|
||||
}
|
||||
|
||||
export function setBindingCnameTarget(
|
||||
db: Db,
|
||||
bindingId: number,
|
||||
target: string | null,
|
||||
): void {
|
||||
db.update(serviceBindings)
|
||||
.set({
|
||||
cname_target: target,
|
||||
updated_at: sql`datetime('now')`,
|
||||
})
|
||||
.where(eq(serviceBindings.id, bindingId))
|
||||
.run();
|
||||
}
|
||||
|
||||
// --- Service Binding Records ---
|
||||
|
||||
export function listRecordsForBinding(db: Db, bindingId: number): DnsRecord[] {
|
||||
@@ -744,8 +865,61 @@ export function unlinkGroupDnsRecord(
|
||||
|
||||
// --- Service Bindings ---
|
||||
|
||||
function dnsRecordMatchesHostname(
|
||||
recordName: string,
|
||||
hostname: string,
|
||||
zoneName: string,
|
||||
): boolean {
|
||||
return dnsRecordNamesMatch(recordName, hostname, zoneName);
|
||||
}
|
||||
|
||||
function enrichServiceBindingView(
|
||||
db: Db,
|
||||
row: Omit<ServiceBindingView, "target_ips"> & { target_ips?: string[] },
|
||||
): ServiceBindingView {
|
||||
const configured = listBindingIps(db, row.id);
|
||||
const linkedRecords = listRecordsForBinding(db, row.id);
|
||||
const linkedIps = linkedRecords
|
||||
.filter((record) => record.record_type.toUpperCase() === "A")
|
||||
.map((record) => record.content);
|
||||
|
||||
const target_ips = [
|
||||
...new Set([
|
||||
...configured,
|
||||
...linkedIps,
|
||||
...(row.target_ip ? [row.target_ip] : []),
|
||||
]),
|
||||
].sort();
|
||||
|
||||
if (target_ips.length === 0) {
|
||||
for (const record of listDnsByDomain(db, row.domain_id)) {
|
||||
if (record.record_type.toUpperCase() !== "A") continue;
|
||||
if (!dnsRecordMatchesHostname(record.name, row.hostname, row.zone_name)) {
|
||||
continue;
|
||||
}
|
||||
if (!target_ips.includes(record.content)) {
|
||||
target_ips.push(record.content);
|
||||
}
|
||||
}
|
||||
target_ips.sort();
|
||||
}
|
||||
|
||||
const sync_status =
|
||||
row.sync_status ??
|
||||
linkedRecords.find((record) => record.sync_status)?.sync_status ??
|
||||
null;
|
||||
|
||||
return {
|
||||
...row,
|
||||
target_ips,
|
||||
target_ip: target_ips[0] ?? null,
|
||||
sync_status,
|
||||
};
|
||||
}
|
||||
|
||||
export function listAllBindings(db: Db): ServiceBindingView[] {
|
||||
return db.all<ServiceBindingView>(sql`
|
||||
return db
|
||||
.all<Omit<ServiceBindingView, "target_ips">>(sql`
|
||||
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
|
||||
d.zone_name, d.group_id, g.name AS group_name,
|
||||
s.name AS service_name, s.slug AS service_slug,
|
||||
@@ -757,11 +931,13 @@ export function listAllBindings(db: Db): ServiceBindingView[] {
|
||||
JOIN services s ON s.id = sb.service_id
|
||||
LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id
|
||||
ORDER BY d.zone_name, s.name
|
||||
`);
|
||||
`)
|
||||
.map((row) => enrichServiceBindingView(db, row));
|
||||
}
|
||||
|
||||
export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingView[] {
|
||||
return db.all<ServiceBindingView>(sql`
|
||||
return db
|
||||
.all<Omit<ServiceBindingView, "target_ips">>(sql`
|
||||
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
|
||||
d.zone_name, d.group_id, g.name AS group_name,
|
||||
s.name AS service_name, s.slug AS service_slug,
|
||||
@@ -774,7 +950,8 @@ export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingVi
|
||||
LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id
|
||||
WHERE sb.domain_id = ${domainId}
|
||||
ORDER BY s.name
|
||||
`);
|
||||
`)
|
||||
.map((row) => enrichServiceBindingView(db, row));
|
||||
}
|
||||
|
||||
export function listBindingsByService(db: Db, serviceId: number): Array<ServiceBinding & { zone_name: string }> {
|
||||
@@ -796,7 +973,7 @@ export function getBinding(db: Db, id: number): ServiceBinding {
|
||||
}
|
||||
|
||||
export function getBindingView(db: Db, id: number): ServiceBindingView {
|
||||
const rows = db.all<ServiceBindingView>(sql`
|
||||
const rows = db.all<Omit<ServiceBindingView, "target_ips">>(sql`
|
||||
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
|
||||
d.zone_name, d.group_id, g.name AS group_name,
|
||||
s.name AS service_name, s.slug AS service_slug,
|
||||
@@ -810,7 +987,7 @@ export function getBindingView(db: Db, id: number): ServiceBindingView {
|
||||
WHERE sb.id = ${id}
|
||||
`);
|
||||
if (!rows[0]) throw new NotFoundError(`service binding ${id}`);
|
||||
return rows[0];
|
||||
return enrichServiceBindingView(db, rows[0]);
|
||||
}
|
||||
|
||||
export function findBinding(
|
||||
@@ -1005,6 +1182,18 @@ export function countCertificatesByStatus(
|
||||
return rows.map((r) => [r.status, r.cnt]);
|
||||
}
|
||||
|
||||
export function deleteCertificatesNotIn(db: Db, hostnames: string[]): number {
|
||||
if (hostnames.length === 0) {
|
||||
const result = db.delete(certificates).run();
|
||||
return result.changes;
|
||||
}
|
||||
const result = db
|
||||
.delete(certificates)
|
||||
.where(notInArray(certificates.hostname, hostnames))
|
||||
.run();
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
// --- Sync Jobs ---
|
||||
|
||||
export function createSyncJob(
|
||||
|
||||
@@ -28,6 +28,7 @@ export const services = sqliteTable("services", {
|
||||
),
|
||||
subdomain: text("subdomain"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
|
||||
sort_order: integer("sort_order").notNull().default(0),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
@@ -59,6 +60,7 @@ export const domains = sqliteTable("domains", {
|
||||
zone_name: text("zone_name").notNull().unique(),
|
||||
cf_zone_id: text("cf_zone_id").notNull(),
|
||||
status: text("status").notNull().default("active"),
|
||||
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
|
||||
last_synced_at: text("last_synced_at"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
@@ -75,6 +77,8 @@ export const subdomains = sqliteTable("subdomains", {
|
||||
.references(() => domains.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
fqdn: text("fqdn").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
@@ -115,6 +119,7 @@ export const serviceBindings = sqliteTable("service_bindings", {
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "cascade" }),
|
||||
hostname: text("hostname").notNull().default("@"),
|
||||
cname_target: text("cname_target"),
|
||||
dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user