@phnx-labs/agents-cli 1.22.114 → 1.22.115
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 +110 -0
- package/README.md +22 -102
- package/dist/browser.js +0 -0
- package/dist/cli/command-registry.js +0 -7
- package/dist/commands/accounts.d.ts +3 -77
- package/dist/commands/accounts.js +13 -366
- package/dist/commands/audit.js +1 -1
- package/dist/commands/auth.js +0 -2
- package/dist/commands/browser.d.ts +53 -0
- package/dist/commands/browser.js +199 -9
- package/dist/commands/doctor.d.ts +2 -3
- package/dist/commands/doctor.js +0 -11
- package/dist/commands/events.d.ts +1 -3
- package/dist/commands/events.js +4 -7
- package/dist/commands/exec.d.ts +0 -1
- package/dist/commands/exec.js +2 -7
- package/dist/commands/feed-watch.js +52 -8
- package/dist/commands/focus.js +1 -1
- package/dist/commands/go.d.ts +0 -17
- package/dist/commands/go.js +2 -19
- package/dist/commands/logs.js +1 -1
- package/dist/commands/mcp.js +8 -83
- package/dist/commands/memory.js +4 -47
- package/dist/commands/message.js +4 -4
- package/dist/commands/plugins.js +1 -93
- package/dist/commands/repo.js +0 -44
- package/dist/commands/resume.js +4 -1
- package/dist/commands/secrets-passthrough.js +2 -2
- package/dist/commands/send.d.ts +4 -4
- package/dist/commands/send.js +6 -51
- package/dist/commands/sessions-backup-setup.js +1 -1
- package/dist/commands/sessions-resume.js +0 -1
- package/dist/commands/sessions-share.d.ts +5 -7
- package/dist/commands/sessions-share.js +98 -49
- package/dist/commands/sessions.js +1 -8
- package/dist/commands/setup-browser.js +18 -2
- package/dist/commands/setup-computer.js +20 -6
- package/dist/commands/setup-secrets.js +24 -4
- package/dist/commands/setup-terminal.d.ts +3 -0
- package/dist/commands/setup-terminal.js +22 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +20 -10
- package/dist/commands/skills.js +0 -8
- package/dist/commands/ssh.d.ts +6 -0
- package/dist/commands/ssh.js +92 -233
- package/dist/commands/sync.js +14 -5
- package/dist/commands/teams.js +1 -1
- package/dist/commands/traces.js +1 -1
- package/dist/lib/accounts/add.d.ts +0 -5
- package/dist/lib/accounts/add.js +1 -7
- package/dist/lib/artifacts-client.d.ts +20 -0
- package/dist/lib/artifacts-client.js +46 -0
- package/dist/lib/auth-mint.js +2 -2
- package/dist/lib/browser/runtime-state.d.ts +55 -0
- package/dist/lib/browser/runtime-state.js +99 -18
- package/dist/lib/browser/service.js +21 -1
- package/dist/lib/cli-resources.js +3 -1
- package/dist/lib/cloud/dispatch.js +1 -1
- package/dist/lib/cloudflare/creds.d.ts +10 -0
- package/dist/lib/cloudflare/creds.js +46 -0
- package/dist/lib/cloudflare/provision.d.ts +35 -0
- package/dist/lib/cloudflare/provision.js +144 -0
- package/dist/lib/computer/sessions-list.d.ts +55 -0
- package/dist/lib/computer/sessions-list.js +168 -1
- package/dist/lib/daemon/daemon.js +8 -1
- package/dist/lib/daemon/feed-stream-service.d.ts +23 -0
- package/dist/lib/daemon/feed-stream-service.js +40 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/devices/connect.d.ts +49 -5
- package/dist/lib/devices/connect.js +169 -21
- package/dist/lib/feed/envelope.d.ts +79 -0
- package/dist/lib/feed/envelope.js +23 -0
- package/dist/lib/feed/events.d.ts +6 -0
- package/dist/lib/feed/events.js +8 -0
- package/dist/lib/feed/hub-server.d.ts +86 -0
- package/dist/lib/feed/hub-server.js +334 -0
- package/dist/lib/feed/hub.d.ts +95 -0
- package/dist/lib/feed/hub.js +255 -0
- package/dist/lib/feed/tool-activity.d.ts +108 -0
- package/dist/lib/feed/tool-activity.js +313 -0
- package/dist/lib/feed/tools.d.ts +198 -0
- package/dist/lib/feed/tools.js +265 -0
- package/dist/lib/feed/watch.d.ts +50 -50
- package/dist/lib/feed/watch.js +147 -16
- package/dist/lib/format.d.ts +1 -1
- package/dist/lib/format.js +1 -1
- package/dist/lib/git.d.ts +0 -16
- package/dist/lib/git.js +0 -58
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/hosts/remote-cmd.d.ts +51 -1
- package/dist/lib/hosts/remote-cmd.js +125 -8
- package/dist/lib/hosts/remote-cmd.test-fixture.d.ts +2 -0
- package/dist/lib/hosts/remote-cmd.test-fixture.js +22 -0
- package/dist/lib/mcp.js +17 -11
- package/dist/lib/probe.d.ts +4 -1
- package/dist/lib/probe.js +5 -2
- package/dist/lib/pwsh.d.ts +33 -0
- package/dist/lib/pwsh.js +56 -0
- package/dist/lib/redact.d.ts +8 -0
- package/dist/lib/redact.js +11 -0
- package/dist/lib/refresh.d.ts +6 -2
- package/dist/lib/refresh.js +92 -72
- package/dist/lib/secrets-cli.d.ts +11 -0
- package/dist/lib/secrets-cli.js +30 -0
- package/dist/lib/secrets-client.js +3 -2
- package/dist/lib/session/detached.d.ts +7 -0
- package/dist/lib/session/detached.js +29 -0
- package/dist/lib/session/remote/peer-stream.d.ts +24 -2
- package/dist/lib/session/remote/peer-stream.js +33 -6
- package/dist/lib/session/sync/backend.d.ts +3 -3
- package/dist/lib/session/sync/backend.js +3 -3
- package/dist/lib/session/sync/provision.d.ts +1 -1
- package/dist/lib/session/sync/provision.js +2 -2
- package/dist/lib/sessions-client.js +0 -3
- package/dist/lib/setup-tool-install.d.ts +3 -0
- package/dist/lib/setup-tool-install.js +26 -0
- package/dist/lib/setup-tool-status.d.ts +22 -0
- package/dist/lib/setup-tool-status.js +215 -0
- package/dist/lib/share-runtime.d.ts +11 -0
- package/dist/lib/share-runtime.js +63 -0
- package/dist/lib/smart-launch.d.ts +1 -5
- package/dist/lib/smart-launch.js +3 -11
- package/dist/lib/ssh-exec.d.ts +44 -0
- package/dist/lib/ssh-exec.js +119 -0
- package/dist/lib/startup/command-registry.d.ts +6 -4
- package/dist/lib/startup/command-registry.js +10 -7
- package/dist/lib/state.js +2 -2
- package/dist/lib/storage/selection.d.ts +2 -2
- package/dist/lib/storage/selection.js +2 -2
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +18 -10
- package/dist/lib/traces/backend.d.ts +1 -2
- package/dist/lib/traces/backend.js +1 -2
- package/dist/lib/traces/provision.d.ts +1 -1
- package/dist/lib/traces/provision.js +2 -2
- package/dist/lib/types.d.ts +8 -6
- package/package.json +2 -3
- package/dist/commands/artifacts-setup.d.ts +0 -53
- package/dist/commands/artifacts-setup.js +0 -161
- package/dist/commands/artifacts.d.ts +0 -18
- package/dist/commands/artifacts.js +0 -58
- package/dist/commands/attach.d.ts +0 -12
- package/dist/commands/attach.js +0 -86
- package/dist/commands/auth-mint.d.ts +0 -12
- package/dist/commands/auth-mint.js +0 -108
- package/dist/commands/reconnect.d.ts +0 -46
- package/dist/commands/reconnect.js +0 -115
- package/dist/commands/share.d.ts +0 -293
- package/dist/commands/share.js +0 -1424
- package/dist/lib/share/analytics.d.ts +0 -13
- package/dist/lib/share/analytics.js +0 -45
- package/dist/lib/share/backend.d.ts +0 -120
- package/dist/lib/share/backend.js +0 -176
- package/dist/lib/share/capture.d.ts +0 -31
- package/dist/lib/share/capture.js +0 -174
- package/dist/lib/share/config.d.ts +0 -72
- package/dist/lib/share/config.js +0 -211
- package/dist/lib/share/delete.d.ts +0 -123
- package/dist/lib/share/delete.js +0 -173
- package/dist/lib/share/html.d.ts +0 -20
- package/dist/lib/share/html.js +0 -88
- package/dist/lib/share/http-error.d.ts +0 -53
- package/dist/lib/share/http-error.js +0 -65
- package/dist/lib/share/og.d.ts +0 -26
- package/dist/lib/share/og.js +0 -84
- package/dist/lib/share/provision.d.ts +0 -127
- package/dist/lib/share/provision.js +0 -285
- package/dist/lib/share/publish.d.ts +0 -379
- package/dist/lib/share/publish.js +0 -818
- package/dist/lib/share/worker-template.d.ts +0 -27
- package/dist/lib/share/worker-template.js +0 -2424
- package/dist/lib/storage/index.d.ts +0 -14
- package/dist/lib/storage/index.js +0 -14
- package/dist/lib/storage/visibility.d.ts +0 -82
- package/dist/lib/storage/visibility.js +0 -99
|
@@ -1,2424 +0,0 @@
|
|
|
1
|
-
import { buildSync } from 'esbuild';
|
|
2
|
-
import { dirname } from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
let bundledWorker;
|
|
5
|
-
let nodeWorkerScript;
|
|
6
|
-
/** Bundle the Worker renderer into an ES module plus workerd-compiled WASM modules. */
|
|
7
|
-
export function renderWorkerBundle() {
|
|
8
|
-
if (bundledWorker)
|
|
9
|
-
return bundledWorker;
|
|
10
|
-
const result = buildSync({
|
|
11
|
-
stdin: {
|
|
12
|
-
contents: renderWorkerSource(),
|
|
13
|
-
loader: 'js',
|
|
14
|
-
resolveDir: dirname(fileURLToPath(import.meta.url)),
|
|
15
|
-
sourcefile: 'agents-share-worker.js',
|
|
16
|
-
},
|
|
17
|
-
bundle: true,
|
|
18
|
-
format: 'esm',
|
|
19
|
-
platform: 'browser',
|
|
20
|
-
target: 'es2022',
|
|
21
|
-
write: false,
|
|
22
|
-
outdir: 'worker-bundle',
|
|
23
|
-
assetNames: '[name]-[hash]',
|
|
24
|
-
minify: true,
|
|
25
|
-
// Fonts are plain data and can live in JavaScript. WASM must remain a
|
|
26
|
-
// compiled module: workerd deliberately forbids runtime code generation,
|
|
27
|
-
// so esbuild's `binary` loader produces a bundle that works in Node but
|
|
28
|
-
// throws "Wasm code generation disallowed by embedder" in Cloudflare.
|
|
29
|
-
loader: { '.wasm': 'copy', '.woff': 'binary' },
|
|
30
|
-
});
|
|
31
|
-
const script = result.outputFiles.find((output) => output.path.endsWith('.js'));
|
|
32
|
-
if (!script)
|
|
33
|
-
throw new Error('Worker bundling produced no JavaScript output.');
|
|
34
|
-
const modules = result.outputFiles
|
|
35
|
-
.filter((output) => output.path.endsWith('.wasm'))
|
|
36
|
-
.map((output) => ({
|
|
37
|
-
name: output.path.split('/').pop(),
|
|
38
|
-
contentType: 'application/wasm',
|
|
39
|
-
contents: new Uint8Array(output.contents),
|
|
40
|
-
}));
|
|
41
|
-
if (modules.length !== 2) {
|
|
42
|
-
throw new Error(`Worker bundling produced ${modules.length} WASM modules; expected yoga and resvg.`);
|
|
43
|
-
}
|
|
44
|
-
bundledWorker = { script: script.text, modules };
|
|
45
|
-
return bundledWorker;
|
|
46
|
-
}
|
|
47
|
-
/** Single-file representation for direct Node tests, which cannot import compiled WASM modules. */
|
|
48
|
-
export function renderWorkerScript() {
|
|
49
|
-
if (nodeWorkerScript)
|
|
50
|
-
return nodeWorkerScript;
|
|
51
|
-
const result = buildSync({
|
|
52
|
-
stdin: {
|
|
53
|
-
contents: renderWorkerSource(),
|
|
54
|
-
loader: 'js',
|
|
55
|
-
resolveDir: dirname(fileURLToPath(import.meta.url)),
|
|
56
|
-
sourcefile: 'agents-share-worker.js',
|
|
57
|
-
},
|
|
58
|
-
bundle: true,
|
|
59
|
-
format: 'esm',
|
|
60
|
-
platform: 'browser',
|
|
61
|
-
target: 'es2022',
|
|
62
|
-
write: false,
|
|
63
|
-
minify: true,
|
|
64
|
-
loader: { '.wasm': 'binary', '.woff': 'binary' },
|
|
65
|
-
});
|
|
66
|
-
const output = result.outputFiles[0];
|
|
67
|
-
if (!output)
|
|
68
|
-
throw new Error('Worker test bundling produced no JavaScript output.');
|
|
69
|
-
nodeWorkerScript = output.text;
|
|
70
|
-
return nodeWorkerScript;
|
|
71
|
-
}
|
|
72
|
-
/** Unbundled Worker source. Kept separate so esbuild can resolve npm modules. */
|
|
73
|
-
export function renderWorkerSource() {
|
|
74
|
-
return `import satori, { init as initSatori } from 'satori/wasm';
|
|
75
|
-
import initYoga from 'yoga-wasm-web';
|
|
76
|
-
import { Resvg, initWasm as initResvg } from '@resvg/resvg-wasm';
|
|
77
|
-
import yogaWasm from 'yoga-wasm-web/dist/yoga.wasm';
|
|
78
|
-
import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm';
|
|
79
|
-
import interRegular from '@fontsource/inter/files/inter-latin-400-normal.woff';
|
|
80
|
-
import interBold from '@fontsource/inter/files/inter-latin-700-normal.woff';
|
|
81
|
-
import jetbrainsMono from '@fontsource/jetbrains-mono/files/jetbrains-mono-latin-600-normal.woff';
|
|
82
|
-
|
|
83
|
-
// GENERATED by agents-cli agents share setup — do not edit here; edit
|
|
84
|
-
// src/lib/share/worker-template.ts and re-run setup.
|
|
85
|
-
export const hooks = {
|
|
86
|
-
verifyPhoenixToken: defaultVerifyPhoenixToken,
|
|
87
|
-
renderOgCard: renderOgCard,
|
|
88
|
-
collabFetch: defaultCollabFetch,
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
export default {
|
|
92
|
-
async fetch(request, env, ctx) {
|
|
93
|
-
const url = new URL(request.url);
|
|
94
|
-
const path = decodeURIComponent(url.pathname.replace(/^\\/+/, ''));
|
|
95
|
-
|
|
96
|
-
if (!path) {
|
|
97
|
-
return new Response('agents share — POST is not it; PUT /<username>/<slug> to publish, GET /<username>/<slug> to view.', {
|
|
98
|
-
status: 200, headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const firstSeg = path.split('/').filter(Boolean)[0] || '';
|
|
103
|
-
|
|
104
|
-
// Same-origin human collaboration transport (PHNX-3835). Every /__collab/*
|
|
105
|
-
// verb loads the R2 share object FIRST, re-derives identity/visibility/
|
|
106
|
-
// provenance server-side, reuses the exact page read gate, then proxies to
|
|
107
|
-
// the Prix artifact-collaboration API with the service token (never surfaced
|
|
108
|
-
// to the browser). Routed BEFORE the __-prefix GET 404 guard below because
|
|
109
|
-
// GET /__collab/context|threads|events are first-class. Fails closed (404)
|
|
110
|
-
// when the managed backend is unconfigured — BYO has no Phoenix identity, so
|
|
111
|
-
// managed collaboration correctly does not exist there.
|
|
112
|
-
if (firstSeg === '__collab') {
|
|
113
|
-
return handleCollab(request, env, url, path, ctx);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if ((request.method === 'GET' || request.method === 'HEAD') && firstSeg.startsWith('__')) {
|
|
117
|
-
return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// POST /__ticket — mint a short-lived, single-navigation login ticket for the
|
|
121
|
-
// authenticated owner (PHNX-3370). The CLI (which holds the Phoenix bearer)
|
|
122
|
-
// calls this, then opens the owner's page with ?phoenix_ticket=<t> so the
|
|
123
|
-
// browser trades it for the __share cookie and the inline visibility control
|
|
124
|
-
// goes live. The ticket is SELF-signed with the Worker's own HMAC secret — no
|
|
125
|
-
// external ticket service — and carries the SAME identity authorizeWrite
|
|
126
|
-
// already proved, so it grants nothing the caller didn't already have.
|
|
127
|
-
if (request.method === 'POST' && firstSeg === '__ticket') {
|
|
128
|
-
const auth = await authorizeWrite(request, env);
|
|
129
|
-
if (auth.error) return auth.error;
|
|
130
|
-
const ticket = await signSelfTicket({ userId: auth.owner, email: auth.email || '' }, env);
|
|
131
|
-
if (!ticket) return json({ error: 'ticket minting is not configured' }, 501);
|
|
132
|
-
return json({ ticket: ticket }, 200);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (request.method === 'PUT') {
|
|
136
|
-
const auth = await authorizeWrite(request, env);
|
|
137
|
-
if (auth.error) return auth.error;
|
|
138
|
-
const vis = normalizeVisibility(request.headers.get('x-share-visibility'));
|
|
139
|
-
if (vis.error) return vis.error;
|
|
140
|
-
const visibility = vis.value;
|
|
141
|
-
if (visibility === 'me' || visibility === 'org') {
|
|
142
|
-
const phoenixBase = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
|
|
143
|
-
if (auth.kind !== 'phoenix' || !phoenixBase) {
|
|
144
|
-
return json({ error: 'visibility me/org requires Phoenix identity' }, 400);
|
|
145
|
-
}
|
|
146
|
-
if (visibility === 'org') {
|
|
147
|
-
const domain = emailDomain(auth.email);
|
|
148
|
-
if (!domain) return json({ error: 'org visibility requires a verified email domain' }, 400);
|
|
149
|
-
if (PUBLIC_INBOX_DOMAINS.indexOf(domain) !== -1) {
|
|
150
|
-
return json({ error: 'org visibility cannot use a public email domain', domain: domain }, 400);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// Token-gated read auth (PHNX-3654). A 'private' page is served only to a
|
|
155
|
-
// request carrying the matching viewer key; we store just its SHA-256 hash
|
|
156
|
-
// so the secret never lands in R2 metadata. Any write principal (Phoenix or
|
|
157
|
-
// BYO WRITE_TOKEN) may publish 'private' — the gate is the token, not an
|
|
158
|
-
// identity, unlike me/org. The token is mandatory: a private page with no
|
|
159
|
-
// stored hash fails closed on read, so refuse the write instead.
|
|
160
|
-
let viewerTokenHash = '';
|
|
161
|
-
if (visibility === 'private') {
|
|
162
|
-
const rawToken = request.headers.get('x-share-viewer-token') || '';
|
|
163
|
-
if (!rawToken) return json({ error: 'visibility private requires a viewer token' }, 400);
|
|
164
|
-
viewerTokenHash = await sha256Hex(rawToken);
|
|
165
|
-
}
|
|
166
|
-
const segments = path.split('/').filter(Boolean);
|
|
167
|
-
if (auth.kind === 'phoenix') {
|
|
168
|
-
// The caller's handle is normally derived from the email local-part. An
|
|
169
|
-
// explicit x-share-handle (the CLI's --handle, PHNX-3547) lets a Phoenix
|
|
170
|
-
// user choose a DIFFERENT free handle — the escape hatch when their
|
|
171
|
-
// derived handle is taken or they want a vanity namespace. Sanitized to
|
|
172
|
-
// the same [a-z0-9-] shape; claim rules below bind it first-writer-writes,
|
|
173
|
-
// exactly like a derived handle.
|
|
174
|
-
const requestedRaw = request.headers.get('x-share-handle') || '';
|
|
175
|
-
const requested = sanitizeNamespace(requestedRaw);
|
|
176
|
-
if (requestedRaw && (!requested || requested.length > 63)) {
|
|
177
|
-
return json({ error: 'invalid handle', handle: requestedRaw }, 400);
|
|
178
|
-
}
|
|
179
|
-
const expected = requested || phoenixHandle(auth);
|
|
180
|
-
if (!expected || segments[0] !== expected) {
|
|
181
|
-
return json({ error: 'namespace mismatch', owner: expected }, 403);
|
|
182
|
-
}
|
|
183
|
-
const claimed = await claimHandle(env.BUCKET, expected, auth.owner, auth.email || '');
|
|
184
|
-
if (claimed.error) return claimed.error;
|
|
185
|
-
}
|
|
186
|
-
const expiresAt = request.headers.get('x-share-expires-at') || '';
|
|
187
|
-
// 'unlisted' hides the page from the public gallery + JSON listing while
|
|
188
|
-
// keeping the direct URL world-readable (capability URL, not secret).
|
|
189
|
-
// me/org are also hidden from gallery/listing; GET is identity-gated
|
|
190
|
-
// and always sends X-Robots-Tag: noindex (same as unlisted).
|
|
191
|
-
const contentType = request.headers.get('content-type') || 'text/html; charset=utf-8';
|
|
192
|
-
// Provenance (RUSH-2683): captured client-side from the exec env/git/clock,
|
|
193
|
-
// never invented here — a header is simply absent when the CLI had nothing
|
|
194
|
-
// to say. --meta entries ride one JSON header; reserved keys are stripped
|
|
195
|
-
// from that JSON UNCONDITIONALLY (not just overwritten when a provenance
|
|
196
|
-
// header happens to be present) so a same-named x-share-meta entry can
|
|
197
|
-
// never smuggle through on a publish that carries no agent/session/host/
|
|
198
|
-
// repo/date at all — e.g. a human publishing outside an agent session, or
|
|
199
|
-
// outside a git checkout.
|
|
200
|
-
// Full-Unicode headers (PHNX-2786): the CLI always sends the latin1-safe
|
|
201
|
-
// x-share-<field> (what a pre-Unicode Worker read), and when the fold was
|
|
202
|
-
// lossy ALSO a percent-encoded x-share-<field>-u companion plus an
|
|
203
|
-
// x-share-encoding: percent opt-in. Prefer the decoded companion when the
|
|
204
|
-
// opt-in is present, so a Japanese/emoji title/label/--meta renders in full
|
|
205
|
-
// instead of (unnamed) or a dropped glyph; a malformed companion falls
|
|
206
|
-
// back to the folded value rather than failing the publish.
|
|
207
|
-
const usePercent = (request.headers.get('x-share-encoding') || '') === 'percent';
|
|
208
|
-
const readText = (name) => {
|
|
209
|
-
const folded = request.headers.get('x-share-' + name) || '';
|
|
210
|
-
if (usePercent) {
|
|
211
|
-
const encoded = request.headers.get('x-share-' + name + '-u');
|
|
212
|
-
if (encoded) {
|
|
213
|
-
try {
|
|
214
|
-
return decodeURIComponent(encoded);
|
|
215
|
-
} catch (e) {
|
|
216
|
-
return folded;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return folded;
|
|
221
|
-
};
|
|
222
|
-
const agent = readText('agent');
|
|
223
|
-
const session = readText('session');
|
|
224
|
-
const host = readText('host');
|
|
225
|
-
const repo = readText('repo');
|
|
226
|
-
const date = readText('date');
|
|
227
|
-
const avatar = readText('avatar');
|
|
228
|
-
const label = readText('label');
|
|
229
|
-
const ogTitle = readText('og-title');
|
|
230
|
-
const ogDescription = readText('og-description');
|
|
231
|
-
const labelSource = request.headers.get('x-share-label-source') || '';
|
|
232
|
-
let extraMeta = {};
|
|
233
|
-
// The -u meta companion carries the whole raw object percent-encoded, so a
|
|
234
|
-
// single decode+parse recovers full-Unicode keys and values; fall back to
|
|
235
|
-
// the per-value-folded x-share-meta when it is absent or malformed.
|
|
236
|
-
const metaEncoded = usePercent ? request.headers.get('x-share-meta-u') : null;
|
|
237
|
-
if (metaEncoded) {
|
|
238
|
-
try {
|
|
239
|
-
const parsed = JSON.parse(decodeURIComponent(metaEncoded));
|
|
240
|
-
if (parsed && typeof parsed === 'object') extraMeta = parsed;
|
|
241
|
-
} catch (e) {
|
|
242
|
-
// malformed unicode meta companion — fall through to the folded header
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
if (!Object.keys(extraMeta).length) {
|
|
246
|
-
const metaHeader = request.headers.get('x-share-meta');
|
|
247
|
-
if (metaHeader) {
|
|
248
|
-
try {
|
|
249
|
-
const parsed = JSON.parse(metaHeader);
|
|
250
|
-
if (parsed && typeof parsed === 'object') extraMeta = parsed;
|
|
251
|
-
} catch {
|
|
252
|
-
// malformed --meta header — ignore rather than fail the whole publish
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
const customMetadata = { ...extraMeta };
|
|
257
|
-
// Strip every reserved key UNCONDITIONALLY before re-applying the real
|
|
258
|
-
// provenance below — an if(value)-guarded overwrite alone leaves a
|
|
259
|
-
// same-named --meta entry in place whenever the real header is absent.
|
|
260
|
-
for (const reservedKey of RESERVED_METADATA_KEYS) {
|
|
261
|
-
delete customMetadata[reservedKey];
|
|
262
|
-
}
|
|
263
|
-
if (expiresAt) customMetadata['expires-at'] = expiresAt;
|
|
264
|
-
customMetadata['visibility'] = visibility;
|
|
265
|
-
if (viewerTokenHash) customMetadata['viewer-token-hash'] = viewerTokenHash;
|
|
266
|
-
const owner =
|
|
267
|
-
auth.kind === 'phoenix'
|
|
268
|
-
? auth.owner
|
|
269
|
-
: (env.SHARE_NAMESPACE || segments[0] || 'byo');
|
|
270
|
-
if (owner) customMetadata['owner'] = owner;
|
|
271
|
-
if (visibility === 'org') customMetadata['org_domain'] = emailDomain(auth.email);
|
|
272
|
-
if (agent) customMetadata['agent'] = agent;
|
|
273
|
-
if (session) customMetadata['session'] = session;
|
|
274
|
-
if (host) customMetadata['host'] = host;
|
|
275
|
-
if (repo) customMetadata['repo'] = repo;
|
|
276
|
-
if (date) customMetadata['date'] = date;
|
|
277
|
-
if (avatar) customMetadata['avatar'] = avatar;
|
|
278
|
-
if (label) customMetadata['label'] = label;
|
|
279
|
-
if (labelSource) customMetadata['label-source'] = labelSource;
|
|
280
|
-
if (ogTitle) customMetadata['og-title'] = ogTitle;
|
|
281
|
-
if (ogDescription) customMetadata['og-description'] = ogDescription;
|
|
282
|
-
|
|
283
|
-
// Revision retention (RUSH-2683): R2 has no native object versioning, so a
|
|
284
|
-
// republish over an EXISTING key first copies the current object to
|
|
285
|
-
// <key>/rev-<ts>-<rand> before the canonical key is overwritten. Default
|
|
286
|
-
// keep-all; --no-revision (x-share-no-revision) skips it. The random
|
|
287
|
-
// suffix guards against two rapid overwrites of the same slug colliding on
|
|
288
|
-
// the millisecond, and against a slug whose canonical key already sits 3+
|
|
289
|
-
// segments deep (an unsupported shape outside the CLI) landing on the same
|
|
290
|
-
// literal revision key.
|
|
291
|
-
const noRevision = !!request.headers.get('x-share-no-revision');
|
|
292
|
-
// PHNX-3542: per-user storage quota + object count + per-file size cap +
|
|
293
|
-
// publish rate limit, enforced ONLY for a managed Phoenix identity. A BYO
|
|
294
|
-
// WRITE_TOKEN publish writes to the operator's OWN bucket at their own
|
|
295
|
-
// cost, so it skips all four — a deliberate, documented policy, NOT a
|
|
296
|
-
// silent no-op.
|
|
297
|
-
//
|
|
298
|
-
// Enforcement measures the REAL request body, never a client-declared size.
|
|
299
|
-
// A spoofed-low content-length must NOT (a) slip an oversized body past the
|
|
300
|
-
// per-file cap, (b) let real bytes exceed the total quota, or — most
|
|
301
|
-
// dangerously — (c) reach the destructive revision-copy + canonical
|
|
302
|
-
// overwrite before the size is known and DESTROY the existing page. So for a
|
|
303
|
-
// Phoenix write we buffer the body bounded by the plan's per-file cap and
|
|
304
|
-
// reject on the REAL size BEFORE any write; only then do we copy the
|
|
305
|
-
// revision and store the buffered bytes. BYO streams unbuffered (uncapped,
|
|
306
|
-
// its own bucket) — which also means a BYO body cannot be re-read for a
|
|
307
|
-
// retry, so BYO gets a single conditional attempt below.
|
|
308
|
-
let putBody = request.body;
|
|
309
|
-
let realBytes = 0;
|
|
310
|
-
if (auth.kind === 'phoenix') {
|
|
311
|
-
const limits = planLimits((await readUsage(env, auth.owner)).usage.plan);
|
|
312
|
-
// Fast-reject an HONEST oversized content-length without reading the body.
|
|
313
|
-
// A dishonest (absent or lied-low) length falls through to the bounded
|
|
314
|
-
// read below, which measures the truth.
|
|
315
|
-
const declaredLen = parseInt(request.headers.get('content-length') || '', 10);
|
|
316
|
-
if (Number.isFinite(declaredLen) && declaredLen > limits.maxFileBytes) {
|
|
317
|
-
return json({ error: 'file too large: max ' + limits.maxFileBytes + ' bytes', maxBytes: limits.maxFileBytes, gotBytes: declaredLen }, 413);
|
|
318
|
-
}
|
|
319
|
-
// readBodyBounded aborts the moment it passes the cap, so a chunked/
|
|
320
|
-
// streaming body can never buffer more than the cap (+ one chunk).
|
|
321
|
-
const read = await readBodyBounded(request, limits.maxFileBytes);
|
|
322
|
-
if (read.oversize) {
|
|
323
|
-
return json({ error: 'file too large: max ' + limits.maxFileBytes + ' bytes', maxBytes: limits.maxFileBytes, gotBytes: read.size }, 413);
|
|
324
|
-
}
|
|
325
|
-
realBytes = read.size;
|
|
326
|
-
putBody = read.bytes;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Revision retention + quota charge + canonical write as a BOUNDED
|
|
330
|
-
// compare-and-swap loop (PHNX-3547). The old code read the current object,
|
|
331
|
-
// archived it, then overwrote the canonical key UNCONDITIONALLY: two
|
|
332
|
-
// concurrent republishers both archived the same old version and the
|
|
333
|
-
// loser's new body ended up neither canonical nor retained — silently
|
|
334
|
-
// discarded. R2 has no transactions, so each attempt re-reads the canonical
|
|
335
|
-
// object, archives it as a revision, and overwrites ONLY while the etag
|
|
336
|
-
// still matches the read (onlyIf.etagMatches — the same CAS primitive the
|
|
337
|
-
// PATCH path at :513 and the usage ledger already rely on; R2 returns null
|
|
338
|
-
// on the mismatch instead of storing). A conflicted attempt therefore loses
|
|
339
|
-
// nothing: it re-reads the winner's body, archives THAT as the revision on
|
|
340
|
-
// the next attempt, and lands its own body canonical — both writers survive.
|
|
341
|
-
// Phoenix bytes are buffered above, so retrying is safe; the rate counter
|
|
342
|
-
// and object count advance once (attempt 0) and a conflicted re-charge only
|
|
343
|
-
// bills the growth beyond what this request already paid. A BYO stream is
|
|
344
|
-
// consumed by the first attempt, so it gets one conditional try and a 409
|
|
345
|
-
// asking the caller to retry the whole publish.
|
|
346
|
-
const MAX_PUT_ATTEMPTS = 3;
|
|
347
|
-
let chargedAlready = 0;
|
|
348
|
-
let chargedNewCanonical = false;
|
|
349
|
-
let putResult = null;
|
|
350
|
-
for (let attempt = 0; attempt < MAX_PUT_ATTEMPTS; attempt++) {
|
|
351
|
-
// The current object is needed for BOTH the revision copy and the charge
|
|
352
|
-
// math, and even a first/no-revision publish must read before its
|
|
353
|
-
// conditional write so two concurrent creates cannot both report 200.
|
|
354
|
-
const existing = await env.BUCKET.get(path);
|
|
355
|
-
if (auth.kind === 'phoenix') {
|
|
356
|
-
const existingSize = existing && typeof existing.size === 'number' ? existing.size : 0;
|
|
357
|
-
const newCanonical = !existing;
|
|
358
|
-
// Keeping a revision retains the old canonical bytes AND adds the new
|
|
359
|
-
// ones, so storage grows by the full new size. A no-revision or first
|
|
360
|
-
// publish grows by new minus the bytes it replaces (may be negative on a
|
|
361
|
-
// shrink; the ledger clamps at >= 0).
|
|
362
|
-
const charge = (!noRevision && existing) ? realBytes : realBytes - existingSize;
|
|
363
|
-
// Conflict retry: only the growth beyond what this request already
|
|
364
|
-
// paid. Exact accounting under a lost race is impossible without
|
|
365
|
-
// reconciling the bucket; this stays the ledger's documented
|
|
366
|
-
// best-effort, same as its >= 0 clamp.
|
|
367
|
-
const bill = Math.max(0, charge - chargedAlready);
|
|
368
|
-
chargedAlready += bill;
|
|
369
|
-
const chargeNewCanonical = newCanonical && attempt === 0;
|
|
370
|
-
const charged = await chargeShareWrite(env, auth, {
|
|
371
|
-
charge: bill,
|
|
372
|
-
newCanonical: chargeNewCanonical,
|
|
373
|
-
fileBytes: realBytes,
|
|
374
|
-
countRate: attempt === 0,
|
|
375
|
-
});
|
|
376
|
-
if (charged.error) return charged.error; // rejected BEFORE any destructive write
|
|
377
|
-
if (chargeNewCanonical) chargedNewCanonical = true;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
if (!noRevision && existing) {
|
|
381
|
-
const existingHeaders = new Headers();
|
|
382
|
-
if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(existingHeaders);
|
|
383
|
-
const existingContentType = existingHeaders.get('content-type');
|
|
384
|
-
const revKey = path + '/rev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
|
385
|
-
await env.BUCKET.put(revKey, existing.body, {
|
|
386
|
-
httpMetadata: existingContentType ? { contentType: existingContentType } : undefined,
|
|
387
|
-
customMetadata: existing.customMetadata || {},
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
const putOpts = { httpMetadata: { contentType }, customMetadata };
|
|
392
|
-
// Existing objects use the bare R2Object#etag for CAS; missing objects
|
|
393
|
-
// use R2's create-only condition so a concurrent first writer wins loud.
|
|
394
|
-
if (existing && existing.etag) putOpts.onlyIf = { etagMatches: existing.etag };
|
|
395
|
-
else putOpts.onlyIf = { etagDoesNotMatch: '*' };
|
|
396
|
-
putResult = await env.BUCKET.put(path, putBody, putOpts);
|
|
397
|
-
if (putResult !== null) break;
|
|
398
|
-
// A failed create-only condition means another first publisher won.
|
|
399
|
-
// Do not turn that loser into a republish: the caller must see the race.
|
|
400
|
-
if (!existing) break;
|
|
401
|
-
if (auth.kind !== 'phoenix') break; // BYO stream is spent — cannot retry
|
|
402
|
-
}
|
|
403
|
-
if (putResult === null) {
|
|
404
|
-
if (auth.kind === 'phoenix') {
|
|
405
|
-
await refundShareWrite(env, auth.owner, {
|
|
406
|
-
refund: chargedAlready,
|
|
407
|
-
freeCanonical: chargedNewCanonical,
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
return json({ error: 'publish conflict: another write landed first, retry the publish' }, 409);
|
|
411
|
-
}
|
|
412
|
-
// A managed republish may change the title/description. Invalidate only
|
|
413
|
-
// its generated sibling so the next crawler receives a fresh card; BYO
|
|
414
|
-
// publishes send no OG metadata and keep their explicitly uploaded cover.
|
|
415
|
-
if (ogTitle) await env.BUCKET.delete(path + '.png');
|
|
416
|
-
return json({ ok: true, url: url.origin + '/' + path, expiresAt: expiresAt || null, unlisted: visibility === 'unlisted', visibility }, 200);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
if (request.method === 'PATCH') {
|
|
420
|
-
const auth = await authorizeWrite(request, env);
|
|
421
|
-
if (auth.error) return auth.error;
|
|
422
|
-
const segments = path.split('/').filter(Boolean);
|
|
423
|
-
if (segments.length < 2) return json({ error: 'metadata edit requires /<username>/<slug>' }, 400);
|
|
424
|
-
if (auth.kind === 'phoenix') {
|
|
425
|
-
const expected = phoenixHandle(auth);
|
|
426
|
-
const handle = segments[0];
|
|
427
|
-
if (!expected || handle !== expected) {
|
|
428
|
-
// An alternate handle must already have a claim; unlike PUT, PATCH
|
|
429
|
-
// cannot create a new namespace as a side effect.
|
|
430
|
-
const claim = handle ? await env.BUCKET.get('__handles/' + handle) : null;
|
|
431
|
-
if (!claim) return json({ error: 'namespace mismatch', owner: expected }, 403);
|
|
432
|
-
}
|
|
433
|
-
// Use the same ownership path as PUT/DELETE so the same verified email
|
|
434
|
-
// under a new userId transfers the claim and re-stamps old pages before
|
|
435
|
-
// the per-object ownership check below.
|
|
436
|
-
const owned = await assertHandleOwner(env.BUCKET, handle, auth.owner, auth.email || '');
|
|
437
|
-
if (owned.error) return json({ error: 'forbidden' }, 403);
|
|
438
|
-
}
|
|
439
|
-
const existing = await env.BUCKET.get(path);
|
|
440
|
-
if (!existing) return json({ error: 'share not found', key: path }, 404);
|
|
441
|
-
// Ownership was settled above, the same way DELETE settles it: WRITE_TOKEN
|
|
442
|
-
// is the endpoint owner/admin credential, and a Phoenix caller has proven
|
|
443
|
-
// the handle claim (or, pre-claim, that no rival userId owns the prefix).
|
|
444
|
-
// There is deliberately NO per-object owner comparison here. Pages in a
|
|
445
|
-
// claimed namespace can carry a stamp that is not the claim holder's
|
|
446
|
-
// userId — a BYO WRITE_TOKEN publish stamps owner = the namespace, a page
|
|
447
|
-
// from the same human's earlier userId that transferHandle never saw, or
|
|
448
|
-
// a page with no stamp at all — and the claim holder could DELETE every
|
|
449
|
-
// one of them yet was refused a visibility change (403 'forbidden'), which
|
|
450
|
-
// left confidential pages public with takedown as the only remedy. The
|
|
451
|
-
// claim is the authority. The stamp itself is deliberately NOT rewritten:
|
|
452
|
-
// the anonymous lazy-expiry path refunds the STAMPED owner's usage ledger
|
|
453
|
-
// (see the GET expiry branch + refundShareWrite), and a fleet/BYO page was
|
|
454
|
-
// never charged to a Phoenix ledger — re-stamping it to the caller would
|
|
455
|
-
// credit her quota with bytes and a slot she never paid for on expiry.
|
|
456
|
-
|
|
457
|
-
let edit;
|
|
458
|
-
try { edit = await request.json(); } catch { return json({ error: 'PATCH body must be JSON' }, 400); }
|
|
459
|
-
if (!edit || typeof edit !== 'object') return json({ error: 'PATCH body must be an object' }, 400);
|
|
460
|
-
const metadata = { ...(existing.customMetadata || {}) };
|
|
461
|
-
const previousVisibility = metadata.visibility || 'public';
|
|
462
|
-
let visibilityChanged = false;
|
|
463
|
-
if (Object.prototype.hasOwnProperty.call(edit, 'label')) {
|
|
464
|
-
if (edit.label !== null && typeof edit.label !== 'string') return json({ error: 'label must be a string or null' }, 400);
|
|
465
|
-
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);
|
|
466
|
-
if (edit.label === null) { delete metadata.label; delete metadata['label-source']; }
|
|
467
|
-
else { metadata.label = edit.label; metadata['label-source'] = 'explicit'; }
|
|
468
|
-
}
|
|
469
|
-
// Visibility is a first-class edit field (like label), not an arbitrary
|
|
470
|
-
// --meta entry — 'visibility' is a RESERVED key, so it can only be changed
|
|
471
|
-
// here, never smuggled through edit.meta. me/org require a Phoenix identity
|
|
472
|
-
// and org additionally requires a private (non-public-inbox) email domain,
|
|
473
|
-
// the SAME gate PUT enforces. This is a metadata-only rewrite: the body is
|
|
474
|
-
// untouched, so no revision is created (identical to a label/meta edit).
|
|
475
|
-
if (Object.prototype.hasOwnProperty.call(edit, 'visibility')) {
|
|
476
|
-
const vis = normalizeVisibility(edit.visibility);
|
|
477
|
-
if (vis.error) return vis.error;
|
|
478
|
-
const visibility = vis.value;
|
|
479
|
-
// 'private' can't be set via the metadata-edit route: token-gating needs a
|
|
480
|
-
// fresh viewer key, which only a full publish mints (PHNX-3654). Re-stamping
|
|
481
|
-
// visibility=private here alone would leave the page gated by a hash that
|
|
482
|
-
// never got stored — inaccessible to everyone. Fail loud.
|
|
483
|
-
if (visibility === 'private') return json({ error: 'visibility private must be set at publish time (share --protected)' }, 400);
|
|
484
|
-
if (visibility === 'me' || visibility === 'org') {
|
|
485
|
-
const phoenixBase = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
|
|
486
|
-
if (auth.kind !== 'phoenix' || !phoenixBase) return json({ error: 'visibility me/org requires Phoenix identity' }, 400);
|
|
487
|
-
if (visibility === 'org') {
|
|
488
|
-
const domain = emailDomain(auth.email);
|
|
489
|
-
if (!domain) return json({ error: 'org visibility requires a verified email domain' }, 400);
|
|
490
|
-
if (PUBLIC_INBOX_DOMAINS.indexOf(domain) !== -1) return json({ error: 'org visibility cannot use a public email domain', domain: domain }, 400);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
metadata.visibility = visibility;
|
|
494
|
-
visibilityChanged = visibility !== previousVisibility;
|
|
495
|
-
// org_domain is meaningful only for org — set it there, drop the stale
|
|
496
|
-
// value on any move away from org so it can never gate a later read.
|
|
497
|
-
if (visibility === 'org') metadata.org_domain = emailDomain(auth.email);
|
|
498
|
-
else delete metadata.org_domain;
|
|
499
|
-
}
|
|
500
|
-
const mode = edit.metaMode || 'merge';
|
|
501
|
-
if (mode !== 'merge' && mode !== 'replace') return json({ error: 'metaMode must be merge or replace' }, 400);
|
|
502
|
-
const incoming = edit.meta || {};
|
|
503
|
-
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return json({ error: 'meta must be an object' }, 400);
|
|
504
|
-
for (const key in incoming) {
|
|
505
|
-
if (RESERVED_METADATA_KEYS.indexOf(key) !== -1) return json({ error: 'reserved metadata key', key: key }, 400);
|
|
506
|
-
if (!/^[a-z0-9-]{1,64}$/.test(key) || typeof incoming[key] !== 'string') return json({ error: 'invalid metadata entry', key: key }, 400);
|
|
507
|
-
}
|
|
508
|
-
if (mode === 'replace') {
|
|
509
|
-
for (const key in metadata) if (RESERVED_METADATA_KEYS.indexOf(key) === -1) delete metadata[key];
|
|
510
|
-
}
|
|
511
|
-
for (const key in incoming) metadata[key] = incoming[key];
|
|
512
|
-
const remove = edit.removeMeta || [];
|
|
513
|
-
if (!Array.isArray(remove)) return json({ error: 'removeMeta must be an array' }, 400);
|
|
514
|
-
for (const key of remove) {
|
|
515
|
-
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);
|
|
516
|
-
delete metadata[key];
|
|
517
|
-
}
|
|
518
|
-
if (new TextEncoder().encode(JSON.stringify(extraMetaOf(metadata))).length > 2048) return json({ error: 'metadata exceeds 2048 bytes' }, 400);
|
|
519
|
-
// R2 assigns a fresh uploaded timestamp to every put. Preserve the
|
|
520
|
-
// canonical publication time before this metadata-only rewrite so gallery
|
|
521
|
-
// ordering and list JSON do not pretend the page was republished today.
|
|
522
|
-
if (!metadata['published-at']) metadata['published-at'] = new Date(existing.uploaded).toISOString();
|
|
523
|
-
const httpHeaders = new Headers();
|
|
524
|
-
if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(httpHeaders);
|
|
525
|
-
const putOpts = { httpMetadata: httpHeaders, customMetadata: metadata };
|
|
526
|
-
// onlyIf.etagMatches wants the bare hash (R2Object#etag), not the
|
|
527
|
-
// quoted HTTP header form (R2Object#httpEtag) — passing httpEtag 500s
|
|
528
|
-
// on a real Workers R2 binding ("Conditional ETag should not be
|
|
529
|
-
// wrapped in quotes"), a defect only a real R2 backend surfaces.
|
|
530
|
-
if (existing.etag) putOpts.onlyIf = { etagMatches: existing.etag };
|
|
531
|
-
const putResult = await env.BUCKET.put(path, existing.body, putOpts);
|
|
532
|
-
if (putResult === null) return json({ error: 'conflict', key: path }, 409);
|
|
533
|
-
// A generated cover copies the canonical visibility gate into its own R2
|
|
534
|
-
// metadata. Invalidate it after a successful visibility rewrite so the
|
|
535
|
-
// next cover GET re-gates and renders from the canonical page. Explicitly
|
|
536
|
-
// uploaded BYO siblings carry no marker and remain independent assets.
|
|
537
|
-
if (visibilityChanged) {
|
|
538
|
-
const cover = await env.BUCKET.get(path + '.png');
|
|
539
|
-
if (cover && cover.customMetadata && cover.customMetadata['og-generated'] === 'true') {
|
|
540
|
-
await env.BUCKET.delete(path + '.png');
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
return json({ ok: true, url: url.origin + '/' + path, label: metadata.label || null, meta: extraMetaOf(metadata), visibility: metadata.visibility || 'public', previousVisibility: previousVisibility }, 200);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
547
|
-
// Single-segment path may be a user gallery/listing OR a legacy flat slug.
|
|
548
|
-
// The disambiguator is whether the <seg>/ prefix holds any object: only a
|
|
549
|
-
// genuine per-user namespace does. BOTH the HTML gallery and the
|
|
550
|
-
// machine-readable listing gate on it, so ?format=json on a legacy flat
|
|
551
|
-
// slug (an object stored at the bare key, before per-user namespaces) does
|
|
552
|
-
// NOT hijack it into a fake empty listing — it falls through to the object
|
|
553
|
-
// GET below and serves its real content, exactly as before.
|
|
554
|
-
const segments = path.split('/').filter(Boolean);
|
|
555
|
-
if (segments.length === 1) {
|
|
556
|
-
const list = await env.BUCKET.list({ prefix: segments[0] + '/', limit: 1 });
|
|
557
|
-
const isNamespace = list.objects && list.objects.length > 0;
|
|
558
|
-
if (isNamespace) {
|
|
559
|
-
if (url.searchParams.get('format') === 'json') {
|
|
560
|
-
const scope = await resolveListingScope(request, env, segments[0]);
|
|
561
|
-
if (scope.error) return scope.error;
|
|
562
|
-
return renderListing(env.BUCKET, url.origin, segments[0], request.method, scope.includeHidden);
|
|
563
|
-
}
|
|
564
|
-
return renderGallery(env.BUCKET, url.origin, segments[0], request.method);
|
|
565
|
-
}
|
|
566
|
-
// Not a namespace: fall through. A legacy flat-slug object resolves to its
|
|
567
|
-
// real content; anything else 404s (an empty/nonexistent namespace, which
|
|
568
|
-
// agents share list reads as "nothing published" via the template-hash
|
|
569
|
-
// signal rather than a missing route).
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// Revision history for one canonical <user>/<slug> key (RUSH-2683). Checked
|
|
573
|
-
// before the plain object GET below so the query param routes even though
|
|
574
|
-
// the canonical key itself resolves to a real object. me/org use the same
|
|
575
|
-
// identity gate as the page GET — listing session/host/repo on an unauthed
|
|
576
|
-
// ?revisions=json must not leak that the page exists.
|
|
577
|
-
if (segments.length === 2 && url.searchParams.get('revisions') === 'json') {
|
|
578
|
-
const canonical = await env.BUCKET.get(path);
|
|
579
|
-
const canonicalVis = canonical ? ((canonical.customMetadata && canonical.customMetadata.visibility) || 'public') : 'public';
|
|
580
|
-
// Only a me/org canonical needs the viewer resolved — and only there does
|
|
581
|
-
// a phoenix_ticket failure gate the response, exactly as before this route
|
|
582
|
-
// shared resolveViewer with the page GET. A public/unlisted revision list
|
|
583
|
-
// never invoked ticket redemption, so a stale ticket must not 401 it.
|
|
584
|
-
if (canonical && isIdentityGated(canonicalVis)) {
|
|
585
|
-
const viewer = await resolveViewer(request, env, url);
|
|
586
|
-
if (viewer.redirect) return viewer.redirect;
|
|
587
|
-
if (viewer.error) return viewer.error;
|
|
588
|
-
const denied = await gateVisibility(url, env, canonical, viewer.identity || null);
|
|
589
|
-
if (denied) return denied;
|
|
590
|
-
}
|
|
591
|
-
// A private canonical gates its revision list on the viewer key too
|
|
592
|
-
// (PHNX-3654) — listing session/host/repo of a token-gated page to an
|
|
593
|
-
// unauthenticated caller would leak the very metadata the gate protects.
|
|
594
|
-
if (canonical && canonicalVis === 'private') {
|
|
595
|
-
const viewer = await resolveViewer(request, env, url);
|
|
596
|
-
if (viewer.redirect) return viewer.redirect;
|
|
597
|
-
const tokenDenied = await gateTokenRead(request, url, canonical, viewer.identity || null);
|
|
598
|
-
if (tokenDenied) return tokenDenied;
|
|
599
|
-
}
|
|
600
|
-
return renderRevisions(
|
|
601
|
-
env.BUCKET,
|
|
602
|
-
url.origin,
|
|
603
|
-
path,
|
|
604
|
-
request.method,
|
|
605
|
-
!!canonical && (isIdentityGated(canonicalVis) || canonicalVis === 'private'),
|
|
606
|
-
);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// Managed OG cover: generated siblings are caches, never visibility
|
|
610
|
-
// authorities. Gate every request against the current canonical page and
|
|
611
|
-
// bind each cached render to that page's etag. Re-read after rendering and
|
|
612
|
-
// after storing so a concurrent PATCH cannot publish a card from the old
|
|
613
|
-
// visibility/title/description snapshot. Explicit BYO siblings carry no
|
|
614
|
-
// generated marker and continue through the ordinary object route below.
|
|
615
|
-
if (segments.length === 2 && path.endsWith('.png')) {
|
|
616
|
-
let existingCover = await env.BUCKET.get(path);
|
|
617
|
-
if (!existingCover || (existingCover.customMetadata && existingCover.customMetadata['og-generated'] === 'true')) {
|
|
618
|
-
const pagePath = path.slice(0, -4);
|
|
619
|
-
const viewer = await resolveViewer(request, env, url);
|
|
620
|
-
if (viewer.redirect) return viewer.redirect;
|
|
621
|
-
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
622
|
-
const page = await env.BUCKET.get(pagePath);
|
|
623
|
-
if (!page) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
624
|
-
const pageVisibility = (page.customMetadata && page.customMetadata.visibility) || 'public';
|
|
625
|
-
if (viewer.error && isIdentityGated(pageVisibility)) return viewer.error;
|
|
626
|
-
const denied = await gateVisibility(url, env, page, viewer.identity || null);
|
|
627
|
-
if (denied) return denied;
|
|
628
|
-
// A token-gated page's cover is token-gated too (PHNX-3654): a crawler
|
|
629
|
-
// fetching <slug>.png without the key gets 404, so no preview leaks.
|
|
630
|
-
const coverTokenDenied = await gateTokenRead(request, url, page, viewer.identity || null);
|
|
631
|
-
if (coverTokenDenied) return coverTokenDenied;
|
|
632
|
-
|
|
633
|
-
if (existingCover && existingCover.customMetadata['og-source-etag'] === page.etag) {
|
|
634
|
-
const current = await env.BUCKET.get(pagePath);
|
|
635
|
-
if (!current || current.etag !== page.etag) { existingCover = null; continue; }
|
|
636
|
-
const currentDenied = await gateVisibility(url, env, current, viewer.identity || null);
|
|
637
|
-
if (currentDenied) return currentDenied;
|
|
638
|
-
return new Response(request.method === 'HEAD' ? null : existingCover.body, {
|
|
639
|
-
status: 200,
|
|
640
|
-
headers: managedCoverHeaders(pageVisibility),
|
|
641
|
-
});
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
const pageHtml = await page.text();
|
|
645
|
-
const meta = page.customMetadata || {};
|
|
646
|
-
let png;
|
|
647
|
-
try {
|
|
648
|
-
png = await hooks.renderOgCard({
|
|
649
|
-
title: meta['og-title'] || extractHtmlMeta(pageHtml, 'title') || meta.label || segments[1],
|
|
650
|
-
description: meta['og-description'] || extractHtmlMeta(pageHtml, 'description') || '',
|
|
651
|
-
handle: segments[0],
|
|
652
|
-
visibility: pageVisibility,
|
|
653
|
-
orgDomain: meta.org_domain || '',
|
|
654
|
-
});
|
|
655
|
-
} catch (error) {
|
|
656
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
657
|
-
return new Response('OG card render failed: ' + detail, {
|
|
658
|
-
status: 500,
|
|
659
|
-
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
|
-
const beforeStore = await env.BUCKET.get(pagePath);
|
|
663
|
-
if (!beforeStore || beforeStore.etag !== page.etag) { existingCover = null; continue; }
|
|
664
|
-
await env.BUCKET.put(path, png, {
|
|
665
|
-
httpMetadata: { contentType: 'image/png' },
|
|
666
|
-
customMetadata: { ...meta, 'og-generated': 'true', 'og-source-etag': page.etag },
|
|
667
|
-
});
|
|
668
|
-
const beforeServe = await env.BUCKET.get(pagePath);
|
|
669
|
-
if (!beforeServe || beforeServe.etag !== page.etag) {
|
|
670
|
-
const justStored = await env.BUCKET.get(path);
|
|
671
|
-
if (justStored && justStored.customMetadata && justStored.customMetadata['og-source-etag'] === page.etag) {
|
|
672
|
-
await env.BUCKET.delete(path);
|
|
673
|
-
}
|
|
674
|
-
existingCover = null;
|
|
675
|
-
continue;
|
|
676
|
-
}
|
|
677
|
-
return new Response(request.method === 'HEAD' ? null : png, {
|
|
678
|
-
status: 200,
|
|
679
|
-
headers: managedCoverHeaders(pageVisibility),
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
return new Response('cover changed during rendering; retry', { status: 503, headers: { 'content-type': 'text/plain', 'retry-after': '1' } });
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
const obj = await env.BUCKET.get(path);
|
|
687
|
-
if (!obj) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
688
|
-
const expiresAt = obj.customMetadata && obj.customMetadata['expires-at'];
|
|
689
|
-
if (expiresAt && Date.now() > Date.parse(expiresAt)) {
|
|
690
|
-
await env.BUCKET.delete(path);
|
|
691
|
-
// PHNX-3542: expiry is a lazy delete with no auth context. Refund the
|
|
692
|
-
// stamped owner's quota (bytes + object count) best-effort — a refund
|
|
693
|
-
// failure must never break serving the 410. A canonical page is 2
|
|
694
|
-
// segments; covers are excluded (never charged) so refund is safe here
|
|
695
|
-
// because only canonical pages ever carry an expires-at.
|
|
696
|
-
const expiredOwner = obj.customMetadata && obj.customMetadata['owner'];
|
|
697
|
-
if (expiredOwner && !path.endsWith('.png')) {
|
|
698
|
-
try {
|
|
699
|
-
await refundShareWrite(env, expiredOwner, {
|
|
700
|
-
refund: typeof obj.size === 'number' ? obj.size : 0,
|
|
701
|
-
freeCanonical: path.split('/').filter(Boolean).length === 2,
|
|
702
|
-
});
|
|
703
|
-
} catch (e) {
|
|
704
|
-
// best-effort: serving the expiry 410 must never depend on the refund
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
return new Response('gone — this link has expired', { status: 410, headers: { 'content-type': 'text/plain' } });
|
|
708
|
-
}
|
|
709
|
-
// Resolve the viewer ONCE — needed both to gate me/org reads AND to decide
|
|
710
|
-
// whether THIS viewer OWNS this namespace (the interactive visibility
|
|
711
|
-
// control below). A successful phoenix_ticket redemption (redirect: set the
|
|
712
|
-
// identity cookie, strip the ticket) is honored on ANY page. But a ticket
|
|
713
|
-
// FAILURE (expired/consumed/unreachable) must only gate a page that needs
|
|
714
|
-
// identity — a public/unlisted page never invoked ticket redemption before
|
|
715
|
-
// this refactor, so a stale ticket there must serve anonymously, not 401.
|
|
716
|
-
const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
|
|
717
|
-
const viewer = await resolveViewer(request, env, url);
|
|
718
|
-
if (viewer.redirect) return viewer.redirect;
|
|
719
|
-
if (viewer.error && isIdentityGated(visibility)) return viewer.error;
|
|
720
|
-
const identity = viewer.identity || null;
|
|
721
|
-
const denied = await gateVisibility(url, env, obj, identity);
|
|
722
|
-
if (denied) return denied;
|
|
723
|
-
// Token-gated read auth (PHNX-3654): a 'private' page is served only to a
|
|
724
|
-
// request carrying the matching viewer key (?k= or Bearer), or to its owner.
|
|
725
|
-
// A miss returns 404 — never leaking that the page exists — exactly like a
|
|
726
|
-
// wrong me/org viewer.
|
|
727
|
-
const tokenDenied = await gateTokenRead(request, url, obj, identity);
|
|
728
|
-
if (tokenDenied) return tokenDenied;
|
|
729
|
-
// The owner of the namespace (their handle === the first path segment) gets
|
|
730
|
-
// an interactive visibility control; everyone else keeps the static cue.
|
|
731
|
-
const isOwner = !!identity && handleFromEmail(identity.email) === firstSeg;
|
|
732
|
-
const ownerDomain = identity ? emailDomain(identity.email) : '';
|
|
733
|
-
const headers = new Headers();
|
|
734
|
-
obj.writeHttpMetadata(headers);
|
|
735
|
-
headers.set('etag', obj.httpEtag);
|
|
736
|
-
if (!headers.has('content-type')) headers.set('content-type', 'text/html; charset=utf-8');
|
|
737
|
-
if (visibility === 'me' || visibility === 'org' || visibility === 'private') {
|
|
738
|
-
// Token-gated + identity-gated reads must never be cached by a shared
|
|
739
|
-
// proxy, and never indexed.
|
|
740
|
-
headers.set('cache-control', 'private, no-store');
|
|
741
|
-
headers.set('X-Robots-Tag', 'noindex');
|
|
742
|
-
} else {
|
|
743
|
-
headers.set('cache-control', 'public, max-age=60');
|
|
744
|
-
if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
|
|
745
|
-
}
|
|
746
|
-
if (request.method === 'HEAD') return new Response(null, { status: 200, headers });
|
|
747
|
-
const ctype = headers.get('content-type') || '';
|
|
748
|
-
// HTML pages ALWAYS get the attribution bar injected at serve time (who
|
|
749
|
-
// shared it, what made it, when, and — the point — a visual VISIBILITY cue),
|
|
750
|
-
// all from stamped metadata. This is unconditional: ?raw does NOT strip the
|
|
751
|
-
// bar from a shared page (the always-on cue from #3122/#3140 is not
|
|
752
|
-
// defeatable with a query param). The body changes, so the R2 etag no
|
|
753
|
-
// longer matches — drop it rather than serve a lying validator.
|
|
754
|
-
if (ctype.indexOf('text/html') !== -1) {
|
|
755
|
-
const rawHtml = await obj.text();
|
|
756
|
-
// Per-slug view counter (RUSH view stats). Stored as a SEPARATE R2 object
|
|
757
|
-
// (__views/<path>) so it never rewrites the page object — a rewrite would
|
|
758
|
-
// reset obj.uploaded and corrupt "last updated". The __-prefix key is GET-
|
|
759
|
-
// blocked for direct requests and lives outside every <user>/ list prefix,
|
|
760
|
-
// so it never leaks into the gallery/listing/revisions. Count only a real
|
|
761
|
-
// visitor's page view: not ?raw (an embed/OG fetch), not the owner's own
|
|
762
|
-
// view, and never HEAD (which returned above). The write rides
|
|
763
|
-
// ctx.waitUntil so it never blocks the response; the displayed count folds
|
|
764
|
-
// in the current view so the visitor sees themselves included.
|
|
765
|
-
const wantsRaw = url.searchParams.get('raw') != null;
|
|
766
|
-
const counting = !wantsRaw && !isOwner;
|
|
767
|
-
const storedViews = await readViews(env, path);
|
|
768
|
-
const views = storedViews + (counting ? 1 : 0);
|
|
769
|
-
if (counting && ctx && typeof ctx.waitUntil === 'function') {
|
|
770
|
-
ctx.waitUntil(writeViews(env, path, views));
|
|
771
|
-
}
|
|
772
|
-
const withBar = injectAttributionBar(rawHtml, obj.customMetadata || {}, firstSeg, {
|
|
773
|
-
isOwner,
|
|
774
|
-
ownerDomain,
|
|
775
|
-
views,
|
|
776
|
-
uploaded: obj.uploaded,
|
|
777
|
-
});
|
|
778
|
-
headers.delete('etag');
|
|
779
|
-
return new Response(withBar, { status: 200, headers });
|
|
780
|
-
}
|
|
781
|
-
// Non-HTML asset. ?raw returns the stored bytes untouched — the escape hatch
|
|
782
|
-
// for <img src>, OG crawlers, iframes, and anyone embedding the asset (the
|
|
783
|
-
// viewer's own media element points back at ?raw). Otherwise a BROWSER
|
|
784
|
-
// navigating directly to a viewable asset (image/video/audio/pdf) gets a
|
|
785
|
-
// lightweight viewer page carrying the same bar; a non-browser fetch (Accept
|
|
786
|
-
// without text/html) falls through to raw bytes so embedding is never broken.
|
|
787
|
-
const wantsRaw = url.searchParams.get('raw') != null;
|
|
788
|
-
const kind = viewableAssetKind(ctype);
|
|
789
|
-
// A token-gated (private) asset is served as raw bytes, NOT wrapped in the
|
|
790
|
-
// viewer page (PHNX-3654): the viewer's inner <img src=…?raw> would drop the
|
|
791
|
-
// ?k= key and 404 (there's no cookie to carry it, unlike me/org), breaking the
|
|
792
|
-
// media. The gate already passed above, so the raw bytes are safe to serve.
|
|
793
|
-
if (!wantsRaw && kind && acceptsHtml(request) && visibility !== 'private') {
|
|
794
|
-
const viewerPage = renderAssetViewer(url.pathname, kind, obj.customMetadata || {}, firstSeg, {
|
|
795
|
-
isOwner,
|
|
796
|
-
ownerDomain,
|
|
797
|
-
});
|
|
798
|
-
const vheaders = new Headers(headers);
|
|
799
|
-
vheaders.set('content-type', 'text/html; charset=utf-8');
|
|
800
|
-
vheaders.delete('etag');
|
|
801
|
-
return new Response(viewerPage, { status: 200, headers: vheaders });
|
|
802
|
-
}
|
|
803
|
-
return new Response(obj.body, { status: 200, headers });
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
if (request.method === 'DELETE') {
|
|
807
|
-
const auth = await authorizeWrite(request, env);
|
|
808
|
-
if (auth.error) return auth.error;
|
|
809
|
-
if (auth.kind === 'phoenix') {
|
|
810
|
-
const handle = phoenixHandle(auth);
|
|
811
|
-
const uid = sanitizeNamespace(auth.owner);
|
|
812
|
-
const delSegments = path.split('/').filter(Boolean);
|
|
813
|
-
// Handle is the public namespace; userId prefix is still deletable so
|
|
814
|
-
// P1 UUID-namespaced objects the same account published can be taken down.
|
|
815
|
-
// A colliding local-part (same handle, different userId) must not be
|
|
816
|
-
// able to DELETE the claimant's pages — consult the claim the same way PUT does.
|
|
817
|
-
if (delSegments[0] && delSegments[0] === uid) {
|
|
818
|
-
// own leftover UUID prefix
|
|
819
|
-
} else if (delSegments[0] && delSegments[0] === handle) {
|
|
820
|
-
const owned = await assertHandleOwner(env.BUCKET, handle, auth.owner, auth.email || '');
|
|
821
|
-
if (owned.error) return owned.error;
|
|
822
|
-
} else if (delSegments[0]) {
|
|
823
|
-
// An explicitly-chosen handle (the CLI's --handle, PHNX-3547): the
|
|
824
|
-
// caller's derived handle differs from the namespace, so the claim is
|
|
825
|
-
// the authority — assertHandleOwner refuses strangers (409) and
|
|
826
|
-
// recovers a same-email account move. No claim at all is a mismatch.
|
|
827
|
-
const claim = await env.BUCKET.get('__handles/' + delSegments[0]);
|
|
828
|
-
if (!claim) return json({ error: 'namespace mismatch', owner: handle || uid }, 403);
|
|
829
|
-
const owned = await assertHandleOwner(env.BUCKET, delSegments[0], auth.owner, auth.email || '');
|
|
830
|
-
if (owned.error) return owned.error;
|
|
831
|
-
} else {
|
|
832
|
-
return json({ error: 'namespace mismatch', owner: handle || uid }, 403);
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
// PHNX-3542: capture the object's size BEFORE deleting so a managed
|
|
836
|
-
// Phoenix owner's quota can be refunded. A canonical page (2 segments, not
|
|
837
|
-
// a .png cover) refunds both bytes and the object count; a retained
|
|
838
|
-
// revision (3+ segments) refunds bytes only; a server-generated .png cover
|
|
839
|
-
// was never charged, so it is skipped entirely.
|
|
840
|
-
let doomed = null;
|
|
841
|
-
const isCover = path.endsWith('.png');
|
|
842
|
-
if (auth.kind === 'phoenix' && !isCover && !firstSeg.startsWith('__')) {
|
|
843
|
-
doomed = typeof env.BUCKET.head === 'function' ? await env.BUCKET.head(path) : await env.BUCKET.get(path);
|
|
844
|
-
}
|
|
845
|
-
await env.BUCKET.delete(path);
|
|
846
|
-
if (doomed) {
|
|
847
|
-
const delSegs = path.split('/').filter(Boolean);
|
|
848
|
-
await refundShareWrite(env, auth.owner, {
|
|
849
|
-
refund: typeof doomed.size === 'number' ? doomed.size : 0,
|
|
850
|
-
freeCanonical: delSegs.length === 2,
|
|
851
|
-
});
|
|
852
|
-
}
|
|
853
|
-
// PHNX-3835: best-effort purge of the deleted share's collaboration
|
|
854
|
-
// threads. Object-first gating already makes comments inaccessible the
|
|
855
|
-
// instant the R2 object is gone (every /__collab request 404s on the
|
|
856
|
-
// missing object), so this is durable cleanup on the Prix side, never the
|
|
857
|
-
// security boundary. Fire-and-forget so a purge failure never fails the
|
|
858
|
-
// delete. Only a managed (Phoenix) canonical page (2 segments, not a
|
|
859
|
-
// cover, not an internal __-key) can carry collaboration.
|
|
860
|
-
const collabCfgDel = collabConfig(env);
|
|
861
|
-
if (
|
|
862
|
-
collabCfgDel &&
|
|
863
|
-
auth.kind === 'phoenix' &&
|
|
864
|
-
!isCover &&
|
|
865
|
-
!firstSeg.startsWith('__') &&
|
|
866
|
-
path.split('/').filter(Boolean).length === 2
|
|
867
|
-
) {
|
|
868
|
-
const purgeOwner = (doomed && doomed.customMetadata && doomed.customMetadata.owner) || auth.owner;
|
|
869
|
-
const purge = purgeCollab(env, collabCfgDel, purgeOwner, path);
|
|
870
|
-
if (ctx && typeof ctx.waitUntil === 'function') ctx.waitUntil(purge);
|
|
871
|
-
else await purge;
|
|
872
|
-
}
|
|
873
|
-
return json({ ok: true, deleted: path }, 200);
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
return new Response('method not allowed', { status: 405 });
|
|
877
|
-
},
|
|
878
|
-
};
|
|
879
|
-
|
|
880
|
-
// A canonical share object's key is exactly 2 segments (<user>/<slug>, or the
|
|
881
|
-
// sibling <user>/<slug>.png OG cover). A retained revision lives a 3rd segment
|
|
882
|
-
// deeper (<user>/<slug>/rev-<ts>-<rand>) — history, not a page in its own
|
|
883
|
-
// right, so every gallery/listing view hides it. A raw external PUT to an
|
|
884
|
-
// already-3-segment-deep path is treated the same way for the same reason:
|
|
885
|
-
// there is no ambiguity to resolve, and the CLI never produces such a key.
|
|
886
|
-
function isRevisionKey(key) {
|
|
887
|
-
return key.split('/').length > 2;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
function publishedAtOf(object) {
|
|
891
|
-
return (object.customMetadata && object.customMetadata['published-at']) || object.uploaded;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
async function renderGallery(bucket, origin, user, method) {
|
|
895
|
-
const objects = [];
|
|
896
|
-
let cursor;
|
|
897
|
-
do {
|
|
898
|
-
const list = await bucket.list({ prefix: user + '/', limit: 1000, cursor, include: ['customMetadata'] });
|
|
899
|
-
objects.push(...(list.objects || []));
|
|
900
|
-
cursor = list.truncated ? list.cursor : undefined;
|
|
901
|
-
} while (cursor);
|
|
902
|
-
|
|
903
|
-
const activeObjects = objects.filter(o => {
|
|
904
|
-
if (isRevisionKey(o.key)) return false;
|
|
905
|
-
if (o.key.endsWith('.png')) return false;
|
|
906
|
-
// Unlisted / me / org pages are reachable by direct URL only — never on the gallery.
|
|
907
|
-
if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility)) return false;
|
|
908
|
-
const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
|
|
909
|
-
return !(expiresAt && Date.now() > Date.parse(expiresAt));
|
|
910
|
-
});
|
|
911
|
-
const items = activeObjects.map(o => {
|
|
912
|
-
const slug = o.key.slice(o.key.indexOf('/') + 1);
|
|
913
|
-
const url = origin + '/' + o.key;
|
|
914
|
-
const label = (o.customMetadata && o.customMetadata['label']) || '';
|
|
915
|
-
const agent = (o.customMetadata && o.customMetadata['agent']) || '';
|
|
916
|
-
return { slug, url, updated: publishedAtOf(o), label, agent };
|
|
917
|
-
});
|
|
918
|
-
|
|
919
|
-
if (method === 'HEAD') {
|
|
920
|
-
return new Response(null, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
const html =
|
|
924
|
-
'<!doctype html><html><head>' +
|
|
925
|
-
'<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">' +
|
|
926
|
-
'<title>' + escapeHtml(user) + ' — agents share</title>' +
|
|
927
|
-
'<style>' +
|
|
928
|
-
'body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:720px;margin:48px auto;padding:0 24px;color:#111;background:#fafafa}' +
|
|
929
|
-
'h1{font-size:28px;margin-bottom:8px}a{color:#0a0a0a;text-decoration:none;border-bottom:1px solid #999}' +
|
|
930
|
-
'a:hover{border-color:#111}ul{list-style:none;padding:0}li{margin:16px 0}' +
|
|
931
|
-
'.slug{font-weight:600}.title{font-weight:600}.meta{color:#666;font-size:14px}' +
|
|
932
|
-
'</style></head><body>' +
|
|
933
|
-
'<h1>@' + escapeHtml(user) + '</h1>' +
|
|
934
|
-
'<p class="meta">' + items.length + ' shared ' + (items.length === 1 ? 'page' : 'pages') + '</p>' +
|
|
935
|
-
'<ul>' +
|
|
936
|
-
items.map(i =>
|
|
937
|
-
'<li>' +
|
|
938
|
-
(i.label ? '<span class="title">' + escapeHtml(i.label) + '</span><br>' : '') +
|
|
939
|
-
'<a class="slug" href="' + escapeHtml(i.url) + '">' + escapeHtml(i.slug) + '</a>' +
|
|
940
|
-
'<br><span class="meta">' + new Date(i.updated).toISOString().slice(0, 10) +
|
|
941
|
-
(i.agent ? ' · ' + escapeHtml(i.agent) : '') + '</span></li>'
|
|
942
|
-
).join('') +
|
|
943
|
-
'</ul></body></html>';
|
|
944
|
-
return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60' } });
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
async function renderListing(bucket, origin, user, method, includeHidden) {
|
|
948
|
-
const objects = [];
|
|
949
|
-
let cursor;
|
|
950
|
-
do {
|
|
951
|
-
const list = await bucket.list({ prefix: user + '/', limit: 1000, cursor, include: ['httpMetadata', 'customMetadata'] });
|
|
952
|
-
objects.push(...(list.objects || []));
|
|
953
|
-
cursor = list.truncated ? list.cursor : undefined;
|
|
954
|
-
} while (cursor);
|
|
955
|
-
|
|
956
|
-
// Revisions never appear as their own listing row, but they count toward
|
|
957
|
-
// their canonical object's revisionCount.
|
|
958
|
-
const revisionCounts = {};
|
|
959
|
-
for (const o of objects) {
|
|
960
|
-
if (!isRevisionKey(o.key)) continue;
|
|
961
|
-
const canonicalKey = o.key.split('/').slice(0, 2).join('/');
|
|
962
|
-
revisionCounts[canonicalKey] = (revisionCounts[canonicalKey] || 0) + 1;
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
const now = Date.now();
|
|
966
|
-
const items = objects
|
|
967
|
-
.filter(o => {
|
|
968
|
-
// Revisions and sibling .png OG covers are always hidden. Hidden-visibility
|
|
969
|
-
// pages are included ONLY when an authenticated owner requested scope=mine.
|
|
970
|
-
if (isRevisionKey(o.key)) return false;
|
|
971
|
-
if (o.key.endsWith('.png')) return false;
|
|
972
|
-
if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility) && !includeHidden) return false;
|
|
973
|
-
const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
|
|
974
|
-
return !(expiresAt && now > Date.parse(expiresAt));
|
|
975
|
-
})
|
|
976
|
-
.map(o => ({
|
|
977
|
-
slug: o.key.slice(o.key.indexOf('/') + 1),
|
|
978
|
-
url: origin + '/' + o.key,
|
|
979
|
-
size: typeof o.size === 'number' ? o.size : 0,
|
|
980
|
-
contentType: (o.httpMetadata && o.httpMetadata.contentType) || null,
|
|
981
|
-
publishedAt: new Date(publishedAtOf(o)).toISOString(),
|
|
982
|
-
expiresAt: (o.customMetadata && o.customMetadata['expires-at']) || null,
|
|
983
|
-
label: (o.customMetadata && o.customMetadata['label']) || null,
|
|
984
|
-
visibility: (o.customMetadata && o.customMetadata.visibility) || 'public',
|
|
985
|
-
agent: (o.customMetadata && o.customMetadata['agent']) || null,
|
|
986
|
-
session: (o.customMetadata && o.customMetadata['session']) || null,
|
|
987
|
-
host: (o.customMetadata && o.customMetadata['host']) || null,
|
|
988
|
-
repo: (o.customMetadata && o.customMetadata['repo']) || null,
|
|
989
|
-
revisionCount: revisionCounts[o.key] || 0,
|
|
990
|
-
meta: extraMetaOf(o.customMetadata),
|
|
991
|
-
}));
|
|
992
|
-
// Newest first, so the human table and any script reads the freshest share top.
|
|
993
|
-
items.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : a.publishedAt > b.publishedAt ? -1 : 0));
|
|
994
|
-
|
|
995
|
-
const cacheHeaders = includeHidden
|
|
996
|
-
? { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'private, no-store', 'X-Robots-Tag': 'noindex' }
|
|
997
|
-
: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' };
|
|
998
|
-
if (method === 'HEAD') {
|
|
999
|
-
return new Response(null, { status: 200, headers: cacheHeaders });
|
|
1000
|
-
}
|
|
1001
|
-
return new Response(JSON.stringify({ user, count: items.length, objects: items }), {
|
|
1002
|
-
status: 200,
|
|
1003
|
-
headers: cacheHeaders,
|
|
1004
|
-
});
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
// The machine-readable listing is public by default. A request with
|
|
1008
|
-
// '?format=json&scope=mine' asks to include the owner's hidden pages
|
|
1009
|
-
// (unlisted / me / org). We honor it ONLY after a valid owner bearer whose
|
|
1010
|
-
// namespace matches the requested handle, so one user cannot list another's
|
|
1011
|
-
// hidden shares. Anonymous or mismatched requests fail loud. A BYO WRITE_TOKEN
|
|
1012
|
-
// bearer is the worker's owner, so namespace enforcement is skipped for BYO.
|
|
1013
|
-
async function resolveListingScope(request, env, user) {
|
|
1014
|
-
const scope = new URL(request.url).searchParams.get('scope');
|
|
1015
|
-
if (scope !== 'mine') return { includeHidden: false };
|
|
1016
|
-
const auth = await authorizeWrite(request, env);
|
|
1017
|
-
if (auth.error) return { error: auth.error };
|
|
1018
|
-
if (auth.kind === 'byo') {
|
|
1019
|
-
return { includeHidden: true };
|
|
1020
|
-
}
|
|
1021
|
-
const owner = phoenixHandle(auth);
|
|
1022
|
-
if (owner !== user) {
|
|
1023
|
-
return { error: json({ error: 'namespace mismatch' }, 403) };
|
|
1024
|
-
}
|
|
1025
|
-
return { includeHidden: true };
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
async function renderRevisions(bucket, origin, key, method, identityGated) {
|
|
1029
|
-
const objects = [];
|
|
1030
|
-
let cursor;
|
|
1031
|
-
do {
|
|
1032
|
-
const list = await bucket.list({ prefix: key + '/rev-', limit: 1000, cursor, include: ['httpMetadata', 'customMetadata'] });
|
|
1033
|
-
objects.push(...(list.objects || []));
|
|
1034
|
-
cursor = list.truncated ? list.cursor : undefined;
|
|
1035
|
-
} while (cursor);
|
|
1036
|
-
|
|
1037
|
-
const items = objects.map(o => ({
|
|
1038
|
-
key: o.key,
|
|
1039
|
-
url: origin + '/' + o.key,
|
|
1040
|
-
size: typeof o.size === 'number' ? o.size : 0,
|
|
1041
|
-
contentType: (o.httpMetadata && o.httpMetadata.contentType) || null,
|
|
1042
|
-
uploadedAt: new Date(o.uploaded).toISOString(),
|
|
1043
|
-
expiresAt: (o.customMetadata && o.customMetadata['expires-at']) || null,
|
|
1044
|
-
label: (o.customMetadata && o.customMetadata['label']) || null,
|
|
1045
|
-
agent: (o.customMetadata && o.customMetadata['agent']) || null,
|
|
1046
|
-
session: (o.customMetadata && o.customMetadata['session']) || null,
|
|
1047
|
-
host: (o.customMetadata && o.customMetadata['host']) || null,
|
|
1048
|
-
repo: (o.customMetadata && o.customMetadata['repo']) || null,
|
|
1049
|
-
meta: extraMetaOf(o.customMetadata),
|
|
1050
|
-
}));
|
|
1051
|
-
// Newest first — the most recently replaced version leads.
|
|
1052
|
-
items.sort((a, b) => (a.uploadedAt < b.uploadedAt ? 1 : a.uploadedAt > b.uploadedAt ? -1 : 0));
|
|
1053
|
-
|
|
1054
|
-
const cacheHeaders = identityGated
|
|
1055
|
-
? { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'private, no-store', 'X-Robots-Tag': 'noindex' }
|
|
1056
|
-
: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' };
|
|
1057
|
-
if (method === 'HEAD') {
|
|
1058
|
-
return new Response(null, { status: 200, headers: cacheHeaders });
|
|
1059
|
-
}
|
|
1060
|
-
return new Response(JSON.stringify({ key, count: items.length, revisions: items }), {
|
|
1061
|
-
status: 200,
|
|
1062
|
-
headers: cacheHeaders,
|
|
1063
|
-
});
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
// The customMetadata keys the CLI sets automatically (provenance + label) —
|
|
1067
|
-
// never a real \`--meta key=value\` entry (the CLI rejects a colliding key
|
|
1068
|
-
// before it ever reaches this Worker; see RESERVED_META_KEYS in publish.ts).
|
|
1069
|
-
// One list, reused both to strip a same-named --meta collision on write and
|
|
1070
|
-
// to split arbitrary --meta entries back out on read.
|
|
1071
|
-
var RESERVED_METADATA_KEYS = ['expires-at', 'published-at', 'visibility', 'viewer-token-hash', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'avatar', 'label', 'label-source', 'og-title', 'og-description', 'og-generated', 'og-source-etag'];
|
|
1072
|
-
var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'icloud.com', 'me.com'];
|
|
1073
|
-
var SHARE_COOKIE = '__Host-phoenix_share';
|
|
1074
|
-
var SHARE_COOKIE_MAX_AGE = 604800;
|
|
1075
|
-
|
|
1076
|
-
// PHNX-3542 — per-user storage limits for the MANAGED share endpoint. The plan
|
|
1077
|
-
// map is the seam for future paid tiers: only 'free' is defined today, and a
|
|
1078
|
-
// ledger with no plan (or an unknown one) resolves to it via planLimits(). Paid
|
|
1079
|
-
// tiers and the write path that sets a user's plan arrive with billing (follow-up
|
|
1080
|
-
// PHNX-3569); this is a legitimately-deferred seam, not a stub — 'free' IS
|
|
1081
|
-
// enforced. Covers/views are server-generated overhead and excluded from the
|
|
1082
|
-
// quota, which counts canonical pages + their retained revisions only.
|
|
1083
|
-
var MiB = 1024 * 1024;
|
|
1084
|
-
var SHARE_PLANS = {
|
|
1085
|
-
free: { maxBytes: 200 * MiB, maxObjects: 150, maxFileBytes: 20 * MiB, ratePerHour: 60 },
|
|
1086
|
-
};
|
|
1087
|
-
var DEFAULT_SHARE_PLAN = 'free';
|
|
1088
|
-
var RATE_WINDOW_MS = 3600 * 1000; // fixed 1h publish-rate window
|
|
1089
|
-
function planLimits(plan) { return SHARE_PLANS[plan] || SHARE_PLANS[DEFAULT_SHARE_PLAN]; }
|
|
1090
|
-
function freshUsage() { return { bytes: 0, count: 0, plan: DEFAULT_SHARE_PLAN, rlStart: 0, rlUsed: 0 }; }
|
|
1091
|
-
function usageNumber(v, fallback) { return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback; }
|
|
1092
|
-
|
|
1093
|
-
// Everything in customMetadata that ISN'T one of the reserved provenance/label
|
|
1094
|
-
// keys above — i.e. the caller's own \`--meta key=value\` entries. Surfaced on
|
|
1095
|
-
// every read route (listing, revisions) so a value stored with \`--meta
|
|
1096
|
-
// kind=plan --meta ticket=RUSH-2683\` is actually visible again, not just
|
|
1097
|
-
// write-only (RUSH-2683 review fix).
|
|
1098
|
-
function extraMetaOf(customMetadata) {
|
|
1099
|
-
var out = {};
|
|
1100
|
-
if (!customMetadata) return out;
|
|
1101
|
-
for (var k in customMetadata) {
|
|
1102
|
-
if (RESERVED_METADATA_KEYS.indexOf(k) === -1) out[k] = customMetadata[k];
|
|
1103
|
-
}
|
|
1104
|
-
return out;
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
function escapeHtml(s) {
|
|
1108
|
-
return String(s)
|
|
1109
|
-
.replace(/&/g, '&')
|
|
1110
|
-
.replace(/</g, '<')
|
|
1111
|
-
.replace(/>/g, '>')
|
|
1112
|
-
.replace(/"/g, '"');
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
// The attribution bar: a slim strip prepended to every served HTML page that
|
|
1116
|
-
// makes the object's stamped metadata visible — who shared it, what made it,
|
|
1117
|
-
// when, and (the whole point) a color-coded VISIBILITY cue Google-Drive style.
|
|
1118
|
-
// All values come from customMetadata; nothing new is stamped at publish time,
|
|
1119
|
-
// so this is a pure Worker change deployable with 'agents artifacts share update'.
|
|
1120
|
-
var VIS_ICON = {
|
|
1121
|
-
me: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="11" width="16" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>',
|
|
1122
|
-
org: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18M6 21V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v17M14 9h4a1 1 0 0 1 1 1v11"/></svg>',
|
|
1123
|
-
unlisted: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>',
|
|
1124
|
-
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>',
|
|
1125
|
-
};
|
|
1126
|
-
|
|
1127
|
-
// Eye glyph for the view-count stat; caret for the owner's interactive chip.
|
|
1128
|
-
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>';
|
|
1129
|
-
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>';
|
|
1130
|
-
|
|
1131
|
-
// The owner-only browser script that turns the visibility chip into a live
|
|
1132
|
-
// control. Self-contained IIFE, namespaced under .agents-share-bar; injected only
|
|
1133
|
-
// when the viewer owns the namespace. It reads the current level and each level's
|
|
1134
|
-
// icon/color/label straight from the rendered menu DOM (no embedded config), then
|
|
1135
|
-
// PATCHes the SAME in-place edit route the CLI uses — method PATCH, JSON body
|
|
1136
|
-
// { visibility } — with credentials:'include' so the viewer's __share cookie /
|
|
1137
|
-
// Phoenix identity rides. Optimistic: flip the chip + spinner, then a green check
|
|
1138
|
-
// on 2xx or revert + the server's error text on failure (an org-on-public-inbox
|
|
1139
|
-
// 400 surfaces verbatim). The share page sets no CSP, so inline JS is fine.
|
|
1140
|
-
var ASH_OWNER_JS = \`(function(){
|
|
1141
|
-
var root = document.querySelector('.agents-share-bar');
|
|
1142
|
-
if(!root) return;
|
|
1143
|
-
var chip = root.querySelector('[data-ash-chip]');
|
|
1144
|
-
var menu = root.querySelector('[data-ash-menu]');
|
|
1145
|
-
var toast = root.querySelector('[data-ash-toast]');
|
|
1146
|
-
if(!chip || !menu || !toast) return;
|
|
1147
|
-
var labelEl = chip.querySelector('[data-ash-label]');
|
|
1148
|
-
var chipIc = chip.querySelector('[data-ash-chip-ic]');
|
|
1149
|
-
var busy = false;
|
|
1150
|
-
function opts(){ return menu.querySelectorAll('[data-ash-opt]'); }
|
|
1151
|
-
function currentKey(){ var s = menu.querySelector('.ash-opt.ash-sel'); return s ? s.getAttribute('data-ash-opt') : ''; }
|
|
1152
|
-
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; }
|
|
1153
|
-
function open(){ menu.classList.add('ash-open'); menu.setAttribute('aria-hidden','false'); }
|
|
1154
|
-
function close(){ menu.classList.remove('ash-open'); menu.setAttribute('aria-hidden','true'); }
|
|
1155
|
-
chip.addEventListener('click', function(e){ e.stopPropagation(); if(menu.classList.contains('ash-open')) close(); else open(); });
|
|
1156
|
-
menu.addEventListener('click', function(e){ e.stopPropagation(); });
|
|
1157
|
-
document.addEventListener('click', close);
|
|
1158
|
-
document.addEventListener('keydown', function(e){ if(e.key==='Escape') close(); });
|
|
1159
|
-
function draw(key){
|
|
1160
|
-
var sel = null, a = opts();
|
|
1161
|
-
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]; }
|
|
1162
|
-
if(!sel) return;
|
|
1163
|
-
var ic = sel.querySelector('.ash-opt-ic');
|
|
1164
|
-
var color = ic ? ic.style.color : '';
|
|
1165
|
-
if(color){ chip.style.color = color; chip.style.borderColor = color + '66'; }
|
|
1166
|
-
if(chipIc && ic) chipIc.innerHTML = ic.innerHTML;
|
|
1167
|
-
var b = sel.querySelector('b');
|
|
1168
|
-
if(labelEl && b) labelEl.textContent = b.textContent;
|
|
1169
|
-
}
|
|
1170
|
-
function say(inner, cls){ toast.innerHTML = inner; toast.className = 'ash-toast ash-show' + (cls ? ' ' + cls : ''); }
|
|
1171
|
-
function fade(ms){ setTimeout(function(){ toast.className = 'ash-toast'; }, ms); }
|
|
1172
|
-
function pick(key){
|
|
1173
|
-
close();
|
|
1174
|
-
var prev = currentKey();
|
|
1175
|
-
if(busy || !key || key===prev) return;
|
|
1176
|
-
busy = true;
|
|
1177
|
-
draw(key);
|
|
1178
|
-
var name = labelFor(key);
|
|
1179
|
-
say('<span class="ash-spin"></span> Saving…');
|
|
1180
|
-
fetch(location.pathname, { method:'PATCH', credentials:'include', headers:{'content-type':'application/json'}, body: JSON.stringify({ visibility: key }) })
|
|
1181
|
-
.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: {} }; }); })
|
|
1182
|
-
.then(function(r){
|
|
1183
|
-
busy = false;
|
|
1184
|
-
if(r.ok){ say('<span class="ash-ok">\\u2713</span> Now ' + name, 'ash-good'); fade(1600); }
|
|
1185
|
-
else {
|
|
1186
|
-
draw(prev);
|
|
1187
|
-
var msg = (r.body && r.body.error) ? String(r.body.error) : ('Could not update (HTTP ' + r.status + ')');
|
|
1188
|
-
say('<span class="ash-err">\\u2715</span> ' + msg.replace(/[<>&]/g, ' '), 'ash-bad');
|
|
1189
|
-
fade(4000);
|
|
1190
|
-
}
|
|
1191
|
-
}, function(){ busy = false; draw(prev); say('<span class="ash-err">\\u2715</span> Network error \\u2014 not saved', 'ash-bad'); fade(4000); });
|
|
1192
|
-
}
|
|
1193
|
-
var a = opts();
|
|
1194
|
-
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]); }
|
|
1195
|
-
})();\`;
|
|
1196
|
-
|
|
1197
|
-
function visibilityChip(visibility, orgDomain) {
|
|
1198
|
-
if (visibility === 'me') return { icon: VIS_ICON.me, label: 'Only you', color: '#f59e0b' };
|
|
1199
|
-
if (visibility === 'org') return { icon: VIS_ICON.org, label: 'Anyone at ' + escapeHtml(orgDomain || 'your organization'), color: '#5b9dff' };
|
|
1200
|
-
if (visibility === 'private') return { icon: VIS_ICON.me, label: 'Protected (link + key)', color: '#f59e0b' };
|
|
1201
|
-
if (visibility === 'unlisted') return { icon: VIS_ICON.unlisted, label: 'Unlisted', color: '#9aa0a6' };
|
|
1202
|
-
return { icon: VIS_ICON.public, label: 'Public', color: '#30a46c' };
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
// A deterministic hue (0-359) from the handle, so a given sharer always gets the
|
|
1206
|
-
// same initials-circle colour without any stored palette.
|
|
1207
|
-
function avatarHue(s) {
|
|
1208
|
-
var h = 0;
|
|
1209
|
-
for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
|
|
1210
|
-
return h;
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
// The avatar slot is ALWAYS rendered: a coloured initials circle derived from the
|
|
1214
|
-
// handle. When the object carries an avatar URL (a Gravatar the CLI stamps at
|
|
1215
|
-
// publish, or any future hosted photo), a real img is layered on top; onerror
|
|
1216
|
-
// removes it so the initials underneath show through when the photo 404s. There
|
|
1217
|
-
// is no CSP on the share page, so the inline onerror fallback is honoured.
|
|
1218
|
-
function renderAvatar(handle, avatarUrl) {
|
|
1219
|
-
var h = (handle || '').trim();
|
|
1220
|
-
var initial = escapeHtml((h.charAt(0) || '?').toUpperCase());
|
|
1221
|
-
var bg = 'hsl(' + avatarHue(h) + ',48%,42%)';
|
|
1222
|
-
var inner = '<span class="ash-av-i">' + initial + '</span>';
|
|
1223
|
-
if (avatarUrl) {
|
|
1224
|
-
inner += '<img class="ash-av-img" src="' + escapeHtml(avatarUrl) + '" alt="" loading="lazy" ' +
|
|
1225
|
-
'referrerpolicy="no-referrer" onerror="this.remove()">';
|
|
1226
|
-
}
|
|
1227
|
-
return '<span class="ash-avatar" style="background:' + bg + ' !important" aria-hidden="true">' + inner + '</span>';
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
// A compact relative time for the "last updated" stat: "just now", "2h ago",
|
|
1231
|
-
// "3d ago", or an ISO date once past ~30 days. Accepts a Date or a parseable
|
|
1232
|
-
// string (R2's obj.uploaded is a Date).
|
|
1233
|
-
function relativeTime(when) {
|
|
1234
|
-
var then = when instanceof Date ? when.getTime() : Date.parse(when);
|
|
1235
|
-
if (!then || isNaN(then)) return '';
|
|
1236
|
-
var diff = Date.now() - then;
|
|
1237
|
-
if (diff < 0) diff = 0;
|
|
1238
|
-
var minute = 60000, hour = 3600000, day = 86400000;
|
|
1239
|
-
if (diff < minute) return 'just now';
|
|
1240
|
-
if (diff < hour) return Math.floor(diff / minute) + 'm ago';
|
|
1241
|
-
if (diff < day) return Math.floor(diff / hour) + 'h ago';
|
|
1242
|
-
if (diff < 30 * day) return Math.floor(diff / day) + 'd ago';
|
|
1243
|
-
return new Date(then).toISOString().slice(0, 10);
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
// The four visibility levels the owner can pick, in menu order. Descriptions
|
|
1247
|
-
// mirror the approved mockup. org always appears — a public-inbox owner still
|
|
1248
|
-
// sees it and lets the server's 400 drive the failure message (never a hidden
|
|
1249
|
-
// client-side rule). orgDomain is the owner's own email domain when known.
|
|
1250
|
-
function visibilityLevels(orgDomain) {
|
|
1251
|
-
return [
|
|
1252
|
-
{ key: 'public', label: 'Public', color: '#30a46c', icon: VIS_ICON.public, desc: 'Anyone with the link · preview card · listed in your gallery' },
|
|
1253
|
-
{ key: 'unlisted', label: 'Unlisted', color: '#9aa0a6', icon: VIS_ICON.unlisted, desc: 'Anyone with the link · no card · hidden from your gallery' },
|
|
1254
|
-
{ key: 'me', label: 'Only you', color: '#f59e0b', icon: VIS_ICON.me, desc: 'Sign-in required · only your account can open it' },
|
|
1255
|
-
{ 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' },
|
|
1256
|
-
];
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
function renderVisibilityMenu(current, orgDomain) {
|
|
1260
|
-
var rows = visibilityLevels(orgDomain).map(function (l) {
|
|
1261
|
-
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') + '">' +
|
|
1262
|
-
'<span class="ash-opt-ic" style="color:' + l.color + '">' + l.icon + '</span>' +
|
|
1263
|
-
'<span class="ash-opt-t"><b>' + l.label + '</b><span>' + l.desc + '</span></span>' +
|
|
1264
|
-
'<span class="ash-rd"></span></button>';
|
|
1265
|
-
}).join('');
|
|
1266
|
-
return '<div class="ash-menu" data-ash-menu role="menu" aria-hidden="true"><h4>Who can see this</h4>' + rows + '</div>';
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
function renderOwnerScript() {
|
|
1270
|
-
return '<script>' + ASH_OWNER_JS + '</script>';
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
// opts: { isOwner, ownerDomain, views, uploaded } — all optional. isOwner turns
|
|
1274
|
-
// the visibility chip into a live control (inline dropdown, Variation A) and
|
|
1275
|
-
// injects the owner-only CSS/JS; views/uploaded render the right-side stats
|
|
1276
|
-
// cluster. With no opts the bar is exactly today's static strip.
|
|
1277
|
-
function renderAttributionBar(meta, handle, opts) {
|
|
1278
|
-
var o = opts || {};
|
|
1279
|
-
var cm = meta || {};
|
|
1280
|
-
var visibility = cm.visibility || 'public';
|
|
1281
|
-
var chip = visibilityChip(visibility, cm.org_domain);
|
|
1282
|
-
var avatar = renderAvatar(handle, cm.avatar);
|
|
1283
|
-
var left = '';
|
|
1284
|
-
// The handle is already the public URL namespace, so surfacing it leaks nothing new.
|
|
1285
|
-
if (handle) left += 'Shared by <strong>' + escapeHtml(handle) + '</strong>';
|
|
1286
|
-
if (cm.agent) left += (left ? '<span class="ash-dot">·</span>' : '') + 'Made with ' + escapeHtml(cm.agent);
|
|
1287
|
-
|
|
1288
|
-
// Stats cluster (👁 <n> views · updated <rel>) — right side, left of the chip.
|
|
1289
|
-
var stats = '';
|
|
1290
|
-
var statBits = '';
|
|
1291
|
-
if (o.views != null) {
|
|
1292
|
-
statBits += STAT_EYE_ICON + '<b>' + escapeHtml(String(o.views)) + '</b> ' + (Number(o.views) === 1 ? 'view' : 'views');
|
|
1293
|
-
}
|
|
1294
|
-
if (o.uploaded) {
|
|
1295
|
-
var rel = relativeTime(o.uploaded);
|
|
1296
|
-
if (rel) statBits += (statBits ? '<span class="ash-statsep">·</span>' : '') + 'updated <b>' + escapeHtml(rel) + '</b>';
|
|
1297
|
-
}
|
|
1298
|
-
if (statBits) stats = '<span class="ash-stats" title="page stats">' + statBits + '</span>';
|
|
1299
|
-
|
|
1300
|
-
// The visibility chip: an interactive control for the owner, a static cue for
|
|
1301
|
-
// everyone else (unchanged from today).
|
|
1302
|
-
var chipHtml;
|
|
1303
|
-
var menu = '';
|
|
1304
|
-
var script = '';
|
|
1305
|
-
if (o.isOwner) {
|
|
1306
|
-
chipHtml = '<span class="ash-chip ash-chip-own" data-ash-chip tabindex="0" role="button" aria-haspopup="menu" ' +
|
|
1307
|
-
'style="color:' + chip.color + ';border-color:' + chip.color + '66">' +
|
|
1308
|
-
'<span data-ash-chip-ic>' + chip.icon + '</span><span data-ash-label>' + chip.label + '</span>' + CARET_ICON + '</span>';
|
|
1309
|
-
menu = renderVisibilityMenu(visibility, o.ownerDomain);
|
|
1310
|
-
script = renderOwnerScript();
|
|
1311
|
-
} else {
|
|
1312
|
-
chipHtml = '<span class="ash-chip" style="color:' + chip.color + ';border-color:' + chip.color + '66">' + chip.icon + '<span>' + chip.label + '</span></span>';
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
var right = stats + chipHtml;
|
|
1316
|
-
if (cm.date) right += '<span class="ash-date">' + escapeHtml(cm.date) + '</span>';
|
|
1317
|
-
// The load-bearing background + base colour ride an INLINE style so the host
|
|
1318
|
-
// page's own CSS can never wash the bar out (inline beats a page stylesheet);
|
|
1319
|
-
// the rest is a namespaced style block with !important on every colour so a
|
|
1320
|
-
// broad page rule (span selector to black, etc.) cannot make the text vanish.
|
|
1321
|
-
// FIXED, full-viewport-width chrome (not sticky-inside-body — that inherited the
|
|
1322
|
-
// page's max-width/margins and rendered as a floating box). html padding-top
|
|
1323
|
-
// pushes the whole page down by the bar's height; flex-wrap:nowrap + ellipsis on
|
|
1324
|
-
// the left keeps the bar exactly one line tall so the push height stays correct.
|
|
1325
|
-
return '<div class="agents-share-bar" role="contentinfo" aria-label="Sharing details" ' +
|
|
1326
|
-
'style="background:#0b0b0c !important;color:#e8e8e8 !important">' +
|
|
1327
|
-
'<style>' +
|
|
1328
|
-
'html{padding-top:38px !important}' +
|
|
1329
|
-
'.agents-share-bar{all:initial;position:fixed !important;top:0 !important;left:0 !important;right:0 !important;width:100% !important;z-index:2147483647 !important;box-sizing:border-box;display:flex !important;align-items:center;gap:12px;flex-wrap:nowrap;' +
|
|
1330
|
-
'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;' +
|
|
1331
|
-
'font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif !important}' +
|
|
1332
|
-
'.agents-share-bar *{box-sizing:border-box;font-family:inherit}' +
|
|
1333
|
-
'.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)}' +
|
|
1334
|
-
'.agents-share-bar .ash-av-i{color:#fff !important}' +
|
|
1335
|
-
'.agents-share-bar .ash-av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:50% !important}' +
|
|
1336
|
-
'.agents-share-bar .ash-left{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#cfd3d9 !important}' +
|
|
1337
|
-
'.agents-share-bar strong{color:#fff !important;font-weight:600}' +
|
|
1338
|
-
'.agents-share-bar .ash-dot{opacity:.4;margin:0 4px}' +
|
|
1339
|
-
'.agents-share-bar .ash-right{flex:none;display:flex;align-items:center;gap:12px}' +
|
|
1340
|
-
'.agents-share-bar .ash-date{color:#9096a0 !important}' +
|
|
1341
|
-
'.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}' +
|
|
1342
|
-
'.agents-share-bar .ash-chip svg{flex:none;vertical-align:middle}' +
|
|
1343
|
-
'.agents-share-bar .ash-stats{display:inline-flex;align-items:center;gap:5px;color:#9aa0a6 !important;font-size:12px;white-space:nowrap}' +
|
|
1344
|
-
'.agents-share-bar .ash-stats b{color:#c9ced6 !important;font-weight:600}' +
|
|
1345
|
-
'.agents-share-bar .ash-stats svg{flex:none;opacity:.85}' +
|
|
1346
|
-
'.agents-share-bar .ash-statsep{opacity:.35;margin:0 3px}' +
|
|
1347
|
-
(o.isOwner ? OWNER_BAR_CSS : '') +
|
|
1348
|
-
'</style>' +
|
|
1349
|
-
avatar +
|
|
1350
|
-
'<span class="ash-left">' + left + '</span>' +
|
|
1351
|
-
'<span class="ash-right">' + right + '</span>' +
|
|
1352
|
-
menu +
|
|
1353
|
-
(o.isOwner ? '<div class="ash-toast" data-ash-toast></div>' : '') +
|
|
1354
|
-
'</div>' +
|
|
1355
|
-
script;
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
// Owner-only chip/menu/toast styling, added to the bar's <style> only when the
|
|
1359
|
-
// viewer owns the namespace. Namespaced under .agents-share-bar with !important
|
|
1360
|
-
// so the host page's CSS can't wash it out; z-index rides the bar's own stacking.
|
|
1361
|
-
var OWNER_BAR_CSS =
|
|
1362
|
-
'.agents-share-bar .ash-chip-own{cursor:pointer;transition:background .12s}' +
|
|
1363
|
-
'.agents-share-bar .ash-chip-own:hover{background:rgba(255,255,255,.06)}' +
|
|
1364
|
-
'.agents-share-bar .ash-caret{opacity:.6;margin-left:1px;flex:none}' +
|
|
1365
|
-
'.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}' +
|
|
1366
|
-
'.agents-share-bar .ash-menu.ash-open{display:block}' +
|
|
1367
|
-
'.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}' +
|
|
1368
|
-
'.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}' +
|
|
1369
|
-
'.agents-share-bar .ash-opt:hover{background:rgba(255,255,255,.05)}' +
|
|
1370
|
-
'.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)}' +
|
|
1371
|
-
'.agents-share-bar .ash-opt-t{flex:1;min-width:0}' +
|
|
1372
|
-
'.agents-share-bar .ash-opt-t b{display:block;font-size:13px;font-weight:600;color:#f0f1f3 !important}' +
|
|
1373
|
-
'.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}' +
|
|
1374
|
-
'.agents-share-bar .ash-rd{flex:none;width:16px;height:16px;border-radius:50%;border:2px solid #4a4f59;margin-top:3px}' +
|
|
1375
|
-
'.agents-share-bar .ash-opt.ash-sel .ash-rd{border-color:#5b9dff;background:radial-gradient(circle at center,#5b9dff 0 5px,transparent 6px)}' +
|
|
1376
|
-
'.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}' +
|
|
1377
|
-
'.agents-share-bar .ash-toast.ash-show{display:inline-flex}' +
|
|
1378
|
-
'.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}' +
|
|
1379
|
-
'@keyframes ash-sp{to{transform:rotate(360deg)}}' +
|
|
1380
|
-
'.agents-share-bar .ash-ok{color:#30a46c;font-weight:700}' +
|
|
1381
|
-
'.agents-share-bar .ash-err{color:#ff6b6b;font-weight:700}';
|
|
1382
|
-
|
|
1383
|
-
function injectAttributionBar(html, meta, handle, opts) {
|
|
1384
|
-
var bar = renderAttributionBar(meta, handle, opts);
|
|
1385
|
-
var m = /<body[^>]*>/i.exec(html);
|
|
1386
|
-
if (m) {
|
|
1387
|
-
var at = m.index + m[0].length;
|
|
1388
|
-
return html.slice(0, at) + bar + html.slice(at);
|
|
1389
|
-
}
|
|
1390
|
-
return bar + html;
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
function acceptsHtml(request) {
|
|
1394
|
-
return (request.headers.get('accept') || '').indexOf('text/html') !== -1;
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
// Assets we can embed in a viewer page. Everything else (JSON, .txt, arbitrary
|
|
1398
|
-
// downloads) is served raw — there is nothing useful to wrap it in.
|
|
1399
|
-
function viewableAssetKind(contentType) {
|
|
1400
|
-
var ct = (contentType || '').toLowerCase();
|
|
1401
|
-
if (ct.indexOf('image/') === 0) return 'image';
|
|
1402
|
-
if (ct.indexOf('video/') === 0) return 'video';
|
|
1403
|
-
if (ct.indexOf('audio/') === 0) return 'audio';
|
|
1404
|
-
if (ct.indexOf('application/pdf') === 0) return 'pdf';
|
|
1405
|
-
return '';
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
// A minimal dark viewer page: the attribution bar on top, the asset centered
|
|
1409
|
-
// below. The media element points back at THIS url with ?raw so it loads the
|
|
1410
|
-
// stored bytes (not the viewer recursively) — and that ?raw fetch re-runs the
|
|
1411
|
-
// same me/org gate, so a private asset stays private inside its own viewer.
|
|
1412
|
-
function renderAssetViewer(pathname, kind, meta, handle, opts) {
|
|
1413
|
-
var rawUrl = escapeHtml(pathname + '?raw=1');
|
|
1414
|
-
var name = '';
|
|
1415
|
-
try { name = decodeURIComponent(pathname.split('/').pop() || 'file'); } catch (e) { name = pathname.split('/').pop() || 'file'; }
|
|
1416
|
-
var bar = renderAttributionBar(meta, handle, opts);
|
|
1417
|
-
var media = '';
|
|
1418
|
-
if (kind === 'image') media = '<img src="' + rawUrl + '" alt="' + escapeHtml(name) + '">';
|
|
1419
|
-
else if (kind === 'video') media = '<video src="' + rawUrl + '" controls playsinline></video>';
|
|
1420
|
-
else if (kind === 'audio') media = '<audio src="' + rawUrl + '" controls></audio>';
|
|
1421
|
-
else if (kind === 'pdf') media = '<iframe src="' + rawUrl + '" title="' + escapeHtml(name) + '"></iframe>';
|
|
1422
|
-
return '<!doctype html><html><head><meta charset="utf-8">' +
|
|
1423
|
-
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
1424
|
-
'<title>' + escapeHtml(name) + '</title>' +
|
|
1425
|
-
'<style>html,body{margin:0;background:#0f0f11}' +
|
|
1426
|
-
'body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box}' +
|
|
1427
|
-
'.ashv{max-width:100%;max-height:calc(100vh - 90px);display:flex;align-items:center;justify-content:center}' +
|
|
1428
|
-
'.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)}' +
|
|
1429
|
-
'.ashv audio{width:min(90vw,560px)}' +
|
|
1430
|
-
'.ashv iframe{width:min(94vw,1000px);height:calc(100vh - 90px);border:0;border-radius:8px;background:#fff}' +
|
|
1431
|
-
'</style></head><body>' + bar + '<div class="ashv">' + media + '</div></body></html>';
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
function json(body, status) {
|
|
1435
|
-
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
// Length-independent constant-time-ish compare (Workers has no timingSafeEqual).
|
|
1439
|
-
function safeEqual(a, b) {
|
|
1440
|
-
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
1441
|
-
const la = a.length, lb = b.length;
|
|
1442
|
-
let out = la ^ lb;
|
|
1443
|
-
for (let i = 0; i < la; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i % lb || 0);
|
|
1444
|
-
return out === 0 && la === lb;
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
// URL-safety, not collision-resistance. Two values that differ only in
|
|
1448
|
-
// case/punctuation share a prefix.
|
|
1449
|
-
function sanitizeNamespace(s) {
|
|
1450
|
-
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
// Public handle: email local-part, plus-tag stripped. Must match CLI handleFromEmail.
|
|
1454
|
-
function handleFromEmail(email) {
|
|
1455
|
-
if (!email) return '';
|
|
1456
|
-
const local = String(email).split('@')[0] || '';
|
|
1457
|
-
const beforePlus = local.split('+')[0] || local;
|
|
1458
|
-
return sanitizeNamespace(beforePlus);
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
function phoenixHandle(auth) {
|
|
1462
|
-
return handleFromEmail(auth.email) || sanitizeNamespace(auth.owner);
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
|
-
// First writer of a handle owns it. Later PUTs from the same userId are fine;
|
|
1466
|
-
// a different userId whose email local-part collides gets 409, not a silent
|
|
1467
|
-
// overwrite — EXCEPT when the verified Phoenix email matches the claim's
|
|
1468
|
-
// recorded email exactly: that is the same human re-authenticated under a new
|
|
1469
|
-
// userId (an account move), and the claim transfers to them instead of
|
|
1470
|
-
// dead-ending (PHNX-3547). The transfer also re-stamps owner on the old
|
|
1471
|
-
// account's objects so PATCH/DELETE keep working.
|
|
1472
|
-
async function assertHandleOwner(bucket, handle, userId, email) {
|
|
1473
|
-
// The __handles/<handle> claim object is the authoritative first-writer record
|
|
1474
|
-
// of ownership. When it exists it decides ownership OUTRIGHT: the recorded
|
|
1475
|
-
// userId may write, anyone else is refused. Consult it FIRST — a stray page
|
|
1476
|
-
// under the namespace stamped with a different owner (e.g. a BYO WRITE_TOKEN
|
|
1477
|
-
// publish, which stamps owner = SHARE_NAMESPACE rather than a userId, or a page
|
|
1478
|
-
// published under the same human's earlier userId before a re-auth) must not
|
|
1479
|
-
// lock the rightful claim holder out of their own handle (PHNX-3291).
|
|
1480
|
-
const key = '__handles/' + handle;
|
|
1481
|
-
const existing = await bucket.get(key);
|
|
1482
|
-
if (existing) {
|
|
1483
|
-
const meta = existing.customMetadata || {};
|
|
1484
|
-
const claimed = meta.userId;
|
|
1485
|
-
if (claimed && claimed !== userId) {
|
|
1486
|
-
// Same verified email, different userId → account move, not a rival:
|
|
1487
|
-
// rebind the claim and migrate the old owner's objects. Legacy claims
|
|
1488
|
-
// written before the claim recorded an email cannot prove this and keep
|
|
1489
|
-
// the permanent 409.
|
|
1490
|
-
if (email && meta.email && String(meta.email).toLowerCase() === String(email).toLowerCase()) {
|
|
1491
|
-
await transferHandle(bucket, handle, claimed, userId, email);
|
|
1492
|
-
return {};
|
|
1493
|
-
}
|
|
1494
|
-
return { error: json({ error: 'handle taken', handle: handle }, 409) };
|
|
1495
|
-
}
|
|
1496
|
-
return {};
|
|
1497
|
-
}
|
|
1498
|
-
// No claim object yet — fall back to the page-owner scan so a colliding email
|
|
1499
|
-
// local-part cannot PUT/DELETE another account's objects in the window before
|
|
1500
|
-
// the claim exists. (claimHandle writes the claim on the first Phoenix PUT.)
|
|
1501
|
-
// Only a DIFFERENT Phoenix userId blocks: a BYO WRITE_TOKEN publish stamps
|
|
1502
|
-
// owner = the namespace (=== handle) rather than a userId, so it is not a rival
|
|
1503
|
-
// identity and must not lock the handle's first Phoenix claimant out (PHNX-3291).
|
|
1504
|
-
const list = await bucket.list({ prefix: handle + '/', include: ['customMetadata'] });
|
|
1505
|
-
for (const o of list.objects || []) {
|
|
1506
|
-
const owner = o.customMetadata && o.customMetadata.owner;
|
|
1507
|
-
if (owner && owner !== userId && owner !== handle) {
|
|
1508
|
-
return { error: json({ error: 'handle taken', handle: handle }, 409) };
|
|
1509
|
-
}
|
|
1510
|
-
}
|
|
1511
|
-
return {};
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
// Account-move recovery (PHNX-3547): the claim's recorded userId held the handle;
|
|
1515
|
-
// the caller proves the SAME verified email under a NEW userId. Rebind the claim
|
|
1516
|
-
// and re-stamp owner on every object the old userId owned under this prefix, so
|
|
1517
|
-
// the moved account keeps full control of its shares. Objects owned by anyone
|
|
1518
|
-
// else (BYO namespace stamps, a pre-claim stray) are left untouched.
|
|
1519
|
-
async function transferHandle(bucket, handle, oldUserId, newUserId, email) {
|
|
1520
|
-
await bucket.put('__handles/' + handle, JSON.stringify({ userId: newUserId, email: email }), {
|
|
1521
|
-
httpMetadata: { contentType: 'application/json' },
|
|
1522
|
-
customMetadata: { userId: newUserId, email: email, visibility: 'unlisted' },
|
|
1523
|
-
});
|
|
1524
|
-
let cursor;
|
|
1525
|
-
do {
|
|
1526
|
-
const list = await bucket.list({ prefix: handle + '/', cursor: cursor, include: ['customMetadata'] });
|
|
1527
|
-
for (const o of list.objects || []) {
|
|
1528
|
-
const owner = o.customMetadata && o.customMetadata.owner;
|
|
1529
|
-
if (!owner || owner !== oldUserId) continue;
|
|
1530
|
-
const obj = await bucket.get(o.key);
|
|
1531
|
-
if (!obj) continue;
|
|
1532
|
-
const headers = new Headers();
|
|
1533
|
-
if (typeof obj.writeHttpMetadata === 'function') obj.writeHttpMetadata(headers);
|
|
1534
|
-
const customMetadata = { ...(obj.customMetadata || {}), owner: newUserId };
|
|
1535
|
-
await bucket.put(o.key, obj.body, {
|
|
1536
|
-
httpMetadata: headers.get('content-type') ? { contentType: headers.get('content-type') } : undefined,
|
|
1537
|
-
customMetadata: customMetadata,
|
|
1538
|
-
});
|
|
1539
|
-
}
|
|
1540
|
-
cursor = list.truncated ? list.cursor : undefined;
|
|
1541
|
-
} while (cursor);
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
async function claimHandle(bucket, handle, userId, email) {
|
|
1545
|
-
const owned = await assertHandleOwner(bucket, handle, userId, email);
|
|
1546
|
-
if (owned.error) return owned;
|
|
1547
|
-
const key = '__handles/' + handle;
|
|
1548
|
-
// Write (or rewrite) so a same-user republish resets object Age against the
|
|
1549
|
-
// bucket's 366-day lifecycle — otherwise the claim can expire while pages stay
|
|
1550
|
-
// live. The claim also records the verified email: it is what lets a future
|
|
1551
|
-
// same-email/different-userId request prove an account move and recover the
|
|
1552
|
-
// handle instead of hitting the permanent 409.
|
|
1553
|
-
await bucket.put(key, JSON.stringify({ userId: userId, email: email }), {
|
|
1554
|
-
httpMetadata: { contentType: 'application/json' },
|
|
1555
|
-
customMetadata: { userId: userId, email: email || '', visibility: 'unlisted' },
|
|
1556
|
-
});
|
|
1557
|
-
return {};
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
function normalizeVisibility(raw) {
|
|
1561
|
-
const v = (raw || '').trim().toLowerCase();
|
|
1562
|
-
if (!v || v === 'public') return { value: 'public' };
|
|
1563
|
-
if (v === 'unlisted' || v === 'private' || v === 'me' || v === 'org') return { value: v };
|
|
1564
|
-
return { error: json({ error: 'visibility must be public, unlisted, private, me, or org' }, 400) };
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
function emailDomain(email) {
|
|
1568
|
-
if (!email) return '';
|
|
1569
|
-
const at = String(email).lastIndexOf('@');
|
|
1570
|
-
if (at < 0) return '';
|
|
1571
|
-
return String(email).slice(at + 1).toLowerCase();
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
function isHiddenFromGallery(visibility) {
|
|
1575
|
-
return visibility === 'unlisted' || visibility === 'private' || visibility === 'me' || visibility === 'org';
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
function isIdentityGated(visibility) {
|
|
1579
|
-
return visibility === 'me' || visibility === 'org';
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
function managedCoverHeaders(visibility) {
|
|
1583
|
-
const headers = new Headers({ 'content-type': 'image/png' });
|
|
1584
|
-
// Token-gated (private) + identity-gated (me/org) covers must never be
|
|
1585
|
-
// cached by a shared proxy, and never indexed. RFC 9111: public on an
|
|
1586
|
-
// Authorization response authorizes reuse for later unauthenticated
|
|
1587
|
-
// requests keyed on /user/slug.png (PHNX-3676).
|
|
1588
|
-
if (isIdentityGated(visibility) || visibility === 'private') {
|
|
1589
|
-
headers.set('cache-control', 'private, no-store');
|
|
1590
|
-
headers.set('X-Robots-Tag', 'noindex');
|
|
1591
|
-
} else {
|
|
1592
|
-
headers.set('cache-control', 'public, max-age=31536000, immutable');
|
|
1593
|
-
if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
|
|
1594
|
-
}
|
|
1595
|
-
return headers;
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
// Gate a me/org read given the ALREADY-RESOLVED viewer identity. Pure/sync: the
|
|
1599
|
-
// caller resolves the viewer once (it also needs the identity for the ownership
|
|
1600
|
-
// check) and both the page GET and the ?revisions=json path share this gate.
|
|
1601
|
-
async function gateVisibility(url, env, obj, identity) {
|
|
1602
|
-
const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
|
|
1603
|
-
if (!isIdentityGated(visibility)) return null;
|
|
1604
|
-
if (!identity) return bounceToLogin(url, env);
|
|
1605
|
-
if (!viewerMayRead(visibility, obj.customMetadata, identity)) {
|
|
1606
|
-
// A 'me' page reads for its stamped owner (the fast path above) OR for the
|
|
1607
|
-
// holder of the namespace's handle claim — the same authority PATCH and
|
|
1608
|
-
// DELETE use. Without this, the claim holder who takes a fleet/BYO-stamped
|
|
1609
|
-
// or pre-stamp page to 'me' (owner = namespace, or none) would be locked
|
|
1610
|
-
// out of her own page: PATCH says 200, GET says 404. The stamp itself stays
|
|
1611
|
-
// untouched (expiry refunds credit the stamped ledger), so the read gate
|
|
1612
|
-
// has to consult the claim rather than the stamp.
|
|
1613
|
-
const handle = decodeURIComponent(url.pathname).split('/').filter(Boolean)[0] || '';
|
|
1614
|
-
if (visibility === 'me' && handle && (await holdsHandleClaim(env.BUCKET, handle, identity.userId))) return null;
|
|
1615
|
-
return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
1616
|
-
}
|
|
1617
|
-
return null;
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
// True when userId is the recorded holder of the __handles/<handle> claim.
|
|
1621
|
-
// Read-only: never transfers or writes a claim (that is claimHandle /
|
|
1622
|
-
// assertHandleOwner's job on the write paths).
|
|
1623
|
-
async function holdsHandleClaim(bucket, handle, userId) {
|
|
1624
|
-
if (!handle || !userId) return false;
|
|
1625
|
-
const claim = await bucket.get('__handles/' + handle);
|
|
1626
|
-
if (!claim) return false;
|
|
1627
|
-
const claimed = claim.customMetadata && claim.customMetadata.userId;
|
|
1628
|
-
return !!claimed && claimed === userId;
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
// SHA-256 hex of a string — the form a 'private' object's stored
|
|
1632
|
-
// 'viewer-token-hash' takes. Both the PUT (hash-on-store) and the read gate use
|
|
1633
|
-
// this, so a token minted by the CLI matches byte-for-byte (PHNX-3654).
|
|
1634
|
-
async function sha256Hex(text) {
|
|
1635
|
-
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(text)));
|
|
1636
|
-
return bufToHex(digest);
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
// The viewer key a reader presents for a token-gated ('private') page: the ?k=
|
|
1640
|
-
// query param first (the shape the CLI emits — https://host/<u>/<s>?k=<token>),
|
|
1641
|
-
// then an Authorization: Bearer token as an alternative for scripted callers.
|
|
1642
|
-
function readViewerKey(request, url) {
|
|
1643
|
-
const q = url.searchParams.get('k');
|
|
1644
|
-
if (q) return q;
|
|
1645
|
-
const bearer = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
|
|
1646
|
-
return bearer || '';
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
// Gate a 'private' (token-gated) read (PHNX-3654). Returns a 404 Response when
|
|
1650
|
-
// the request may NOT read, or null when it may. A miss ALWAYS 404s — never a
|
|
1651
|
-
// 401 and never a distinct "wrong key" — so a token-gated page never even leaks
|
|
1652
|
-
// that it exists, exactly like a wrong me/org viewer. The namespace owner
|
|
1653
|
-
// (resolved identity whose userId stamped the object) is let through without the
|
|
1654
|
-
// key so 'share open' and the owner's own browsing still work. Constant-time
|
|
1655
|
-
// compare (safeEqual) over the stored SHA-256 hash keeps the check timing-safe.
|
|
1656
|
-
async function gateTokenRead(request, url, obj, identity) {
|
|
1657
|
-
const meta = obj.customMetadata || {};
|
|
1658
|
-
const visibility = meta.visibility || 'public';
|
|
1659
|
-
if (visibility !== 'private') return null;
|
|
1660
|
-
const notFound = function () {
|
|
1661
|
-
return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
1662
|
-
};
|
|
1663
|
-
// Owner bypass: the signed-in owner of the page reads their own private page
|
|
1664
|
-
// without the key (mirrors 'me').
|
|
1665
|
-
if (identity && meta.owner && meta.owner === identity.userId) return null;
|
|
1666
|
-
const stored = meta['viewer-token-hash'] || '';
|
|
1667
|
-
// Fail closed: a private object with no stored hash can never be matched, so it
|
|
1668
|
-
// is unreadable rather than accidentally public.
|
|
1669
|
-
if (!stored) return notFound();
|
|
1670
|
-
const presented = readViewerKey(request, url);
|
|
1671
|
-
if (!presented) return notFound();
|
|
1672
|
-
const presentedHash = await sha256Hex(presented);
|
|
1673
|
-
if (!safeEqual(presentedHash, stored)) return notFound();
|
|
1674
|
-
return null;
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
// Per-slug view counter, stored as a SEPARATE R2 object under __views/<path> so
|
|
1678
|
-
// counting a view never rewrites the page object (which would reset its uploaded
|
|
1679
|
-
// timestamp and corrupt "last updated"). Best-effort telemetry: a read/write
|
|
1680
|
-
// failure must never break page serving, so both degrade to a no-op rather than
|
|
1681
|
-
// throwing. R2 has no atomic increment, so two simultaneous views can race and
|
|
1682
|
-
// lose a count — acceptable for an approximate visitor count.
|
|
1683
|
-
async function readViews(env, path) {
|
|
1684
|
-
try {
|
|
1685
|
-
const obj = await env.BUCKET.get('__views/' + path);
|
|
1686
|
-
if (!obj) return 0;
|
|
1687
|
-
const raw = typeof obj.text === 'function' ? await obj.text() : '';
|
|
1688
|
-
const n = parseInt(raw, 10);
|
|
1689
|
-
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
1690
|
-
} catch {
|
|
1691
|
-
return 0;
|
|
1692
|
-
}
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
async function writeViews(env, path, count) {
|
|
1696
|
-
try {
|
|
1697
|
-
await env.BUCKET.put('__views/' + path, String(count), {
|
|
1698
|
-
httpMetadata: { contentType: 'text/plain; charset=utf-8' },
|
|
1699
|
-
});
|
|
1700
|
-
} catch {
|
|
1701
|
-
// best-effort: a failed counter write must never surface to the visitor
|
|
1702
|
-
}
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
// --- PHNX-3542 per-user usage ledger (R2 conditional-put CAS) ---------------
|
|
1706
|
-
// The ledger is a single R2 object at __usage/<owner> holding the running
|
|
1707
|
-
// { bytes, count, plan, rlStart, rlUsed } for one user. It mirrors the __views /
|
|
1708
|
-
// __handles precedent: a __-prefixed key, GET-blocked and outside every gallery/
|
|
1709
|
-
// listing/revision prefix. R2 has no atomic increment, so mutations use a
|
|
1710
|
-
// read → mutate → conditional-put loop (onlyIf.etagMatches), the same primitive
|
|
1711
|
-
// the PATCH metadata-edit path already relies on.
|
|
1712
|
-
function usageKey(owner) { return '__usage/' + sanitizeNamespace(owner); }
|
|
1713
|
-
|
|
1714
|
-
async function readUsage(env, owner) {
|
|
1715
|
-
const obj = await env.BUCKET.get(usageKey(owner));
|
|
1716
|
-
if (!obj) return { etag: null, usage: freshUsage() };
|
|
1717
|
-
let usage = freshUsage();
|
|
1718
|
-
try {
|
|
1719
|
-
const raw = typeof obj.text === 'function' ? await obj.text() : '';
|
|
1720
|
-
const parsed = JSON.parse(raw);
|
|
1721
|
-
if (parsed && typeof parsed === 'object') {
|
|
1722
|
-
usage = {
|
|
1723
|
-
bytes: usageNumber(parsed.bytes, 0),
|
|
1724
|
-
count: usageNumber(parsed.count, 0),
|
|
1725
|
-
plan: typeof parsed.plan === 'string' ? parsed.plan : DEFAULT_SHARE_PLAN,
|
|
1726
|
-
rlStart: usageNumber(parsed.rlStart, 0),
|
|
1727
|
-
rlUsed: usageNumber(parsed.rlUsed, 0),
|
|
1728
|
-
};
|
|
1729
|
-
}
|
|
1730
|
-
} catch (e) {
|
|
1731
|
-
// Malformed ledger — treat as fresh zero but KEEP the etag so the next CAS
|
|
1732
|
-
// put overwrites the corrupt object rather than looping against it forever.
|
|
1733
|
-
}
|
|
1734
|
-
return { etag: obj.etag || null, usage: usage };
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
|
-
async function writeUsageCas(env, owner, prevEtag, usage) {
|
|
1738
|
-
const body = JSON.stringify(usage);
|
|
1739
|
-
const opts = { httpMetadata: { contentType: 'application/json' } };
|
|
1740
|
-
if (prevEtag) {
|
|
1741
|
-
// Existing object: conditional put. R2 returns null when the etag no longer
|
|
1742
|
-
// matches (a concurrent writer won the race) → report failure so the caller
|
|
1743
|
-
// re-reads and retries.
|
|
1744
|
-
opts.onlyIf = { etagMatches: prevEtag };
|
|
1745
|
-
const res = await env.BUCKET.put(usageKey(owner), body, opts);
|
|
1746
|
-
return res !== null;
|
|
1747
|
-
}
|
|
1748
|
-
// Fresh key: plain create. Neither R2 nor the test harness exposes a
|
|
1749
|
-
// conditional-create predicate, so the only race is two simultaneous
|
|
1750
|
-
// first-creates of the same owner's ledger — a benign, one-time bounded loss.
|
|
1751
|
-
await env.BUCKET.put(usageKey(owner), body, opts);
|
|
1752
|
-
return true;
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
// CAS retry loop. fn(usage) returns { reject: Response } to fail loud without
|
|
1756
|
-
// committing, or { commit: usage, result } to persist and return result. On CAS
|
|
1757
|
-
// contention it re-reads and retries; exhausting the retries fails loud with 503.
|
|
1758
|
-
async function withUsage(env, owner, fn) {
|
|
1759
|
-
for (let attempt = 0; attempt < 6; attempt++) {
|
|
1760
|
-
const state = await readUsage(env, owner);
|
|
1761
|
-
const outcome = fn(state.usage);
|
|
1762
|
-
if (outcome.reject) return outcome.reject;
|
|
1763
|
-
const ok = await writeUsageCas(env, owner, state.etag, outcome.commit);
|
|
1764
|
-
if (ok) return outcome.result;
|
|
1765
|
-
}
|
|
1766
|
-
return json({ error: 'usage ledger contended, retry' }, 503);
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
function rateLimited(retryAfterSec, ratePerHour) {
|
|
1770
|
-
return new Response(
|
|
1771
|
-
JSON.stringify({ error: 'rate limit: too many publishes, retry later', retryAfterSec: retryAfterSec, ratePerHour: ratePerHour }),
|
|
1772
|
-
{ status: 429, headers: { 'content-type': 'application/json', 'retry-after': String(retryAfterSec) } },
|
|
1773
|
-
);
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
// Charge one authed PUT against the owner's ledger. Rate → per-file cap → object
|
|
1777
|
-
// count → byte quota, each failing loud (429 / 413) before anything is written.
|
|
1778
|
-
// Returns { error: Response } on any rejection, else { limits } (the resolved
|
|
1779
|
-
// plan limits, reused by the post-write real-size reconcile). Managed only: the
|
|
1780
|
-
// caller guards on auth.kind === 'phoenix'.
|
|
1781
|
-
async function chargeShareWrite(env, auth, params) {
|
|
1782
|
-
const now = Date.now();
|
|
1783
|
-
const out = await withUsage(env, auth.owner, function (usage) {
|
|
1784
|
-
const limits = planLimits(usage.plan);
|
|
1785
|
-
// Rate limit — only a user-initiated page PUT counts (countRate). A fixed 1h
|
|
1786
|
-
// window: reset when the window has rolled over, otherwise reject at the cap.
|
|
1787
|
-
if (params.countRate) {
|
|
1788
|
-
if (now - usage.rlStart >= RATE_WINDOW_MS) { usage.rlStart = now; usage.rlUsed = 0; }
|
|
1789
|
-
if (usage.rlUsed >= limits.ratePerHour) {
|
|
1790
|
-
const retryAfterSec = Math.max(1, Math.ceil((usage.rlStart + RATE_WINDOW_MS - now) / 1000));
|
|
1791
|
-
return { reject: rateLimited(retryAfterSec, limits.ratePerHour) };
|
|
1792
|
-
}
|
|
1793
|
-
usage.rlUsed += 1;
|
|
1794
|
-
}
|
|
1795
|
-
// Per-file size cap.
|
|
1796
|
-
if (typeof params.fileBytes === 'number' && params.fileBytes > limits.maxFileBytes) {
|
|
1797
|
-
return { reject: json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: params.fileBytes }, 413) };
|
|
1798
|
-
}
|
|
1799
|
-
// Object (canonical page) count.
|
|
1800
|
-
if (params.newCanonical) {
|
|
1801
|
-
if (usage.count + 1 > limits.maxObjects) {
|
|
1802
|
-
return { reject: json({ error: 'artifact limit reached', maxObjects: limits.maxObjects }, 413) };
|
|
1803
|
-
}
|
|
1804
|
-
usage.count += 1;
|
|
1805
|
-
}
|
|
1806
|
-
// Total byte quota. charge may be negative on a shrink — clamp at >= 0.
|
|
1807
|
-
if (usage.bytes + params.charge > limits.maxBytes) {
|
|
1808
|
-
return { reject: json({ error: 'storage limit reached', maxBytes: limits.maxBytes, usedBytes: usage.bytes }, 413) };
|
|
1809
|
-
}
|
|
1810
|
-
usage.bytes = Math.max(0, usage.bytes + params.charge);
|
|
1811
|
-
return { commit: usage, result: { limits: limits } };
|
|
1812
|
-
});
|
|
1813
|
-
if (out instanceof Response) return { error: out };
|
|
1814
|
-
return { limits: out.limits };
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
// Read a request body fully into memory, BOUNDED: abort the moment it exceeds
|
|
1818
|
-
// maxBytes, so a chunked/streaming body can never buffer more than the cap (plus
|
|
1819
|
-
// one in-flight chunk) and OOM the Worker. Returns { oversize: true, size } once
|
|
1820
|
-
// the cap is passed (size is a lower bound, >= cap), else { bytes, size } with
|
|
1821
|
-
// the exact bytes. A body-less request yields an empty buffer. This is what lets
|
|
1822
|
-
// enforcement key on the REAL size instead of a spoofable declared header.
|
|
1823
|
-
async function readBodyBounded(request, maxBytes) {
|
|
1824
|
-
if (!request.body || typeof request.body.getReader !== 'function') {
|
|
1825
|
-
const buf = await request.arrayBuffer();
|
|
1826
|
-
const bytes = new Uint8Array(buf);
|
|
1827
|
-
if (bytes.byteLength > maxBytes) return { oversize: true, size: bytes.byteLength };
|
|
1828
|
-
return { bytes: bytes, size: bytes.byteLength };
|
|
1829
|
-
}
|
|
1830
|
-
const reader = request.body.getReader();
|
|
1831
|
-
const chunks = [];
|
|
1832
|
-
let size = 0;
|
|
1833
|
-
while (true) {
|
|
1834
|
-
const step = await reader.read();
|
|
1835
|
-
if (step.done) break;
|
|
1836
|
-
const chunk = step.value;
|
|
1837
|
-
size += chunk.byteLength;
|
|
1838
|
-
if (size > maxBytes) {
|
|
1839
|
-
try { await reader.cancel(); } catch (e) { /* best-effort */ }
|
|
1840
|
-
return { oversize: true, size: size };
|
|
1841
|
-
}
|
|
1842
|
-
chunks.push(chunk);
|
|
1843
|
-
}
|
|
1844
|
-
const out = new Uint8Array(size);
|
|
1845
|
-
let offset = 0;
|
|
1846
|
-
for (let i = 0; i < chunks.length; i++) { out.set(chunks[i], offset); offset += chunks[i].byteLength; }
|
|
1847
|
-
return { bytes: out, size: size };
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
// Refund on DELETE / expiry. Never rejects, never creates a ledger: if the owner
|
|
1851
|
-
// was never charged (no __usage object) it is a pure no-op.
|
|
1852
|
-
async function refundShareWrite(env, owner, params) {
|
|
1853
|
-
for (let attempt = 0; attempt < 6; attempt++) {
|
|
1854
|
-
const state = await readUsage(env, owner);
|
|
1855
|
-
if (!state.etag) return; // no ledger — owner never charged, nothing to refund
|
|
1856
|
-
state.usage.bytes = Math.max(0, state.usage.bytes - (params.refund || 0));
|
|
1857
|
-
if (params.freeCanonical) state.usage.count = Math.max(0, state.usage.count - 1);
|
|
1858
|
-
if (await writeUsageCas(env, owner, state.etag, state.usage)) return;
|
|
1859
|
-
}
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
function viewerMayRead(visibility, meta, identity) {
|
|
1863
|
-
if (visibility === 'me') {
|
|
1864
|
-
const owner = meta && meta.owner;
|
|
1865
|
-
return !!owner && owner === identity.userId;
|
|
1866
|
-
}
|
|
1867
|
-
if (visibility === 'org') {
|
|
1868
|
-
const stamped = meta && meta.org_domain;
|
|
1869
|
-
if (!stamped) return false;
|
|
1870
|
-
return emailDomain(identity.email) === String(stamped).toLowerCase();
|
|
1871
|
-
}
|
|
1872
|
-
return true;
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
|
-
function bounceToLogin(url, env) {
|
|
1876
|
-
const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
|
|
1877
|
-
if (!base) return json({ error: 'phoenix login is not configured' }, 401);
|
|
1878
|
-
return new Response(null, {
|
|
1879
|
-
status: 302,
|
|
1880
|
-
headers: { Location: base + '/login?return=' + encodeURIComponent(url.toString()) },
|
|
1881
|
-
});
|
|
1882
|
-
}
|
|
1883
|
-
|
|
1884
|
-
function readCookie(request, name) {
|
|
1885
|
-
const header = request.headers.get('Cookie') || '';
|
|
1886
|
-
const parts = header.split(';');
|
|
1887
|
-
for (let i = 0; i < parts.length; i++) {
|
|
1888
|
-
const p = parts[i];
|
|
1889
|
-
const eq = p.indexOf('=');
|
|
1890
|
-
if (eq < 0) continue;
|
|
1891
|
-
if (p.slice(0, eq).trim() === name) return p.slice(eq + 1).trim();
|
|
1892
|
-
}
|
|
1893
|
-
return '';
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
function toB64Url(s) {
|
|
1897
|
-
const bytes = new TextEncoder().encode(s);
|
|
1898
|
-
let bin = '';
|
|
1899
|
-
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
1900
|
-
return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
|
|
1901
|
-
}
|
|
1902
|
-
|
|
1903
|
-
function fromB64Url(s) {
|
|
1904
|
-
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
|
|
1905
|
-
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
|
|
1906
|
-
const bin = atob(b64);
|
|
1907
|
-
const bytes = new Uint8Array(bin.length);
|
|
1908
|
-
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
1909
|
-
return new TextDecoder().decode(bytes);
|
|
1910
|
-
}
|
|
1911
|
-
|
|
1912
|
-
function bufToHex(buf) {
|
|
1913
|
-
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
|
|
1914
|
-
}
|
|
1915
|
-
|
|
1916
|
-
async function hmacHex(secret, data) {
|
|
1917
|
-
const key = await crypto.subtle.importKey(
|
|
1918
|
-
'raw',
|
|
1919
|
-
new TextEncoder().encode(secret),
|
|
1920
|
-
{ name: 'HMAC', hash: 'SHA-256' },
|
|
1921
|
-
false,
|
|
1922
|
-
['sign'],
|
|
1923
|
-
);
|
|
1924
|
-
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
|
|
1925
|
-
return bufToHex(sig);
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
async function signShareCookie(identity, env) {
|
|
1929
|
-
const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
|
|
1930
|
-
if (!secret) return '';
|
|
1931
|
-
const exp = Math.floor(Date.now() / 1000) + SHARE_COOKIE_MAX_AGE;
|
|
1932
|
-
const payload = identity.userId + '|' + (identity.email || '') + '|' + exp;
|
|
1933
|
-
const sig = await hmacHex(secret, payload);
|
|
1934
|
-
const value = toB64Url(payload) + '.' + sig;
|
|
1935
|
-
return SHARE_COOKIE + '=' + value + '; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=' + SHARE_COOKIE_MAX_AGE;
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
// A short-lived login ticket, self-signed with the same HMAC secret as the cookie
|
|
1939
|
-
// but DOMAIN-SEPARATED (the signature covers a 'ticket:'-prefixed payload), so a
|
|
1940
|
-
// ticket value can never be replayed as a cookie value or vice versa. It rides in
|
|
1941
|
-
// the ?phoenix_ticket= param for exactly one navigation; resolveViewer verifies it
|
|
1942
|
-
// locally and exchanges it for the __share cookie. TTL is deliberately tiny — it
|
|
1943
|
-
// only has to survive the round-trip from CLI mint to the browser opening the URL.
|
|
1944
|
-
var SELF_TICKET_MAX_AGE = 120;
|
|
1945
|
-
|
|
1946
|
-
async function signSelfTicket(identity, env) {
|
|
1947
|
-
const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
|
|
1948
|
-
if (!secret || !identity || !identity.userId) return '';
|
|
1949
|
-
const exp = Math.floor(Date.now() / 1000) + SELF_TICKET_MAX_AGE;
|
|
1950
|
-
const payload = identity.userId + '|' + (identity.email || '') + '|' + exp;
|
|
1951
|
-
const sig = await hmacHex(secret, 'ticket:' + payload);
|
|
1952
|
-
return toB64Url(payload) + '.' + sig;
|
|
1953
|
-
}
|
|
1954
|
-
|
|
1955
|
-
async function verifySelfTicket(ticket, env) {
|
|
1956
|
-
const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
|
|
1957
|
-
if (!secret || !ticket) return null;
|
|
1958
|
-
const dot = ticket.lastIndexOf('.');
|
|
1959
|
-
if (dot < 1) return null;
|
|
1960
|
-
let payload;
|
|
1961
|
-
try {
|
|
1962
|
-
payload = fromB64Url(ticket.slice(0, dot));
|
|
1963
|
-
} catch {
|
|
1964
|
-
return null;
|
|
1965
|
-
}
|
|
1966
|
-
const sig = ticket.slice(dot + 1);
|
|
1967
|
-
const expected = await hmacHex(secret, 'ticket:' + payload);
|
|
1968
|
-
if (!safeEqual(sig, expected)) return null;
|
|
1969
|
-
const first = payload.indexOf('|');
|
|
1970
|
-
const last = payload.lastIndexOf('|');
|
|
1971
|
-
if (first < 0 || last <= first) return null;
|
|
1972
|
-
const userId = payload.slice(0, first);
|
|
1973
|
-
const email = payload.slice(first + 1, last);
|
|
1974
|
-
const exp = Number(payload.slice(last + 1));
|
|
1975
|
-
if (!userId || !Number.isFinite(exp) || Math.floor(Date.now() / 1000) > exp) return null;
|
|
1976
|
-
return { userId: userId, email: email };
|
|
1977
|
-
}
|
|
1978
|
-
|
|
1979
|
-
// Single-use enforcement for a self-signed ticket: claim its signature the first
|
|
1980
|
-
// time it is redeemed, and reject any later presentation of the same ticket. The
|
|
1981
|
-
// HMAC + short TTL stop forgery and bound the window, but without this a ticket the
|
|
1982
|
-
// CLI printed (--json / --no-open / a browser-open failure) could be replayed by
|
|
1983
|
-
// anyone who captured that output before it expired, minting the owner's 7-day
|
|
1984
|
-
// cookie a second time. Get-then-put mirrors claimHandle's one-time-claim pattern;
|
|
1985
|
-
// the marker rides a __-prefixed key (GET-blocked, excluded from every listing).
|
|
1986
|
-
async function consumeSelfTicket(env, sig) {
|
|
1987
|
-
if (!sig) return false;
|
|
1988
|
-
const key = '__ticket-used/' + sig;
|
|
1989
|
-
const existing = await env.BUCKET.get(key);
|
|
1990
|
-
if (existing) return false;
|
|
1991
|
-
await env.BUCKET.put(key, '1', {
|
|
1992
|
-
httpMetadata: { contentType: 'text/plain' },
|
|
1993
|
-
customMetadata: { visibility: 'unlisted' },
|
|
1994
|
-
});
|
|
1995
|
-
return true;
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
async function identityFromCookie(request, env) {
|
|
1999
|
-
const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
|
|
2000
|
-
if (!secret) return null;
|
|
2001
|
-
const raw = readCookie(request, SHARE_COOKIE);
|
|
2002
|
-
if (!raw) return null;
|
|
2003
|
-
const dot = raw.lastIndexOf('.');
|
|
2004
|
-
if (dot < 1) return null;
|
|
2005
|
-
let payload;
|
|
2006
|
-
try {
|
|
2007
|
-
payload = fromB64Url(raw.slice(0, dot));
|
|
2008
|
-
} catch {
|
|
2009
|
-
return null;
|
|
2010
|
-
}
|
|
2011
|
-
const sig = raw.slice(dot + 1);
|
|
2012
|
-
const expected = await hmacHex(secret, payload);
|
|
2013
|
-
if (!safeEqual(sig, expected)) return null;
|
|
2014
|
-
const first = payload.indexOf('|');
|
|
2015
|
-
const last = payload.lastIndexOf('|');
|
|
2016
|
-
if (first < 0 || last <= first) return null;
|
|
2017
|
-
const userId = payload.slice(0, first);
|
|
2018
|
-
const email = payload.slice(first + 1, last);
|
|
2019
|
-
const cookieExp = Number(payload.slice(last + 1));
|
|
2020
|
-
if (!userId || !Number.isFinite(cookieExp) || Math.floor(Date.now() / 1000) > cookieExp) return null;
|
|
2021
|
-
return { userId: userId, email: email };
|
|
2022
|
-
}
|
|
2023
|
-
|
|
2024
|
-
async function redeemTicket(ticket, env) {
|
|
2025
|
-
const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
|
|
2026
|
-
if (!base || !ticket) return null;
|
|
2027
|
-
let res;
|
|
2028
|
-
try {
|
|
2029
|
-
res = await fetch(base + '/api/v1/auth/ticket', {
|
|
2030
|
-
method: 'POST',
|
|
2031
|
-
headers: { 'content-type': 'application/json' },
|
|
2032
|
-
body: JSON.stringify({ ticket: ticket }),
|
|
2033
|
-
});
|
|
2034
|
-
} catch {
|
|
2035
|
-
return null;
|
|
2036
|
-
}
|
|
2037
|
-
if (!res.ok) return null;
|
|
2038
|
-
let body;
|
|
2039
|
-
try {
|
|
2040
|
-
body = await res.json();
|
|
2041
|
-
} catch {
|
|
2042
|
-
return null;
|
|
2043
|
-
}
|
|
2044
|
-
if (!body || typeof body.userId !== 'string' || !body.userId) return null;
|
|
2045
|
-
return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
|
|
2046
|
-
}
|
|
2047
|
-
|
|
2048
|
-
async function resolveViewer(request, env, url) {
|
|
2049
|
-
const claims = await hooks.verifyPhoenixToken(request, env);
|
|
2050
|
-
if (claims && typeof claims.userId === 'string' && claims.userId) {
|
|
2051
|
-
return { identity: { userId: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' } };
|
|
2052
|
-
}
|
|
2053
|
-
const cookieId = await identityFromCookie(request, env);
|
|
2054
|
-
if (cookieId) return { identity: cookieId };
|
|
2055
|
-
const ticket = url.searchParams.get('phoenix_ticket');
|
|
2056
|
-
if (!ticket) return {};
|
|
2057
|
-
// Prefer the locally-verifiable self-signed ticket (PHNX-3370, no network), and
|
|
2058
|
-
// enforce single use by claiming its signature on first redemption — a captured
|
|
2059
|
-
// ticket replayed within its TTL finds it already spent. Fall back to the
|
|
2060
|
-
// external identity-server redeem for tickets it issued.
|
|
2061
|
-
let redeemed = await verifySelfTicket(ticket, env);
|
|
2062
|
-
if (redeemed) {
|
|
2063
|
-
const sig = ticket.slice(ticket.lastIndexOf('.') + 1);
|
|
2064
|
-
if (!(await consumeSelfTicket(env, sig))) redeemed = null;
|
|
2065
|
-
} else {
|
|
2066
|
-
redeemed = await redeemTicket(ticket, env);
|
|
2067
|
-
}
|
|
2068
|
-
if (!redeemed) return { error: json({ error: 'invalid ticket' }, 401) };
|
|
2069
|
-
const cookie = await signShareCookie(redeemed, env);
|
|
2070
|
-
if (!cookie) return { error: json({ error: 'phoenix login is not configured' }, 401) };
|
|
2071
|
-
url.searchParams.delete('phoenix_ticket');
|
|
2072
|
-
const headers = new Headers();
|
|
2073
|
-
headers.set('Location', url.toString());
|
|
2074
|
-
headers.append('Set-Cookie', cookie);
|
|
2075
|
-
return { redirect: new Response(null, { status: 302, headers: headers }) };
|
|
2076
|
-
}
|
|
2077
|
-
|
|
2078
|
-
function extractHtmlMeta(html, field) {
|
|
2079
|
-
const pattern = field === 'title'
|
|
2080
|
-
? /<title[^>]*>([\\s\\S]*?)<\\/title>/i
|
|
2081
|
-
: /<meta[^>]+(?:name|property)=["'](?:description|og:description)["'][^>]+content=["']([^"']*)["'][^>]*>/i;
|
|
2082
|
-
const match = pattern.exec(html);
|
|
2083
|
-
return match ? match[1].replace(/<[^>]*>/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').trim() : '';
|
|
2084
|
-
}
|
|
2085
|
-
|
|
2086
|
-
let rendererReady;
|
|
2087
|
-
async function renderOgCard(input) {
|
|
2088
|
-
if (!rendererReady) {
|
|
2089
|
-
rendererReady = Promise.all([
|
|
2090
|
-
initYoga(yogaWasm).then(function (yoga) { initSatori(yoga); }),
|
|
2091
|
-
initResvg(resvgWasm),
|
|
2092
|
-
]);
|
|
2093
|
-
}
|
|
2094
|
-
await rendererReady;
|
|
2095
|
-
const visibilityLabels = {
|
|
2096
|
-
public: 'PUBLIC',
|
|
2097
|
-
unlisted: 'UNLISTED',
|
|
2098
|
-
private: 'PROTECTED',
|
|
2099
|
-
me: 'ONLY YOU',
|
|
2100
|
-
org: input.orgDomain ? 'ANYONE AT ' + input.orgDomain.toUpperCase() : 'ORGANIZATION',
|
|
2101
|
-
};
|
|
2102
|
-
const node = {
|
|
2103
|
-
type: 'div',
|
|
2104
|
-
props: {
|
|
2105
|
-
style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#0a0a0a', color: '#f5f5f5', padding: '68px 76px 58px', fontFamily: 'Inter' },
|
|
2106
|
-
children: [
|
|
2107
|
-
{ type: 'div', props: { style: { display: 'flex', color: '#a3e635', fontFamily: 'JetBrains Mono', fontSize: 25, fontWeight: 600, letterSpacing: '-0.5px' }, children: 'share.getrush.ai' } },
|
|
2108
|
-
{ type: 'div', props: { style: { display: 'flex', flexDirection: 'column', flexGrow: 1, justifyContent: 'center', maxWidth: 1050 }, children: [
|
|
2109
|
-
{ 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' } },
|
|
2110
|
-
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,
|
|
2111
|
-
].filter(Boolean) } },
|
|
2112
|
-
{ type: 'div', props: { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderTop: '1px solid #262626', paddingTop: 25, fontSize: 21, color: '#a3a3a3' }, children: [
|
|
2113
|
-
{ type: 'div', props: { style: { display: 'flex' }, children: [
|
|
2114
|
-
{ type: 'span', props: { style: { color: '#737373' }, children: 'shared by ' } },
|
|
2115
|
-
{ type: 'span', props: { style: { marginLeft: 7, color: '#f5f5f5', fontWeight: 700 }, children: '@' + input.handle } },
|
|
2116
|
-
] } },
|
|
2117
|
-
{ 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' } },
|
|
2118
|
-
] } },
|
|
2119
|
-
],
|
|
2120
|
-
},
|
|
2121
|
-
};
|
|
2122
|
-
const svg = await satori(node, {
|
|
2123
|
-
width: 1200,
|
|
2124
|
-
height: 630,
|
|
2125
|
-
fonts: [
|
|
2126
|
-
{ name: 'Inter', data: interRegular, weight: 400, style: 'normal' },
|
|
2127
|
-
{ name: 'Inter', data: interBold, weight: 700, style: 'normal' },
|
|
2128
|
-
{ name: 'JetBrains Mono', data: jetbrainsMono, weight: 600, style: 'normal' },
|
|
2129
|
-
],
|
|
2130
|
-
});
|
|
2131
|
-
return new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();
|
|
2132
|
-
}
|
|
2133
|
-
|
|
2134
|
-
async function defaultVerifyPhoenixToken(request, env) {
|
|
2135
|
-
const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
|
|
2136
|
-
if (!presented) return null;
|
|
2137
|
-
const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
|
|
2138
|
-
if (!base) return null;
|
|
2139
|
-
let res;
|
|
2140
|
-
try {
|
|
2141
|
-
res = await fetch(base + '/api/v1/auth/me', {
|
|
2142
|
-
method: 'GET',
|
|
2143
|
-
headers: { Authorization: 'Bearer ' + presented },
|
|
2144
|
-
});
|
|
2145
|
-
} catch {
|
|
2146
|
-
return null;
|
|
2147
|
-
}
|
|
2148
|
-
if (!res.ok) return null;
|
|
2149
|
-
let body;
|
|
2150
|
-
try {
|
|
2151
|
-
body = await res.json();
|
|
2152
|
-
} catch {
|
|
2153
|
-
return null;
|
|
2154
|
-
}
|
|
2155
|
-
if (!body || typeof body.userId !== 'string' || !body.userId) return null;
|
|
2156
|
-
return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
|
|
2157
|
-
}
|
|
2158
|
-
|
|
2159
|
-
// Write principals, in order: WRITE_TOKEN equality (admin/BYO), then a Phoenix
|
|
2160
|
-
// bearer, then the signed-in viewer's __share cookie. The cookie is an
|
|
2161
|
-
// HMAC-signed {userId,email} proof (identityFromCookie verifies it), which lets
|
|
2162
|
-
// the shared page's inline visibility control PATCH with credentials:'include'
|
|
2163
|
-
// and no bearer — the browser has the identity cookie, not a token. It cannot be
|
|
2164
|
-
// abused cross-site: the cookie is SameSite=Lax, which blocks it from riding a
|
|
2165
|
-
// cross-origin non-GET (CSRF), and namespace/owner enforcement on PUT/PATCH/
|
|
2166
|
-
// DELETE still confines a cookie holder to their own pages. 401 when none apply.
|
|
2167
|
-
async function authorizeWrite(request, env) {
|
|
2168
|
-
const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
|
|
2169
|
-
if (presented) {
|
|
2170
|
-
if (env.WRITE_TOKEN && safeEqual(presented, env.WRITE_TOKEN)) {
|
|
2171
|
-
return { kind: 'byo', owner: env.SHARE_NAMESPACE || 'byo' };
|
|
2172
|
-
}
|
|
2173
|
-
const claims = await hooks.verifyPhoenixToken(request, env);
|
|
2174
|
-
if (claims && typeof claims.userId === 'string' && claims.userId) {
|
|
2175
|
-
return { kind: 'phoenix', owner: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' };
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2178
|
-
const cookieId = await identityFromCookie(request, env);
|
|
2179
|
-
if (cookieId && cookieId.userId) {
|
|
2180
|
-
return { kind: 'phoenix', owner: cookieId.userId, email: typeof cookieId.email === 'string' ? cookieId.email : '' };
|
|
2181
|
-
}
|
|
2182
|
-
return { error: json({ error: 'unauthorized' }, 401) };
|
|
2183
|
-
}
|
|
2184
|
-
|
|
2185
|
-
// ---- Same-origin human collaboration transport (PHNX-3835) ----
|
|
2186
|
-
//
|
|
2187
|
-
// The Worker is the trust boundary for collaboration exactly as it is for the
|
|
2188
|
-
// page GET. For every /__collab/* request it:
|
|
2189
|
-
// 1. loads the R2 share object FIRST (object-first) and 404s if absent — share
|
|
2190
|
-
// existence is never enumerable, and a deleted share drops its comments the
|
|
2191
|
-
// instant the object is gone (no separate revocation step);
|
|
2192
|
-
// 2. re-derives share identity, current revision (etag), owner, visibility,
|
|
2193
|
-
// org domain, and producer provenance SERVER-SIDE from R2 metadata, ignoring
|
|
2194
|
-
// anything the browser supplied (the 'share' field is a LOOKUP key only,
|
|
2195
|
-
// never trusted as identity);
|
|
2196
|
-
// 3. reuses the EXACT page read gate (me/org identity gate + private token
|
|
2197
|
-
// gate), mapping any denial to 404 so an out-of-scope viewer cannot tell the
|
|
2198
|
-
// share exists; every WRITE additionally requires a verified signed-in
|
|
2199
|
-
// Phoenix human;
|
|
2200
|
-
// 4. proxies to the Prix artifact-collaboration API with the service token in
|
|
2201
|
-
// Authorization (never surfaced to the browser) plus trusted X-Artifact-* /
|
|
2202
|
-
// X-Phoenix-* headers, streaming SSE through without buffering.
|
|
2203
|
-
//
|
|
2204
|
-
// Missing PRIX_ARTIFACT_COLLAB_BASE or ARTIFACT_COLLAB_SERVICE_TOKEN disables the
|
|
2205
|
-
// whole surface (404) without touching artifact page GET.
|
|
2206
|
-
|
|
2207
|
-
var COLLAB_VISIBILITIES = ['public', 'unlisted', 'private', 'me', 'org'];
|
|
2208
|
-
|
|
2209
|
-
// The managed collaboration backend, or null when unconfigured. Both the base
|
|
2210
|
-
// URL and the service token must be present; either missing fails the whole
|
|
2211
|
-
// surface closed.
|
|
2212
|
-
function collabConfig(env) {
|
|
2213
|
-
const base = typeof env.PRIX_ARTIFACT_COLLAB_BASE === 'string' ? env.PRIX_ARTIFACT_COLLAB_BASE.replace(/\\/+$/, '').trim() : '';
|
|
2214
|
-
const token = typeof env.ARTIFACT_COLLAB_SERVICE_TOKEN === 'string' ? env.ARTIFACT_COLLAB_SERVICE_TOKEN : '';
|
|
2215
|
-
if (!base || !token) return null;
|
|
2216
|
-
return { base: base, token: token };
|
|
2217
|
-
}
|
|
2218
|
-
|
|
2219
|
-
// No collaboration response may be cached (visibility can downgrade on any
|
|
2220
|
-
// request), and every collab body is JSON unless overridden (SSE).
|
|
2221
|
-
function collabHeaders(extra) {
|
|
2222
|
-
const h = new Headers(extra || {});
|
|
2223
|
-
if (!h.has('content-type')) h.set('content-type', 'application/json');
|
|
2224
|
-
h.set('cache-control', 'no-store');
|
|
2225
|
-
return h;
|
|
2226
|
-
}
|
|
2227
|
-
|
|
2228
|
-
function collabError(message, status) {
|
|
2229
|
-
return new Response(JSON.stringify({ error: message }), { status: status, headers: collabHeaders() });
|
|
2230
|
-
}
|
|
2231
|
-
|
|
2232
|
-
// A collab denial is ALWAYS a 404 — never a 302 login bounce or a 401 for a read
|
|
2233
|
-
// — so an out-of-scope viewer cannot enumerate share existence.
|
|
2234
|
-
function collabNotFound() {
|
|
2235
|
-
return collabError('not found', 404);
|
|
2236
|
-
}
|
|
2237
|
-
|
|
2238
|
-
// The page READ gate, reused verbatim for collaboration reads/subscriptions but
|
|
2239
|
-
// with every denial normalized to 404. Returns null when the read is allowed.
|
|
2240
|
-
async function collabReadGate(request, url, obj, identity) {
|
|
2241
|
-
const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
|
|
2242
|
-
// Unknown visibility fails closed.
|
|
2243
|
-
if (COLLAB_VISIBILITIES.indexOf(visibility) === -1) return collabNotFound();
|
|
2244
|
-
if (isIdentityGated(visibility)) {
|
|
2245
|
-
// me/org: require a matching identity; a missing/wrong viewer 404s (never the
|
|
2246
|
-
// page GET's 302 login bounce, which is meaningless to a fetch()).
|
|
2247
|
-
if (!identity || !viewerMayRead(visibility, obj.customMetadata, identity)) return collabNotFound();
|
|
2248
|
-
return null;
|
|
2249
|
-
}
|
|
2250
|
-
if (visibility === 'private') {
|
|
2251
|
-
// private: require the viewer token, exactly as the page GET does.
|
|
2252
|
-
const denied = await gateTokenRead(request, url, obj, identity);
|
|
2253
|
-
if (denied) return collabNotFound();
|
|
2254
|
-
return null;
|
|
2255
|
-
}
|
|
2256
|
-
return null; // public | unlisted — readable when the object exists
|
|
2257
|
-
}
|
|
2258
|
-
|
|
2259
|
-
// Map a browser /__collab sub-route to the Prix artifact-collaboration route +
|
|
2260
|
-
// method. Returns null for any unsupported shape (fail loud, never a wrong path).
|
|
2261
|
-
// Only the cursor rides through as a query param; the browser's own \`share\`
|
|
2262
|
-
// param is dropped (Prix derives identity from the trusted header, not the path).
|
|
2263
|
-
function collabTarget(rest, method, url) {
|
|
2264
|
-
if (rest.length === 1 && rest[0] === 'context' && method === 'GET') {
|
|
2265
|
-
return { path: '/context', search: '', sse: false };
|
|
2266
|
-
}
|
|
2267
|
-
if (rest.length === 1 && rest[0] === 'threads') {
|
|
2268
|
-
if (method === 'GET') {
|
|
2269
|
-
const after = url.searchParams.get('after');
|
|
2270
|
-
return { path: '/threads', search: after ? '?after=' + encodeURIComponent(after) : '', sse: false };
|
|
2271
|
-
}
|
|
2272
|
-
if (method === 'POST') return { path: '/threads', search: '', sse: false };
|
|
2273
|
-
}
|
|
2274
|
-
if (rest.length === 2 && rest[0] === 'threads' && method === 'PATCH') {
|
|
2275
|
-
return { path: '/threads/' + encodeURIComponent(rest[1]), search: '', sse: false };
|
|
2276
|
-
}
|
|
2277
|
-
if (rest.length === 3 && rest[0] === 'threads' && rest[2] === 'replies' && method === 'POST') {
|
|
2278
|
-
return { path: '/threads/' + encodeURIComponent(rest[1]) + '/replies', search: '', sse: false };
|
|
2279
|
-
}
|
|
2280
|
-
if (rest.length === 2 && rest[0] === 'comments' && method === 'PATCH') {
|
|
2281
|
-
return { path: '/comments/' + encodeURIComponent(rest[1]), search: '', sse: false };
|
|
2282
|
-
}
|
|
2283
|
-
if (rest.length === 1 && rest[0] === 'events' && method === 'GET') {
|
|
2284
|
-
return { path: '/events', search: '', sse: true };
|
|
2285
|
-
}
|
|
2286
|
-
return null;
|
|
2287
|
-
}
|
|
2288
|
-
|
|
2289
|
-
async function handleCollab(request, env, url, path, ctx) {
|
|
2290
|
-
const cfg = collabConfig(env);
|
|
2291
|
-
// Fail closed: no managed backend → the surface does not exist.
|
|
2292
|
-
if (!cfg) return collabNotFound();
|
|
2293
|
-
|
|
2294
|
-
const segs = path.split('/').filter(Boolean); // ['__collab', ...]
|
|
2295
|
-
const rest = segs.slice(1);
|
|
2296
|
-
const method = request.method;
|
|
2297
|
-
|
|
2298
|
-
// Resolve the share path. GET carries it in ?share=, mutations in the JSON
|
|
2299
|
-
// body — read that body ONCE here so \`share\` extraction and verbatim upstream
|
|
2300
|
-
// forwarding share a single read.
|
|
2301
|
-
const isMutation = method === 'POST' || method === 'PATCH';
|
|
2302
|
-
let sharePath = '';
|
|
2303
|
-
let bodyText = '';
|
|
2304
|
-
if (isMutation) {
|
|
2305
|
-
bodyText = await request.text();
|
|
2306
|
-
let parsed;
|
|
2307
|
-
try { parsed = JSON.parse(bodyText || '{}'); } catch { return collabError('body must be JSON', 400); }
|
|
2308
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return collabError('body must be a JSON object', 400);
|
|
2309
|
-
sharePath = typeof parsed.share === 'string' ? parsed.share : '';
|
|
2310
|
-
} else {
|
|
2311
|
-
sharePath = url.searchParams.get('share') || '';
|
|
2312
|
-
}
|
|
2313
|
-
sharePath = String(sharePath).replace(/^\\/+/, '');
|
|
2314
|
-
const shareFirst = sharePath.split('/').filter(Boolean)[0] || '';
|
|
2315
|
-
// Never let a collab lookup reach an internal __-key or an empty share.
|
|
2316
|
-
if (!sharePath || shareFirst.startsWith('__')) return collabNotFound();
|
|
2317
|
-
|
|
2318
|
-
// OBJECT-FIRST: load the share before deriving anything. A missing share
|
|
2319
|
-
// (never provisioned, or just deleted) 404s.
|
|
2320
|
-
const obj = await env.BUCKET.get(sharePath);
|
|
2321
|
-
if (!obj) return collabNotFound();
|
|
2322
|
-
|
|
2323
|
-
// Re-derive identity SERVER-SIDE and apply the page read gate. Collab clients
|
|
2324
|
-
// authenticate with the Bearer or the __share cookie, never a ?phoenix_ticket=
|
|
2325
|
-
// (that is a page-navigation param the CLI appends when OPENING a page). So we
|
|
2326
|
-
// use only the resolved identity and ignore any redirect outcome — a 302 login
|
|
2327
|
-
// bounce is meaningless to a fetch(). If a ticket were present it would be
|
|
2328
|
-
// redeemed (and, being single-use, consumed) here but its cookie discarded, so
|
|
2329
|
-
// the request falls through as anonymous — the fail-safe (more restrictive)
|
|
2330
|
-
// direction, never an escalation.
|
|
2331
|
-
const viewer = await resolveViewer(request, env, url);
|
|
2332
|
-
const identity = viewer.identity || null;
|
|
2333
|
-
const readDenied = await collabReadGate(request, url, obj, identity);
|
|
2334
|
-
if (readDenied) return readDenied;
|
|
2335
|
-
|
|
2336
|
-
// Every write requires a verified signed-in Phoenix human, ON TOP of the read
|
|
2337
|
-
// gate (public/unlisted readers may read anonymously but never write).
|
|
2338
|
-
if (isMutation && (!identity || !identity.userId)) {
|
|
2339
|
-
return collabError('sign in with Phoenix to comment', 401);
|
|
2340
|
-
}
|
|
2341
|
-
|
|
2342
|
-
const target = collabTarget(rest, method, url);
|
|
2343
|
-
if (!target) return collabError('unsupported collaboration route', 404);
|
|
2344
|
-
|
|
2345
|
-
// Trusted, server-derived values. owner + NUL + normalized path → a stable
|
|
2346
|
-
// share id; the raw private access token is NEVER used as identity.
|
|
2347
|
-
const meta = obj.customMetadata || {};
|
|
2348
|
-
const visibility = meta.visibility || 'public';
|
|
2349
|
-
const owner = meta.owner || '';
|
|
2350
|
-
const shareId = await sha256Hex(owner + '\\u0000' + sharePath);
|
|
2351
|
-
const revision = obj.etag || '';
|
|
2352
|
-
|
|
2353
|
-
const headers = new Headers();
|
|
2354
|
-
headers.set('authorization', 'Bearer ' + cfg.token);
|
|
2355
|
-
headers.set('x-artifact-share-id', shareId);
|
|
2356
|
-
if (revision) headers.set('x-artifact-revision', revision);
|
|
2357
|
-
headers.set('x-artifact-visibility', visibility);
|
|
2358
|
-
if (owner) headers.set('x-artifact-owner-id', owner);
|
|
2359
|
-
if (meta.org_domain) headers.set('x-artifact-org-domain', meta.org_domain);
|
|
2360
|
-
if (identity && identity.userId) headers.set('x-phoenix-actor-id', identity.userId);
|
|
2361
|
-
if (identity && identity.email) headers.set('x-phoenix-actor-email', identity.email);
|
|
2362
|
-
// Preserve existing R2 producer provenance (RUSH-2683) so Prix can attribute a
|
|
2363
|
-
// comment thread to the run that made the artifact.
|
|
2364
|
-
if (meta.agent) headers.set('x-artifact-agent', meta.agent);
|
|
2365
|
-
if (meta.session) headers.set('x-artifact-session', meta.session);
|
|
2366
|
-
if (meta.host) headers.set('x-artifact-host', meta.host);
|
|
2367
|
-
if (meta.repo) headers.set('x-artifact-repo', meta.repo);
|
|
2368
|
-
// Idempotency + resumable-SSE headers ride through unchanged.
|
|
2369
|
-
const idem = request.headers.get('idempotency-key');
|
|
2370
|
-
if (idem) headers.set('idempotency-key', idem);
|
|
2371
|
-
const lastEvent = request.headers.get('last-event-id');
|
|
2372
|
-
if (lastEvent) headers.set('last-event-id', lastEvent);
|
|
2373
|
-
if (target.sse) headers.set('accept', 'text/event-stream');
|
|
2374
|
-
else if (isMutation) headers.set('content-type', 'application/json');
|
|
2375
|
-
|
|
2376
|
-
const init = { method: method, headers: headers };
|
|
2377
|
-
if (isMutation) init.body = bodyText;
|
|
2378
|
-
// Propagate a client disconnect to the upstream fetch (SSE cancel).
|
|
2379
|
-
if (request.signal) init.signal = request.signal;
|
|
2380
|
-
|
|
2381
|
-
let upstream;
|
|
2382
|
-
try {
|
|
2383
|
-
upstream = await hooks.collabFetch(new Request(cfg.base + '/v1/artifact-collaboration' + target.path + target.search, init));
|
|
2384
|
-
} catch (e) {
|
|
2385
|
-
return collabError('collaboration backend unavailable', 502);
|
|
2386
|
-
}
|
|
2387
|
-
|
|
2388
|
-
// Forward Prix status + body. Never echo the service token; every collab
|
|
2389
|
-
// response is no-store. SSE streams through unbuffered — event IDs and content
|
|
2390
|
-
// type ride the bytes, and disabling proxy buffering keeps the stream live.
|
|
2391
|
-
const ct = upstream.headers.get('content-type');
|
|
2392
|
-
const outHeaders = collabHeaders();
|
|
2393
|
-
if (target.sse) {
|
|
2394
|
-
outHeaders.set('content-type', ct || 'text/event-stream');
|
|
2395
|
-
outHeaders.set('x-accel-buffering', 'no');
|
|
2396
|
-
} else if (ct) {
|
|
2397
|
-
outHeaders.set('content-type', ct);
|
|
2398
|
-
}
|
|
2399
|
-
return new Response(upstream.body, { status: upstream.status, headers: outHeaders });
|
|
2400
|
-
}
|
|
2401
|
-
|
|
2402
|
-
// Best-effort authenticated purge of a deleted share's collaboration threads.
|
|
2403
|
-
// Swallows every error — a purge failure must never fail the share delete, and
|
|
2404
|
-
// object-first gating already made the comments inaccessible.
|
|
2405
|
-
async function purgeCollab(env, cfg, owner, sharePath) {
|
|
2406
|
-
try {
|
|
2407
|
-
const shareId = await sha256Hex((owner || '') + '\\u0000' + sharePath);
|
|
2408
|
-
const headers = new Headers();
|
|
2409
|
-
headers.set('authorization', 'Bearer ' + cfg.token);
|
|
2410
|
-
headers.set('x-artifact-share-id', shareId);
|
|
2411
|
-
await hooks.collabFetch(new Request(cfg.base + '/v1/artifact-collaboration/purge', { method: 'POST', headers: headers }));
|
|
2412
|
-
} catch (e) {
|
|
2413
|
-
// best-effort: a purge failure must never fail the share delete
|
|
2414
|
-
}
|
|
2415
|
-
}
|
|
2416
|
-
|
|
2417
|
-
// The real upstream hop to Prix. Overridable in tests (like verifyPhoenixToken)
|
|
2418
|
-
// so the generated Worker's derivation/gating is exercised against a simulated
|
|
2419
|
-
// backend without a live network.
|
|
2420
|
-
async function defaultCollabFetch(request) {
|
|
2421
|
-
return fetch(request);
|
|
2422
|
-
}
|
|
2423
|
-
`;
|
|
2424
|
-
}
|