diff --git a/Overlord-Server/src/db/repositories.ts b/Overlord-Server/src/db/repositories.ts index 295ac76..2eb8d2d 100644 --- a/Overlord-Server/src/db/repositories.ts +++ b/Overlord-Server/src/db/repositories.ts @@ -4,210 +4,10 @@ import { getThumbnailSummaries } from "../thumbnails"; import { db, dbPath } from "./connection"; import "./schema"; -export type ChatMessageRecord = { - id: number; - userId: number; - username: string; - userRole: string; - message: string; - createdAt: number; -}; - -export function insertChatMessage(userId: number, username: string, userRole: string, message: string): ChatMessageRecord { - const createdAt = Date.now(); - const result = db.run( - `INSERT INTO chat_messages (user_id, username, user_role, message, created_at) VALUES (?, ?, ?, ?, ?)`, - userId, username, userRole, message, createdAt, - ); - const id = Number((result as any).lastInsertRowid); - return { id, userId, username, userRole, message, createdAt }; -} - -export function getChatHistory(before?: number, limit: number = 50, retentionMs?: number): ChatMessageRecord[] { - const maxLimit = Math.min(Math.max(1, limit), 200); - const cutoff = retentionMs && retentionMs > 0 ? Date.now() - retentionMs : 0; - let rows: any[]; - if (before) { - if (cutoff > 0) { - rows = db.query( - `SELECT id, user_id, username, user_role, message, created_at FROM chat_messages WHERE created_at < ? AND created_at > ? ORDER BY created_at DESC LIMIT ?`, - ).all(before, cutoff, maxLimit); - } else { - rows = db.query( - `SELECT id, user_id, username, user_role, message, created_at FROM chat_messages WHERE created_at < ? ORDER BY created_at DESC LIMIT ?`, - ).all(before, maxLimit); - } - } else { - if (cutoff > 0) { - rows = db.query( - `SELECT id, user_id, username, user_role, message, created_at FROM chat_messages WHERE created_at > ? ORDER BY created_at DESC LIMIT ?`, - ).all(cutoff, maxLimit); - } else { - rows = db.query( - `SELECT id, user_id, username, user_role, message, created_at FROM chat_messages ORDER BY created_at DESC LIMIT ?`, - ).all(maxLimit); - } - } - return rows.map((r: any) => ({ - id: r.id, - userId: r.user_id, - username: r.username, - userRole: r.user_role, - message: r.message, - createdAt: r.created_at, - })).reverse(); -} - -export function deleteExpiredChatMessages(retentionMs: number): number { - if (retentionMs <= 0) return 0; - const cutoff = Date.now() - retentionMs; - const result = db.run(`DELETE FROM chat_messages WHERE created_at < ?`, cutoff); - return Number((result as any).changes ?? 0); -} - -export type SharedFileRecord = { - id: string; - filename: string; - storedPath: string; - size: number; - mimeType: string; - uploadedBy: number; - uploadedByUsername: string; - passwordHash: string | null; - maxDownloads: number | null; - downloadCount: number; - expiresAt: number | null; - createdAt: number; - description: string | null; -}; - -export function insertSharedFile(file: SharedFileRecord): void { - db.run( - `INSERT INTO shared_files (id, filename, stored_path, size, mime_type, uploaded_by, uploaded_by_username, password_hash, max_downloads, download_count, expires_at, created_at, description) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - file.id, - file.filename, - file.storedPath, - file.size, - file.mimeType, - file.uploadedBy, - file.uploadedByUsername, - file.passwordHash, - file.maxDownloads, - file.downloadCount, - file.expiresAt, - file.createdAt, - file.description, - ); -} - -export function getSharedFile(id: string): SharedFileRecord | null { - const row = db.query(`SELECT * FROM shared_files WHERE id=?`).get(id); - if (!row) return null; - return { - id: row.id, - filename: row.filename, - storedPath: row.stored_path, - size: row.size, - mimeType: row.mime_type, - uploadedBy: row.uploaded_by, - uploadedByUsername: row.uploaded_by_username, - passwordHash: row.password_hash, - maxDownloads: row.max_downloads, - downloadCount: row.download_count, - expiresAt: row.expires_at, - createdAt: row.created_at, - description: row.description, - }; -} - -export function listSharedFiles(): SharedFileRecord[] { - return db - .query(`SELECT * FROM shared_files ORDER BY created_at DESC`) - .all() - .map((row: any) => ({ - id: row.id, - filename: row.filename, - storedPath: row.stored_path, - size: row.size, - mimeType: row.mime_type, - uploadedBy: row.uploaded_by, - uploadedByUsername: row.uploaded_by_username, - passwordHash: row.password_hash, - maxDownloads: row.max_downloads, - downloadCount: row.download_count, - expiresAt: row.expires_at, - createdAt: row.created_at, - description: row.description, - })); -} - -export function deleteSharedFile(id: string): boolean { - const result = db.run(`DELETE FROM shared_files WHERE id=?`, id); - return (result as any)?.changes ? (result as any).changes > 0 : false; -} - -export function updateSharedFile( - id: string, - updates: { - passwordHash?: string | null; - maxDownloads?: number | null; - expiresAt?: number | null; - description?: string | null; - }, -): boolean { - const setClauses: string[] = []; - const values: any[] = []; - - if (updates.passwordHash !== undefined) { - setClauses.push("password_hash=?"); - values.push(updates.passwordHash); - } - if (updates.maxDownloads !== undefined) { - setClauses.push("max_downloads=?"); - values.push(updates.maxDownloads); - } - if (updates.expiresAt !== undefined) { - setClauses.push("expires_at=?"); - values.push(updates.expiresAt); - } - if (updates.description !== undefined) { - setClauses.push("description=?"); - values.push(updates.description); - } - - if (setClauses.length === 0) return false; - - values.push(id); - const result = db.run( - `UPDATE shared_files SET ${setClauses.join(", ")} WHERE id=?`, - ...values, - ); - return (result as any)?.changes ? (result as any).changes > 0 : false; -} - -export function incrementSharedFileDownloadCount(id: string): boolean { - const result = db.run( - `UPDATE shared_files SET download_count = download_count + 1 WHERE id=?`, - id, - ); - return (result as any)?.changes ? (result as any).changes > 0 : false; -} - -export function deleteExpiredSharedFiles(): string[] { - const now = Date.now(); - const expired = db - .query(`SELECT id, stored_path FROM shared_files WHERE expires_at IS NOT NULL AND expires_at < ?`) - .all(now); - const ids = expired.map((r: any) => r.id); - const paths = expired.map((r: any) => r.stored_path); - if (ids.length > 0) { - for (const id of ids) { - db.run(`DELETE FROM shared_files WHERE id=?`, id); - } - } - return paths; -} +export * from "./repositories/chat"; +export * from "./repositories/revoked-tokens"; +export * from "./repositories/sessions"; +export * from "./repositories/shared-files"; export type ClientDbRow = Omit, "online"> & { id: string; @@ -534,153 +334,6 @@ export function isIpBanned(ip: string): boolean { return !!row?.ip; } -// ── Revoked token persistence ── - -function hashToken(token: string): string { - return new Bun.CryptoHasher("sha256").update(token).digest("hex"); -} - -export function persistRevokedToken(token: string, expiresAt: number): void { - db.run( - `INSERT OR IGNORE INTO revoked_tokens (token_hash, expires_at) VALUES (?, ?)`, - hashToken(token), - expiresAt, - ); -} - -export function persistRevokedTokenHash(tokenHash: string, expiresAt: number): void { - db.run( - `INSERT OR IGNORE INTO revoked_tokens (token_hash, expires_at) VALUES (?, ?)`, - tokenHash, - expiresAt, - ); -} - -export function isTokenRevoked(token: string): boolean { - const row = db.query<{ token_hash: string }>( - `SELECT token_hash FROM revoked_tokens WHERE token_hash=?`, - ).get(hashToken(token)); - return !!row; -} - -export function loadAllRevokedTokenHashes(): Set { - const now = Math.floor(Date.now() / 1000); - const rows = db.query<{ token_hash: string }>( - `SELECT token_hash FROM revoked_tokens WHERE expires_at > ?`, - ).all(now); - return new Set(rows.map((r) => r.token_hash)); -} - -export function pruneExpiredRevokedTokens(): number { - const now = Math.floor(Date.now() / 1000); - const result = db.run(`DELETE FROM revoked_tokens WHERE expires_at <= ?`, now); - return result.changes; -} - -export type SessionRecord = { - id: string; - userId: number; - tokenHash: string; - ip: string | null; - userAgent: string | null; - createdAt: number; - lastActivity: number; - expiresAt: number; - revoked: boolean; -}; - -export function createSession(session: { - id: string; - userId: number; - tokenHash: string; - ip: string | null; - userAgent: string | null; - createdAt: number; - expiresAt: number; -}): void { - db.run( - `INSERT INTO sessions (id, user_id, token_hash, ip, user_agent, created_at, last_activity, expires_at, revoked) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`, - session.id, - session.userId, - session.tokenHash, - session.ip, - session.userAgent, - session.createdAt, - session.createdAt, - session.expiresAt, - ); -} - -export function getSessionByTokenHash(tokenHash: string): SessionRecord | null { - const row = db.query( - `SELECT * FROM sessions WHERE token_hash=? AND revoked=0`, - ).get(tokenHash); - if (!row) return null; - return mapSessionRow(row); -} - -export function getSessionById(id: string): SessionRecord | null { - const row = db.query(`SELECT * FROM sessions WHERE id=?`).get(id); - if (!row) return null; - return mapSessionRow(row); -} - -export function listUserSessions(userId: number): SessionRecord[] { - const rows = db.query( - `SELECT * FROM sessions WHERE user_id=? ORDER BY created_at DESC`, - ).all(userId); - return rows.map(mapSessionRow); -} - -export function updateSessionActivity(tokenHash: string): void { - const now = Math.floor(Date.now() / 1000); - db.run(`UPDATE sessions SET last_activity=? WHERE token_hash=? AND revoked=0`, now, tokenHash); -} - -export function revokeSessionByTokenHash(tokenHash: string): boolean { - const result = db.run(`UPDATE sessions SET revoked=1 WHERE token_hash=? AND revoked=0`, tokenHash); - return result.changes > 0; -} - -export function revokeSessionById(sessionId: string): { tokenHash: string | null } { - const row = db.query<{ token_hash: string }>( - `SELECT token_hash FROM sessions WHERE id=? AND revoked=0`, - ).get(sessionId); - if (!row) return { tokenHash: null }; - db.run(`UPDATE sessions SET revoked=1 WHERE id=?`, sessionId); - return { tokenHash: row.token_hash }; -} - -export function revokeAllUserSessions(userId: number): number { - const result = db.run(`UPDATE sessions SET revoked=1 WHERE user_id=? AND revoked=0`, userId); - return result.changes; -} - -export function pruneExpiredSessions(): number { - const now = Math.floor(Date.now() / 1000); - const result = db.run(`DELETE FROM sessions WHERE expires_at <= ?`, now); - return result.changes; -} - -export function hashTokenForSession(token: string): string { - return hashToken(token); -} - -function mapSessionRow(row: any): SessionRecord { - return { - id: row.id, - userId: row.user_id, - tokenHash: row.token_hash, - ip: row.ip, - userAgent: row.user_agent, - createdAt: row.created_at, - lastActivity: row.last_activity, - expiresAt: row.expires_at, - revoked: !!row.revoked, - }; -} - export function markAllClientsOffline() { db.run(`UPDATE clients SET online=0`); invalidateClientMetricsSummaryCache(); @@ -1444,16 +1097,6 @@ export function getOldestBuildForUser(userId: number): BuildRecord | null { }; } -export function deleteInactiveSessions(userId: number): number { - const now = Math.floor(Date.now() / 1000); - const result = db.run( - `DELETE FROM sessions WHERE user_id = ? AND (revoked = 1 OR expires_at <= ?)`, - userId, - now, - ); - return result.changes; -} - export type AutoScriptTrigger = "on_connect" | "on_first_connect" | "on_connect_once"; export type AutoScript = { diff --git a/Overlord-Server/src/db/repositories/chat.ts b/Overlord-Server/src/db/repositories/chat.ts new file mode 100644 index 0000000..bf46fe5 --- /dev/null +++ b/Overlord-Server/src/db/repositories/chat.ts @@ -0,0 +1,67 @@ +import { db } from "../connection"; +import "../schema"; + +export type ChatMessageRecord = { + id: number; + userId: number; + username: string; + userRole: string; + message: string; + createdAt: number; +}; + +export function insertChatMessage(userId: number, username: string, userRole: string, message: string): ChatMessageRecord { + const createdAt = Date.now(); + const result = db.run( + `INSERT INTO chat_messages (user_id, username, user_role, message, created_at) VALUES (?, ?, ?, ?, ?)`, + userId, + username, + userRole, + message, + createdAt, + ); + const id = Number((result as any).lastInsertRowid); + return { id, userId, username, userRole, message, createdAt }; +} + +export function getChatHistory(before?: number, limit: number = 50, retentionMs?: number): ChatMessageRecord[] { + const maxLimit = Math.min(Math.max(1, limit), 200); + const cutoff = retentionMs && retentionMs > 0 ? Date.now() - retentionMs : 0; + let rows: any[]; + if (before) { + if (cutoff > 0) { + rows = db.query( + `SELECT id, user_id, username, user_role, message, created_at FROM chat_messages WHERE created_at < ? AND created_at > ? ORDER BY created_at DESC LIMIT ?`, + ).all(before, cutoff, maxLimit); + } else { + rows = db.query( + `SELECT id, user_id, username, user_role, message, created_at FROM chat_messages WHERE created_at < ? ORDER BY created_at DESC LIMIT ?`, + ).all(before, maxLimit); + } + } else { + if (cutoff > 0) { + rows = db.query( + `SELECT id, user_id, username, user_role, message, created_at FROM chat_messages WHERE created_at > ? ORDER BY created_at DESC LIMIT ?`, + ).all(cutoff, maxLimit); + } else { + rows = db.query( + `SELECT id, user_id, username, user_role, message, created_at FROM chat_messages ORDER BY created_at DESC LIMIT ?`, + ).all(maxLimit); + } + } + return rows.map((r: any) => ({ + id: r.id, + userId: r.user_id, + username: r.username, + userRole: r.user_role, + message: r.message, + createdAt: r.created_at, + })).reverse(); +} + +export function deleteExpiredChatMessages(retentionMs: number): number { + if (retentionMs <= 0) return 0; + const cutoff = Date.now() - retentionMs; + const result = db.run(`DELETE FROM chat_messages WHERE created_at < ?`, cutoff); + return Number((result as any).changes ?? 0); +} diff --git a/Overlord-Server/src/db/repositories/revoked-tokens.ts b/Overlord-Server/src/db/repositories/revoked-tokens.ts new file mode 100644 index 0000000..61e3f7e --- /dev/null +++ b/Overlord-Server/src/db/repositories/revoked-tokens.ts @@ -0,0 +1,40 @@ +import { db } from "../connection"; +import "../schema"; +import { hashToken } from "./token-hash"; + +export function persistRevokedToken(token: string, expiresAt: number): void { + db.run( + `INSERT OR IGNORE INTO revoked_tokens (token_hash, expires_at) VALUES (?, ?)`, + hashToken(token), + expiresAt, + ); +} + +export function persistRevokedTokenHash(tokenHash: string, expiresAt: number): void { + db.run( + `INSERT OR IGNORE INTO revoked_tokens (token_hash, expires_at) VALUES (?, ?)`, + tokenHash, + expiresAt, + ); +} + +export function isTokenRevoked(token: string): boolean { + const row = db.query<{ token_hash: string }>( + `SELECT token_hash FROM revoked_tokens WHERE token_hash=?`, + ).get(hashToken(token)); + return !!row; +} + +export function loadAllRevokedTokenHashes(): Set { + const now = Math.floor(Date.now() / 1000); + const rows = db.query<{ token_hash: string }>( + `SELECT token_hash FROM revoked_tokens WHERE expires_at > ?`, + ).all(now); + return new Set(rows.map((r) => r.token_hash)); +} + +export function pruneExpiredRevokedTokens(): number { + const now = Math.floor(Date.now() / 1000); + const result = db.run(`DELETE FROM revoked_tokens WHERE expires_at <= ?`, now); + return result.changes; +} diff --git a/Overlord-Server/src/db/repositories/sessions.ts b/Overlord-Server/src/db/repositories/sessions.ts new file mode 100644 index 0000000..4e1976e --- /dev/null +++ b/Overlord-Server/src/db/repositories/sessions.ts @@ -0,0 +1,117 @@ +import { db } from "../connection"; +import "../schema"; +import { hashToken } from "./token-hash"; + +export type SessionRecord = { + id: string; + userId: number; + tokenHash: string; + ip: string | null; + userAgent: string | null; + createdAt: number; + lastActivity: number; + expiresAt: number; + revoked: boolean; +}; + +export function createSession(session: { + id: string; + userId: number; + tokenHash: string; + ip: string | null; + userAgent: string | null; + createdAt: number; + expiresAt: number; +}): void { + db.run( + `INSERT INTO sessions (id, user_id, token_hash, ip, user_agent, created_at, last_activity, expires_at, revoked) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`, + session.id, + session.userId, + session.tokenHash, + session.ip, + session.userAgent, + session.createdAt, + session.createdAt, + session.expiresAt, + ); +} + +export function getSessionByTokenHash(tokenHash: string): SessionRecord | null { + const row = db.query( + `SELECT * FROM sessions WHERE token_hash=? AND revoked=0`, + ).get(tokenHash); + if (!row) return null; + return mapSessionRow(row); +} + +export function getSessionById(id: string): SessionRecord | null { + const row = db.query(`SELECT * FROM sessions WHERE id=?`).get(id); + if (!row) return null; + return mapSessionRow(row); +} + +export function listUserSessions(userId: number): SessionRecord[] { + const rows = db.query( + `SELECT * FROM sessions WHERE user_id=? ORDER BY created_at DESC`, + ).all(userId); + return rows.map(mapSessionRow); +} + +export function updateSessionActivity(tokenHash: string): void { + const now = Math.floor(Date.now() / 1000); + db.run(`UPDATE sessions SET last_activity=? WHERE token_hash=? AND revoked=0`, now, tokenHash); +} + +export function revokeSessionByTokenHash(tokenHash: string): boolean { + const result = db.run(`UPDATE sessions SET revoked=1 WHERE token_hash=? AND revoked=0`, tokenHash); + return result.changes > 0; +} + +export function revokeSessionById(sessionId: string): { tokenHash: string | null } { + const row = db.query<{ token_hash: string }>( + `SELECT token_hash FROM sessions WHERE id=? AND revoked=0`, + ).get(sessionId); + if (!row) return { tokenHash: null }; + db.run(`UPDATE sessions SET revoked=1 WHERE id=?`, sessionId); + return { tokenHash: row.token_hash }; +} + +export function revokeAllUserSessions(userId: number): number { + const result = db.run(`UPDATE sessions SET revoked=1 WHERE user_id=? AND revoked=0`, userId); + return result.changes; +} + +export function pruneExpiredSessions(): number { + const now = Math.floor(Date.now() / 1000); + const result = db.run(`DELETE FROM sessions WHERE expires_at <= ?`, now); + return result.changes; +} + +export function hashTokenForSession(token: string): string { + return hashToken(token); +} + +export function deleteInactiveSessions(userId: number): number { + const now = Math.floor(Date.now() / 1000); + const result = db.run( + `DELETE FROM sessions WHERE user_id = ? AND (revoked = 1 OR expires_at <= ?)`, + userId, + now, + ); + return result.changes; +} + +function mapSessionRow(row: any): SessionRecord { + return { + id: row.id, + userId: row.user_id, + tokenHash: row.token_hash, + ip: row.ip, + userAgent: row.user_agent, + createdAt: row.created_at, + lastActivity: row.last_activity, + expiresAt: row.expires_at, + revoked: !!row.revoked, + }; +} diff --git a/Overlord-Server/src/db/repositories/shared-files.ts b/Overlord-Server/src/db/repositories/shared-files.ts new file mode 100644 index 0000000..df2a5a5 --- /dev/null +++ b/Overlord-Server/src/db/repositories/shared-files.ts @@ -0,0 +1,146 @@ +import { db } from "../connection"; +import "../schema"; + +export type SharedFileRecord = { + id: string; + filename: string; + storedPath: string; + size: number; + mimeType: string; + uploadedBy: number; + uploadedByUsername: string; + passwordHash: string | null; + maxDownloads: number | null; + downloadCount: number; + expiresAt: number | null; + createdAt: number; + description: string | null; +}; + +export function insertSharedFile(file: SharedFileRecord): void { + db.run( + `INSERT INTO shared_files (id, filename, stored_path, size, mime_type, uploaded_by, uploaded_by_username, password_hash, max_downloads, download_count, expires_at, created_at, description) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + file.id, + file.filename, + file.storedPath, + file.size, + file.mimeType, + file.uploadedBy, + file.uploadedByUsername, + file.passwordHash, + file.maxDownloads, + file.downloadCount, + file.expiresAt, + file.createdAt, + file.description, + ); +} + +export function getSharedFile(id: string): SharedFileRecord | null { + const row = db.query(`SELECT * FROM shared_files WHERE id=?`).get(id); + if (!row) return null; + return { + id: row.id, + filename: row.filename, + storedPath: row.stored_path, + size: row.size, + mimeType: row.mime_type, + uploadedBy: row.uploaded_by, + uploadedByUsername: row.uploaded_by_username, + passwordHash: row.password_hash, + maxDownloads: row.max_downloads, + downloadCount: row.download_count, + expiresAt: row.expires_at, + createdAt: row.created_at, + description: row.description, + }; +} + +export function listSharedFiles(): SharedFileRecord[] { + return db + .query(`SELECT * FROM shared_files ORDER BY created_at DESC`) + .all() + .map((row: any) => ({ + id: row.id, + filename: row.filename, + storedPath: row.stored_path, + size: row.size, + mimeType: row.mime_type, + uploadedBy: row.uploaded_by, + uploadedByUsername: row.uploaded_by_username, + passwordHash: row.password_hash, + maxDownloads: row.max_downloads, + downloadCount: row.download_count, + expiresAt: row.expires_at, + createdAt: row.created_at, + description: row.description, + })); +} + +export function deleteSharedFile(id: string): boolean { + const result = db.run(`DELETE FROM shared_files WHERE id=?`, id); + return (result as any)?.changes ? (result as any).changes > 0 : false; +} + +export function updateSharedFile( + id: string, + updates: { + passwordHash?: string | null; + maxDownloads?: number | null; + expiresAt?: number | null; + description?: string | null; + }, +): boolean { + const setClauses: string[] = []; + const values: any[] = []; + + if (updates.passwordHash !== undefined) { + setClauses.push("password_hash=?"); + values.push(updates.passwordHash); + } + if (updates.maxDownloads !== undefined) { + setClauses.push("max_downloads=?"); + values.push(updates.maxDownloads); + } + if (updates.expiresAt !== undefined) { + setClauses.push("expires_at=?"); + values.push(updates.expiresAt); + } + if (updates.description !== undefined) { + setClauses.push("description=?"); + values.push(updates.description); + } + + if (setClauses.length === 0) return false; + + values.push(id); + const result = db.run( + `UPDATE shared_files SET ${setClauses.join(", ")} WHERE id=?`, + ...values, + ); + return (result as any)?.changes ? (result as any).changes > 0 : false; +} + +export function incrementSharedFileDownloadCount(id: string): boolean { + const result = db.run( + `UPDATE shared_files SET download_count = download_count + 1 WHERE id=?`, + id, + ); + return (result as any)?.changes ? (result as any).changes > 0 : false; +} + +export function deleteExpiredSharedFiles(): string[] { + const now = Date.now(); + const expired = db + .query(`SELECT id, stored_path FROM shared_files WHERE expires_at IS NOT NULL AND expires_at < ?`) + .all(now); + const ids = expired.map((r: any) => r.id); + const paths = expired.map((r: any) => r.stored_path); + if (ids.length > 0) { + for (const id of ids) { + db.run(`DELETE FROM shared_files WHERE id=?`, id); + } + } + return paths; +} diff --git a/Overlord-Server/src/db/repositories/token-hash.ts b/Overlord-Server/src/db/repositories/token-hash.ts new file mode 100644 index 0000000..219ea45 --- /dev/null +++ b/Overlord-Server/src/db/repositories/token-hash.ts @@ -0,0 +1,3 @@ +export function hashToken(token: string): string { + return new Bun.CryptoHasher("sha256").update(token).digest("hex"); +}