@phnx-labs/agents-cli 1.22.53 → 1.22.55
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 +208 -0
- package/README.md +59 -9
- 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/routines.js +31 -2
- 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 +79 -3
- package/dist/commands/share.js +347 -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 +11 -1
- package/dist/commands/view.js +35 -7
- 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 +68 -1
- package/dist/lib/accounting/usage.js +116 -10
- 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/cdp.d.ts +1 -1
- package/dist/lib/browser/cdp.js +1 -1
- package/dist/lib/browser/ffmpeg.d.ts +12 -0
- package/dist/lib/browser/ffmpeg.js +184 -0
- package/dist/lib/browser/remote-control.d.ts +9 -7
- package/dist/lib/browser/remote-control.js +9 -7
- package/dist/lib/browser/service.js +119 -25
- 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/browser-task-reap-service.d.ts +14 -0
- package/dist/lib/daemon/browser-task-reap-service.js +26 -0
- package/dist/lib/daemon/daemon.js +87 -176
- package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
- package/dist/lib/daemon/heartbeat-service.js +26 -0
- package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
- package/dist/lib/daemon/monitor-engine-service.js +15 -7
- package/dist/lib/daemon/runner.d.ts +15 -0
- package/dist/lib/daemon/runner.js +23 -0
- package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
- package/dist/lib/daemon/secrets-broker-service.js +17 -32
- package/dist/lib/daemon/service.d.ts +2 -2
- package/dist/lib/daemon/service.js +1 -1
- package/dist/lib/daemon/session-state-service.d.ts +21 -0
- package/dist/lib/daemon/session-state-service.js +34 -0
- package/dist/lib/daemon/supervisor.d.ts +17 -7
- package/dist/lib/daemon/supervisor.js +87 -14
- package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
- package/dist/lib/daemon/tmux-reap-service.js +28 -0
- package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
- package/dist/lib/daemon/webhook-receiver-service.js +17 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +25 -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/devices/harness-inventory.js +5 -2
- 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.d.ts +5 -1
- package/dist/lib/monitors/engine.js +13 -4
- 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/scheduling/routines.d.ts +47 -0
- package/dist/lib/scheduling/routines.js +70 -1
- 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 +41 -5
- package/dist/lib/session/db.js +132 -30
- 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/capture.js +11 -2
- package/dist/lib/share/publish.d.ts +56 -5
- package/dist/lib/share/publish.js +126 -18
- package/dist/lib/share/worker-template.d.ts +3 -12
- package/dist/lib/share/worker-template.js +860 -59
- 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 +7 -0
- package/package.json +9 -2
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { buildSync } from 'esbuild';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
1
4
|
// The Cloudflare Worker that fronts the R2 share bucket.
|
|
2
5
|
//
|
|
3
6
|
// One tiny Worker does both sides:
|
|
4
|
-
// - PUT /<username>/<slug> —
|
|
7
|
+
// - PUT /<username>/<slug> — write-gated by authorizeWrite's THREE principals
|
|
8
|
+
// (not a fallback chain — each is a distinct legitimate identity):
|
|
5
9
|
// 1. Static WRITE_TOKEN (BYO Cloudflare) — checked first when the presented
|
|
6
10
|
// bearer equals env.WRITE_TOKEN. owner = env.SHARE_NAMESPACE or the
|
|
7
11
|
// path's first segment.
|
|
@@ -12,8 +16,14 @@
|
|
|
12
16
|
// so one user cannot write another's prefix (403 namespace mismatch).
|
|
13
17
|
// A __handles/<handle> claim binds the handle to the first userId that
|
|
14
18
|
// writes it; a different userId gets 409 handle taken.
|
|
19
|
+
// 3. __share HMAC cookie — the signed-in viewer's identity cookie (same
|
|
20
|
+
// {userId,email} identityFromCookie verifies for GET). Lets the shared
|
|
21
|
+
// page's inline visibility control PATCH with credentials:'include' and
|
|
22
|
+
// no bearer; SameSite=Lax blocks it cross-site, and the same namespace/
|
|
23
|
+
// owner checks confine it to the holder's own pages. Applies to PUT,
|
|
24
|
+
// PATCH, and DELETE alike, since all three share authorizeWrite.
|
|
15
25
|
// A managed deployment sets PHOENIX_ID_BASE; BYO sets WRITE_TOKEN; the
|
|
16
|
-
// platform endpoint may set both. Fail loud (401) when
|
|
26
|
+
// platform endpoint may set both. Fail loud (401) when none authenticates.
|
|
17
27
|
// Writes the body to R2 via the BUCKET binding, storing visibility
|
|
18
28
|
// (public|unlisted|me|org), owner, org_domain (org only), an optional
|
|
19
29
|
// expires-at, plus provenance (agent/session/host/repo/date), a label, and
|
|
@@ -22,6 +32,13 @@
|
|
|
22
32
|
// domain is 400. Overwriting an existing slug first copies the current
|
|
23
33
|
// object to <slug>/rev-<ts>-<rand> (revision history) unless
|
|
24
34
|
// x-share-no-revision is set.
|
|
35
|
+
// - PATCH /<username>/<slug> — authenticated metadata-only edit. Rewrites the
|
|
36
|
+
// exact existing body with all HTTP/custom metadata preserved except the
|
|
37
|
+
// explicitly requested label/arbitrary metadata changes; never revisions.
|
|
38
|
+
// Conditional put (onlyIf etagMatches) so a concurrent republish 409s
|
|
39
|
+
// instead of rolling the body back. Phoenix requires customMetadata.owner
|
|
40
|
+
// === auth.owner (fail closed when the stamp is missing); WRITE_TOKEN is
|
|
41
|
+
// the admin repair path.
|
|
25
42
|
// - GET /<username>/<slug> — public|unlisted are anonymous; me requires the
|
|
26
43
|
// Phoenix owner, org requires a same-domain Phoenix identity (Bearer, then
|
|
27
44
|
// HMAC cookie, then phoenix_ticket). Unauthenticated me/org 302s to
|
|
@@ -54,15 +71,52 @@
|
|
|
54
71
|
* until its owner re-runs `agents artifacts share update`. Cosmetic renames are not
|
|
55
72
|
* worth that; change this text only alongside a real Worker behavior change.
|
|
56
73
|
*/
|
|
74
|
+
let bundledWorkerScript;
|
|
75
|
+
/** Bundle the Worker and its renderer into one uploadable ES module. */
|
|
57
76
|
export function renderWorkerScript() {
|
|
58
|
-
|
|
77
|
+
if (bundledWorkerScript)
|
|
78
|
+
return bundledWorkerScript;
|
|
79
|
+
const result = buildSync({
|
|
80
|
+
stdin: {
|
|
81
|
+
contents: renderWorkerSource(),
|
|
82
|
+
loader: 'js',
|
|
83
|
+
resolveDir: dirname(fileURLToPath(import.meta.url)),
|
|
84
|
+
sourcefile: 'agents-share-worker.js',
|
|
85
|
+
},
|
|
86
|
+
bundle: true,
|
|
87
|
+
format: 'esm',
|
|
88
|
+
platform: 'browser',
|
|
89
|
+
target: 'es2022',
|
|
90
|
+
write: false,
|
|
91
|
+
minify: true,
|
|
92
|
+
loader: { '.wasm': 'binary', '.woff': 'binary' },
|
|
93
|
+
});
|
|
94
|
+
const output = result.outputFiles[0];
|
|
95
|
+
if (!output)
|
|
96
|
+
throw new Error('Worker bundling produced no JavaScript output.');
|
|
97
|
+
bundledWorkerScript = output.text;
|
|
98
|
+
return bundledWorkerScript;
|
|
99
|
+
}
|
|
100
|
+
/** Unbundled Worker source. Kept separate so esbuild can resolve npm modules. */
|
|
101
|
+
export function renderWorkerSource() {
|
|
102
|
+
return `import satori, { init as initSatori } from 'satori/wasm';
|
|
103
|
+
import initYoga from 'yoga-wasm-web';
|
|
104
|
+
import { Resvg, initWasm as initResvg } from '@resvg/resvg-wasm';
|
|
105
|
+
import yogaWasm from 'yoga-wasm-web/dist/yoga.wasm';
|
|
106
|
+
import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm';
|
|
107
|
+
import interRegular from '@fontsource/inter/files/inter-latin-400-normal.woff';
|
|
108
|
+
import interBold from '@fontsource/inter/files/inter-latin-700-normal.woff';
|
|
109
|
+
import jetbrainsMono from '@fontsource/jetbrains-mono/files/jetbrains-mono-latin-600-normal.woff';
|
|
110
|
+
|
|
111
|
+
// GENERATED by agents-cli agents share setup — do not edit here; edit
|
|
59
112
|
// src/lib/share/worker-template.ts and re-run setup.
|
|
60
113
|
export const hooks = {
|
|
61
114
|
verifyPhoenixToken: defaultVerifyPhoenixToken,
|
|
115
|
+
renderOgCard: renderOgCard,
|
|
62
116
|
};
|
|
63
117
|
|
|
64
118
|
export default {
|
|
65
|
-
async fetch(request, env) {
|
|
119
|
+
async fetch(request, env, ctx) {
|
|
66
120
|
const url = new URL(request.url);
|
|
67
121
|
const path = decodeURIComponent(url.pathname.replace(/^\\/+/, ''));
|
|
68
122
|
|
|
@@ -77,6 +131,21 @@ export default {
|
|
|
77
131
|
return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
78
132
|
}
|
|
79
133
|
|
|
134
|
+
// POST /__ticket — mint a short-lived, single-navigation login ticket for the
|
|
135
|
+
// authenticated owner (PHNX-3370). The CLI (which holds the Phoenix bearer)
|
|
136
|
+
// calls this, then opens the owner's page with ?phoenix_ticket=<t> so the
|
|
137
|
+
// browser trades it for the __share cookie and the inline visibility control
|
|
138
|
+
// goes live. The ticket is SELF-signed with the Worker's own HMAC secret — no
|
|
139
|
+
// external ticket service — and carries the SAME identity authorizeWrite
|
|
140
|
+
// already proved, so it grants nothing the caller didn't already have.
|
|
141
|
+
if (request.method === 'POST' && firstSeg === '__ticket') {
|
|
142
|
+
const auth = await authorizeWrite(request, env);
|
|
143
|
+
if (auth.error) return auth.error;
|
|
144
|
+
const ticket = await signSelfTicket({ userId: auth.owner, email: auth.email || '' }, env);
|
|
145
|
+
if (!ticket) return json({ error: 'ticket minting is not configured' }, 501);
|
|
146
|
+
return json({ ticket: ticket }, 200);
|
|
147
|
+
}
|
|
148
|
+
|
|
80
149
|
if (request.method === 'PUT') {
|
|
81
150
|
const auth = await authorizeWrite(request, env);
|
|
82
151
|
if (auth.error) return auth.error;
|
|
@@ -119,21 +188,60 @@ export default {
|
|
|
119
188
|
// never smuggle through on a publish that carries no agent/session/host/
|
|
120
189
|
// repo/date at all — e.g. a human publishing outside an agent session, or
|
|
121
190
|
// outside a git checkout.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
191
|
+
// Full-Unicode headers (PHNX-2786): the CLI always sends the latin1-safe
|
|
192
|
+
// x-share-<field> (what a pre-Unicode Worker read), and when the fold was
|
|
193
|
+
// lossy ALSO a percent-encoded x-share-<field>-u companion plus an
|
|
194
|
+
// x-share-encoding: percent opt-in. Prefer the decoded companion when the
|
|
195
|
+
// opt-in is present, so a Japanese/emoji title/label/--meta renders in full
|
|
196
|
+
// instead of (unnamed) or a dropped glyph; a malformed companion falls
|
|
197
|
+
// back to the folded value rather than failing the publish.
|
|
198
|
+
const usePercent = (request.headers.get('x-share-encoding') || '') === 'percent';
|
|
199
|
+
const readText = (name) => {
|
|
200
|
+
const folded = request.headers.get('x-share-' + name) || '';
|
|
201
|
+
if (usePercent) {
|
|
202
|
+
const encoded = request.headers.get('x-share-' + name + '-u');
|
|
203
|
+
if (encoded) {
|
|
204
|
+
try {
|
|
205
|
+
return decodeURIComponent(encoded);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return folded;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return folded;
|
|
212
|
+
};
|
|
213
|
+
const agent = readText('agent');
|
|
214
|
+
const session = readText('session');
|
|
215
|
+
const host = readText('host');
|
|
216
|
+
const repo = readText('repo');
|
|
217
|
+
const date = readText('date');
|
|
218
|
+
const avatar = readText('avatar');
|
|
219
|
+
const label = readText('label');
|
|
220
|
+
const ogTitle = readText('og-title');
|
|
221
|
+
const ogDescription = readText('og-description');
|
|
128
222
|
const labelSource = request.headers.get('x-share-label-source') || '';
|
|
129
223
|
let extraMeta = {};
|
|
130
|
-
|
|
131
|
-
|
|
224
|
+
// The -u meta companion carries the whole raw object percent-encoded, so a
|
|
225
|
+
// single decode+parse recovers full-Unicode keys and values; fall back to
|
|
226
|
+
// the per-value-folded x-share-meta when it is absent or malformed.
|
|
227
|
+
const metaEncoded = usePercent ? request.headers.get('x-share-meta-u') : null;
|
|
228
|
+
if (metaEncoded) {
|
|
132
229
|
try {
|
|
133
|
-
const parsed = JSON.parse(
|
|
230
|
+
const parsed = JSON.parse(decodeURIComponent(metaEncoded));
|
|
134
231
|
if (parsed && typeof parsed === 'object') extraMeta = parsed;
|
|
135
|
-
} catch {
|
|
136
|
-
// malformed
|
|
232
|
+
} catch (e) {
|
|
233
|
+
// malformed unicode meta companion — fall through to the folded header
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (!Object.keys(extraMeta).length) {
|
|
237
|
+
const metaHeader = request.headers.get('x-share-meta');
|
|
238
|
+
if (metaHeader) {
|
|
239
|
+
try {
|
|
240
|
+
const parsed = JSON.parse(metaHeader);
|
|
241
|
+
if (parsed && typeof parsed === 'object') extraMeta = parsed;
|
|
242
|
+
} catch {
|
|
243
|
+
// malformed --meta header — ignore rather than fail the whole publish
|
|
244
|
+
}
|
|
137
245
|
}
|
|
138
246
|
}
|
|
139
247
|
const customMetadata = { ...extraMeta };
|
|
@@ -156,8 +264,11 @@ export default {
|
|
|
156
264
|
if (host) customMetadata['host'] = host;
|
|
157
265
|
if (repo) customMetadata['repo'] = repo;
|
|
158
266
|
if (date) customMetadata['date'] = date;
|
|
267
|
+
if (avatar) customMetadata['avatar'] = avatar;
|
|
159
268
|
if (label) customMetadata['label'] = label;
|
|
160
269
|
if (labelSource) customMetadata['label-source'] = labelSource;
|
|
270
|
+
if (ogTitle) customMetadata['og-title'] = ogTitle;
|
|
271
|
+
if (ogDescription) customMetadata['og-description'] = ogDescription;
|
|
161
272
|
|
|
162
273
|
// Revision retention (RUSH-2683): R2 has no native object versioning, so a
|
|
163
274
|
// republish over an EXISTING key first copies the current object to
|
|
@@ -186,9 +297,114 @@ export default {
|
|
|
186
297
|
httpMetadata: { contentType },
|
|
187
298
|
customMetadata,
|
|
188
299
|
});
|
|
300
|
+
// A managed republish may change the title/description. Invalidate only
|
|
301
|
+
// its generated sibling so the next crawler receives a fresh card; BYO
|
|
302
|
+
// publishes send no OG metadata and keep their explicitly uploaded cover.
|
|
303
|
+
if (ogTitle) await env.BUCKET.delete(path + '.png');
|
|
189
304
|
return json({ ok: true, url: url.origin + '/' + path, expiresAt: expiresAt || null, unlisted: visibility === 'unlisted', visibility }, 200);
|
|
190
305
|
}
|
|
191
306
|
|
|
307
|
+
if (request.method === 'PATCH') {
|
|
308
|
+
const auth = await authorizeWrite(request, env);
|
|
309
|
+
if (auth.error) return auth.error;
|
|
310
|
+
const segments = path.split('/').filter(Boolean);
|
|
311
|
+
if (segments.length < 2) return json({ error: 'metadata edit requires /<username>/<slug>' }, 400);
|
|
312
|
+
if (auth.kind === 'phoenix') {
|
|
313
|
+
const expected = phoenixHandle(auth);
|
|
314
|
+
if (!expected || segments[0] !== expected) return json({ error: 'namespace mismatch', owner: expected }, 403);
|
|
315
|
+
}
|
|
316
|
+
const existing = await env.BUCKET.get(path);
|
|
317
|
+
if (!existing) return json({ error: 'share not found', key: path }, 404);
|
|
318
|
+
const owner = existing.customMetadata && existing.customMetadata.owner;
|
|
319
|
+
// WRITE_TOKEN is the endpoint owner/admin credential (the same authority
|
|
320
|
+
// the DELETE path grants it). Phoenix must prove ownership — fail closed
|
|
321
|
+
// when the object has no owner stamp (handles collide after sanitization).
|
|
322
|
+
if (auth.kind === 'phoenix' && (!owner || owner !== auth.owner)) return json({ error: 'forbidden' }, 403);
|
|
323
|
+
|
|
324
|
+
let edit;
|
|
325
|
+
try { edit = await request.json(); } catch { return json({ error: 'PATCH body must be JSON' }, 400); }
|
|
326
|
+
if (!edit || typeof edit !== 'object') return json({ error: 'PATCH body must be an object' }, 400);
|
|
327
|
+
const metadata = { ...(existing.customMetadata || {}) };
|
|
328
|
+
const previousVisibility = metadata.visibility || 'public';
|
|
329
|
+
let visibilityChanged = false;
|
|
330
|
+
if (Object.prototype.hasOwnProperty.call(edit, 'label')) {
|
|
331
|
+
if (edit.label !== null && typeof edit.label !== 'string') return json({ error: 'label must be a string or null' }, 400);
|
|
332
|
+
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);
|
|
333
|
+
if (edit.label === null) { delete metadata.label; delete metadata['label-source']; }
|
|
334
|
+
else { metadata.label = edit.label; metadata['label-source'] = 'explicit'; }
|
|
335
|
+
}
|
|
336
|
+
// Visibility is a first-class edit field (like label), not an arbitrary
|
|
337
|
+
// --meta entry — 'visibility' is a RESERVED key, so it can only be changed
|
|
338
|
+
// here, never smuggled through edit.meta. me/org require a Phoenix identity
|
|
339
|
+
// and org additionally requires a private (non-public-inbox) email domain,
|
|
340
|
+
// the SAME gate PUT enforces. This is a metadata-only rewrite: the body is
|
|
341
|
+
// untouched, so no revision is created (identical to a label/meta edit).
|
|
342
|
+
if (Object.prototype.hasOwnProperty.call(edit, 'visibility')) {
|
|
343
|
+
const vis = normalizeVisibility(edit.visibility);
|
|
344
|
+
if (vis.error) return vis.error;
|
|
345
|
+
const visibility = vis.value;
|
|
346
|
+
if (visibility === 'me' || visibility === 'org') {
|
|
347
|
+
const phoenixBase = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
|
|
348
|
+
if (auth.kind !== 'phoenix' || !phoenixBase) return json({ error: 'visibility me/org requires Phoenix identity' }, 400);
|
|
349
|
+
if (visibility === 'org') {
|
|
350
|
+
const domain = emailDomain(auth.email);
|
|
351
|
+
if (!domain) return json({ error: 'org visibility requires a verified email domain' }, 400);
|
|
352
|
+
if (PUBLIC_INBOX_DOMAINS.indexOf(domain) !== -1) return json({ error: 'org visibility cannot use a public email domain', domain: domain }, 400);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
metadata.visibility = visibility;
|
|
356
|
+
visibilityChanged = visibility !== previousVisibility;
|
|
357
|
+
// org_domain is meaningful only for org — set it there, drop the stale
|
|
358
|
+
// value on any move away from org so it can never gate a later read.
|
|
359
|
+
if (visibility === 'org') metadata.org_domain = emailDomain(auth.email);
|
|
360
|
+
else delete metadata.org_domain;
|
|
361
|
+
}
|
|
362
|
+
const mode = edit.metaMode || 'merge';
|
|
363
|
+
if (mode !== 'merge' && mode !== 'replace') return json({ error: 'metaMode must be merge or replace' }, 400);
|
|
364
|
+
const incoming = edit.meta || {};
|
|
365
|
+
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return json({ error: 'meta must be an object' }, 400);
|
|
366
|
+
for (const key in incoming) {
|
|
367
|
+
if (RESERVED_METADATA_KEYS.indexOf(key) !== -1) return json({ error: 'reserved metadata key', key: key }, 400);
|
|
368
|
+
if (!/^[a-z0-9-]{1,64}$/.test(key) || typeof incoming[key] !== 'string') return json({ error: 'invalid metadata entry', key: key }, 400);
|
|
369
|
+
}
|
|
370
|
+
if (mode === 'replace') {
|
|
371
|
+
for (const key in metadata) if (RESERVED_METADATA_KEYS.indexOf(key) === -1) delete metadata[key];
|
|
372
|
+
}
|
|
373
|
+
for (const key in incoming) metadata[key] = incoming[key];
|
|
374
|
+
const remove = edit.removeMeta || [];
|
|
375
|
+
if (!Array.isArray(remove)) return json({ error: 'removeMeta must be an array' }, 400);
|
|
376
|
+
for (const key of remove) {
|
|
377
|
+
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);
|
|
378
|
+
delete metadata[key];
|
|
379
|
+
}
|
|
380
|
+
if (new TextEncoder().encode(JSON.stringify(extraMetaOf(metadata))).length > 2048) return json({ error: 'metadata exceeds 2048 bytes' }, 400);
|
|
381
|
+
// R2 assigns a fresh uploaded timestamp to every put. Preserve the
|
|
382
|
+
// canonical publication time before this metadata-only rewrite so gallery
|
|
383
|
+
// ordering and list JSON do not pretend the page was republished today.
|
|
384
|
+
if (!metadata['published-at']) metadata['published-at'] = new Date(existing.uploaded).toISOString();
|
|
385
|
+
const httpHeaders = new Headers();
|
|
386
|
+
if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(httpHeaders);
|
|
387
|
+
const putOpts = { httpMetadata: httpHeaders, customMetadata: metadata };
|
|
388
|
+
// onlyIf.etagMatches wants the bare hash (R2Object#etag), not the
|
|
389
|
+
// quoted HTTP header form (R2Object#httpEtag) — passing httpEtag 500s
|
|
390
|
+
// on a real Workers R2 binding ("Conditional ETag should not be
|
|
391
|
+
// wrapped in quotes"), a defect only a real R2 backend surfaces.
|
|
392
|
+
if (existing.etag) putOpts.onlyIf = { etagMatches: existing.etag };
|
|
393
|
+
const putResult = await env.BUCKET.put(path, existing.body, putOpts);
|
|
394
|
+
if (putResult === null) return json({ error: 'conflict', key: path }, 409);
|
|
395
|
+
// A generated cover copies the canonical visibility gate into its own R2
|
|
396
|
+
// metadata. Invalidate it after a successful visibility rewrite so the
|
|
397
|
+
// next cover GET re-gates and renders from the canonical page. Explicitly
|
|
398
|
+
// uploaded BYO siblings carry no marker and remain independent assets.
|
|
399
|
+
if (visibilityChanged) {
|
|
400
|
+
const cover = await env.BUCKET.get(path + '.png');
|
|
401
|
+
if (cover && cover.customMetadata && cover.customMetadata['og-generated'] === 'true') {
|
|
402
|
+
await env.BUCKET.delete(path + '.png');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return json({ ok: true, url: url.origin + '/' + path, label: metadata.label || null, meta: extraMetaOf(metadata), visibility: metadata.visibility || 'public', previousVisibility: previousVisibility }, 200);
|
|
406
|
+
}
|
|
407
|
+
|
|
192
408
|
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
193
409
|
// Single-segment path may be a user gallery/listing OR a legacy flat slug.
|
|
194
410
|
// The disambiguator is whether the <seg>/ prefix holds any object: only a
|
|
@@ -202,9 +418,12 @@ export default {
|
|
|
202
418
|
const list = await env.BUCKET.list({ prefix: segments[0] + '/', limit: 1 });
|
|
203
419
|
const isNamespace = list.objects && list.objects.length > 0;
|
|
204
420
|
if (isNamespace) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
421
|
+
if (url.searchParams.get('format') === 'json') {
|
|
422
|
+
const scope = await resolveListingScope(request, env, segments[0]);
|
|
423
|
+
if (scope.error) return scope.error;
|
|
424
|
+
return renderListing(env.BUCKET, url.origin, segments[0], request.method, scope.includeHidden);
|
|
425
|
+
}
|
|
426
|
+
return renderGallery(env.BUCKET, url.origin, segments[0], request.method);
|
|
208
427
|
}
|
|
209
428
|
// Not a namespace: fall through. A legacy flat-slug object resolves to its
|
|
210
429
|
// real content; anything else 404s (an empty/nonexistent namespace, which
|
|
@@ -219,8 +438,15 @@ export default {
|
|
|
219
438
|
// ?revisions=json must not leak that the page exists.
|
|
220
439
|
if (segments.length === 2 && url.searchParams.get('revisions') === 'json') {
|
|
221
440
|
const canonical = await env.BUCKET.get(path);
|
|
222
|
-
|
|
223
|
-
|
|
441
|
+
// Only a me/org canonical needs the viewer resolved — and only there does
|
|
442
|
+
// a phoenix_ticket failure gate the response, exactly as before this route
|
|
443
|
+
// shared resolveViewer with the page GET. A public/unlisted revision list
|
|
444
|
+
// never invoked ticket redemption, so a stale ticket must not 401 it.
|
|
445
|
+
if (canonical && isIdentityGated((canonical.customMetadata && canonical.customMetadata.visibility) || 'public')) {
|
|
446
|
+
const viewer = await resolveViewer(request, env, url);
|
|
447
|
+
if (viewer.redirect) return viewer.redirect;
|
|
448
|
+
if (viewer.error) return viewer.error;
|
|
449
|
+
const denied = gateVisibility(url, env, canonical, viewer.identity || null);
|
|
224
450
|
if (denied) return denied;
|
|
225
451
|
}
|
|
226
452
|
return renderRevisions(
|
|
@@ -232,6 +458,70 @@ export default {
|
|
|
232
458
|
);
|
|
233
459
|
}
|
|
234
460
|
|
|
461
|
+
// Managed OG cover: generated siblings are caches, never visibility
|
|
462
|
+
// authorities. Gate every request against the current canonical page and
|
|
463
|
+
// bind each cached render to that page's etag. Re-read after rendering and
|
|
464
|
+
// after storing so a concurrent PATCH cannot publish a card from the old
|
|
465
|
+
// visibility/title/description snapshot. Explicit BYO siblings carry no
|
|
466
|
+
// generated marker and continue through the ordinary object route below.
|
|
467
|
+
if (segments.length === 2 && path.endsWith('.png')) {
|
|
468
|
+
let existingCover = await env.BUCKET.get(path);
|
|
469
|
+
if (!existingCover || (existingCover.customMetadata && existingCover.customMetadata['og-generated'] === 'true')) {
|
|
470
|
+
const pagePath = path.slice(0, -4);
|
|
471
|
+
const viewer = await resolveViewer(request, env, url);
|
|
472
|
+
if (viewer.redirect) return viewer.redirect;
|
|
473
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
474
|
+
const page = await env.BUCKET.get(pagePath);
|
|
475
|
+
if (!page) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
476
|
+
const pageVisibility = (page.customMetadata && page.customMetadata.visibility) || 'public';
|
|
477
|
+
if (viewer.error && isIdentityGated(pageVisibility)) return viewer.error;
|
|
478
|
+
const denied = gateVisibility(url, env, page, viewer.identity || null);
|
|
479
|
+
if (denied) return denied;
|
|
480
|
+
|
|
481
|
+
if (existingCover && existingCover.customMetadata['og-source-etag'] === page.etag) {
|
|
482
|
+
const current = await env.BUCKET.get(pagePath);
|
|
483
|
+
if (!current || current.etag !== page.etag) { existingCover = null; continue; }
|
|
484
|
+
const currentDenied = gateVisibility(url, env, current, viewer.identity || null);
|
|
485
|
+
if (currentDenied) return currentDenied;
|
|
486
|
+
return new Response(request.method === 'HEAD' ? null : existingCover.body, {
|
|
487
|
+
status: 200,
|
|
488
|
+
headers: managedCoverHeaders(pageVisibility),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const pageHtml = await page.text();
|
|
493
|
+
const meta = page.customMetadata || {};
|
|
494
|
+
const png = await hooks.renderOgCard({
|
|
495
|
+
title: meta['og-title'] || extractHtmlMeta(pageHtml, 'title') || meta.label || segments[1],
|
|
496
|
+
description: meta['og-description'] || extractHtmlMeta(pageHtml, 'description') || '',
|
|
497
|
+
handle: segments[0],
|
|
498
|
+
visibility: pageVisibility,
|
|
499
|
+
orgDomain: meta.org_domain || '',
|
|
500
|
+
});
|
|
501
|
+
const beforeStore = await env.BUCKET.get(pagePath);
|
|
502
|
+
if (!beforeStore || beforeStore.etag !== page.etag) { existingCover = null; continue; }
|
|
503
|
+
await env.BUCKET.put(path, png, {
|
|
504
|
+
httpMetadata: { contentType: 'image/png' },
|
|
505
|
+
customMetadata: { ...meta, 'og-generated': 'true', 'og-source-etag': page.etag },
|
|
506
|
+
});
|
|
507
|
+
const beforeServe = await env.BUCKET.get(pagePath);
|
|
508
|
+
if (!beforeServe || beforeServe.etag !== page.etag) {
|
|
509
|
+
const justStored = await env.BUCKET.get(path);
|
|
510
|
+
if (justStored && justStored.customMetadata && justStored.customMetadata['og-source-etag'] === page.etag) {
|
|
511
|
+
await env.BUCKET.delete(path);
|
|
512
|
+
}
|
|
513
|
+
existingCover = null;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
return new Response(request.method === 'HEAD' ? null : png, {
|
|
517
|
+
status: 200,
|
|
518
|
+
headers: managedCoverHeaders(pageVisibility),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
return new Response('cover changed during rendering; retry', { status: 503, headers: { 'content-type': 'text/plain', 'retry-after': '1' } });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
235
525
|
const obj = await env.BUCKET.get(path);
|
|
236
526
|
if (!obj) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
237
527
|
const expiresAt = obj.customMetadata && obj.customMetadata['expires-at'];
|
|
@@ -239,9 +529,24 @@ export default {
|
|
|
239
529
|
await env.BUCKET.delete(path);
|
|
240
530
|
return new Response('gone — this link has expired', { status: 410, headers: { 'content-type': 'text/plain' } });
|
|
241
531
|
}
|
|
242
|
-
|
|
243
|
-
|
|
532
|
+
// Resolve the viewer ONCE — needed both to gate me/org reads AND to decide
|
|
533
|
+
// whether THIS viewer OWNS this namespace (the interactive visibility
|
|
534
|
+
// control below). A successful phoenix_ticket redemption (redirect: set the
|
|
535
|
+
// identity cookie, strip the ticket) is honored on ANY page. But a ticket
|
|
536
|
+
// FAILURE (expired/consumed/unreachable) must only gate a page that needs
|
|
537
|
+
// identity — a public/unlisted page never invoked ticket redemption before
|
|
538
|
+
// this refactor, so a stale ticket there must serve anonymously, not 401.
|
|
244
539
|
const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
|
|
540
|
+
const viewer = await resolveViewer(request, env, url);
|
|
541
|
+
if (viewer.redirect) return viewer.redirect;
|
|
542
|
+
if (viewer.error && isIdentityGated(visibility)) return viewer.error;
|
|
543
|
+
const identity = viewer.identity || null;
|
|
544
|
+
const denied = gateVisibility(url, env, obj, identity);
|
|
545
|
+
if (denied) return denied;
|
|
546
|
+
// The owner of the namespace (their handle === the first path segment) gets
|
|
547
|
+
// an interactive visibility control; everyone else keeps the static cue.
|
|
548
|
+
const isOwner = !!identity && handleFromEmail(identity.email) === firstSeg;
|
|
549
|
+
const ownerDomain = identity ? emailDomain(identity.email) : '';
|
|
245
550
|
const headers = new Headers();
|
|
246
551
|
obj.writeHttpMetadata(headers);
|
|
247
552
|
headers.set('etag', obj.httpEtag);
|
|
@@ -254,18 +559,58 @@ export default {
|
|
|
254
559
|
if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
|
|
255
560
|
}
|
|
256
561
|
if (request.method === 'HEAD') return new Response(null, { status: 200, headers });
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
562
|
+
const ctype = headers.get('content-type') || '';
|
|
563
|
+
// HTML pages ALWAYS get the attribution bar injected at serve time (who
|
|
564
|
+
// shared it, what made it, when, and — the point — a visual VISIBILITY cue),
|
|
565
|
+
// all from stamped metadata. This is unconditional: ?raw does NOT strip the
|
|
566
|
+
// bar from a shared page (the always-on cue from #3122/#3140 is not
|
|
567
|
+
// defeatable with a query param). The body changes, so the R2 etag no
|
|
261
568
|
// longer matches — drop it rather than serve a lying validator.
|
|
262
|
-
|
|
263
|
-
if (isHtmlPage) {
|
|
569
|
+
if (ctype.indexOf('text/html') !== -1) {
|
|
264
570
|
const rawHtml = await obj.text();
|
|
265
|
-
|
|
571
|
+
// Per-slug view counter (RUSH view stats). Stored as a SEPARATE R2 object
|
|
572
|
+
// (__views/<path>) so it never rewrites the page object — a rewrite would
|
|
573
|
+
// reset obj.uploaded and corrupt "last updated". The __-prefix key is GET-
|
|
574
|
+
// blocked for direct requests and lives outside every <user>/ list prefix,
|
|
575
|
+
// so it never leaks into the gallery/listing/revisions. Count only a real
|
|
576
|
+
// visitor's page view: not ?raw (an embed/OG fetch), not the owner's own
|
|
577
|
+
// view, and never HEAD (which returned above). The write rides
|
|
578
|
+
// ctx.waitUntil so it never blocks the response; the displayed count folds
|
|
579
|
+
// in the current view so the visitor sees themselves included.
|
|
580
|
+
const wantsRaw = url.searchParams.get('raw') != null;
|
|
581
|
+
const counting = !wantsRaw && !isOwner;
|
|
582
|
+
const storedViews = await readViews(env, path);
|
|
583
|
+
const views = storedViews + (counting ? 1 : 0);
|
|
584
|
+
if (counting && ctx && typeof ctx.waitUntil === 'function') {
|
|
585
|
+
ctx.waitUntil(writeViews(env, path, views));
|
|
586
|
+
}
|
|
587
|
+
const withBar = injectAttributionBar(rawHtml, obj.customMetadata || {}, firstSeg, {
|
|
588
|
+
isOwner,
|
|
589
|
+
ownerDomain,
|
|
590
|
+
views,
|
|
591
|
+
uploaded: obj.uploaded,
|
|
592
|
+
});
|
|
266
593
|
headers.delete('etag');
|
|
267
594
|
return new Response(withBar, { status: 200, headers });
|
|
268
595
|
}
|
|
596
|
+
// Non-HTML asset. ?raw returns the stored bytes untouched — the escape hatch
|
|
597
|
+
// for <img src>, OG crawlers, iframes, and anyone embedding the asset (the
|
|
598
|
+
// viewer's own media element points back at ?raw). Otherwise a BROWSER
|
|
599
|
+
// navigating directly to a viewable asset (image/video/audio/pdf) gets a
|
|
600
|
+
// lightweight viewer page carrying the same bar; a non-browser fetch (Accept
|
|
601
|
+
// without text/html) falls through to raw bytes so embedding is never broken.
|
|
602
|
+
const wantsRaw = url.searchParams.get('raw') != null;
|
|
603
|
+
const kind = viewableAssetKind(ctype);
|
|
604
|
+
if (!wantsRaw && kind && acceptsHtml(request)) {
|
|
605
|
+
const viewerPage = renderAssetViewer(url.pathname, kind, obj.customMetadata || {}, firstSeg, {
|
|
606
|
+
isOwner,
|
|
607
|
+
ownerDomain,
|
|
608
|
+
});
|
|
609
|
+
const vheaders = new Headers(headers);
|
|
610
|
+
vheaders.set('content-type', 'text/html; charset=utf-8');
|
|
611
|
+
vheaders.delete('etag');
|
|
612
|
+
return new Response(viewerPage, { status: 200, headers: vheaders });
|
|
613
|
+
}
|
|
269
614
|
return new Response(obj.body, { status: 200, headers });
|
|
270
615
|
}
|
|
271
616
|
|
|
@@ -307,6 +652,10 @@ function isRevisionKey(key) {
|
|
|
307
652
|
return key.split('/').length > 2;
|
|
308
653
|
}
|
|
309
654
|
|
|
655
|
+
function publishedAtOf(object) {
|
|
656
|
+
return (object.customMetadata && object.customMetadata['published-at']) || object.uploaded;
|
|
657
|
+
}
|
|
658
|
+
|
|
310
659
|
async function renderGallery(bucket, origin, user, method) {
|
|
311
660
|
const objects = [];
|
|
312
661
|
let cursor;
|
|
@@ -329,7 +678,7 @@ async function renderGallery(bucket, origin, user, method) {
|
|
|
329
678
|
const url = origin + '/' + o.key;
|
|
330
679
|
const label = (o.customMetadata && o.customMetadata['label']) || '';
|
|
331
680
|
const agent = (o.customMetadata && o.customMetadata['agent']) || '';
|
|
332
|
-
return { slug, url, updated: o
|
|
681
|
+
return { slug, url, updated: publishedAtOf(o), label, agent };
|
|
333
682
|
});
|
|
334
683
|
|
|
335
684
|
if (method === 'HEAD') {
|
|
@@ -360,7 +709,7 @@ async function renderGallery(bucket, origin, user, method) {
|
|
|
360
709
|
return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60' } });
|
|
361
710
|
}
|
|
362
711
|
|
|
363
|
-
async function renderListing(bucket, origin, user, method) {
|
|
712
|
+
async function renderListing(bucket, origin, user, method, includeHidden) {
|
|
364
713
|
const objects = [];
|
|
365
714
|
let cursor;
|
|
366
715
|
do {
|
|
@@ -381,11 +730,11 @@ async function renderListing(bucket, origin, user, method) {
|
|
|
381
730
|
const now = Date.now();
|
|
382
731
|
const items = objects
|
|
383
732
|
.filter(o => {
|
|
384
|
-
//
|
|
385
|
-
// pages
|
|
733
|
+
// Revisions and sibling .png OG covers are always hidden. Hidden-visibility
|
|
734
|
+
// pages are included ONLY when an authenticated owner requested scope=mine.
|
|
386
735
|
if (isRevisionKey(o.key)) return false;
|
|
387
736
|
if (o.key.endsWith('.png')) return false;
|
|
388
|
-
if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility)) return false;
|
|
737
|
+
if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility) && !includeHidden) return false;
|
|
389
738
|
const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
|
|
390
739
|
return !(expiresAt && now > Date.parse(expiresAt));
|
|
391
740
|
})
|
|
@@ -394,9 +743,10 @@ async function renderListing(bucket, origin, user, method) {
|
|
|
394
743
|
url: origin + '/' + o.key,
|
|
395
744
|
size: typeof o.size === 'number' ? o.size : 0,
|
|
396
745
|
contentType: (o.httpMetadata && o.httpMetadata.contentType) || null,
|
|
397
|
-
publishedAt: new Date(o
|
|
746
|
+
publishedAt: new Date(publishedAtOf(o)).toISOString(),
|
|
398
747
|
expiresAt: (o.customMetadata && o.customMetadata['expires-at']) || null,
|
|
399
748
|
label: (o.customMetadata && o.customMetadata['label']) || null,
|
|
749
|
+
visibility: (o.customMetadata && o.customMetadata.visibility) || 'public',
|
|
400
750
|
agent: (o.customMetadata && o.customMetadata['agent']) || null,
|
|
401
751
|
session: (o.customMetadata && o.customMetadata['session']) || null,
|
|
402
752
|
host: (o.customMetadata && o.customMetadata['host']) || null,
|
|
@@ -407,15 +757,39 @@ async function renderListing(bucket, origin, user, method) {
|
|
|
407
757
|
// Newest first, so the human table and any script reads the freshest share top.
|
|
408
758
|
items.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : a.publishedAt > b.publishedAt ? -1 : 0));
|
|
409
759
|
|
|
760
|
+
const cacheHeaders = includeHidden
|
|
761
|
+
? { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'private, no-store', 'X-Robots-Tag': 'noindex' }
|
|
762
|
+
: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' };
|
|
410
763
|
if (method === 'HEAD') {
|
|
411
|
-
return new Response(null, { status: 200, headers:
|
|
764
|
+
return new Response(null, { status: 200, headers: cacheHeaders });
|
|
412
765
|
}
|
|
413
766
|
return new Response(JSON.stringify({ user, count: items.length, objects: items }), {
|
|
414
767
|
status: 200,
|
|
415
|
-
headers:
|
|
768
|
+
headers: cacheHeaders,
|
|
416
769
|
});
|
|
417
770
|
}
|
|
418
771
|
|
|
772
|
+
// The machine-readable listing is public by default. A request with
|
|
773
|
+
// '?format=json&scope=mine' asks to include the owner's hidden pages
|
|
774
|
+
// (unlisted / me / org). We honor it ONLY after a valid owner bearer whose
|
|
775
|
+
// namespace matches the requested handle, so one user cannot list another's
|
|
776
|
+
// hidden shares. Anonymous or mismatched requests fail loud. A BYO WRITE_TOKEN
|
|
777
|
+
// bearer is the worker's owner, so namespace enforcement is skipped for BYO.
|
|
778
|
+
async function resolveListingScope(request, env, user) {
|
|
779
|
+
const scope = new URL(request.url).searchParams.get('scope');
|
|
780
|
+
if (scope !== 'mine') return { includeHidden: false };
|
|
781
|
+
const auth = await authorizeWrite(request, env);
|
|
782
|
+
if (auth.error) return { error: auth.error };
|
|
783
|
+
if (auth.kind === 'byo') {
|
|
784
|
+
return { includeHidden: true };
|
|
785
|
+
}
|
|
786
|
+
const owner = phoenixHandle(auth);
|
|
787
|
+
if (owner !== user) {
|
|
788
|
+
return { error: json({ error: 'namespace mismatch' }, 403) };
|
|
789
|
+
}
|
|
790
|
+
return { includeHidden: true };
|
|
791
|
+
}
|
|
792
|
+
|
|
419
793
|
async function renderRevisions(bucket, origin, key, method, identityGated) {
|
|
420
794
|
const objects = [];
|
|
421
795
|
let cursor;
|
|
@@ -459,7 +833,7 @@ async function renderRevisions(bucket, origin, key, method, identityGated) {
|
|
|
459
833
|
// before it ever reaches this Worker; see RESERVED_META_KEYS in publish.ts).
|
|
460
834
|
// One list, reused both to strip a same-named --meta collision on write and
|
|
461
835
|
// 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'];
|
|
836
|
+
var RESERVED_METADATA_KEYS = ['expires-at', 'published-at', 'visibility', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'avatar', 'label', 'label-source', 'og-title', 'og-description', 'og-generated', 'og-source-etag'];
|
|
463
837
|
var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'icloud.com', 'me.com'];
|
|
464
838
|
var SHARE_COOKIE = '__Host-phoenix_share';
|
|
465
839
|
var SHARE_COOKIE_MAX_AGE = 604800;
|
|
@@ -498,6 +872,76 @@ var VIS_ICON = {
|
|
|
498
872
|
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
873
|
};
|
|
500
874
|
|
|
875
|
+
// Eye glyph for the view-count stat; caret for the owner's interactive chip.
|
|
876
|
+
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>';
|
|
877
|
+
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>';
|
|
878
|
+
|
|
879
|
+
// The owner-only browser script that turns the visibility chip into a live
|
|
880
|
+
// control. Self-contained IIFE, namespaced under .agents-share-bar; injected only
|
|
881
|
+
// when the viewer owns the namespace. It reads the current level and each level's
|
|
882
|
+
// icon/color/label straight from the rendered menu DOM (no embedded config), then
|
|
883
|
+
// PATCHes the SAME in-place edit route the CLI uses — method PATCH, JSON body
|
|
884
|
+
// { visibility } — with credentials:'include' so the viewer's __share cookie /
|
|
885
|
+
// Phoenix identity rides. Optimistic: flip the chip + spinner, then a green check
|
|
886
|
+
// on 2xx or revert + the server's error text on failure (an org-on-public-inbox
|
|
887
|
+
// 400 surfaces verbatim). The share page sets no CSP, so inline JS is fine.
|
|
888
|
+
var ASH_OWNER_JS = \`(function(){
|
|
889
|
+
var root = document.querySelector('.agents-share-bar');
|
|
890
|
+
if(!root) return;
|
|
891
|
+
var chip = root.querySelector('[data-ash-chip]');
|
|
892
|
+
var menu = root.querySelector('[data-ash-menu]');
|
|
893
|
+
var toast = root.querySelector('[data-ash-toast]');
|
|
894
|
+
if(!chip || !menu || !toast) return;
|
|
895
|
+
var labelEl = chip.querySelector('[data-ash-label]');
|
|
896
|
+
var chipIc = chip.querySelector('[data-ash-chip-ic]');
|
|
897
|
+
var busy = false;
|
|
898
|
+
function opts(){ return menu.querySelectorAll('[data-ash-opt]'); }
|
|
899
|
+
function currentKey(){ var s = menu.querySelector('.ash-opt.ash-sel'); return s ? s.getAttribute('data-ash-opt') : ''; }
|
|
900
|
+
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; }
|
|
901
|
+
function open(){ menu.classList.add('ash-open'); menu.setAttribute('aria-hidden','false'); }
|
|
902
|
+
function close(){ menu.classList.remove('ash-open'); menu.setAttribute('aria-hidden','true'); }
|
|
903
|
+
chip.addEventListener('click', function(e){ e.stopPropagation(); if(menu.classList.contains('ash-open')) close(); else open(); });
|
|
904
|
+
menu.addEventListener('click', function(e){ e.stopPropagation(); });
|
|
905
|
+
document.addEventListener('click', close);
|
|
906
|
+
document.addEventListener('keydown', function(e){ if(e.key==='Escape') close(); });
|
|
907
|
+
function draw(key){
|
|
908
|
+
var sel = null, a = opts();
|
|
909
|
+
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]; }
|
|
910
|
+
if(!sel) return;
|
|
911
|
+
var ic = sel.querySelector('.ash-opt-ic');
|
|
912
|
+
var color = ic ? ic.style.color : '';
|
|
913
|
+
if(color){ chip.style.color = color; chip.style.borderColor = color + '66'; }
|
|
914
|
+
if(chipIc && ic) chipIc.innerHTML = ic.innerHTML;
|
|
915
|
+
var b = sel.querySelector('b');
|
|
916
|
+
if(labelEl && b) labelEl.textContent = b.textContent;
|
|
917
|
+
}
|
|
918
|
+
function say(inner, cls){ toast.innerHTML = inner; toast.className = 'ash-toast ash-show' + (cls ? ' ' + cls : ''); }
|
|
919
|
+
function fade(ms){ setTimeout(function(){ toast.className = 'ash-toast'; }, ms); }
|
|
920
|
+
function pick(key){
|
|
921
|
+
close();
|
|
922
|
+
var prev = currentKey();
|
|
923
|
+
if(busy || !key || key===prev) return;
|
|
924
|
+
busy = true;
|
|
925
|
+
draw(key);
|
|
926
|
+
var name = labelFor(key);
|
|
927
|
+
say('<span class="ash-spin"></span> Saving…');
|
|
928
|
+
fetch(location.pathname, { method:'PATCH', credentials:'include', headers:{'content-type':'application/json'}, body: JSON.stringify({ visibility: key }) })
|
|
929
|
+
.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: {} }; }); })
|
|
930
|
+
.then(function(r){
|
|
931
|
+
busy = false;
|
|
932
|
+
if(r.ok){ say('<span class="ash-ok">\\u2713</span> Now ' + name, 'ash-good'); fade(1600); }
|
|
933
|
+
else {
|
|
934
|
+
draw(prev);
|
|
935
|
+
var msg = (r.body && r.body.error) ? String(r.body.error) : ('Could not update (HTTP ' + r.status + ')');
|
|
936
|
+
say('<span class="ash-err">\\u2715</span> ' + msg.replace(/[<>&]/g, ' '), 'ash-bad');
|
|
937
|
+
fade(4000);
|
|
938
|
+
}
|
|
939
|
+
}, function(){ busy = false; draw(prev); say('<span class="ash-err">\\u2715</span> Network error \\u2014 not saved', 'ash-bad'); fade(4000); });
|
|
940
|
+
}
|
|
941
|
+
var a = opts();
|
|
942
|
+
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]); }
|
|
943
|
+
})();\`;
|
|
944
|
+
|
|
501
945
|
function visibilityChip(visibility, orgDomain) {
|
|
502
946
|
if (visibility === 'me') return { icon: VIS_ICON.me, label: 'Only you', color: '#f59e0b' };
|
|
503
947
|
if (visibility === 'org') return { icon: VIS_ICON.org, label: 'Anyone at ' + escapeHtml(orgDomain || 'your organization'), color: '#5b9dff' };
|
|
@@ -505,14 +949,117 @@ function visibilityChip(visibility, orgDomain) {
|
|
|
505
949
|
return { icon: VIS_ICON.public, label: 'Public', color: '#30a46c' };
|
|
506
950
|
}
|
|
507
951
|
|
|
508
|
-
|
|
952
|
+
// A deterministic hue (0-359) from the handle, so a given sharer always gets the
|
|
953
|
+
// same initials-circle colour without any stored palette.
|
|
954
|
+
function avatarHue(s) {
|
|
955
|
+
var h = 0;
|
|
956
|
+
for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
|
|
957
|
+
return h;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// The avatar slot is ALWAYS rendered: a coloured initials circle derived from the
|
|
961
|
+
// handle. When the object carries an avatar URL (a Gravatar the CLI stamps at
|
|
962
|
+
// publish, or any future hosted photo), a real img is layered on top; onerror
|
|
963
|
+
// removes it so the initials underneath show through when the photo 404s. There
|
|
964
|
+
// is no CSP on the share page, so the inline onerror fallback is honoured.
|
|
965
|
+
function renderAvatar(handle, avatarUrl) {
|
|
966
|
+
var h = (handle || '').trim();
|
|
967
|
+
var initial = escapeHtml((h.charAt(0) || '?').toUpperCase());
|
|
968
|
+
var bg = 'hsl(' + avatarHue(h) + ',48%,42%)';
|
|
969
|
+
var inner = '<span class="ash-av-i">' + initial + '</span>';
|
|
970
|
+
if (avatarUrl) {
|
|
971
|
+
inner += '<img class="ash-av-img" src="' + escapeHtml(avatarUrl) + '" alt="" loading="lazy" ' +
|
|
972
|
+
'referrerpolicy="no-referrer" onerror="this.remove()">';
|
|
973
|
+
}
|
|
974
|
+
return '<span class="ash-avatar" style="background:' + bg + ' !important" aria-hidden="true">' + inner + '</span>';
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// A compact relative time for the "last updated" stat: "just now", "2h ago",
|
|
978
|
+
// "3d ago", or an ISO date once past ~30 days. Accepts a Date or a parseable
|
|
979
|
+
// string (R2's obj.uploaded is a Date).
|
|
980
|
+
function relativeTime(when) {
|
|
981
|
+
var then = when instanceof Date ? when.getTime() : Date.parse(when);
|
|
982
|
+
if (!then || isNaN(then)) return '';
|
|
983
|
+
var diff = Date.now() - then;
|
|
984
|
+
if (diff < 0) diff = 0;
|
|
985
|
+
var minute = 60000, hour = 3600000, day = 86400000;
|
|
986
|
+
if (diff < minute) return 'just now';
|
|
987
|
+
if (diff < hour) return Math.floor(diff / minute) + 'm ago';
|
|
988
|
+
if (diff < day) return Math.floor(diff / hour) + 'h ago';
|
|
989
|
+
if (diff < 30 * day) return Math.floor(diff / day) + 'd ago';
|
|
990
|
+
return new Date(then).toISOString().slice(0, 10);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// The four visibility levels the owner can pick, in menu order. Descriptions
|
|
994
|
+
// mirror the approved mockup. org always appears — a public-inbox owner still
|
|
995
|
+
// sees it and lets the server's 400 drive the failure message (never a hidden
|
|
996
|
+
// client-side rule). orgDomain is the owner's own email domain when known.
|
|
997
|
+
function visibilityLevels(orgDomain) {
|
|
998
|
+
return [
|
|
999
|
+
{ key: 'public', label: 'Public', color: '#30a46c', icon: VIS_ICON.public, desc: 'Anyone with the link · preview card · listed in your gallery' },
|
|
1000
|
+
{ key: 'unlisted', label: 'Unlisted', color: '#9aa0a6', icon: VIS_ICON.unlisted, desc: 'Anyone with the link · no card · hidden from your gallery' },
|
|
1001
|
+
{ key: 'me', label: 'Only you', color: '#f59e0b', icon: VIS_ICON.me, desc: 'Sign-in required · only your account can open it' },
|
|
1002
|
+
{ 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' },
|
|
1003
|
+
];
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function renderVisibilityMenu(current, orgDomain) {
|
|
1007
|
+
var rows = visibilityLevels(orgDomain).map(function (l) {
|
|
1008
|
+
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') + '">' +
|
|
1009
|
+
'<span class="ash-opt-ic" style="color:' + l.color + '">' + l.icon + '</span>' +
|
|
1010
|
+
'<span class="ash-opt-t"><b>' + l.label + '</b><span>' + l.desc + '</span></span>' +
|
|
1011
|
+
'<span class="ash-rd"></span></button>';
|
|
1012
|
+
}).join('');
|
|
1013
|
+
return '<div class="ash-menu" data-ash-menu role="menu" aria-hidden="true"><h4>Who can see this</h4>' + rows + '</div>';
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function renderOwnerScript() {
|
|
1017
|
+
return '<script>' + ASH_OWNER_JS + '</script>';
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// opts: { isOwner, ownerDomain, views, uploaded } — all optional. isOwner turns
|
|
1021
|
+
// the visibility chip into a live control (inline dropdown, Variation A) and
|
|
1022
|
+
// injects the owner-only CSS/JS; views/uploaded render the right-side stats
|
|
1023
|
+
// cluster. With no opts the bar is exactly today's static strip.
|
|
1024
|
+
function renderAttributionBar(meta, handle, opts) {
|
|
1025
|
+
var o = opts || {};
|
|
509
1026
|
var cm = meta || {};
|
|
510
|
-
var
|
|
1027
|
+
var visibility = cm.visibility || 'public';
|
|
1028
|
+
var chip = visibilityChip(visibility, cm.org_domain);
|
|
1029
|
+
var avatar = renderAvatar(handle, cm.avatar);
|
|
511
1030
|
var left = '';
|
|
512
1031
|
// The handle is already the public URL namespace, so surfacing it leaks nothing new.
|
|
513
1032
|
if (handle) left += 'Shared by <strong>' + escapeHtml(handle) + '</strong>';
|
|
514
1033
|
if (cm.agent) left += (left ? '<span class="ash-dot">·</span>' : '') + 'Made with ' + escapeHtml(cm.agent);
|
|
515
|
-
|
|
1034
|
+
|
|
1035
|
+
// Stats cluster (👁 <n> views · updated <rel>) — right side, left of the chip.
|
|
1036
|
+
var stats = '';
|
|
1037
|
+
var statBits = '';
|
|
1038
|
+
if (o.views != null) {
|
|
1039
|
+
statBits += STAT_EYE_ICON + '<b>' + escapeHtml(String(o.views)) + '</b> ' + (Number(o.views) === 1 ? 'view' : 'views');
|
|
1040
|
+
}
|
|
1041
|
+
if (o.uploaded) {
|
|
1042
|
+
var rel = relativeTime(o.uploaded);
|
|
1043
|
+
if (rel) statBits += (statBits ? '<span class="ash-statsep">·</span>' : '') + 'updated <b>' + escapeHtml(rel) + '</b>';
|
|
1044
|
+
}
|
|
1045
|
+
if (statBits) stats = '<span class="ash-stats" title="page stats">' + statBits + '</span>';
|
|
1046
|
+
|
|
1047
|
+
// The visibility chip: an interactive control for the owner, a static cue for
|
|
1048
|
+
// everyone else (unchanged from today).
|
|
1049
|
+
var chipHtml;
|
|
1050
|
+
var menu = '';
|
|
1051
|
+
var script = '';
|
|
1052
|
+
if (o.isOwner) {
|
|
1053
|
+
chipHtml = '<span class="ash-chip ash-chip-own" data-ash-chip tabindex="0" role="button" aria-haspopup="menu" ' +
|
|
1054
|
+
'style="color:' + chip.color + ';border-color:' + chip.color + '66">' +
|
|
1055
|
+
'<span data-ash-chip-ic>' + chip.icon + '</span><span data-ash-label>' + chip.label + '</span>' + CARET_ICON + '</span>';
|
|
1056
|
+
menu = renderVisibilityMenu(visibility, o.ownerDomain);
|
|
1057
|
+
script = renderOwnerScript();
|
|
1058
|
+
} else {
|
|
1059
|
+
chipHtml = '<span class="ash-chip" style="color:' + chip.color + ';border-color:' + chip.color + '66">' + chip.icon + '<span>' + chip.label + '</span></span>';
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
var right = stats + chipHtml;
|
|
516
1063
|
if (cm.date) right += '<span class="ash-date">' + escapeHtml(cm.date) + '</span>';
|
|
517
1064
|
// The load-bearing background + base colour ride an INLINE style so the host
|
|
518
1065
|
// page's own CSS can never wash the bar out (inline beats a page stylesheet);
|
|
@@ -530,6 +1077,9 @@ function renderAttributionBar(meta, handle) {
|
|
|
530
1077
|
'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
1078
|
'font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif !important}' +
|
|
532
1079
|
'.agents-share-bar *{box-sizing:border-box;font-family:inherit}' +
|
|
1080
|
+
'.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)}' +
|
|
1081
|
+
'.agents-share-bar .ash-av-i{color:#fff !important}' +
|
|
1082
|
+
'.agents-share-bar .ash-av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:50% !important}' +
|
|
533
1083
|
'.agents-share-bar .ash-left{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#cfd3d9 !important}' +
|
|
534
1084
|
'.agents-share-bar strong{color:#fff !important;font-weight:600}' +
|
|
535
1085
|
'.agents-share-bar .ash-dot{opacity:.4;margin:0 4px}' +
|
|
@@ -537,14 +1087,48 @@ function renderAttributionBar(meta, handle) {
|
|
|
537
1087
|
'.agents-share-bar .ash-date{color:#9096a0 !important}' +
|
|
538
1088
|
'.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
1089
|
'.agents-share-bar .ash-chip svg{flex:none;vertical-align:middle}' +
|
|
1090
|
+
'.agents-share-bar .ash-stats{display:inline-flex;align-items:center;gap:5px;color:#9aa0a6 !important;font-size:12px;white-space:nowrap}' +
|
|
1091
|
+
'.agents-share-bar .ash-stats b{color:#c9ced6 !important;font-weight:600}' +
|
|
1092
|
+
'.agents-share-bar .ash-stats svg{flex:none;opacity:.85}' +
|
|
1093
|
+
'.agents-share-bar .ash-statsep{opacity:.35;margin:0 3px}' +
|
|
1094
|
+
(o.isOwner ? OWNER_BAR_CSS : '') +
|
|
540
1095
|
'</style>' +
|
|
1096
|
+
avatar +
|
|
541
1097
|
'<span class="ash-left">' + left + '</span>' +
|
|
542
1098
|
'<span class="ash-right">' + right + '</span>' +
|
|
543
|
-
|
|
1099
|
+
menu +
|
|
1100
|
+
(o.isOwner ? '<div class="ash-toast" data-ash-toast></div>' : '') +
|
|
1101
|
+
'</div>' +
|
|
1102
|
+
script;
|
|
544
1103
|
}
|
|
545
1104
|
|
|
546
|
-
|
|
547
|
-
|
|
1105
|
+
// Owner-only chip/menu/toast styling, added to the bar's <style> only when the
|
|
1106
|
+
// viewer owns the namespace. Namespaced under .agents-share-bar with !important
|
|
1107
|
+
// so the host page's CSS can't wash it out; z-index rides the bar's own stacking.
|
|
1108
|
+
var OWNER_BAR_CSS =
|
|
1109
|
+
'.agents-share-bar .ash-chip-own{cursor:pointer;transition:background .12s}' +
|
|
1110
|
+
'.agents-share-bar .ash-chip-own:hover{background:rgba(255,255,255,.06)}' +
|
|
1111
|
+
'.agents-share-bar .ash-caret{opacity:.6;margin-left:1px;flex:none}' +
|
|
1112
|
+
'.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}' +
|
|
1113
|
+
'.agents-share-bar .ash-menu.ash-open{display:block}' +
|
|
1114
|
+
'.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}' +
|
|
1115
|
+
'.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}' +
|
|
1116
|
+
'.agents-share-bar .ash-opt:hover{background:rgba(255,255,255,.05)}' +
|
|
1117
|
+
'.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)}' +
|
|
1118
|
+
'.agents-share-bar .ash-opt-t{flex:1;min-width:0}' +
|
|
1119
|
+
'.agents-share-bar .ash-opt-t b{display:block;font-size:13px;font-weight:600;color:#f0f1f3 !important}' +
|
|
1120
|
+
'.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}' +
|
|
1121
|
+
'.agents-share-bar .ash-rd{flex:none;width:16px;height:16px;border-radius:50%;border:2px solid #4a4f59;margin-top:3px}' +
|
|
1122
|
+
'.agents-share-bar .ash-opt.ash-sel .ash-rd{border-color:#5b9dff;background:radial-gradient(circle at center,#5b9dff 0 5px,transparent 6px)}' +
|
|
1123
|
+
'.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}' +
|
|
1124
|
+
'.agents-share-bar .ash-toast.ash-show{display:inline-flex}' +
|
|
1125
|
+
'.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}' +
|
|
1126
|
+
'@keyframes ash-sp{to{transform:rotate(360deg)}}' +
|
|
1127
|
+
'.agents-share-bar .ash-ok{color:#30a46c;font-weight:700}' +
|
|
1128
|
+
'.agents-share-bar .ash-err{color:#ff6b6b;font-weight:700}';
|
|
1129
|
+
|
|
1130
|
+
function injectAttributionBar(html, meta, handle, opts) {
|
|
1131
|
+
var bar = renderAttributionBar(meta, handle, opts);
|
|
548
1132
|
var m = /<body[^>]*>/i.exec(html);
|
|
549
1133
|
if (m) {
|
|
550
1134
|
var at = m.index + m[0].length;
|
|
@@ -553,6 +1137,47 @@ function injectAttributionBar(html, meta, handle) {
|
|
|
553
1137
|
return bar + html;
|
|
554
1138
|
}
|
|
555
1139
|
|
|
1140
|
+
function acceptsHtml(request) {
|
|
1141
|
+
return (request.headers.get('accept') || '').indexOf('text/html') !== -1;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Assets we can embed in a viewer page. Everything else (JSON, .txt, arbitrary
|
|
1145
|
+
// downloads) is served raw — there is nothing useful to wrap it in.
|
|
1146
|
+
function viewableAssetKind(contentType) {
|
|
1147
|
+
var ct = (contentType || '').toLowerCase();
|
|
1148
|
+
if (ct.indexOf('image/') === 0) return 'image';
|
|
1149
|
+
if (ct.indexOf('video/') === 0) return 'video';
|
|
1150
|
+
if (ct.indexOf('audio/') === 0) return 'audio';
|
|
1151
|
+
if (ct.indexOf('application/pdf') === 0) return 'pdf';
|
|
1152
|
+
return '';
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// A minimal dark viewer page: the attribution bar on top, the asset centered
|
|
1156
|
+
// below. The media element points back at THIS url with ?raw so it loads the
|
|
1157
|
+
// stored bytes (not the viewer recursively) — and that ?raw fetch re-runs the
|
|
1158
|
+
// same me/org gate, so a private asset stays private inside its own viewer.
|
|
1159
|
+
function renderAssetViewer(pathname, kind, meta, handle, opts) {
|
|
1160
|
+
var rawUrl = escapeHtml(pathname + '?raw=1');
|
|
1161
|
+
var name = '';
|
|
1162
|
+
try { name = decodeURIComponent(pathname.split('/').pop() || 'file'); } catch (e) { name = pathname.split('/').pop() || 'file'; }
|
|
1163
|
+
var bar = renderAttributionBar(meta, handle, opts);
|
|
1164
|
+
var media = '';
|
|
1165
|
+
if (kind === 'image') media = '<img src="' + rawUrl + '" alt="' + escapeHtml(name) + '">';
|
|
1166
|
+
else if (kind === 'video') media = '<video src="' + rawUrl + '" controls playsinline></video>';
|
|
1167
|
+
else if (kind === 'audio') media = '<audio src="' + rawUrl + '" controls></audio>';
|
|
1168
|
+
else if (kind === 'pdf') media = '<iframe src="' + rawUrl + '" title="' + escapeHtml(name) + '"></iframe>';
|
|
1169
|
+
return '<!doctype html><html><head><meta charset="utf-8">' +
|
|
1170
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
1171
|
+
'<title>' + escapeHtml(name) + '</title>' +
|
|
1172
|
+
'<style>html,body{margin:0;background:#0f0f11}' +
|
|
1173
|
+
'body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box}' +
|
|
1174
|
+
'.ashv{max-width:100%;max-height:calc(100vh - 90px);display:flex;align-items:center;justify-content:center}' +
|
|
1175
|
+
'.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)}' +
|
|
1176
|
+
'.ashv audio{width:min(90vw,560px)}' +
|
|
1177
|
+
'.ashv iframe{width:min(94vw,1000px);height:calc(100vh - 90px);border:0;border-radius:8px;background:#fff}' +
|
|
1178
|
+
'</style></head><body>' + bar + '<div class="ashv">' + media + '</div></body></html>';
|
|
1179
|
+
}
|
|
1180
|
+
|
|
556
1181
|
function json(body, status) {
|
|
557
1182
|
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
558
1183
|
}
|
|
@@ -654,19 +1279,59 @@ function isIdentityGated(visibility) {
|
|
|
654
1279
|
return visibility === 'me' || visibility === 'org';
|
|
655
1280
|
}
|
|
656
1281
|
|
|
657
|
-
|
|
1282
|
+
function managedCoverHeaders(visibility) {
|
|
1283
|
+
const headers = new Headers({ 'content-type': 'image/png' });
|
|
1284
|
+
if (isIdentityGated(visibility)) {
|
|
1285
|
+
headers.set('cache-control', 'private, no-store');
|
|
1286
|
+
headers.set('X-Robots-Tag', 'noindex');
|
|
1287
|
+
} else {
|
|
1288
|
+
headers.set('cache-control', 'public, max-age=31536000, immutable');
|
|
1289
|
+
if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
|
|
1290
|
+
}
|
|
1291
|
+
return headers;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Gate a me/org read given the ALREADY-RESOLVED viewer identity. Pure/sync: the
|
|
1295
|
+
// caller resolves the viewer once (it also needs the identity for the ownership
|
|
1296
|
+
// check) and both the page GET and the ?revisions=json path share this gate.
|
|
1297
|
+
function gateVisibility(url, env, obj, identity) {
|
|
658
1298
|
const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
|
|
659
1299
|
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)) {
|
|
1300
|
+
if (!identity) return bounceToLogin(url, env);
|
|
1301
|
+
if (!viewerMayRead(visibility, obj.customMetadata, identity)) {
|
|
665
1302
|
return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
666
1303
|
}
|
|
667
1304
|
return null;
|
|
668
1305
|
}
|
|
669
1306
|
|
|
1307
|
+
// Per-slug view counter, stored as a SEPARATE R2 object under __views/<path> so
|
|
1308
|
+
// counting a view never rewrites the page object (which would reset its uploaded
|
|
1309
|
+
// timestamp and corrupt "last updated"). Best-effort telemetry: a read/write
|
|
1310
|
+
// failure must never break page serving, so both degrade to a no-op rather than
|
|
1311
|
+
// throwing. R2 has no atomic increment, so two simultaneous views can race and
|
|
1312
|
+
// lose a count — acceptable for an approximate visitor count.
|
|
1313
|
+
async function readViews(env, path) {
|
|
1314
|
+
try {
|
|
1315
|
+
const obj = await env.BUCKET.get('__views/' + path);
|
|
1316
|
+
if (!obj) return 0;
|
|
1317
|
+
const raw = typeof obj.text === 'function' ? await obj.text() : '';
|
|
1318
|
+
const n = parseInt(raw, 10);
|
|
1319
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
1320
|
+
} catch {
|
|
1321
|
+
return 0;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
async function writeViews(env, path, count) {
|
|
1326
|
+
try {
|
|
1327
|
+
await env.BUCKET.put('__views/' + path, String(count), {
|
|
1328
|
+
httpMetadata: { contentType: 'text/plain; charset=utf-8' },
|
|
1329
|
+
});
|
|
1330
|
+
} catch {
|
|
1331
|
+
// best-effort: a failed counter write must never surface to the visitor
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
670
1335
|
function viewerMayRead(visibility, meta, identity) {
|
|
671
1336
|
if (visibility === 'me') {
|
|
672
1337
|
const owner = meta && meta.owner;
|
|
@@ -743,6 +1408,66 @@ async function signShareCookie(identity, env) {
|
|
|
743
1408
|
return SHARE_COOKIE + '=' + value + '; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=' + SHARE_COOKIE_MAX_AGE;
|
|
744
1409
|
}
|
|
745
1410
|
|
|
1411
|
+
// A short-lived login ticket, self-signed with the same HMAC secret as the cookie
|
|
1412
|
+
// but DOMAIN-SEPARATED (the signature covers a 'ticket:'-prefixed payload), so a
|
|
1413
|
+
// ticket value can never be replayed as a cookie value or vice versa. It rides in
|
|
1414
|
+
// the ?phoenix_ticket= param for exactly one navigation; resolveViewer verifies it
|
|
1415
|
+
// locally and exchanges it for the __share cookie. TTL is deliberately tiny — it
|
|
1416
|
+
// only has to survive the round-trip from CLI mint to the browser opening the URL.
|
|
1417
|
+
var SELF_TICKET_MAX_AGE = 120;
|
|
1418
|
+
|
|
1419
|
+
async function signSelfTicket(identity, env) {
|
|
1420
|
+
const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
|
|
1421
|
+
if (!secret || !identity || !identity.userId) return '';
|
|
1422
|
+
const exp = Math.floor(Date.now() / 1000) + SELF_TICKET_MAX_AGE;
|
|
1423
|
+
const payload = identity.userId + '|' + (identity.email || '') + '|' + exp;
|
|
1424
|
+
const sig = await hmacHex(secret, 'ticket:' + payload);
|
|
1425
|
+
return toB64Url(payload) + '.' + sig;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
async function verifySelfTicket(ticket, env) {
|
|
1429
|
+
const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
|
|
1430
|
+
if (!secret || !ticket) return null;
|
|
1431
|
+
const dot = ticket.lastIndexOf('.');
|
|
1432
|
+
if (dot < 1) return null;
|
|
1433
|
+
let payload;
|
|
1434
|
+
try {
|
|
1435
|
+
payload = fromB64Url(ticket.slice(0, dot));
|
|
1436
|
+
} catch {
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
const sig = ticket.slice(dot + 1);
|
|
1440
|
+
const expected = await hmacHex(secret, 'ticket:' + payload);
|
|
1441
|
+
if (!safeEqual(sig, expected)) return null;
|
|
1442
|
+
const first = payload.indexOf('|');
|
|
1443
|
+
const last = payload.lastIndexOf('|');
|
|
1444
|
+
if (first < 0 || last <= first) return null;
|
|
1445
|
+
const userId = payload.slice(0, first);
|
|
1446
|
+
const email = payload.slice(first + 1, last);
|
|
1447
|
+
const exp = Number(payload.slice(last + 1));
|
|
1448
|
+
if (!userId || !Number.isFinite(exp) || Math.floor(Date.now() / 1000) > exp) return null;
|
|
1449
|
+
return { userId: userId, email: email };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Single-use enforcement for a self-signed ticket: claim its signature the first
|
|
1453
|
+
// time it is redeemed, and reject any later presentation of the same ticket. The
|
|
1454
|
+
// HMAC + short TTL stop forgery and bound the window, but without this a ticket the
|
|
1455
|
+
// CLI printed (--json / --no-open / a browser-open failure) could be replayed by
|
|
1456
|
+
// anyone who captured that output before it expired, minting the owner's 7-day
|
|
1457
|
+
// cookie a second time. Get-then-put mirrors claimHandle's one-time-claim pattern;
|
|
1458
|
+
// the marker rides a __-prefixed key (GET-blocked, excluded from every listing).
|
|
1459
|
+
async function consumeSelfTicket(env, sig) {
|
|
1460
|
+
if (!sig) return false;
|
|
1461
|
+
const key = '__ticket-used/' + sig;
|
|
1462
|
+
const existing = await env.BUCKET.get(key);
|
|
1463
|
+
if (existing) return false;
|
|
1464
|
+
await env.BUCKET.put(key, '1', {
|
|
1465
|
+
httpMetadata: { contentType: 'text/plain' },
|
|
1466
|
+
customMetadata: { visibility: 'unlisted' },
|
|
1467
|
+
});
|
|
1468
|
+
return true;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
746
1471
|
async function identityFromCookie(request, env) {
|
|
747
1472
|
const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
|
|
748
1473
|
if (!secret) return null;
|
|
@@ -802,7 +1527,17 @@ async function resolveViewer(request, env, url) {
|
|
|
802
1527
|
if (cookieId) return { identity: cookieId };
|
|
803
1528
|
const ticket = url.searchParams.get('phoenix_ticket');
|
|
804
1529
|
if (!ticket) return {};
|
|
805
|
-
|
|
1530
|
+
// Prefer the locally-verifiable self-signed ticket (PHNX-3370, no network), and
|
|
1531
|
+
// enforce single use by claiming its signature on first redemption — a captured
|
|
1532
|
+
// ticket replayed within its TTL finds it already spent. Fall back to the
|
|
1533
|
+
// external identity-server redeem for tickets it issued.
|
|
1534
|
+
let redeemed = await verifySelfTicket(ticket, env);
|
|
1535
|
+
if (redeemed) {
|
|
1536
|
+
const sig = ticket.slice(ticket.lastIndexOf('.') + 1);
|
|
1537
|
+
if (!(await consumeSelfTicket(env, sig))) redeemed = null;
|
|
1538
|
+
} else {
|
|
1539
|
+
redeemed = await redeemTicket(ticket, env);
|
|
1540
|
+
}
|
|
806
1541
|
if (!redeemed) return { error: json({ error: 'invalid ticket' }, 401) };
|
|
807
1542
|
const cookie = await signShareCookie(redeemed, env);
|
|
808
1543
|
if (!cookie) return { error: json({ error: 'phoenix login is not configured' }, 401) };
|
|
@@ -813,6 +1548,61 @@ async function resolveViewer(request, env, url) {
|
|
|
813
1548
|
return { redirect: new Response(null, { status: 302, headers: headers }) };
|
|
814
1549
|
}
|
|
815
1550
|
|
|
1551
|
+
function extractHtmlMeta(html, field) {
|
|
1552
|
+
const pattern = field === 'title'
|
|
1553
|
+
? /<title[^>]*>([\\s\\S]*?)<\\/title>/i
|
|
1554
|
+
: /<meta[^>]+(?:name|property)=["'](?:description|og:description)["'][^>]+content=["']([^"']*)["'][^>]*>/i;
|
|
1555
|
+
const match = pattern.exec(html);
|
|
1556
|
+
return match ? match[1].replace(/<[^>]*>/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').trim() : '';
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
let rendererReady;
|
|
1560
|
+
async function renderOgCard(input) {
|
|
1561
|
+
if (!rendererReady) {
|
|
1562
|
+
rendererReady = Promise.all([
|
|
1563
|
+
initYoga(yogaWasm).then(function (yoga) { initSatori(yoga); }),
|
|
1564
|
+
initResvg(resvgWasm),
|
|
1565
|
+
]);
|
|
1566
|
+
}
|
|
1567
|
+
await rendererReady;
|
|
1568
|
+
const visibilityLabels = {
|
|
1569
|
+
public: 'PUBLIC',
|
|
1570
|
+
unlisted: 'UNLISTED',
|
|
1571
|
+
me: 'ONLY YOU',
|
|
1572
|
+
org: input.orgDomain ? 'ANYONE AT ' + input.orgDomain.toUpperCase() : 'ORGANIZATION',
|
|
1573
|
+
};
|
|
1574
|
+
const node = {
|
|
1575
|
+
type: 'div',
|
|
1576
|
+
props: {
|
|
1577
|
+
style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#0a0a0a', color: '#f5f5f5', padding: '68px 76px 58px', fontFamily: 'Inter' },
|
|
1578
|
+
children: [
|
|
1579
|
+
{ type: 'div', props: { style: { display: 'flex', color: '#a3e635', fontFamily: 'JetBrains Mono', fontSize: 25, fontWeight: 600, letterSpacing: '-0.5px' }, children: 'AGI · agents-cli.sh' } },
|
|
1580
|
+
{ type: 'div', props: { style: { display: 'flex', flexDirection: 'column', flexGrow: 1, justifyContent: 'center', maxWidth: 1050 }, children: [
|
|
1581
|
+
{ type: 'div', props: { style: { display: 'flex', fontSize: 66, lineHeight: 1.06, fontWeight: 700, letterSpacing: '-2.8px', maxHeight: 218, overflow: 'hidden' }, children: input.title || 'Shared artifact' } },
|
|
1582
|
+
input.description ? { type: 'div', props: { style: { display: 'flex', marginTop: 24, color: '#a3a3a3', fontSize: 27, lineHeight: 1.35, maxHeight: 74, overflow: 'hidden' }, children: input.description } } : null,
|
|
1583
|
+
].filter(Boolean) } },
|
|
1584
|
+
{ type: 'div', props: { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderTop: '1px solid #262626', paddingTop: 25, fontSize: 21, color: '#a3a3a3' }, children: [
|
|
1585
|
+
{ type: 'div', props: { style: { display: 'flex' }, children: [
|
|
1586
|
+
{ type: 'span', props: { style: { color: '#737373' }, children: 'shared by ' } },
|
|
1587
|
+
{ type: 'span', props: { style: { marginLeft: 7, color: '#f5f5f5', fontWeight: 700 }, children: '@' + input.handle } },
|
|
1588
|
+
] } },
|
|
1589
|
+
{ type: 'div', props: { style: { display: 'flex', border: '1px solid #3f3f46', borderRadius: 999, padding: '9px 17px', color: '#d4d4d8', fontFamily: 'JetBrains Mono', fontSize: 16, letterSpacing: '1px' }, children: visibilityLabels[input.visibility] || 'PUBLIC' } },
|
|
1590
|
+
] } },
|
|
1591
|
+
],
|
|
1592
|
+
},
|
|
1593
|
+
};
|
|
1594
|
+
const svg = await satori(node, {
|
|
1595
|
+
width: 1200,
|
|
1596
|
+
height: 630,
|
|
1597
|
+
fonts: [
|
|
1598
|
+
{ name: 'Inter', data: interRegular, weight: 400, style: 'normal' },
|
|
1599
|
+
{ name: 'Inter', data: interBold, weight: 700, style: 'normal' },
|
|
1600
|
+
{ name: 'JetBrains Mono', data: jetbrainsMono, weight: 600, style: 'normal' },
|
|
1601
|
+
],
|
|
1602
|
+
});
|
|
1603
|
+
return new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();
|
|
1604
|
+
}
|
|
1605
|
+
|
|
816
1606
|
async function defaultVerifyPhoenixToken(request, env) {
|
|
817
1607
|
const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
|
|
818
1608
|
if (!presented) return null;
|
|
@@ -838,17 +1628,28 @@ async function defaultVerifyPhoenixToken(request, env) {
|
|
|
838
1628
|
return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
|
|
839
1629
|
}
|
|
840
1630
|
|
|
841
|
-
//
|
|
842
|
-
//
|
|
1631
|
+
// Write principals, in order: WRITE_TOKEN equality (admin/BYO), then a Phoenix
|
|
1632
|
+
// bearer, then the signed-in viewer's __share cookie. The cookie is an
|
|
1633
|
+
// HMAC-signed {userId,email} proof (identityFromCookie verifies it), which lets
|
|
1634
|
+
// the shared page's inline visibility control PATCH with credentials:'include'
|
|
1635
|
+
// and no bearer — the browser has the identity cookie, not a token. It cannot be
|
|
1636
|
+
// abused cross-site: the cookie is SameSite=Lax, which blocks it from riding a
|
|
1637
|
+
// cross-origin non-GET (CSRF), and namespace/owner enforcement on PUT/PATCH/
|
|
1638
|
+
// DELETE still confines a cookie holder to their own pages. 401 when none apply.
|
|
843
1639
|
async function authorizeWrite(request, env) {
|
|
844
1640
|
const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
|
|
845
|
-
if (
|
|
846
|
-
|
|
847
|
-
|
|
1641
|
+
if (presented) {
|
|
1642
|
+
if (env.WRITE_TOKEN && safeEqual(presented, env.WRITE_TOKEN)) {
|
|
1643
|
+
return { kind: 'byo', owner: env.SHARE_NAMESPACE || 'byo' };
|
|
1644
|
+
}
|
|
1645
|
+
const claims = await hooks.verifyPhoenixToken(request, env);
|
|
1646
|
+
if (claims && typeof claims.userId === 'string' && claims.userId) {
|
|
1647
|
+
return { kind: 'phoenix', owner: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' };
|
|
1648
|
+
}
|
|
848
1649
|
}
|
|
849
|
-
const
|
|
850
|
-
if (
|
|
851
|
-
return { kind: 'phoenix', owner:
|
|
1650
|
+
const cookieId = await identityFromCookie(request, env);
|
|
1651
|
+
if (cookieId && cookieId.userId) {
|
|
1652
|
+
return { kind: 'phoenix', owner: cookieId.userId, email: typeof cookieId.email === 'string' ? cookieId.email : '' };
|
|
852
1653
|
}
|
|
853
1654
|
return { error: json({ error: 'unauthorized' }, 401) };
|
|
854
1655
|
}
|