import { Database } from "bun:sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { eq, ne, and, isNull } from "drizzle-orm"; import { mkdirSync } from "node:fs"; import { dirname, isAbsolute, resolve as resolvePath } from "node:path"; import * as schema from "./db/schema"; const DEFAULT_SQLITE_URL = "file:./data/caddy-proxy-manager.db"; type GlobalForDrizzle = typeof globalThis & { __DRIZZLE_DB__?: ReturnType>; __SQLITE_CLIENT__?: InstanceType; __MIGRATIONS_RAN__?: boolean; }; function resolveSqlitePath(rawUrl: string): string { if (!rawUrl) { return ":memory:"; } if (rawUrl === ":memory:" || rawUrl === "file::memory:") { return ":memory:"; } if (rawUrl.startsWith("file:./") || rawUrl.startsWith("file:../")) { const relative = rawUrl.slice("file:".length); return resolvePath(process.cwd(), relative); } 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; export 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 }); 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: unknown) { // During build, pages may be pre-rendered in parallel, causing race conditions // with migrations. If tables already exist, just continue. if ( typeof error === "object" && error !== null && "code" in error && "message" in error && error.code === "SQLITE_ERROR" && typeof error.message === "string" && 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; } } /** * One-time migration: populate `accounts` table from existing users' provider/subject fields. * Also creates credential accounts for password users and syncs env OAuth providers. * Idempotent — skips if already run (checked via settings flag). */ function runBetterAuthDataMigration() { if (sqlitePath === ":memory:") return; const { settings, users, accounts } = schema; const flag = db.select().from(settings).where(eq(settings.key, "better_auth_migrated")).get(); if (flag) return; const now = new Date().toISOString(); // Migrate OAuth users: create account rows from users.provider/subject const oauthUsers = db.select().from(users).where(ne(users.provider, "credentials")).all(); for (const user of oauthUsers) { if (!user.provider || !user.subject) continue; const existing = db.select().from(accounts).where( and(eq(accounts.userId, user.id), eq(accounts.providerId, user.provider), eq(accounts.accountId, user.subject)) ).get(); if (!existing) { db.insert(accounts).values({ userId: user.id, accountId: user.subject, providerId: user.provider, createdAt: user.createdAt, updatedAt: user.updatedAt, }).run(); } } // Migrate credentials users: create credential account rows const credentialUsers = db.select().from(users).where(eq(users.provider, "credentials")).all(); for (const user of credentialUsers) { const existing = db.select().from(accounts).where( and(eq(accounts.userId, user.id), eq(accounts.providerId, "credential")) ).get(); if (!existing) { db.insert(accounts).values({ userId: user.id, accountId: user.id.toString(), providerId: "credential", password: user.passwordHash, createdAt: user.createdAt, updatedAt: user.updatedAt, }).run(); } } // Populate username field for all users (derived from email prefix) const usersWithoutUsername = db.select().from(users).where(isNull(users.username)).all(); for (const user of usersWithoutUsername) { const usernameFromEmail = user.email.split("@")[0] || user.email; db.update(users).set({ username: usernameFromEmail.toLowerCase(), displayUsername: usernameFromEmail, }).where(eq(users.id, user.id)).run(); } db.insert(settings).values({ key: "better_auth_migrated", value: "true", updatedAt: now }).run(); console.log("Better Auth data migration complete: populated accounts table"); } /** * Sync OAUTH_* env vars into the oauthProviders table (synchronous). * Uses raw Drizzle queries since this runs at module load time. */ function runEnvProviderSync() { if (sqlitePath === ":memory:") return; // Lazy import to avoid circular dependency at module load let config: { oauth: { enabled: boolean; providerName: string; clientId: string | null; clientSecret: string | null; issuer: string | null; authorizationUrl: string | null; tokenUrl: string | null; userinfoUrl: string | null; allowAutoLinking: boolean } }; try { config = require("./config").config; // eslint-disable-line @typescript-eslint/no-require-imports } catch { return; } if (!config.oauth.enabled || !config.oauth.clientId || !config.oauth.clientSecret) return; const { oauthProviders } = schema; let encryptSecret: (v: string) => string; try { encryptSecret = require("./secret").encryptSecret; // eslint-disable-line @typescript-eslint/no-require-imports } catch (e) { console.error("CRITICAL: Failed to load encryption module, refusing to store plaintext secrets:", e); return; } const name = config.oauth.providerName; // Use a slug-based ID so the OAuth callback URL is predictable const providerId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "oauth"; const existing = db.select().from(oauthProviders).where(eq(oauthProviders.name, name)).get(); const now = new Date().toISOString(); if (existing && existing.source === "env") { db.update(oauthProviders).set({ clientId: encryptSecret(config.oauth.clientId), clientSecret: encryptSecret(config.oauth.clientSecret), issuer: config.oauth.issuer ?? null, authorizationUrl: config.oauth.authorizationUrl ?? null, tokenUrl: config.oauth.tokenUrl ?? null, userinfoUrl: config.oauth.userinfoUrl ?? null, autoLink: config.oauth.allowAutoLinking, updatedAt: now, }).where(eq(oauthProviders.id, existing.id)).run(); } else if (!existing) { db.insert(oauthProviders).values({ id: providerId, name, type: "oidc", clientId: encryptSecret(config.oauth.clientId), clientSecret: encryptSecret(config.oauth.clientSecret), issuer: config.oauth.issuer ?? null, authorizationUrl: config.oauth.authorizationUrl ?? null, tokenUrl: config.oauth.tokenUrl ?? null, userinfoUrl: config.oauth.userinfoUrl ?? null, scopes: "openid email profile", autoLink: config.oauth.allowAutoLinking, enabled: true, source: "env", createdAt: now, updatedAt: now, }).run(); console.log(`Synced OAuth provider from env: ${name}`); } } try { runBetterAuthDataMigration(); runEnvProviderSync(); } catch (error) { console.warn("Better Auth data migration warning:", 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(); }