refactor(db): route remaining raw DB access through repositories

proxmox, session-log, oidc-utils, webauthn and guacamole recording now use
repositories (new WebauthnCredentialRepository; SsoProviderRepository
listEnabled; HostRepository findDecryptedByIdAs/listProxmoxEnabled).
Remaining raw access: db boot code, simple-db-ops and docker.ts, which are
removed/restructured in later phases.
This commit is contained in:
LukeGus
2026-07-16 00:04:36 -05:00
parent ef4969dd35
commit d710989070
9 changed files with 278 additions and 213 deletions
@@ -1,6 +1,7 @@
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { getDb, getSqlite } from "../db/index.js";
import type { DatabaseContext } from "./database-context.js";
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
import { AlertRepository } from "./alert-repository.js";
import { ApiKeyRepository } from "./api-key-repository.js";
import { AuditLogRepository } from "./audit-log-repository.js";
@@ -68,6 +69,13 @@ export function getCurrentSettingValue(key: string): string | null {
return row?.value ?? null;
}
export function createCurrentWebauthnCredentialRepository(): WebauthnCredentialRepository {
return new WebauthnCredentialRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("webauthn_credential_repository_write"),
);
}
export function createCurrentAlertRepository(): AlertRepository {
return new AlertRepository(
createCurrentRepositoryContext(),
@@ -78,6 +78,32 @@ export class HostRepository {
return rows[0] ?? null;
}
async findDecryptedByIdAs(
userId: string,
hostId: number,
): Promise<HostRecord | null> {
const row = await this.findById(hostId);
if (!row) return null;
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) return null;
return DataCrypto.decryptRecord("ssh_data", row, userId, userDataKey);
}
async listProxmoxEnabled(): Promise<
Pick<HostRecord, "id" | "userId" | "proxmoxConfig">[]
> {
return this.context.drizzle
.select({
id: hosts.id,
userId: hosts.userId,
proxmoxConfig: hosts.proxmoxConfig,
})
.from(hosts)
.where(eq(hosts.enableProxmox, true));
}
async listByUserId(userId: string): Promise<HostRecord[]> {
return this.context.drizzle
.select()
@@ -35,6 +35,14 @@ export class SsoProviderRepository {
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
}
async listEnabled(): Promise<SsoProviderRecord[]> {
return this.context.drizzle
.select()
.from(ssoProviders)
.where(eq(ssoProviders.enabled, true))
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
}
async listAll(): Promise<SsoProviderRecord[]> {
return this.context.drizzle
.select()
@@ -0,0 +1,86 @@
import { and, eq } from "drizzle-orm";
import { webauthnCredentials } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
export type WebauthnCredentialRecord = typeof webauthnCredentials.$inferSelect;
export type NewWebauthnCredentialRecord =
typeof webauthnCredentials.$inferInsert;
export interface WebauthnCredentialAuthState {
counter: number;
backedUp: boolean;
deviceType: string | null;
lastUsedAt: string;
}
export class WebauthnCredentialRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUserId(userId: string): Promise<WebauthnCredentialRecord[]> {
return this.context.drizzle
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.userId, userId));
}
async findByCredentialId(
credentialId: string,
): Promise<WebauthnCredentialRecord | null> {
const rows = await this.context.drizzle
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.credentialId, credentialId))
.limit(1);
return rows[0] ?? null;
}
async create(
record: NewWebauthnCredentialRecord,
): Promise<WebauthnCredentialRecord> {
const rows = await this.context.drizzle
.insert(webauthnCredentials)
.values(record)
.returning();
await this.afterWrite();
return rows[0];
}
async updateAuthState(
id: string,
state: WebauthnCredentialAuthState,
): Promise<void> {
await this.context.drizzle
.update(webauthnCredentials)
.set(state)
.where(eq(webauthnCredentials.id, id));
await this.afterWrite();
}
async deleteForUser(userId: string, id: string): Promise<boolean> {
const rows = await this.context.drizzle
.delete(webauthnCredentials)
.where(
and(
eq(webauthnCredentials.id, id),
eq(webauthnCredentials.userId, userId),
),
)
.returning({ id: webauthnCredentials.id });
if (rows.length > 0) {
await this.afterWrite();
}
return rows.length > 0;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
+80 -112
View File
@@ -1,10 +1,11 @@
import express from "express";
import { Client as SSHClient } from "ssh2";
import { db, getDb, DatabaseSaveTrigger } from "../db/index.js";
import { hosts, sshCredentials } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { logger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
} from "../repositories/factory.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { SSHHost } from "../../../types/index.js";
@@ -288,25 +289,24 @@ async function discoverProxmoxGuestsForHost(
defaultCredentialId: number | null;
config: ReturnType<typeof parseProxmoxConfig>;
}> {
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
if (!DataCrypto.canUserAccessData(userId)) {
const error = new Error("Session expired — please log in again");
(error as Error & { code?: string }).code = "SESSION_EXPIRED";
throw error;
}
const hostResults = await SimpleDBOps.select(
getDb().select().from(hosts).where(eq(hosts.id, parsedHostId)),
"ssh_data",
const hostRecord = await createCurrentHostRepository().findDecryptedByIdAs(
userId,
parsedHostId,
);
if (!hostResults.length) {
if (!hostRecord) {
const error = new Error("Host not found");
(error as Error & { status?: number }).status = 404;
throw error;
}
const host = hostResults[0] as unknown as SSHHost;
const host = hostRecord as unknown as SSHHost;
const proxmoxCfgRaw = parseJsonObject(host.proxmoxConfig);
const config = parseProxmoxConfig(proxmoxCfgRaw);
@@ -362,21 +362,13 @@ async function discoverProxmoxGuestsForHost(
});
}
} else {
const creds = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId as number),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
if (creds.length > 0) {
const c = creds[0];
const cred =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
host.credentialId as number,
);
if (cred) {
const c = cred;
resolvedCredentials = {
password: c.password as string | undefined,
sshKey: (c.key || c.privateKey) as string | undefined,
@@ -634,11 +626,10 @@ async function syncProxmoxHost(
);
const now = new Date().toISOString();
const existingHosts = await SimpleDBOps.select<Record<string, unknown>>(
db.select().from(hosts).where(eq(hosts.userId, userId)),
"ssh_data",
userId,
);
const existingHosts =
(await createCurrentHostRepository().listDecryptedByUserId(
userId,
)) as unknown as Record<string, unknown>[];
const existingBySource = new Map<string, Record<string, unknown>>();
for (const host of existingHosts) {
const source = getProxmoxSource(host);
@@ -712,12 +703,10 @@ async function syncProxmoxHost(
};
if (existing) {
await SimpleDBOps.update(
hosts,
"ssh_data",
eq(hosts.id, existing.id as number),
update,
await createCurrentHostRepository().updateEncryptedForUser(
userId,
existing.id as number,
update,
);
result.updated++;
continue;
@@ -732,57 +721,51 @@ async function syncProxmoxHost(
enableRdp: connectionType === "rdp",
});
await SimpleDBOps.insert(
hosts,
"ssh_data",
{
...update,
userId,
createdAt: now,
pin: false,
authType: connectionType === "rdp" ? "password" : importAuth.authType,
credentialId:
connectionType === "ssh" ? importAuth.credentialId : null,
overrideCredentialUsername: importAuth.overrideCredentialUsername,
password: null,
key: null,
keyPassword: null,
keyType: null,
rdpUser: null,
rdpPassword: null,
rdpDomain: null,
rdpSecurity: null,
rdpIgnoreCert: 0,
rdpPort: connectionType === "rdp" ? 3389 : null,
vncUser: null,
vncPassword: null,
vncPort: null,
telnetUser: null,
telnetPassword: null,
telnetPort: null,
defaultPath: "/",
tunnelConnections: "[]",
jumpHosts: null,
quickActions: null,
statsConfig: null,
dockerConfig: null,
terminalConfig: null,
forceKeyboardInteractive: "false",
useSocks5: 0,
socks5Host: null,
socks5Port: null,
socks5Username: null,
socks5Password: null,
socks5ProxyChain: null,
portKnockSequence: null,
showTerminalInSidebar: 0,
showFileManagerInSidebar: 0,
showTunnelInSidebar: 0,
showDockerInSidebar: 0,
showServerStatsInSidebar: 0,
},
await createCurrentHostRepository().createEncryptedForUser(userId, {
...update,
userId,
);
createdAt: now,
pin: false,
authType: connectionType === "rdp" ? "password" : importAuth.authType,
credentialId: connectionType === "ssh" ? importAuth.credentialId : null,
overrideCredentialUsername: importAuth.overrideCredentialUsername,
password: null,
key: null,
keyPassword: null,
keyType: null,
rdpUser: null,
rdpPassword: null,
rdpDomain: null,
rdpSecurity: null,
rdpIgnoreCert: 0,
rdpPort: connectionType === "rdp" ? 3389 : null,
vncUser: null,
vncPassword: null,
vncPort: null,
telnetUser: null,
telnetPassword: null,
telnetPort: null,
defaultPath: "/",
tunnelConnections: "[]",
jumpHosts: null,
quickActions: null,
statsConfig: null,
dockerConfig: null,
terminalConfig: null,
forceKeyboardInteractive: "false",
useSocks5: 0,
socks5Host: null,
socks5Port: null,
socks5Username: null,
socks5Password: null,
socks5ProxyChain: null,
portKnockSequence: null,
showTerminalInSidebar: 0,
showFileManagerInSidebar: 0,
showTunnelInSidebar: 0,
showDockerInSidebar: 0,
showServerStatsInSidebar: 0,
});
result.created++;
}
@@ -793,10 +776,9 @@ async function syncProxmoxHost(
const source = getProxmoxSource(existing);
if (!source) continue;
const missingSince = source.missingSince || now;
await SimpleDBOps.update(
hosts,
"ssh_data",
eq(hosts.id, existing.id as number),
await createCurrentHostRepository().updateEncryptedForUser(
userId,
existing.id as number,
{
tags: mergeTags(existing.tags, ["proxmox-missing"]),
proxmoxConfig: JSON.stringify({
@@ -808,7 +790,6 @@ async function syncProxmoxHost(
}),
updatedAt: now,
},
userId,
);
result.markedMissing++;
}
@@ -842,18 +823,13 @@ async function writeSyncStatus(
hostId: number,
patch: Record<string, unknown>,
): Promise<void> {
const rows = await db
.select({ proxmoxConfig: hosts.proxmoxConfig })
.from(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.limit(1);
if (!rows.length) return;
const config = parseJsonObject(rows[0].proxmoxConfig);
await db
.update(hosts)
.set({ proxmoxConfig: JSON.stringify({ ...config, ...patch }) })
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)));
DatabaseSaveTrigger.triggerSave("proxmox_sync_status");
const hostRepository = createCurrentHostRepository();
const row = await hostRepository.findByIdForUser(userId, hostId);
if (!row) return;
const config = parseJsonObject(row.proxmoxConfig);
await hostRepository.updateForUser(userId, hostId, {
proxmoxConfig: JSON.stringify({ ...config, ...patch }),
});
}
router.post("/sync", authenticateJWT, requireDataAccess, async (req, res) => {
@@ -886,22 +862,14 @@ router.post("/sync", authenticateJWT, requireDataAccess, async (req, res) => {
async function runDueProxmoxAutoSyncs(): Promise<void> {
try {
const rows = await db
.select({
id: hosts.id,
userId: hosts.userId,
enableProxmox: hosts.enableProxmox,
proxmoxConfig: hosts.proxmoxConfig,
})
.from(hosts)
.where(eq(hosts.enableProxmox, true));
const rows = await createCurrentHostRepository().listProxmoxEnabled();
const now = Date.now();
for (const row of rows) {
const configRaw = parseJsonObject(row.proxmoxConfig);
const config = parseProxmoxConfig(configRaw);
if (!config.autoSyncEnabled) continue;
if (!SimpleDBOps.isUserDataUnlocked(row.userId)) continue;
if (!DataCrypto.canUserAccessData(row.userId)) continue;
const lastSyncAt =
typeof configRaw.lastSyncAt === "string"
@@ -6,8 +6,11 @@ import { apiLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { Request, Response } from "express";
import { PermissionManager } from "../../utils/permission-manager.js";
import { createCurrentSessionRecordingRepository } from "../repositories/factory.js";
import { getDb } from "../db/index.js";
import {
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
getCurrentSettingValue,
} from "../repositories/factory.js";
const router = express.Router();
@@ -29,12 +32,10 @@ function getRetentionDays(): number {
10,
);
try {
const row = getDb()
.$client.prepare(
"SELECT value FROM settings WHERE key = 'session_recording_retention_days'",
)
.get() as { value?: string } | undefined;
const configured = parseInt(row?.value || "", 10);
const configured = parseInt(
getCurrentSettingValue("session_recording_retention_days") || "",
10,
);
if (configured >= 1 && configured <= 3650) return configured;
} catch {
// use environment/default below
@@ -164,11 +165,10 @@ router.put(
.status(400)
.json({ error: "Retention must be between 1 and 3650 days" });
}
getDb()
.$client.prepare(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.run("session_recording_retention_days", String(retentionDays));
await createCurrentSettingsRepository().upsert(
"session_recording_retention_days",
String(retentionDays),
);
void pruneOldLogs();
res.json({ retentionDays });
},
@@ -2,9 +2,6 @@ import { authLogger } from "../../utils/logger.js";
import type { SSOProviderType } from "../../../types/index.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { Agent } from "undici";
import { eq } from "drizzle-orm";
import { getDb } from "../db/index.js";
import { ssoProviders } from "../db/schema.js";
import {
createCurrentSettingsRepository,
createCurrentSsoProviderRepository,
@@ -426,10 +423,7 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{
const target = normalizeIssuer(issuer);
try {
const rows = await getDb()
.select()
.from(ssoProviders)
.where(eq(ssoProviders.enabled, true));
const rows = await createCurrentSsoProviderRepository().listEnabled();
for (const row of rows) {
if (!["oidc", "github", "google"].includes(row.type)) continue;
let parsed: Record<string, unknown>;
@@ -12,7 +12,6 @@ import {
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { and, eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
@@ -21,8 +20,12 @@ import {
generateDeviceFingerprint,
parseUserAgent,
} from "../../utils/user-agent-parser.js";
import { db, saveMemoryDatabaseToFile } from "../db/index.js";
import { users, webauthnCredentials } from "../db/schema.js";
import {
createCurrentUserRepository,
createCurrentWebauthnCredentialRepository,
getCurrentSettingValue,
} from "../repositories/factory.js";
import type { WebauthnCredentialRecord } from "../repositories/webauthn-credential-repository.js";
type UserVerification = "discouraged" | "preferred" | "required";
type NativeAppRequestChecker = (req: Request) => boolean;
@@ -113,7 +116,7 @@ function parseTransports(value: string | null): AuthenticatorTransportFuture[] {
}
function getCredentialForVerification(
credential: typeof webauthnCredentials.$inferSelect,
credential: WebauthnCredentialRecord,
): WebAuthnCredential {
return {
id: credential.credentialId as Base64URLString,
@@ -135,10 +138,8 @@ export function registerUserWebAuthnRoutes(
return res.status(401).json({ error: "Authentication required" });
}
const credentials = await db
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.userId, userId));
const credentials =
await createCurrentWebauthnCredentialRepository().listByUserId(userId);
res.json({
credentials: credentials.map((credential) => ({
@@ -169,15 +170,13 @@ export function registerUserWebAuthnRoutes(
});
}
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user.length) {
const user = await createCurrentUserRepository().findById(userId);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const existing = await db
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.userId, userId));
const existing =
await createCurrentWebauthnCredentialRepository().listByUserId(userId);
const origin = getRequestOrigin(req);
const rpID = getRpID(origin);
@@ -189,8 +188,8 @@ export function registerUserWebAuthnRoutes(
rpName: "Termix",
rpID,
userID: Buffer.from(userId, "utf8"),
userName: user[0].username,
userDisplayName: user[0].username,
userName: user.username,
userDisplayName: user.username,
attestationType: "none",
excludeCredentials: existing.map((credential) => ({
id: credential.credentialId as Base64URLString,
@@ -259,7 +258,7 @@ export function registerUserWebAuthnRoutes(
? req.body.name.trim().slice(0, 80)
: "Passkey";
await db.insert(webauthnCredentials).values({
await createCurrentWebauthnCredentialRepository().create({
id: nanoid(),
userId,
name,
@@ -273,7 +272,6 @@ export function registerUserWebAuthnRoutes(
createdAt: new Date().toISOString(),
});
await saveMemoryDatabaseToFile();
res.json({ success: true });
} catch (error) {
authLogger.warn("WebAuthn registration failed", {
@@ -301,19 +299,14 @@ export function registerUserWebAuthnRoutes(
| undefined;
if (username) {
const user = await db
.select()
.from(users)
.where(eq(users.username, username));
if (!user.length) {
const user = await createCurrentUserRepository().findByUsername(username);
if (!user) {
return res.status(404).json({ error: "No passkeys found" });
}
userId = user[0].id;
const credentials = await db
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.userId, userId));
userId = user.id;
const credentials =
await createCurrentWebauthnCredentialRepository().listByUserId(userId);
if (!credentials.length) {
return res.status(404).json({ error: "No passkeys found" });
@@ -360,16 +353,15 @@ export function registerUserWebAuthnRoutes(
return res.status(400).json({ error: "Invalid passkey response" });
}
const credentials = await db
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.credentialId, response.id));
const credential =
await createCurrentWebauthnCredentialRepository().findByCredentialId(
response.id,
);
if (!credentials.length) {
if (!credential) {
return res.status(401).json({ error: "Passkey not recognized" });
}
const credential = credentials[0];
if (challenge.userId && challenge.userId !== credential.userId) {
return res.status(401).json({ error: "Passkey not recognized" });
}
@@ -391,15 +383,13 @@ export function registerUserWebAuthnRoutes(
return res.status(401).json({ error: "Passkey authentication failed" });
}
const user = await db
.select()
.from(users)
.where(eq(users.id, credential.userId));
if (!user.length) {
const userRecord = await createCurrentUserRepository().findById(
credential.userId,
);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
const deviceInfo = parseUserAgent(req);
const dataUnlocked = await authManager.authenticateWebAuthnUser(
userRecord.id,
@@ -413,15 +403,15 @@ export function registerUserWebAuthnRoutes(
});
}
await db
.update(webauthnCredentials)
.set({
await createCurrentWebauthnCredentialRepository().updateAuthState(
credential.id,
{
counter: verification.authenticationInfo.newCounter,
backedUp: verification.authenticationInfo.credentialBackedUp,
deviceType: verification.authenticationInfo.credentialDeviceType,
lastUsedAt: new Date().toISOString(),
})
.where(eq(webauthnCredentials.id, credential.id));
},
);
if (userRecord.totpEnabled) {
const deviceFingerprint = generateDeviceFingerprint(deviceInfo);
@@ -431,7 +421,6 @@ export function registerUserWebAuthnRoutes(
);
if (!isTrusted) {
await saveMemoryDatabaseToFile();
const tempToken = await authManager.generateJWTToken(userRecord.id, {
pendingTOTP: true,
expiresIn: "10m",
@@ -451,15 +440,9 @@ export function registerUserWebAuthnRoutes(
deviceInfo: deviceInfo.deviceInfo,
});
await saveMemoryDatabaseToFile();
const timeoutRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'session_timeout_hours'",
)
.get() as { value: string } | undefined;
const timeoutHours = timeoutRow
? parseInt(timeoutRow.value, 10) || 24
const timeoutSetting = getCurrentSettingValue("session_timeout_hours");
const timeoutHours = timeoutSetting
? parseInt(timeoutSetting, 10) || 24
: 24;
const maxAge = req.body?.rememberMe
? 30 * 24 * 60 * 60 * 1000
@@ -498,15 +481,10 @@ export function registerUserWebAuthnRoutes(
const credentialId = String(req.params.credentialId);
await db
.delete(webauthnCredentials)
.where(
and(
eq(webauthnCredentials.id, credentialId),
eq(webauthnCredentials.userId, userId),
),
);
await saveMemoryDatabaseToFile();
await createCurrentWebauthnCredentialRepository().deleteForUser(
userId,
credentialId,
);
res.json({ success: true });
},