@cobinar/dalus 0.1.8 → 0.1.11

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 (41) hide show
  1. package/README.md +10 -36
  2. package/bin/{dalus.js → dalus.mjs} +5 -14
  3. package/convert_to_mjs.py +59 -0
  4. package/fix_requires.py +57 -0
  5. package/package.json +4 -4
  6. package/publish-dalus.bat +1 -1
  7. package/src/{api-client.js → api-client.mjs} +67 -67
  8. package/src/commands/{forge.js → forge.mjs} +104 -104
  9. package/src/commands/{init.js → init.mjs} +53 -53
  10. package/src/{forgers/login.js → commands/login.mjs} +288 -288
  11. package/src/{config.js → config.mjs} +147 -147
  12. package/src/cors.mjs +19 -0
  13. package/src/forgers/{database.js → database.mjs} +34 -34
  14. package/src/forgers/{pages.js → pages.mjs} +35 -35
  15. package/src/forgers/storage.mjs +39 -0
  16. package/src/forgers/{vault.js → vault.mjs} +27 -27
  17. package/src/forgers/{workers.js → workers.mjs} +85 -85
  18. package/src/{fs-utils.js → fs-utils.mjs} +36 -36
  19. package/src/handlers/login-callback.mjs +143 -0
  20. package/src/handlers/services.mjs +45 -0
  21. package/src/index.mjs +36 -0
  22. package/src/auth.js +0 -78
  23. package/src/commands/auth.js +0 -78
  24. package/src/commands/cors.js +0 -34
  25. package/src/commands/index.js +0 -115
  26. package/src/commands/login.js +0 -288
  27. package/src/commands/storage.js +0 -72
  28. package/src/commands/utils.js +0 -7
  29. package/src/commands/validators.js +0 -101
  30. package/src/cors.js +0 -34
  31. package/src/forgers/auth.js +0 -78
  32. package/src/forgers/cors.js +0 -34
  33. package/src/forgers/index.js +0 -115
  34. package/src/forgers/storage.js +0 -72
  35. package/src/forgers/utils.js +0 -7
  36. package/src/forgers/validators.js +0 -101
  37. package/src/index.js +0 -115
  38. package/src/login.js +0 -288
  39. package/src/storage.js +0 -72
  40. package/src/utils.js +0 -7
  41. package/src/validators.js +0 -101
