fixed dashboard cards, add fuse js, fix load times, fix csp in scripts, fix remote desktop not working first try.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { deleteClientRow, listClients, setClientBookmark, upsertClientRow } from "./db";
|
||||
import { deleteClientRow, listClients, setClientBookmark, setClientTag, upsertClientRow } from "./db";
|
||||
|
||||
const createdClientIds: string[] = [];
|
||||
|
||||
@@ -110,4 +110,33 @@ describe("client list ordering", () => {
|
||||
cleanupCreatedClients();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("search tolerates typos across client metadata", () => {
|
||||
try {
|
||||
const prefix = `fuse-${Date.now().toString(36)}`;
|
||||
const id = `${prefix}-finance-terminal`;
|
||||
|
||||
createTempClient(id, {
|
||||
online: true,
|
||||
lastSeen: Date.now(),
|
||||
host: "finance-terminal",
|
||||
});
|
||||
setClientTag(id, "Payroll Workstation", "Quarterly reporting machine");
|
||||
|
||||
const result = listClients({
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
search: "payrol workstaton",
|
||||
sort: "last_seen_desc",
|
||||
statusFilter: "all",
|
||||
osFilter: "all",
|
||||
countryFilter: "all",
|
||||
enrollmentFilter: "all",
|
||||
});
|
||||
|
||||
expect(result.items.some((item) => item.id === id)).toBe(true);
|
||||
} finally {
|
||||
cleanupCreatedClients();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClientInfo, ListFilters, ListResult, ClientRole } from "../types";
|
||||
import Fuse from "fuse.js";
|
||||
import { getThumbnailSummaries } from "../thumbnails";
|
||||
import { db, dbPath } from "./connection";
|
||||
import "./schema";
|
||||
@@ -770,6 +771,119 @@ export function computeClientSuspiciousFlags(
|
||||
return flags;
|
||||
}
|
||||
|
||||
const CLIENT_SEARCH_KEYS = [
|
||||
{ name: "nickname", weight: 0.32 },
|
||||
{ name: "host", weight: 0.28 },
|
||||
{ name: "user", weight: 0.18 },
|
||||
{ name: "id", weight: 0.18 },
|
||||
{ name: "customTag", weight: 0.2 },
|
||||
{ name: "customTagNote", weight: 0.1 },
|
||||
{ name: "groupName", weight: 0.14 },
|
||||
{ name: "os", weight: 0.12 },
|
||||
{ name: "ip", weight: 0.1 },
|
||||
{ name: "hwid", weight: 0.08 },
|
||||
{ name: "country", weight: 0.06 },
|
||||
{ name: "version", weight: 0.06 },
|
||||
{ name: "buildTag", weight: 0.06 },
|
||||
{ name: "cpu", weight: 0.05 },
|
||||
{ name: "gpu", weight: 0.05 },
|
||||
{ name: "ram", weight: 0.04 },
|
||||
] as const;
|
||||
|
||||
const CLIENT_SEARCH_SQL_COLUMNS = [
|
||||
"COALESCE(c.nickname,'')",
|
||||
"COALESCE(c.host,'')",
|
||||
"COALESCE(c.user,'')",
|
||||
"c.id",
|
||||
"COALESCE(c.custom_tag,'')",
|
||||
"COALESCE(c.custom_tag_note,'')",
|
||||
"COALESCE(g.name,'')",
|
||||
"COALESCE(c.os,'')",
|
||||
"COALESCE(c.ip,'')",
|
||||
"COALESCE(c.hwid,'')",
|
||||
"COALESCE(c.country,'')",
|
||||
"COALESCE(c.version,'')",
|
||||
"COALESCE(c.build_tag,'')",
|
||||
"COALESCE(c.cpu,'')",
|
||||
"COALESCE(c.gpu,'')",
|
||||
"COALESCE(c.ram,'')",
|
||||
] as const;
|
||||
|
||||
function escapeLikeTerm(term: string): string {
|
||||
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
|
||||
}
|
||||
|
||||
function buildFuzzyCandidateWhere(search: string): { sql: string; params: string[] } {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return { sql: "", params: [] };
|
||||
|
||||
const terms = Array.from(new Set([
|
||||
query,
|
||||
...query.split(/[\s,;:|/\\]+/),
|
||||
].map((term) => term.trim()).filter((term) => term.length >= 2))).slice(0, 8);
|
||||
|
||||
if (!terms.length) return { sql: "", params: [] };
|
||||
|
||||
const fieldClause = CLIENT_SEARCH_SQL_COLUMNS
|
||||
.map((column) => `LOWER(${column}) LIKE ? ESCAPE '\\'`)
|
||||
.join(" OR ");
|
||||
const sql = terms.map(() => `(${fieldClause})`).join(" OR ");
|
||||
const params = terms.flatMap((term) => {
|
||||
const like = `%${escapeLikeTerm(term)}%`;
|
||||
return CLIENT_SEARCH_SQL_COLUMNS.map(() => like);
|
||||
});
|
||||
|
||||
return { sql: `(${sql})`, params };
|
||||
}
|
||||
|
||||
function buildClientSearchFtsQuery(search: string): string {
|
||||
const terms = Array.from(new Set(
|
||||
search
|
||||
.trim()
|
||||
.split(/[\s,;:|/\\]+/)
|
||||
.map((term) => term.replace(/["*]/g, "").trim())
|
||||
.filter((term) => term.length >= 2),
|
||||
)).slice(0, 8);
|
||||
|
||||
return terms.map((term) => `"${term}"*`).join(" OR ");
|
||||
}
|
||||
|
||||
function getFtsClientSearchCandidateIds(search: string, limit: number): string[] | null {
|
||||
const ftsQuery = buildClientSearchFtsQuery(search);
|
||||
if (!ftsQuery) return [];
|
||||
|
||||
try {
|
||||
return db
|
||||
.query<{ id: string }>(
|
||||
`SELECT id
|
||||
FROM client_search_fts
|
||||
WHERE client_search_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(ftsQuery, limit)
|
||||
.map((row) => row.id)
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fuzzySearchClientRows(rows: any[], search: string): any[] {
|
||||
const query = search.trim();
|
||||
if (!query) return rows;
|
||||
|
||||
const fuse = new Fuse(rows, {
|
||||
keys: CLIENT_SEARCH_KEYS as any,
|
||||
threshold: 0.36,
|
||||
distance: 120,
|
||||
ignoreLocation: true,
|
||||
shouldSort: true,
|
||||
});
|
||||
|
||||
return fuse.search(query).map((result) => result.item);
|
||||
}
|
||||
|
||||
export function listClients(filters: ListFilters): ListResult {
|
||||
const {
|
||||
page,
|
||||
@@ -789,14 +903,6 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
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") {
|
||||
@@ -859,11 +965,9 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
|
||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||
const needsGroupJoinForFilter =
|
||||
!!search ||
|
||||
sort === "group_asc" ||
|
||||
sort === "group_desc" ||
|
||||
(groupFilter !== undefined && groupFilter !== "all");
|
||||
const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : "";
|
||||
|
||||
const orderBy = (() => {
|
||||
const online = "c.online DESC";
|
||||
@@ -894,49 +998,99 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
}
|
||||
})();
|
||||
|
||||
const totalRow = db
|
||||
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql}`)
|
||||
.get(...params) ?? { c: 0 };
|
||||
const onlineRow = db
|
||||
.query<{ c: number }>(
|
||||
`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`,
|
||||
)
|
||||
.get(...params) ?? { c: 0 };
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const clientFields =
|
||||
"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.battery_percent as batteryPercent, c.battery_charging as batteryCharging, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason";
|
||||
|
||||
const rows = needsGroupJoinForFilter
|
||||
? db
|
||||
.query<any>(
|
||||
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
|
||||
FROM clients c
|
||||
LEFT JOIN client_groups g ON g.id = c.group_id
|
||||
${whereSql}
|
||||
${orderBy}
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, pageSize, offset)
|
||||
: db
|
||||
.query<any>(
|
||||
`WITH page_clients AS (
|
||||
SELECT ${clientFields}
|
||||
let rows: any[] | undefined;
|
||||
let totalCount = 0;
|
||||
let onlineCount = 0;
|
||||
|
||||
if (search) {
|
||||
const searchWhere = [...where];
|
||||
const searchParams = [...params];
|
||||
const candidateLimit = Math.min(5000, Math.max(1000, offset + pageSize * 8));
|
||||
const ftsCandidateIds = getFtsClientSearchCandidateIds(search, candidateLimit);
|
||||
|
||||
if (ftsCandidateIds) {
|
||||
if (!ftsCandidateIds.length) {
|
||||
rows = [];
|
||||
totalCount = 0;
|
||||
onlineCount = 0;
|
||||
} else {
|
||||
searchWhere.push(`c.id IN (${ftsCandidateIds.map(() => "?").join(",")})`);
|
||||
searchParams.push(...ftsCandidateIds);
|
||||
}
|
||||
} else {
|
||||
const searchCandidate = buildFuzzyCandidateWhere(search);
|
||||
if (searchCandidate.sql) {
|
||||
searchWhere.push(searchCandidate.sql);
|
||||
searchParams.push(...searchCandidate.params);
|
||||
}
|
||||
}
|
||||
|
||||
if (rows === undefined) {
|
||||
const searchWhereSql = searchWhere.length ? `WHERE ${searchWhere.join(" AND ")}` : "";
|
||||
const candidates = db
|
||||
.query<any>(
|
||||
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
|
||||
FROM clients c
|
||||
LEFT JOIN client_groups g ON g.id = c.group_id
|
||||
${searchWhereSql}
|
||||
${orderBy}`,
|
||||
)
|
||||
.all(...searchParams);
|
||||
const matches = fuzzySearchClientRows(candidates, search);
|
||||
totalCount = matches.length;
|
||||
onlineCount = matches.filter((row: any) => row.online === 1).length;
|
||||
rows = matches.slice(offset, offset + pageSize);
|
||||
}
|
||||
} else {
|
||||
const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : "";
|
||||
const totalRow = db
|
||||
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql}`)
|
||||
.get(...params) ?? { c: 0 };
|
||||
const onlineRow = db
|
||||
.query<{ c: number }>(
|
||||
`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`,
|
||||
)
|
||||
.get(...params) ?? { c: 0 };
|
||||
totalCount = totalRow.c;
|
||||
onlineCount = onlineRow.c;
|
||||
|
||||
rows = needsGroupJoinForFilter
|
||||
? db
|
||||
.query<any>(
|
||||
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
|
||||
FROM clients c
|
||||
LEFT JOIN client_groups g ON g.id = c.group_id
|
||||
${whereSql}
|
||||
${orderBy}
|
||||
LIMIT ? OFFSET ?
|
||||
)
|
||||
SELECT pc.*, g.name as groupName, g.color as groupColor
|
||||
FROM page_clients pc
|
||||
LEFT JOIN client_groups g ON g.id = pc.groupId`,
|
||||
)
|
||||
.all(...params, pageSize, offset);
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, pageSize, offset)
|
||||
: db
|
||||
.query<any>(
|
||||
`WITH page_clients AS (
|
||||
SELECT ${clientFields}
|
||||
FROM clients c
|
||||
${whereSql}
|
||||
${orderBy}
|
||||
LIMIT ? OFFSET ?
|
||||
)
|
||||
SELECT pc.*, g.name as groupName, g.color as groupColor
|
||||
FROM page_clients pc
|
||||
LEFT JOIN client_groups g ON g.id = pc.groupId`,
|
||||
)
|
||||
.all(...params, pageSize, offset);
|
||||
}
|
||||
|
||||
const shouldScanSuspicious = SUSPICIOUS_SCAN_MAX_CLIENTS === 0 || totalRow.c <= SUSPICIOUS_SCAN_MAX_CLIENTS;
|
||||
const shouldScanSuspicious = !search && (SUSPICIOUS_SCAN_MAX_CLIENTS === 0 || totalCount <= SUSPICIOUS_SCAN_MAX_CLIENTS);
|
||||
const suspiciousSets = shouldScanSuspicious
|
||||
? getFloodSetsWithCache()
|
||||
: { floodedHwids: new Set<string>(), floodedHardware: new Set<string>(), floodedIps: new Set<string>() };
|
||||
rows = rows || [];
|
||||
const thumbnailSummaries = getThumbnailSummaries(rows.map((row: any) => row.id));
|
||||
|
||||
const items = rows.map((c: any) => {
|
||||
@@ -986,7 +1140,7 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
};
|
||||
});
|
||||
|
||||
return { page, pageSize, total: totalRow.c, online: onlineRow.c, items };
|
||||
return { page, pageSize, total: totalCount, online: onlineCount, items };
|
||||
}
|
||||
|
||||
export type ClientMetricsSummary = {
|
||||
@@ -998,7 +1152,56 @@ export type ClientMetricsSummary = {
|
||||
byCountryOnline: Record<string, number>;
|
||||
};
|
||||
|
||||
const CLIENT_METRICS_SUMMARY_TTL_MS = 4_000;
|
||||
let clientMetricsSummaryCache: { expiresAt: number; summary: ClientMetricsSummary } | null = null;
|
||||
const userClientMetricsSummaryCache = new Map<number, { expiresAt: number; summary: ClientMetricsSummary }>();
|
||||
|
||||
function cloneClientMetricsSummary(summary: ClientMetricsSummary): ClientMetricsSummary {
|
||||
return {
|
||||
total: summary.total,
|
||||
online: summary.online,
|
||||
byOS: { ...summary.byOS },
|
||||
byCountry: { ...summary.byCountry },
|
||||
byOSOnline: { ...summary.byOSOnline },
|
||||
byCountryOnline: { ...summary.byCountryOnline },
|
||||
};
|
||||
}
|
||||
|
||||
function makeClientMetricsSummary(
|
||||
counts: { total: number | null; online: number | null },
|
||||
osRows: { key: string; total: number; online: number | null }[],
|
||||
countryRows: { key: string; total: number; online: number | null }[],
|
||||
): ClientMetricsSummary {
|
||||
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 getClientMetricsSummary(): ClientMetricsSummary {
|
||||
const now = Date.now();
|
||||
if (clientMetricsSummaryCache && clientMetricsSummaryCache.expiresAt > now) {
|
||||
return cloneClientMetricsSummary(clientMetricsSummaryCache.summary);
|
||||
}
|
||||
|
||||
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`,
|
||||
@@ -1027,31 +1230,21 @@ export function getClientMetricsSummary(): ClientMetricsSummary {
|
||||
)
|
||||
.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,
|
||||
const summary = makeClientMetricsSummary(counts, osRows, countryRows);
|
||||
clientMetricsSummaryCache = {
|
||||
expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS,
|
||||
summary,
|
||||
};
|
||||
return cloneClientMetricsSummary(summary);
|
||||
}
|
||||
|
||||
export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSummary {
|
||||
const now = Date.now();
|
||||
const cached = userClientMetricsSummaryCache.get(userId);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cloneClientMetricsSummary(cached.summary);
|
||||
}
|
||||
|
||||
const filter = `WHERE built_by_user_id = ?`;
|
||||
|
||||
const counts = db
|
||||
@@ -1082,28 +1275,12 @@ export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSum
|
||||
)
|
||||
.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,
|
||||
};
|
||||
const summary = makeClientMetricsSummary(counts, osRows, countryRows);
|
||||
userClientMetricsSummaryCache.set(userId, {
|
||||
expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS,
|
||||
summary,
|
||||
});
|
||||
return cloneClientMetricsSummary(summary);
|
||||
}
|
||||
|
||||
export function getOnlineClientCountForUser(userId: number): number {
|
||||
|
||||
@@ -148,6 +148,82 @@ try {
|
||||
} catch {}
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_clients_group_id ON clients(group_id);`);
|
||||
|
||||
try {
|
||||
db.run(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS client_search_fts USING fts5(
|
||||
id UNINDEXED,
|
||||
host,
|
||||
user,
|
||||
nickname,
|
||||
custom_tag,
|
||||
custom_tag_note,
|
||||
os,
|
||||
ip,
|
||||
hwid,
|
||||
country,
|
||||
version,
|
||||
build_tag,
|
||||
cpu,
|
||||
gpu,
|
||||
ram,
|
||||
tokenize = 'unicode61'
|
||||
);
|
||||
`);
|
||||
|
||||
const counts = db
|
||||
.query<{ clients: number; indexed: number }>(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM clients) as clients,
|
||||
(SELECT COUNT(*) FROM client_search_fts) as indexed`,
|
||||
)
|
||||
.get() ?? { clients: 0, indexed: 0 };
|
||||
|
||||
if (counts.clients !== counts.indexed) {
|
||||
db.run(`DELETE FROM client_search_fts`);
|
||||
db.run(`
|
||||
INSERT INTO client_search_fts(
|
||||
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
|
||||
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
|
||||
)
|
||||
SELECT
|
||||
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
|
||||
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
|
||||
FROM clients
|
||||
`);
|
||||
}
|
||||
|
||||
db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS clients_search_ai AFTER INSERT ON clients BEGIN
|
||||
INSERT INTO client_search_fts(
|
||||
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
|
||||
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
|
||||
)
|
||||
VALUES (
|
||||
new.rowid, new.id, new.host, new.user, new.nickname, new.custom_tag, new.custom_tag_note,
|
||||
new.os, new.ip, new.hwid, new.country, new.version, new.build_tag, new.cpu, new.gpu, new.ram
|
||||
);
|
||||
END;
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS clients_search_ad AFTER DELETE ON clients BEGIN
|
||||
DELETE FROM client_search_fts WHERE rowid = old.rowid;
|
||||
END;
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS clients_search_au AFTER UPDATE ON clients BEGIN
|
||||
DELETE FROM client_search_fts WHERE rowid = old.rowid;
|
||||
INSERT INTO client_search_fts(
|
||||
rowid, id, host, user, nickname, custom_tag, custom_tag_note,
|
||||
os, ip, hwid, country, version, build_tag, cpu, gpu, ram
|
||||
)
|
||||
VALUES (
|
||||
new.rowid, new.id, new.host, new.user, new.nickname, new.custom_tag, new.custom_tag_note,
|
||||
new.os, new.ip, new.hwid, new.country, new.version, new.build_tag, new.cpu, new.gpu, new.ram
|
||||
);
|
||||
END;
|
||||
`);
|
||||
} catch {}
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS banned_ips (
|
||||
ip TEXT PRIMARY KEY,
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("SECURITY_HEADERS", () => {
|
||||
const csp = SECURITY_HEADERS["Content-Security-Policy"];
|
||||
expect(csp).toContain("default-src 'self'");
|
||||
expect(csp).toContain("script-src 'self'");
|
||||
expect(csp).toContain("font-src 'self' data:");
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export const CORS_HEADERS = {
|
||||
};
|
||||
|
||||
export const SECURITY_HEADERS = {
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self'; connect-src 'self'; object-src blob:; frame-ancestors 'none'",
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' data:; connect-src 'self'; object-src blob:; frame-ancestors 'none'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
|
||||
@@ -160,6 +160,7 @@ function broadcastFrameToViewers(
|
||||
}
|
||||
|
||||
const rdSendStats = { lastLog: 0, frames: 0, sendMs: 0, bytes: 0 };
|
||||
const rdDebugFrameLogAt = new Map<string, number>();
|
||||
export const rdStreamingState = new Map<string, {
|
||||
isStreaming: boolean;
|
||||
display: number;
|
||||
@@ -223,9 +224,12 @@ function sendConsoleCommand(target: ClientInfo | undefined, commandType: string,
|
||||
}
|
||||
|
||||
function sendDesktopCommandWithId(target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>, commandId: string) {
|
||||
if (!target) return false;
|
||||
if (!target) {
|
||||
logger.warn(`[rd-debug] send command skipped, target missing command=${commandType} id=${commandId}`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
logger.debug(`[rd] send command ${commandType} -> ${target.id}`);
|
||||
logger.debug(`[rd-debug] send command command=${commandType} client=${target.id} id=${commandId} payload=${JSON.stringify(payload || {})}`);
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: commandType as any, payload, id: commandId }));
|
||||
metrics.recordCommand(commandType);
|
||||
return true;
|
||||
@@ -326,6 +330,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
||||
logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`);
|
||||
switch (payload.type) {
|
||||
case "desktop_start":
|
||||
logger.debug(`[rd-debug] desktop_start requested client=${clientId} session=${ws.data.sessionId || ""} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length} webrtc=${(payload as any).webrtc === true}`);
|
||||
if (!state.isStreaming) {
|
||||
if (target.os === "darwin" && target.permissions) {
|
||||
const missing: string[] = [];
|
||||
@@ -363,14 +368,15 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
||||
sendDesktopCommand(target, "desktop_start", {});
|
||||
state.isStreaming = true;
|
||||
rdStreamingState.set(clientId, state);
|
||||
logger.debug(`[rd] started streaming for client ${clientId}${(payload as any).webrtc === true ? " (webrtc)" : ""}`);
|
||||
logger.debug(`[rd-debug] desktop_start forwarded client=${clientId} nextState=${JSON.stringify(state)} webrtc=${(payload as any).webrtc === true}`);
|
||||
} else {
|
||||
logger.debug(`[rd] ignoring duplicate desktop_start for client ${clientId}`);
|
||||
logger.warn(`[rd-debug] desktop_start ignored duplicate client=${clientId} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length}`);
|
||||
}
|
||||
break;
|
||||
case "desktop_stop": {
|
||||
const otherViewers = sessionManager.getRdSessionsForClient(clientId)
|
||||
.filter(s => s.id !== ws.data.sessionId);
|
||||
logger.debug(`[rd-debug] desktop_stop requested client=${clientId} session=${ws.data.sessionId || ""} otherViewers=${otherViewers.length} state=${JSON.stringify(state)}`);
|
||||
if (otherViewers.length === 0) {
|
||||
stopRemoteDesktopRecording(clientId, "desktop stopped");
|
||||
sendDesktopCommand(target, "desktop_stop", {});
|
||||
@@ -648,6 +654,12 @@ function handleRemoteDesktopFrame(payload: any) {
|
||||
const frameFps = Number(header?.fps) || 0;
|
||||
if (frameFps > 0) state.lastFps = frameFps;
|
||||
rdStreamingState.set(clientId, state);
|
||||
const now = Date.now();
|
||||
const lastDebugFrameLog = rdDebugFrameLogAt.get(clientId) || 0;
|
||||
if (!lastDebugFrameLog || now - lastDebugFrameLog >= 2000) {
|
||||
rdDebugFrameLogAt.set(clientId, now);
|
||||
logger.debug(`[rd-debug] frame received client=${clientId} bytes=${bytes?.byteLength || 0} fps=${frameFps || 0} viewers=${sessionManager.getRdSessionsForClient(clientId).length} header=${JSON.stringify(header || {})}`);
|
||||
}
|
||||
broadcastRemoteDesktopFrame(clientId, bytes, header);
|
||||
}
|
||||
|
||||
@@ -660,7 +672,9 @@ function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header
|
||||
}
|
||||
recordRemoteDesktopFrame(clientId, header, bytes);
|
||||
const buf = buildViewerFrameBuffer(bytes, header);
|
||||
return broadcastFrameToViewers(sessionManager.getRdSessionsForClient(clientId), buf, header);
|
||||
const sessions = sessionManager.getRdSessionsForClient(clientId);
|
||||
const sent = broadcastFrameToViewers(sessions, buf, header);
|
||||
return sent;
|
||||
}
|
||||
|
||||
(globalThis as any).__rdBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
|
||||
|
||||
Reference in New Issue
Block a user