Skip to content
Closed
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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,19 @@ GITTENSORY_REVIEW_DRAFT=false
# # bare Tailscale MagicDNS `*.ts.net` address) once you've confirmed
# # it's genuinely public (Funnel enabled, a reverse proxy in front of
# # it, etc.). Default false (warning shown).

# --- Public screenshot bucket (JSONbored/gittensory#4184; optional) ---
# For an instance that stays deliberately private (e.g. Tailscale-only, no public HTTP surface at all):
# uploads visual-capture screenshots to a dedicated PUBLIC Cloudflare R2 bucket and links directly to it,
# instead of requiring PUBLIC_API_ORIGIN itself to be publicly reachable. All five required together; any
# one missing means not configured (captures keep using PUBLIC_API_ORIGIN, exactly as before this feature).
# R2_PUBLIC_ACCOUNT_ID= # your Cloudflare account ID
# R2_PUBLIC_BUCKET= # a bucket dedicated to this -- never reuse one holding other
# # audit/private data, since every object in it is world-readable
# R2_PUBLIC_ACCESS_KEY_ID= # from R2 -> Manage API Tokens -> Create -> "Object Read & Write",
# R2_PUBLIC_SECRET_ACCESS_KEY= # scoped to ONLY the bucket above -- shown once, save it then
# R2_PUBLIC_BASE_URL= # the bucket's public base URL: its r2.dev dev URL (R2 -> bucket
# # -> Settings -> Public access -> Allow Access), or a custom domain
# SELFHOST_SETUP_TOKEN=change-this-long-random-value # REQUIRED to unlock the first-run /setup wizard. Without it
# # /setup returns 400; with it, enter the token in the browser form
# # or send an x-setup-token / Bearer header. Never put this token in
Expand Down
25 changes: 25 additions & 0 deletions apps/gittensory-ui/src/lib/selfhost-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,26 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
name: "QUEUE_STARTUP_JITTER_MIN_JOBS",
firstReference: "src/selfhost/queue-common.ts",
},
{
name: "R2_PUBLIC_ACCESS_KEY_ID",
firstReference: "src/selfhost/r2-public-upload.ts",
},
{
name: "R2_PUBLIC_ACCOUNT_ID",
firstReference: "src/selfhost/r2-public-upload.ts",
},
{
name: "R2_PUBLIC_BASE_URL",
firstReference: "src/selfhost/r2-public-upload.ts",
},
{
name: "R2_PUBLIC_BUCKET",
firstReference: "src/selfhost/r2-public-upload.ts",
},
{
name: "R2_PUBLIC_SECRET_ACCESS_KEY",
firstReference: "src/selfhost/r2-public-upload.ts",
},
{
name: "REDIS_URL",
firstReference: "src/selfhost/preflight.ts",
Expand Down Expand Up @@ -503,6 +523,11 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
"| `QUEUE_CONCURRENCY` | `src/selfhost/pg-queue.ts` |",
"| `QUEUE_DEAD_LETTER_AUTO_RETRY_MAX_EXTRA_ATTEMPTS` | `src/selfhost/queue-common.ts` |",
"| `QUEUE_STARTUP_JITTER_MIN_JOBS` | `src/selfhost/queue-common.ts` |",
"| `R2_PUBLIC_ACCESS_KEY_ID` | `src/selfhost/r2-public-upload.ts` |",
"| `R2_PUBLIC_ACCOUNT_ID` | `src/selfhost/r2-public-upload.ts` |",
"| `R2_PUBLIC_BASE_URL` | `src/selfhost/r2-public-upload.ts` |",
"| `R2_PUBLIC_BUCKET` | `src/selfhost/r2-public-upload.ts` |",
"| `R2_PUBLIC_SECRET_ACCESS_KEY` | `src/selfhost/r2-public-upload.ts` |",
"| `REDIS_URL` | `src/selfhost/preflight.ts` |",
"| `REVIEW_AUDIT_DIR` | `src/server.ts` |",
"| `SELFHOST_BUNDLE_ALL` | `scripts/build-selfhost.mjs` |",
Expand Down
9 changes: 9 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ declare global {
* Deliberately NOT declared in this chunk; the review path keeps its current concurrency behavior. */
PUBLIC_API_ORIGIN?: string;
PUBLIC_SITE_ORIGIN?: string;
/** JSONbored/gittensory#4184: upload visual-capture screenshots to a dedicated public R2 bucket and link
* directly to it, for a self-host instance whose PUBLIC_API_ORIGIN stays deliberately private (e.g.
* Tailscale-only). All five required together (see resolveR2PublicUploadConfig) -- any one missing means
* not configured, not an error. */
R2_PUBLIC_ACCOUNT_ID?: string;
R2_PUBLIC_BUCKET?: string;
R2_PUBLIC_ACCESS_KEY_ID?: string;
R2_PUBLIC_SECRET_ACCESS_KEY?: string;
R2_PUBLIC_BASE_URL?: string;
AI_SUMMARIES_ENABLED?: string;
AI_PUBLIC_COMMENTS_ENABLED?: string;
/** Model id for a genuine Cloudflare Workers AI binding only — no live deployment (hosted or self-host)
Expand Down
6 changes: 6 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ import {
FALLBACK_WORKFLOW_NAME,
parseFallbackRunCorrelation,
} from "../review/visual/actions-fallback";
import { resolveR2PublicUploadConfig, uploadToPublicR2Bucket } from "../selfhost/r2-public-upload";
import {
buildVisualRegressionFindings,
buildVisualVisionUserPrompt,
Expand Down Expand Up @@ -4534,6 +4535,11 @@ async function storeVisualCaptureFallbackShots(
if (!png) continue;
const key = await fallbackShotR2Key(headSha, path, viewportName);
await env.REVIEW_AUDIT.put(key, png, { httpMetadata: { contentType: "image/png" } }).catch(() => undefined);
// Mirror to the public bucket too (#4184) so capture.ts's resolveFallbackAfterShot -- which trusts any
// key found in REVIEW_AUDIT to already be mirrored -- doesn't link to an object that was never actually
// uploaded there.
const r2Public = resolveR2PublicUploadConfig(env);
if (r2Public) await uploadToPublicR2Bucket(r2Public, key, png, "image/png");
}
}
}
Expand Down
37 changes: 33 additions & 4 deletions src/review/visual/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
// (env.REVIEW_AUDIT), and embedded as <PUBLIC_API_ORIGIN>/gittensory/shot?key=<r2key> so GitHub's image
// proxy fetches a fast static object instead of waiting on a live browser render.
//
// JSONbored/gittensory#4184: on self-host, env.REVIEW_AUDIT is often a local-filesystem store (see
// blob-store.ts) fronted by an instance that stays deliberately PRIVATE (e.g. Tailscale-only, no public HTTP
// surface at all) — PUBLIC_API_ORIGIN in that case is unfetchable by GitHub, so the embedded URL renders as a
// broken image. When r2-public-upload.ts's config is present, every fresh render is ALSO uploaded to a
// dedicated, deliberately public R2 bucket and its direct URL used instead, so the private instance itself
// never has to answer a single public request. Config absent (default) ⇒ byte-identical to the R2/local-fs
// behavior described above.
//
// PORTED from reviewbot's src/agents/gittensory/capture.ts (mapFilesToRoutes / routeForFile / capturePage /
// buildCapture), adapted to gittensory bindings + origins. The agent-config-driven route rules, authed-route
// preview session, and explicit-route override are intentionally dropped here — gittensory's UI uses the
Expand All @@ -24,6 +32,7 @@ import {
import { captureScrollFrames, captureShot, DESKTOP_VIEWPORT, MOBILE_VIEWPORT, type ShotTheme, type Viewport } from "./shot";
import { compareCapturedScreenshots, isVisualDiffAvailable, type VisualDiffOutcome } from "./pixel-diff";
import { encodeScrollGif, isScrollGifAvailable } from "./scroll-gif";
import { publicUrlForKey, resolveR2PublicUploadConfig, uploadToPublicR2Bucket } from "../../selfhost/r2-public-upload";

const NAMESPACE = "gittensory";
const DEFAULT_ROUTES = ["/"];
Expand Down Expand Up @@ -242,9 +251,15 @@ async function capturePage(
`${target.headSha ?? target.prNumber}:${slot}:${viewportName}:${page}${theme ? `:${theme}` : ""}${theme && themeStorageKey ? `:${themeStorageKey}` : ""}`,
);
const key = `${NAMESPACE}/shots/${fingerprint.slice(0, 40)}.png`;
const url = shotBase ? `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}` : onDemand;
const localUrl = shotBase ? `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}` : onDemand;
const r2Public = resolveR2PublicUploadConfig(env);
const cached = await env.REVIEW_AUDIT.get(key).catch(() => null);
if (cached) {
// A public bucket is trusted to already mirror any key this instance ever caches locally, since the
// two are written together below on every fresh render going forward -- see r2-public-upload.ts's
// module doc for the one-time transition caveat this accepts for cache entries written before the
// bucket was configured.
const url = r2Public ? publicUrlForKey(r2Public, key) : localUrl;
if (!includeBytes) return { url };
const bytes = await new Response(cached.body).arrayBuffer().then((buf) => new Uint8Array(buf)).catch(() => undefined);
return { url, ...(bytes ? { png: bytes } : {}) };
Expand All @@ -257,6 +272,7 @@ async function capturePage(
}
if (png) {
await env.REVIEW_AUDIT.put(key, png, { httpMetadata: { contentType: "image/png" } }).catch(() => undefined);
const url = r2Public ? (await uploadToPublicR2Bucket(r2Public, key, png, "image/png")) ?? localUrl : localUrl;
return { url, ...(includeBytes ? { png } : {}) };
}
}
Expand All @@ -281,6 +297,8 @@ async function resolveFallbackAfterShot(
const key = await fallbackShotR2Key(target.headSha, path, viewportName);
const cached = await env.REVIEW_AUDIT.get(key).catch(() => null);
if (!cached) return { url: placeholder };
const r2Public = resolveR2PublicUploadConfig(env);
if (r2Public) return { url: publicUrlForKey(r2Public, key) };
const shotBase = env.PUBLIC_API_ORIGIN;
return { url: shotBase ? `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}` : placeholder };
}
Expand All @@ -302,6 +320,11 @@ async function uploadDiffImage(
const fingerprint = await sha256Hex(`${target.headSha ?? target.prNumber}:diff:${viewportName}:${path}${theme ? `:${theme}` : ""}`);
const key = `${NAMESPACE}/shots/${fingerprint.slice(0, 40)}-diff.png`;
await env.REVIEW_AUDIT.put(key, diff.diffImagePng, { httpMetadata: { contentType: "image/png" } }).catch(() => undefined);
const r2Public = resolveR2PublicUploadConfig(env);
if (r2Public) {
const publicUrl = await uploadToPublicR2Bucket(r2Public, key, diff.diffImagePng, "image/png");
if (publicUrl) return publicUrl;
}
return `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}`;
}

Expand Down Expand Up @@ -334,9 +357,11 @@ async function captureScrollGif(
`${target.headSha ?? target.prNumber}:scrollgif:${slot}:${viewportName}:${page}${theme ? `:${theme}` : ""}${theme && themeStorageKey ? `:${themeStorageKey}` : ""}`,
);
const key = `${NAMESPACE}/shots/${fingerprint.slice(0, 40)}.gif`;
const url = `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}`;
const localUrl = `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}`;
const r2Public = resolveR2PublicUploadConfig(env);
const cached = await env.REVIEW_AUDIT.get(key).catch(() => null);
if (cached) return url;
// Same "already mirrored" trust as capturePage's own cache-hit branch — see that function's comment.
if (cached) return r2Public ? publicUrlForKey(r2Public, key) : localUrl;
const { frames, authWalled } = await captureScrollFrames(env, page, viewport, theme ? { theme, ...(themeStorageKey ? { themeStorageKey } : {}) } : {}).catch(() => ({ frames: [] as Uint8Array[], authWalled: false }));
if (authWalled || frames.length === 0) return undefined;
const gifBytes = await encodeScrollGif(
Expand All @@ -345,7 +370,11 @@ async function captureScrollGif(
);
if (!gifBytes) return undefined;
await env.REVIEW_AUDIT.put(key, gifBytes, { httpMetadata: { contentType: "image/gif" } }).catch(() => undefined);
return url;
if (r2Public) {
const publicUrl = await uploadToPublicR2Bucket(r2Public, key, gifBytes, "image/gif");
if (publicUrl) return publicUrl;
}
return localUrl;
}

/** Per-repo `review.visual` config, as resolved by the caller from the manifest (#3609 / #3610 / #3678 /
Expand Down
170 changes: 170 additions & 0 deletions src/selfhost/r2-public-upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Self-host public screenshot bucket (JSONbored/gittensory#4184 follow-up). Self-host's REVIEW_AUDIT is a
// LOCAL-FILESYSTEM store (blob-store.ts) served through this instance's own /gittensory/shot route -- fine
// for a self-host box that stays fully private (Tailscale-only, no public HTTP surface by design), but that
// means the URL embedded in a public PR comment is never fetchable by GitHub. Rather than requiring the whole
// instance to expose a public origin, upload the SAME captured PNG a second time to a dedicated, deliberately
// PUBLIC Cloudflare R2 bucket and link directly to ITS public URL instead -- the private instance itself never
// needs to answer a single public request.
//
// Self-host only (Node's node:crypto for SigV4; the Worker bundle never imports this). No new dependency: R2's
// S3-compatible API needs AWS SigV4 request signing, implemented here directly rather than pulling in the full
// @aws-sdk/client-s3 (its transitive dependency weight buys nothing over a few dozen lines of HMAC chaining for
// the ONE operation this needs -- a single-object PUT, no multipart, no listing, no other S3 verb).
import { createHash, createHmac } from "node:crypto";

const SERVICE = "s3";
const REGION = "auto";
const ALGORITHM = "AWS4-HMAC-SHA256";

export type R2PublicUploadConfig = {
accountId: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
/** The bucket's public base URL (its r2.dev dev URL, or a custom domain) -- no trailing slash. */
publicBaseUrl: string;
};

function hex(input: Buffer): string {
return input.toString("hex");
}

function sha256HexBytes(bytes: Uint8Array | string): string {
return hex(createHash("sha256").update(bytes).digest());
}

function hmac(key: Buffer | string, data: string): Buffer {
return createHmac("sha256", key).update(data).digest();
}

/** URI-encode one path segment the way SigV4's canonical URI requires (RFC 3986 unreserved chars kept
* literal; every other byte percent-encoded, uppercase hex) -- stricter than `encodeURIComponent`, which
* leaves `!'()*` unescaped. */
function encodeUriSegment(segment: string): string {
return encodeURIComponent(segment).replace(
/[!'()*]/g,
(ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`,
);
}

function canonicalUri(bucket: string, key: string): string {
const segments = `${bucket}/${key}`.split("/").map(encodeUriSegment);
return `/${segments.join("/")}`;
}

function amzTimestamps(now: Date): { amzDate: string; dateStamp: string } {
const iso = now.toISOString().replace(/[:-]|\.\d{3}/g, ""); // -> YYYYMMDDTHHMMSSZ
return { amzDate: iso, dateStamp: iso.slice(0, 8) };
}

function signingKey(secretAccessKey: string, dateStamp: string): Buffer {
const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp);
const kRegion = hmac(kDate, REGION);
const kService = hmac(kRegion, SERVICE);
return hmac(kService, "aws4_request");
}

export type R2SigV4Request = {
method: "PUT";
host: string;
uri: string;
amzDate: string;
dateStamp: string;
contentType: string;
payloadHash: string;
accessKeyId: string;
secretAccessKey: string;
};

/** Build the SigV4 Authorization header value for one PUT request. Pure -- every timestamp/hash is a
* parameter, never computed internally, so this is fully deterministic and unit-testable without touching
* the network or the system clock. */
export function signR2PutRequest(req: R2SigV4Request): string {
const canonicalHeaders =
`content-type:${req.contentType}\n` +
`host:${req.host}\n` +
`x-amz-content-sha256:${req.payloadHash}\n` +
`x-amz-date:${req.amzDate}\n`;
const signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
const canonicalRequest = [req.method, req.uri, "", canonicalHeaders, signedHeaders, req.payloadHash].join("\n");

const credentialScope = `${req.dateStamp}/${REGION}/${SERVICE}/aws4_request`;
const stringToSign = [ALGORITHM, req.amzDate, credentialScope, sha256HexBytes(canonicalRequest)].join("\n");

const signature = hex(hmac(signingKey(req.secretAccessKey, req.dateStamp), stringToSign));
return `${ALGORITHM} Credential=${req.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
}

/** The public URL a key would have IF it's already in the bucket -- no network call, just string-building.
* Used both as `uploadToPublicR2Bucket`'s own success return value and by a caller that already knows (by
* its own convention) that a given key was uploaded on a previous call and wants to reconstruct the same
* URL without paying for a redundant upload. */
export function publicUrlForKey(config: R2PublicUploadConfig, key: string): string {
return `${config.publicBaseUrl.replace(/\/+$/, "")}/${key}`;
}

/** Upload `bytes` to the configured public R2 bucket under `key` and return its public URL, or undefined on
* any failure (missing config, network error, non-2xx response) -- mirrors capture.ts's own "never throw,
* degrade to no URL" convention so a bucket outage can never sink a review. */
export async function uploadToPublicR2Bucket(
config: R2PublicUploadConfig | undefined,
key: string,
bytes: Uint8Array,
contentType: string,
): Promise<string | undefined> {
if (!config) return undefined;
try {
const host = `${config.accountId}.r2.cloudflarestorage.com`;
const uri = canonicalUri(config.bucket, key);
const { amzDate, dateStamp } = amzTimestamps(new Date());
const payloadHash = sha256HexBytes(bytes);
const authorization = signR2PutRequest({
method: "PUT",
host,
uri,
amzDate,
dateStamp,
contentType,
payloadHash,
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
});
const response = await fetch(`https://${host}${uri}`, {
method: "PUT",
headers: {
"content-type": contentType,
"x-amz-content-sha256": payloadHash,
"x-amz-date": amzDate,
authorization,
},
body: bytes,
});
if (!response.ok) return undefined;
return publicUrlForKey(config, key);
} catch {
return undefined;
}
}

/** Resolve the public-bucket config from env, or undefined when any piece is missing -- absent config is the
* legitimate "not opted in" case (captures keep using the private-instance-served URL, exactly as before
* this feature), not an error. */
export function resolveR2PublicUploadConfig(env: {
R2_PUBLIC_ACCOUNT_ID?: string | undefined;
R2_PUBLIC_BUCKET?: string | undefined;
R2_PUBLIC_ACCESS_KEY_ID?: string | undefined;
R2_PUBLIC_SECRET_ACCESS_KEY?: string | undefined;
R2_PUBLIC_BASE_URL?: string | undefined;
}): R2PublicUploadConfig | undefined {
const { R2_PUBLIC_ACCOUNT_ID, R2_PUBLIC_BUCKET, R2_PUBLIC_ACCESS_KEY_ID, R2_PUBLIC_SECRET_ACCESS_KEY, R2_PUBLIC_BASE_URL } = env;
if (!R2_PUBLIC_ACCOUNT_ID || !R2_PUBLIC_BUCKET || !R2_PUBLIC_ACCESS_KEY_ID || !R2_PUBLIC_SECRET_ACCESS_KEY || !R2_PUBLIC_BASE_URL) {
return undefined;
}
return {
accountId: R2_PUBLIC_ACCOUNT_ID,
bucket: R2_PUBLIC_BUCKET,
accessKeyId: R2_PUBLIC_ACCESS_KEY_ID,
secretAccessKey: R2_PUBLIC_SECRET_ACCESS_KEY,
publicBaseUrl: R2_PUBLIC_BASE_URL,
};
}
Loading
Loading