import { 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 { 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 ` lines, an optional * `deepen `, 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 { 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 { if (res.status !== 200 || !res.body) return res; const reader = res.body.getReader(); const first = await reader.read(); const body = new ReadableStream({ 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 { 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//info/refs?service=git-upload-pack` and * `POST /git//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 { 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())); }