@@ -1,115 +0,0 @@
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
- //
@@ -1,288 +0,0 @@
1
- 'use strict';
2
- const http = require('http');
3
- const crypto = require('crypto');
4
- const readline = require('readline');
5
- const { execFile } = require('child_process');
6
- const { saveCredentials, clearCredentials, loadCredentials, CREDENTIALS_PATH } = require('../config');
7
-
8
- // Where dalus's OWN worker (the login page this opens) currently lives is
9
- // looked up here, not hardcoded — see cobinar's src/handlers/services.js.
10
- // That's the one, deliberately-fixed anchor point everything else in this
11
- // file is relative to; if IT ever needs to move, that's a cobinar.com
12
- // redeploy, not a new npm release everyone has to go update.
13
- const SERVICES_ENDPOINT = 'https://8bnk.dalus.cobinar.com';
14
- // Only used if the discovery call above fails outright (a network hiccup,
15
- // cobinar.com briefly unreachable) -- not the source of truth, just keeps
16
- // `dalus login` from being completely dead during a transient outage of
17
- // that one lookup. --web-base always wins over both.
18
- const FALLBACK_DALUS_BASE = 'https://8bnk.dalus.cobinar.com';
19
- // cobinar-developers-worker — mints the actual bearer token dashboard-worker
20
- // checks (a signed "workerToken"), from the one-time code dalus's own
21
- // worker hands us. Not the same thing as dalus.cobinar.com itself or as
22
- // auth.cobinar.com (Cobinar's general sign-in provider, which dalus's
23
- // worker is one client of) — those are three separate services. See the
24
- // comment on exchangeSsoCode below for the exact handoff.
25
- const DEFAULT_DEVELOPERS_BASE = 'https://worker.dashboard.cobinar.com';
26
- const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
27
-
28
- function ask(question) {
29
- return new Promise((resolve) => {
30
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
31
- rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
32
- });
33
- }
34
-
35
- function openBrowser(url) {
36
- // execFile, not exec — no shell involved for the process WE spawn. That
37
- // alone isn't quite enough on Windows: `cmd /c start` hands its arguments
38
- // to cmd.exe's OWN re-parsing of the whole line as a new command, where an
39
- // unescaped "&" means "run two commands", not "a literal character in a
40
- // URL" — which is exactly what truncated a real sign-in URL at its first
41
- // "&" here before. rundll32 is a normal Win32 program, not a shell, so it
42
- // never re-interprets "&" (or anything else) in its argument at all.
43
- const done = () => {}; // best-effort; the URL is always printed too
44
- if (process.platform === 'darwin') execFile('open', [url], done);
45
- else if (process.platform === 'win32') execFile('rundll32', ['url.dll,FileProtocolHandler', url], done);
46
- else execFile('xdg-open', [url], done);
47
- }
48
-
49
- const REQUEST_TIMEOUT_MS = 8000;
50
-
51
- function getJson(urlString) {
52
- return new Promise((resolve, reject) => {
53
- const url = new URL(urlString);
54
- const req = require(url.protocol === 'http:' ? 'http' : 'https')
55
- .get(url, (res) => {
56
- let out = '';
57
- res.on('data', (c) => (out += c));
58
- res.on('end', () => {
59
- let parsed = null;
60
- try { parsed = JSON.parse(out); } catch { /* leaves parsed null below */ }
61
- resolve({ status: res.statusCode, json: parsed });
62
- });
63
- })
64
- .on('error', reject);
65
- req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out')));
66
- });
67
- }
68
-
69
- // The one lookup this whole file depends on: where does dalus's own worker
70
- // (the login page browserLogin below sends people to) currently live. See
71
- // SERVICES_ENDPOINT's comment above for why this is a runtime fetch and not
72
- // a constant.
73
- async function discoverDalusBase() {
74
- try {
75
- const { status, json } = await getJson(SERVICES_ENDPOINT);
76
- if (status === 200 && json && typeof json.dalus === 'string' && json.dalus) {
77
- return json.dalus;
78
- }
79
- } catch {
80
- // Falls through to FALLBACK_DALUS_BASE below.
81
- }
82
- return FALLBACK_DALUS_BASE;
83
- }
84
-
85
- function postJson(urlString, body) {
86
- return new Promise((resolve, reject) => {
87
- const url = new URL(urlString);
88
- const data = JSON.stringify(body);
89
- const req = require(url.protocol === 'http:' ? 'http' : 'https').request(
90
- {
91
- hostname: url.hostname,
92
- port: url.port || (url.protocol === 'http:' ? 80 : 443),
93
- path: url.pathname + url.search,
94
- method: 'POST',
95
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
96
- },
97
- (res) => {
98
- let out = '';
99
- res.on('data', (c) => (out += c));
100
- res.on('end', () => {
101
- let parsed = null;
102
- try { parsed = JSON.parse(out); } catch { /* leaves parsed null below */ }
103
- resolve({ status: res.statusCode, json: parsed });
104
- });
105
- },
106
- );
107
- req.on('error', reject);
108
- req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out')));
109
- req.write(data);
110
- req.end();
111
- });
112
- }
113
-
114
- // The other half of cobinar-developers-worker's POST /auth/redeem-code:
115
- // cobinar.com's callback page writes { uid, email, name, picture } to a KV
116
- // namespace it shares with cobinar-developers-worker, keyed by a one-time
117
- // code, and hands US that code (over the loopback server below, once the
118
- // browser gets there). Redeeming it here — not on cobinar.com's server, and
119
- // not in the browser — is what actually mints the workerToken dashboard-
120
- // worker will accept; nothing before this point has needed dashboard-worker
121
- // to trust anything, since a workerToken is the first credential in this
122
- // whole flow it actually checks.
123
- async function exchangeSsoCode(developersBase, code) {
124
- const { status, json } = await postJson(`${developersBase}/auth/redeem-code`, { code });
125
- if (status !== 200 || !json || typeof json.workerToken !== 'string') {
126
- const detail = json && json.error ? json.error : `HTTP ${status}`;
127
- throw new Error(`Could not finish signing in (${detail}). Run "dalus login" again.`);
128
- }
129
- return json;
130
- }
131
-
132
- // Starts a one-shot local server, opens dalus's own worker (wherever
133
- // discoverDalusBase() says it currently lives) to sign in, and waits for
134
- // its /login/callback page to POST a one-time SSO code back here once the
135
- // auth.cobinar.com round trip finishes. dalus's worker has its own
136
- // client_id/secret, registered independently of cobinar.com's — see that
137
- // worker's public/login/index.html and public/login/callback.html for the
138
- // other half of this handshake, and exchangeSsoCode above for what happens
139
- // to the code once it arrives here.
140
- function browserLogin(webBase, developersBase) {
141
- // "ls" (local state): proves the browser tab that finishes is the one
142
- // THIS process opened, not some other local process guessing the port
143
- // and racing it — the port alone isn't a secret (any local process can
144
- // see what's listening), this is.
145
- const ls = crypto.randomBytes(24).toString('hex');
146
- const allowedOrigin = new URL(webBase).origin;
147
-
148
- return new Promise((resolve, reject) => {
149
- let settled = false;
150
-
151
- const server = http.createServer((req, res) => {
152
- const url = new URL(req.url, 'http://127.0.0.1');
153
- res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
154
- res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
155
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
156
-
157
- if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
158
- if (url.pathname !== '/complete' || req.method !== 'POST') { res.writeHead(404); res.end(); return; }
159
-
160
- let body = '';
161
- req.on('data', (chunk) => {
162
- body += chunk;
163
- if (body.length > 1e6) req.destroy(); // this payload is a short code + a small profile, never anywhere near this size
164
- });
165
- req.on('end', async () => {
166
- let parsed = null;
167
- try { parsed = JSON.parse(body); } catch { /* falls through to the ok:false below */ }
168
-
169
- if (!parsed || parsed.ls !== ls || typeof parsed.code !== 'string' || !parsed.code) {
170
- res.writeHead(400, { 'Content-Type': 'application/json' });
171
- res.end(JSON.stringify({ ok: false }));
172
- return; // deliberately not finish()/reject() here — a stray or malicious request to this port shouldn't cancel a login still in progress
173
- }
174
-
175
- try {
176
- const redeemed = await exchangeSsoCode(developersBase, parsed.code);
177
- res.writeHead(200, { 'Content-Type': 'application/json' });
178
- res.end(JSON.stringify({ ok: true }));
179
- finish(null, {
180
- token: redeemed.workerToken,
181
- user: parsed.user || { email: redeemed.cobinarEmail, name: redeemed.displayName, picture: redeemed.photoURL },
182
- });
183
- } catch (err) {
184
- res.writeHead(502, { 'Content-Type': 'application/json' });
185
- res.end(JSON.stringify({ ok: false, error: err.message }));
186
- finish(err);
187
- }
188
- });
189
- });
190
-
191
- function finish(err, result) {
192
- if (settled) return;
193
- settled = true;
194
- clearTimeout(timer);
195
- server.close();
196
- if (err) reject(err); else resolve(result);
197
- }
198
-
199
- const timer = setTimeout(() => {
200
- finish(new Error(`Timed out waiting for sign-in (${LOGIN_TIMEOUT_MS / 1000}s). Run "dalus login" again.`));
201
- }, LOGIN_TIMEOUT_MS);
202
-
203
- server.on('error', (err) => finish(err));
204
-
205
- server.listen(0, '127.0.0.1', () => {
206
- const port = server.address().port;
207
- // port and ls travel together as one opaque value, not two params
208
- // joined by "&" — see openBrowser's comment above for exactly why
209
- // that character caused real trouble here. Neither value can ever
210
- // contain a "." (port is digits, ls is hex), so joining/splitting on
211
- // it is unambiguous. The trailing slash on /login/ is deliberate
212
- // too: requesting the bare path and hoping a redirect adds the slash
213
- // back is one more hop that could, in principle, drop the query
214
- // string — this just starts at the real address.
215
- const loginUrl = `${webBase}/login/?d=${port}.${ls}`;
216
- console.log('Opening your browser to sign in with Cobinar...');
217
- console.log(`If it doesn't open automatically, visit:\n ${loginUrl}\n`);
218
- openBrowser(loginUrl);
219
- });
220
- });
221
- }
222
-
223
- async function loginCommand(args) {
224
- if (args.includes('--logout')) {
225
- clearCredentials();
226
- console.log('Logged out.');
227
- return;
228
- }
229
- if (args.includes('--whoami')) {
230
- const creds = loadCredentials();
231
- if (!creds) { console.log('Not logged in.'); return; }
232
- console.log(`API base: ${creds.apiBase}`);
233
- if (creds.user && creds.user.email) console.log(`Signed in as: ${creds.user.email}`);
234
- console.log(`Token: ${creds.token.slice(0, 8)}...${creds.token.slice(-4)} (stored in ${CREDENTIALS_PATH})`);
235
- console.log('Note: this token expires 2 hours after signing in — "dalus login" again once it does.');
236
- return;
237
- }
238
-
239
- const tokenArgIdx = args.indexOf('--token');
240
- const apiBaseArgIdx = args.indexOf('--api-base');
241
-
242
- // --token stays as a manual escape hatch for CI/headless boxes that
243
- // can't open a browser at all — not the default anymore, but not worth
244
- // removing until there's a real headless flow (one-time code / email,
245
- // planned later) to replace it with.
246
- if (tokenArgIdx !== -1) {
247
- const token = args[tokenArgIdx + 1];
248
- const apiBase = apiBaseArgIdx !== -1 ? args[apiBaseArgIdx + 1] : await ask('Cobinar dashboard-worker URL: ');
249
- if (!token || !apiBase) {
250
- console.error('Both a worker URL and a token are required.');
251
- process.exitCode = 1;
252
- return;
253
- }
254
- saveCredentials({ apiBase: apiBase.replace(/\/$/, ''), token });
255
- console.log(`Saved to ${CREDENTIALS_PATH}. Run "dalus forge" from a project directory to deploy.`);
256
- return;
257
- }
258
-
259
- const webBaseArgIdx = args.indexOf('--web-base');
260
- const webBase = (webBaseArgIdx !== -1 ? args[webBaseArgIdx + 1] : await discoverDalusBase()).replace(/\/$/, '');
261
- const developersBaseArgIdx = args.indexOf('--developers-base');
262
- const developersBase = (developersBaseArgIdx !== -1 ? args[developersBaseArgIdx + 1] : DEFAULT_DEVELOPERS_BASE).replace(/\/$/, '');
263
-
264
- let result;
265
- try {
266
- result = await browserLogin(webBase, developersBase);
267
- } catch (err) {
268
- console.error(err.message);
269
- process.exitCode = 1;
270
- return;
271
- }
272
-
273
- const apiBase = apiBaseArgIdx !== -1
274
- ? args[apiBaseArgIdx + 1]
275
- : await ask('Cobinar dashboard-worker URL (e.g. https://worker.lobby.cobinar.com): ');
276
- if (!apiBase) {
277
- console.error('A dashboard-worker URL is required.');
278
- process.exitCode = 1;
279
- return;
280
- }
281
-
282
- saveCredentials({ apiBase: apiBase.replace(/\/$/, ''), token: result.token, user: result.user || null });
283
- console.log(`Signed in${result.user && result.user.email ? ' as ' + result.user.email : ''}. Saved to ${CREDENTIALS_PATH}.`);
284
- console.log('This session lasts 2 hours — "dalus login" again once it expires.');
285
- console.log('Run "dalus forge" from a project directory to deploy.');
286
- }
287
-
288
- module.exports = { loginCommand, browserLogin, exchangeSsoCode, discoverDalusBase };
@@ -1,72 +0,0 @@
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
- }
@@ -1,7 +0,0 @@
1
- // src/utils.js
2
-
3
- export function generateId() {
4
- const buf = new Uint8Array(8);
5
- crypto.getRandomValues(buf);
6
- return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('').slice(0, 12);
7
- }
@@ -1,101 +0,0 @@
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 DELETED
@@ -1,34 +0,0 @@
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
- }