git LFS server implemented on Cloudflare Workers + R2
cloudflare git lfs workers r2
at main 181 lines 4.8 kB view raw
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>;