first rewrite commit
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import db, { nowIso } from "../db";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
|
||||
export type AccessListEntry = {
|
||||
id: number;
|
||||
username: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AccessList = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
entries: AccessListEntry[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AccessListInput = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
users?: { username: string; password: string }[];
|
||||
};
|
||||
|
||||
function parseAccessList(row: any): AccessList {
|
||||
const entries = db
|
||||
.prepare(
|
||||
`SELECT id, username, created_at, updated_at
|
||||
FROM access_list_entries
|
||||
WHERE access_list_id = ?
|
||||
ORDER BY username ASC`
|
||||
)
|
||||
.all(row.id) as AccessListEntry[];
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
entries,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function listAccessLists(): AccessList[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, name, description, created_at, updated_at
|
||||
FROM access_lists
|
||||
ORDER BY name ASC`
|
||||
)
|
||||
.all();
|
||||
return rows.map(parseAccessList);
|
||||
}
|
||||
|
||||
export function getAccessList(id: number): AccessList | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, name, description, created_at, updated_at
|
||||
FROM access_lists WHERE id = ?`
|
||||
)
|
||||
.get(id);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return parseAccessList(row);
|
||||
}
|
||||
|
||||
export async function createAccessList(input: AccessListInput, actorUserId: number) {
|
||||
const now = nowIso();
|
||||
const tx = db.transaction(() => {
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO access_lists (name, description, created_at, updated_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(input.name.trim(), input.description ?? null, now, now, actorUserId);
|
||||
const accessListId = Number(result.lastInsertRowid);
|
||||
|
||||
if (input.users) {
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO access_list_entries (access_list_id, username, password_hash, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const account of input.users) {
|
||||
const hash = bcrypt.hashSync(account.password, 10);
|
||||
insert.run(accessListId, account.username, hash, now, now);
|
||||
}
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
entityType: "access_list",
|
||||
entityId: accessListId,
|
||||
summary: `Created access list ${input.name}`
|
||||
});
|
||||
|
||||
return accessListId;
|
||||
});
|
||||
|
||||
const id = tx();
|
||||
await applyCaddyConfig();
|
||||
return getAccessList(id)!;
|
||||
}
|
||||
|
||||
export async function updateAccessList(
|
||||
id: number,
|
||||
input: { name?: string; description?: string | null },
|
||||
actorUserId: number
|
||||
) {
|
||||
const existing = getAccessList(id);
|
||||
if (!existing) {
|
||||
throw new Error("Access list not found");
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
db.prepare(
|
||||
`UPDATE access_lists SET name = ?, description = ?, updated_at = ? WHERE id = ?`
|
||||
).run(input.name ?? existing.name, input.description ?? existing.description, now, id);
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "update",
|
||||
entityType: "access_list",
|
||||
entityId: id,
|
||||
summary: `Updated access list ${input.name ?? existing.name}`
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
return getAccessList(id)!;
|
||||
}
|
||||
|
||||
export async function addAccessListEntry(
|
||||
accessListId: number,
|
||||
entry: { username: string; password: string },
|
||||
actorUserId: number
|
||||
) {
|
||||
const list = getAccessList(accessListId);
|
||||
if (!list) {
|
||||
throw new Error("Access list not found");
|
||||
}
|
||||
const now = nowIso();
|
||||
const hash = bcrypt.hashSync(entry.password, 10);
|
||||
db.prepare(
|
||||
`INSERT INTO access_list_entries (access_list_id, username, password_hash, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(accessListId, entry.username, hash, now, now);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
entityType: "access_list_entry",
|
||||
entityId: accessListId,
|
||||
summary: `Added user ${entry.username} to access list ${list.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
return getAccessList(accessListId)!;
|
||||
}
|
||||
|
||||
export async function removeAccessListEntry(accessListId: number, entryId: number, actorUserId: number) {
|
||||
const list = getAccessList(accessListId);
|
||||
if (!list) {
|
||||
throw new Error("Access list not found");
|
||||
}
|
||||
db.prepare("DELETE FROM access_list_entries WHERE id = ?").run(entryId);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
entityType: "access_list_entry",
|
||||
entityId: entryId,
|
||||
summary: `Removed entry from access list ${list.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
return getAccessList(accessListId)!;
|
||||
}
|
||||
|
||||
export async function deleteAccessList(id: number, actorUserId: number) {
|
||||
const existing = getAccessList(id);
|
||||
if (!existing) {
|
||||
throw new Error("Access list not found");
|
||||
}
|
||||
db.prepare("DELETE FROM access_lists WHERE id = ?").run(id);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
entityType: "access_list",
|
||||
entityId: id,
|
||||
summary: `Deleted access list ${existing.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import db from "../db";
|
||||
|
||||
export type AuditEvent = {
|
||||
id: number;
|
||||
user_id: number | null;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: number | null;
|
||||
summary: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export function listAuditEvents(limit = 100): AuditEvent[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, user_id, action, entity_type, entity_id, summary, created_at
|
||||
FROM audit_events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(limit) as AuditEvent[];
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import db, { nowIso } from "../db";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
|
||||
export type CertificateType = "managed" | "imported";
|
||||
|
||||
export type Certificate = {
|
||||
id: number;
|
||||
name: string;
|
||||
type: CertificateType;
|
||||
domain_names: string[];
|
||||
auto_renew: boolean;
|
||||
provider_options: Record<string, unknown> | null;
|
||||
certificate_pem: string | null;
|
||||
private_key_pem: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CertificateInput = {
|
||||
name: string;
|
||||
type: CertificateType;
|
||||
domain_names: string[];
|
||||
auto_renew?: boolean;
|
||||
provider_options?: Record<string, unknown> | null;
|
||||
certificate_pem?: string | null;
|
||||
private_key_pem?: string | null;
|
||||
};
|
||||
|
||||
function parseCertificate(row: any): Certificate {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
domain_names: JSON.parse(row.domain_names),
|
||||
auto_renew: Boolean(row.auto_renew),
|
||||
provider_options: row.provider_options ? JSON.parse(row.provider_options) : null,
|
||||
certificate_pem: row.certificate_pem,
|
||||
private_key_pem: row.private_key_pem,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function listCertificates(): Certificate[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, name, type, domain_names, auto_renew, provider_options, certificate_pem, private_key_pem,
|
||||
created_at, updated_at
|
||||
FROM certificates ORDER BY created_at DESC`
|
||||
)
|
||||
.all();
|
||||
return rows.map(parseCertificate);
|
||||
}
|
||||
|
||||
export function getCertificate(id: number): Certificate | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, name, type, domain_names, auto_renew, provider_options, certificate_pem, private_key_pem,
|
||||
created_at, updated_at
|
||||
FROM certificates WHERE id = ?`
|
||||
)
|
||||
.get(id);
|
||||
return row ? parseCertificate(row) : null;
|
||||
}
|
||||
|
||||
function validateCertificateInput(input: CertificateInput) {
|
||||
if (!input.domain_names || input.domain_names.length === 0) {
|
||||
throw new Error("At least one domain is required for a certificate");
|
||||
}
|
||||
if (input.type === "imported") {
|
||||
if (!input.certificate_pem || !input.private_key_pem) {
|
||||
throw new Error("Imported certificates require certificate and key PEM data");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCertificate(input: CertificateInput, actorUserId: number) {
|
||||
validateCertificateInput(input);
|
||||
const now = nowIso();
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO certificates (name, type, domain_names, auto_renew, provider_options, certificate_pem, private_key_pem,
|
||||
created_at, updated_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
input.name.trim(),
|
||||
input.type,
|
||||
JSON.stringify(Array.from(new Set(input.domain_names.map((domain) => domain.trim().toLowerCase())))),
|
||||
(input.auto_renew ?? true) ? 1 : 0,
|
||||
input.provider_options ? JSON.stringify(input.provider_options) : null,
|
||||
input.certificate_pem ?? null,
|
||||
input.private_key_pem ?? null,
|
||||
now,
|
||||
now,
|
||||
actorUserId
|
||||
);
|
||||
const id = Number(result.lastInsertRowid);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
entityType: "certificate",
|
||||
entityId: id,
|
||||
summary: `Created certificate ${input.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
return getCertificate(id)!;
|
||||
}
|
||||
|
||||
export async function updateCertificate(id: number, input: Partial<CertificateInput>, actorUserId: number) {
|
||||
const existing = getCertificate(id);
|
||||
if (!existing) {
|
||||
throw new Error("Certificate not found");
|
||||
}
|
||||
|
||||
const merged: CertificateInput = {
|
||||
name: input.name ?? existing.name,
|
||||
type: input.type ?? existing.type,
|
||||
domain_names: input.domain_names ?? existing.domain_names,
|
||||
auto_renew: input.auto_renew ?? existing.auto_renew,
|
||||
provider_options: input.provider_options ?? existing.provider_options,
|
||||
certificate_pem: input.certificate_pem ?? existing.certificate_pem,
|
||||
private_key_pem: input.private_key_pem ?? existing.private_key_pem
|
||||
};
|
||||
|
||||
validateCertificateInput(merged);
|
||||
|
||||
const now = nowIso();
|
||||
db.prepare(
|
||||
`UPDATE certificates
|
||||
SET name = ?, type = ?, domain_names = ?, auto_renew = ?, provider_options = ?, certificate_pem = ?, private_key_pem = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
merged.name.trim(),
|
||||
merged.type,
|
||||
JSON.stringify(Array.from(new Set(merged.domain_names))),
|
||||
merged.auto_renew ? 1 : 0,
|
||||
merged.provider_options ? JSON.stringify(merged.provider_options) : null,
|
||||
merged.certificate_pem ?? null,
|
||||
merged.private_key_pem ?? null,
|
||||
now,
|
||||
id
|
||||
);
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "update",
|
||||
entityType: "certificate",
|
||||
entityId: id,
|
||||
summary: `Updated certificate ${merged.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
return getCertificate(id)!;
|
||||
}
|
||||
|
||||
export async function deleteCertificate(id: number, actorUserId: number) {
|
||||
const existing = getCertificate(id);
|
||||
if (!existing) {
|
||||
throw new Error("Certificate not found");
|
||||
}
|
||||
|
||||
db.prepare("DELETE FROM certificates WHERE id = ?").run(id);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
entityType: "certificate",
|
||||
entityId: id,
|
||||
summary: `Deleted certificate ${existing.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import db, { nowIso } from "../db";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
|
||||
export type DeadHost = {
|
||||
id: number;
|
||||
name: string;
|
||||
domains: string[];
|
||||
status_code: number;
|
||||
response_body: string | null;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type DeadHostInput = {
|
||||
name: string;
|
||||
domains: string[];
|
||||
status_code?: number;
|
||||
response_body?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function parse(row: any): DeadHost {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
domains: JSON.parse(row.domains),
|
||||
status_code: row.status_code,
|
||||
response_body: row.response_body,
|
||||
enabled: Boolean(row.enabled),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function listDeadHosts(): DeadHost[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, name, domains, status_code, response_body, enabled, created_at, updated_at
|
||||
FROM dead_hosts ORDER BY created_at DESC`
|
||||
)
|
||||
.all();
|
||||
return rows.map(parse);
|
||||
}
|
||||
|
||||
export function getDeadHost(id: number): DeadHost | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, name, domains, status_code, response_body, enabled, created_at, updated_at
|
||||
FROM dead_hosts WHERE id = ?`
|
||||
)
|
||||
.get(id);
|
||||
return row ? parse(row) : null;
|
||||
}
|
||||
|
||||
export async function createDeadHost(input: DeadHostInput, actorUserId: number) {
|
||||
if (!input.domains || input.domains.length === 0) {
|
||||
throw new Error("At least one domain is required");
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO dead_hosts (name, domains, status_code, response_body, enabled, created_at, updated_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
input.name.trim(),
|
||||
JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
|
||||
input.status_code ?? 503,
|
||||
input.response_body ?? null,
|
||||
(input.enabled ?? true) ? 1 : 0,
|
||||
now,
|
||||
now,
|
||||
actorUserId
|
||||
);
|
||||
const id = Number(result.lastInsertRowid);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
entityType: "dead_host",
|
||||
entityId: id,
|
||||
summary: `Created dead host ${input.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
return getDeadHost(id)!;
|
||||
}
|
||||
|
||||
export async function updateDeadHost(id: number, input: Partial<DeadHostInput>, actorUserId: number) {
|
||||
const existing = getDeadHost(id);
|
||||
if (!existing) {
|
||||
throw new Error("Dead host not found");
|
||||
}
|
||||
const now = nowIso();
|
||||
db.prepare(
|
||||
`UPDATE dead_hosts
|
||||
SET name = ?, domains = ?, status_code = ?, response_body = ?, enabled = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
input.name ?? existing.name,
|
||||
JSON.stringify(input.domains ? Array.from(new Set(input.domains)) : existing.domains),
|
||||
input.status_code ?? existing.status_code,
|
||||
input.response_body ?? existing.response_body,
|
||||
(input.enabled ?? existing.enabled) ? 1 : 0,
|
||||
now,
|
||||
id
|
||||
);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "update",
|
||||
entityType: "dead_host",
|
||||
entityId: id,
|
||||
summary: `Updated dead host ${input.name ?? existing.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
return getDeadHost(id)!;
|
||||
}
|
||||
|
||||
export async function deleteDeadHost(id: number, actorUserId: number) {
|
||||
const existing = getDeadHost(id);
|
||||
if (!existing) {
|
||||
throw new Error("Dead host not found");
|
||||
}
|
||||
db.prepare("DELETE FROM dead_hosts WHERE id = ?").run(id);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
entityType: "dead_host",
|
||||
entityId: id,
|
||||
summary: `Deleted dead host ${existing.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import db, { nowIso } from "../db";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
import { logAuditEvent } from "../audit";
|
||||
|
||||
export type ProxyHost = {
|
||||
id: number;
|
||||
name: string;
|
||||
domains: string[];
|
||||
upstreams: string[];
|
||||
certificate_id: number | null;
|
||||
access_list_id: number | null;
|
||||
ssl_forced: boolean;
|
||||
hsts_enabled: boolean;
|
||||
hsts_subdomains: boolean;
|
||||
allow_websocket: boolean;
|
||||
preserve_host_header: boolean;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ProxyHostInput = {
|
||||
name: string;
|
||||
domains: string[];
|
||||
upstreams: string[];
|
||||
certificate_id?: number | null;
|
||||
access_list_id?: number | null;
|
||||
ssl_forced?: boolean;
|
||||
hsts_enabled?: boolean;
|
||||
hsts_subdomains?: boolean;
|
||||
allow_websocket?: boolean;
|
||||
preserve_host_header?: boolean;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function parseProxyHost(row: any): ProxyHost {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
domains: JSON.parse(row.domains),
|
||||
upstreams: JSON.parse(row.upstreams),
|
||||
certificate_id: row.certificate_id ?? null,
|
||||
access_list_id: row.access_list_id ?? null,
|
||||
ssl_forced: Boolean(row.ssl_forced),
|
||||
hsts_enabled: Boolean(row.hsts_enabled),
|
||||
hsts_subdomains: Boolean(row.hsts_subdomains),
|
||||
allow_websocket: Boolean(row.allow_websocket),
|
||||
preserve_host_header: Boolean(row.preserve_host_header),
|
||||
enabled: Boolean(row.enabled),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function listProxyHosts(): ProxyHost[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, name, domains, upstreams, certificate_id, access_list_id, ssl_forced, hsts_enabled,
|
||||
hsts_subdomains, allow_websocket, preserve_host_header, enabled, created_at, updated_at
|
||||
FROM proxy_hosts
|
||||
ORDER BY created_at DESC`
|
||||
)
|
||||
.all();
|
||||
return rows.map(parseProxyHost);
|
||||
}
|
||||
|
||||
export async function createProxyHost(input: ProxyHostInput, actorUserId: number) {
|
||||
if (!input.domains || input.domains.length === 0) {
|
||||
throw new Error("At least one domain must be specified");
|
||||
}
|
||||
if (!input.upstreams || input.upstreams.length === 0) {
|
||||
throw new Error("At least one upstream must be specified");
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
const tx = db.transaction(() => {
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO proxy_hosts
|
||||
(name, domains, upstreams, certificate_id, access_list_id, ssl_forced, hsts_enabled,
|
||||
hsts_subdomains, allow_websocket, preserve_host_header, enabled, created_at, updated_at, owner_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
input.name.trim(),
|
||||
JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
|
||||
JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))),
|
||||
input.certificate_id ?? null,
|
||||
input.access_list_id ?? null,
|
||||
input.ssl_forced ? 1 : 0,
|
||||
(input.hsts_enabled ?? true) ? 1 : 0,
|
||||
input.hsts_subdomains ? 1 : 0,
|
||||
(input.allow_websocket ?? true) ? 1 : 0,
|
||||
(input.preserve_host_header ?? true) ? 1 : 0,
|
||||
(input.enabled ?? true) ? 1 : 0,
|
||||
now,
|
||||
now,
|
||||
actorUserId
|
||||
);
|
||||
|
||||
const id = Number(result.lastInsertRowid);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
entityType: "proxy_host",
|
||||
entityId: id,
|
||||
summary: `Created proxy host ${input.name}`,
|
||||
data: input
|
||||
});
|
||||
|
||||
return id;
|
||||
});
|
||||
|
||||
const id = tx();
|
||||
await applyCaddyConfig();
|
||||
return getProxyHost(id)!;
|
||||
}
|
||||
|
||||
export function getProxyHost(id: number): ProxyHost | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, name, domains, upstreams, certificate_id, access_list_id, ssl_forced, hsts_enabled,
|
||||
hsts_subdomains, allow_websocket, preserve_host_header, enabled, created_at, updated_at
|
||||
FROM proxy_hosts WHERE id = ?`
|
||||
)
|
||||
.get(id);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return parseProxyHost(row);
|
||||
}
|
||||
|
||||
export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>, actorUserId: number) {
|
||||
const existing = getProxyHost(id);
|
||||
if (!existing) {
|
||||
throw new Error("Proxy host not found");
|
||||
}
|
||||
|
||||
const domains = input.domains ? JSON.stringify(Array.from(new Set(input.domains))) : JSON.stringify(existing.domains);
|
||||
const upstreams = input.upstreams ? JSON.stringify(Array.from(new Set(input.upstreams))) : JSON.stringify(existing.upstreams);
|
||||
|
||||
const now = nowIso();
|
||||
db.prepare(
|
||||
`UPDATE proxy_hosts
|
||||
SET name = ?, domains = ?, upstreams = ?, certificate_id = ?, access_list_id = ?, ssl_forced = ?, hsts_enabled = ?,
|
||||
hsts_subdomains = ?, allow_websocket = ?, preserve_host_header = ?, enabled = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
input.name ?? existing.name,
|
||||
domains,
|
||||
upstreams,
|
||||
input.certificate_id ?? existing.certificate_id,
|
||||
input.access_list_id ?? existing.access_list_id,
|
||||
(input.ssl_forced ?? existing.ssl_forced) ? 1 : 0,
|
||||
(input.hsts_enabled ?? existing.hsts_enabled) ? 1 : 0,
|
||||
(input.hsts_subdomains ?? existing.hsts_subdomains) ? 1 : 0,
|
||||
(input.allow_websocket ?? existing.allow_websocket) ? 1 : 0,
|
||||
(input.preserve_host_header ?? existing.preserve_host_header) ? 1 : 0,
|
||||
(input.enabled ?? existing.enabled) ? 1 : 0,
|
||||
now,
|
||||
id
|
||||
);
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "update",
|
||||
entityType: "proxy_host",
|
||||
entityId: id,
|
||||
summary: `Updated proxy host ${input.name ?? existing.name}`,
|
||||
data: input
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
return getProxyHost(id)!;
|
||||
}
|
||||
|
||||
export async function deleteProxyHost(id: number, actorUserId: number) {
|
||||
const existing = getProxyHost(id);
|
||||
if (!existing) {
|
||||
throw new Error("Proxy host not found");
|
||||
}
|
||||
|
||||
db.prepare("DELETE FROM proxy_hosts WHERE id = ?").run(id);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
entityType: "proxy_host",
|
||||
entityId: id,
|
||||
summary: `Deleted proxy host ${existing.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import db, { nowIso } from "../db";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
|
||||
export type RedirectHost = {
|
||||
id: number;
|
||||
name: string;
|
||||
domains: string[];
|
||||
destination: string;
|
||||
status_code: number;
|
||||
preserve_query: boolean;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type RedirectHostInput = {
|
||||
name: string;
|
||||
domains: string[];
|
||||
destination: string;
|
||||
status_code?: number;
|
||||
preserve_query?: boolean;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function parse(row: any): RedirectHost {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
domains: JSON.parse(row.domains),
|
||||
destination: row.destination,
|
||||
status_code: row.status_code,
|
||||
preserve_query: Boolean(row.preserve_query),
|
||||
enabled: Boolean(row.enabled),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function listRedirectHosts(): RedirectHost[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, name, domains, destination, status_code, preserve_query, enabled, created_at, updated_at
|
||||
FROM redirect_hosts ORDER BY created_at DESC`
|
||||
)
|
||||
.all();
|
||||
return rows.map(parse);
|
||||
}
|
||||
|
||||
export function getRedirectHost(id: number): RedirectHost | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, name, domains, destination, status_code, preserve_query, enabled, created_at, updated_at
|
||||
FROM redirect_hosts WHERE id = ?`
|
||||
)
|
||||
.get(id);
|
||||
return row ? parse(row) : null;
|
||||
}
|
||||
|
||||
export async function createRedirectHost(input: RedirectHostInput, actorUserId: number) {
|
||||
if (!input.domains || input.domains.length === 0) {
|
||||
throw new Error("At least one domain is required");
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO redirect_hosts (name, domains, destination, status_code, preserve_query, enabled, created_at, updated_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
input.name.trim(),
|
||||
JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
|
||||
input.destination.trim(),
|
||||
input.status_code ?? 302,
|
||||
input.preserve_query ? 1 : 0,
|
||||
(input.enabled ?? true) ? 1 : 0,
|
||||
now,
|
||||
now,
|
||||
actorUserId
|
||||
);
|
||||
const id = Number(result.lastInsertRowid);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
entityType: "redirect_host",
|
||||
entityId: id,
|
||||
summary: `Created redirect ${input.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
return getRedirectHost(id)!;
|
||||
}
|
||||
|
||||
export async function updateRedirectHost(id: number, input: Partial<RedirectHostInput>, actorUserId: number) {
|
||||
const existing = getRedirectHost(id);
|
||||
if (!existing) {
|
||||
throw new Error("Redirect host not found");
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
db.prepare(
|
||||
`UPDATE redirect_hosts
|
||||
SET name = ?, domains = ?, destination = ?, status_code = ?, preserve_query = ?, enabled = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
input.name ?? existing.name,
|
||||
JSON.stringify(input.domains ? Array.from(new Set(input.domains)) : existing.domains),
|
||||
input.destination ?? existing.destination,
|
||||
input.status_code ?? existing.status_code,
|
||||
(input.preserve_query ?? existing.preserve_query) ? 1 : 0,
|
||||
(input.enabled ?? existing.enabled) ? 1 : 0,
|
||||
now,
|
||||
id
|
||||
);
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "update",
|
||||
entityType: "redirect_host",
|
||||
entityId: id,
|
||||
summary: `Updated redirect ${input.name ?? existing.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
return getRedirectHost(id)!;
|
||||
}
|
||||
|
||||
export async function deleteRedirectHost(id: number, actorUserId: number) {
|
||||
const existing = getRedirectHost(id);
|
||||
if (!existing) {
|
||||
throw new Error("Redirect host not found");
|
||||
}
|
||||
db.prepare("DELETE FROM redirect_hosts WHERE id = ?").run(id);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
entityType: "redirect_host",
|
||||
entityId: id,
|
||||
summary: `Deleted redirect ${existing.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import db, { nowIso } from "../db";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
|
||||
export type StreamHost = {
|
||||
id: number;
|
||||
name: string;
|
||||
listen_port: number;
|
||||
protocol: string;
|
||||
upstream: string;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type StreamHostInput = {
|
||||
name: string;
|
||||
listen_port: number;
|
||||
protocol: string;
|
||||
upstream: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function parse(row: any): StreamHost {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
listen_port: row.listen_port,
|
||||
protocol: row.protocol,
|
||||
upstream: row.upstream,
|
||||
enabled: Boolean(row.enabled),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function listStreamHosts(): StreamHost[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, name, listen_port, protocol, upstream, enabled, created_at, updated_at
|
||||
FROM stream_hosts ORDER BY created_at DESC`
|
||||
)
|
||||
.all();
|
||||
return rows.map(parse);
|
||||
}
|
||||
|
||||
export function getStreamHost(id: number): StreamHost | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, name, listen_port, protocol, upstream, enabled, created_at, updated_at
|
||||
FROM stream_hosts WHERE id = ?`
|
||||
)
|
||||
.get(id);
|
||||
return row ? parse(row) : null;
|
||||
}
|
||||
|
||||
function assertProtocol(protocol: string) {
|
||||
if (!["tcp", "udp"].includes(protocol.toLowerCase())) {
|
||||
throw new Error("Protocol must be tcp or udp");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createStreamHost(input: StreamHostInput, actorUserId: number) {
|
||||
assertProtocol(input.protocol);
|
||||
const now = nowIso();
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO stream_hosts (name, listen_port, protocol, upstream, enabled, created_at, updated_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
input.name.trim(),
|
||||
input.listen_port,
|
||||
input.protocol.toLowerCase(),
|
||||
input.upstream.trim(),
|
||||
(input.enabled ?? true) ? 1 : 0,
|
||||
now,
|
||||
now,
|
||||
actorUserId
|
||||
);
|
||||
const id = Number(result.lastInsertRowid);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
entityType: "stream_host",
|
||||
entityId: id,
|
||||
summary: `Created stream ${input.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
return getStreamHost(id)!;
|
||||
}
|
||||
|
||||
export async function updateStreamHost(id: number, input: Partial<StreamHostInput>, actorUserId: number) {
|
||||
const existing = getStreamHost(id);
|
||||
if (!existing) {
|
||||
throw new Error("Stream host not found");
|
||||
}
|
||||
const protocol = input.protocol ? input.protocol.toLowerCase() : existing.protocol;
|
||||
assertProtocol(protocol);
|
||||
const now = nowIso();
|
||||
db.prepare(
|
||||
`UPDATE stream_hosts
|
||||
SET name = ?, listen_port = ?, protocol = ?, upstream = ?, enabled = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
input.name ?? existing.name,
|
||||
input.listen_port ?? existing.listen_port,
|
||||
protocol,
|
||||
input.upstream ?? existing.upstream,
|
||||
(input.enabled ?? existing.enabled) ? 1 : 0,
|
||||
now,
|
||||
id
|
||||
);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "update",
|
||||
entityType: "stream_host",
|
||||
entityId: id,
|
||||
summary: `Updated stream ${input.name ?? existing.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
return getStreamHost(id)!;
|
||||
}
|
||||
|
||||
export async function deleteStreamHost(id: number, actorUserId: number) {
|
||||
const existing = getStreamHost(id);
|
||||
if (!existing) {
|
||||
throw new Error("Stream host not found");
|
||||
}
|
||||
db.prepare("DELETE FROM stream_hosts WHERE id = ?").run(id);
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
entityType: "stream_host",
|
||||
entityId: id,
|
||||
summary: `Deleted stream ${existing.name}`
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import db, { nowIso } from "../db";
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: "admin" | "user" | "viewer";
|
||||
provider: string;
|
||||
subject: string;
|
||||
avatar_url: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export function getUserById(userId: number): User | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, email, name, role, provider, subject, avatar_url, status, created_at, updated_at
|
||||
FROM users WHERE id = ?`
|
||||
)
|
||||
.get(userId) as User | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function getUserCount(): number {
|
||||
const row = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number };
|
||||
return Number(row.count);
|
||||
}
|
||||
|
||||
export function findUserByProviderSubject(provider: string, subject: string): User | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, email, name, role, provider, subject, avatar_url, status, created_at, updated_at
|
||||
FROM users WHERE provider = ? AND subject = ?`
|
||||
)
|
||||
.get(provider, subject) as User | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function findUserByEmail(email: string): User | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, email, name, role, provider, subject, avatar_url, status, created_at, updated_at
|
||||
FROM users WHERE email = ?`
|
||||
)
|
||||
.get(email) as User | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function createUser(data: {
|
||||
email: string;
|
||||
name?: string | null;
|
||||
role?: User["role"];
|
||||
provider: string;
|
||||
subject: string;
|
||||
avatar_url?: string | null;
|
||||
}): User {
|
||||
const now = nowIso();
|
||||
const role = data.role ?? "user";
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO users (email, name, role, provider, subject, avatar_url, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`
|
||||
);
|
||||
const info = stmt.run(data.email, data.name ?? null, role, data.provider, data.subject, data.avatar_url ?? null, now, now);
|
||||
|
||||
return {
|
||||
id: Number(info.lastInsertRowid),
|
||||
email: data.email,
|
||||
name: data.name ?? null,
|
||||
role,
|
||||
provider: data.provider,
|
||||
subject: data.subject,
|
||||
avatar_url: data.avatar_url ?? null,
|
||||
status: "active",
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
};
|
||||
}
|
||||
|
||||
export function updateUserProfile(userId: number, data: { email?: string; name?: string | null; avatar_url?: string | null }): User | null {
|
||||
const current = getUserById(userId);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
const nextEmail = data.email ?? current.email;
|
||||
const nextName = data.name ?? current.name;
|
||||
const nextAvatar = data.avatar_url ?? current.avatar_url;
|
||||
const now = nowIso();
|
||||
db.prepare(
|
||||
`UPDATE users
|
||||
SET email = ?, name = ?, avatar_url = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(nextEmail, nextName, nextAvatar, now, userId);
|
||||
|
||||
return {
|
||||
...current,
|
||||
email: nextEmail,
|
||||
name: nextName,
|
||||
avatar_url: nextAvatar,
|
||||
updated_at: now
|
||||
};
|
||||
}
|
||||
|
||||
export function listUsers(): User[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, email, name, role, provider, subject, avatar_url, status, created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY created_at ASC`
|
||||
)
|
||||
.all() as User[];
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function promoteToAdmin(userId: number) {
|
||||
const now = nowIso();
|
||||
db.prepare("UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?").run(now, userId);
|
||||
}
|
||||
Reference in New Issue
Block a user