187 lines · 5.4 KB
1 /**
2 * POST /:owner/:repo.git/git-receive-pack
3 *
4 * Handles git push. Parses the incoming pack file, stores objects in R2,
5 * and updates refs in D1.
6 */
7
8 import { pktLineParse, pktLineEncode, pktFlush, pktConcat, pktSideBandEncode, SIDE_BAND } from '@gitfastr/shared';
9 import { parsePack } from '@gitfastr/shared/git/pack-parse.js';
10 import { requireAuth } from '../../middleware/auth.js';
11 import { RepoService } from '../../services/repo-service.js';
12 import { ObjectStore } from '../../services/object-store.js';
13 import { RefStore } from '../../services/ref-store.js';
14 import { ForbiddenError } from '@gitfastr/shared/utils/errors.js';
15 import { WebhookService } from '../../services/webhook-service.js';
16 import { ActivityService } from '../../services/activity-service.js';
17 import type { HandlerContext } from '../../router.js';
18 import type { RefUpdateCommand } from '@gitfastr/shared';
19
20 const encoder = new TextEncoder();
21 const decoder = new TextDecoder();
22 const ZERO_SHA = '0'.repeat(40);
23
24 export async function handleReceivePack(
25 request: Request,
26 hctx: HandlerContext
27 ): Promise<Response> {
28 const userId = await requireAuth(request, hctx.env.DB);
29
30 const { owner, repo: repoName } = hctx.params;
31 const repoService = new RepoService(hctx.env.DB);
32 const { repo } = await repoService.getByOwnerAndName(owner, repoName);
33
34 // Check write access
35 const hasAccess = await repoService.hasWriteAccess(repo.id, userId);
36 if (!hasAccess) {
37 throw new ForbiddenError('No write access to this repository');
38 }
39
40 // Read the entire request body
41 const body = new Uint8Array(await request.arrayBuffer());
42
43 // Parse pkt-lines to extract ref update commands
44 const { lines } = pktLineParse(body);
45
46 const commands: RefUpdateCommand[] = [];
47 let packStart = 0;
48 let capsLine = true;
49
50 for (const line of lines) {
51 if (line.type === 'flush') {
52 break;
53 }
54 if (line.type !== 'data') continue;
55
56 let lineStr = decoder.decode(line.data);
57
58 // First data line may include capabilities after NUL
59 if (capsLine) {
60 const nulIdx = lineStr.indexOf('\0');
61 if (nulIdx !== -1) {
62 lineStr = lineStr.slice(0, nulIdx);
63 }
64 capsLine = false;
65 }
66
67 // Trim trailing newline
68 if (lineStr.endsWith('\n')) lineStr = lineStr.slice(0, -1);
69
70 // Parse: "old-sha new-sha refname"
71 const parts = lineStr.split(' ');
72 if (parts.length >= 3) {
73 commands.push({
74 oldSha: parts[0],
75 newSha: parts[1],
76 refName: parts[2],
77 });
78 }
79 }
80
81 // Find where the PACK data starts (after the flush following commands)
82 // Look for "PACK" signature in the body
83 const packSig = encoder.encode('PACK');
84 for (let i = 0; i < body.length - 4; i++) {
85 if (
86 body[i] === packSig[0] &&
87 body[i + 1] === packSig[1] &&
88 body[i + 2] === packSig[2] &&
89 body[i + 3] === packSig[3]
90 ) {
91 packStart = i;
92 break;
93 }
94 }
95
96 const objectStore = new ObjectStore(hctx.env.OBJECTS, owner, repoName);
97 const refStore = new RefStore(hctx.env.DB, repo.id);
98
99 // Parse and store objects from the pack
100 if (packStart > 0) {
101 const packData = body.slice(packStart);
102
103 const objects = await parsePack(packData, async (sha: string) => {
104 return objectStore.get(sha);
105 });
106
107 // Store all objects in R2
108 for (const obj of objects) {
109 await objectStore.put(obj.sha, obj.type, obj.content);
110 }
111 }
112
113 // Apply ref updates
114 try {
115 await refStore.applyUpdates(commands);
116 } catch (error) {
117 // Report error via side-band
118 const errMsg = error instanceof Error ? error.message : 'Unknown error';
119 const errorReport = pktConcat(
120 pktSideBandEncode(
121 SIDE_BAND.ERROR,
122 encoder.encode(`error: ${errMsg}\n`)
123 ),
124 pktFlush()
125 );
126 return new Response(errorReport, {
127 status: 200,
128 headers: { 'Content-Type': 'application/x-git-receive-pack-result' },
129 });
130 }
131
132 // Fire webhooks and log activity (non-blocking)
133 const webhookService = new WebhookService(hctx.env.DB);
134 const activityService = new ActivityService(hctx.env.DB);
135
136 for (const cmd of commands) {
137 if (cmd.newSha !== ZERO_SHA) {
138 const refName = cmd.refName.replace('refs/heads/', '').replace('refs/tags/', '');
139 const payload = {
140 event: 'push' as const,
141 repository: { owner, name: repoName, full_name: `${owner}/${repoName}` },
142 sender: { username: owner },
143 ref: cmd.refName,
144 before: cmd.oldSha,
145 after: cmd.newSha,
146 };
147
148 const deliveries = webhookService.fireWebhooks(repo.id, 'push', payload);
149 deliveries.then((promises) => promises.forEach((p) => hctx.ctx.waitUntil(p)));
150
151 hctx.ctx.waitUntil(
152 activityService.log(repo.id, userId, 'push', { ref: refName, new_sha: cmd.newSha })
153 );
154 }
155 }
156
157 // Build success response with report-status
158 const reportLines: Uint8Array[] = [];
159
160 // Unpack status
161 reportLines.push(
162 pktSideBandEncode(SIDE_BAND.DATA, pktLineEncode('unpack ok\n'))
163 );
164
165 // Per-ref status
166 for (const cmd of commands) {
167 reportLines.push(
168 pktSideBandEncode(
169 SIDE_BAND.DATA,
170 pktLineEncode(`ok ${cmd.refName}\n`)
171 )
172 );
173 }
174
175 reportLines.push(
176 pktSideBandEncode(SIDE_BAND.DATA, pktFlush())
177 );
178 reportLines.push(pktFlush());
179
180 const responseBody = pktConcat(...reportLines);
181
182 return new Response(responseBody, {
183 status: 200,
184 headers: { 'Content-Type': 'application/x-git-receive-pack-result' },
185 });
186 }
187