| import type { IncomingMessage, ServerResponse } from "node:http"; |
| import type { PluginLogger } from "openclaw/plugin-sdk/diffs"; |
| import type { DiffArtifactStore } from "./store.js"; |
| import { DIFF_ARTIFACT_ID_PATTERN, DIFF_ARTIFACT_TOKEN_PATTERN } from "./types.js"; |
| import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js"; |
|
|
| const VIEW_PREFIX = "/plugins/diffs/view/"; |
| const VIEWER_MAX_FAILURES_PER_WINDOW = 40; |
| const VIEWER_FAILURE_WINDOW_MS = 60_000; |
| const VIEWER_LOCKOUT_MS = 60_000; |
| const VIEWER_LIMITER_MAX_KEYS = 2_048; |
| const VIEWER_CONTENT_SECURITY_POLICY = [ |
| "default-src 'none'", |
| "script-src 'self'", |
| "style-src 'unsafe-inline'", |
| "img-src 'self' data:", |
| "font-src 'self' data:", |
| "connect-src 'none'", |
| "base-uri 'none'", |
| "frame-ancestors 'self'", |
| "object-src 'none'", |
| ].join("; "); |
|
|
| export function createDiffsHttpHandler(params: { |
| store: DiffArtifactStore; |
| logger?: PluginLogger; |
| allowRemoteViewer?: boolean; |
| }) { |
| const viewerFailureLimiter = new ViewerFailureLimiter(); |
|
|
| return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => { |
| const parsed = parseRequestUrl(req.url); |
| if (!parsed) { |
| return false; |
| } |
|
|
| if (parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) { |
| return await serveAsset(req, res, parsed.pathname, params.logger); |
| } |
|
|
| if (!parsed.pathname.startsWith(VIEW_PREFIX)) { |
| return false; |
| } |
|
|
| const access = resolveViewerAccess(req); |
| if (!access.localRequest && params.allowRemoteViewer !== true) { |
| respondText(res, 404, "Diff not found"); |
| return true; |
| } |
|
|
| if (req.method !== "GET" && req.method !== "HEAD") { |
| respondText(res, 405, "Method not allowed"); |
| return true; |
| } |
|
|
| if (!access.localRequest) { |
| const throttled = viewerFailureLimiter.check(access.remoteKey); |
| if (!throttled.allowed) { |
| res.statusCode = 429; |
| setSharedHeaders(res, "text/plain; charset=utf-8"); |
| res.setHeader("Retry-After", String(Math.max(1, Math.ceil(throttled.retryAfterMs / 1000)))); |
| res.end("Too Many Requests"); |
| return true; |
| } |
| } |
|
|
| const pathParts = parsed.pathname.split("/").filter(Boolean); |
| const id = pathParts[3]; |
| const token = pathParts[4]; |
| if ( |
| !id || |
| !token || |
| !DIFF_ARTIFACT_ID_PATTERN.test(id) || |
| !DIFF_ARTIFACT_TOKEN_PATTERN.test(token) |
| ) { |
| recordRemoteFailure(viewerFailureLimiter, access); |
| respondText(res, 404, "Diff not found"); |
| return true; |
| } |
|
|
| const artifact = await params.store.getArtifact(id, token); |
| if (!artifact) { |
| recordRemoteFailure(viewerFailureLimiter, access); |
| respondText(res, 404, "Diff not found or expired"); |
| return true; |
| } |
|
|
| try { |
| const html = await params.store.readHtml(id); |
| resetRemoteFailures(viewerFailureLimiter, access); |
| res.statusCode = 200; |
| setSharedHeaders(res, "text/html; charset=utf-8"); |
| res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY); |
| if (req.method === "HEAD") { |
| res.end(); |
| } else { |
| res.end(html); |
| } |
| return true; |
| } catch (error) { |
| recordRemoteFailure(viewerFailureLimiter, access); |
| params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`); |
| respondText(res, 500, "Failed to load diff"); |
| return true; |
| } |
| }; |
| } |
|
|
| function parseRequestUrl(rawUrl?: string): URL | null { |
| if (!rawUrl) { |
| return null; |
| } |
| try { |
| return new URL(rawUrl, "http://127.0.0.1"); |
| } catch { |
| return null; |
| } |
| } |
|
|
| async function serveAsset( |
| req: IncomingMessage, |
| res: ServerResponse, |
| pathname: string, |
| logger?: PluginLogger, |
| ): Promise<boolean> { |
| if (req.method !== "GET" && req.method !== "HEAD") { |
| respondText(res, 405, "Method not allowed"); |
| return true; |
| } |
|
|
| try { |
| const asset = await getServedViewerAsset(pathname); |
| if (!asset) { |
| respondText(res, 404, "Asset not found"); |
| return true; |
| } |
|
|
| res.statusCode = 200; |
| setSharedHeaders(res, asset.contentType); |
| if (req.method === "HEAD") { |
| res.end(); |
| } else { |
| res.end(asset.body); |
| } |
| return true; |
| } catch (error) { |
| logger?.warn(`Failed to serve diffs asset ${pathname}: ${String(error)}`); |
| respondText(res, 500, "Failed to load asset"); |
| return true; |
| } |
| } |
|
|
| function respondText(res: ServerResponse, statusCode: number, body: string): void { |
| res.statusCode = statusCode; |
| setSharedHeaders(res, "text/plain; charset=utf-8"); |
| res.end(body); |
| } |
|
|
| function setSharedHeaders(res: ServerResponse, contentType: string): void { |
| res.setHeader("cache-control", "no-store, max-age=0"); |
| res.setHeader("content-type", contentType); |
| res.setHeader("x-content-type-options", "nosniff"); |
| res.setHeader("referrer-policy", "no-referrer"); |
| } |
|
|
| function normalizeRemoteClientKey(remoteAddress: string | undefined): string { |
| const normalized = remoteAddress?.trim().toLowerCase(); |
| if (!normalized) { |
| return "unknown"; |
| } |
| return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized; |
| } |
|
|
| function isLoopbackClientIp(clientIp: string): boolean { |
| return clientIp === "127.0.0.1" || clientIp === "::1"; |
| } |
|
|
| function hasProxyForwardingHints(req: IncomingMessage): boolean { |
| const headers = req.headers ?? {}; |
| return Boolean( |
| headers["x-forwarded-for"] || |
| headers["x-real-ip"] || |
| headers.forwarded || |
| headers["x-forwarded-host"] || |
| headers["x-forwarded-proto"], |
| ); |
| } |
|
|
| function resolveViewerAccess(req: IncomingMessage): { |
| remoteKey: string; |
| localRequest: boolean; |
| } { |
| const remoteKey = normalizeRemoteClientKey(req.socket?.remoteAddress); |
| const localRequest = isLoopbackClientIp(remoteKey) && !hasProxyForwardingHints(req); |
| return { remoteKey, localRequest }; |
| } |
|
|
| function recordRemoteFailure( |
| limiter: ViewerFailureLimiter, |
| access: { remoteKey: string; localRequest: boolean }, |
| ): void { |
| if (!access.localRequest) { |
| limiter.recordFailure(access.remoteKey); |
| } |
| } |
|
|
| function resetRemoteFailures( |
| limiter: ViewerFailureLimiter, |
| access: { remoteKey: string; localRequest: boolean }, |
| ): void { |
| if (!access.localRequest) { |
| limiter.reset(access.remoteKey); |
| } |
| } |
|
|
| type RateLimitCheckResult = { |
| allowed: boolean; |
| retryAfterMs: number; |
| }; |
|
|
| type ViewerFailureState = { |
| windowStartMs: number; |
| failures: number; |
| lockUntilMs: number; |
| }; |
|
|
| class ViewerFailureLimiter { |
| private readonly failures = new Map<string, ViewerFailureState>(); |
|
|
| check(key: string): RateLimitCheckResult { |
| this.prune(); |
| const state = this.failures.get(key); |
| if (!state) { |
| return { allowed: true, retryAfterMs: 0 }; |
| } |
| const now = Date.now(); |
| if (state.lockUntilMs > now) { |
| return { allowed: false, retryAfterMs: state.lockUntilMs - now }; |
| } |
| if (now - state.windowStartMs >= VIEWER_FAILURE_WINDOW_MS) { |
| this.failures.delete(key); |
| return { allowed: true, retryAfterMs: 0 }; |
| } |
| return { allowed: true, retryAfterMs: 0 }; |
| } |
|
|
| recordFailure(key: string): void { |
| this.prune(); |
| const now = Date.now(); |
| const current = this.failures.get(key); |
| const next = |
| !current || now - current.windowStartMs >= VIEWER_FAILURE_WINDOW_MS |
| ? { |
| windowStartMs: now, |
| failures: 1, |
| lockUntilMs: 0, |
| } |
| : { |
| ...current, |
| failures: current.failures + 1, |
| }; |
| if (next.failures >= VIEWER_MAX_FAILURES_PER_WINDOW) { |
| next.lockUntilMs = now + VIEWER_LOCKOUT_MS; |
| } |
| this.failures.set(key, next); |
| } |
|
|
| reset(key: string): void { |
| this.failures.delete(key); |
| } |
|
|
| private prune(): void { |
| if (this.failures.size < VIEWER_LIMITER_MAX_KEYS) { |
| return; |
| } |
| const now = Date.now(); |
| for (const [key, state] of this.failures) { |
| if (state.lockUntilMs <= now && now - state.windowStartMs >= VIEWER_FAILURE_WINDOW_MS) { |
| this.failures.delete(key); |
| } |
| if (this.failures.size < VIEWER_LIMITER_MAX_KEYS) { |
| return; |
| } |
| } |
| if (this.failures.size >= VIEWER_LIMITER_MAX_KEYS) { |
| this.failures.clear(); |
| } |
| } |
| } |
|
|