@phnx-labs/agents-cli 1.22.53 → 1.22.54
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/CHANGELOG.md +190 -0
- package/README.md +41 -8
- package/dist/bootstrap.js +55 -154
- package/dist/cli/command-registry.d.ts +5 -0
- package/dist/cli/command-registry.js +8 -1
- package/dist/commands/accounts.js +219 -173
- package/dist/commands/apply.js +6 -3
- package/dist/commands/auth-mint.d.ts +8 -0
- package/dist/commands/auth-mint.js +96 -0
- package/dist/commands/auth.js +5 -1
- package/dist/commands/browser.js +1 -1
- package/dist/commands/cost.js +8 -2
- package/dist/commands/daemon.js +2 -2
- package/dist/commands/doctor.js +6 -1
- package/dist/commands/exec.js +10 -8
- package/dist/commands/focus.d.ts +1 -0
- package/dist/commands/focus.js +2 -2
- package/dist/commands/go.d.ts +5 -4
- package/dist/commands/go.js +7 -7
- package/dist/commands/insights.js +9 -0
- package/dist/commands/monitors.js +85 -30
- package/dist/commands/output.js +8 -2
- package/dist/commands/repo.js +18 -0
- package/dist/commands/secrets.js +33 -14
- package/dist/commands/sessions.d.ts +20 -12
- package/dist/commands/sessions.js +64 -20
- package/dist/commands/setup-accounts.d.ts +8 -0
- package/dist/commands/setup-accounts.js +47 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +11 -2
- package/dist/commands/share.d.ts +52 -3
- package/dist/commands/share.js +262 -18
- package/dist/commands/ssh.d.ts +7 -0
- package/dist/commands/ssh.js +18 -2
- package/dist/commands/status.js +14 -0
- package/dist/commands/view.d.ts +3 -1
- package/dist/commands/view.js +5 -4
- package/dist/lib/account-registry.js +15 -3
- package/dist/lib/accounting/rotate.d.ts +20 -6
- package/dist/lib/accounting/rotate.js +38 -7
- package/dist/lib/accounting/usage.d.ts +37 -1
- package/dist/lib/accounting/usage.js +71 -6
- package/dist/lib/agent-spec/agents.d.ts +5 -2
- package/dist/lib/agent-spec/agents.js +25 -7
- package/dist/lib/analytics/mix-commands.js +12 -6
- package/dist/lib/auth-mint.d.ts +150 -0
- package/dist/lib/auth-mint.js +434 -0
- package/dist/lib/browser/remote-control.d.ts +9 -7
- package/dist/lib/browser/remote-control.js +9 -7
- package/dist/lib/claude-account-token.d.ts +10 -0
- package/dist/lib/claude-account-token.js +14 -4
- package/dist/lib/config-drift.d.ts +37 -0
- package/dist/lib/config-drift.js +72 -0
- package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
- package/dist/lib/daemon/auth-sync-service.js +34 -0
- package/dist/lib/daemon/daemon.js +30 -4
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +3 -3
- package/dist/lib/device-config.js +5 -5
- package/dist/lib/devices/connect.d.ts +26 -0
- package/dist/lib/devices/connect.js +48 -1
- package/dist/lib/devices/doctor-findings.d.ts +5 -1
- package/dist/lib/devices/doctor-findings.js +19 -1
- package/dist/lib/exec.d.ts +28 -0
- package/dist/lib/exec.js +73 -7
- package/dist/lib/feed/feed.d.ts +1 -1
- package/dist/lib/feed/feed.js +23 -1
- package/dist/lib/feed-broadcast.js +1 -1
- package/dist/lib/fleet/apply.d.ts +11 -0
- package/dist/lib/fleet/apply.js +23 -3
- package/dist/lib/fleet/auth-sync.js +5 -3
- package/dist/lib/help.d.ts +9 -0
- package/dist/lib/help.js +29 -1
- package/dist/lib/hosts/passthrough.d.ts +1 -10
- package/dist/lib/hosts/passthrough.js +1 -13
- package/dist/lib/installations/versions.js +9 -1
- package/dist/lib/linux-userns.d.ts +58 -0
- package/dist/lib/linux-userns.js +116 -0
- package/dist/lib/memory.d.ts +26 -0
- package/dist/lib/memory.js +80 -1
- package/dist/lib/monitors/config.d.ts +11 -0
- package/dist/lib/monitors/config.js +8 -0
- package/dist/lib/monitors/engine.js +8 -1
- package/dist/lib/monitors/state.d.ts +37 -1
- package/dist/lib/monitors/state.js +79 -4
- package/dist/lib/permissions-registry.d.ts +2 -0
- package/dist/lib/permissions-registry.js +116 -14
- package/dist/lib/permissions.d.ts +5 -3
- package/dist/lib/permissions.js +25 -27
- package/dist/lib/profiles.d.ts +8 -7
- package/dist/lib/profiles.js +12 -0
- package/dist/lib/project-key.d.ts +9 -0
- package/dist/lib/project-key.js +11 -0
- package/dist/lib/secrets/bundles.d.ts +35 -0
- package/dist/lib/secrets/bundles.js +78 -1
- package/dist/lib/secrets/push.d.ts +3 -8
- package/dist/lib/secrets/push.js +18 -14
- package/dist/lib/secrets/remote.d.ts +9 -18
- package/dist/lib/secrets/remote.js +11 -26
- package/dist/lib/secrets/reserved-sync.d.ts +65 -0
- package/dist/lib/secrets/reserved-sync.js +129 -0
- package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
- package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
- package/dist/lib/self-heal/registry.js +4 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/active.js +1 -4
- package/dist/lib/session/db.d.ts +39 -4
- package/dist/lib/session/db.js +130 -29
- package/dist/lib/session/discover.d.ts +32 -4
- package/dist/lib/session/discover.js +119 -25
- package/dist/lib/session/insights.d.ts +14 -0
- package/dist/lib/session/insights.js +25 -2
- package/dist/lib/session/linear.js +1 -1
- package/dist/lib/session/shell-programs.d.ts +17 -0
- package/dist/lib/session/shell-programs.js +21 -0
- package/dist/lib/session/state.js +2 -1
- package/dist/lib/session/stream-render.js +2 -1
- package/dist/lib/session/tool-calls.js +2 -5
- package/dist/lib/session/trajectory-html.js +2 -1
- package/dist/lib/session/trajectory.js +3 -12
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/share/publish.d.ts +53 -5
- package/dist/lib/share/publish.js +99 -17
- package/dist/lib/share/worker-template.js +582 -57
- package/dist/lib/startup/root-command.js +2 -1
- package/dist/lib/state.d.ts +16 -0
- package/dist/lib/state.js +178 -46
- package/dist/lib/sync-status.d.ts +4 -0
- package/dist/lib/sync-status.js +3 -0
- package/dist/lib/traces/classify.js +24 -19
- package/dist/lib/usage-refresh.js +2 -1
- package/dist/lib/view-types.d.ts +2 -0
- package/package.json +2 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The Cloudflare Worker that fronts the R2 share bucket.
|
|
2
2
|
//
|
|
3
3
|
// One tiny Worker does both sides:
|
|
4
|
-
// - PUT /<username>/<slug> —
|
|
4
|
+
// - PUT /<username>/<slug> — write-gated by authorizeWrite's THREE principals
|
|
5
|
+
// (not a fallback chain — each is a distinct legitimate identity):
|
|
5
6
|
// 1. Static WRITE_TOKEN (BYO Cloudflare) — checked first when the presented
|
|
6
7
|
// bearer equals env.WRITE_TOKEN. owner = env.SHARE_NAMESPACE or the
|
|
7
8
|
// path's first segment.
|
|
@@ -12,8 +13,14 @@
|
|
|
12
13
|
// so one user cannot write another's prefix (403 namespace mismatch).
|
|
13
14
|
// A __handles/<handle> claim binds the handle to the first userId that
|
|
14
15
|
// writes it; a different userId gets 409 handle taken.
|
|
16
|
+
// 3. __share HMAC cookie — the signed-in viewer's identity cookie (same
|
|
17
|
+
// {userId,email} identityFromCookie verifies for GET). Lets the shared
|
|
18
|
+
// page's inline visibility control PATCH with credentials:'include' and
|
|
19
|
+
// no bearer; SameSite=Lax blocks it cross-site, and the same namespace/
|
|
20
|
+
// owner checks confine it to the holder's own pages. Applies to PUT,
|
|
21
|
+
// PATCH, and DELETE alike, since all three share authorizeWrite.
|
|
15
22
|
// A managed deployment sets PHOENIX_ID_BASE; BYO sets WRITE_TOKEN; the
|
|
16
|
-
// platform endpoint may set both. Fail loud (401) when
|
|
23
|
+
// platform endpoint may set both. Fail loud (401) when none authenticates.
|
|
17
24
|
// Writes the body to R2 via the BUCKET binding, storing visibility
|
|
18
25
|
// (public|unlisted|me|org), owner, org_domain (org only), an optional
|
|
19
26
|
// expires-at, plus provenance (agent/session/host/repo/date), a label, and
|
|
@@ -22,6 +29,13 @@
|
|
|
22
29
|
// domain is 400. Overwriting an existing slug first copies the current
|
|
23
30
|
// object to <slug>/rev-<ts>-<rand> (revision history) unless
|
|
24
31
|
// x-share-no-revision is set.
|
|
32
|
+
// - PATCH /<username>/<slug> — authenticated metadata-only edit. Rewrites the
|
|
33
|
+
// exact existing body with all HTTP/custom metadata preserved except the
|
|
34
|
+
// explicitly requested label/arbitrary metadata changes; never revisions.
|
|
35
|
+
// Conditional put (onlyIf etagMatches) so a concurrent republish 409s
|
|
36
|
+
// instead of rolling the body back. Phoenix requires customMetadata.owner
|
|
37
|
+
// === auth.owner (fail closed when the stamp is missing); WRITE_TOKEN is
|
|
38
|
+
// the admin repair path.
|
|
25
39
|
// - GET /<username>/<slug> — public|unlisted are anonymous; me requires the
|
|
26
40
|
// Phoenix owner, org requires a same-domain Phoenix identity (Bearer, then
|
|
27
41
|
// HMAC cookie, then phoenix_ticket). Unauthenticated me/org 302s to
|
|
@@ -62,7 +76,7 @@ export const hooks = {
|
|
|
62
76
|
};
|
|
63
77
|
|
|
64
78
|
export default {
|
|
65
|
-
async fetch(request, env) {
|
|
79
|
+
async fetch(request, env, ctx) {
|
|
66
80
|
const url = new URL(request.url);
|
|
67
81
|
const path = decodeURIComponent(url.pathname.replace(/^\\/+/, ''));
|
|
68
82
|
|
|
@@ -119,21 +133,58 @@ export default {
|
|
|
119
133
|
// never smuggle through on a publish that carries no agent/session/host/
|
|
120
134
|
// repo/date at all — e.g. a human publishing outside an agent session, or
|
|
121
135
|
// outside a git checkout.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
136
|
+
// Full-Unicode headers (PHNX-2786): the CLI always sends the latin1-safe
|
|
137
|
+
// x-share-<field> (what a pre-Unicode Worker read), and when the fold was
|
|
138
|
+
// lossy ALSO a percent-encoded x-share-<field>-u companion plus an
|
|
139
|
+
// x-share-encoding: percent opt-in. Prefer the decoded companion when the
|
|
140
|
+
// opt-in is present, so a Japanese/emoji title/label/--meta renders in full
|
|
141
|
+
// instead of (unnamed) or a dropped glyph; a malformed companion falls
|
|
142
|
+
// back to the folded value rather than failing the publish.
|
|
143
|
+
const usePercent = (request.headers.get('x-share-encoding') || '') === 'percent';
|
|
144
|
+
const readText = (name) => {
|
|
145
|
+
const folded = request.headers.get('x-share-' + name) || '';
|
|
146
|
+
if (usePercent) {
|
|
147
|
+
const encoded = request.headers.get('x-share-' + name + '-u');
|
|
148
|
+
if (encoded) {
|
|
149
|
+
try {
|
|
150
|
+
return decodeURIComponent(encoded);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return folded;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return folded;
|
|
157
|
+
};
|
|
158
|
+
const agent = readText('agent');
|
|
159
|
+
const session = readText('session');
|
|
160
|
+
const host = readText('host');
|
|
161
|
+
const repo = readText('repo');
|
|
162
|
+
const date = readText('date');
|
|
163
|
+
const avatar = readText('avatar');
|
|
164
|
+
const label = readText('label');
|
|
128
165
|
const labelSource = request.headers.get('x-share-label-source') || '';
|
|
129
166
|
let extraMeta = {};
|
|
130
|
-
|
|
131
|
-
|
|
167
|
+
// The -u meta companion carries the whole raw object percent-encoded, so a
|
|
168
|
+
// single decode+parse recovers full-Unicode keys and values; fall back to
|
|
169
|
+
// the per-value-folded x-share-meta when it is absent or malformed.
|
|
170
|
+
const metaEncoded = usePercent ? request.headers.get('x-share-meta-u') : null;
|
|
171
|
+
if (metaEncoded) {
|
|
132
172
|
try {
|
|
133
|
-
const parsed = JSON.parse(
|
|
173
|
+
const parsed = JSON.parse(decodeURIComponent(metaEncoded));
|
|
134
174
|
if (parsed && typeof parsed === 'object') extraMeta = parsed;
|
|
135
|
-
} catch {
|
|
136
|
-
// malformed
|
|
175
|
+
} catch (e) {
|
|
176
|
+
// malformed unicode meta companion — fall through to the folded header
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!Object.keys(extraMeta).length) {
|
|
180
|
+
const metaHeader = request.headers.get('x-share-meta');
|
|
181
|
+
if (metaHeader) {
|
|
182
|
+
try {
|
|
183
|
+
const parsed = JSON.parse(metaHeader);
|
|
184
|
+
if (parsed && typeof parsed === 'object') extraMeta = parsed;
|
|
185
|
+
} catch {
|
|
186
|
+
// malformed --meta header — ignore rather than fail the whole publish
|
|
187
|
+
}
|
|
137
188
|
}
|
|
138
189
|
}
|
|
139
190
|
const customMetadata = { ...extraMeta };
|
|
@@ -156,6 +207,7 @@ export default {
|
|
|
156
207
|
if (host) customMetadata['host'] = host;
|
|
157
208
|
if (repo) customMetadata['repo'] = repo;
|
|
158
209
|
if (date) customMetadata['date'] = date;
|
|
210
|
+
if (avatar) customMetadata['avatar'] = avatar;
|
|
159
211
|
if (label) customMetadata['label'] = label;
|
|
160
212
|
if (labelSource) customMetadata['label-source'] = labelSource;
|
|
161
213
|
|
|
@@ -189,6 +241,95 @@ export default {
|
|
|
189
241
|
return json({ ok: true, url: url.origin + '/' + path, expiresAt: expiresAt || null, unlisted: visibility === 'unlisted', visibility }, 200);
|
|
190
242
|
}
|
|
191
243
|
|
|
244
|
+
if (request.method === 'PATCH') {
|
|
245
|
+
const auth = await authorizeWrite(request, env);
|
|
246
|
+
if (auth.error) return auth.error;
|
|
247
|
+
const segments = path.split('/').filter(Boolean);
|
|
248
|
+
if (segments.length < 2) return json({ error: 'metadata edit requires /<username>/<slug>' }, 400);
|
|
249
|
+
if (auth.kind === 'phoenix') {
|
|
250
|
+
const expected = phoenixHandle(auth);
|
|
251
|
+
if (!expected || segments[0] !== expected) return json({ error: 'namespace mismatch', owner: expected }, 403);
|
|
252
|
+
}
|
|
253
|
+
const existing = await env.BUCKET.get(path);
|
|
254
|
+
if (!existing) return json({ error: 'share not found', key: path }, 404);
|
|
255
|
+
const owner = existing.customMetadata && existing.customMetadata.owner;
|
|
256
|
+
// WRITE_TOKEN is the endpoint owner/admin credential (the same authority
|
|
257
|
+
// the DELETE path grants it). Phoenix must prove ownership — fail closed
|
|
258
|
+
// when the object has no owner stamp (handles collide after sanitization).
|
|
259
|
+
if (auth.kind === 'phoenix' && (!owner || owner !== auth.owner)) return json({ error: 'forbidden' }, 403);
|
|
260
|
+
|
|
261
|
+
let edit;
|
|
262
|
+
try { edit = await request.json(); } catch { return json({ error: 'PATCH body must be JSON' }, 400); }
|
|
263
|
+
if (!edit || typeof edit !== 'object') return json({ error: 'PATCH body must be an object' }, 400);
|
|
264
|
+
const metadata = { ...(existing.customMetadata || {}) };
|
|
265
|
+
const previousVisibility = metadata.visibility || 'public';
|
|
266
|
+
if (Object.prototype.hasOwnProperty.call(edit, 'label')) {
|
|
267
|
+
if (edit.label !== null && typeof edit.label !== 'string') return json({ error: 'label must be a string or null' }, 400);
|
|
268
|
+
if (typeof edit.label === 'string' && (edit.label.trim() !== edit.label || !edit.label || edit.label.length > 200)) return json({ error: 'label must be 1-200 trimmed characters' }, 400);
|
|
269
|
+
if (edit.label === null) { delete metadata.label; delete metadata['label-source']; }
|
|
270
|
+
else { metadata.label = edit.label; metadata['label-source'] = 'explicit'; }
|
|
271
|
+
}
|
|
272
|
+
// Visibility is a first-class edit field (like label), not an arbitrary
|
|
273
|
+
// --meta entry — 'visibility' is a RESERVED key, so it can only be changed
|
|
274
|
+
// here, never smuggled through edit.meta. me/org require a Phoenix identity
|
|
275
|
+
// and org additionally requires a private (non-public-inbox) email domain,
|
|
276
|
+
// the SAME gate PUT enforces. This is a metadata-only rewrite: the body is
|
|
277
|
+
// untouched, so no revision is created (identical to a label/meta edit).
|
|
278
|
+
if (Object.prototype.hasOwnProperty.call(edit, 'visibility')) {
|
|
279
|
+
const vis = normalizeVisibility(edit.visibility);
|
|
280
|
+
if (vis.error) return vis.error;
|
|
281
|
+
const visibility = vis.value;
|
|
282
|
+
if (visibility === 'me' || visibility === 'org') {
|
|
283
|
+
const phoenixBase = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
|
|
284
|
+
if (auth.kind !== 'phoenix' || !phoenixBase) return json({ error: 'visibility me/org requires Phoenix identity' }, 400);
|
|
285
|
+
if (visibility === 'org') {
|
|
286
|
+
const domain = emailDomain(auth.email);
|
|
287
|
+
if (!domain) return json({ error: 'org visibility requires a verified email domain' }, 400);
|
|
288
|
+
if (PUBLIC_INBOX_DOMAINS.indexOf(domain) !== -1) return json({ error: 'org visibility cannot use a public email domain', domain: domain }, 400);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
metadata.visibility = visibility;
|
|
292
|
+
// org_domain is meaningful only for org — set it there, drop the stale
|
|
293
|
+
// value on any move away from org so it can never gate a later read.
|
|
294
|
+
if (visibility === 'org') metadata.org_domain = emailDomain(auth.email);
|
|
295
|
+
else delete metadata.org_domain;
|
|
296
|
+
}
|
|
297
|
+
const mode = edit.metaMode || 'merge';
|
|
298
|
+
if (mode !== 'merge' && mode !== 'replace') return json({ error: 'metaMode must be merge or replace' }, 400);
|
|
299
|
+
const incoming = edit.meta || {};
|
|
300
|
+
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return json({ error: 'meta must be an object' }, 400);
|
|
301
|
+
for (const key in incoming) {
|
|
302
|
+
if (RESERVED_METADATA_KEYS.indexOf(key) !== -1) return json({ error: 'reserved metadata key', key: key }, 400);
|
|
303
|
+
if (!/^[a-z0-9-]{1,64}$/.test(key) || typeof incoming[key] !== 'string') return json({ error: 'invalid metadata entry', key: key }, 400);
|
|
304
|
+
}
|
|
305
|
+
if (mode === 'replace') {
|
|
306
|
+
for (const key in metadata) if (RESERVED_METADATA_KEYS.indexOf(key) === -1) delete metadata[key];
|
|
307
|
+
}
|
|
308
|
+
for (const key in incoming) metadata[key] = incoming[key];
|
|
309
|
+
const remove = edit.removeMeta || [];
|
|
310
|
+
if (!Array.isArray(remove)) return json({ error: 'removeMeta must be an array' }, 400);
|
|
311
|
+
for (const key of remove) {
|
|
312
|
+
if (typeof key !== 'string' || RESERVED_METADATA_KEYS.indexOf(key) !== -1 || !/^[a-z0-9-]{1,64}$/.test(key)) return json({ error: 'invalid removable metadata key', key: key }, 400);
|
|
313
|
+
delete metadata[key];
|
|
314
|
+
}
|
|
315
|
+
if (new TextEncoder().encode(JSON.stringify(extraMetaOf(metadata))).length > 2048) return json({ error: 'metadata exceeds 2048 bytes' }, 400);
|
|
316
|
+
// R2 assigns a fresh uploaded timestamp to every put. Preserve the
|
|
317
|
+
// canonical publication time before this metadata-only rewrite so gallery
|
|
318
|
+
// ordering and list JSON do not pretend the page was republished today.
|
|
319
|
+
if (!metadata['published-at']) metadata['published-at'] = new Date(existing.uploaded).toISOString();
|
|
320
|
+
const httpHeaders = new Headers();
|
|
321
|
+
if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(httpHeaders);
|
|
322
|
+
const putOpts = { httpMetadata: httpHeaders, customMetadata: metadata };
|
|
323
|
+
// onlyIf.etagMatches wants the bare hash (R2Object#etag), not the
|
|
324
|
+
// quoted HTTP header form (R2Object#httpEtag) — passing httpEtag 500s
|
|
325
|
+
// on a real Workers R2 binding ("Conditional ETag should not be
|
|
326
|
+
// wrapped in quotes"), a defect only a real R2 backend surfaces.
|
|
327
|
+
if (existing.etag) putOpts.onlyIf = { etagMatches: existing.etag };
|
|
328
|
+
const putResult = await env.BUCKET.put(path, existing.body, putOpts);
|
|
329
|
+
if (putResult === null) return json({ error: 'conflict', key: path }, 409);
|
|
330
|
+
return json({ ok: true, url: url.origin + '/' + path, label: metadata.label || null, meta: extraMetaOf(metadata), visibility: metadata.visibility || 'public', previousVisibility: previousVisibility }, 200);
|
|
331
|
+
}
|
|
332
|
+
|
|
192
333
|
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
193
334
|
// Single-segment path may be a user gallery/listing OR a legacy flat slug.
|
|
194
335
|
// The disambiguator is whether the <seg>/ prefix holds any object: only a
|
|
@@ -202,9 +343,12 @@ export default {
|
|
|
202
343
|
const list = await env.BUCKET.list({ prefix: segments[0] + '/', limit: 1 });
|
|
203
344
|
const isNamespace = list.objects && list.objects.length > 0;
|
|
204
345
|
if (isNamespace) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
346
|
+
if (url.searchParams.get('format') === 'json') {
|
|
347
|
+
const scope = await resolveListingScope(request, env, segments[0]);
|
|
348
|
+
if (scope.error) return scope.error;
|
|
349
|
+
return renderListing(env.BUCKET, url.origin, segments[0], request.method, scope.includeHidden);
|
|
350
|
+
}
|
|
351
|
+
return renderGallery(env.BUCKET, url.origin, segments[0], request.method);
|
|
208
352
|
}
|
|
209
353
|
// Not a namespace: fall through. A legacy flat-slug object resolves to its
|
|
210
354
|
// real content; anything else 404s (an empty/nonexistent namespace, which
|
|
@@ -219,8 +363,15 @@ export default {
|
|
|
219
363
|
// ?revisions=json must not leak that the page exists.
|
|
220
364
|
if (segments.length === 2 && url.searchParams.get('revisions') === 'json') {
|
|
221
365
|
const canonical = await env.BUCKET.get(path);
|
|
222
|
-
|
|
223
|
-
|
|
366
|
+
// Only a me/org canonical needs the viewer resolved — and only there does
|
|
367
|
+
// a phoenix_ticket failure gate the response, exactly as before this route
|
|
368
|
+
// shared resolveViewer with the page GET. A public/unlisted revision list
|
|
369
|
+
// never invoked ticket redemption, so a stale ticket must not 401 it.
|
|
370
|
+
if (canonical && isIdentityGated((canonical.customMetadata && canonical.customMetadata.visibility) || 'public')) {
|
|
371
|
+
const viewer = await resolveViewer(request, env, url);
|
|
372
|
+
if (viewer.redirect) return viewer.redirect;
|
|
373
|
+
if (viewer.error) return viewer.error;
|
|
374
|
+
const denied = gateVisibility(url, env, canonical, viewer.identity || null);
|
|
224
375
|
if (denied) return denied;
|
|
225
376
|
}
|
|
226
377
|
return renderRevisions(
|
|
@@ -239,9 +390,24 @@ export default {
|
|
|
239
390
|
await env.BUCKET.delete(path);
|
|
240
391
|
return new Response('gone — this link has expired', { status: 410, headers: { 'content-type': 'text/plain' } });
|
|
241
392
|
}
|
|
242
|
-
|
|
243
|
-
|
|
393
|
+
// Resolve the viewer ONCE — needed both to gate me/org reads AND to decide
|
|
394
|
+
// whether THIS viewer OWNS this namespace (the interactive visibility
|
|
395
|
+
// control below). A successful phoenix_ticket redemption (redirect: set the
|
|
396
|
+
// identity cookie, strip the ticket) is honored on ANY page. But a ticket
|
|
397
|
+
// FAILURE (expired/consumed/unreachable) must only gate a page that needs
|
|
398
|
+
// identity — a public/unlisted page never invoked ticket redemption before
|
|
399
|
+
// this refactor, so a stale ticket there must serve anonymously, not 401.
|
|
244
400
|
const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
|
|
401
|
+
const viewer = await resolveViewer(request, env, url);
|
|
402
|
+
if (viewer.redirect) return viewer.redirect;
|
|
403
|
+
if (viewer.error && isIdentityGated(visibility)) return viewer.error;
|
|
404
|
+
const identity = viewer.identity || null;
|
|
405
|
+
const denied = gateVisibility(url, env, obj, identity);
|
|
406
|
+
if (denied) return denied;
|
|
407
|
+
// The owner of the namespace (their handle === the first path segment) gets
|
|
408
|
+
// an interactive visibility control; everyone else keeps the static cue.
|
|
409
|
+
const isOwner = !!identity && handleFromEmail(identity.email) === firstSeg;
|
|
410
|
+
const ownerDomain = identity ? emailDomain(identity.email) : '';
|
|
245
411
|
const headers = new Headers();
|
|
246
412
|
obj.writeHttpMetadata(headers);
|
|
247
413
|
headers.set('etag', obj.httpEtag);
|
|
@@ -254,18 +420,58 @@ export default {
|
|
|
254
420
|
if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
|
|
255
421
|
}
|
|
256
422
|
if (request.method === 'HEAD') return new Response(null, { status: 200, headers });
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
423
|
+
const ctype = headers.get('content-type') || '';
|
|
424
|
+
// HTML pages ALWAYS get the attribution bar injected at serve time (who
|
|
425
|
+
// shared it, what made it, when, and — the point — a visual VISIBILITY cue),
|
|
426
|
+
// all from stamped metadata. This is unconditional: ?raw does NOT strip the
|
|
427
|
+
// bar from a shared page (the always-on cue from #3122/#3140 is not
|
|
428
|
+
// defeatable with a query param). The body changes, so the R2 etag no
|
|
261
429
|
// longer matches — drop it rather than serve a lying validator.
|
|
262
|
-
|
|
263
|
-
if (isHtmlPage) {
|
|
430
|
+
if (ctype.indexOf('text/html') !== -1) {
|
|
264
431
|
const rawHtml = await obj.text();
|
|
265
|
-
|
|
432
|
+
// Per-slug view counter (RUSH view stats). Stored as a SEPARATE R2 object
|
|
433
|
+
// (__views/<path>) so it never rewrites the page object — a rewrite would
|
|
434
|
+
// reset obj.uploaded and corrupt "last updated". The __-prefix key is GET-
|
|
435
|
+
// blocked for direct requests and lives outside every <user>/ list prefix,
|
|
436
|
+
// so it never leaks into the gallery/listing/revisions. Count only a real
|
|
437
|
+
// visitor's page view: not ?raw (an embed/OG fetch), not the owner's own
|
|
438
|
+
// view, and never HEAD (which returned above). The write rides
|
|
439
|
+
// ctx.waitUntil so it never blocks the response; the displayed count folds
|
|
440
|
+
// in the current view so the visitor sees themselves included.
|
|
441
|
+
const wantsRaw = url.searchParams.get('raw') != null;
|
|
442
|
+
const counting = !wantsRaw && !isOwner;
|
|
443
|
+
const storedViews = await readViews(env, path);
|
|
444
|
+
const views = storedViews + (counting ? 1 : 0);
|
|
445
|
+
if (counting && ctx && typeof ctx.waitUntil === 'function') {
|
|
446
|
+
ctx.waitUntil(writeViews(env, path, views));
|
|
447
|
+
}
|
|
448
|
+
const withBar = injectAttributionBar(rawHtml, obj.customMetadata || {}, firstSeg, {
|
|
449
|
+
isOwner,
|
|
450
|
+
ownerDomain,
|
|
451
|
+
views,
|
|
452
|
+
uploaded: obj.uploaded,
|
|
453
|
+
});
|
|
266
454
|
headers.delete('etag');
|
|
267
455
|
return new Response(withBar, { status: 200, headers });
|
|
268
456
|
}
|
|
457
|
+
// Non-HTML asset. ?raw returns the stored bytes untouched — the escape hatch
|
|
458
|
+
// for <img src>, OG crawlers, iframes, and anyone embedding the asset (the
|
|
459
|
+
// viewer's own media element points back at ?raw). Otherwise a BROWSER
|
|
460
|
+
// navigating directly to a viewable asset (image/video/audio/pdf) gets a
|
|
461
|
+
// lightweight viewer page carrying the same bar; a non-browser fetch (Accept
|
|
462
|
+
// without text/html) falls through to raw bytes so embedding is never broken.
|
|
463
|
+
const wantsRaw = url.searchParams.get('raw') != null;
|
|
464
|
+
const kind = viewableAssetKind(ctype);
|
|
465
|
+
if (!wantsRaw && kind && acceptsHtml(request)) {
|
|
466
|
+
const viewerPage = renderAssetViewer(url.pathname, kind, obj.customMetadata || {}, firstSeg, {
|
|
467
|
+
isOwner,
|
|
468
|
+
ownerDomain,
|
|
469
|
+
});
|
|
470
|
+
const vheaders = new Headers(headers);
|
|
471
|
+
vheaders.set('content-type', 'text/html; charset=utf-8');
|
|
472
|
+
vheaders.delete('etag');
|
|
473
|
+
return new Response(viewerPage, { status: 200, headers: vheaders });
|
|
474
|
+
}
|
|
269
475
|
return new Response(obj.body, { status: 200, headers });
|
|
270
476
|
}
|
|
271
477
|
|
|
@@ -307,6 +513,10 @@ function isRevisionKey(key) {
|
|
|
307
513
|
return key.split('/').length > 2;
|
|
308
514
|
}
|
|
309
515
|
|
|
516
|
+
function publishedAtOf(object) {
|
|
517
|
+
return (object.customMetadata && object.customMetadata['published-at']) || object.uploaded;
|
|
518
|
+
}
|
|
519
|
+
|
|
310
520
|
async function renderGallery(bucket, origin, user, method) {
|
|
311
521
|
const objects = [];
|
|
312
522
|
let cursor;
|
|
@@ -329,7 +539,7 @@ async function renderGallery(bucket, origin, user, method) {
|
|
|
329
539
|
const url = origin + '/' + o.key;
|
|
330
540
|
const label = (o.customMetadata && o.customMetadata['label']) || '';
|
|
331
541
|
const agent = (o.customMetadata && o.customMetadata['agent']) || '';
|
|
332
|
-
return { slug, url, updated: o
|
|
542
|
+
return { slug, url, updated: publishedAtOf(o), label, agent };
|
|
333
543
|
});
|
|
334
544
|
|
|
335
545
|
if (method === 'HEAD') {
|
|
@@ -360,7 +570,7 @@ async function renderGallery(bucket, origin, user, method) {
|
|
|
360
570
|
return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60' } });
|
|
361
571
|
}
|
|
362
572
|
|
|
363
|
-
async function renderListing(bucket, origin, user, method) {
|
|
573
|
+
async function renderListing(bucket, origin, user, method, includeHidden) {
|
|
364
574
|
const objects = [];
|
|
365
575
|
let cursor;
|
|
366
576
|
do {
|
|
@@ -381,11 +591,11 @@ async function renderListing(bucket, origin, user, method) {
|
|
|
381
591
|
const now = Date.now();
|
|
382
592
|
const items = objects
|
|
383
593
|
.filter(o => {
|
|
384
|
-
//
|
|
385
|
-
// pages
|
|
594
|
+
// Revisions and sibling .png OG covers are always hidden. Hidden-visibility
|
|
595
|
+
// pages are included ONLY when an authenticated owner requested scope=mine.
|
|
386
596
|
if (isRevisionKey(o.key)) return false;
|
|
387
597
|
if (o.key.endsWith('.png')) return false;
|
|
388
|
-
if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility)) return false;
|
|
598
|
+
if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility) && !includeHidden) return false;
|
|
389
599
|
const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
|
|
390
600
|
return !(expiresAt && now > Date.parse(expiresAt));
|
|
391
601
|
})
|
|
@@ -394,9 +604,10 @@ async function renderListing(bucket, origin, user, method) {
|
|
|
394
604
|
url: origin + '/' + o.key,
|
|
395
605
|
size: typeof o.size === 'number' ? o.size : 0,
|
|
396
606
|
contentType: (o.httpMetadata && o.httpMetadata.contentType) || null,
|
|
397
|
-
publishedAt: new Date(o
|
|
607
|
+
publishedAt: new Date(publishedAtOf(o)).toISOString(),
|
|
398
608
|
expiresAt: (o.customMetadata && o.customMetadata['expires-at']) || null,
|
|
399
609
|
label: (o.customMetadata && o.customMetadata['label']) || null,
|
|
610
|
+
visibility: (o.customMetadata && o.customMetadata.visibility) || 'public',
|
|
400
611
|
agent: (o.customMetadata && o.customMetadata['agent']) || null,
|
|
401
612
|
session: (o.customMetadata && o.customMetadata['session']) || null,
|
|
402
613
|
host: (o.customMetadata && o.customMetadata['host']) || null,
|
|
@@ -407,15 +618,39 @@ async function renderListing(bucket, origin, user, method) {
|
|
|
407
618
|
// Newest first, so the human table and any script reads the freshest share top.
|
|
408
619
|
items.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : a.publishedAt > b.publishedAt ? -1 : 0));
|
|
409
620
|
|
|
621
|
+
const cacheHeaders = includeHidden
|
|
622
|
+
? { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'private, no-store', 'X-Robots-Tag': 'noindex' }
|
|
623
|
+
: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' };
|
|
410
624
|
if (method === 'HEAD') {
|
|
411
|
-
return new Response(null, { status: 200, headers:
|
|
625
|
+
return new Response(null, { status: 200, headers: cacheHeaders });
|
|
412
626
|
}
|
|
413
627
|
return new Response(JSON.stringify({ user, count: items.length, objects: items }), {
|
|
414
628
|
status: 200,
|
|
415
|
-
headers:
|
|
629
|
+
headers: cacheHeaders,
|
|
416
630
|
});
|
|
417
631
|
}
|
|
418
632
|
|
|
633
|
+
// The machine-readable listing is public by default. A request with
|
|
634
|
+
// '?format=json&scope=mine' asks to include the owner's hidden pages
|
|
635
|
+
// (unlisted / me / org). We honor it ONLY after a valid owner bearer whose
|
|
636
|
+
// namespace matches the requested handle, so one user cannot list another's
|
|
637
|
+
// hidden shares. Anonymous or mismatched requests fail loud. A BYO WRITE_TOKEN
|
|
638
|
+
// bearer is the worker's owner, so namespace enforcement is skipped for BYO.
|
|
639
|
+
async function resolveListingScope(request, env, user) {
|
|
640
|
+
const scope = new URL(request.url).searchParams.get('scope');
|
|
641
|
+
if (scope !== 'mine') return { includeHidden: false };
|
|
642
|
+
const auth = await authorizeWrite(request, env);
|
|
643
|
+
if (auth.error) return { error: auth.error };
|
|
644
|
+
if (auth.kind === 'byo') {
|
|
645
|
+
return { includeHidden: true };
|
|
646
|
+
}
|
|
647
|
+
const owner = phoenixHandle(auth);
|
|
648
|
+
if (owner !== user) {
|
|
649
|
+
return { error: json({ error: 'namespace mismatch' }, 403) };
|
|
650
|
+
}
|
|
651
|
+
return { includeHidden: true };
|
|
652
|
+
}
|
|
653
|
+
|
|
419
654
|
async function renderRevisions(bucket, origin, key, method, identityGated) {
|
|
420
655
|
const objects = [];
|
|
421
656
|
let cursor;
|
|
@@ -459,7 +694,7 @@ async function renderRevisions(bucket, origin, key, method, identityGated) {
|
|
|
459
694
|
// before it ever reaches this Worker; see RESERVED_META_KEYS in publish.ts).
|
|
460
695
|
// One list, reused both to strip a same-named --meta collision on write and
|
|
461
696
|
// to split arbitrary --meta entries back out on read.
|
|
462
|
-
var RESERVED_METADATA_KEYS = ['expires-at', 'visibility', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'label', 'label-source'];
|
|
697
|
+
var RESERVED_METADATA_KEYS = ['expires-at', 'published-at', 'visibility', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'avatar', 'label', 'label-source'];
|
|
463
698
|
var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'icloud.com', 'me.com'];
|
|
464
699
|
var SHARE_COOKIE = '__Host-phoenix_share';
|
|
465
700
|
var SHARE_COOKIE_MAX_AGE = 604800;
|
|
@@ -498,6 +733,76 @@ var VIS_ICON = {
|
|
|
498
733
|
public: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></svg>',
|
|
499
734
|
};
|
|
500
735
|
|
|
736
|
+
// Eye glyph for the view-count stat; caret for the owner's interactive chip.
|
|
737
|
+
var STAT_EYE_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>';
|
|
738
|
+
var CARET_ICON = '<svg class="ash-caret" viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.4"><path d="m6 9 6 6 6-6"/></svg>';
|
|
739
|
+
|
|
740
|
+
// The owner-only browser script that turns the visibility chip into a live
|
|
741
|
+
// control. Self-contained IIFE, namespaced under .agents-share-bar; injected only
|
|
742
|
+
// when the viewer owns the namespace. It reads the current level and each level's
|
|
743
|
+
// icon/color/label straight from the rendered menu DOM (no embedded config), then
|
|
744
|
+
// PATCHes the SAME in-place edit route the CLI uses — method PATCH, JSON body
|
|
745
|
+
// { visibility } — with credentials:'include' so the viewer's __share cookie /
|
|
746
|
+
// Phoenix identity rides. Optimistic: flip the chip + spinner, then a green check
|
|
747
|
+
// on 2xx or revert + the server's error text on failure (an org-on-public-inbox
|
|
748
|
+
// 400 surfaces verbatim). The share page sets no CSP, so inline JS is fine.
|
|
749
|
+
var ASH_OWNER_JS = \`(function(){
|
|
750
|
+
var root = document.querySelector('.agents-share-bar');
|
|
751
|
+
if(!root) return;
|
|
752
|
+
var chip = root.querySelector('[data-ash-chip]');
|
|
753
|
+
var menu = root.querySelector('[data-ash-menu]');
|
|
754
|
+
var toast = root.querySelector('[data-ash-toast]');
|
|
755
|
+
if(!chip || !menu || !toast) return;
|
|
756
|
+
var labelEl = chip.querySelector('[data-ash-label]');
|
|
757
|
+
var chipIc = chip.querySelector('[data-ash-chip-ic]');
|
|
758
|
+
var busy = false;
|
|
759
|
+
function opts(){ return menu.querySelectorAll('[data-ash-opt]'); }
|
|
760
|
+
function currentKey(){ var s = menu.querySelector('.ash-opt.ash-sel'); return s ? s.getAttribute('data-ash-opt') : ''; }
|
|
761
|
+
function labelFor(key){ var a = opts(); for(var i=0;i<a.length;i++){ if(a[i].getAttribute('data-ash-opt')===key){ var b = a[i].querySelector('b'); return b ? b.textContent : key; } } return key; }
|
|
762
|
+
function open(){ menu.classList.add('ash-open'); menu.setAttribute('aria-hidden','false'); }
|
|
763
|
+
function close(){ menu.classList.remove('ash-open'); menu.setAttribute('aria-hidden','true'); }
|
|
764
|
+
chip.addEventListener('click', function(e){ e.stopPropagation(); if(menu.classList.contains('ash-open')) close(); else open(); });
|
|
765
|
+
menu.addEventListener('click', function(e){ e.stopPropagation(); });
|
|
766
|
+
document.addEventListener('click', close);
|
|
767
|
+
document.addEventListener('keydown', function(e){ if(e.key==='Escape') close(); });
|
|
768
|
+
function draw(key){
|
|
769
|
+
var sel = null, a = opts();
|
|
770
|
+
for(var i=0;i<a.length;i++){ var on = a[i].getAttribute('data-ash-opt')===key; a[i].classList.toggle('ash-sel', on); if(on) sel = a[i]; }
|
|
771
|
+
if(!sel) return;
|
|
772
|
+
var ic = sel.querySelector('.ash-opt-ic');
|
|
773
|
+
var color = ic ? ic.style.color : '';
|
|
774
|
+
if(color){ chip.style.color = color; chip.style.borderColor = color + '66'; }
|
|
775
|
+
if(chipIc && ic) chipIc.innerHTML = ic.innerHTML;
|
|
776
|
+
var b = sel.querySelector('b');
|
|
777
|
+
if(labelEl && b) labelEl.textContent = b.textContent;
|
|
778
|
+
}
|
|
779
|
+
function say(inner, cls){ toast.innerHTML = inner; toast.className = 'ash-toast ash-show' + (cls ? ' ' + cls : ''); }
|
|
780
|
+
function fade(ms){ setTimeout(function(){ toast.className = 'ash-toast'; }, ms); }
|
|
781
|
+
function pick(key){
|
|
782
|
+
close();
|
|
783
|
+
var prev = currentKey();
|
|
784
|
+
if(busy || !key || key===prev) return;
|
|
785
|
+
busy = true;
|
|
786
|
+
draw(key);
|
|
787
|
+
var name = labelFor(key);
|
|
788
|
+
say('<span class="ash-spin"></span> Saving…');
|
|
789
|
+
fetch(location.pathname, { method:'PATCH', credentials:'include', headers:{'content-type':'application/json'}, body: JSON.stringify({ visibility: key }) })
|
|
790
|
+
.then(function(res){ return res.json().then(function(j){ return { ok: res.ok, status: res.status, body: j }; }, function(){ return { ok: res.ok, status: res.status, body: {} }; }); })
|
|
791
|
+
.then(function(r){
|
|
792
|
+
busy = false;
|
|
793
|
+
if(r.ok){ say('<span class="ash-ok">\\u2713</span> Now ' + name, 'ash-good'); fade(1600); }
|
|
794
|
+
else {
|
|
795
|
+
draw(prev);
|
|
796
|
+
var msg = (r.body && r.body.error) ? String(r.body.error) : ('Could not update (HTTP ' + r.status + ')');
|
|
797
|
+
say('<span class="ash-err">\\u2715</span> ' + msg.replace(/[<>&]/g, ' '), 'ash-bad');
|
|
798
|
+
fade(4000);
|
|
799
|
+
}
|
|
800
|
+
}, function(){ busy = false; draw(prev); say('<span class="ash-err">\\u2715</span> Network error \\u2014 not saved', 'ash-bad'); fade(4000); });
|
|
801
|
+
}
|
|
802
|
+
var a = opts();
|
|
803
|
+
for(var i=0;i<a.length;i++){ (function(el){ el.addEventListener('click', function(e){ e.stopPropagation(); pick(el.getAttribute('data-ash-opt')); }); })(a[i]); }
|
|
804
|
+
})();\`;
|
|
805
|
+
|
|
501
806
|
function visibilityChip(visibility, orgDomain) {
|
|
502
807
|
if (visibility === 'me') return { icon: VIS_ICON.me, label: 'Only you', color: '#f59e0b' };
|
|
503
808
|
if (visibility === 'org') return { icon: VIS_ICON.org, label: 'Anyone at ' + escapeHtml(orgDomain || 'your organization'), color: '#5b9dff' };
|
|
@@ -505,14 +810,117 @@ function visibilityChip(visibility, orgDomain) {
|
|
|
505
810
|
return { icon: VIS_ICON.public, label: 'Public', color: '#30a46c' };
|
|
506
811
|
}
|
|
507
812
|
|
|
508
|
-
|
|
813
|
+
// A deterministic hue (0-359) from the handle, so a given sharer always gets the
|
|
814
|
+
// same initials-circle colour without any stored palette.
|
|
815
|
+
function avatarHue(s) {
|
|
816
|
+
var h = 0;
|
|
817
|
+
for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
|
|
818
|
+
return h;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// The avatar slot is ALWAYS rendered: a coloured initials circle derived from the
|
|
822
|
+
// handle. When the object carries an avatar URL (a Gravatar the CLI stamps at
|
|
823
|
+
// publish, or any future hosted photo), a real img is layered on top; onerror
|
|
824
|
+
// removes it so the initials underneath show through when the photo 404s. There
|
|
825
|
+
// is no CSP on the share page, so the inline onerror fallback is honoured.
|
|
826
|
+
function renderAvatar(handle, avatarUrl) {
|
|
827
|
+
var h = (handle || '').trim();
|
|
828
|
+
var initial = escapeHtml((h.charAt(0) || '?').toUpperCase());
|
|
829
|
+
var bg = 'hsl(' + avatarHue(h) + ',48%,42%)';
|
|
830
|
+
var inner = '<span class="ash-av-i">' + initial + '</span>';
|
|
831
|
+
if (avatarUrl) {
|
|
832
|
+
inner += '<img class="ash-av-img" src="' + escapeHtml(avatarUrl) + '" alt="" loading="lazy" ' +
|
|
833
|
+
'referrerpolicy="no-referrer" onerror="this.remove()">';
|
|
834
|
+
}
|
|
835
|
+
return '<span class="ash-avatar" style="background:' + bg + ' !important" aria-hidden="true">' + inner + '</span>';
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// A compact relative time for the "last updated" stat: "just now", "2h ago",
|
|
839
|
+
// "3d ago", or an ISO date once past ~30 days. Accepts a Date or a parseable
|
|
840
|
+
// string (R2's obj.uploaded is a Date).
|
|
841
|
+
function relativeTime(when) {
|
|
842
|
+
var then = when instanceof Date ? when.getTime() : Date.parse(when);
|
|
843
|
+
if (!then || isNaN(then)) return '';
|
|
844
|
+
var diff = Date.now() - then;
|
|
845
|
+
if (diff < 0) diff = 0;
|
|
846
|
+
var minute = 60000, hour = 3600000, day = 86400000;
|
|
847
|
+
if (diff < minute) return 'just now';
|
|
848
|
+
if (diff < hour) return Math.floor(diff / minute) + 'm ago';
|
|
849
|
+
if (diff < day) return Math.floor(diff / hour) + 'h ago';
|
|
850
|
+
if (diff < 30 * day) return Math.floor(diff / day) + 'd ago';
|
|
851
|
+
return new Date(then).toISOString().slice(0, 10);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// The four visibility levels the owner can pick, in menu order. Descriptions
|
|
855
|
+
// mirror the approved mockup. org always appears — a public-inbox owner still
|
|
856
|
+
// sees it and lets the server's 400 drive the failure message (never a hidden
|
|
857
|
+
// client-side rule). orgDomain is the owner's own email domain when known.
|
|
858
|
+
function visibilityLevels(orgDomain) {
|
|
859
|
+
return [
|
|
860
|
+
{ key: 'public', label: 'Public', color: '#30a46c', icon: VIS_ICON.public, desc: 'Anyone with the link · preview card · listed in your gallery' },
|
|
861
|
+
{ key: 'unlisted', label: 'Unlisted', color: '#9aa0a6', icon: VIS_ICON.unlisted, desc: 'Anyone with the link · no card · hidden from your gallery' },
|
|
862
|
+
{ key: 'me', label: 'Only you', color: '#f59e0b', icon: VIS_ICON.me, desc: 'Sign-in required · only your account can open it' },
|
|
863
|
+
{ key: 'org', label: 'Anyone at ' + escapeHtml(orgDomain || 'your org'), color: '#5b9dff', icon: VIS_ICON.org, desc: 'Anyone signed in with a ' + escapeHtml(orgDomain || 'workspace-domain') + ' account' },
|
|
864
|
+
];
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function renderVisibilityMenu(current, orgDomain) {
|
|
868
|
+
var rows = visibilityLevels(orgDomain).map(function (l) {
|
|
869
|
+
return '<button type="button" class="ash-opt' + (l.key === current ? ' ash-sel' : '') + '" data-ash-opt="' + l.key + '" role="menuitemradio" aria-checked="' + (l.key === current ? 'true' : 'false') + '">' +
|
|
870
|
+
'<span class="ash-opt-ic" style="color:' + l.color + '">' + l.icon + '</span>' +
|
|
871
|
+
'<span class="ash-opt-t"><b>' + l.label + '</b><span>' + l.desc + '</span></span>' +
|
|
872
|
+
'<span class="ash-rd"></span></button>';
|
|
873
|
+
}).join('');
|
|
874
|
+
return '<div class="ash-menu" data-ash-menu role="menu" aria-hidden="true"><h4>Who can see this</h4>' + rows + '</div>';
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function renderOwnerScript() {
|
|
878
|
+
return '<script>' + ASH_OWNER_JS + '</script>';
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// opts: { isOwner, ownerDomain, views, uploaded } — all optional. isOwner turns
|
|
882
|
+
// the visibility chip into a live control (inline dropdown, Variation A) and
|
|
883
|
+
// injects the owner-only CSS/JS; views/uploaded render the right-side stats
|
|
884
|
+
// cluster. With no opts the bar is exactly today's static strip.
|
|
885
|
+
function renderAttributionBar(meta, handle, opts) {
|
|
886
|
+
var o = opts || {};
|
|
509
887
|
var cm = meta || {};
|
|
510
|
-
var
|
|
888
|
+
var visibility = cm.visibility || 'public';
|
|
889
|
+
var chip = visibilityChip(visibility, cm.org_domain);
|
|
890
|
+
var avatar = renderAvatar(handle, cm.avatar);
|
|
511
891
|
var left = '';
|
|
512
892
|
// The handle is already the public URL namespace, so surfacing it leaks nothing new.
|
|
513
893
|
if (handle) left += 'Shared by <strong>' + escapeHtml(handle) + '</strong>';
|
|
514
894
|
if (cm.agent) left += (left ? '<span class="ash-dot">·</span>' : '') + 'Made with ' + escapeHtml(cm.agent);
|
|
515
|
-
|
|
895
|
+
|
|
896
|
+
// Stats cluster (👁 <n> views · updated <rel>) — right side, left of the chip.
|
|
897
|
+
var stats = '';
|
|
898
|
+
var statBits = '';
|
|
899
|
+
if (o.views != null) {
|
|
900
|
+
statBits += STAT_EYE_ICON + '<b>' + escapeHtml(String(o.views)) + '</b> ' + (Number(o.views) === 1 ? 'view' : 'views');
|
|
901
|
+
}
|
|
902
|
+
if (o.uploaded) {
|
|
903
|
+
var rel = relativeTime(o.uploaded);
|
|
904
|
+
if (rel) statBits += (statBits ? '<span class="ash-statsep">·</span>' : '') + 'updated <b>' + escapeHtml(rel) + '</b>';
|
|
905
|
+
}
|
|
906
|
+
if (statBits) stats = '<span class="ash-stats" title="page stats">' + statBits + '</span>';
|
|
907
|
+
|
|
908
|
+
// The visibility chip: an interactive control for the owner, a static cue for
|
|
909
|
+
// everyone else (unchanged from today).
|
|
910
|
+
var chipHtml;
|
|
911
|
+
var menu = '';
|
|
912
|
+
var script = '';
|
|
913
|
+
if (o.isOwner) {
|
|
914
|
+
chipHtml = '<span class="ash-chip ash-chip-own" data-ash-chip tabindex="0" role="button" aria-haspopup="menu" ' +
|
|
915
|
+
'style="color:' + chip.color + ';border-color:' + chip.color + '66">' +
|
|
916
|
+
'<span data-ash-chip-ic>' + chip.icon + '</span><span data-ash-label>' + chip.label + '</span>' + CARET_ICON + '</span>';
|
|
917
|
+
menu = renderVisibilityMenu(visibility, o.ownerDomain);
|
|
918
|
+
script = renderOwnerScript();
|
|
919
|
+
} else {
|
|
920
|
+
chipHtml = '<span class="ash-chip" style="color:' + chip.color + ';border-color:' + chip.color + '66">' + chip.icon + '<span>' + chip.label + '</span></span>';
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
var right = stats + chipHtml;
|
|
516
924
|
if (cm.date) right += '<span class="ash-date">' + escapeHtml(cm.date) + '</span>';
|
|
517
925
|
// The load-bearing background + base colour ride an INLINE style so the host
|
|
518
926
|
// page's own CSS can never wash the bar out (inline beats a page stylesheet);
|
|
@@ -530,6 +938,9 @@ function renderAttributionBar(meta, handle) {
|
|
|
530
938
|
'padding:8px 16px !important;background:#0b0b0c !important;color:#e8e8e8 !important;border-bottom:1px solid #23232a !important;box-shadow:0 1px 3px rgba(0,0,0,.35) !important;' +
|
|
531
939
|
'font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif !important}' +
|
|
532
940
|
'.agents-share-bar *{box-sizing:border-box;font-family:inherit}' +
|
|
941
|
+
'.agents-share-bar .ash-avatar{flex:none;position:relative;width:26px;height:26px;border-radius:50% !important;overflow:hidden;display:inline-flex;align-items:center;justify-content:center;color:#fff !important;font-weight:600;font-size:12px;line-height:1;box-shadow:inset 0 0 0 1px rgba(255,255,255,.14)}' +
|
|
942
|
+
'.agents-share-bar .ash-av-i{color:#fff !important}' +
|
|
943
|
+
'.agents-share-bar .ash-av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:50% !important}' +
|
|
533
944
|
'.agents-share-bar .ash-left{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#cfd3d9 !important}' +
|
|
534
945
|
'.agents-share-bar strong{color:#fff !important;font-weight:600}' +
|
|
535
946
|
'.agents-share-bar .ash-dot{opacity:.4;margin:0 4px}' +
|
|
@@ -537,14 +948,48 @@ function renderAttributionBar(meta, handle) {
|
|
|
537
948
|
'.agents-share-bar .ash-date{color:#9096a0 !important}' +
|
|
538
949
|
'.agents-share-bar .ash-chip{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;border:1px solid;font-weight:600;font-size:12px;white-space:nowrap}' +
|
|
539
950
|
'.agents-share-bar .ash-chip svg{flex:none;vertical-align:middle}' +
|
|
951
|
+
'.agents-share-bar .ash-stats{display:inline-flex;align-items:center;gap:5px;color:#9aa0a6 !important;font-size:12px;white-space:nowrap}' +
|
|
952
|
+
'.agents-share-bar .ash-stats b{color:#c9ced6 !important;font-weight:600}' +
|
|
953
|
+
'.agents-share-bar .ash-stats svg{flex:none;opacity:.85}' +
|
|
954
|
+
'.agents-share-bar .ash-statsep{opacity:.35;margin:0 3px}' +
|
|
955
|
+
(o.isOwner ? OWNER_BAR_CSS : '') +
|
|
540
956
|
'</style>' +
|
|
957
|
+
avatar +
|
|
541
958
|
'<span class="ash-left">' + left + '</span>' +
|
|
542
959
|
'<span class="ash-right">' + right + '</span>' +
|
|
543
|
-
|
|
960
|
+
menu +
|
|
961
|
+
(o.isOwner ? '<div class="ash-toast" data-ash-toast></div>' : '') +
|
|
962
|
+
'</div>' +
|
|
963
|
+
script;
|
|
544
964
|
}
|
|
545
965
|
|
|
546
|
-
|
|
547
|
-
|
|
966
|
+
// Owner-only chip/menu/toast styling, added to the bar's <style> only when the
|
|
967
|
+
// viewer owns the namespace. Namespaced under .agents-share-bar with !important
|
|
968
|
+
// so the host page's CSS can't wash it out; z-index rides the bar's own stacking.
|
|
969
|
+
var OWNER_BAR_CSS =
|
|
970
|
+
'.agents-share-bar .ash-chip-own{cursor:pointer;transition:background .12s}' +
|
|
971
|
+
'.agents-share-bar .ash-chip-own:hover{background:rgba(255,255,255,.06)}' +
|
|
972
|
+
'.agents-share-bar .ash-caret{opacity:.6;margin-left:1px;flex:none}' +
|
|
973
|
+
'.agents-share-bar .ash-menu{position:absolute !important;top:calc(100% + 6px);right:12px;min-width:286px;max-width:calc(100vw - 24px);background:#151517 !important;border:1px solid #2a2a30 !important;border-radius:12px;box-shadow:0 12px 34px rgba(0,0,0,.5);padding:6px;z-index:2147483647;display:none;text-align:left}' +
|
|
974
|
+
'.agents-share-bar .ash-menu.ash-open{display:block}' +
|
|
975
|
+
'.agents-share-bar .ash-menu h4{margin:6px 8px 8px;font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#7c8290 !important;font-weight:700}' +
|
|
976
|
+
'.agents-share-bar .ash-opt{display:flex;width:100%;text-align:left;align-items:flex-start;gap:10px;padding:8px;border:0;background:none;border-radius:8px;cursor:pointer;color:#e8e8e8 !important;font:inherit;font-size:13px}' +
|
|
977
|
+
'.agents-share-bar .ash-opt:hover{background:rgba(255,255,255,.05)}' +
|
|
978
|
+
'.agents-share-bar .ash-opt-ic{flex:none;width:26px;height:26px;border-radius:7px;display:inline-flex;align-items:center;justify-content:center;background:rgba(255,255,255,.06)}' +
|
|
979
|
+
'.agents-share-bar .ash-opt-t{flex:1;min-width:0}' +
|
|
980
|
+
'.agents-share-bar .ash-opt-t b{display:block;font-size:13px;font-weight:600;color:#f0f1f3 !important}' +
|
|
981
|
+
'.agents-share-bar .ash-opt-t span{display:block;font-size:11.5px;color:#9096a0 !important;line-height:1.35;margin-top:1px;white-space:normal}' +
|
|
982
|
+
'.agents-share-bar .ash-rd{flex:none;width:16px;height:16px;border-radius:50%;border:2px solid #4a4f59;margin-top:3px}' +
|
|
983
|
+
'.agents-share-bar .ash-opt.ash-sel .ash-rd{border-color:#5b9dff;background:radial-gradient(circle at center,#5b9dff 0 5px,transparent 6px)}' +
|
|
984
|
+
'.agents-share-bar .ash-toast{position:absolute !important;top:calc(100% + 6px);right:12px;max-width:calc(100vw - 24px);background:#151517 !important;border:1px solid #2a2a30 !important;color:#e8e8e8 !important;font-size:12px;padding:7px 12px;border-radius:9px;display:none;align-items:center;gap:7px;z-index:2147483647}' +
|
|
985
|
+
'.agents-share-bar .ash-toast.ash-show{display:inline-flex}' +
|
|
986
|
+
'.agents-share-bar .ash-spin{width:12px;height:12px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:ash-sp .6s linear infinite;display:inline-block;flex:none}' +
|
|
987
|
+
'@keyframes ash-sp{to{transform:rotate(360deg)}}' +
|
|
988
|
+
'.agents-share-bar .ash-ok{color:#30a46c;font-weight:700}' +
|
|
989
|
+
'.agents-share-bar .ash-err{color:#ff6b6b;font-weight:700}';
|
|
990
|
+
|
|
991
|
+
function injectAttributionBar(html, meta, handle, opts) {
|
|
992
|
+
var bar = renderAttributionBar(meta, handle, opts);
|
|
548
993
|
var m = /<body[^>]*>/i.exec(html);
|
|
549
994
|
if (m) {
|
|
550
995
|
var at = m.index + m[0].length;
|
|
@@ -553,6 +998,47 @@ function injectAttributionBar(html, meta, handle) {
|
|
|
553
998
|
return bar + html;
|
|
554
999
|
}
|
|
555
1000
|
|
|
1001
|
+
function acceptsHtml(request) {
|
|
1002
|
+
return (request.headers.get('accept') || '').indexOf('text/html') !== -1;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Assets we can embed in a viewer page. Everything else (JSON, .txt, arbitrary
|
|
1006
|
+
// downloads) is served raw — there is nothing useful to wrap it in.
|
|
1007
|
+
function viewableAssetKind(contentType) {
|
|
1008
|
+
var ct = (contentType || '').toLowerCase();
|
|
1009
|
+
if (ct.indexOf('image/') === 0) return 'image';
|
|
1010
|
+
if (ct.indexOf('video/') === 0) return 'video';
|
|
1011
|
+
if (ct.indexOf('audio/') === 0) return 'audio';
|
|
1012
|
+
if (ct.indexOf('application/pdf') === 0) return 'pdf';
|
|
1013
|
+
return '';
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// A minimal dark viewer page: the attribution bar on top, the asset centered
|
|
1017
|
+
// below. The media element points back at THIS url with ?raw so it loads the
|
|
1018
|
+
// stored bytes (not the viewer recursively) — and that ?raw fetch re-runs the
|
|
1019
|
+
// same me/org gate, so a private asset stays private inside its own viewer.
|
|
1020
|
+
function renderAssetViewer(pathname, kind, meta, handle, opts) {
|
|
1021
|
+
var rawUrl = escapeHtml(pathname + '?raw=1');
|
|
1022
|
+
var name = '';
|
|
1023
|
+
try { name = decodeURIComponent(pathname.split('/').pop() || 'file'); } catch (e) { name = pathname.split('/').pop() || 'file'; }
|
|
1024
|
+
var bar = renderAttributionBar(meta, handle, opts);
|
|
1025
|
+
var media = '';
|
|
1026
|
+
if (kind === 'image') media = '<img src="' + rawUrl + '" alt="' + escapeHtml(name) + '">';
|
|
1027
|
+
else if (kind === 'video') media = '<video src="' + rawUrl + '" controls playsinline></video>';
|
|
1028
|
+
else if (kind === 'audio') media = '<audio src="' + rawUrl + '" controls></audio>';
|
|
1029
|
+
else if (kind === 'pdf') media = '<iframe src="' + rawUrl + '" title="' + escapeHtml(name) + '"></iframe>';
|
|
1030
|
+
return '<!doctype html><html><head><meta charset="utf-8">' +
|
|
1031
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
1032
|
+
'<title>' + escapeHtml(name) + '</title>' +
|
|
1033
|
+
'<style>html,body{margin:0;background:#0f0f11}' +
|
|
1034
|
+
'body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box}' +
|
|
1035
|
+
'.ashv{max-width:100%;max-height:calc(100vh - 90px);display:flex;align-items:center;justify-content:center}' +
|
|
1036
|
+
'.ashv img,.ashv video{max-width:100%;max-height:calc(100vh - 90px);display:block;border-radius:8px;box-shadow:0 10px 40px rgba(0,0,0,.5)}' +
|
|
1037
|
+
'.ashv audio{width:min(90vw,560px)}' +
|
|
1038
|
+
'.ashv iframe{width:min(94vw,1000px);height:calc(100vh - 90px);border:0;border-radius:8px;background:#fff}' +
|
|
1039
|
+
'</style></head><body>' + bar + '<div class="ashv">' + media + '</div></body></html>';
|
|
1040
|
+
}
|
|
1041
|
+
|
|
556
1042
|
function json(body, status) {
|
|
557
1043
|
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
558
1044
|
}
|
|
@@ -654,19 +1140,47 @@ function isIdentityGated(visibility) {
|
|
|
654
1140
|
return visibility === 'me' || visibility === 'org';
|
|
655
1141
|
}
|
|
656
1142
|
|
|
657
|
-
|
|
1143
|
+
// Gate a me/org read given the ALREADY-RESOLVED viewer identity. Pure/sync: the
|
|
1144
|
+
// caller resolves the viewer once (it also needs the identity for the ownership
|
|
1145
|
+
// check) and both the page GET and the ?revisions=json path share this gate.
|
|
1146
|
+
function gateVisibility(url, env, obj, identity) {
|
|
658
1147
|
const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
|
|
659
1148
|
if (!isIdentityGated(visibility)) return null;
|
|
660
|
-
|
|
661
|
-
if (
|
|
662
|
-
if (viewer.error) return viewer.error;
|
|
663
|
-
if (!viewer.identity) return bounceToLogin(url, env);
|
|
664
|
-
if (!viewerMayRead(visibility, obj.customMetadata, viewer.identity)) {
|
|
1149
|
+
if (!identity) return bounceToLogin(url, env);
|
|
1150
|
+
if (!viewerMayRead(visibility, obj.customMetadata, identity)) {
|
|
665
1151
|
return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
666
1152
|
}
|
|
667
1153
|
return null;
|
|
668
1154
|
}
|
|
669
1155
|
|
|
1156
|
+
// Per-slug view counter, stored as a SEPARATE R2 object under __views/<path> so
|
|
1157
|
+
// counting a view never rewrites the page object (which would reset its uploaded
|
|
1158
|
+
// timestamp and corrupt "last updated"). Best-effort telemetry: a read/write
|
|
1159
|
+
// failure must never break page serving, so both degrade to a no-op rather than
|
|
1160
|
+
// throwing. R2 has no atomic increment, so two simultaneous views can race and
|
|
1161
|
+
// lose a count — acceptable for an approximate visitor count.
|
|
1162
|
+
async function readViews(env, path) {
|
|
1163
|
+
try {
|
|
1164
|
+
const obj = await env.BUCKET.get('__views/' + path);
|
|
1165
|
+
if (!obj) return 0;
|
|
1166
|
+
const raw = typeof obj.text === 'function' ? await obj.text() : '';
|
|
1167
|
+
const n = parseInt(raw, 10);
|
|
1168
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
1169
|
+
} catch {
|
|
1170
|
+
return 0;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
async function writeViews(env, path, count) {
|
|
1175
|
+
try {
|
|
1176
|
+
await env.BUCKET.put('__views/' + path, String(count), {
|
|
1177
|
+
httpMetadata: { contentType: 'text/plain; charset=utf-8' },
|
|
1178
|
+
});
|
|
1179
|
+
} catch {
|
|
1180
|
+
// best-effort: a failed counter write must never surface to the visitor
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
670
1184
|
function viewerMayRead(visibility, meta, identity) {
|
|
671
1185
|
if (visibility === 'me') {
|
|
672
1186
|
const owner = meta && meta.owner;
|
|
@@ -838,17 +1352,28 @@ async function defaultVerifyPhoenixToken(request, env) {
|
|
|
838
1352
|
return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
|
|
839
1353
|
}
|
|
840
1354
|
|
|
841
|
-
//
|
|
842
|
-
//
|
|
1355
|
+
// Write principals, in order: WRITE_TOKEN equality (admin/BYO), then a Phoenix
|
|
1356
|
+
// bearer, then the signed-in viewer's __share cookie. The cookie is an
|
|
1357
|
+
// HMAC-signed {userId,email} proof (identityFromCookie verifies it), which lets
|
|
1358
|
+
// the shared page's inline visibility control PATCH with credentials:'include'
|
|
1359
|
+
// and no bearer — the browser has the identity cookie, not a token. It cannot be
|
|
1360
|
+
// abused cross-site: the cookie is SameSite=Lax, which blocks it from riding a
|
|
1361
|
+
// cross-origin non-GET (CSRF), and namespace/owner enforcement on PUT/PATCH/
|
|
1362
|
+
// DELETE still confines a cookie holder to their own pages. 401 when none apply.
|
|
843
1363
|
async function authorizeWrite(request, env) {
|
|
844
1364
|
const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
|
|
845
|
-
if (
|
|
846
|
-
|
|
847
|
-
|
|
1365
|
+
if (presented) {
|
|
1366
|
+
if (env.WRITE_TOKEN && safeEqual(presented, env.WRITE_TOKEN)) {
|
|
1367
|
+
return { kind: 'byo', owner: env.SHARE_NAMESPACE || 'byo' };
|
|
1368
|
+
}
|
|
1369
|
+
const claims = await hooks.verifyPhoenixToken(request, env);
|
|
1370
|
+
if (claims && typeof claims.userId === 'string' && claims.userId) {
|
|
1371
|
+
return { kind: 'phoenix', owner: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' };
|
|
1372
|
+
}
|
|
848
1373
|
}
|
|
849
|
-
const
|
|
850
|
-
if (
|
|
851
|
-
return { kind: 'phoenix', owner:
|
|
1374
|
+
const cookieId = await identityFromCookie(request, env);
|
|
1375
|
+
if (cookieId && cookieId.userId) {
|
|
1376
|
+
return { kind: 'phoenix', owner: cookieId.userId, email: typeof cookieId.email === 'string' ? cookieId.email : '' };
|
|
852
1377
|
}
|
|
853
1378
|
return { error: json({ error: 'unauthorized' }, 401) };
|
|
854
1379
|
}
|