@cobinar/dalus 0.1.1 → 0.1.5
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/README.md +13 -5
- package/bin/dalus.js +5 -1
- package/package.json +1 -1
- package/publish-dalus.bat +32 -0
- package/src/auth.js +78 -0
- package/src/commands/auth.js +78 -0
- package/src/commands/cors.js +34 -0
- package/src/commands/index.js +115 -0
- package/src/commands/login.js +150 -23
- package/src/commands/storage.js +72 -0
- package/src/commands/utils.js +7 -0
- package/src/commands/validators.js +101 -0
- package/src/cors.js +34 -0
- package/src/forgers/auth.js +78 -0
- package/src/forgers/cors.js +34 -0
- package/src/forgers/index.js +115 -0
- package/src/forgers/login.js +288 -0
- package/src/forgers/storage.js +63 -30
- package/src/forgers/utils.js +7 -0
- package/src/forgers/validators.js +101 -0
- package/src/index.js +115 -0
- package/src/login.js +288 -0
- package/src/storage.js +72 -0
- package/src/utils.js +7 -0
- package/src/validators.js +101 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/storage.js
|
|
2
|
+
import { generateId } from './utils.js';
|
|
3
|
+
|
|
4
|
+
const FOLDER_COLLECTIONS = new Set(['posts', 'products', 'projects']);
|
|
5
|
+
const FLAT_COLLECTIONS = new Set(['images', 'files']);
|
|
6
|
+
export const ALL_COLLECTIONS = new Set([...FOLDER_COLLECTIONS, ...FLAT_COLLECTIONS]);
|
|
7
|
+
export { FOLDER_COLLECTIONS, FLAT_COLLECTIONS };
|
|
8
|
+
|
|
9
|
+
export function generateUploadPath(collection, entityId, ext, slot) {
|
|
10
|
+
const uid = generateId();
|
|
11
|
+
if (FLAT_COLLECTIONS.has(collection)) {
|
|
12
|
+
return `cobinar/${collection}/${uid}.${ext.toLowerCase()}`;
|
|
13
|
+
}
|
|
14
|
+
const prefix = slot ? `${slot}-${uid}` : uid;
|
|
15
|
+
return `cobinar/${collection}/${entityId}/${prefix}.${ext.toLowerCase()}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function uploadFile(bucket, key, body, contentType, metadata = {}) {
|
|
19
|
+
await bucket.put(key, body, {
|
|
20
|
+
httpMetadata: {
|
|
21
|
+
contentType,
|
|
22
|
+
cacheControl: 'public, max-age=31536000, immutable',
|
|
23
|
+
},
|
|
24
|
+
customMetadata: {
|
|
25
|
+
uploadedAt: new Date().toISOString(),
|
|
26
|
+
...sanitizeMetadata(metadata),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
return { key };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function deleteFiles(bucket, keys) {
|
|
33
|
+
if (!keys || keys.length === 0) return;
|
|
34
|
+
await bucket.delete(keys);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function streamFile(bucket, key, extraHeaders = {}) {
|
|
38
|
+
const object = await bucket.get(key);
|
|
39
|
+
if (!object) {
|
|
40
|
+
return new Response(JSON.stringify({ ok: false, error: 'File not found' }), {
|
|
41
|
+
status: 404,
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const headers = new Headers({
|
|
46
|
+
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
|
|
47
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
48
|
+
ETag: object.etag,
|
|
49
|
+
'Last-Modified': object.uploaded?.toUTCString() || '',
|
|
50
|
+
'X-Served-By': 'cobinar-r2',
|
|
51
|
+
...extraHeaders,
|
|
52
|
+
});
|
|
53
|
+
return new Response(object.body, { headers });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getPublicUrl(key, env) {
|
|
57
|
+
if (env.R2_PUBLIC_DOMAIN) {
|
|
58
|
+
return `https://${env.R2_PUBLIC_DOMAIN}/${key}`;
|
|
59
|
+
}
|
|
60
|
+
const domain = env.WORKER_DOMAIN || 'cobinar-r2.workers.dev';
|
|
61
|
+
return `https://${domain}/assets/${key}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sanitizeMetadata(obj) {
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
67
|
+
if (v !== null && v !== undefined) {
|
|
68
|
+
out[k] = String(v);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// src/validators.js
|
|
2
|
+
|
|
3
|
+
export const ALLOWED_TYPES = {
|
|
4
|
+
image: {
|
|
5
|
+
mimes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif', 'image/svg+xml'],
|
|
6
|
+
extensions: ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif', 'svg'],
|
|
7
|
+
maxBytes: 25 * 1024 * 1024,
|
|
8
|
+
label: 'Image',
|
|
9
|
+
},
|
|
10
|
+
projectImage: {
|
|
11
|
+
mimes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif'],
|
|
12
|
+
extensions: ['jpg', 'jpeg', 'png', 'webp', 'avif'],
|
|
13
|
+
maxBytes: 10 * 1024 * 1024,
|
|
14
|
+
label: 'Project image',
|
|
15
|
+
},
|
|
16
|
+
productImage: {
|
|
17
|
+
mimes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif'],
|
|
18
|
+
extensions: ['jpg', 'jpeg', 'png', 'webp', 'avif'],
|
|
19
|
+
maxBytes: 10 * 1024 * 1024,
|
|
20
|
+
label: 'Product image',
|
|
21
|
+
},
|
|
22
|
+
postMedia: {
|
|
23
|
+
mimes: [
|
|
24
|
+
'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif',
|
|
25
|
+
'video/mp4', 'video/webm', 'video/quicktime', 'application/pdf',
|
|
26
|
+
],
|
|
27
|
+
extensions: ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif', 'mp4', 'webm', 'mov', 'pdf'],
|
|
28
|
+
maxBytes: 50 * 1024 * 1024,
|
|
29
|
+
label: 'Post media',
|
|
30
|
+
},
|
|
31
|
+
file: {
|
|
32
|
+
mimes: [
|
|
33
|
+
'application/pdf', 'application/zip', 'application/x-zip-compressed',
|
|
34
|
+
'application/octet-stream', 'text/plain', 'text/csv',
|
|
35
|
+
'application/vnd.ms-excel',
|
|
36
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
37
|
+
'application/msword',
|
|
38
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
39
|
+
'application/json',
|
|
40
|
+
],
|
|
41
|
+
extensions: ['pdf', 'zip', 'txt', 'csv', 'xls', 'xlsx', 'doc', 'docx', 'json', 'bin'],
|
|
42
|
+
maxBytes: 100 * 1024 * 1024,
|
|
43
|
+
label: 'File',
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_RULE = {
|
|
48
|
+
posts: 'postMedia',
|
|
49
|
+
products: 'productImage',
|
|
50
|
+
projects: 'projectImage',
|
|
51
|
+
images: 'image',
|
|
52
|
+
files: 'file',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function validateFile(mimeType, sizeBytes, filename, ruleName) {
|
|
56
|
+
const rule = ALLOWED_TYPES[ruleName];
|
|
57
|
+
if (!rule) return { valid: false, error: `Unknown validation rule: "${ruleName}"` };
|
|
58
|
+
|
|
59
|
+
const baseMime = mimeType.split(';')[0].trim().toLowerCase();
|
|
60
|
+
if (!rule.mimes.includes(baseMime)) {
|
|
61
|
+
return { valid: false, error: `${rule.label} must be one of: ${rule.mimes.join(', ')}. Got: ${baseMime}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const rawExt = (filename.split('.').pop() || '').toLowerCase();
|
|
65
|
+
if (!rule.extensions.includes(rawExt)) {
|
|
66
|
+
return { valid: false, error: `${rule.label} extension must be .${rule.extensions.join(', .')}. Got: .${rawExt}` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (sizeBytes > rule.maxBytes) {
|
|
70
|
+
const maxMb = (rule.maxBytes / 1024 / 1024).toFixed(0);
|
|
71
|
+
const gotMb = (sizeBytes / 1024 / 1024).toFixed(1);
|
|
72
|
+
return { valid: false, error: `${rule.label} must be under ${maxMb} MB. Got: ${gotMb} MB` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { valid: true, ext: rawExt };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function extFromMime(mimeType) {
|
|
79
|
+
const map = {
|
|
80
|
+
'image/jpeg': 'jpg',
|
|
81
|
+
'image/png': 'png',
|
|
82
|
+
'image/webp': 'webp',
|
|
83
|
+
'image/gif': 'gif',
|
|
84
|
+
'image/avif': 'avif',
|
|
85
|
+
'image/svg+xml': 'svg',
|
|
86
|
+
'video/mp4': 'mp4',
|
|
87
|
+
'video/webm': 'webm',
|
|
88
|
+
'video/quicktime': 'mov',
|
|
89
|
+
'application/pdf': 'pdf',
|
|
90
|
+
'application/zip': 'zip',
|
|
91
|
+
'application/x-zip-compressed': 'zip',
|
|
92
|
+
'text/plain': 'txt',
|
|
93
|
+
'text/csv': 'csv',
|
|
94
|
+
'application/vnd.ms-excel': 'xls',
|
|
95
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
|
96
|
+
'application/msword': 'doc',
|
|
97
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
|
98
|
+
'application/json': 'json',
|
|
99
|
+
};
|
|
100
|
+
return map[mimeType.split(';')[0].trim().toLowerCase()] || 'bin';
|
|
101
|
+
}
|
package/src/cors.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// src/cors.js
|
|
2
|
+
|
|
3
|
+
export function getAllowedOrigins(env) {
|
|
4
|
+
return (env.ALLOWED_ORIGINS || '')
|
|
5
|
+
.split(/[,\n\r]+/)
|
|
6
|
+
.map((o) => o.trim())
|
|
7
|
+
.map((o) => o.replace(/[\r\n\t\x00-\x1F\x7F]/g, ''))
|
|
8
|
+
.filter((o) => /^https?:\/\/[a-zA-Z0-9.-]+(:\d+)?$/.test(o));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function corsHeaders(env, requestOrigin = '') {
|
|
12
|
+
const allowed = getAllowedOrigins(env);
|
|
13
|
+
const origin = allowed.includes(requestOrigin) ? requestOrigin : allowed[0] || '';
|
|
14
|
+
const headers = {
|
|
15
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
16
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With',
|
|
17
|
+
'Access-Control-Expose-Headers': 'X-Upload-Key, X-Served-By',
|
|
18
|
+
Vary: 'Origin',
|
|
19
|
+
};
|
|
20
|
+
if (origin) {
|
|
21
|
+
headers['Access-Control-Allow-Origin'] = origin;
|
|
22
|
+
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
23
|
+
headers['Access-Control-Max-Age'] = '86400';
|
|
24
|
+
}
|
|
25
|
+
return headers;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function handleCors(request, env) {
|
|
29
|
+
const origin = request.headers.get('Origin') || '';
|
|
30
|
+
return new Response(null, {
|
|
31
|
+
status: 204,
|
|
32
|
+
headers: corsHeaders(env, origin),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/auth.js
|
|
2
|
+
|
|
3
|
+
const GOOGLE_JWK_URL = 'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
|
|
4
|
+
|
|
5
|
+
let _keyCache = null;
|
|
6
|
+
let _cacheExpires = 0;
|
|
7
|
+
|
|
8
|
+
async function getVerificationKeys() {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
if (_keyCache && now < _cacheExpires) return _keyCache;
|
|
11
|
+
|
|
12
|
+
const res = await fetch(GOOGLE_JWK_URL);
|
|
13
|
+
if (!res.ok) throw new Error('Failed to fetch Firebase JWKs');
|
|
14
|
+
const jwks = await res.json();
|
|
15
|
+
|
|
16
|
+
const cc = res.headers.get('Cache-Control') || '';
|
|
17
|
+
const maxAge = parseInt((cc.match(/max-age=(\d+)/) || [, '3600'])[1], 10);
|
|
18
|
+
_cacheExpires = now + maxAge * 1000;
|
|
19
|
+
|
|
20
|
+
_keyCache = new Map();
|
|
21
|
+
await Promise.all(
|
|
22
|
+
(jwks.keys || []).map(async (jwk) => {
|
|
23
|
+
const key = await crypto.subtle.importKey(
|
|
24
|
+
'jwk',
|
|
25
|
+
jwk,
|
|
26
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
27
|
+
false,
|
|
28
|
+
['verify'],
|
|
29
|
+
);
|
|
30
|
+
_keyCache.set(jwk.kid, key);
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
return _keyCache;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function base64urlDecode(str) {
|
|
37
|
+
const b64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
38
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
39
|
+
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function b64DecodeJson(b64url) {
|
|
43
|
+
return JSON.parse(new TextDecoder().decode(base64urlDecode(b64url)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function verifyFirebaseToken(idToken, projectId) {
|
|
47
|
+
const parts = idToken.split('.');
|
|
48
|
+
if (parts.length !== 3) throw new Error('Malformed JWT: expected 3 segments');
|
|
49
|
+
const [headerB64, payloadB64, sigB64] = parts;
|
|
50
|
+
|
|
51
|
+
let header, payload;
|
|
52
|
+
try {
|
|
53
|
+
header = b64DecodeJson(headerB64);
|
|
54
|
+
payload = b64DecodeJson(payloadB64);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error('Could not decode JWT segments');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const now = Math.floor(Date.now() / 1000);
|
|
60
|
+
if (header.alg !== 'RS256') throw new Error(`Unexpected algorithm: ${header.alg}`);
|
|
61
|
+
if (payload.aud !== projectId) throw new Error(`Invalid audience: expected "${projectId}", got "${payload.aud}"`);
|
|
62
|
+
if (payload.iss !== `https://securetoken.google.com/${projectId}`) throw new Error('Invalid issuer');
|
|
63
|
+
if (payload.exp < now) throw new Error('Token expired');
|
|
64
|
+
if (payload.iat > now + 60) throw new Error('Token issued in the future — clock skew too large');
|
|
65
|
+
if (!payload.sub) throw new Error('Missing subject claim');
|
|
66
|
+
|
|
67
|
+
const keys = await getVerificationKeys();
|
|
68
|
+
const key = keys.get(header.kid);
|
|
69
|
+
if (!key) throw new Error(`Unknown key ID: ${header.kid}`);
|
|
70
|
+
|
|
71
|
+
const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
72
|
+
const signature = base64urlDecode(sigB64);
|
|
73
|
+
const valid = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, signature, signingInput);
|
|
74
|
+
if (!valid) throw new Error('Signature verification failed');
|
|
75
|
+
|
|
76
|
+
payload.uid = payload.sub || payload.user_id;
|
|
77
|
+
return payload;
|
|
78
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// src/cors.js
|
|
2
|
+
|
|
3
|
+
export function getAllowedOrigins(env) {
|
|
4
|
+
return (env.ALLOWED_ORIGINS || '')
|
|
5
|
+
.split(/[,\n\r]+/)
|
|
6
|
+
.map((o) => o.trim())
|
|
7
|
+
.map((o) => o.replace(/[\r\n\t\x00-\x1F\x7F]/g, ''))
|
|
8
|
+
.filter((o) => /^https?:\/\/[a-zA-Z0-9.-]+(:\d+)?$/.test(o));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function corsHeaders(env, requestOrigin = '') {
|
|
12
|
+
const allowed = getAllowedOrigins(env);
|
|
13
|
+
const origin = allowed.includes(requestOrigin) ? requestOrigin : allowed[0] || '';
|
|
14
|
+
const headers = {
|
|
15
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
16
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With',
|
|
17
|
+
'Access-Control-Expose-Headers': 'X-Upload-Key, X-Served-By',
|
|
18
|
+
Vary: 'Origin',
|
|
19
|
+
};
|
|
20
|
+
if (origin) {
|
|
21
|
+
headers['Access-Control-Allow-Origin'] = origin;
|
|
22
|
+
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
23
|
+
headers['Access-Control-Max-Age'] = '86400';
|
|
24
|
+
}
|
|
25
|
+
return headers;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function handleCors(request, env) {
|
|
29
|
+
const origin = request.headers.get('Origin') || '';
|
|
30
|
+
return new Response(null, {
|
|
31
|
+
status: 204,
|
|
32
|
+
headers: corsHeaders(env, origin),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// src/index.js — Cobinar R2 Worker · Entry Point
|
|
3
|
+
// Created by Marc-Arthur Samuel Dalus · Cobinar Systems
|
|
4
|
+
//
|
|
5
|
+
// Routes:
|
|
6
|
+
// GET /health — Uptime check (no auth)
|
|
7
|
+
// GET /assets/:key — Stream public R2 asset (CDN fallback)
|
|
8
|
+
// POST /api/auth/callback — Cobinar Auth (OAuth) code exchange (public —
|
|
9
|
+
// this is how a visitor becomes authenticated)
|
|
10
|
+
// GET /api/services — where Cobinar's other workers currently
|
|
11
|
+
// live (public, read-only; see that
|
|
12
|
+
// handler for why this is KV-backed)
|
|
13
|
+
// POST /api/upload — Upload file to R2 (Firebase auth + admin only)
|
|
14
|
+
// DELETE /api/delete — Delete R2 object (Firebase auth + admin only)
|
|
15
|
+
// (anything else) — Delegated to env.ASSETS (the site in ./public)
|
|
16
|
+
//
|
|
17
|
+
// assets.run_worker_first is set to `true` (unconditional) rather than a
|
|
18
|
+
// glob-pattern array scoped to /api/* — Cloudflare's asset-vs-worker
|
|
19
|
+
// precedence for that project turned out to return 405 for every method
|
|
20
|
+
// other than GET regardless of pattern, so this Worker now runs for every
|
|
21
|
+
// request and explicitly hands off anything it doesn't recognize to
|
|
22
|
+
// env.ASSETS.fetch(request) itself, rather than depending on that layer.
|
|
23
|
+
//
|
|
24
|
+
// Bindings (wrangler.toml):
|
|
25
|
+
// COBINAR_R2 R2Bucket — cobinar bucket
|
|
26
|
+
// FIREBASE_PROJECT_ID — cobinar-prod
|
|
27
|
+
// ALLOWED_ORIGINS — CORS allowlist
|
|
28
|
+
// WORKER_DOMAIN — worker.cobinar.com
|
|
29
|
+
// R2_PUBLIC_DOMAIN — cdn.cobinar.com
|
|
30
|
+
// ADMIN_EMAIL — cobinar@cobinar.com
|
|
31
|
+
// MAX_UPLOAD_SIZE — 104857600 (100 MB)
|
|
32
|
+
// COBINAR_AUTH_CLIENT_ID — Cobinar Auth OAuth client id (public)
|
|
33
|
+
// COBINAR_AUTH_CLIENT_SECRET — SECRET — set via `wrangler secret put`, never in this repo
|
|
34
|
+
// COBINAR_AUTH_BASE — https://auth.cobinar.com
|
|
35
|
+
//
|
|
36
|
+
// Folder layout in bucket:
|
|
37
|
+
// cobinar/posts/{postId}/{slot}-{uuid}.{ext} ← post media
|
|
38
|
+
// cobinar/products/{productId}/{slot}-{uuid}.{ext} ← product images
|
|
39
|
+
// cobinar/projects/{projectId}/{slot}-{uuid}.{ext} ← project assets
|
|
40
|
+
// cobinar/images/{uuid}.{ext} ← flat image store
|
|
41
|
+
// cobinar/files/{uuid}.{ext} ← flat file store
|
|
42
|
+
//
|
|
43
|
+
// CDN delivery (once cdn.cobinar.com R2 custom domain is configured):
|
|
44
|
+
// https://cdn.cobinar.com/{key} → direct R2 edge (zero Worker cost)
|
|
45
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
46
|
+
|
|
47
|
+
import { handleCors, corsHeaders } from './cors.js';
|
|
48
|
+
import { verifyFirebaseToken } from './auth.js';
|
|
49
|
+
import { handleUpload } from './handlers/upload.js';
|
|
50
|
+
import { handleDelete } from './handlers/delete.js';
|
|
51
|
+
import { handleServe } from './handlers/serve.js';
|
|
52
|
+
import { handleHealth } from './handlers/health.js';
|
|
53
|
+
import { handleAuthCallback } from './handlers/auth-callback.js';
|
|
54
|
+
import { handleServices } from './handlers/services.js';
|
|
55
|
+
|
|
56
|
+
export default {
|
|
57
|
+
async fetch(request, env, ctx) {
|
|
58
|
+
const url = new URL(request.url);
|
|
59
|
+
const path = url.pathname;
|
|
60
|
+
const method = request.method;
|
|
61
|
+
|
|
62
|
+
// ── CORS preflight ──────────────────────────────────────────────────────
|
|
63
|
+
if (method === 'OPTIONS') return handleCors(request, env);
|
|
64
|
+
|
|
65
|
+
// ── Health check (no auth) ──────────────────────────────────────────────
|
|
66
|
+
if (path === '/health' && method === 'GET') return handleHealth(request, env);
|
|
67
|
+
|
|
68
|
+
// ── Public asset serving — CDN fallback until cdn.cobinar.com is live ───
|
|
69
|
+
if (method === 'GET' && path.startsWith('/assets/')) {
|
|
70
|
+
return handleServe(request, env, path.slice('/assets/'.length));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Cobinar Auth code exchange — public. A visitor has no session yet;
|
|
74
|
+
// this is the one request that creates one, so it can't require one. ──
|
|
75
|
+
if (path === '/api/auth/callback' && method === 'POST') {
|
|
76
|
+
return handleAuthCallback(request, env);
|
|
77
|
+
}
|
|
78
|
+
if (path === '/api/services' && method === 'GET') {
|
|
79
|
+
return handleServices(request, env);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Authenticated API routes ────────────────────────────────────────────
|
|
83
|
+
if (path.startsWith('/api/')) {
|
|
84
|
+
const token = (request.headers.get('Authorization') || '').replace(/^Bearer\s+/i, '').trim();
|
|
85
|
+
if (!token) return jsonError('Missing Authorization header', 401, env, request);
|
|
86
|
+
|
|
87
|
+
let claims;
|
|
88
|
+
try {
|
|
89
|
+
claims = await verifyFirebaseToken(token, env.FIREBASE_PROJECT_ID);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return jsonError('Invalid or expired token: ' + e.message, 401, env, request);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (path === '/api/upload' && method === 'POST') return handleUpload(request, env, claims);
|
|
95
|
+
if (path === '/api/delete' && method === 'DELETE') return handleDelete(request, env, claims);
|
|
96
|
+
|
|
97
|
+
return jsonError('Unknown API route', 404, env, request);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Everything else: hand off to static assets (site pages, css, js, icons).
|
|
101
|
+
// Explicit here rather than relying on Cloudflare's asset-vs-worker
|
|
102
|
+
// precedence, since that's exactly the layer that's been unreliable. ──
|
|
103
|
+
return env.ASSETS.fetch(request);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function jsonError(message, status, env, request) {
|
|
108
|
+
const origin = request?.headers?.get('Origin') || '';
|
|
109
|
+
return new Response(JSON.stringify({ ok: false, error: message }), {
|
|
110
|
+
status,
|
|
111
|
+
headers: { 'Content-Type': 'application/json', ...corsHeaders(env, origin) },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// Created by Marc-Arthur Samuel Dalus · Cobinar Systems
|
|
115
|
+
//
|