From 057ca3e03eb9c2a358b4704108bd30a7aed24bef Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:13:45 -0700 Subject: [PATCH] feat(selfhost): persist visual-review screenshots via an fs-backed REVIEW_AUDIT store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual review uses env.REVIEW_AUDIT (R2) to cache + serve captured PNGs from /gittensory/shot?key=…; self-host had no such binding, so screenshots couldn't persist (they degraded to on-demand re-render and never survived a restart). Add a minimal R2Bucket-compatible filesystem blob store (the get/put surface capture.ts + the shot route use), bound when REVIEW_AUDIT_DIR is set — modular + off by default (unset ⇒ on-demand, byte-identical to before). Keys are boundary-checked so none can escape the base dir. Node-only; wired in the codecov-ignored server bootstrap. --- .env.example | 5 ++++ docs/self-hosting.md | 5 +++- src/selfhost/blob-store.ts | 40 +++++++++++++++++++++++++++ src/server.ts | 5 ++++ test/unit/selfhost-blob-store.test.ts | 36 ++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/selfhost/blob-store.ts create mode 100644 test/unit/selfhost-blob-store.test.ts diff --git a/.env.example b/.env.example index 44a407a52c..af89b55180 100644 --- a/.env.example +++ b/.env.example @@ -137,6 +137,11 @@ GITTENSORY_REVIEW_DRAFT=false # # cache (prevents double-processing of GitHub retries). Off when unset. # QDRANT_URL= # set to http://qdrant:6333 to use Qdrant as the RAG vector store # # (--profile qdrant). Overrides the built-in sqlite-vec / pgvector. +# BROWSER_WS_ENDPOINT= # ws:// endpoint of a browserless/chrome sidecar → enables visual +# # (before/after screenshot) review. Unset = visual review off. +# REVIEW_AUDIT_DIR=/data/shots # dir to persist captured screenshots (fs-backed blob store) so they +# # cache + serve from /gittensory/shot instead of re-rendering. Unset +# # = on-demand only (no persistence). Pair with BROWSER_WS_ENDPOINT. # DISCORD_WEBHOOK_URL= # one Discord channel for per-action notifications (merged/closed/ # # manual) on ANY repo you review. Unset = no Discord notifications. # # Collection and schema are auto-created at startup. Off when unset. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 5e67d983d7..7319b304c8 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -269,7 +269,10 @@ is **not** available on the Postgres backend yet — it degrades to no-context. These are Cloudflare-platform features; they degrade cleanly and the core reviewer is unaffected: -- **Visual PR capture** (Browser Rendering binding) — off; reviews run text-only. +- **Visual PR capture** — off by default (reviews run text-only). To enable on self-host: point + `BROWSER_WS_ENDPOINT` at a browserless/chrome sidecar **and** set `REVIEW_AUDIT_DIR` so captured screenshots + persist (an fs-backed store standing in for the cloud's R2 bucket); without `REVIEW_AUDIT_DIR` captures degrade + to on-demand re-render. - **Distributed rate limiting** (RateLimiter Durable Object) — off by default; set `REDIS_URL` for a Redis-backed fixed-window limiter (see §7). Otherwise put a reverse proxy / WAF in front. - **Vectorize-backed RAG** and **R2 audit storage** — inert unless you wire equivalent backends. diff --git a/src/selfhost/blob-store.ts b/src/selfhost/blob-store.ts new file mode 100644 index 0000000000..8e890ebd98 --- /dev/null +++ b/src/selfhost/blob-store.ts @@ -0,0 +1,40 @@ +// Self-host blob store (#10). A minimal R2Bucket-compatible store backed by the local filesystem — the persistence +// the visual-review screenshot path (src/review/visual/capture.ts + the /gittensory/shot serve route) reads/writes +// through `env.REVIEW_AUDIT`. The cloud uses the Cloudflare R2 binding; self-host has none, so visual captures +// previously could not be cached/persisted (they degraded to on-demand re-render). This implements only the get/put +// surface those two paths use; every other R2Bucket method is unused on self-host. Node-only (fs import never +// reaches the Worker bundle — wired in server.ts behind REVIEW_AUDIT_DIR). MODULAR + off by default: unset +// REVIEW_AUDIT_DIR ⇒ no REVIEW_AUDIT binding ⇒ captures degrade to on-demand exactly as before. +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve, sep } from "node:path"; + +/** Build a filesystem-backed REVIEW_AUDIT store rooted at `baseDir`. Keys are app-generated + * (`gittensory/shots/.png`) and the serve route already prefix-checks + rejects `..`, but the path is + * resolved + boundary-checked here too so a key can never escape the base directory. */ +export function createFsBlobStore(baseDir: string): R2Bucket { + const base = resolve(baseDir); + const pathFor = (key: string): string => { + const full = resolve(base, key.replace(/^[/\\]+/, "")); // strip any leading slash so the key stays relative + if (!full.startsWith(base + sep)) throw new Error("blob key escapes base dir"); + return full; + }; + const store = { + /** Stream a stored object's bytes, or null on a miss (ENOENT / unreadable). The serve route reads `.body`. */ + async get(key: string): Promise { + try { + const bytes = await readFile(pathFor(key)); + return { body: new Response(bytes).body } as unknown as R2ObjectBody; + } catch { + return null; + } + }, + /** Persist `value` (the captured PNG) under `key`, creating parent dirs. Accepts any R2 put body type. */ + async put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null): Promise { + const target = pathFor(key); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, Buffer.from(await new Response(value ?? "").arrayBuffer())); + return { key } as unknown as R2Object; + }, + }; + return store as unknown as R2Bucket; +} diff --git a/src/server.ts b/src/server.ts index 82c41022c3..8c85ce6ca0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -36,6 +36,7 @@ import { createPgQueue } from "./selfhost/pg-queue"; import { createPgVectorize, initPgVectorize } from "./selfhost/pg-vectorize"; import { createSqliteQueue } from "./selfhost/sqlite-queue"; import { createSqliteVectorize } from "./selfhost/vectorize"; +import { createFsBlobStore } from "./selfhost/blob-store"; import { makeLocalManifestReader } from "./selfhost/private-config"; import { setLocalManifestReader } from "./signals/focus-manifest-loader"; import type { JobMessage } from "./types"; @@ -237,6 +238,10 @@ async function main(): Promise { // Visual review: when BROWSER_WS_ENDPOINT is set, expose a truthy BROWSER binding so shot.ts's // `if (!env.BROWSER) return` guard is bypassed; the puppeteer stub then connects via WS. ...(process.env.BROWSER_WS_ENDPOINT ? { BROWSER: {} } : {}), + // Visual screenshot persistence (#10): bind an fs-backed REVIEW_AUDIT store when REVIEW_AUDIT_DIR is set so + // captured PNGs are cached + served from /gittensory/shot?key=… instead of re-rendering on demand. Unset ⇒ + // no binding ⇒ on-demand behavior, byte-identical to before. + ...(process.env.REVIEW_AUDIT_DIR ? { REVIEW_AUDIT: createFsBlobStore(process.env.REVIEW_AUDIT_DIR) } : {}), } as unknown as Env; gauge("gittensory_queue_pending", () => backend.queue.size()); diff --git a/test/unit/selfhost-blob-store.test.ts b/test/unit/selfhost-blob-store.test.ts new file mode 100644 index 0000000000..75fca7a00f --- /dev/null +++ b/test/unit/selfhost-blob-store.test.ts @@ -0,0 +1,36 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createFsBlobStore } from "../../src/selfhost/blob-store"; + +describe("createFsBlobStore (#10 — self-host visual screenshot persistence)", () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "gitt-blob-")); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it("round-trips a PNG: put then get streams the same bytes back (parent dirs created)", async () => { + const store = createFsBlobStore(dir); + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]); + await store.put("gittensory/shots/abc.png", png); + const obj = await store.get("gittensory/shots/abc.png"); + expect(obj).not.toBeNull(); + expect(Array.from(new Uint8Array(await new Response(obj!.body).arrayBuffer()))).toEqual(Array.from(png)); + }); + + it("returns null on a miss", async () => { + expect(await createFsBlobStore(dir).get("gittensory/shots/missing.png")).toBeNull(); + }); + + it("accepts a string value too (any R2 put body type)", async () => { + const store = createFsBlobStore(dir); + await store.put("gittensory/shots/s.png", "hello"); + expect(await new Response((await store.get("gittensory/shots/s.png"))!.body).text()).toBe("hello"); + }); + + it("rejects a key that escapes the base dir — put throws, get is a safe miss (no traversal)", async () => { + const store = createFsBlobStore(dir); + await expect(store.put("../escape.png", new Uint8Array([1]))).rejects.toThrow(/escapes base dir/); + expect(await store.get("../../etc/passwd")).toBeNull(); // the pathFor throw is caught inside get → safe miss + }); +});