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