diff --git a/api.yml b/api.yml index 7f64e23..0601dfc 100644 --- a/api.yml +++ b/api.yml @@ -190,6 +190,9 @@ components: description: Whether to enable server time tracking thumbnail_format: $ref: "#/components/schemas/ThumbnailFormat" + logging_stack: + type: boolean + description: Enable logging stack for all log levels Config: allOf: - $ref: "#/components/schemas/ConfigOptional" @@ -226,6 +229,7 @@ components: - max_import_img_count - enable_server_timing - thumbnail_format + - logging_stack ConfigUpdated: description: result of updateConfig type: object diff --git a/fresh.gen.ts b/fresh.gen.ts index e46a369..afce77a 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -24,6 +24,7 @@ import * as $api_gallery_meta_gids_ from "./routes/api/gallery/meta/[gids].ts"; import * as $api_health_check from "./routes/api/health_check.ts"; import * as $api_log from "./routes/api/log.ts"; import * as $api_log_id_ from "./routes/api/log/[id].ts"; +import * as $api_log_realtime from "./routes/api/log/realtime.ts"; import * as $api_shared_token from "./routes/api/shared_token.ts"; import * as $api_shared_token_list from "./routes/api/shared_token/list.ts"; import * as $api_status from "./routes/api/status.ts"; @@ -76,6 +77,7 @@ const manifest = { "./routes/api/health_check.ts": $api_health_check, "./routes/api/log.ts": $api_log, "./routes/api/log/[id].ts": $api_log_id_, + "./routes/api/log/realtime.ts": $api_log_realtime, "./routes/api/shared_token.ts": $api_shared_token, "./routes/api/shared_token/list.ts": $api_shared_token_list, "./routes/api/status.ts": $api_status, diff --git a/routes/api/log/realtime.ts b/routes/api/log/realtime.ts new file mode 100644 index 0000000..83fc148 --- /dev/null +++ b/routes/api/log/realtime.ts @@ -0,0 +1,66 @@ +import { Handlers } from "$fresh/server.ts"; +import { return_error } from "../../../server/utils.ts"; +import { User, UserPermission } from "../../../db.ts"; +import { base_logger, LogEntry } from "../../../utils/logger.ts"; +import { ExitTarget } from "../../../signal_handler.ts"; +import { toJSON } from "../../../utils.ts"; + +export type LogRealtimeClientData = { type: "ping" } | { type: "close" } | { + type: "pong"; +}; + +export const handler: Handlers = { + GET(req, ctx) { + const u = ctx.state.user; + if ( + u && !u.is_admin && + !(Number(u.permissions) & UserPermission.QueryLog) + ) { + return return_error(403, "Permission denied."); + } + const { socket, response } = Deno.upgradeWebSocket(req); + const handle = ( + e: CustomEvent, + ) => { + if (socket.readyState === socket.OPEN) { + socket.send(toJSON({ type: e.type, detail: e.detail })); + } + }; + const close_handle = () => { + sendMessage({ type: "close" }); + socket.close(); + }; + const removeListener = () => { + base_logger.removeEventListener("new_log", handle); + ExitTarget.removeEventListener("close", close_handle); + }; + function sendMessage(mes: { type: string }) { + if (socket.readyState === socket.OPEN) { + socket.send(toJSON(mes)); + } + } + const interval = setInterval(() => { + sendMessage({ type: "ping" }); + }, 30000); + socket.onclose = () => { + clearInterval(interval); + removeListener(); + }; + socket.onmessage = (e) => { + try { + const d: LogRealtimeClientData = JSON.parse(e.data); + if (d.type == "close") { + sendMessage({ type: "close" }); + socket.close(); + } else if (d.type == "ping") { + sendMessage({ type: "pong" }); + } + } catch (_) { + null; + } + }; + base_logger.addEventListener("new_log", handle); + ExitTarget.addEventListener("close", close_handle); + return response; + }, +}; diff --git a/server.ts b/server.ts index f1ac3b1..19e4344 100644 --- a/server.ts +++ b/server.ts @@ -6,6 +6,7 @@ import twindPlugin from "$fresh/plugins/twind.ts"; import twindConfig from "./twind.config.ts"; import { load_translation } from "./server/i18ns.ts"; import { base_logger } from "./utils/logger.ts"; +import { ExitTarget } from "./signal_handler.ts"; let task_manager: TaskManager | undefined = undefined; let cfg_path: string | undefined = undefined; @@ -37,15 +38,19 @@ export async function startServer(path: string) { if (!(e instanceof AlreadyClosedError)) throw e; }); await load_translation(task_manager.aborts); - setInterval(() => { + const tasks: number[] = []; + tasks.push(setInterval(() => { task_manager?.db.remove_expired_token(); - }, 86_400_000); - setInterval(() => { + }, 86_400_000)); + tasks.push(setInterval(() => { if (!task_manager) return; task_manager.db.remove_expired_ehmeta( task_manager.cfg.eh_metadata_cache_time, ); - }, 3600_000); + }, 3600_000)); + ExitTarget.addEventListener("close", () => { + for (const t of tasks) clearInterval(t); + }); return start(manifest, { signal: task_manager.aborts, plugins: [twindPlugin(twindConfig)], diff --git a/utils/logger.ts b/utils/logger.ts index 8d7aafd..a8ad212 100644 --- a/utils/logger.ts +++ b/utils/logger.ts @@ -59,7 +59,11 @@ export function format_message( }).join(" "); } -class BaseLogger { +type EventMap = { + new_log: LogEntry; +}; + +class BaseLogger extends EventTarget { db?: Db; #cfg?: Config; #exist_table: Set = new Set(); @@ -144,16 +148,36 @@ class BaseLogger { level >= LogLevel.Warn ? stackTrace(2) : undefined; - this.db.query( - "INSERT INTO log (time, message, level, type, stack) VALUES (?, ?, ?, ?, ?);", + const now = new Date(); + const result = this.db.query<[number | bigint]>( + "INSERT INTO log (time, message, level, type, stack) VALUES (?, ?, ?, ?, ?) RETURNING id;", [ - Date.now(), + now.getTime(), message, level, type, stack === undefined ? null : stack, ], ); + if (result) { + const entry: LogEntry = { + id: result[0][0], + time: now, + message, + level, + type, + stack, + }; + this.dispatchEvent("new_log", entry); + } + } + // @ts-ignore Better type inference + addEventListener( + type: T, + callback: (e: CustomEvent) => void | Promise, + options?: boolean | AddEventListenerOptions, + ): void { + super.addEventListener(type, callback, options); } clear( type?: string | null, @@ -245,6 +269,13 @@ class BaseLogger { if (!this.db) return; this.db.query("DELETE FROM log WHERE id = ?;", [id]); } + // @ts-ignore Different parameters + dispatchEvent( + type: T, + detail: EventMap[T], + ): boolean { + return super.dispatchEvent(new CustomEvent(type, { detail })); + } error(type: string, ...messages: unknown[]) { this.add(type, LogLevel.Error, ...messages); } @@ -369,6 +400,18 @@ class BaseLogger { if (!this.db) return; this.db.query("VACUUM;"); } + // @ts-ignore Better type inference + removeEventListener( + type: T, + callback: (e: CustomEvent) => void | Promise, + options?: boolean | EventListenerOptions, + ): void { + super.removeEventListener( + type, + callback, + options, + ); + } trace(type: string, ...messages: unknown[]) { this.add(type, LogLevel.Trace, ...messages); }