@cobinar/dalus 0.1.11 → 0.1.12
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.
- package/package.json +1 -1
- package/src/handlers/services.mjs +1 -1
- package/worker/index.js +658 -0
package/package.json
CHANGED
package/worker/index.js
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
3
|
+
|
|
4
|
+
// src/commands/cors.js
|
|
5
|
+
function getAllowedOrigins(env) {
|
|
6
|
+
return (env.ALLOWED_ORIGINS || "").split(/[,\n\r]+/).map((o) => o.trim()).map((o) => o.replace(/[\r\n\t\x00-\x1F\x7F]/g, "")).filter((o) => /^https?:\/\/[a-zA-Z0-9.-]+(:\d+)?$/.test(o));
|
|
7
|
+
}
|
|
8
|
+
__name(getAllowedOrigins, "getAllowedOrigins");
|
|
9
|
+
function corsHeaders(env, requestOrigin = "") {
|
|
10
|
+
const allowed = getAllowedOrigins(env);
|
|
11
|
+
const origin = allowed.includes(requestOrigin) ? requestOrigin : allowed[0] || "";
|
|
12
|
+
const headers = {
|
|
13
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
14
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With",
|
|
15
|
+
"Access-Control-Expose-Headers": "X-Upload-Key, X-Served-By",
|
|
16
|
+
Vary: "Origin"
|
|
17
|
+
};
|
|
18
|
+
if (origin) {
|
|
19
|
+
headers["Access-Control-Allow-Origin"] = origin;
|
|
20
|
+
headers["Access-Control-Allow-Credentials"] = "true";
|
|
21
|
+
headers["Access-Control-Max-Age"] = "86400";
|
|
22
|
+
}
|
|
23
|
+
return headers;
|
|
24
|
+
}
|
|
25
|
+
__name(corsHeaders, "corsHeaders");
|
|
26
|
+
function handleCors(request, env) {
|
|
27
|
+
const origin = request.headers.get("Origin") || "";
|
|
28
|
+
return new Response(null, {
|
|
29
|
+
status: 204,
|
|
30
|
+
headers: corsHeaders(env, origin)
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
__name(handleCors, "handleCors");
|
|
34
|
+
|
|
35
|
+
// src/commands/auth.js
|
|
36
|
+
var GOOGLE_JWK_URL = "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com";
|
|
37
|
+
var _keyCache = null;
|
|
38
|
+
var _cacheExpires = 0;
|
|
39
|
+
async function getVerificationKeys() {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (_keyCache && now < _cacheExpires) return _keyCache;
|
|
42
|
+
const res = await fetch(GOOGLE_JWK_URL);
|
|
43
|
+
if (!res.ok) throw new Error("Failed to fetch Firebase JWKs");
|
|
44
|
+
const jwks = await res.json();
|
|
45
|
+
const cc = res.headers.get("Cache-Control") || "";
|
|
46
|
+
const maxAge = parseInt((cc.match(/max-age=(\d+)/) || [, "3600"])[1], 10);
|
|
47
|
+
_cacheExpires = now + maxAge * 1e3;
|
|
48
|
+
_keyCache = /* @__PURE__ */ new Map();
|
|
49
|
+
await Promise.all(
|
|
50
|
+
(jwks.keys || []).map(async (jwk) => {
|
|
51
|
+
const key = await crypto.subtle.importKey(
|
|
52
|
+
"jwk",
|
|
53
|
+
jwk,
|
|
54
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
55
|
+
false,
|
|
56
|
+
["verify"]
|
|
57
|
+
);
|
|
58
|
+
_keyCache.set(jwk.kid, key);
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
return _keyCache;
|
|
62
|
+
}
|
|
63
|
+
__name(getVerificationKeys, "getVerificationKeys");
|
|
64
|
+
function base64urlDecode(str) {
|
|
65
|
+
const b64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
66
|
+
const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
|
|
67
|
+
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
|
|
68
|
+
}
|
|
69
|
+
__name(base64urlDecode, "base64urlDecode");
|
|
70
|
+
function b64DecodeJson(b64url) {
|
|
71
|
+
return JSON.parse(new TextDecoder().decode(base64urlDecode(b64url)));
|
|
72
|
+
}
|
|
73
|
+
__name(b64DecodeJson, "b64DecodeJson");
|
|
74
|
+
async function verifyFirebaseToken(idToken, projectId) {
|
|
75
|
+
const parts = idToken.split(".");
|
|
76
|
+
if (parts.length !== 3) throw new Error("Malformed JWT: expected 3 segments");
|
|
77
|
+
const [headerB64, payloadB64, sigB64] = parts;
|
|
78
|
+
let header, payload;
|
|
79
|
+
try {
|
|
80
|
+
header = b64DecodeJson(headerB64);
|
|
81
|
+
payload = b64DecodeJson(payloadB64);
|
|
82
|
+
} catch {
|
|
83
|
+
throw new Error("Could not decode JWT segments");
|
|
84
|
+
}
|
|
85
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
86
|
+
if (header.alg !== "RS256") throw new Error(`Unexpected algorithm: ${header.alg}`);
|
|
87
|
+
if (payload.aud !== projectId) throw new Error(`Invalid audience: expected "${projectId}", got "${payload.aud}"`);
|
|
88
|
+
if (payload.iss !== `https://securetoken.google.com/${projectId}`) throw new Error("Invalid issuer");
|
|
89
|
+
if (payload.exp < now) throw new Error("Token expired");
|
|
90
|
+
if (payload.iat > now + 60) throw new Error("Token issued in the future \u2014 clock skew too large");
|
|
91
|
+
if (!payload.sub) throw new Error("Missing subject claim");
|
|
92
|
+
const keys = await getVerificationKeys();
|
|
93
|
+
const key = keys.get(header.kid);
|
|
94
|
+
if (!key) throw new Error(`Unknown key ID: ${header.kid}`);
|
|
95
|
+
const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
96
|
+
const signature = base64urlDecode(sigB64);
|
|
97
|
+
const valid = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, signingInput);
|
|
98
|
+
if (!valid) throw new Error("Signature verification failed");
|
|
99
|
+
payload.uid = payload.sub || payload.user_id;
|
|
100
|
+
return payload;
|
|
101
|
+
}
|
|
102
|
+
__name(verifyFirebaseToken, "verifyFirebaseToken");
|
|
103
|
+
|
|
104
|
+
// src/commands/validators.js
|
|
105
|
+
var ALLOWED_TYPES = {
|
|
106
|
+
image: {
|
|
107
|
+
mimes: ["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif", "image/svg+xml"],
|
|
108
|
+
extensions: ["jpg", "jpeg", "png", "webp", "gif", "avif", "svg"],
|
|
109
|
+
maxBytes: 25 * 1024 * 1024,
|
|
110
|
+
label: "Image"
|
|
111
|
+
},
|
|
112
|
+
projectImage: {
|
|
113
|
+
mimes: ["image/jpeg", "image/png", "image/webp", "image/avif"],
|
|
114
|
+
extensions: ["jpg", "jpeg", "png", "webp", "avif"],
|
|
115
|
+
maxBytes: 10 * 1024 * 1024,
|
|
116
|
+
label: "Project image"
|
|
117
|
+
},
|
|
118
|
+
productImage: {
|
|
119
|
+
mimes: ["image/jpeg", "image/png", "image/webp", "image/avif"],
|
|
120
|
+
extensions: ["jpg", "jpeg", "png", "webp", "avif"],
|
|
121
|
+
maxBytes: 10 * 1024 * 1024,
|
|
122
|
+
label: "Product image"
|
|
123
|
+
},
|
|
124
|
+
postMedia: {
|
|
125
|
+
mimes: [
|
|
126
|
+
"image/jpeg",
|
|
127
|
+
"image/png",
|
|
128
|
+
"image/webp",
|
|
129
|
+
"image/gif",
|
|
130
|
+
"image/avif",
|
|
131
|
+
"video/mp4",
|
|
132
|
+
"video/webm",
|
|
133
|
+
"video/quicktime",
|
|
134
|
+
"application/pdf"
|
|
135
|
+
],
|
|
136
|
+
extensions: ["jpg", "jpeg", "png", "webp", "gif", "avif", "mp4", "webm", "mov", "pdf"],
|
|
137
|
+
maxBytes: 50 * 1024 * 1024,
|
|
138
|
+
label: "Post media"
|
|
139
|
+
},
|
|
140
|
+
file: {
|
|
141
|
+
mimes: [
|
|
142
|
+
"application/pdf",
|
|
143
|
+
"application/zip",
|
|
144
|
+
"application/x-zip-compressed",
|
|
145
|
+
"application/octet-stream",
|
|
146
|
+
"text/plain",
|
|
147
|
+
"text/csv",
|
|
148
|
+
"application/vnd.ms-excel",
|
|
149
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
150
|
+
"application/msword",
|
|
151
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
152
|
+
"application/json"
|
|
153
|
+
],
|
|
154
|
+
extensions: ["pdf", "zip", "txt", "csv", "xls", "xlsx", "doc", "docx", "json", "bin"],
|
|
155
|
+
maxBytes: 100 * 1024 * 1024,
|
|
156
|
+
label: "File"
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
var DEFAULT_RULE = {
|
|
160
|
+
posts: "postMedia",
|
|
161
|
+
products: "productImage",
|
|
162
|
+
projects: "projectImage",
|
|
163
|
+
images: "image",
|
|
164
|
+
files: "file"
|
|
165
|
+
};
|
|
166
|
+
function validateFile(mimeType, sizeBytes, filename, ruleName) {
|
|
167
|
+
const rule = ALLOWED_TYPES[ruleName];
|
|
168
|
+
if (!rule) return { valid: false, error: `Unknown validation rule: "${ruleName}"` };
|
|
169
|
+
const baseMime = mimeType.split(";")[0].trim().toLowerCase();
|
|
170
|
+
if (!rule.mimes.includes(baseMime)) {
|
|
171
|
+
return { valid: false, error: `${rule.label} must be one of: ${rule.mimes.join(", ")}. Got: ${baseMime}` };
|
|
172
|
+
}
|
|
173
|
+
const rawExt = (filename.split(".").pop() || "").toLowerCase();
|
|
174
|
+
if (!rule.extensions.includes(rawExt)) {
|
|
175
|
+
return { valid: false, error: `${rule.label} extension must be .${rule.extensions.join(", .")}. Got: .${rawExt}` };
|
|
176
|
+
}
|
|
177
|
+
if (sizeBytes > rule.maxBytes) {
|
|
178
|
+
const maxMb = (rule.maxBytes / 1024 / 1024).toFixed(0);
|
|
179
|
+
const gotMb = (sizeBytes / 1024 / 1024).toFixed(1);
|
|
180
|
+
return { valid: false, error: `${rule.label} must be under ${maxMb} MB. Got: ${gotMb} MB` };
|
|
181
|
+
}
|
|
182
|
+
return { valid: true, ext: rawExt };
|
|
183
|
+
}
|
|
184
|
+
__name(validateFile, "validateFile");
|
|
185
|
+
function extFromMime(mimeType) {
|
|
186
|
+
const map = {
|
|
187
|
+
"image/jpeg": "jpg",
|
|
188
|
+
"image/png": "png",
|
|
189
|
+
"image/webp": "webp",
|
|
190
|
+
"image/gif": "gif",
|
|
191
|
+
"image/avif": "avif",
|
|
192
|
+
"image/svg+xml": "svg",
|
|
193
|
+
"video/mp4": "mp4",
|
|
194
|
+
"video/webm": "webm",
|
|
195
|
+
"video/quicktime": "mov",
|
|
196
|
+
"application/pdf": "pdf",
|
|
197
|
+
"application/zip": "zip",
|
|
198
|
+
"application/x-zip-compressed": "zip",
|
|
199
|
+
"text/plain": "txt",
|
|
200
|
+
"text/csv": "csv",
|
|
201
|
+
"application/vnd.ms-excel": "xls",
|
|
202
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
203
|
+
"application/msword": "doc",
|
|
204
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
205
|
+
"application/json": "json"
|
|
206
|
+
};
|
|
207
|
+
return map[mimeType.split(";")[0].trim().toLowerCase()] || "bin";
|
|
208
|
+
}
|
|
209
|
+
__name(extFromMime, "extFromMime");
|
|
210
|
+
|
|
211
|
+
// src/commands/utils.js
|
|
212
|
+
function generateId() {
|
|
213
|
+
const buf = new Uint8Array(8);
|
|
214
|
+
crypto.getRandomValues(buf);
|
|
215
|
+
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("").slice(0, 12);
|
|
216
|
+
}
|
|
217
|
+
__name(generateId, "generateId");
|
|
218
|
+
|
|
219
|
+
// src/commands/storage.js
|
|
220
|
+
var FOLDER_COLLECTIONS = /* @__PURE__ */ new Set(["posts", "products", "projects"]);
|
|
221
|
+
var FLAT_COLLECTIONS = /* @__PURE__ */ new Set(["images", "files"]);
|
|
222
|
+
var ALL_COLLECTIONS = /* @__PURE__ */ new Set([...FOLDER_COLLECTIONS, ...FLAT_COLLECTIONS]);
|
|
223
|
+
function generateUploadPath(collection, entityId, ext, slot) {
|
|
224
|
+
const uid = generateId();
|
|
225
|
+
if (FLAT_COLLECTIONS.has(collection)) {
|
|
226
|
+
return `cobinar/${collection}/${uid}.${ext.toLowerCase()}`;
|
|
227
|
+
}
|
|
228
|
+
const prefix = slot ? `${slot}-${uid}` : uid;
|
|
229
|
+
return `cobinar/${collection}/${entityId}/${prefix}.${ext.toLowerCase()}`;
|
|
230
|
+
}
|
|
231
|
+
__name(generateUploadPath, "generateUploadPath");
|
|
232
|
+
async function uploadFile(bucket, key, body, contentType, metadata = {}) {
|
|
233
|
+
await bucket.put(key, body, {
|
|
234
|
+
httpMetadata: {
|
|
235
|
+
contentType,
|
|
236
|
+
cacheControl: "public, max-age=31536000, immutable"
|
|
237
|
+
},
|
|
238
|
+
customMetadata: {
|
|
239
|
+
uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
240
|
+
...sanitizeMetadata(metadata)
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
return { key };
|
|
244
|
+
}
|
|
245
|
+
__name(uploadFile, "uploadFile");
|
|
246
|
+
async function deleteFiles(bucket, keys) {
|
|
247
|
+
if (!keys || keys.length === 0) return;
|
|
248
|
+
await bucket.delete(keys);
|
|
249
|
+
}
|
|
250
|
+
__name(deleteFiles, "deleteFiles");
|
|
251
|
+
async function streamFile(bucket, key, extraHeaders = {}) {
|
|
252
|
+
const object = await bucket.get(key);
|
|
253
|
+
if (!object) {
|
|
254
|
+
return new Response(JSON.stringify({ ok: false, error: "File not found" }), {
|
|
255
|
+
status: 404,
|
|
256
|
+
headers: { "Content-Type": "application/json" }
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
const headers = new Headers({
|
|
260
|
+
"Content-Type": object.httpMetadata?.contentType || "application/octet-stream",
|
|
261
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
262
|
+
ETag: object.etag,
|
|
263
|
+
"Last-Modified": object.uploaded?.toUTCString() || "",
|
|
264
|
+
"X-Served-By": "cobinar-r2",
|
|
265
|
+
...extraHeaders
|
|
266
|
+
});
|
|
267
|
+
return new Response(object.body, { headers });
|
|
268
|
+
}
|
|
269
|
+
__name(streamFile, "streamFile");
|
|
270
|
+
function getPublicUrl(key, env) {
|
|
271
|
+
if (env.R2_PUBLIC_DOMAIN) {
|
|
272
|
+
return `https://${env.R2_PUBLIC_DOMAIN}/${key}`;
|
|
273
|
+
}
|
|
274
|
+
const domain = env.WORKER_DOMAIN || "cobinar-r2.workers.dev";
|
|
275
|
+
return `https://${domain}/assets/${key}`;
|
|
276
|
+
}
|
|
277
|
+
__name(getPublicUrl, "getPublicUrl");
|
|
278
|
+
function sanitizeMetadata(obj) {
|
|
279
|
+
const out = {};
|
|
280
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
281
|
+
if (v !== null && v !== void 0) {
|
|
282
|
+
out[k] = String(v);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
__name(sanitizeMetadata, "sanitizeMetadata");
|
|
288
|
+
|
|
289
|
+
// src/commands/handlers/upload.js
|
|
290
|
+
async function handleUpload(request, env, claims) {
|
|
291
|
+
const origin = request.headers.get("Origin") || "";
|
|
292
|
+
const adminEmail = env.ADMIN_EMAIL || "cobinar@cobinar.com";
|
|
293
|
+
if (claims.email !== adminEmail) {
|
|
294
|
+
return err("Upload restricted to admin only.", 403, env, origin);
|
|
295
|
+
}
|
|
296
|
+
let formData;
|
|
297
|
+
try {
|
|
298
|
+
formData = await request.formData();
|
|
299
|
+
} catch {
|
|
300
|
+
return err("Could not parse multipart/form-data body.", 400, env, origin);
|
|
301
|
+
}
|
|
302
|
+
const file = formData.get("file");
|
|
303
|
+
const collection = (formData.get("collection") || "").trim().toLowerCase();
|
|
304
|
+
const entityId = (formData.get("entityId") || "").trim();
|
|
305
|
+
const slot = (formData.get("slot") || "").trim();
|
|
306
|
+
const ruleInput = (formData.get("rule") || "").trim();
|
|
307
|
+
if (!file || typeof file.arrayBuffer !== "function") {
|
|
308
|
+
return err('No file provided in form field "file".', 400, env, origin);
|
|
309
|
+
}
|
|
310
|
+
if (!ALL_COLLECTIONS.has(collection)) {
|
|
311
|
+
return err(`Invalid collection. Must be one of: ${[...ALL_COLLECTIONS].join(", ")}.`, 400, env, origin);
|
|
312
|
+
}
|
|
313
|
+
if (FOLDER_COLLECTIONS.has(collection)) {
|
|
314
|
+
if (!entityId || !/^[a-zA-Z0-9_-]{1,128}$/.test(entityId)) {
|
|
315
|
+
return err(`"entityId" is required for "${collection}" and must be 1\u2013128 alphanumeric characters.`, 400, env, origin);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (slot && !/^[a-z][a-z0-9_-]{0,31}$/.test(slot)) {
|
|
319
|
+
return err(
|
|
320
|
+
"Invalid slot. Must start with a letter, contain only lowercase letters, numbers, hyphens, underscores, max 32 chars.",
|
|
321
|
+
400,
|
|
322
|
+
env,
|
|
323
|
+
origin
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
const mimeType = (file.type || "application/octet-stream").split(";")[0].trim();
|
|
327
|
+
const sizeBytes = file.size;
|
|
328
|
+
const filename = file.name || "upload";
|
|
329
|
+
const maxBytes = parseInt(env.MAX_UPLOAD_SIZE || "104857600", 10);
|
|
330
|
+
if (sizeBytes > maxBytes) {
|
|
331
|
+
const maxMb = (maxBytes / 1024 / 1024).toFixed(0);
|
|
332
|
+
const gotMb = (sizeBytes / 1024 / 1024).toFixed(1);
|
|
333
|
+
return err(`File too large. Max ${maxMb} MB allowed; received ${gotMb} MB.`, 413, env, origin);
|
|
334
|
+
}
|
|
335
|
+
const ruleName = ruleInput || DEFAULT_RULE[collection] || "file";
|
|
336
|
+
const validation = validateFile(mimeType, sizeBytes, filename, ruleName);
|
|
337
|
+
if (!validation.valid) {
|
|
338
|
+
return err(validation.error, 422, env, origin);
|
|
339
|
+
}
|
|
340
|
+
const ext = extFromMime(mimeType);
|
|
341
|
+
const key = generateUploadPath(collection, entityId || null, ext, slot || null);
|
|
342
|
+
const body = await file.arrayBuffer();
|
|
343
|
+
try {
|
|
344
|
+
await uploadFile(env.COBINAR_R2, key, body, mimeType, {
|
|
345
|
+
uploadedBy: claims.uid || claims.sub,
|
|
346
|
+
uploadedEmail: claims.email || "",
|
|
347
|
+
originalName: filename,
|
|
348
|
+
collection,
|
|
349
|
+
entityId: entityId || "",
|
|
350
|
+
slot: slot || ""
|
|
351
|
+
});
|
|
352
|
+
} catch (e) {
|
|
353
|
+
console.error("[cobinar-r2 upload] R2 put failed:", e.message);
|
|
354
|
+
return err("Storage write failed. Please retry.", 500, env, origin);
|
|
355
|
+
}
|
|
356
|
+
const publicUrl = getPublicUrl(key, env);
|
|
357
|
+
return new Response(
|
|
358
|
+
JSON.stringify({
|
|
359
|
+
ok: true,
|
|
360
|
+
key,
|
|
361
|
+
publicUrl,
|
|
362
|
+
mimeType,
|
|
363
|
+
sizeBytes,
|
|
364
|
+
collection,
|
|
365
|
+
entityId: entityId || null,
|
|
366
|
+
slot: slot || null
|
|
367
|
+
}),
|
|
368
|
+
{
|
|
369
|
+
status: 200,
|
|
370
|
+
headers: { "Content-Type": "application/json", "X-Upload-Key": key, ...corsHeaders(env, origin) }
|
|
371
|
+
}
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
__name(handleUpload, "handleUpload");
|
|
375
|
+
function err(message, status, env, origin = "") {
|
|
376
|
+
return new Response(JSON.stringify({ ok: false, error: message }), {
|
|
377
|
+
status,
|
|
378
|
+
headers: { "Content-Type": "application/json", ...corsHeaders(env, origin) }
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
__name(err, "err");
|
|
382
|
+
|
|
383
|
+
// src/commands/handlers/delete.js
|
|
384
|
+
var SAFE_KEY_PATTERN = /^cobinar\/[a-z][a-z0-9_-]*\/[a-zA-Z0-9_-]{1,128}(\.[a-z0-9]{1,10}|\/[a-zA-Z0-9_.\-]{1,255})$/;
|
|
385
|
+
async function handleDelete(request, env, claims) {
|
|
386
|
+
const origin = request.headers.get("Origin") || "";
|
|
387
|
+
const adminEmail = env.ADMIN_EMAIL || "cobinar@cobinar.com";
|
|
388
|
+
if (claims.email !== adminEmail) {
|
|
389
|
+
return err2("Delete restricted to admin only.", 403, env, origin);
|
|
390
|
+
}
|
|
391
|
+
let body;
|
|
392
|
+
try {
|
|
393
|
+
body = await request.json();
|
|
394
|
+
} catch {
|
|
395
|
+
return err2("Request body must be valid JSON.", 400, env, origin);
|
|
396
|
+
}
|
|
397
|
+
const rawKeys = body.keys ? Array.isArray(body.keys) ? body.keys : [body.keys] : body.key ? [body.key] : [];
|
|
398
|
+
if (rawKeys.length === 0) {
|
|
399
|
+
return err2('Provide at least one key to delete via "key" or "keys".', 400, env, origin);
|
|
400
|
+
}
|
|
401
|
+
if (rawKeys.length > 1e3) {
|
|
402
|
+
return err2("Cannot delete more than 1000 keys per request.", 400, env, origin);
|
|
403
|
+
}
|
|
404
|
+
const malformed = rawKeys.filter((k) => typeof k !== "string" || !SAFE_KEY_PATTERN.test(k));
|
|
405
|
+
if (malformed.length > 0) {
|
|
406
|
+
return err2(
|
|
407
|
+
`One or more keys have an invalid format. Keys must start with "cobinar/". Bad keys: ${malformed.slice(0, 3).join(", ")}`,
|
|
408
|
+
400,
|
|
409
|
+
env,
|
|
410
|
+
origin
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
await deleteFiles(env.COBINAR_R2, rawKeys);
|
|
415
|
+
} catch (e) {
|
|
416
|
+
console.error("[cobinar-r2 delete] R2 delete failed:", e.message);
|
|
417
|
+
return err2("Storage delete failed. Please retry.", 500, env, origin);
|
|
418
|
+
}
|
|
419
|
+
return new Response(JSON.stringify({ ok: true, deleted: rawKeys }), {
|
|
420
|
+
status: 200,
|
|
421
|
+
headers: { "Content-Type": "application/json", ...corsHeaders(env, origin) }
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
__name(handleDelete, "handleDelete");
|
|
425
|
+
function err2(message, status, env, origin) {
|
|
426
|
+
return new Response(JSON.stringify({ ok: false, error: message }), {
|
|
427
|
+
status,
|
|
428
|
+
headers: { "Content-Type": "application/json", ...corsHeaders(env, origin) }
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
__name(err2, "err");
|
|
432
|
+
|
|
433
|
+
// src/commands/handlers/serve.js
|
|
434
|
+
var PUBLIC_PREFIXES = [
|
|
435
|
+
"cobinar/posts/",
|
|
436
|
+
"cobinar/products/",
|
|
437
|
+
"cobinar/projects/",
|
|
438
|
+
"cobinar/images/",
|
|
439
|
+
"cobinar/files/"
|
|
440
|
+
];
|
|
441
|
+
async function handleServe(request, env, key) {
|
|
442
|
+
if (!key || key.includes("..") || key.startsWith("/") || key.includes("\0")) {
|
|
443
|
+
return new Response("Forbidden", { status: 403 });
|
|
444
|
+
}
|
|
445
|
+
const isPublic = PUBLIC_PREFIXES.some((prefix) => key.startsWith(prefix));
|
|
446
|
+
if (!isPublic) return new Response("Not found", { status: 404 });
|
|
447
|
+
const ifNoneMatch = request.headers.get("If-None-Match");
|
|
448
|
+
if (ifNoneMatch) {
|
|
449
|
+
const head = await env.COBINAR_R2.head(key);
|
|
450
|
+
if (head && head.etag === ifNoneMatch) {
|
|
451
|
+
return new Response(null, { status: 304 });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return streamFile(env.COBINAR_R2, key, { "X-Served-By": "cobinar-r2" });
|
|
455
|
+
}
|
|
456
|
+
__name(handleServe, "handleServe");
|
|
457
|
+
|
|
458
|
+
// src/commands/handlers/health.js
|
|
459
|
+
async function handleHealth(request, env) {
|
|
460
|
+
const start = Date.now();
|
|
461
|
+
let r2Status = "ok";
|
|
462
|
+
try {
|
|
463
|
+
await env.COBINAR_R2.head("__health");
|
|
464
|
+
} catch (e) {
|
|
465
|
+
r2Status = "error: " + e.message;
|
|
466
|
+
}
|
|
467
|
+
return new Response(
|
|
468
|
+
JSON.stringify({
|
|
469
|
+
ok: true,
|
|
470
|
+
service: "cobinar-r2",
|
|
471
|
+
version: "1.0.0",
|
|
472
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
473
|
+
latencyMs: Date.now() - start,
|
|
474
|
+
r2: r2Status,
|
|
475
|
+
bucket: "cobinar",
|
|
476
|
+
environment: env.WORKER_DOMAIN || "unknown"
|
|
477
|
+
}),
|
|
478
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
__name(handleHealth, "handleHealth");
|
|
482
|
+
|
|
483
|
+
// src/commands/handlers/auth-callback.js
|
|
484
|
+
var DEFAULT_AUTH_BASE = "https://auth.cobinar.com";
|
|
485
|
+
async function handleAuthCallback(request, env) {
|
|
486
|
+
const origin = request.headers.get("Origin") || "";
|
|
487
|
+
const headers = { "Content-Type": "application/json", ...corsHeaders(env, origin) };
|
|
488
|
+
const fail = /* @__PURE__ */ __name((message, status = 400) => new Response(JSON.stringify({ ok: false, error: message }), { status, headers }), "fail");
|
|
489
|
+
const authBase = env.COBINAR_AUTH_BASE || DEFAULT_AUTH_BASE;
|
|
490
|
+
const clientId = env.COBINAR_AUTH_CLIENT_ID;
|
|
491
|
+
const clientSecret = env.COBINAR_AUTH_CLIENT_SECRET;
|
|
492
|
+
if (!clientId || !clientSecret) {
|
|
493
|
+
return fail("Sign-in is not configured yet on the server.", 500);
|
|
494
|
+
}
|
|
495
|
+
let body;
|
|
496
|
+
try {
|
|
497
|
+
body = await request.json();
|
|
498
|
+
} catch {
|
|
499
|
+
return fail("Malformed request.");
|
|
500
|
+
}
|
|
501
|
+
const code = typeof body?.code === "string" ? body.code.trim() : "";
|
|
502
|
+
if (!code) return fail("Missing authorization code.");
|
|
503
|
+
const isCli = body?.cli === true;
|
|
504
|
+
const redirectUri = typeof body?.redirect_uri === "string" ? body.redirect_uri.trim() : "";
|
|
505
|
+
if (!/^https:\/\/[a-zA-Z0-9.-]+(\/[^\s]*)?$/.test(redirectUri)) {
|
|
506
|
+
return fail("Missing or invalid redirect_uri.");
|
|
507
|
+
}
|
|
508
|
+
let tokenJson;
|
|
509
|
+
try {
|
|
510
|
+
const tokenRes = await fetch(`${authBase}/oauth/token`, {
|
|
511
|
+
method: "POST",
|
|
512
|
+
headers: { "Content-Type": "application/json" },
|
|
513
|
+
body: JSON.stringify({
|
|
514
|
+
grant_type: "authorization_code",
|
|
515
|
+
code,
|
|
516
|
+
client_id: clientId,
|
|
517
|
+
client_secret: clientSecret,
|
|
518
|
+
redirect_uri: redirectUri
|
|
519
|
+
})
|
|
520
|
+
});
|
|
521
|
+
tokenJson = await safeJson(tokenRes);
|
|
522
|
+
if (!tokenRes.ok || !tokenJson?.access_token) {
|
|
523
|
+
console.error("[auth-callback] token exchange rejected:", tokenRes.status, JSON.stringify(tokenJson), "redirect_uri sent:", redirectUri);
|
|
524
|
+
return fail(friendlyOAuthError(tokenJson), tokenRes.status === 401 ? 401 : 400);
|
|
525
|
+
}
|
|
526
|
+
} catch (err3) {
|
|
527
|
+
console.error("[auth-callback] token fetch threw:", err3?.name, err3?.message, err3?.cause ? String(err3.cause) : "");
|
|
528
|
+
return fail("Could not reach the sign-in service. Please try again.", 502);
|
|
529
|
+
}
|
|
530
|
+
let userInfo;
|
|
531
|
+
try {
|
|
532
|
+
const userRes = await fetch(`${authBase}/oauth/userinfo`, {
|
|
533
|
+
headers: { Authorization: `Bearer ${tokenJson.access_token}` }
|
|
534
|
+
});
|
|
535
|
+
userInfo = await safeJson(userRes);
|
|
536
|
+
if (!userRes.ok || !userInfo) {
|
|
537
|
+
console.error("[auth-callback] userinfo rejected:", userRes.status, JSON.stringify(userInfo));
|
|
538
|
+
return fail("Signed in, but could not load your profile. Please try again.", 502);
|
|
539
|
+
}
|
|
540
|
+
} catch (err3) {
|
|
541
|
+
console.error("[auth-callback] userinfo fetch threw:", err3?.name, err3?.message, err3?.cause ? String(err3.cause) : "");
|
|
542
|
+
return fail("Signed in, but could not load your profile. Please try again.", 502);
|
|
543
|
+
}
|
|
544
|
+
const user = {
|
|
545
|
+
uid: userInfo.uid || userInfo.sub || null,
|
|
546
|
+
email: userInfo.email || userInfo.cobinarEmail || null,
|
|
547
|
+
name: userInfo.name || null,
|
|
548
|
+
picture: userInfo.picture || null
|
|
549
|
+
};
|
|
550
|
+
if (!user.uid || !user.email) {
|
|
551
|
+
return fail("Sign-in succeeded but the profile was incomplete.", 502);
|
|
552
|
+
}
|
|
553
|
+
if (env.SSO_KV) {
|
|
554
|
+
try {
|
|
555
|
+
const ssoCode = randomCode();
|
|
556
|
+
await env.SSO_KV.put(`sso:${ssoCode}`, JSON.stringify(user), { expirationTtl: 60 });
|
|
557
|
+
headers["Set-Cookie"] = `cobinar_sso=${ssoCode}; Domain=.cobinar.com; Path=/; Max-Age=60; Secure; SameSite=Lax`;
|
|
558
|
+
} catch (err3) {
|
|
559
|
+
console.error("[auth-callback] SSO handoff write failed:", err3?.message || err3);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const payload = isCli ? { ok: true, user, token: tokenJson.access_token } : { ok: true, user };
|
|
563
|
+
return new Response(JSON.stringify(payload), { status: 200, headers });
|
|
564
|
+
}
|
|
565
|
+
__name(handleAuthCallback, "handleAuthCallback");
|
|
566
|
+
function randomCode() {
|
|
567
|
+
const bytes = crypto.getRandomValues(new Uint8Array(24));
|
|
568
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
569
|
+
}
|
|
570
|
+
__name(randomCode, "randomCode");
|
|
571
|
+
async function safeJson(res) {
|
|
572
|
+
try {
|
|
573
|
+
return await res.json();
|
|
574
|
+
} catch {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
__name(safeJson, "safeJson");
|
|
579
|
+
function friendlyOAuthError(tokenJson) {
|
|
580
|
+
const code = tokenJson?.error || "";
|
|
581
|
+
switch (code) {
|
|
582
|
+
case "invalid_grant":
|
|
583
|
+
return "That sign-in link expired or was already used. Please sign in again.";
|
|
584
|
+
case "invalid_client":
|
|
585
|
+
return "Sign-in is misconfigured on our end. Please try again later.";
|
|
586
|
+
case "access_denied":
|
|
587
|
+
return "Sign-in was cancelled.";
|
|
588
|
+
default:
|
|
589
|
+
return "Could not complete sign-in. Please try again.";
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
__name(friendlyOAuthError, "friendlyOAuthError");
|
|
593
|
+
|
|
594
|
+
// src/commands/handlers/services.js
|
|
595
|
+
var FALLBACK = {
|
|
596
|
+
dalus: "https://8bnk.dalus.cobinar.com"
|
|
597
|
+
};
|
|
598
|
+
async function handleServices(request, env) {
|
|
599
|
+
const headers = { "Content-Type": "application/json", "Cache-Control": "public, max-age=300" };
|
|
600
|
+
const result = {};
|
|
601
|
+
for (const name of Object.keys(FALLBACK)) {
|
|
602
|
+
let value = null;
|
|
603
|
+
try {
|
|
604
|
+
value = await env.SSO_KV.get(`service:${name}`);
|
|
605
|
+
} catch (err3) {
|
|
606
|
+
console.error("[services] KV read failed for", name, ":", err3?.message);
|
|
607
|
+
}
|
|
608
|
+
result[name] = value || FALLBACK[name];
|
|
609
|
+
}
|
|
610
|
+
return new Response(JSON.stringify(result), { status: 200, headers });
|
|
611
|
+
}
|
|
612
|
+
__name(handleServices, "handleServices");
|
|
613
|
+
|
|
614
|
+
// src/commands/index.js
|
|
615
|
+
var index_default = {
|
|
616
|
+
async fetch(request, env, ctx) {
|
|
617
|
+
const url = new URL(request.url);
|
|
618
|
+
const path = url.pathname;
|
|
619
|
+
const method = request.method;
|
|
620
|
+
if (method === "OPTIONS") return handleCors(request, env);
|
|
621
|
+
if (path === "/health" && method === "GET") return handleHealth(request, env);
|
|
622
|
+
if (method === "GET" && path.startsWith("/assets/")) {
|
|
623
|
+
return handleServe(request, env, path.slice("/assets/".length));
|
|
624
|
+
}
|
|
625
|
+
if (path === "/api/auth/callback" && method === "POST") {
|
|
626
|
+
return handleAuthCallback(request, env);
|
|
627
|
+
}
|
|
628
|
+
if (path === "/api/services" && method === "GET") {
|
|
629
|
+
return handleServices(request, env);
|
|
630
|
+
}
|
|
631
|
+
if (path.startsWith("/api/")) {
|
|
632
|
+
const token = (request.headers.get("Authorization") || "").replace(/^Bearer\s+/i, "").trim();
|
|
633
|
+
if (!token) return jsonError("Missing Authorization header", 401, env, request);
|
|
634
|
+
let claims;
|
|
635
|
+
try {
|
|
636
|
+
claims = await verifyFirebaseToken(token, env.FIREBASE_PROJECT_ID);
|
|
637
|
+
} catch (e) {
|
|
638
|
+
return jsonError("Invalid or expired token: " + e.message, 401, env, request);
|
|
639
|
+
}
|
|
640
|
+
if (path === "/api/upload" && method === "POST") return handleUpload(request, env, claims);
|
|
641
|
+
if (path === "/api/delete" && method === "DELETE") return handleDelete(request, env, claims);
|
|
642
|
+
return jsonError("Unknown API route", 404, env, request);
|
|
643
|
+
}
|
|
644
|
+
return env.ASSETS.fetch(request);
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
function jsonError(message, status, env, request) {
|
|
648
|
+
const origin = request?.headers?.get("Origin") || "";
|
|
649
|
+
return new Response(JSON.stringify({ ok: false, error: message }), {
|
|
650
|
+
status,
|
|
651
|
+
headers: { "Content-Type": "application/json", ...corsHeaders(env, origin) }
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
__name(jsonError, "jsonError");
|
|
655
|
+
export {
|
|
656
|
+
index_default as default
|
|
657
|
+
};
|
|
658
|
+
//# sourceMappingURL=index.js.map
|