git LFS server implemented on Cloudflare Workers + R2
cloudflare
git
lfs
workers
r2
1import { timingSafeEqual } from "node:crypto";
2import type { LfsBatchRequest, LfsObjectResult, LfsVerifyRequest } from "./lfs";
3import { lfsJson, lfsError } from "./lfs";
4import { presignR2Url } from "./s3-presign";
5
6const OID_RE = /^[0-9a-f]{64}$/;
7const ROUTE_RE = /^\/([^/]+?)(?:\.git)?\/info\/lfs\/objects\/(batch|verify)$/;
8
9function authenticate(request: Request, env: Env): boolean {
10 const auth = request.headers.get("Authorization");
11 if (!auth?.startsWith("Basic ")) return false;
12
13 let decoded: string;
14 try {
15 decoded = atob(auth.slice(6));
16 } catch {
17 return false;
18 }
19
20 const colon = decoded.indexOf(":");
21 if (colon === -1) return false;
22
23 const user = decoded.slice(0, colon);
24 const pass = decoded.slice(colon + 1);
25
26 const enc = new TextEncoder();
27 const expectedUser = enc.encode(env.R2_ACCESS_KEY_ID);
28 const expectedPass = enc.encode(env.R2_SECRET_ACCESS_KEY);
29 const givenUser = enc.encode(user);
30 const givenPass = enc.encode(pass);
31
32 if (expectedUser.byteLength !== givenUser.byteLength || expectedPass.byteLength !== givenPass.byteLength) {
33 return false;
34 }
35
36 return timingSafeEqual(givenUser, expectedUser) && timingSafeEqual(givenPass, expectedPass);
37}
38
39async function handleBatch(request: Request, env: Env, repo: string): Promise<Response> {
40 const body = (await request.json()) as LfsBatchRequest;
41 const { operation, objects } = body;
42
43 if (operation !== "download" && operation !== "upload") {
44 return lfsError(422, `Unsupported operation: ${operation}`);
45 }
46
47 if (!objects?.length) {
48 return lfsError(422, "No objects in request");
49 }
50
51 const results = await Promise.all(
52 objects.map(async (obj): Promise<LfsObjectResult> => {
53 if (!OID_RE.test(obj.oid)) {
54 return { oid: obj.oid, size: obj.size, error: { code: 422, message: "Invalid OID" } };
55 }
56
57 const key = `${repo}/${obj.oid}`;
58
59 if (operation === "download") {
60 const head = await env.LFS_BUCKET.head(key);
61 if (!head) {
62 return { oid: obj.oid, size: obj.size, error: { code: 404, message: "Object not found" } };
63 }
64
65 const href = await presignR2Url({
66 accountId: env.R2_ACCOUNT_ID,
67 bucket: env.R2_BUCKET_NAME,
68 key,
69 accessKeyId: env.R2_ACCESS_KEY_ID,
70 secretAccessKey: env.R2_SECRET_ACCESS_KEY,
71 method: "GET",
72 });
73
74 return {
75 oid: obj.oid,
76 size: obj.size,
77 authenticated: true,
78 actions: { download: { href, expires_in: 3600 } },
79 };
80 }
81
82 // upload
83 const head = await env.LFS_BUCKET.head(key);
84 if (head && head.size === obj.size && head.customMetadata?.sha256 === obj.oid) {
85 // Already uploaded, skip
86 return { oid: obj.oid, size: obj.size };
87 }
88
89 const href = await presignR2Url({
90 accountId: env.R2_ACCOUNT_ID,
91 bucket: env.R2_BUCKET_NAME,
92 key,
93 accessKeyId: env.R2_ACCESS_KEY_ID,
94 secretAccessKey: env.R2_SECRET_ACCESS_KEY,
95 method: "PUT",
96 signedHeaders: { "x-amz-meta-sha256": obj.oid },
97 });
98
99 const verifyUrl = new URL(request.url);
100 verifyUrl.pathname = `/${repo}.git/info/lfs/objects/verify`;
101
102 return {
103 oid: obj.oid,
104 size: obj.size,
105 authenticated: true,
106 actions: {
107 upload: {
108 href,
109 header: { "x-amz-meta-sha256": obj.oid },
110 expires_in: 3600,
111 },
112 verify: {
113 href: verifyUrl.toString(),
114 expires_in: 3600,
115 },
116 },
117 };
118 }),
119 );
120
121 return lfsJson({ transfer: "basic", objects: results });
122}
123
124async function handleVerify(request: Request, env: Env, repo: string): Promise<Response> {
125 const body = (await request.json()) as LfsVerifyRequest;
126 const { oid, size } = body;
127
128 if (!OID_RE.test(oid)) {
129 return lfsError(422, "Invalid OID");
130 }
131
132 const key = `${repo}/${oid}`;
133 const head = await env.LFS_BUCKET.head(key);
134
135 if (!head) {
136 return lfsError(404, "Object not found");
137 }
138
139 if (head.size !== size) {
140 return lfsError(422, `Size mismatch: expected ${size}, got ${head.size}`);
141 }
142
143 if (head.customMetadata?.sha256 !== oid) {
144 return lfsError(422, "OID mismatch in metadata");
145 }
146
147 return new Response(null, { status: 200 });
148}
149
150export default {
151 async fetch(request: Request, env: Env): Promise<Response> {
152 if (request.method !== "POST") {
153 return lfsError(405, "Method not allowed");
154 }
155
156 const url = new URL(request.url);
157 const match = ROUTE_RE.exec(url.pathname);
158 if (!match) {
159 return lfsError(404, "Not found");
160 }
161
162 const [, repo, action] = match;
163
164 if (!authenticate(request, env)) {
165 return new Response(JSON.stringify({ message: "Credentials needed" }), {
166 status: 401,
167 headers: {
168 "Content-Type": "application/vnd.git-lfs+json",
169 "WWW-Authenticate": "Basic",
170 "LFS-Authenticate": "Basic realm=\"Git LFS\"",
171 },
172 });
173 }
174
175 if (action === "batch") {
176 return handleBatch(request, env, repo);
177 }
178
179 return handleVerify(request, env, repo);
180 },
181} satisfies ExportedHandler<Env>;