@0xordek/git-me 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +150 -112
  2. package/dist/cli.js +606 -26
  3. package/dist/worker.js +635 -0
  4. package/package.json +28 -23
package/dist/worker.js ADDED
@@ -0,0 +1,635 @@
1
+ // src/config.ts
2
+ var ConfigError = class extends Error {
3
+ constructor(message = "configuration error") {
4
+ super(message);
5
+ this.name = "ConfigError";
6
+ }
7
+ };
8
+ function loadConfig(env) {
9
+ if (!env.GITME_AUTH_TOKEN) throw new ConfigError();
10
+ const transferMode = env.GITME_TRANSFER_MODE ?? "proxy";
11
+ if (transferMode !== "proxy" && transferMode !== "direct") throw new ConfigError();
12
+ const signedUrlTtlSeconds = parseSignedUrlTtl(env.GITME_SIGNED_URL_TTL_SECONDS);
13
+ const config = { authToken: env.GITME_AUTH_TOKEN, transferMode, signedUrlTtlSeconds };
14
+ if (transferMode === "direct") {
15
+ const { GITME_R2_ACCOUNT_ID, GITME_R2_ACCESS_KEY_ID, GITME_R2_SECRET_ACCESS_KEY, GITME_R2_BUCKET_NAME } = env;
16
+ if (!GITME_R2_ACCOUNT_ID || !GITME_R2_ACCESS_KEY_ID || !GITME_R2_SECRET_ACCESS_KEY || !GITME_R2_BUCKET_NAME) {
17
+ throw new ConfigError();
18
+ }
19
+ config.r2Signing = {
20
+ accountId: GITME_R2_ACCOUNT_ID,
21
+ accessKeyId: GITME_R2_ACCESS_KEY_ID,
22
+ secretAccessKey: GITME_R2_SECRET_ACCESS_KEY,
23
+ bucketName: GITME_R2_BUCKET_NAME
24
+ };
25
+ }
26
+ return config;
27
+ }
28
+ function healthResponse(env) {
29
+ try {
30
+ const config = loadConfig(env);
31
+ return appJson(200, { ok: true, transfer_mode: config.transferMode });
32
+ } catch {
33
+ return appJson(500, { ok: false });
34
+ }
35
+ }
36
+ function parseSignedUrlTtl(raw) {
37
+ if (raw === void 0) return 900;
38
+ const value = Number(raw);
39
+ if (!Number.isInteger(value) || value < 60 || value > 3600) throw new ConfigError();
40
+ return value;
41
+ }
42
+ function appJson(status, body) {
43
+ return new Response(JSON.stringify(body) + "\n", { status, headers: { "Content-Type": "application/json" } });
44
+ }
45
+
46
+ // src/signing.ts
47
+ var ALGORITHM = "AWS4-HMAC-SHA256";
48
+ var REGION = "auto";
49
+ var SERVICE = "s3";
50
+ var TERMINATOR = "aws4_request";
51
+ var PAYLOAD_HASH = "UNSIGNED-PAYLOAD";
52
+ async function presignR2Url(input) {
53
+ const now = input.now ?? /* @__PURE__ */ new Date();
54
+ const amzDate = formatAmzDate(now);
55
+ const dateStamp = amzDate.slice(0, 8);
56
+ const credentialScope = `${dateStamp}/${REGION}/${SERVICE}/${TERMINATOR}`;
57
+ const host = `${input.signing.accountId}.r2.cloudflarestorage.com`;
58
+ const canonicalUri = `/${encodePathSegment(input.signing.bucketName)}/${encodePath(input.key)}`;
59
+ const query = [
60
+ ["X-Amz-Algorithm", ALGORITHM],
61
+ ["X-Amz-Content-Sha256", PAYLOAD_HASH],
62
+ ["X-Amz-Credential", `${input.signing.accessKeyId}/${credentialScope}`],
63
+ ["X-Amz-Date", amzDate],
64
+ ["X-Amz-Expires", String(input.expiresSeconds)],
65
+ ["X-Amz-SignedHeaders", "host"]
66
+ ];
67
+ const canonicalQuery = canonicalQueryString(query);
68
+ const canonicalRequest = [
69
+ input.method,
70
+ canonicalUri,
71
+ canonicalQuery,
72
+ `host:${host}
73
+ `,
74
+ "host",
75
+ PAYLOAD_HASH
76
+ ].join("\n");
77
+ const stringToSign = [ALGORITHM, amzDate, credentialScope, await sha256Hex(canonicalRequest)].join("\n");
78
+ const signingKey = await deriveSigningKey(input.signing.secretAccessKey, dateStamp);
79
+ const signature = await hmacHex(signingKey, stringToSign);
80
+ return `https://${host}${canonicalUri}?${canonicalQueryString([...query, ["X-Amz-Signature", signature]])}`;
81
+ }
82
+ function canonicalQueryString(query) {
83
+ return query.map(([key, value]) => [encodeQueryPart(key), encodeQueryPart(value)]).sort(([ak, av], [bk, bv]) => compareAscii(ak, bk) || compareAscii(av, bv)).map(([key, value]) => `${key}=${value}`).join("&");
84
+ }
85
+ function compareAscii(a, b) {
86
+ if (a < b) return -1;
87
+ if (a > b) return 1;
88
+ return 0;
89
+ }
90
+ function encodePath(path) {
91
+ return path.split("/").map(encodePathSegment).join("/");
92
+ }
93
+ function encodePathSegment(segment) {
94
+ return encodeQueryPart(segment);
95
+ }
96
+ function encodeQueryPart(value) {
97
+ return encodeURIComponent(value).replace(/[!'()*]/g, (char) => "%" + char.charCodeAt(0).toString(16).toUpperCase());
98
+ }
99
+ function formatAmzDate(date) {
100
+ return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
101
+ }
102
+ async function deriveSigningKey(secretAccessKey, dateStamp) {
103
+ const dateKey = await hmacBytes(textBytes("AWS4" + secretAccessKey), dateStamp);
104
+ const regionKey = await hmacBytes(dateKey, REGION);
105
+ const serviceKey = await hmacBytes(regionKey, SERVICE);
106
+ const signingKeyBytes = await hmacBytes(serviceKey, TERMINATOR);
107
+ return importHmacKey(signingKeyBytes);
108
+ }
109
+ async function sha256Hex(value) {
110
+ const digest = await crypto.subtle.digest("SHA-256", toArrayBuffer(textBytes(value)));
111
+ return bytesToHex(new Uint8Array(digest));
112
+ }
113
+ async function hmacBytes(keyBytes, value) {
114
+ const key = await importHmacKey(keyBytes);
115
+ const signature = await crypto.subtle.sign("HMAC", key, toArrayBuffer(textBytes(value)));
116
+ return new Uint8Array(signature);
117
+ }
118
+ async function hmacHex(key, value) {
119
+ const signature = await crypto.subtle.sign("HMAC", key, toArrayBuffer(textBytes(value)));
120
+ return bytesToHex(new Uint8Array(signature));
121
+ }
122
+ function importHmacKey(keyBytes) {
123
+ return crypto.subtle.importKey("raw", toArrayBuffer(keyBytes), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
124
+ }
125
+ function textBytes(value) {
126
+ return new TextEncoder().encode(value);
127
+ }
128
+ function toArrayBuffer(bytes) {
129
+ const copy = new Uint8Array(bytes.byteLength);
130
+ copy.set(bytes);
131
+ return copy.buffer;
132
+ }
133
+ function bytesToHex(bytes) {
134
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
135
+ }
136
+
137
+ // src/auth.ts
138
+ var USER_REGISTRY = "admin:users";
139
+ async function createUser(env, username, password, access) {
140
+ await requestAuth(env, username, { action: "create", username, password, access });
141
+ }
142
+ async function deleteUser(env, username) {
143
+ await requestAuth(env, username, { action: "delete", username });
144
+ }
145
+ async function authenticateUser(env, username, password, source) {
146
+ return await requestAuth(env, username, { action: "authenticate", username, password, source });
147
+ }
148
+ async function updateUserIndex(env, username, access) {
149
+ await requestAuth(env, USER_REGISTRY, access ? { action: "upsert", username, access } : { action: "remove", username });
150
+ }
151
+ async function listUsers(env) {
152
+ return (await requestAuth(env, USER_REGISTRY, { action: "list" })).users;
153
+ }
154
+ async function requestAuth(env, username, body) {
155
+ const request = new Request("https://git-me.internal/auth", {
156
+ method: "POST",
157
+ headers: { "Content-Type": "application/json" },
158
+ body: JSON.stringify(body)
159
+ });
160
+ const response2 = await env.GITME_AUTH.getByName(username).fetch(request);
161
+ if (!response2.ok) throw new Error("authentication service error");
162
+ return await response2.json();
163
+ }
164
+
165
+ // src/auth-do.ts
166
+ var RECORD_KEY = "record";
167
+ var USERS_KEY = "users";
168
+ var ATTEMPTS_PREFIX = "attempts:";
169
+ var PBKDF2_ITERATIONS = 6e5;
170
+ var MAX_FAILURES = 5;
171
+ var FAILURE_WINDOW_MS = 6e4;
172
+ var LOCKOUT_MS = 6e4;
173
+ var AuthUser = class {
174
+ constructor(state, env) {
175
+ this.state = state;
176
+ this.env = env;
177
+ }
178
+ state;
179
+ env;
180
+ async fetch(request) {
181
+ return await this.state.blockConcurrencyWhile(async () => await this.handle(request));
182
+ }
183
+ async handle(request) {
184
+ if (request.method !== "POST") return response(405, { ok: false });
185
+ let input;
186
+ try {
187
+ input = await request.json();
188
+ } catch {
189
+ return response(400, { ok: false });
190
+ }
191
+ if (!isAuthRequest(input)) return response(400, { ok: false });
192
+ if (input.action === "list") return response(200, { ok: true, users: await this.readUsers() });
193
+ if (input.action === "upsert" || input.action === "remove") return await this.updateUsers(input);
194
+ if (input.action === "create") return await this.create(input);
195
+ if (input.action === "delete") return await this.remove(input);
196
+ return await this.authenticate(input);
197
+ }
198
+ async create(input) {
199
+ if (input.password.length < 12 || input.password.length > 1024) return response(400, { ok: false });
200
+ const record = await createRecord(input.password, input.access);
201
+ await this.state.storage.put(RECORD_KEY, record);
202
+ await this.env.GITME_KV.delete(`user:${input.username}`);
203
+ await updateUserIndex(this.env, input.username, input.access);
204
+ await this.state.storage.put(RECORD_KEY, { ...record, indexed: true });
205
+ return response(200, { ok: true, access: input.access });
206
+ }
207
+ async remove(input) {
208
+ await this.state.storage.put(RECORD_KEY, { version: 1, deleted: true });
209
+ await this.env.GITME_KV.delete(`user:${input.username}`);
210
+ await updateUserIndex(this.env, input.username);
211
+ return response(200, { ok: true });
212
+ }
213
+ async authenticate(input) {
214
+ if (await this.isLocked(input.source)) return response(200, { ok: false });
215
+ const stored = await this.state.storage.get(RECORD_KEY);
216
+ if (stored && !("deleted" in stored)) {
217
+ if (await verifyPassword(input.password, stored)) {
218
+ await this.state.storage.delete(attemptKey(input.source));
219
+ if (!stored.indexed) {
220
+ await updateUserIndex(this.env, input.username, stored.access);
221
+ await this.state.storage.put(RECORD_KEY, { ...stored, indexed: true });
222
+ }
223
+ return response(200, { ok: true, access: stored.access });
224
+ }
225
+ await this.recordFailure(input.source);
226
+ return response(200, { ok: false });
227
+ }
228
+ if (stored) return response(200, { ok: false });
229
+ const legacy = await this.legacyRecord(input.username);
230
+ if (legacy && await legacyPasswordMatches(input.password, legacy.password_sha256)) {
231
+ const access = legacy.access === "write" ? "write" : legacy.access === "read" ? "read" : null;
232
+ if (access) {
233
+ const record = await createRecord(input.password, access);
234
+ await this.state.storage.put(RECORD_KEY, record);
235
+ await this.state.storage.delete(attemptKey(input.source));
236
+ await this.env.GITME_KV.delete(`user:${input.username}`);
237
+ await updateUserIndex(this.env, input.username, access);
238
+ await this.state.storage.put(RECORD_KEY, { ...record, indexed: true });
239
+ return response(200, { ok: true, access });
240
+ }
241
+ }
242
+ if (legacy) await this.recordFailure(input.source);
243
+ return response(200, { ok: false });
244
+ }
245
+ async updateUsers(input) {
246
+ const users = await this.readUsers();
247
+ const existing = users.findIndex((user) => user.username === input.username);
248
+ if (input.action === "remove") {
249
+ if (existing >= 0) users.splice(existing, 1);
250
+ } else if (existing >= 0) {
251
+ users[existing] = { username: input.username, access: input.access };
252
+ } else {
253
+ users.push({ username: input.username, access: input.access });
254
+ }
255
+ users.sort((left, right) => left.username.localeCompare(right.username));
256
+ await this.state.storage.put(USERS_KEY, users);
257
+ return response(200, { ok: true });
258
+ }
259
+ async readUsers() {
260
+ const users = await this.state.storage.get(USERS_KEY);
261
+ return Array.isArray(users) ? users.filter(isIndexedUser).sort((left, right) => left.username.localeCompare(right.username)) : [];
262
+ }
263
+ async legacyRecord(username) {
264
+ const raw = await this.env.GITME_KV.get(`user:${username}`);
265
+ if (!raw) return null;
266
+ try {
267
+ const value = JSON.parse(raw);
268
+ return typeof value === "object" && value ? value : null;
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+ async isLocked(source) {
274
+ const attempts = await this.state.storage.get(attemptKey(source));
275
+ return Boolean(attempts && attempts.lockedUntil > Date.now());
276
+ }
277
+ async recordFailure(source) {
278
+ const now = Date.now();
279
+ const key = attemptKey(source);
280
+ const previous = await this.state.storage.get(key);
281
+ const attempts = !previous || previous.windowStartedAt + FAILURE_WINDOW_MS <= now ? { count: 1, windowStartedAt: now, lockedUntil: 0 } : { ...previous, count: previous.count + 1 };
282
+ if (attempts.count >= MAX_FAILURES) attempts.lockedUntil = now + LOCKOUT_MS;
283
+ await this.state.storage.put(key, attempts);
284
+ }
285
+ };
286
+ function isAuthRequest(value) {
287
+ if (!isRecord(value) || typeof value.action !== "string") return false;
288
+ if (value.action === "list") return true;
289
+ if (typeof value.username !== "string") return false;
290
+ if (value.action === "remove") return true;
291
+ if (value.action === "upsert") return value.access === "read" || value.access === "write";
292
+ if (value.action === "delete") return true;
293
+ if (value.action === "authenticate") return typeof value.password === "string" && typeof value.source === "string" && value.source.length > 0 && value.source.length <= 64;
294
+ return value.action === "create" && typeof value.password === "string" && (value.access === "read" || value.access === "write");
295
+ }
296
+ function isIndexedUser(value) {
297
+ return isRecord(value) && typeof value.username === "string" && (value.access === "read" || value.access === "write");
298
+ }
299
+ function isRecord(value) {
300
+ return typeof value === "object" && value !== null;
301
+ }
302
+ function attemptKey(source) {
303
+ return ATTEMPTS_PREFIX + source;
304
+ }
305
+ async function createRecord(password, access) {
306
+ const salt = crypto.getRandomValues(new Uint8Array(16));
307
+ return { version: 1, access, salt: bytesToBase64(salt), hash: await passwordHash(password, salt) };
308
+ }
309
+ async function verifyPassword(password, record) {
310
+ try {
311
+ return constantTimeEqual(await passwordHash(password, base64ToBytes(record.salt)), record.hash);
312
+ } catch {
313
+ return false;
314
+ }
315
+ }
316
+ async function passwordHash(password, salt) {
317
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
318
+ const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: toArrayBuffer2(salt), iterations: PBKDF2_ITERATIONS }, key, 256);
319
+ return bytesToBase64(new Uint8Array(bits));
320
+ }
321
+ async function legacyPasswordMatches(password, hash) {
322
+ if (typeof hash !== "string" || !/^[0-9a-f]{64}$/i.test(hash)) return false;
323
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(password));
324
+ return constantTimeEqual(bytesToHex2(new Uint8Array(digest)), hash.toLowerCase());
325
+ }
326
+ function constantTimeEqual(left, right) {
327
+ if (left.length !== right.length) return false;
328
+ let difference = 0;
329
+ for (let index = 0; index < left.length; index += 1) difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
330
+ return difference === 0;
331
+ }
332
+ function bytesToBase64(bytes) {
333
+ return btoa(String.fromCharCode(...bytes));
334
+ }
335
+ function base64ToBytes(value) {
336
+ return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
337
+ }
338
+ function bytesToHex2(bytes) {
339
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
340
+ }
341
+ function toArrayBuffer2(bytes) {
342
+ const copy = new Uint8Array(bytes.byteLength);
343
+ copy.set(bytes);
344
+ return copy.buffer;
345
+ }
346
+ function response(status, body) {
347
+ return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
348
+ }
349
+
350
+ // src/worker.ts
351
+ var LFS_CONTENT_TYPE = "application/vnd.git-lfs+json";
352
+ var OBJECT_PREFIX = "objects/";
353
+ var OID_RE = /^[0-9a-fA-F]{64}$/;
354
+ var USERNAME_RE = /^[a-z0-9][a-z0-9_.-]{0,62}$/;
355
+ var worker_default = {
356
+ async fetch(request, env, _ctx) {
357
+ const requestId = crypto.randomUUID();
358
+ let path = "";
359
+ try {
360
+ const url = new URL(request.url);
361
+ path = url.pathname;
362
+ if (request.method === "GET" && url.pathname === "/health") {
363
+ return healthResponse(env);
364
+ }
365
+ let config;
366
+ try {
367
+ config = loadConfig(env);
368
+ } catch (error) {
369
+ if (error instanceof ConfigError) return lfsError(500, "configuration error");
370
+ throw error;
371
+ }
372
+ if (url.pathname === "/admin/users") return handleAdminUsers(request, env, config);
373
+ if (url.pathname.startsWith("/admin/users/")) return handleAdminUser(request, env, config, url.pathname.slice("/admin/users/".length));
374
+ if (url.pathname === "/objects/batch") {
375
+ return handleBatch(request, env, config);
376
+ }
377
+ if (url.pathname.startsWith("/objects/")) {
378
+ const oid = url.pathname.slice("/objects/".length);
379
+ if (request.method === "PUT") {
380
+ const auth = await requireLfsAccess(request, env, config, "write");
381
+ if (auth) return auth;
382
+ return handleUpload(request, env, oid);
383
+ }
384
+ if (request.method === "GET") {
385
+ const auth = await requireLfsAccess(request, env, config, "read");
386
+ if (auth) return auth;
387
+ return handleDownload(env, oid);
388
+ }
389
+ return lfsError(405, "method not allowed");
390
+ }
391
+ return new Response("not found\n", { status: 404 });
392
+ } catch (error) {
393
+ console.error("git-me request failed", {
394
+ requestId,
395
+ method: request.method,
396
+ path,
397
+ error: error instanceof Error ? error.message : String(error)
398
+ });
399
+ const response2 = lfsError(500, "internal server error");
400
+ response2.headers.set("X-Request-Id", requestId);
401
+ return response2;
402
+ }
403
+ }
404
+ };
405
+ async function handleBatch(request, env, config) {
406
+ if (request.method !== "POST") return lfsError(405, "method not allowed");
407
+ const auth = await authenticateLfs(request, env, config);
408
+ if ("response" in auth) return auth.response;
409
+ if (!isLfsContentType(request.headers.get("Content-Type"))) {
410
+ return lfsError(415, "content type must be " + LFS_CONTENT_TYPE);
411
+ }
412
+ let body;
413
+ try {
414
+ body = await request.json();
415
+ } catch {
416
+ return lfsError(400, "invalid JSON");
417
+ }
418
+ const transfer = selectTransfer(body.transfers);
419
+ if (!transfer) return lfsError(400, "unsupported transfer adapter");
420
+ if (!isLfsOperation(body.operation)) {
421
+ return lfsError(400, "lfs: unknown batch operation: " + body.operation);
422
+ }
423
+ if (!canAccess(auth.access, body.operation === "upload" ? "write" : "read")) return lfsError(403, "forbidden");
424
+ if (!Array.isArray(body.objects)) return lfsError(400, "objects must be an array");
425
+ const objects = [];
426
+ for (const obj of body.objects) {
427
+ const validation = validateObject(obj);
428
+ if (validation) return lfsError(400, validation);
429
+ objects.push(obj);
430
+ }
431
+ const responseObjects = [];
432
+ for (const obj of objects) {
433
+ const href = new URL(`/objects/${obj.oid}`, request.url).href;
434
+ if (body.operation === "upload") {
435
+ const existing = await env.GITME_R2.head(OBJECT_PREFIX + obj.oid);
436
+ if (existing?.customMetadata?.sha256 === obj.oid.toLowerCase()) {
437
+ responseObjects.push(existing.size === obj.size ? { oid: obj.oid, size: obj.size } : objectConflict(obj));
438
+ continue;
439
+ }
440
+ responseObjects.push({ oid: obj.oid, size: obj.size, actions: { upload: { href } } });
441
+ continue;
442
+ }
443
+ const head = await env.GITME_R2.head(OBJECT_PREFIX + obj.oid);
444
+ if (!head || head.size !== obj.size) {
445
+ responseObjects.push(objectNotFound(obj));
446
+ continue;
447
+ }
448
+ if (config.transferMode === "direct" && config.r2Signing) {
449
+ if (head.customMetadata?.sha256 !== obj.oid.toLowerCase()) {
450
+ responseObjects.push({ oid: obj.oid, size: head.size, actions: { download: { href } } });
451
+ continue;
452
+ }
453
+ responseObjects.push({
454
+ oid: obj.oid,
455
+ size: head.size,
456
+ actions: {
457
+ download: {
458
+ href: await presignR2Url({
459
+ method: "GET",
460
+ key: OBJECT_PREFIX + obj.oid,
461
+ expiresSeconds: config.signedUrlTtlSeconds,
462
+ signing: config.r2Signing
463
+ }),
464
+ expires_in: config.signedUrlTtlSeconds
465
+ }
466
+ }
467
+ });
468
+ continue;
469
+ }
470
+ responseObjects.push({ oid: obj.oid, size: head.size, actions: { download: { href } } });
471
+ }
472
+ return json(200, { transfer, objects: responseObjects });
473
+ }
474
+ async function handleUpload(request, env, oid) {
475
+ if (!OID_RE.test(oid)) return lfsError(400, "invalid oid");
476
+ if (!request.body) return lfsError(400, "missing request body");
477
+ const objectKey = OBJECT_PREFIX + oid;
478
+ const tempKey = OBJECT_PREFIX + ".tmp/" + crypto.randomUUID();
479
+ const [storeStream, digestStream] = request.body.tee();
480
+ const putPromise = env.GITME_R2.put(tempKey, storeStream);
481
+ const digest = await digestAndCount(digestStream);
482
+ await putPromise;
483
+ if (digest.hex.toLowerCase() !== oid.toLowerCase()) {
484
+ await env.GITME_R2.delete(tempKey);
485
+ return lfsError(400, "upload hash mismatch");
486
+ }
487
+ const tempObject = await env.GITME_R2.get(tempKey);
488
+ if (!tempObject?.body) return lfsError(500, "internal server error");
489
+ await env.GITME_R2.put(objectKey, tempObject.body, { customMetadata: { sha256: oid.toLowerCase() } });
490
+ await env.GITME_R2.delete(tempKey);
491
+ return new Response(null, { status: 200 });
492
+ }
493
+ async function handleAdminUser(request, env, config, rawUsername) {
494
+ if (request.headers.get("Authorization") !== `Bearer ${config.authToken}`) return appJson2(401, { message: "authentication required" });
495
+ const username = normalizeUsername(decodeURIComponent(rawUsername));
496
+ if (!username) return appJson2(400, { message: "invalid username" });
497
+ if (request.method === "PUT") {
498
+ let body;
499
+ try {
500
+ body = await request.json();
501
+ } catch {
502
+ return appJson2(400, { message: "invalid JSON" });
503
+ }
504
+ if (typeof body.password !== "string" || body.password.length < 12) return appJson2(400, { message: "password must be at least 12 characters" });
505
+ const access = body.access === "read" || body.access === "write" ? body.access : "";
506
+ if (!access) return appJson2(400, { message: "access must be read or write" });
507
+ await createUser(env, username, body.password, access);
508
+ return appJson2(200, { username, access });
509
+ }
510
+ if (request.method === "DELETE") {
511
+ await deleteUser(env, username);
512
+ return appJson2(200, { username, deleted: true });
513
+ }
514
+ return appJson2(405, { message: "method not allowed" });
515
+ }
516
+ async function handleAdminUsers(request, env, config) {
517
+ if (request.headers.get("Authorization") !== `Bearer ${config.authToken}`) return appJson2(401, { message: "authentication required" });
518
+ if (request.method !== "GET") return appJson2(405, { message: "method not allowed" });
519
+ return appJson2(200, { users: await listUsers(env) });
520
+ }
521
+ async function handleDownload(env, oid) {
522
+ if (!OID_RE.test(oid)) return lfsError(400, "invalid oid");
523
+ const object = await env.GITME_R2.get(OBJECT_PREFIX + oid);
524
+ if (!object) return lfsError(404, "object not found");
525
+ return new Response(object.body, {
526
+ status: 200,
527
+ headers: {
528
+ "Content-Type": "application/octet-stream",
529
+ "Content-Length": String(object.size)
530
+ }
531
+ });
532
+ }
533
+ function isLfsOperation(operation) {
534
+ return operation === "upload" || operation === "download";
535
+ }
536
+ function isLfsContentType(contentType) {
537
+ return (contentType || "").split(";", 1)[0].trim().toLowerCase() === LFS_CONTENT_TYPE;
538
+ }
539
+ function selectTransfer(transfers) {
540
+ if (transfers == null) return "basic";
541
+ if (!Array.isArray(transfers)) return "";
542
+ if (transfers.length === 0) return "basic";
543
+ return transfers.includes("basic") ? "basic" : "";
544
+ }
545
+ function validateObject(obj) {
546
+ if (!isObjectRecord(obj) || !OID_RE.test(String(obj.oid || ""))) return "invalid oid";
547
+ if (!Number.isInteger(obj.size) || obj.size < 0) return "object size must not be negative";
548
+ return "";
549
+ }
550
+ function isObjectRecord(value) {
551
+ return typeof value === "object" && value !== null && "oid" in value && "size" in value;
552
+ }
553
+ function objectNotFound(obj) {
554
+ return { oid: obj.oid, size: obj.size, error: { code: 404, message: "object not found" } };
555
+ }
556
+ function objectConflict(obj) {
557
+ return { oid: obj.oid, size: obj.size, error: { code: 409, message: "object size mismatch" } };
558
+ }
559
+ function json(status, body) {
560
+ return new Response(JSON.stringify(body) + "\n", { status, headers: { "Content-Type": LFS_CONTENT_TYPE } });
561
+ }
562
+ function lfsError(status, message) {
563
+ return json(status, { message });
564
+ }
565
+ function lfsAuthError() {
566
+ const res = lfsError(401, "authentication required");
567
+ res.headers.set("WWW-Authenticate", 'Basic realm="git-me"');
568
+ return res;
569
+ }
570
+ function appJson2(status, body) {
571
+ return new Response(JSON.stringify(body) + "\n", { status, headers: { "Content-Type": "application/json" } });
572
+ }
573
+ async function requireLfsAccess(request, env, config, needed) {
574
+ const auth = await authenticateLfs(request, env, config);
575
+ if ("response" in auth) return auth.response;
576
+ return canAccess(auth.access, needed) ? null : lfsError(403, "forbidden");
577
+ }
578
+ async function authenticateLfs(request, env, config) {
579
+ const auth = request.headers.get("Authorization") || "";
580
+ if (auth === `Bearer ${config.authToken}`) return { access: "write" };
581
+ if (!auth.startsWith("Basic ")) return { response: lfsAuthError() };
582
+ const credentials = decodeBasicAuth(auth.slice("Basic ".length));
583
+ if (!credentials) return { response: lfsAuthError() };
584
+ const username = normalizeUsername(credentials.username);
585
+ if (!username) return { response: lfsAuthError() };
586
+ const result = await authenticateUser(env, username, credentials.password, clientSource(request));
587
+ if (!result.ok || !result.access) return { response: lfsAuthError() };
588
+ return { access: result.access };
589
+ }
590
+ function decodeBasicAuth(encoded) {
591
+ try {
592
+ const decoded = atob(encoded);
593
+ const separator = decoded.indexOf(":");
594
+ if (separator < 1) return null;
595
+ return { username: decoded.slice(0, separator), password: decoded.slice(separator + 1) };
596
+ } catch {
597
+ return null;
598
+ }
599
+ }
600
+ function normalizeUsername(username) {
601
+ const normalized = username.trim().toLowerCase();
602
+ return USERNAME_RE.test(normalized) ? normalized : "";
603
+ }
604
+ function clientSource(request) {
605
+ const source = request.headers.get("CF-Connecting-IP") || "unknown";
606
+ return /^[0-9a-fA-F:.]{1,45}$/.test(source) ? source : "unknown";
607
+ }
608
+ function canAccess(actual, needed) {
609
+ return actual === "write" || needed === "read";
610
+ }
611
+ async function digestAndCount(stream) {
612
+ if (typeof DigestStream === "function") {
613
+ let size = 0;
614
+ const counter = new TransformStream({
615
+ transform(chunk, controller) {
616
+ size += chunk.byteLength;
617
+ controller.enqueue(chunk);
618
+ }
619
+ });
620
+ const digester = new DigestStream("SHA-256");
621
+ await stream.pipeThrough(counter).pipeTo(digester);
622
+ const digest2 = await digester.digest;
623
+ return { hex: bytesToHex3(new Uint8Array(digest2)), size };
624
+ }
625
+ const buffer = await new Response(stream).arrayBuffer();
626
+ const digest = await crypto.subtle.digest("SHA-256", buffer);
627
+ return { hex: bytesToHex3(new Uint8Array(digest)), size: buffer.byteLength };
628
+ }
629
+ function bytesToHex3(bytes) {
630
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
631
+ }
632
+ export {
633
+ AuthUser,
634
+ worker_default as default
635
+ };