@cobinar/dalus 0.1.3 → 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/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 +80 -21
- 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
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
echo 🚀 Preparing to publish new @cobinar/dalus version...
|
|
3
|
+
|
|
4
|
+
:: Navigate to your exact project directory
|
|
5
|
+
cd /d "D:\Downloads\Cobinar-Lobby + Dashboard + Da;us CLI\dalus"
|
|
6
|
+
|
|
7
|
+
echo.
|
|
8
|
+
echo 🔐 Checking npm authentication...
|
|
9
|
+
call npm whoami >nul 2>&1
|
|
10
|
+
if %ERRORLEVEL% neq 0 (
|
|
11
|
+
echo ⚠️ Your npm session expired.
|
|
12
|
+
echo Please log in to your npm account - press ENTER to open browser.
|
|
13
|
+
call npm login
|
|
14
|
+
) else (
|
|
15
|
+
echo ✅ Authenticated successfully.
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
echo.
|
|
19
|
+
echo 📦 Bumping version...
|
|
20
|
+
call npm version patch
|
|
21
|
+
|
|
22
|
+
echo.
|
|
23
|
+
echo ☁️ Publishing to npm - waiting for auth token if 2FA is required...
|
|
24
|
+
call npm publish --access public
|
|
25
|
+
|
|
26
|
+
echo.
|
|
27
|
+
echo 🔄 Downloading and forcing install of the latest version...
|
|
28
|
+
call npm install -g @cobinar/dalus@latest --force
|
|
29
|
+
|
|
30
|
+
echo.
|
|
31
|
+
echo ✅ Deployment complete!
|
|
32
|
+
pause
|
package/src/auth.js
ADDED
|
@@ -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,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
|
+
//
|
package/src/commands/login.js
CHANGED
|
@@ -5,13 +5,23 @@ const readline = require('readline');
|
|
|
5
5
|
const { execFile } = require('child_process');
|
|
6
6
|
const { saveCredentials, clearCredentials, loadCredentials, CREDENTIALS_PATH } = require('../config');
|
|
7
7
|
|
|
8
|
-
|
|
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://cobinar.com/api/services';
|
|
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';
|
|
9
19
|
// cobinar-developers-worker — mints the actual bearer token dashboard-worker
|
|
10
|
-
// checks (a signed "workerToken"), from the one-time code
|
|
11
|
-
//
|
|
12
|
-
// auth.cobinar.com (Cobinar's general sign-in provider, which
|
|
13
|
-
// is
|
|
14
|
-
// exchangeSsoCode below for the exact handoff.
|
|
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.
|
|
15
25
|
const DEFAULT_DEVELOPERS_BASE = 'https://worker.dashboard.cobinar.com';
|
|
16
26
|
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
17
27
|
|
|
@@ -23,14 +33,55 @@ function ask(question) {
|
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
function openBrowser(url) {
|
|
26
|
-
// execFile, not exec — no shell involved
|
|
27
|
-
//
|
|
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.
|
|
28
43
|
const done = () => {}; // best-effort; the URL is always printed too
|
|
29
44
|
if (process.platform === 'darwin') execFile('open', [url], done);
|
|
30
|
-
else if (process.platform === 'win32') execFile('
|
|
45
|
+
else if (process.platform === 'win32') execFile('rundll32', ['url.dll,FileProtocolHandler', url], done);
|
|
31
46
|
else execFile('xdg-open', [url], done);
|
|
32
47
|
}
|
|
33
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
|
+
|
|
34
85
|
function postJson(urlString, body) {
|
|
35
86
|
return new Promise((resolve, reject) => {
|
|
36
87
|
const url = new URL(urlString);
|
|
@@ -54,6 +105,7 @@ function postJson(urlString, body) {
|
|
|
54
105
|
},
|
|
55
106
|
);
|
|
56
107
|
req.on('error', reject);
|
|
108
|
+
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out')));
|
|
57
109
|
req.write(data);
|
|
58
110
|
req.end();
|
|
59
111
|
});
|
|
@@ -77,15 +129,14 @@ async function exchangeSsoCode(developersBase, code) {
|
|
|
77
129
|
return json;
|
|
78
130
|
}
|
|
79
131
|
|
|
80
|
-
// Starts a one-shot local server, opens
|
|
81
|
-
//
|
|
82
|
-
// POST a one-time SSO code back here once the
|
|
83
|
-
// trip finishes. dalus
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
// code once it arrives.
|
|
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.
|
|
89
140
|
function browserLogin(webBase, developersBase) {
|
|
90
141
|
// "ls" (local state): proves the browser tab that finishes is the one
|
|
91
142
|
// THIS process opened, not some other local process guessing the port
|
|
@@ -153,7 +204,15 @@ function browserLogin(webBase, developersBase) {
|
|
|
153
204
|
|
|
154
205
|
server.listen(0, '127.0.0.1', () => {
|
|
155
206
|
const port = server.address().port;
|
|
156
|
-
|
|
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}`;
|
|
157
216
|
console.log('Opening your browser to sign in with Cobinar...');
|
|
158
217
|
console.log(`If it doesn't open automatically, visit:\n ${loginUrl}\n`);
|
|
159
218
|
openBrowser(loginUrl);
|
|
@@ -198,7 +257,7 @@ async function loginCommand(args) {
|
|
|
198
257
|
}
|
|
199
258
|
|
|
200
259
|
const webBaseArgIdx = args.indexOf('--web-base');
|
|
201
|
-
const webBase = (webBaseArgIdx !== -1 ? args[webBaseArgIdx + 1] :
|
|
260
|
+
const webBase = (webBaseArgIdx !== -1 ? args[webBaseArgIdx + 1] : await discoverDalusBase()).replace(/\/$/, '');
|
|
202
261
|
const developersBaseArgIdx = args.indexOf('--developers-base');
|
|
203
262
|
const developersBase = (developersBaseArgIdx !== -1 ? args[developersBaseArgIdx + 1] : DEFAULT_DEVELOPERS_BASE).replace(/\/$/, '');
|
|
204
263
|
|
|
@@ -226,4 +285,4 @@ async function loginCommand(args) {
|
|
|
226
285
|
console.log('Run "dalus forge" from a project directory to deploy.');
|
|
227
286
|
}
|
|
228
287
|
|
|
229
|
-
module.exports = { loginCommand, browserLogin, exchangeSsoCode };
|
|
288
|
+
module.exports = { loginCommand, browserLogin, exchangeSsoCode, discoverDalusBase };
|
|
@@ -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
|
+
}
|