Files
caddy-proxy-manager/src/lib/db.ts
fuomag9 1237cdee4f Fix lint errors: remove unused imports and fix type assertions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:57:56 +02:00

275 lines
9.1 KiB
TypeScript

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<typeof drizzle<typeof schema>>;
__SQLITE_CLIENT__?: InstanceType<typeof Database>;
__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();
}