112 lines · 3.2 KB
| 1 | /** |
| 2 | * GET /:owner/:repo.git/info/refs?service=git-upload-pack|git-receive-pack |
| 3 | * |
| 4 | * Reference discovery endpoint. Advertises refs and capabilities. |
| 5 | */ |
| 6 | |
| 7 | import { buildServiceAdvertisement } from '@gitfastr/shared'; |
| 8 | import { authenticate } from '../../middleware/auth.js'; |
| 9 | import { RepoService } from '../../services/repo-service.js'; |
| 10 | import { RefStore } from '../../services/ref-store.js'; |
| 11 | import { ForbiddenError } from '@gitfastr/shared/utils/errors.js'; |
| 12 | import type { HandlerContext } from '../../router.js'; |
| 13 | |
| 14 | const UPLOAD_PACK_CAPS = [ |
| 15 | 'multi_ack_detailed', |
| 16 | 'thin-pack', |
| 17 | 'no-progress', |
| 18 | 'ofs-delta', |
| 19 | 'side-band-64k', |
| 20 | 'allow-reachable-sha1-in-want', |
| 21 | ]; |
| 22 | |
| 23 | const RECEIVE_PACK_CAPS = [ |
| 24 | 'report-status', |
| 25 | 'ofs-delta', |
| 26 | 'side-band-64k', |
| 27 | 'delete-refs', |
| 28 | ]; |
| 29 | |
| 30 | export async function handleInfoRefs( |
| 31 | request: Request, |
| 32 | hctx: HandlerContext |
| 33 | ): Promise<Response> { |
| 34 | const url = new URL(request.url); |
| 35 | const service = url.searchParams.get('service'); |
| 36 | |
| 37 | if (service !== 'git-upload-pack' && service !== 'git-receive-pack') { |
| 38 | return new Response('Invalid service', { status: 400 }); |
| 39 | } |
| 40 | |
| 41 | const { owner, repo: repoName } = hctx.params; |
| 42 | const repoService = new RepoService(hctx.env.DB); |
| 43 | const { repo } = await repoService.getByOwnerAndName(owner, repoName); |
| 44 | |
| 45 | // Auth check |
| 46 | const userId = await authenticate(request, hctx.env.DB); |
| 47 | |
| 48 | if (service === 'git-receive-pack') { |
| 49 | // Push requires write access |
| 50 | const hasAccess = await repoService.hasWriteAccess(repo.id, userId); |
| 51 | if (!hasAccess) { |
| 52 | return new Response('', { |
| 53 | status: 401, |
| 54 | headers: { 'WWW-Authenticate': 'Basic realm="gitfastr"' }, |
| 55 | }); |
| 56 | } |
| 57 | } else { |
| 58 | // Fetch requires read access |
| 59 | const hasAccess = await repoService.hasReadAccess(repo, userId); |
| 60 | if (!hasAccess) { |
| 61 | return new Response('', { |
| 62 | status: 401, |
| 63 | headers: { 'WWW-Authenticate': 'Basic realm="gitfastr"' }, |
| 64 | }); |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | const refStore = new RefStore(hctx.env.DB, repo.id); |
| 69 | const refs = await refStore.getAll(); |
| 70 | |
| 71 | // Find the default branch ref for HEAD resolution |
| 72 | const defaultBranchRef = refs.find( |
| 73 | (r) => r.name === `refs/heads/${repo.default_branch}` |
| 74 | ); |
| 75 | |
| 76 | const baseCaps = |
| 77 | service === 'git-upload-pack' ? [...UPLOAD_PACK_CAPS] : [...RECEIVE_PACK_CAPS]; |
| 78 | |
| 79 | // Add symref capability so clients know HEAD -> default branch |
| 80 | if (defaultBranchRef) { |
| 81 | baseCaps.push(`symref=HEAD:refs/heads/${repo.default_branch}`); |
| 82 | } |
| 83 | const capabilities = baseCaps; |
| 84 | |
| 85 | // Map DB refs to the format buildServiceAdvertisement expects |
| 86 | const refList = refs |
| 87 | .filter((r) => !r.is_symbolic) |
| 88 | .map((r) => ({ sha: r.sha, name: r.name })); |
| 89 | |
| 90 | // Add HEAD pointing to the default branch if it exists |
| 91 | if (defaultBranchRef) { |
| 92 | refList.unshift({ sha: defaultBranchRef.sha, name: 'HEAD' }); |
| 93 | } |
| 94 | |
| 95 | // Sort HEAD first, then alphabetically |
| 96 | refList.sort((a, b) => { |
| 97 | if (a.name === 'HEAD') return -1; |
| 98 | if (b.name === 'HEAD') return 1; |
| 99 | return a.name.localeCompare(b.name); |
| 100 | }); |
| 101 | |
| 102 | const body = buildServiceAdvertisement(service, refList, capabilities); |
| 103 | |
| 104 | return new Response(body, { |
| 105 | status: 200, |
| 106 | headers: { |
| 107 | 'Content-Type': `application/x-${service}-advertisement`, |
| 108 | 'Cache-Control': 'no-cache', |
| 109 | }, |
| 110 | }); |
| 111 | } |
| 112 |