Files
UHD_Media_Tool/apps/server/src/http/sse-pg.ts
T
rootandClaude Opus 5.5 ff230e5da7 feat(server): W2-A login gate, secrets, settings, instances, audit, engine panel API and PG-backed SSE bus
- Admin login gate (docs/design/auth-reference.md): admin_user/admin_session/login_attempt
  (migration 0015), one-time setup token logged once + conditional UPDATE, argon2id via
  hash-wasm (WASM; compiled binary has no --allow-ffi), sha256-stored sessions in HttpOnly
  SameSite=Lax cookie umt_session (fixed 12h TTL, soft revoke), HMAC-derived double-submit
  CSRF, IP (5) + account (15) failure limits per 15 min, password change revokes other
  sessions, optional TOTP with replay counter, AuditAction enum.
- Unified auth middleware: cookie session or Bearer API key -> c.var.actor
  {type: admin|api_key, id, name, scopes}; requireScope works for both; requireAdminSession.
- ctx.secrets: AES-256-GCM, per-secret HKDF-SHA256 key from SECRET_MASTER_KEY, AAD bound
  to (id, purpose); ctx.settings: 30s cache invalidated by settings.changed events.
- Endpoints: /auth/*, /settings, /api-keys, /secrets, /audit-events, library-roots,
  downloader/media-server instances (+test), path-mappings (+verify),
  PUT /sites/{id}/credentials, /engine/runs|stats|cancel (read-only openworkflow schema).
- SSE: PgEventBus (sse_event table, migration 0016, LISTEN/NOTIFY umt_events, catch-up by
  seq, pruning, resync); EventBusPort is now async.
- Tests: login/rate limit/revocation/CSRF/TOTP, secrets round trip and no log leakage,
  admin APIs against mock qB/Emby, SSE across two Deno subprocesses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 19:59:54 +08:00

218 lines
7.3 KiB
TypeScript

import type { EventEnvelope } from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
import pg from "pg";
import type { Logger } from "pino";
import type { BusListener, EventBusPort, SinceResult } from "./sse.ts";
/** 默认通知通道 */
export const EVENTS_CHANNEL = "umt_events";
/** 发布时串行化 seq 分配与提交的咨询锁键('umte') */
const PUBLISH_LOCK_KEY = 0x756d7465;
export interface PgEventBusOptions {
db: Kysely<DB>;
/** LISTEN 专用连接(不走连接池) */
connectionString: string;
logger: Logger;
channel?: string;
/** sse_event 保留最近多少条供 ?after= 补读,默认 10000 */
retain?: number;
/** 每发布多少条裁剪一次,默认 200 */
pruneEvery?: number;
/** LISTEN 断线重连退避上限,默认 10s */
maxReconnectMs?: number;
/** 仅测试:LISTEN 连接的 search_path 等 pg 启动参数 */
pgOptions?: string;
}
type Row = { seq: number; type: string; at: Date; payload: unknown };
const toEnvelope = (r: Row): EventEnvelope => ({
seq: Number(r.seq),
type: r.type,
at: r.at.toISOString(),
payload: r.payload,
});
/**
* 多进程 SSE 总线:发布 = 在 sse_event 追加一行(持咨询锁,保证 seq 顺序即提交顺序)+ pg_notify(channel, seq);
* 每个进程一条 LISTEN 连接,收到通知后按 seq 从表里补读并分发给本进程订阅者(包括自己发布的)。
* LISTEN 断线期间的事件在重连后按 lastDispatched 补读,不丢。
*/
export class PgEventBus implements EventBusPort {
private readonly listeners = new Set<BusListener>();
private readonly channel: string;
private readonly retain: number;
private readonly pruneEvery: number;
private client: pg.Client | null = null;
private lastDispatched = 0;
private draining: Promise<void> | null = null;
private drainAgain = false;
private published = 0;
private stopped = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private constructor(private readonly opts: PgEventBusOptions) {
this.channel = opts.channel ?? EVENTS_CHANNEL;
if (!/^[a-z_][a-z0-9_]*$/.test(this.channel)) throw new Error(`非法通道名 ${this.channel}`);
this.retain = opts.retain ?? 10_000;
this.pruneEvery = opts.pruneEvery ?? 200;
}
/** 建总线并开始 LISTEN;从当前最大 seq 之后开始分发 */
static async start(opts: PgEventBusOptions): Promise<PgEventBus> {
const bus = new PgEventBus(opts);
bus.lastDispatched = await bus.maxSeq();
await bus.connect();
return bus;
}
private get log() {
return this.opts.logger;
}
private async maxSeq(): Promise<number> {
const r = await sql<{ m: number | null }>`select max(seq) as m from sse_event`.execute(this.opts.db);
return Number(r.rows[0]?.m ?? 0);
}
private async connect(): Promise<void> {
const client = new pg.Client({
connectionString: this.opts.connectionString,
...(this.opts.pgOptions ? { options: this.opts.pgOptions } : {}),
});
client.on("notification", (msg) => {
if (msg.channel === this.channel) this.scheduleDrain();
});
const onLost = (err?: Error) => {
if (this.client !== client) return;
this.client = null;
client.removeAllListeners("notification");
client.end().catch(() => {});
if (!this.stopped) {
this.log.warn({ err }, "SSE 总线 LISTEN 连接断开,准备重连");
this.reconnect(500);
}
};
client.on("error", onLost);
client.on("end", () => onLost());
await client.connect();
await client.query(`listen ${this.channel}`);
this.client = client;
// 断线期间可能漏掉通知:连上后补读一次
this.scheduleDrain();
}
private reconnect(delayMs: number) {
if (this.stopped || this.reconnectTimer) return;
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null;
try {
await this.connect();
this.log.info("SSE 总线 LISTEN 已重连");
} catch (err) {
this.log.warn({ err }, "SSE 总线重连失败");
this.reconnect(Math.min(delayMs * 2, this.opts.maxReconnectMs ?? 10_000));
}
}, delayMs);
}
private scheduleDrain() {
if (this.draining) {
this.drainAgain = true;
return;
}
this.draining = (async () => {
try {
do {
this.drainAgain = false;
await this.drainOnce();
} while (this.drainAgain && !this.stopped);
} catch (err) {
this.log.warn({ err }, "SSE 总线补读失败");
} finally {
this.draining = null;
}
})();
}
private async drainOnce() {
for (;;) {
const rows = await sql<Row>`select seq, type, at, payload from sse_event
where seq > ${this.lastDispatched} order by seq limit 500`.execute(this.opts.db);
for (const r of rows.rows) {
const e = toEnvelope(r);
this.lastDispatched = e.seq;
for (const l of this.listeners) {
try {
l(e);
} catch (err) {
this.log.warn({ err, type: e.type }, "SSE 订阅者异常");
}
}
}
if (rows.rows.length < 500) return;
}
}
async publish<P>(type: string, payload: P): Promise<EventEnvelope<P> | null> {
try {
const row = await this.opts.db.transaction().execute(async (trx) => {
await sql`select pg_advisory_xact_lock(${PUBLISH_LOCK_KEY})`.execute(trx);
const r = await sql<Row>`insert into sse_event (type, payload)
values (${type}, ${JSON.stringify(payload ?? null)}::jsonb) returning seq, type, at, payload`.execute(trx);
const inserted = r.rows[0]!;
// NOTIFY 在提交时才投递
await sql`select pg_notify(${this.channel}, ${String(inserted.seq)})`.execute(trx);
return inserted;
});
this.published += 1;
if (this.published % this.pruneEvery === 0) void this.prune();
return toEnvelope(row) as EventEnvelope<P>;
} catch (err) {
this.log.error({ err, type }, "SSE 事件发布失败");
return null;
}
}
/** 只保留最近 retain 条 */
async prune(): Promise<void> {
try {
await sql`delete from sse_event where seq <= (select max(seq) from sse_event) - ${this.retain}`.execute(
this.opts.db,
);
} catch (err) {
this.log.warn({ err }, "sse_event 裁剪失败");
}
}
async since(after: number, limit = 1000): Promise<SinceResult> {
const [rows, bounds] = await Promise.all([
sql<Row>`select seq, type, at, payload from sse_event where seq > ${after} order by seq limit ${limit}`.execute(
this.opts.db,
),
sql<{ lo: number | null; hi: number | null }>`select min(seq) as lo, max(seq) as hi from sse_event`.execute(
this.opts.db,
),
]);
const lo = bounds.rows[0]?.lo;
const hi = Number(bounds.rows[0]?.hi ?? 0);
// 裁剪过:请求起点早于最老保留条目
const gap = after > 0 && lo != null && after < Number(lo) - 1;
return { events: rows.rows.map(toEnvelope), gap, latestSeq: Math.max(hi, after) };
}
subscribe(l: BusListener): () => void {
this.listeners.add(l);
return () => this.listeners.delete(l);
}
async stop(): Promise<void> {
this.stopped = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
const c = this.client;
this.client = null;
await c?.end().catch(() => {});
await this.draining;
}
}