Restrict plugin lookups and reuse cached chat reads (Refs #98)

This commit is contained in:
2026-10-17 10:31:13 -04:00
parent b9bc897d6e
commit f3b9699317
8 changed files with 311 additions and 10 deletions
+6 -1
View File
@@ -78,7 +78,12 @@ export function routePermission(routeId: string | null, method: string): RoutePe
return 'admin';
// These reads initiate fleet file scans or spend external API quota.
if (routeId === '/api/drift' || routeId === '/api/plugins/github/release') return 'operator';
if (
routeId === '/api/drift' ||
routeId === '/api/plugins/github/release' ||
routeId === '/api/plugins/updates'
)
return 'operator';
// Summaries remain visible to viewers; raw configuration, historical file
// bytes, and unfiltered diagnostics may contain runtime credentials.
+42 -2
View File
@@ -209,12 +209,38 @@ interface ChatReadState {
}
const readers = new Map<string, ChatReadState>();
const failures = new Map<string, number>();
const CHAT_READ_MIN_AGE_MS = 15_000;
type LastChatRead = { completedAt: number } & ({ missing: boolean } | { error: unknown });
const lastReads = new Map<string, LastChatRead>();
function chatReadKey(server: ServerRow): string {
return `${server.name}:${server.created_at}:${server.native_instance_id}:${server.instance_generation}`;
}
/** Live viewers reuse the worker's reader state instead of adding a read per tab. */
export async function readServerChat(
server: ServerRow
): Promise<{ entries: ContextualChatEntry[]; missing: boolean }> {
const key = chatReadKey(server);
const existing = pending.get(key);
if (existing) return existing;
const last = lastReads.get(key);
if (!last || Date.now() - last.completedAt >= CHAT_READ_MIN_AGE_MS)
return collectServerChat(server);
if ('error' in last) throw last.error;
const state = readers.get(server.name);
return {
// Telemetry may have started a new match since the last log read.
entries: currentChatEntries(server, state?.key === key ? state.entries : []),
missing: last.missing
};
}
/** Shared by the live API and the worker; collection does not require a viewer. */
export function collectServerChat(
server: ServerRow
): Promise<{ entries: ContextualChatEntry[]; missing: boolean }> {
const key = `${server.name}:${server.created_at}:${server.native_instance_id}:${server.instance_generation}`;
const key = chatReadKey(server);
const existing = pending.get(key);
if (existing) return existing;
const work = (async () => {
@@ -319,7 +345,19 @@ export function collectServerChat(
state.signature = signature;
readers.set(server.name, state);
return { entries: currentChatEntries(current, state.entries), missing: false };
})().finally(() => pending.delete(key));
})()
.then(
(result) => {
lastReads.set(key, { completedAt: Date.now(), missing: result.missing });
return result;
},
(error: unknown) => {
// Failed reads also observe the cooldown, without advancing the cursor.
lastReads.set(key, { completedAt: Date.now(), error });
throw error;
}
)
.finally(() => pending.delete(key));
pending.set(key, work);
return work;
}
@@ -329,8 +367,10 @@ export async function collectFleetChat(): Promise<void> {
const observe = observationReader();
const live = listServers().filter((server) => !server.destroyed_at);
const names = new Set(live.map((server) => server.name));
const keys = new Set(live.map(chatReadKey));
for (const name of readers.keys()) if (!names.has(name)) readers.delete(name);
for (const name of failures.keys()) if (!names.has(name)) failures.delete(name);
for (const key of lastReads.keys()) if (!keys.has(key)) lastReads.delete(key);
const servers = live.filter((server) => {
if (server.host_id && !hosts.get(server.host_id)?.connected) return false;
if (server.container_state && !['running', 'unknown'].includes(server.container_state))
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import { getServer } from '$lib/server/fleet/servers.js';
import { gameLogKey, resolveLogPath } from '$lib/server/fleet/logs.js';
import { collectServerChat } from '$lib/server/fleet/chat.js';
import { readServerChat } from '$lib/server/fleet/chat.js';
export async function GET({ params }: { params: { name: string } }) {
const headers = { 'Cache-Control': 'no-store' };
@@ -14,7 +14,7 @@ export async function GET({ params }: { params: { name: string } }) {
return json({ ok: false, reason: 'Path traversal rejected' }, { status: 400, headers });
try {
return json({ ok: true, ...(await collectServerChat(server)) }, { headers });
return json({ ok: true, ...(await readServerChat(server)) }, { headers });
} catch (error) {
console.error('Could not read server chat', error);
return json({ ok: false, reason: 'Could not read server chat' }, { status: 500, headers });