Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
40 changes: 40 additions & 0 deletions src/selfhost/blob-store.ts
Original file line number Diff line number Diff line change
@@ -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/<hash>.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<R2ObjectBody | null> {
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<R2Object> {
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;
}
5 changes: 5 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -237,6 +238,10 @@ async function main(): Promise<void> {
// 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());
Expand Down
36 changes: 36 additions & 0 deletions test/unit/selfhost-blob-store.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
Loading