Files
overlord/Overlord-Server/src/db.ts
T

934 lines
26 KiB
TypeScript
Raw Normal View History

2026-03-05 00:04:26 -06:00
import Database from "bun:sqlite";
import { ClientInfo, ListFilters, ListResult, ClientRole } from "./types";
import { getThumbnail } from "./thumbnails";
import { resolve } from "path";
import { ensureDataDir } from "./paths";
const dataDir = ensureDataDir();
const dbPath = resolve(dataDir, "overlord.db");
const db = new Database(dbPath);
console.log(`[db] Using database at: ${dbPath}`);
db.run(`
CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
hwid TEXT,
role TEXT,
ip TEXT,
host TEXT,
os TEXT,
arch TEXT,
version TEXT,
user TEXT,
nickname TEXT,
custom_tag TEXT,
custom_tag_note TEXT,
2026-03-05 00:04:26 -06:00
monitors INTEGER,
country TEXT,
last_seen INTEGER,
online INTEGER,
ping_ms INTEGER,
bookmarked INTEGER NOT NULL DEFAULT 0
2026-03-05 00:04:26 -06:00
);
`);
try {
db.run(`ALTER TABLE clients ADD COLUMN role TEXT`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN hwid TEXT`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN ip TEXT`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN nickname TEXT`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN custom_tag TEXT`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN custom_tag_note TEXT`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN bookmarked INTEGER NOT NULL DEFAULT 0`);
} catch {}
2026-03-20 22:38:17 -05:00
try {
db.run(`ALTER TABLE clients ADD COLUMN enrollment_status TEXT NOT NULL DEFAULT 'pending'`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN public_key TEXT`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN key_fingerprint TEXT`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN enrolled_at INTEGER`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN enrolled_by TEXT`);
} catch {}
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_public_key ON clients(public_key);`,
);
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_key_fingerprint ON clients(key_fingerprint);`,
);
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_enrollment_status ON clients(enrollment_status);`,
);
2026-03-09 22:43:33 -05:00
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_online_last_seen ON clients(online, last_seen DESC);`,
);
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_os_last_seen ON clients(os, last_seen DESC);`,
);
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_ping_ms ON clients(ping_ms);`,
);
2026-03-05 00:04:26 -06:00
db.run(`
CREATE TABLE IF NOT EXISTS banned_ips (
ip TEXT PRIMARY KEY,
reason TEXT,
created_at INTEGER NOT NULL
);
`);
db.run(
`CREATE INDEX IF NOT EXISTS idx_banned_ips_created_at ON banned_ips(created_at DESC);`,
);
db.run(`
CREATE TABLE IF NOT EXISTS builds (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
start_time INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
2026-03-12 21:26:53 -05:00
files TEXT NOT NULL,
build_tag TEXT,
built_by_user_id INTEGER
2026-03-05 00:04:26 -06:00
);
`);
2026-03-12 21:26:53 -05:00
try { db.run(`ALTER TABLE builds ADD COLUMN build_tag TEXT`); } catch {}
try { db.run(`ALTER TABLE builds ADD COLUMN built_by_user_id INTEGER`); } catch {}
db.run(`CREATE INDEX IF NOT EXISTS idx_builds_build_tag ON builds(build_tag);`);
2026-03-05 00:04:26 -06:00
db.run(`
CREATE TABLE IF NOT EXISTS notification_screenshots (
id TEXT PRIMARY KEY,
notification_id TEXT NOT NULL,
client_id TEXT NOT NULL,
ts INTEGER NOT NULL,
format TEXT NOT NULL,
width INTEGER,
height INTEGER,
bytes BLOB NOT NULL
);
`);
db.run(
`CREATE INDEX IF NOT EXISTS idx_notification_screenshots_notification_id ON notification_screenshots(notification_id);`,
);
db.run(
`CREATE INDEX IF NOT EXISTS idx_notification_screenshots_ts ON notification_screenshots(ts DESC);`,
);
db.run(`
CREATE TABLE IF NOT EXISTS auto_scripts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
trigger TEXT NOT NULL,
script TEXT NOT NULL,
script_type TEXT NOT NULL,
enabled INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
db.run(
`CREATE INDEX IF NOT EXISTS idx_auto_scripts_trigger ON auto_scripts(trigger, enabled);`,
);
2026-03-17 13:27:44 -05:00
try {
db.run(`ALTER TABLE auto_scripts ADD COLUMN os_filter TEXT NOT NULL DEFAULT '[]'`);
} catch { /* column already exists */ }
2026-03-05 00:04:26 -06:00
db.run(`
CREATE TABLE IF NOT EXISTS auto_script_runs (
script_id TEXT NOT NULL,
client_id TEXT NOT NULL,
ts INTEGER NOT NULL,
PRIMARY KEY (script_id, client_id)
);
`);
db.run(
`CREATE INDEX IF NOT EXISTS idx_auto_script_runs_ts ON auto_script_runs(ts DESC);`,
);
export function upsertClientRow(
partial: Partial<ClientInfo> & {
id: string;
lastSeen?: number;
online?: number;
},
) {
const now = partial.lastSeen ?? Date.now();
db.run(
2026-03-20 22:38:17 -05:00
`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, enrollment_status, public_key, key_fingerprint)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2026-03-05 00:04:26 -06:00
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,
2026-03-05 00:04:26 -06:00
monitors=COALESCE(excluded.monitors, clients.monitors),
country=COALESCE(excluded.country, clients.country),
last_seen=excluded.last_seen,
online=COALESCE(excluded.online, clients.online),
2026-03-20 22:38:17 -05:00
ping_ms=COALESCE(excluded.ping_ms, clients.ping_ms),
public_key=COALESCE(excluded.public_key, clients.public_key),
key_fingerprint=COALESCE(excluded.key_fingerprint, clients.key_fingerprint)
2026-03-05 00:04:26 -06:00
`,
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,
2026-03-05 00:04:26 -06:00
partial.monitors ?? null,
partial.country ?? null,
now,
partial.online ?? 0,
partial.pingMs ?? null,
2026-03-20 22:38:17 -05:00
partial.enrollmentStatus ?? "pending",
partial.publicKey ?? null,
partial.keyFingerprint ?? null,
2026-03-05 00:04:26 -06:00
);
if (partial.hwid) {
db.run(
`DELETE FROM clients WHERE hwid=? AND id<>?`,
partial.hwid,
partial.id,
);
}
}
export function setOnlineState(id: string, online: boolean) {
db.run(
`UPDATE clients SET online=?, last_seen=? WHERE id=?`,
online ? 1 : 0,
Date.now(),
id,
);
}
export function deleteClientRow(id: string) {
db.run(`DELETE FROM clients WHERE id=?`, id);
}
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;
}
2026-03-05 00:04:26 -06:00
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);
}
2026-03-06 20:50:02 -06:00
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,
}));
}
2026-03-05 00:04:26 -06:00
export function isIpBanned(ip: string): boolean {
const row = db.query<{ ip: string }>(`SELECT ip FROM banned_ips WHERE ip=?`).get(ip);
return !!row?.ip;
}
export function markAllClientsOffline() {
db.run(`UPDATE clients SET online=0`);
console.log("[db] marked all clients as offline");
}
export function listClients(filters: ListFilters): ListResult {
2026-03-06 20:50:02 -06:00
const {
page,
pageSize,
search,
sort,
statusFilter,
osFilter,
countryFilter,
2026-03-20 22:38:17 -05:00
enrollmentFilter,
2026-03-06 20:50:02 -06:00
allowedClientIds,
deniedClientIds,
} = filters;
2026-03-05 00:04:26 -06:00
const where: string[] = [];
const params: any[] = [];
if (search) {
where.push(
"(LOWER(COALESCE(host,'')) LIKE ? OR LOWER(COALESCE(user,'')) LIKE ? OR LOWER(COALESCE(nickname,'')) LIKE ? OR LOWER(COALESCE(custom_tag,'')) LIKE ? OR LOWER(COALESCE(custom_tag_note,'')) LIKE ? OR LOWER(id) LIKE ?)",
2026-03-05 00:04:26 -06:00
);
const needle = `%${search}%`;
params.push(needle, needle, needle, needle, needle, needle);
2026-03-05 00:04:26 -06:00
}
if (statusFilter === "online") {
where.push("online=1");
} else if (statusFilter === "offline") {
where.push("online=0");
}
2026-03-20 22:38:17 -05:00
if (enrollmentFilter && enrollmentFilter !== "all") {
where.push("enrollment_status=?");
params.push(enrollmentFilter);
}
2026-03-05 00:04:26 -06:00
if (osFilter && osFilter !== "all") {
where.push("os=?");
params.push(osFilter);
}
if (countryFilter && countryFilter !== "all") {
where.push("UPPER(COALESCE(country,'ZZ'))=?");
params.push(countryFilter.toUpperCase());
}
2026-03-06 20:50:02 -06:00
if (Array.isArray(allowedClientIds)) {
if (allowedClientIds.length === 0) {
where.push("1=0");
} else {
where.push(`id IN (${allowedClientIds.map(() => "?").join(",")})`);
params.push(...allowedClientIds);
}
}
if (Array.isArray(deniedClientIds) && deniedClientIds.length > 0) {
where.push(`id NOT IN (${deniedClientIds.map(() => "?").join(",")})`);
params.push(...deniedClientIds);
}
2026-03-05 00:04:26 -06:00
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const orderBy = (() => {
const bookmark = "bookmarked DESC";
2026-03-05 00:04:26 -06:00
switch (sort) {
2026-03-17 00:42:57 -05:00
case "stable":
return `ORDER BY ${bookmark}, online DESC, id ASC`;
2026-03-05 00:04:26 -06:00
case "ping_asc":
return `ORDER BY ${bookmark}, ping_ms IS NULL, ping_ms ASC, id ASC`;
2026-03-05 00:04:26 -06:00
case "ping_desc":
return `ORDER BY ${bookmark}, ping_ms IS NULL, ping_ms DESC, id ASC`;
2026-03-05 00:04:26 -06:00
case "host_asc":
return `ORDER BY ${bookmark}, LOWER(COALESCE(nickname, host)) ASC, id ASC`;
2026-03-05 00:04:26 -06:00
case "host_desc":
return `ORDER BY ${bookmark}, LOWER(COALESCE(nickname, host)) DESC, id ASC`;
case "country_asc":
return `ORDER BY ${bookmark}, LOWER(COALESCE(country, 'zz')) ASC, id ASC`;
case "country_desc":
return `ORDER BY ${bookmark}, LOWER(COALESCE(country, 'zz')) DESC, id ASC`;
2026-03-05 00:04:26 -06:00
default:
return `ORDER BY ${bookmark}, last_seen DESC, id ASC`;
2026-03-05 00:04:26 -06:00
}
})();
const totalRow = db
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients ${whereSql}`)
.get(...params) ?? { c: 0 };
const onlineRow = db
2026-03-06 20:50:02 -06:00
.query<{ c: number }>(
`SELECT COUNT(*) as c FROM clients ${whereSql ? `${whereSql} AND online=1` : "WHERE online=1"}`,
)
.get(...params) ?? { c: 0 };
2026-03-05 00:04:26 -06:00
const offset = (page - 1) * pageSize;
const rows = db
.query<any>(
2026-03-20 22:38:17 -05:00
`SELECT id, hwid, role, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs, bookmarked, enrollment_status as enrollmentStatus, public_key as publicKey, key_fingerprint as keyFingerprint
2026-03-05 00:04:26 -06:00
FROM clients
${whereSql}
${orderBy}
LIMIT ? OFFSET ?`,
)
.all(...params, pageSize, offset);
const items = rows.map((c: any) => ({
id: c.id,
hwid: c.hwid,
role: (c.role as ClientRole) || "client",
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,
2026-03-05 00:04:26 -06:00
monitors: c.monitors,
country: c.country || "ZZ",
pingMs: c.pingMs ?? null,
online: c.online === 1,
bookmarked: c.bookmarked === 1,
2026-03-20 22:38:17 -05:00
enrollmentStatus: c.enrollmentStatus || "pending",
publicKey: c.publicKey || null,
keyFingerprint: c.keyFingerprint || null,
2026-03-05 00:04:26 -06:00
thumbnail: getThumbnail(c.id),
}));
return { page, pageSize, total: totalRow.c, online: onlineRow.c, items };
}
2026-03-09 22:43:33 -05:00
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,
};
}
2026-03-05 00:04:26 -06:00
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;
2026-03-17 13:27:44 -05:00
osFilter: string[];
2026-03-05 00:04:26 -06:00
createdAt: number;
updatedAt: number;
};
function mapAutoScriptRow(row: any): AutoScript {
2026-03-17 13:27:44 -05:00
let osFilter: string[] = [];
try {
const parsed = JSON.parse(row.os_filter || "[]");
osFilter = Array.isArray(parsed) ? parsed : [];
} catch { }
2026-03-05 00:04:26 -06:00
return {
id: row.id,
name: row.name,
trigger: row.trigger as AutoScriptTrigger,
script: row.script,
scriptType: row.script_type,
enabled: row.enabled === 1,
2026-03-17 13:27:44 -05:00
osFilter,
2026-03-05 00:04:26 -06:00
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;
2026-03-17 13:27:44 -05:00
osFilter: string[];
2026-03-05 00:04:26 -06:00
}): AutoScript {
const now = Date.now();
db.run(
2026-03-17 13:27:44 -05:00
`INSERT INTO auto_scripts (id, name, trigger, script, script_type, enabled, os_filter, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
2026-03-05 00:04:26 -06:00
,
input.id,
input.name,
input.trigger,
input.script,
input.scriptType,
input.enabled ? 1 : 0,
2026-03-17 13:27:44 -05:00
JSON.stringify(input.osFilter ?? []),
2026-03-05 00:04:26 -06:00
now,
now,
);
return getAutoScript(input.id)!;
}
export function updateAutoScript(
id: string,
input: Partial<{
name: string;
trigger: AutoScriptTrigger;
script: string;
scriptType: string;
enabled: boolean;
2026-03-17 13:27:44 -05:00
osFilter: string[];
2026-03-05 00:04:26 -06:00
}>,
): 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,
2026-03-17 13:27:44 -05:00
osFilter: Array.isArray(input.osFilter) ? input.osFilter : current.osFilter,
2026-03-05 00:04:26 -06:00
};
db.run(
2026-03-17 13:27:44 -05:00
`UPDATE auto_scripts SET name=?, trigger=?, script=?, script_type=?, enabled=?, os_filter=?, updated_at=? WHERE id=?`
2026-03-05 00:04:26 -06:00
,
next.name,
next.trigger,
next.script,
next.scriptType,
next.enabled ? 1 : 0,
2026-03-17 13:27:44 -05:00
JSON.stringify(next.osFilter),
2026-03-05 00:04:26 -06:00
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;
}
2026-03-05 00:04:26 -06:00
export interface BuildRecord {
id: string;
status: string;
startTime: number;
expiresAt: number;
files: Array<{
name: string;
filename: string;
platform: string;
2026-03-12 12:05:42 -05:00
version?: string;
2026-03-05 00:04:26 -06:00
size: number;
}>;
2026-03-12 21:26:53 -05:00
buildTag?: string;
builtByUserId?: number;
2026-03-05 00:04:26 -06:00
}
export function saveBuild(build: BuildRecord) {
db.run(
2026-03-12 21:26:53 -05:00
`INSERT OR REPLACE INTO builds (id, status, start_time, expires_at, files, build_tag, built_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
2026-03-05 00:04:26 -06:00
build.id,
build.status,
build.startTime,
build.expiresAt,
JSON.stringify(build.files),
2026-03-12 21:26:53 -05:00
build.buildTag || null,
build.builtByUserId || null,
2026-03-05 00:04:26 -06:00
);
}
export function getBuild(id: string): BuildRecord | null {
const row = db.query<any>(`SELECT * FROM builds WHERE id = ?`).get(id);
if (!row) return null;
return {
id: row.id,
status: row.status,
startTime: row.start_time,
expiresAt: row.expires_at,
files: JSON.parse(row.files),
2026-03-12 21:26:53 -05:00
buildTag: row.build_tag || undefined,
builtByUserId: row.built_by_user_id || undefined,
};
}
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 {
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,
2026-03-05 00:04:26 -06:00
};
}
export function getAllBuilds(userId?: number, role?: string): BuildRecord[] {
2026-03-16 18:48:41 -05:00
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((row) => ({
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,
}));
}
2026-03-05 00:04:26 -06:00
const rows = db
.query<any>("SELECT * FROM builds ORDER BY start_time DESC")
2026-03-05 00:04:26 -06:00
.all();
return rows.map((row) => ({
id: row.id,
status: row.status,
startTime: row.start_time,
expiresAt: row.expires_at,
files: JSON.parse(row.files),
2026-03-12 21:26:53 -05:00
buildTag: row.build_tag || undefined,
builtByUserId: row.built_by_user_id || undefined,
2026-03-05 00:04:26 -06:00
}));
}
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 function clearNotificationScreenshots() {
db.run(`DELETE FROM notification_screenshots`);
console.log("[db] cleared notification screenshots");
}
2026-03-20 22:38:17 -05:00
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,
): boolean {
const result = db.run(
`UPDATE clients SET enrollment_status=?, enrolled_at=?, enrolled_by=? WHERE id=?`,
status,
status === "approved" ? Date.now() : null,
status === "approved" ? (approvedBy ?? 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(): {
pending: number;
approved: number;
denied: number;
} {
const rows = db
.query<{ status: string; c: number }>(
`SELECT COALESCE(enrollment_status,'pending') as status, COUNT(*) as c FROM clients GROUP BY enrollment_status`,
)
.all();
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 function getPendingClients(): {
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;
}[] {
return db
.query<any>(
`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' ORDER BY last_seen DESC`,
)
.all()
.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,
}));
}