Rewritten to use drizzle instead of prisma

commit c0894548dac5133bd89da5b68684443748fa2559
Author: fuomag9 <1580624+fuomag9@users.noreply.github.com>
Date:   Fri Nov 7 18:38:30 2025 +0100

    Update config.ts

commit 5a4f1159d2123ada0f698a10011c24720bf6ea6f
Author: fuomag9 <1580624+fuomag9@users.noreply.github.com>
Date:   Fri Nov 7 15:58:13 2025 +0100

    first drizzle rewrite
This commit is contained in:
fuomag9
2025-11-07 19:26:32 +01:00
parent 20a72008ac
commit 3be4e1bf7d
27 changed files with 3258 additions and 1148 deletions

View File

@@ -1,4 +1,5 @@
import prisma, { nowIso } from "./db";
import db, { nowIso } from "./db";
import { auditEvents } from "./db/schema";
export function logAuditEvent(params: {
userId?: number | null;
@@ -8,18 +9,18 @@ export function logAuditEvent(params: {
summary?: string | null;
data?: unknown;
}) {
prisma.auditEvent.create({
data: {
try {
db.insert(auditEvents).values({
userId: params.userId ?? null,
action: params.action,
entityType: params.entityType,
entityId: params.entityId ?? null,
summary: params.summary ?? null,
data: params.data ? JSON.stringify(params.data) : null,
createdAt: new Date(nowIso())
}
}).catch((error: unknown) => {
createdAt: nowIso()
}).run();
} catch (error) {
// Log error but don't throw to avoid breaking the main flow
console.error("Failed to log audit event:", error);
});
}
}

View File

@@ -1,8 +1,9 @@
import NextAuth, { type DefaultSession } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import prisma from "./db";
import db from "./db";
import { config } from "./config";
import { users } from "./db/schema";
declare module "next-auth" {
interface Session {
@@ -36,8 +37,8 @@ function createCredentialsProvider() {
// Look up user in database by email (constructed from username)
const email = `${username}@localhost`;
const user = await prisma.user.findUnique({
where: { email }
const user = await db.query.users.findFirst({
where: (table, operators) => operators.eq(table.email, email)
});
if (!user || user.status !== "active" || !user.passwordHash) {

View File

@@ -1,9 +1,16 @@
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import crypto from "node:crypto";
import prisma, { nowIso } from "./db";
import db, { nowIso } from "./db";
import { config } from "./config";
import { getCloudflareSettings, getGeneralSettings, setSetting } from "./settings";
import {
accessListEntries,
certificates,
deadHosts,
proxyHosts,
redirectHosts
} from "./db/schema";
const CERTS_DIR = process.env.CERTS_DIRECTORY || join(process.cwd(), "data", "certs");
mkdirSync(CERTS_DIR, { recursive: true, mode: 0o700 });
@@ -721,69 +728,68 @@ async function buildTlsAutomation(
}
async function buildCaddyDocument() {
const [proxyHosts, redirectHosts, deadHosts, certRows, accessListEntries] = await Promise.all([
prisma.proxyHost.findMany({
select: {
id: true,
name: true,
domains: true,
upstreams: true,
certificateId: true,
accessListId: true,
sslForced: true,
hstsEnabled: true,
hstsSubdomains: true,
allowWebsocket: true,
preserveHostHeader: true,
skipHttpsHostnameValidation: true,
meta: true,
enabled: true
}
}),
prisma.redirectHost.findMany({
select: {
id: true,
name: true,
domains: true,
destination: true,
statusCode: true,
preserveQuery: true,
enabled: true
}
}),
prisma.deadHost.findMany({
select: {
id: true,
name: true,
domains: true,
statusCode: true,
responseBody: true,
enabled: true
}
}),
prisma.certificate.findMany({
select: {
id: true,
name: true,
type: true,
domainNames: true,
certificatePem: true,
privateKeyPem: true,
autoRenew: true,
providerOptions: true
}
}),
prisma.accessListEntry.findMany({
select: {
accessListId: true,
username: true,
passwordHash: true
}
})
const [proxyHostRecords, redirectHostRecords, deadHostRecords, certRows, accessListEntryRecords] = await Promise.all([
db
.select({
id: proxyHosts.id,
name: proxyHosts.name,
domains: proxyHosts.domains,
upstreams: proxyHosts.upstreams,
certificateId: proxyHosts.certificateId,
accessListId: proxyHosts.accessListId,
sslForced: proxyHosts.sslForced,
hstsEnabled: proxyHosts.hstsEnabled,
hstsSubdomains: proxyHosts.hstsSubdomains,
allowWebsocket: proxyHosts.allowWebsocket,
preserveHostHeader: proxyHosts.preserveHostHeader,
skipHttpsHostnameValidation: proxyHosts.skipHttpsHostnameValidation,
meta: proxyHosts.meta,
enabled: proxyHosts.enabled
})
.from(proxyHosts),
db
.select({
id: redirectHosts.id,
name: redirectHosts.name,
domains: redirectHosts.domains,
destination: redirectHosts.destination,
statusCode: redirectHosts.statusCode,
preserveQuery: redirectHosts.preserveQuery,
enabled: redirectHosts.enabled
})
.from(redirectHosts),
db
.select({
id: deadHosts.id,
name: deadHosts.name,
domains: deadHosts.domains,
statusCode: deadHosts.statusCode,
responseBody: deadHosts.responseBody,
enabled: deadHosts.enabled
})
.from(deadHosts),
db
.select({
id: certificates.id,
name: certificates.name,
type: certificates.type,
domainNames: certificates.domainNames,
certificatePem: certificates.certificatePem,
privateKeyPem: certificates.privateKeyPem,
autoRenew: certificates.autoRenew,
providerOptions: certificates.providerOptions
})
.from(certificates),
db
.select({
accessListId: accessListEntries.accessListId,
username: accessListEntries.username,
passwordHash: accessListEntries.passwordHash
})
.from(accessListEntries)
]);
// Map Prisma results to expected types
const proxyHostRows: ProxyHostRow[] = proxyHosts.map((h: typeof proxyHosts[0]) => ({
const proxyHostRows: ProxyHostRow[] = proxyHostRecords.map((h) => ({
id: h.id,
name: h.name,
domains: h.domains,
@@ -800,7 +806,7 @@ async function buildCaddyDocument() {
enabled: h.enabled ? 1 : 0
}));
const redirectHostRows: RedirectHostRow[] = redirectHosts.map((h: typeof redirectHosts[0]) => ({
const redirectHostRows: RedirectHostRow[] = redirectHostRecords.map((h) => ({
id: h.id,
name: h.name,
domains: h.domains,
@@ -810,7 +816,7 @@ async function buildCaddyDocument() {
enabled: h.enabled ? 1 : 0
}));
const deadHostRows: DeadHostRow[] = deadHosts.map((h: typeof deadHosts[0]) => ({
const deadHostRows: DeadHostRow[] = deadHostRecords.map((h) => ({
id: h.id,
name: h.name,
domains: h.domains,
@@ -830,10 +836,10 @@ async function buildCaddyDocument() {
provider_options: c.providerOptions
}));
const accessListEntryRows: AccessListEntryRow[] = accessListEntries.map((e: typeof accessListEntries[0]) => ({
access_list_id: e.accessListId,
username: e.username,
password_hash: e.passwordHash
const accessListEntryRows: AccessListEntryRow[] = accessListEntryRecords.map((entry) => ({
access_list_id: entry.accessListId,
username: entry.username,
password_hash: entry.passwordHash
}));
const certificateMap = new Map(certRowsMapped.map((cert) => [cert.id, cert]));

View File

@@ -1,62 +1,127 @@
const DEV_SECRET = "dev-secret-change-in-production-12345678901234567890123456789012";
const DEFAULT_ADMIN_USERNAME = "admin";
const DEFAULT_ADMIN_PASSWORD = "admin";
const DISALLOWED_SESSION_SECRETS = new Set([DEV_SECRET, "change-me-in-production"]);
const DISALLOWED_SESSION_SECRETS = new Set([
"change-me-in-production",
"dev-secret-change-in-production-12345678901234567890123456789012"
]);
const DEFAULT_CADDY_URL = process.env.NODE_ENV === "development" ? "http://localhost:2019" : "http://caddy:2019";
const MIN_SESSION_SECRET_LENGTH = 32;
const MIN_ADMIN_PASSWORD_LENGTH = 12;
const isProduction = process.env.NODE_ENV === "production";
const isNodeRuntime = process.env.NEXT_RUNTIME === "nodejs";
const allowDevFallback = !isProduction || !isNodeRuntime;
const isRuntimeProduction = isProduction && isNodeRuntime;
const isDevelopment = process.env.NODE_ENV === "development";
// Only enforce strict validation in actual production runtime, not during build
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build" || !process.env.NEXT_RUNTIME;
const isRuntimeProduction = isProduction && isNodeRuntime && !isBuildPhase;
function resolveSessionSecret(): string {
const rawSecret = process.env.SESSION_SECRET ?? null;
const secret = rawSecret?.trim();
// Always return a value (build phase needs this)
if (!secret) {
// In development, allow missing secret
if (isDevelopment && !secret) {
return DEV_SECRET;
}
// Only validate in actual runtime production (not during build)
// In production build phase, allow temporary value
if (isProduction && !isNodeRuntime && !secret) {
return DEV_SECRET;
}
// Use provided secret or dev secret
const finalSecret = secret || DEV_SECRET;
// Strict validation in production runtime
if (isRuntimeProduction) {
if (!secret) {
throw new Error(
"SESSION_SECRET environment variable is required in production. " +
"Generate a secure secret with: openssl rand -base64 32"
);
}
if (DISALLOWED_SESSION_SECRETS.has(secret)) {
throw new Error("SESSION_SECRET is using a known insecure placeholder value. Provide a unique secret.");
throw new Error(
"SESSION_SECRET is using a known insecure placeholder value. " +
"Generate a secure secret with: openssl rand -base64 32"
);
}
if (secret.length < MIN_SESSION_SECRET_LENGTH) {
throw new Error(`SESSION_SECRET must be at least ${MIN_SESSION_SECRET_LENGTH} characters long in production.`);
throw new Error(
`SESSION_SECRET must be at least ${MIN_SESSION_SECRET_LENGTH} characters long in production. ` +
"Generate a secure secret with: openssl rand -base64 32"
);
}
}
return secret;
return finalSecret;
}
function resolveAdminCredentials() {
const rawUsername = process.env.ADMIN_USERNAME ?? DEFAULT_ADMIN_USERNAME;
const rawPassword = process.env.ADMIN_PASSWORD ?? DEFAULT_ADMIN_PASSWORD;
const username = rawUsername?.trim();
const password = rawPassword?.trim();
const rawUsername = process.env.ADMIN_USERNAME ?? null;
const rawPassword = process.env.ADMIN_PASSWORD ?? null;
const username = rawUsername?.trim() || DEFAULT_ADMIN_USERNAME;
const password = rawPassword?.trim() || DEFAULT_ADMIN_PASSWORD;
// Always return values (build phase needs this)
if (!username || !password) {
return { username: DEFAULT_ADMIN_USERNAME, password: DEFAULT_ADMIN_PASSWORD };
// In development, allow defaults
if (isDevelopment) {
if (username === DEFAULT_ADMIN_USERNAME || password === DEFAULT_ADMIN_PASSWORD) {
console.log("Using default admin credentials for development (admin/admin)");
}
return { username, password };
}
// Only validate in actual runtime production (not during build)
// In production build phase, allow defaults temporarily
if (isProduction && !isNodeRuntime) {
return { username, password };
}
// Strict validation in production runtime
if (isRuntimeProduction) {
if (username === DEFAULT_ADMIN_USERNAME) {
throw new Error("ADMIN_USERNAME must be changed from the default value when running in production.");
const errors: string[] = [];
// Username validation - just ensure it's set
if (!rawUsername || !username) {
errors.push(
"ADMIN_USERNAME must be set"
);
}
if (password === DEFAULT_ADMIN_PASSWORD) {
throw new Error("ADMIN_PASSWORD must be changed from the default value when running in production.");
// Password validation - strict requirements
if (!rawPassword || password === DEFAULT_ADMIN_PASSWORD) {
errors.push(
"ADMIN_PASSWORD must be set to a custom value in production (not 'admin')"
);
} else {
if (password.length < MIN_ADMIN_PASSWORD_LENGTH) {
errors.push(
`ADMIN_PASSWORD must be at least ${MIN_ADMIN_PASSWORD_LENGTH} characters long`
);
}
if (!/[A-Z]/.test(password) || !/[a-z]/.test(password)) {
errors.push(
"ADMIN_PASSWORD must include both uppercase and lowercase letters"
);
}
if (!/[0-9]/.test(password)) {
errors.push(
"ADMIN_PASSWORD must include at least one number"
);
}
if (!/[^A-Za-z0-9]/.test(password)) {
errors.push(
"ADMIN_PASSWORD must include at least one special character"
);
}
}
if (password.length < MIN_ADMIN_PASSWORD_LENGTH) {
throw new Error(`ADMIN_PASSWORD must be at least ${MIN_ADMIN_PASSWORD_LENGTH} characters long in production.`);
}
if (!/[A-Za-z]/.test(password) || !/[0-9]/.test(password)) {
throw new Error("ADMIN_PASSWORD must include both letters and numbers for adequate complexity.");
if (errors.length > 0) {
throw new Error(
"Admin credentials validation failed:\n" +
errors.map(e => ` - ${e}`).join("\n") +
"\n\nSet secure credentials using ADMIN_USERNAME and ADMIN_PASSWORD environment variables."
);
}
}

View File

@@ -1,20 +1,128 @@
import { PrismaClient } from "@prisma/client";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { mkdirSync } from "node:fs";
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
import * as schema from "./db/schema";
// Prevent multiple instances of Prisma Client in development
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
const DEFAULT_SQLITE_URL = "file:./data/caddy-proxy-manager.db";
type GlobalForDrizzle = typeof globalThis & {
__DRIZZLE_DB__?: ReturnType<typeof drizzle<typeof schema>>;
__SQLITE_CLIENT__?: Database.Database;
__MIGRATIONS_RAN__?: boolean;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
function resolveSqlitePath(rawUrl: string): string {
if (!rawUrl) {
return ":memory:";
}
if (rawUrl === ":memory:" || rawUrl === "file::memory:") {
return ":memory:";
}
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
if (rawUrl.startsWith("file:./") || rawUrl.startsWith("file:../")) {
const relative = rawUrl.slice("file:".length);
return resolvePath(process.cwd(), relative);
}
export default prisma;
if (rawUrl.startsWith("file:")) {
try {
const fileUrl = new URL(rawUrl);
if (fileUrl.host && fileUrl.host !== "localhost") {
throw new Error("Remote SQLite hosts are not supported.");
}
return decodeURIComponent(fileUrl.pathname);
} catch {
const remainder = rawUrl.slice("file:".length);
if (!remainder) {
return ":memory:";
}
return isAbsolute(remainder) ? remainder : resolvePath(process.cwd(), remainder);
}
}
return isAbsolute(rawUrl) ? rawUrl : resolvePath(process.cwd(), rawUrl);
}
const databaseUrl = process.env.DATABASE_URL ?? DEFAULT_SQLITE_URL;
const sqlitePath = resolveSqlitePath(databaseUrl);
function ensureDirectoryFor(pathname: string) {
if (pathname === ":memory:") {
return;
}
const dir = dirname(pathname);
mkdirSync(dir, { recursive: true });
}
const globalForDrizzle = globalThis as GlobalForDrizzle;
const sqlite =
globalForDrizzle.__SQLITE_CLIENT__ ??
(() => {
ensureDirectoryFor(sqlitePath);
return new Database(sqlitePath);
})();
if (process.env.NODE_ENV !== "production") {
globalForDrizzle.__SQLITE_CLIENT__ = sqlite;
}
export const db =
globalForDrizzle.__DRIZZLE_DB__ ?? drizzle(sqlite, { schema, casing: "snake_case" });
if (process.env.NODE_ENV !== "production") {
globalForDrizzle.__DRIZZLE_DB__ = db;
}
const migrationsFolder = resolvePath(process.cwd(), "drizzle");
function runMigrations() {
if (sqlitePath === ":memory:") {
return;
}
if (globalForDrizzle.__MIGRATIONS_RAN__) {
return;
}
try {
migrate(db, { migrationsFolder });
globalForDrizzle.__MIGRATIONS_RAN__ = true;
} catch (error: any) {
// During build, pages may be pre-rendered in parallel, causing race conditions
// with migrations. If tables already exist, just continue.
if (error?.code === 'SQLITE_ERROR' && error?.message?.includes('already exists')) {
console.log('Database tables already exist, skipping migrations');
globalForDrizzle.__MIGRATIONS_RAN__ = true;
return;
}
throw error;
}
}
try {
runMigrations();
} catch (error) {
console.error("Failed to run database migrations:", error);
// In build mode, allow the build to continue even if migrations fail
// The runtime initialization will handle migrations properly
if (process.env.NODE_ENV !== 'production' || process.env.NEXT_PHASE === 'phase-production-build') {
console.warn('Continuing despite migration error during build phase');
} else {
throw error;
}
}
export { schema };
export default db;
export function nowIso(): string {
return new Date().toISOString();
}
export function toIso(value: string | Date | null | undefined): string | null {
if (!value) {
return null;
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}

175
src/lib/db/schema.ts Normal file
View File

@@ -0,0 +1,175 @@
import { integer, text, sqliteTable, uniqueIndex, index } from "drizzle-orm/sqlite-core";
export const users = sqliteTable(
"users",
{
id: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull(),
name: text("name"),
passwordHash: text("password_hash"),
role: text("role").notNull().default("user"),
provider: text("provider").notNull(),
subject: text("subject").notNull(),
avatarUrl: text("avatar_url"),
status: text("status").notNull().default("active"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({
emailUnique: uniqueIndex("users_email_unique").on(table.email),
providerSubjectIdx: index("users_provider_subject_idx").on(table.provider, table.subject)
})
);
export const sessions = sqliteTable(
"sessions",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
token: text("token").notNull(),
expiresAt: text("expires_at").notNull(),
createdAt: text("created_at").notNull()
},
(table) => ({
tokenUnique: uniqueIndex("sessions_token_unique").on(table.token)
})
);
export const oauthStates = sqliteTable(
"oauth_states",
{
id: integer("id").primaryKey({ autoIncrement: true }),
state: text("state").notNull(),
codeVerifier: text("code_verifier").notNull(),
redirectTo: text("redirect_to"),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at").notNull()
},
(table) => ({
stateUnique: uniqueIndex("oauth_state_unique").on(table.state)
})
);
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: text("updated_at").notNull()
});
export const accessLists = sqliteTable("access_lists", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
description: text("description"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});
export const accessListEntries = sqliteTable(
"access_list_entries",
{
id: integer("id").primaryKey({ autoIncrement: true }),
accessListId: integer("access_list_id")
.references(() => accessLists.id, { onDelete: "cascade" })
.notNull(),
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({
accessListIdIdx: index("access_list_entries_list_idx").on(table.accessListId)
})
);
export const certificates = sqliteTable("certificates", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
type: text("type").notNull(),
domainNames: text("domain_names").notNull(),
autoRenew: integer("auto_renew", { mode: "boolean" }).notNull().default(true),
providerOptions: text("provider_options"),
certificatePem: text("certificate_pem"),
privateKeyPem: text("private_key_pem"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});
export const proxyHosts = sqliteTable("proxy_hosts", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
domains: text("domains").notNull(),
upstreams: text("upstreams").notNull(),
certificateId: integer("certificate_id").references(() => certificates.id, { onDelete: "set null" }),
accessListId: integer("access_list_id").references(() => accessLists.id, { onDelete: "set null" }),
ownerUserId: integer("owner_user_id").references(() => users.id, { onDelete: "set null" }),
sslForced: integer("ssl_forced", { mode: "boolean" }).notNull().default(true),
hstsEnabled: integer("hsts_enabled", { mode: "boolean" }).notNull().default(true),
hstsSubdomains: integer("hsts_subdomains", { mode: "boolean" }).notNull().default(false),
allowWebsocket: integer("allow_websocket", { mode: "boolean" }).notNull().default(true),
preserveHostHeader: integer("preserve_host_header", { mode: "boolean" }).notNull().default(true),
meta: text("meta"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
skipHttpsHostnameValidation: integer("skip_https_hostname_validation", { mode: "boolean" })
.notNull()
.default(false)
});
export const redirectHosts = sqliteTable("redirect_hosts", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
domains: text("domains").notNull(),
destination: text("destination").notNull(),
statusCode: integer("status_code").notNull().default(302),
preserveQuery: integer("preserve_query", { mode: "boolean" }).notNull().default(true),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});
export const deadHosts = sqliteTable("dead_hosts", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
domains: text("domains").notNull(),
statusCode: integer("status_code").notNull().default(503),
responseBody: text("response_body"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});
export const apiTokens = sqliteTable(
"api_tokens",
{
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
tokenHash: text("token_hash").notNull(),
createdBy: integer("created_by")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
createdAt: text("created_at").notNull(),
lastUsedAt: text("last_used_at"),
expiresAt: text("expires_at")
},
(table) => ({
tokenHashUnique: uniqueIndex("api_tokens_token_hash_unique").on(table.tokenHash)
})
);
export const auditEvents = sqliteTable("audit_events", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").references(() => users.id, { onDelete: "set null" }),
action: text("action").notNull(),
entityType: text("entity_type").notNull(),
entityId: integer("entity_id"),
summary: text("summary"),
data: text("data"),
createdAt: text("created_at").notNull()
});

View File

@@ -1,6 +1,8 @@
import bcrypt from "bcryptjs";
import prisma, { nowIso } from "./db";
import db, { nowIso } from "./db";
import { config } from "./config";
import { users } from "./db/schema";
import { eq } from "drizzle-orm";
/**
* Ensures the admin user from environment variables exists in the database.
@@ -17,43 +19,41 @@ export async function ensureAdminUser(): Promise<void> {
const passwordHash = bcrypt.hashSync(config.adminPassword, 12);
// Check if admin user already exists
const existingUser = await prisma.user.findUnique({
where: { id: adminId }
const existingUser = await db.query.users.findFirst({
where: (table, { eq }) => eq(table.id, adminId)
});
if (existingUser) {
// Admin user exists, update credentials if needed
// Always update password hash to handle password changes in env vars
const now = new Date(nowIso());
await prisma.user.update({
where: { id: adminId },
data: {
const now = nowIso();
await db
.update(users)
.set({
email: adminEmail,
subject,
passwordHash,
updatedAt: now
}
});
})
.where(eq(users.id, adminId));
console.log(`Updated admin user: ${config.adminUsername}`);
return;
}
// Create admin user with hashed password
const now = new Date(nowIso());
await prisma.user.create({
data: {
id: adminId,
email: adminEmail,
name: config.adminUsername,
passwordHash, // Store hashed password instead of plaintext
role: "admin",
provider,
subject,
avatarUrl: null,
status: "active",
createdAt: now,
updatedAt: now
}
const now = nowIso();
await db.insert(users).values({
id: adminId,
email: adminEmail,
name: config.adminUsername,
passwordHash,
role: "admin",
provider,
subject,
avatarUrl: null,
status: "active",
createdAt: now,
updatedAt: now
});
console.log(`Created admin user: ${config.adminUsername}`);

View File

@@ -1,7 +1,9 @@
import bcrypt from "bcryptjs";
import prisma, { nowIso } from "../db";
import { logAuditEvent } from "../audit";
import db, { nowIso, toIso } from "../db";
import { applyCaddyConfig } from "../caddy";
import { logAuditEvent } from "../audit";
import { accessListEntries, accessLists } from "../db/schema";
import { asc, eq, inArray } from "drizzle-orm";
export type AccessListEntry = {
id: number;
@@ -25,94 +27,101 @@ export type AccessListInput = {
users?: { username: string; password: string }[];
};
function toAccessList(
row: {
id: number;
name: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
},
entries: {
id: number;
username: string;
createdAt: Date;
updatedAt: Date;
}[]
): AccessList {
type AccessListRow = typeof accessLists.$inferSelect;
type AccessListEntryRow = typeof accessListEntries.$inferSelect;
function buildEntry(row: AccessListEntryRow): AccessListEntry {
return {
id: row.id,
username: row.username,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
};
}
function toAccessList(row: AccessListRow, entries: AccessListEntryRow[]): AccessList {
return {
id: row.id,
name: row.name,
description: row.description,
entries: entries.map((entry) => ({
id: entry.id,
username: entry.username,
created_at: entry.createdAt.toISOString(),
updated_at: entry.updatedAt.toISOString()
})),
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString()
entries: entries
.slice()
.sort((a, b) => a.username.localeCompare(b.username))
.map(buildEntry),
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
};
}
export async function listAccessLists(): Promise<AccessList[]> {
const lists = await prisma.accessList.findMany({
orderBy: { name: "asc" },
include: {
entries: {
select: {
id: true,
username: true,
createdAt: true,
updatedAt: true
},
orderBy: { username: "asc" }
}
}
const lists = await db.query.accessLists.findMany({
orderBy: (table) => asc(table.name)
});
return lists.map((list: typeof lists[0]) => toAccessList(list, list.entries));
if (lists.length === 0) {
return [];
}
const listIds = lists.map((list) => list.id);
const entries = await db
.select()
.from(accessListEntries)
.where(inArray(accessListEntries.accessListId, listIds));
const entriesByList = new Map<number, AccessListEntryRow[]>();
for (const entry of entries) {
const bucket = entriesByList.get(entry.accessListId) ?? [];
bucket.push(entry);
entriesByList.set(entry.accessListId, bucket);
}
return lists.map((list) => toAccessList(list, entriesByList.get(list.id) ?? []));
}
export async function getAccessList(id: number): Promise<AccessList | null> {
const list = await prisma.accessList.findUnique({
where: { id },
include: {
entries: {
select: {
id: true,
username: true,
createdAt: true,
updatedAt: true
},
orderBy: { username: "asc" }
}
}
const list = await db.query.accessLists.findFirst({
where: (table, operators) => operators.eq(table.id, id)
});
return list ? toAccessList(list, list.entries) : null;
if (!list) {
return null;
}
const entries = await db
.select()
.from(accessListEntries)
.where(eq(accessListEntries.accessListId, id))
.orderBy(asc(accessListEntries.username));
return toAccessList(list, entries);
}
export async function createAccessList(input: AccessListInput, actorUserId: number) {
const now = new Date(nowIso());
const now = nowIso();
const accessList = await prisma.accessList.create({
data: {
const [accessList] = await db
.insert(accessLists)
.values({
name: input.name.trim(),
description: input.description ?? null,
createdBy: actorUserId,
createdAt: now,
updatedAt: now,
entries: input.users
? {
create: input.users.map((account) => ({
username: account.username,
passwordHash: bcrypt.hashSync(account.password, 10),
createdAt: now,
updatedAt: now
}))
}
: undefined
}
});
updatedAt: now
})
.returning();
if (!accessList) {
throw new Error("Failed to create access list");
}
if (input.users && input.users.length > 0) {
await db.insert(accessListEntries).values(
input.users.map((account) => ({
accessListId: accessList.id,
username: account.username,
passwordHash: bcrypt.hashSync(account.password, 10),
createdAt: now,
updatedAt: now
}))
);
}
logAuditEvent({
userId: actorUserId,
@@ -136,15 +145,15 @@ export async function updateAccessList(
throw new Error("Access list not found");
}
const now = new Date(nowIso());
await prisma.accessList.update({
where: { id },
data: {
const now = nowIso();
await db
.update(accessLists)
.set({
name: input.name ?? existing.name,
description: input.description ?? existing.description,
updatedAt: now
}
});
})
.where(eq(accessLists.id, id));
logAuditEvent({
userId: actorUserId,
@@ -163,23 +172,23 @@ export async function addAccessListEntry(
entry: { username: string; password: string },
actorUserId: number
) {
const list = await prisma.accessList.findUnique({
where: { id: accessListId }
const list = await db.query.accessLists.findFirst({
where: (table, operators) => operators.eq(table.id, accessListId)
});
if (!list) {
throw new Error("Access list not found");
}
const now = new Date(nowIso());
const now = nowIso();
const hash = bcrypt.hashSync(entry.password, 10);
await prisma.accessListEntry.create({
data: {
accessListId,
username: entry.username,
passwordHash: hash,
createdAt: now,
updatedAt: now
}
await db.insert(accessListEntries).values({
accessListId,
username: entry.username,
passwordHash: hash,
createdAt: now,
updatedAt: now
});
logAuditEvent({
userId: actorUserId,
action: "create",
@@ -192,15 +201,15 @@ export async function addAccessListEntry(
}
export async function removeAccessListEntry(accessListId: number, entryId: number, actorUserId: number) {
const list = await prisma.accessList.findUnique({
where: { id: accessListId }
const list = await db.query.accessLists.findFirst({
where: (table, operators) => operators.eq(table.id, accessListId)
});
if (!list) {
throw new Error("Access list not found");
}
await prisma.accessListEntry.delete({
where: { id: entryId }
});
await db.delete(accessListEntries).where(eq(accessListEntries.id, entryId));
logAuditEvent({
userId: actorUserId,
action: "delete",
@@ -213,15 +222,15 @@ export async function removeAccessListEntry(accessListId: number, entryId: numbe
}
export async function deleteAccessList(id: number, actorUserId: number) {
const existing = await prisma.accessList.findUnique({
where: { id }
const existing = await db.query.accessLists.findFirst({
where: (table, operators) => operators.eq(table.id, id)
});
if (!existing) {
throw new Error("Access list not found");
}
await prisma.accessList.delete({
where: { id }
});
await db.delete(accessLists).where(eq(accessLists.id, id));
logAuditEvent({
userId: actorUserId,
action: "delete",

View File

@@ -1,4 +1,6 @@
import prisma from "../db";
import db, { toIso } from "../db";
import { auditEvents } from "../db/schema";
import { desc } from "drizzle-orm";
export type AuditEvent = {
id: number;
@@ -11,18 +13,19 @@ export type AuditEvent = {
};
export async function listAuditEvents(limit = 100): Promise<AuditEvent[]> {
const events = await prisma.auditEvent.findMany({
orderBy: { createdAt: "desc" },
take: limit
});
const events = await db
.select()
.from(auditEvents)
.orderBy(desc(auditEvents.createdAt))
.limit(limit);
return events.map((event: typeof events[0]) => ({
return events.map((event) => ({
id: event.id,
user_id: event.userId,
action: event.action,
entity_type: event.entityType,
entity_id: event.entityId,
summary: event.summary,
created_at: event.createdAt.toISOString()
created_at: toIso(event.createdAt)!
}));
}

View File

@@ -1,6 +1,8 @@
import prisma, { nowIso } from "../db";
import db, { nowIso, toIso } from "../db";
import { logAuditEvent } from "../audit";
import { applyCaddyConfig } from "../caddy";
import { certificates } from "../db/schema";
import { desc, eq } from "drizzle-orm";
export type CertificateType = "managed" | "imported";
@@ -27,18 +29,9 @@ export type CertificateInput = {
private_key_pem?: string | null;
};
function parseCertificate(row: {
id: number;
name: string;
type: string;
domainNames: string;
autoRenew: boolean;
providerOptions: string | null;
certificatePem: string | null;
privateKeyPem: string | null;
createdAt: Date;
updatedAt: Date;
}): Certificate {
type CertificateRow = typeof certificates.$inferSelect;
function parseCertificate(row: CertificateRow): Certificate {
return {
id: row.id,
name: row.name,
@@ -48,21 +41,19 @@ function parseCertificate(row: {
provider_options: row.providerOptions ? JSON.parse(row.providerOptions) : null,
certificate_pem: row.certificatePem,
private_key_pem: row.privateKeyPem,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString()
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
};
}
export async function listCertificates(): Promise<Certificate[]> {
const certificates = await prisma.certificate.findMany({
orderBy: { createdAt: "desc" }
});
return certificates.map(parseCertificate);
const rows = await db.select().from(certificates).orderBy(desc(certificates.createdAt));
return rows.map(parseCertificate);
}
export async function getCertificate(id: number): Promise<Certificate | null> {
const cert = await prisma.certificate.findUnique({
where: { id }
const cert = await db.query.certificates.findFirst({
where: (table, { eq }) => eq(table.id, id)
});
return cert ? parseCertificate(cert) : null;
}
@@ -80,9 +71,10 @@ function validateCertificateInput(input: CertificateInput) {
export async function createCertificate(input: CertificateInput, actorUserId: number) {
validateCertificateInput(input);
const now = new Date(nowIso());
const record = await prisma.certificate.create({
data: {
const now = nowIso();
const [record] = await db
.insert(certificates)
.values({
name: input.name.trim(),
type: input.type,
domainNames: JSON.stringify(
@@ -95,8 +87,13 @@ export async function createCertificate(input: CertificateInput, actorUserId: nu
createdAt: now,
updatedAt: now,
createdBy: actorUserId
}
});
})
.returning();
if (!record) {
throw new Error("Failed to create certificate");
}
logAuditEvent({
userId: actorUserId,
action: "create",
@@ -126,10 +123,10 @@ export async function updateCertificate(id: number, input: Partial<CertificateIn
validateCertificateInput(merged);
const now = new Date(nowIso());
await prisma.certificate.update({
where: { id },
data: {
const now = nowIso();
await db
.update(certificates)
.set({
name: merged.name.trim(),
type: merged.type,
domainNames: JSON.stringify(Array.from(new Set(merged.domain_names))),
@@ -138,8 +135,8 @@ export async function updateCertificate(id: number, input: Partial<CertificateIn
certificatePem: merged.certificate_pem ?? null,
privateKeyPem: merged.private_key_pem ?? null,
updatedAt: now
}
});
})
.where(eq(certificates.id, id));
logAuditEvent({
userId: actorUserId,
@@ -158,9 +155,7 @@ export async function deleteCertificate(id: number, actorUserId: number) {
throw new Error("Certificate not found");
}
await prisma.certificate.delete({
where: { id }
});
await db.delete(certificates).where(eq(certificates.id, id));
logAuditEvent({
userId: actorUserId,
action: "delete",

View File

@@ -1,6 +1,8 @@
import prisma, { nowIso } from "../db";
import db, { nowIso, toIso } from "../db";
import { logAuditEvent } from "../audit";
import { applyCaddyConfig } from "../caddy";
import { deadHosts } from "../db/schema";
import { desc, eq } from "drizzle-orm";
export type DeadHost = {
id: number;
@@ -21,16 +23,9 @@ export type DeadHostInput = {
enabled?: boolean;
};
function parse(row: {
id: number;
name: string;
domains: string;
statusCode: number;
responseBody: string | null;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
}): DeadHost {
type DeadHostRow = typeof deadHosts.$inferSelect;
function parse(row: DeadHostRow): DeadHost {
return {
id: row.id,
name: row.name,
@@ -38,21 +33,19 @@ function parse(row: {
status_code: row.statusCode,
response_body: row.responseBody,
enabled: row.enabled,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString()
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
};
}
export async function listDeadHosts(): Promise<DeadHost[]> {
const hosts = await prisma.deadHost.findMany({
orderBy: { createdAt: "desc" }
});
const hosts = await db.select().from(deadHosts).orderBy(desc(deadHosts.createdAt));
return hosts.map(parse);
}
export async function getDeadHost(id: number): Promise<DeadHost | null> {
const host = await prisma.deadHost.findUnique({
where: { id }
const host = await db.query.deadHosts.findFirst({
where: (table, { eq }) => eq(table.id, id)
});
return host ? parse(host) : null;
}
@@ -62,9 +55,10 @@ export async function createDeadHost(input: DeadHostInput, actorUserId: number)
throw new Error("At least one domain is required");
}
const now = new Date(nowIso());
const record = await prisma.deadHost.create({
data: {
const now = nowIso();
const [record] = await db
.insert(deadHosts)
.values({
name: input.name.trim(),
domains: JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
statusCode: input.status_code ?? 503,
@@ -73,8 +67,12 @@ export async function createDeadHost(input: DeadHostInput, actorUserId: number)
createdAt: now,
updatedAt: now,
createdBy: actorUserId
}
});
})
.returning();
if (!record) {
throw new Error("Failed to create dead host");
}
logAuditEvent({
userId: actorUserId,
action: "create",
@@ -91,18 +89,18 @@ export async function updateDeadHost(id: number, input: Partial<DeadHostInput>,
if (!existing) {
throw new Error("Dead host not found");
}
const now = new Date(nowIso());
await prisma.deadHost.update({
where: { id },
data: {
const now = nowIso();
await db
.update(deadHosts)
.set({
name: input.name ?? existing.name,
domains: JSON.stringify(input.domains ? Array.from(new Set(input.domains)) : existing.domains),
statusCode: input.status_code ?? existing.status_code,
responseBody: input.response_body ?? existing.response_body,
enabled: input.enabled ?? existing.enabled,
updatedAt: now
}
});
})
.where(eq(deadHosts.id, id));
logAuditEvent({
userId: actorUserId,
action: "update",
@@ -119,9 +117,7 @@ export async function deleteDeadHost(id: number, actorUserId: number) {
if (!existing) {
throw new Error("Dead host not found");
}
await prisma.deadHost.delete({
where: { id }
});
await db.delete(deadHosts).where(eq(deadHosts.id, id));
logAuditEvent({
userId: actorUserId,
action: "delete",

View File

@@ -1,6 +1,8 @@
import prisma, { nowIso } from "../db";
import db, { nowIso, toIso } from "../db";
import { applyCaddyConfig } from "../caddy";
import { logAuditEvent } from "../audit";
import { proxyHosts } from "../db/schema";
import { desc, eq } from "drizzle-orm";
const DEFAULT_AUTHENTIK_HEADERS = [
"X-Authentik-Username",
@@ -94,25 +96,7 @@ export type ProxyHostInput = {
authentik?: ProxyHostAuthentikInput | null;
};
type ProxyHostRow = {
id: number;
name: string;
domains: string;
upstreams: string;
certificateId: number | null;
accessListId: number | null;
ownerUserId: number | null;
sslForced: boolean;
hstsEnabled: boolean;
hstsSubdomains: boolean;
allowWebsocket: boolean;
preserveHostHeader: boolean;
meta: string | null;
skipHttpsHostnameValidation: boolean;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
};
type ProxyHostRow = typeof proxyHosts.$inferSelect;
function normalizeMetaValue(value: string | null | undefined) {
if (!value) {
@@ -394,8 +378,8 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
preserve_host_header: row.preserveHostHeader,
skip_https_hostname_validation: row.skipHttpsHostnameValidation,
enabled: row.enabled,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!,
custom_reverse_proxy_json: meta.custom_reverse_proxy_json ?? null,
custom_pre_handlers_json: meta.custom_pre_handlers_json ?? null,
authentik: hydrateAuthentik(meta.authentik)
@@ -403,9 +387,7 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
}
export async function listProxyHosts(): Promise<ProxyHost[]> {
const hosts = await prisma.proxyHost.findMany({
orderBy: { createdAt: "desc" }
});
const hosts = await db.select().from(proxyHosts).orderBy(desc(proxyHosts.createdAt));
return hosts.map(parseProxyHost);
}
@@ -417,10 +399,11 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
throw new Error("At least one upstream must be specified");
}
const now = new Date(nowIso());
const now = nowIso();
const meta = buildMeta({}, input);
const record = await prisma.proxyHost.create({
data: {
const [record] = await db
.insert(proxyHosts)
.values({
name: input.name.trim(),
domains: JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))),
@@ -437,8 +420,12 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
enabled: input.enabled ?? true,
createdAt: now,
updatedAt: now
}
});
})
.returning();
if (!record) {
throw new Error("Failed to create proxy host");
}
logAuditEvent({
userId: actorUserId,
@@ -454,8 +441,8 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
}
export async function getProxyHost(id: number): Promise<ProxyHost | null> {
const host = await prisma.proxyHost.findUnique({
where: { id }
const host = await db.query.proxyHosts.findFirst({
where: (table, { eq }) => eq(table.id, id)
});
return host ? parseProxyHost(host) : null;
}
@@ -475,10 +462,10 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
};
const meta = buildMeta(existingMeta, input);
const now = new Date(nowIso());
await prisma.proxyHost.update({
where: { id },
data: {
const now = nowIso();
await db
.update(proxyHosts)
.set({
name: input.name ?? existing.name,
domains,
upstreams,
@@ -493,8 +480,8 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
skipHttpsHostnameValidation: input.skip_https_hostname_validation ?? existing.skip_https_hostname_validation,
enabled: input.enabled ?? existing.enabled,
updatedAt: now
}
});
})
.where(eq(proxyHosts.id, id));
logAuditEvent({
userId: actorUserId,
@@ -515,9 +502,7 @@ export async function deleteProxyHost(id: number, actorUserId: number) {
throw new Error("Proxy host not found");
}
await prisma.proxyHost.delete({
where: { id }
});
await db.delete(proxyHosts).where(eq(proxyHosts.id, id));
logAuditEvent({
userId: actorUserId,
action: "delete",

View File

@@ -1,6 +1,8 @@
import prisma, { nowIso } from "../db";
import db, { nowIso, toIso } from "../db";
import { logAuditEvent } from "../audit";
import { applyCaddyConfig } from "../caddy";
import { redirectHosts } from "../db/schema";
import { desc, eq } from "drizzle-orm";
export type RedirectHost = {
id: number;
@@ -23,17 +25,9 @@ export type RedirectHostInput = {
enabled?: boolean;
};
function parseDbRecord(record: {
id: number;
name: string;
domains: string;
destination: string;
statusCode: number;
preserveQuery: boolean;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
}): RedirectHost {
type RedirectHostRow = typeof redirectHosts.$inferSelect;
function parseDbRecord(record: RedirectHostRow): RedirectHost {
return {
id: record.id,
name: record.name,
@@ -42,21 +36,19 @@ function parseDbRecord(record: {
status_code: record.statusCode,
preserve_query: record.preserveQuery,
enabled: record.enabled,
created_at: record.createdAt.toISOString(),
updated_at: record.updatedAt.toISOString()
created_at: toIso(record.createdAt)!,
updated_at: toIso(record.updatedAt)!
};
}
export async function listRedirectHosts(): Promise<RedirectHost[]> {
const records = await prisma.redirectHost.findMany({
orderBy: { createdAt: "desc" }
});
const records = await db.select().from(redirectHosts).orderBy(desc(redirectHosts.createdAt));
return records.map(parseDbRecord);
}
export async function getRedirectHost(id: number): Promise<RedirectHost | null> {
const record = await prisma.redirectHost.findUnique({
where: { id }
const record = await db.query.redirectHosts.findFirst({
where: (table, { eq }) => eq(table.id, id)
});
return record ? parseDbRecord(record) : null;
}
@@ -66,9 +58,10 @@ export async function createRedirectHost(input: RedirectHostInput, actorUserId:
throw new Error("At least one domain is required");
}
const now = new Date(nowIso());
const record = await prisma.redirectHost.create({
data: {
const now = nowIso();
const [record] = await db
.insert(redirectHosts)
.values({
name: input.name.trim(),
domains: JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
destination: input.destination.trim(),
@@ -78,8 +71,12 @@ export async function createRedirectHost(input: RedirectHostInput, actorUserId:
createdAt: now,
updatedAt: now,
createdBy: actorUserId
}
});
})
.returning();
if (!record) {
throw new Error("Failed to create redirect host");
}
logAuditEvent({
userId: actorUserId,
@@ -98,10 +95,10 @@ export async function updateRedirectHost(id: number, input: Partial<RedirectHost
throw new Error("Redirect host not found");
}
const now = new Date(nowIso());
await prisma.redirectHost.update({
where: { id },
data: {
const now = nowIso();
await db
.update(redirectHosts)
.set({
name: input.name ?? existing.name,
domains: input.domains ? JSON.stringify(Array.from(new Set(input.domains))) : JSON.stringify(existing.domains),
destination: input.destination ?? existing.destination,
@@ -109,8 +106,8 @@ export async function updateRedirectHost(id: number, input: Partial<RedirectHost
preserveQuery: input.preserve_query ?? existing.preserve_query,
enabled: input.enabled ?? existing.enabled,
updatedAt: now
}
});
})
.where(eq(redirectHosts.id, id));
logAuditEvent({
userId: actorUserId,
@@ -129,9 +126,7 @@ export async function deleteRedirectHost(id: number, actorUserId: number) {
throw new Error("Redirect host not found");
}
await prisma.redirectHost.delete({
where: { id }
});
await db.delete(redirectHosts).where(eq(redirectHosts.id, id));
logAuditEvent({
userId: actorUserId,

View File

@@ -1,4 +1,6 @@
import prisma, { nowIso } from "../db";
import db, { nowIso, toIso } from "../db";
import { users } from "../db/schema";
import { and, asc, count, eq } from "drizzle-orm";
export type User = {
id: number;
@@ -14,19 +16,9 @@ export type User = {
updated_at: string;
};
function parseDbUser(user: {
id: number;
email: string;
name: string | null;
passwordHash: string | null;
role: string;
provider: string;
subject: string;
avatarUrl: string | null;
status: string;
createdAt: Date;
updatedAt: Date;
}): User {
type DbUser = typeof users.$inferSelect;
function parseDbUser(user: DbUser): User {
return {
id: user.id,
email: user.email,
@@ -37,38 +29,34 @@ function parseDbUser(user: {
subject: user.subject,
avatar_url: user.avatarUrl,
status: user.status,
created_at: user.createdAt.toISOString(),
updated_at: user.updatedAt.toISOString()
created_at: toIso(user.createdAt)!,
updated_at: toIso(user.updatedAt)!
};
}
export async function getUserById(userId: number): Promise<User | null> {
const user = await prisma.user.findUnique({
where: { id: userId }
const user = await db.query.users.findFirst({
where: (table, { eq }) => eq(table.id, userId)
});
return user ? parseDbUser(user) : null;
}
export async function getUserCount(): Promise<number> {
return await prisma.user.count();
const result = await db.select({ value: count() }).from(users);
return result[0]?.value ?? 0;
}
export async function findUserByProviderSubject(provider: string, subject: string): Promise<User | null> {
const user = await prisma.user.findFirst({
where: {
provider,
subject
}
const user = await db.query.users.findFirst({
where: (table, operators) => and(operators.eq(table.provider, provider), operators.eq(table.subject, subject))
});
return user ? parseDbUser(user) : null;
}
export async function findUserByEmail(email: string): Promise<User | null> {
const normalizedEmail = email.trim().toLowerCase();
const user = await prisma.user.findFirst({
where: {
email: normalizedEmail
}
const user = await db.query.users.findFirst({
where: (table, { eq }) => eq(table.email, normalizedEmail)
});
return user ? parseDbUser(user) : null;
}
@@ -82,12 +70,13 @@ export async function createUser(data: {
avatar_url?: string | null;
passwordHash?: string | null;
}): Promise<User> {
const now = new Date(nowIso());
const now = nowIso();
const role = data.role ?? "user";
const email = data.email.trim().toLowerCase();
const user = await prisma.user.create({
data: {
const [user] = await db
.insert(users)
.values({
email,
name: data.name ?? null,
passwordHash: data.passwordHash ?? null,
@@ -98,8 +87,8 @@ export async function createUser(data: {
status: "active",
createdAt: now,
updatedAt: now
}
});
})
.returning();
return parseDbUser(user);
}
@@ -110,45 +99,46 @@ export async function updateUserProfile(userId: number, data: { email?: string;
return null;
}
const now = new Date(nowIso());
const user = await prisma.user.update({
where: { id: userId },
data: {
const now = nowIso();
const [updated] = await db
.update(users)
.set({
email: data.email ?? current.email,
name: data.name ?? current.name,
avatarUrl: data.avatar_url ?? current.avatar_url,
updatedAt: now
}
});
})
.where(eq(users.id, userId))
.returning();
return parseDbUser(user);
return updated ? parseDbUser(updated) : null;
}
export async function updateUserPassword(userId: number, passwordHash: string): Promise<void> {
const now = new Date(nowIso());
await prisma.user.update({
where: { id: userId },
data: {
const now = nowIso();
await db
.update(users)
.set({
passwordHash,
updatedAt: now
}
});
})
.where(eq(users.id, userId));
}
export async function listUsers(): Promise<User[]> {
const users = await prisma.user.findMany({
orderBy: { createdAt: "asc" }
const rows = await db.query.users.findMany({
orderBy: (table, { asc }) => asc(table.createdAt)
});
return users.map(parseDbUser);
return rows.map(parseDbUser);
}
export async function promoteToAdmin(userId: number): Promise<void> {
const now = new Date(nowIso());
await prisma.user.update({
where: { id: userId },
data: {
const now = nowIso();
await db
.update(users)
.set({
role: "admin",
updatedAt: now
}
});
})
.where(eq(users.id, userId));
}

View File

@@ -1,4 +1,6 @@
import prisma, { nowIso } from "./db";
import db, { nowIso } from "./db";
import { settings } from "./db/schema";
import { eq } from "drizzle-orm";
export type SettingValue<T> = T | null;
@@ -14,8 +16,8 @@ export type GeneralSettings = {
};
export async function getSetting<T>(key: string): Promise<SettingValue<T>> {
const setting = await prisma.setting.findUnique({
where: { key }
const setting = await db.query.settings.findFirst({
where: (table, { eq }) => eq(table.key, key)
});
if (!setting) {
@@ -32,20 +34,22 @@ export async function getSetting<T>(key: string): Promise<SettingValue<T>> {
export async function setSetting<T>(key: string, value: T): Promise<void> {
const payload = JSON.stringify(value);
const now = new Date(nowIso());
const now = nowIso();
await prisma.setting.upsert({
where: { key },
update: {
value: payload,
updatedAt: now
},
create: {
await db
.insert(settings)
.values({
key,
value: payload,
updatedAt: now
}
});
})
.onConflictDoUpdate({
target: settings.key,
set: {
value: payload,
updatedAt: now
}
});
}
export async function getCloudflareSettings(): Promise<CloudflareSettings | null> {