Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159import { isDid } from '@atcute/lexicons/syntax';import { serviceEndpoint } from '../identity.ts';import { cached, NO_STORE, POLICY } from './cache.ts';import type { Gateway } from './context.ts';import { didDocument } from './identity.ts';import { CORS, failureResponse, fetchUpstream, limitStream, readLimited, UpstreamError, xrpcError } from './proxy.ts';
/** A clone streams for longer than an XRPC call may take. */const GIT_TIMEOUT_MS = 60_000;/** The SDK's own import cap. */const MAX_PACK_BYTES = 100_000_000;/** An upload-pack request is wants and haves; a megabyte is thousands of them. */const MAX_REQUEST_BYTES = 1_000_000;
const ROUTE = /^\/git\/([^/]+)\/(info\/refs|git-upload-pack|git-receive-pack)$/;
const FORWARDED = ['content-type', 'accept', 'git-protocol'];
/** The knot a repo DID's document names as `#tangled_knot`. The gateway proxies only there. */async function knotFor(g: Gateway, did: string): Promise<string | Response> { try { const doc = await didDocument(g, did); if (!doc.found) return xrpcError('RepoNotFound', doc.message, 404); const knot = serviceEndpoint(doc.value, did, { id: '#tangled_knot' }); return knot ? new URL(knot).origin : xrpcError('RepoNotFound', `${did} names no public https #tangled_knot`, 404); } catch (error) { return failureResponse(error); }}
const latin1 = new TextDecoder('latin1');
/** * Whether an upload-pack request is a pure fetch of named commits: `want <sha>` lines, an optional * `deepen <n>`, flushes, and `done`, with no `have` or `shallow` lines. The answer to such a request * depends only on the request bytes and the commits, which never change, so it can be cached by body. */export function isPureWant(body: Uint8Array): boolean { let at = 0; let wants = 0; let done = false; while (at < body.length) { const length = Number.parseInt(latin1.decode(body.subarray(at, at + 4)), 16); if (!/^[0-9a-f]{4}$/.test(latin1.decode(body.subarray(at, at + 4))) || Number.isNaN(length)) return false; if (length === 0) { at += 4; continue; } if (length < 5 || at + length > body.length) return false; const line = latin1.decode(body.subarray(at + 4, at + length)).replace(/\n$/, ''); at += length; if (/^want [0-9a-f]{40}(?: .*)?$/.test(line)) wants++; else if (line === 'done') done = true; else if (!/^deepen [1-9]\d*$/.test(line)) return false; } return wants > 0 && done;}
async function sha256Hex(bytes: Uint8Array): Promise<string> { const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); return Array.from(digest, (b) => b.toString(16).padStart(2, '0')).join('');}
/** A knot that cannot serve a want answers 200 with an `ERR` pkt-line. That answer is passed on but * never cached: the commit may be pushed later. Reads the first chunk to tell. */async function markErrors(res: Response): Promise<Response> { if (res.status !== 200 || !res.body) return res; const reader = res.body.getReader(); const first = await reader.read(); const body = new ReadableStream<Uint8Array>({ start(controller) { if (!first.done) controller.enqueue(first.value); }, async pull(controller) { const next = await reader.read(); if (next.done) controller.close(); else controller.enqueue(next.value); }, cancel: (reason) => reader.cancel(reason), }); const headers = new Headers(res.headers); if (!first.done && /^[0-9a-f]{4}ERR /.test(latin1.decode(first.value.subarray(0, 12)))) headers.set(NO_STORE, '1'); return new Response(body, { status: res.status, headers });}
async function forward(g: Gateway, url: string, init: RequestInit): Promise<Response> { try { const res = await fetchUpstream(g.fetch, url, { ...init, timeoutMs: GIT_TIMEOUT_MS }); if (res.status >= 500) { await res.body?.cancel(); return xrpcError('UpstreamFailure', `${new URL(url).host} answered ${res.status}`, 502); } if (res.status !== 200) { const bytes = await readLimited(res, 64_000); return new Response(bytes, { status: res.status, headers: { ...CORS, 'content-type': res.headers.get('content-type') ?? 'text/plain' } }); } const headers = { ...CORS, 'content-type': res.headers.get('content-type') ?? 'application/octet-stream' }; return new Response(res.body && limitStream(res.body, MAX_PACK_BYTES), { headers }); } catch (error) { return failureResponse(error); }}
function forwardedHeaders(request: Request): Headers { const headers = new Headers(); for (const name of FORWARDED) { const value = request.headers.get(name); if (value) headers.set(name, value); } return headers;}
/** * Smart-HTTP reads for a tangled repo, by repo DID: `/git/<did>/info/refs?service=git-upload-pack` and * `POST /git/<did>/git-upload-pack`. Pushes are refused: a push token is single-use and bound to the * knot, so it goes from the browser to the knot directly. */export async function handleGit(g: Gateway, request: Request, bypass: boolean): Promise<Response | undefined> { const url = new URL(request.url); const match = ROUTE.exec(url.pathname); if (!match) return undefined; const [, did, endpoint] = match as unknown as [string, string, string]; if (endpoint === 'git-receive-pack' || url.searchParams.get('service') === 'git-receive-pack') { return xrpcError('MethodNotAllowed', 'push straight to the knot; the gateway only reads', 403); } if (!isDid(did)) return xrpcError('InvalidRequest', `${did} is not a DID`, 400);
if (endpoint === 'info/refs') { if (request.method !== 'GET') return xrpcError('MethodNotAllowed', 'info/refs is GET', 405); if (url.searchParams.get('service') !== 'git-upload-pack') return xrpcError('InvalidRequest', 'service must be git-upload-pack', 400); const knot = await knotFor(g, did); if (knot instanceof Response) return knot; const res = await forward(g, `${knot}/${did}/info/refs?service=git-upload-pack`, { headers: forwardedHeaders(request) }); res.headers.set('cache-control', 'no-store'); return res; }
if (request.method !== 'POST') return xrpcError('MethodNotAllowed', 'git-upload-pack is POST', 405); let body: Uint8Array; try { body = await readLimited(new Response(request.body, { headers: request.headers }), MAX_REQUEST_BYTES); } catch (error) { if (error instanceof UpstreamError && error.reason === 'too-large') return xrpcError('PayloadTooLarge', error.message, 413); throw error; } const knot = await knotFor(g, did); if (knot instanceof Response) return knot; const load = () => forward(g, `${knot}/${did}/git-upload-pack`, { method: 'POST', headers: forwardedHeaders(request), body });
const cacheable = isPureWant(body) && !request.headers.get('git-protocol') && !request.headers.get('content-encoding'); if (!cacheable) { const res = await load(); res.headers.set('cache-control', 'no-store'); return res; } const key = new URL(`/git/${did}/git-upload-pack?body=${await sha256Hex(body)}`, g.origin).toString(); return cached(g, key, POLICY.immutable, bypass, async () => markErrors(await load()));}