275 lines
9.1 KiB
TypeScript
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();
|
|
}
|