@chrischall/gemini-mcp 1.3.0 → 1.5.0

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/dist/client.js CHANGED
@@ -153,8 +153,17 @@ export class GeminiClient {
153
153
  * server is single-user, so its one singleton client is one session.
154
154
  */
155
155
  session = new SessionState();
156
+ /** Per-account character/style library — hosted connector only (see options). */
157
+ library;
158
+ /** Signed upload-URL minter — hosted connector only (see options). */
159
+ uploadUrls;
156
160
  constructor(opts = {}) {
157
161
  this.mediaSink = opts.mediaSink ?? createDiskSink();
162
+ this.library = opts.library;
163
+ this.uploadUrls = opts.uploadUrls;
164
+ // A replayed idempotent result must not hand back an expired media URL, so
165
+ // the job registry needs a way to re-sign from the stable r2_key.
166
+ this.session.jobs.resigner = this.mediaSink;
158
167
  this.explicitApiKey = opts.apiKey?.trim() || undefined;
159
168
  this.fetchImpl = opts.fetchImpl ?? fetch;
160
169
  this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -227,6 +236,21 @@ export class GeminiClient {
227
236
  // one unbounded network operation in the server.
228
237
  return fetchRemoteImage(url, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
229
238
  }
239
+ /**
240
+ * Read back media this connector generated, straight out of its own store.
241
+ *
242
+ * The alternative — fetching our own `/media` URL — needs a signature the
243
+ * caller cannot mint and returns 403, which is the loop this exists to close.
244
+ */
245
+ async readStoredMedia(key) {
246
+ const stored = await this.mediaSink.read?.(key.trim());
247
+ if (!stored) {
248
+ // Remediation goes in the MESSAGE: MCP serialization drops hints.
249
+ throw new McpToolError(`No stored media for r2_key "${key}" — objects are removed on a retention schedule, so an old key may be gone. ` +
250
+ 'Re-run the generation, or upload the bytes with `url` / `data_base64`.', { hint: 'Use the exact media[].r2_key from a generation result.' });
251
+ }
252
+ return stored;
253
+ }
230
254
  /** The default model after env override (no per-call arg). */
231
255
  defaultModel() {
232
256
  return resolveModel(undefined, readEnvVar('GEMINI_IMAGE_MODEL'));
package/dist/inputs.js CHANGED
@@ -7,6 +7,7 @@ export function hasImageInput(args) {
7
7
  args.images_base64?.length ||
8
8
  args.images_url?.length ||
9
9
  args.images_file_uris?.length ||
10
+ args.images_r2_keys?.length ||
10
11
  args.from_clipboard);
11
12
  }
12
13
  /** Conservative TTL when the API doesn't tell us: documented retention is ~48h. */
@@ -75,6 +76,40 @@ export async function resolveImageInputs(args, client) {
75
76
  (report.file_uris ??= []).push(file.name);
76
77
  inputs.push(input);
77
78
  }
79
+ // 6. r2_keys — the connector reads its OWN store (tenant-gated), so a signed
80
+ // upload or an earlier generation becomes a reference without a fetch, a
81
+ // signature, or a byte in the conversation. Deduped within the call, same as
82
+ // URLs, and — also same as URLs — a large object is promoted to a Files API
83
+ // reference rather than inlined, because `generateContent` caps a whole
84
+ // request near 20MB and two large inline references would fail it.
85
+ // `readStoredMedia` throws an actionable error for a swept/foreign key.
86
+ const seenKeys = new Map();
87
+ for (const key of args.images_r2_keys ?? []) {
88
+ const hit = seenKeys.get(key);
89
+ if (hit) {
90
+ inputs.push(hit);
91
+ continue;
92
+ }
93
+ const stored = await client.readStoredMedia(key);
94
+ const entry = {
95
+ r2_key: key.trim(),
96
+ mime_type: stored.mimeType,
97
+ bytes: stored.bytes.byteLength,
98
+ };
99
+ (report.r2_keys ??= []).push(entry);
100
+ let input;
101
+ if (stored.bytes.byteLength <= IMAGE_INLINE_MAX_BYTES) {
102
+ input = { base64: bytesToBase64(stored.bytes), mimeType: stored.mimeType };
103
+ }
104
+ else {
105
+ const displayName = key.trim().split('/').pop() || 'image';
106
+ const uploaded = await client.uploadBytes(stored.bytes, stored.mimeType, displayName);
107
+ entry.file_uri = uploaded.name;
108
+ input = { uri: uploaded.uri, mimeType: uploaded.mimeType };
109
+ }
110
+ seenKeys.set(key, input);
111
+ inputs.push(input);
112
+ }
78
113
  return { inputs, report: Object.keys(report).length > 0 ? report : undefined };
79
114
  }
80
115
  /**
@@ -161,5 +196,6 @@ export function requireImageInput(args) {
161
196
  if (hasImageInput(args))
162
197
  return;
163
198
  throw new McpToolError('Provide at least one input image via `images_url` (an https URL the server fetches), `images_file_uris` ' +
164
- '(from gemini_upload_file), `images` (a local path), `images_base64`, or `from_clipboard`.');
199
+ '(from gemini_upload_file), `images_r2_keys` (a hosted-connector upload or earlier generation), ' +
200
+ '`images` (a local path), `images_base64`, or `from_clipboard`.');
165
201
  }
package/dist/jobs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID, createHash } from 'node:crypto';
2
2
  import { textResult, McpToolError } from '@chrischall/mcp-utils';
3
+ import { downloadFilename } from './media-endpoint.js';
3
4
  /** Recently-completed jobs are reusable by key for this long, then evicted. */
4
5
  const JOB_TTL_MS = 600_000; // 10 min
5
6
  /** Hard cap on retained entries (oldest-settled evicted first). */
@@ -22,6 +23,91 @@ function isExpired(entry, now) {
22
23
  export function fingerprintRequest(toolName, parts) {
23
24
  return createHash('sha256').update(`${toolName}\u0000${JSON.stringify(parts)}`).digest('hex');
24
25
  }
26
+ /**
27
+ * Re-mint any media URLs a recorded result carries, before it is replayed.
28
+ *
29
+ * A recorded result can outlive the signed URLs inside it — and after a change
30
+ * to the result shape it can even carry the OLD shape entirely. Either way the
31
+ * caller gets something that looks right and does not work. Re-signing from the
32
+ * `r2_key`, which is stable, fixes both: an expired link becomes a live one,
33
+ * and a legacy entry gains the `url`/`curl_hint` fields the current shape has.
34
+ *
35
+ * Best-effort by design. A replay that cannot be refreshed is still a valid
36
+ * replay — the point of idempotency is not billing twice, and failing here
37
+ * would trade a stale URL for no result at all.
38
+ */
39
+ async function refreshMedia(result, sink) {
40
+ const first = result.content?.[0];
41
+ if (!sink?.resign || !first || first.type !== 'text')
42
+ return result;
43
+ let body;
44
+ try {
45
+ body = JSON.parse(first.text);
46
+ }
47
+ catch {
48
+ return result;
49
+ }
50
+ const entries = Array.isArray(body.media) ? body.media : [];
51
+ if (!entries.some((e) => typeof e.r2_key === 'string'))
52
+ return result;
53
+ // Per ENTRY, not per key: filtering first would shift every assignment after
54
+ // an entry without an r2_key and truncate the flat list below.
55
+ const refreshed = await Promise.all(entries.map(async (e) => {
56
+ if (typeof e.r2_key !== 'string')
57
+ return undefined;
58
+ try {
59
+ return await sink.resign(e.r2_key);
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }));
65
+ if (refreshed.every((r) => r === undefined))
66
+ return result;
67
+ const mediaKeyName = ['images', 'videos', 'audios'].find((k) => Array.isArray(body[k]));
68
+ const refs = [];
69
+ refreshed.forEach((fresh, i) => {
70
+ if (!fresh) {
71
+ refs.push(String(entries[i]?.url ?? ''));
72
+ return;
73
+ }
74
+ // Drop the recorded expiry rather than carrying it over: a re-signed URL
75
+ // has its own deadline (or none, on a public-bucket sink), and the old one
76
+ // would mislabel a live link as expired.
77
+ const { expires_at: _stale, ...rest } = entries[i];
78
+ entries[i] = {
79
+ ...rest,
80
+ url: fresh.ref,
81
+ ...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
82
+ curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? 'media')} "${fresh.ref}"`,
83
+ };
84
+ refs.push(fresh.ref);
85
+ });
86
+ body.media = entries;
87
+ if (mediaKeyName)
88
+ body[mediaKeyName] = refs;
89
+ // A multi-image set also carries a bundle zip; its link expires like any
90
+ // other and re-signs from its own r2_key. Best-effort, like the rest.
91
+ const bundle = body.bundle;
92
+ if (bundle && typeof bundle.r2_key === 'string') {
93
+ try {
94
+ const fresh = await sink.resign(bundle.r2_key);
95
+ if (fresh) {
96
+ const { expires_at: _staleBundle, ...rest } = bundle;
97
+ body.bundle = {
98
+ ...rest,
99
+ url: fresh.ref,
100
+ ...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
101
+ curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? 'media')} "${fresh.ref}"`,
102
+ };
103
+ body.bundle_url = fresh.ref;
104
+ }
105
+ }
106
+ catch { /* an un-refreshed bundle link is still a valid replay */ }
107
+ }
108
+ body.media_urls_refreshed = true;
109
+ return { ...result, content: [{ ...first, text: JSON.stringify(body, null, 2) }, ...(result.content ?? []).slice(1)] };
110
+ }
25
111
  /** Clone `result`, adding `reused: true` + `reused_job_id` to its meta block. */
26
112
  function annotateReused(result, jobId) {
27
113
  const content = result.content ?? [];
@@ -52,12 +138,31 @@ function jobHandle(jobId, status) {
52
138
  hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`,
53
139
  });
54
140
  }
141
+ /**
142
+ * Await `promise` for at most `waitMs`; `undefined` means the budget expired
143
+ * with the work still pending (rejections propagate as usual). The timer never
144
+ * holds the process open, and a late rejection after the budget expired is
145
+ * already handled by the registry's own settle handlers.
146
+ */
147
+ function awaitWithBudget(promise, waitMs) {
148
+ return new Promise((resolve, reject) => {
149
+ const timer = setTimeout(() => resolve(undefined), waitMs);
150
+ timer.unref?.();
151
+ promise.then((result) => { clearTimeout(timer); resolve(result); }, (err) => { clearTimeout(timer); reject(err); });
152
+ });
153
+ }
55
154
  /**
56
155
  * One session's jobs. Construct one per session and never share it — see the
57
156
  * cross-tenant note at the top of this file. Reachable as
58
157
  * `client.session.jobs`.
59
158
  */
60
159
  export class JobRegistry {
160
+ /**
161
+ * Lets a replayed result have its media URLs re-minted. Set by the session
162
+ * that owns this registry; absent on stdio, where refs are file paths that
163
+ * never expire.
164
+ */
165
+ resigner;
61
166
  jobs = new Map();
62
167
  byKey = new Map(); // idempotencyKey -> jobId
63
168
  runningByFingerprint = new Map(); // fingerprint -> jobId (only while running)
@@ -108,7 +213,7 @@ export class JobRegistry {
108
213
  * different registry and therefore a genuinely separate generation.
109
214
  */
110
215
  async dispatch(opts, work) {
111
- const { toolName, fingerprint, idempotencyKey, async } = opts;
216
+ const { toolName, fingerprint, idempotencyKey, async, waitMs } = opts;
112
217
  const now = Date.now();
113
218
  let hit;
114
219
  if (idempotencyKey !== undefined) {
@@ -126,7 +231,15 @@ export class JobRegistry {
126
231
  if (hit) {
127
232
  if (async)
128
233
  return jobHandle(hit.jobId, hit.status);
129
- return annotateReused(await hit.promise, hit.jobId);
234
+ const settled = waitMs !== undefined && hit.status === 'running'
235
+ ? await awaitWithBudget(hit.promise, waitMs)
236
+ : await hit.promise;
237
+ if (settled === undefined)
238
+ return jobHandle(hit.jobId, 'running');
239
+ // Refresh before annotating: a recorded result can outlive the signed
240
+ // URLs inside it, and replaying an expired link is its own kind of wrong
241
+ // answer — it looks like success.
242
+ return annotateReused(await refreshMedia(settled, this.resigner), hit.jobId);
130
243
  }
131
244
  const jobId = randomUUID();
132
245
  this.evict(now);
@@ -146,6 +259,13 @@ export class JobRegistry {
146
259
  });
147
260
  if (async)
148
261
  return jobHandle(jobId, 'running');
262
+ if (waitMs !== undefined) {
263
+ const settled = await awaitWithBudget(promise, waitMs);
264
+ // Budget expired with the work still running: hand back the job id — the
265
+ // work continues and the caller polls, exactly as with `async: true`,
266
+ // except a fast result would have been returned in-band.
267
+ return settled ?? jobHandle(jobId, 'running');
268
+ }
149
269
  return promise;
150
270
  }
151
271
  /**
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The per-account reference library: named characters (a reference image + a
3
+ * description) and named styles (a reusable prompt fragment, optionally with a
4
+ * reference image of its own).
5
+ *
6
+ * Why it exists: keeping a recurring subject consistent across generations
7
+ * means re-supplying the same reference image and the same description every
8
+ * session — re-uploading the photo, re-typing the description, re-explaining
9
+ * the style. The library makes each of those a NAME: save once, then pass
10
+ * `characters: ["finn"]` / `style: "bold-cartoon-sports"` on any generation.
11
+ *
12
+ * Storage is the connector's R2 bucket under `lib/<tenant>/…`:
13
+ *
14
+ * - `lib/<tenant>/characters/<name>.json` — the record
15
+ * - `lib/<tenant>/characters/<name>.img` — the reference image bytes
16
+ * - `lib/<tenant>/styles/<name>.json` (+ `.img` when a reference was given)
17
+ *
18
+ * Two properties are load-bearing:
19
+ *
20
+ * - **No expiry.** The retention cron sweeps `gen/`, `media/` and `up/` but
21
+ * deliberately never `lib/` (media-cleanup.ts) — a saved character outlives
22
+ * every signed URL and every upload. That is why saving COPIES the image
23
+ * bytes into `lib/` rather than storing an `r2_key` pointer: a pointer into
24
+ * `gen/` or `up/` would dangle after the next sweep.
25
+ * - **Tenant scoping by construction.** Every key this module touches is
26
+ * derived from the session's own tenant id; caller-supplied names are
27
+ * validated against a strict slug charset, so no name can address another
28
+ * account's namespace (or escape `lib/` at all).
29
+ *
30
+ * Worker-safe: no node imports, no module-scope state, structural bucket type.
31
+ */
32
+ /** Key prefix for the library — the one prefix the retention sweep must skip. */
33
+ export const LIBRARY_KEY_PREFIX = 'lib';
34
+ /**
35
+ * Library names are slugs: 1–40 chars, lowercase alphanumerics plus `-`/`_`,
36
+ * starting alphanumeric. Doubles as key-safety (no `/`, `.`, whitespace).
37
+ */
38
+ export const LIBRARY_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/;
39
+ export function isValidLibraryName(name) {
40
+ return LIBRARY_NAME_PATTERN.test(name);
41
+ }
42
+ /** Build the R2-backed library for one session. */
43
+ export function createR2Library(bucket, opts) {
44
+ const now = opts.now ?? (() => new Date());
45
+ let tenantCache;
46
+ const tenantId = () => (tenantCache ??= Promise.resolve(typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant));
47
+ const assertName = (name) => {
48
+ if (!isValidLibraryName(name)) {
49
+ throw new Error(`Invalid library name "${name}" — use 1-40 lowercase letters, digits, "-" or "_" (e.g. "finn", "bold-cartoon-sports").`);
50
+ }
51
+ return name;
52
+ };
53
+ const keyFor = async (kind, name, ext) => `${LIBRARY_KEY_PREFIX}/${await tenantId()}/${kind}/${assertName(name)}.${ext}`;
54
+ async function readJson(key) {
55
+ const object = await bucket.get(key);
56
+ if (!object)
57
+ return undefined;
58
+ try {
59
+ return JSON.parse(new TextDecoder().decode(await object.arrayBuffer()));
60
+ }
61
+ catch {
62
+ // A malformed record is unreadable, not fatal — callers treat it as absent.
63
+ return undefined;
64
+ }
65
+ }
66
+ async function readImage(kind, name) {
67
+ const object = await bucket.get(await keyFor(kind, name, 'img'));
68
+ if (!object)
69
+ return undefined;
70
+ return {
71
+ bytes: new Uint8Array(await object.arrayBuffer()),
72
+ mimeType: object.httpMetadata?.contentType ?? 'application/octet-stream',
73
+ };
74
+ }
75
+ async function listRecords(kind) {
76
+ const prefix = `${LIBRARY_KEY_PREFIX}/${await tenantId()}/${kind}/`;
77
+ const keys = [];
78
+ let cursor;
79
+ for (;;) {
80
+ const page = await bucket.list({ prefix, cursor, limit: 1000 });
81
+ keys.push(...page.objects.map((o) => o.key).filter((k) => k.endsWith('.json')));
82
+ if (!page.truncated)
83
+ break;
84
+ cursor = page.cursor;
85
+ }
86
+ const records = await Promise.all(keys.map((k) => readJson(k)));
87
+ return records
88
+ .filter((r) => r !== undefined)
89
+ .sort((a, b) => a.name.localeCompare(b.name));
90
+ }
91
+ async function deleteEntry(kind, name) {
92
+ const jsonKey = await keyFor(kind, name, 'json');
93
+ const existed = (await bucket.get(jsonKey)) !== null;
94
+ await bucket.delete([jsonKey, await keyFor(kind, name, 'img')]);
95
+ return existed;
96
+ }
97
+ return {
98
+ async saveCharacter({ name, description, image }) {
99
+ const jsonKey = await keyFor('characters', name, 'json');
100
+ const imageKey = await keyFor('characters', name, 'img');
101
+ const at = now().toISOString();
102
+ const existing = await readJson(jsonKey);
103
+ const record = {
104
+ name,
105
+ description,
106
+ image_key: imageKey,
107
+ mime_type: image.mimeType,
108
+ created_at: existing?.created_at ?? at,
109
+ updated_at: at,
110
+ };
111
+ await bucket.put(imageKey, image.bytes, { httpMetadata: { contentType: image.mimeType } });
112
+ await bucket.put(jsonKey, JSON.stringify(record, null, 2), { httpMetadata: { contentType: 'application/json' } });
113
+ return record;
114
+ },
115
+ async getCharacter(name) {
116
+ return readJson(await keyFor('characters', name, 'json'));
117
+ },
118
+ readCharacterImage: (name) => readImage('characters', name),
119
+ listCharacters: () => listRecords('characters'),
120
+ deleteCharacter: (name) => deleteEntry('characters', name),
121
+ async saveStyle({ name, promptFragment, image }) {
122
+ const jsonKey = await keyFor('styles', name, 'json');
123
+ const imageKey = await keyFor('styles', name, 'img');
124
+ const at = now().toISOString();
125
+ const existing = await readJson(jsonKey);
126
+ const record = {
127
+ name,
128
+ prompt_fragment: promptFragment,
129
+ ...(image ? { image_key: imageKey, mime_type: image.mimeType } : {}),
130
+ created_at: existing?.created_at ?? at,
131
+ updated_at: at,
132
+ };
133
+ if (image) {
134
+ await bucket.put(imageKey, image.bytes, { httpMetadata: { contentType: image.mimeType } });
135
+ }
136
+ else if (existing?.image_key) {
137
+ // Re-saving without an image REPLACES the style — a stale reference
138
+ // image left behind would silently keep steering generations.
139
+ await bucket.delete(imageKey);
140
+ }
141
+ await bucket.put(jsonKey, JSON.stringify(record, null, 2), { httpMetadata: { contentType: 'application/json' } });
142
+ return record;
143
+ },
144
+ async getStyle(name) {
145
+ return readJson(await keyFor('styles', name, 'json'));
146
+ },
147
+ readStyleImage: (name) => readImage('styles', name),
148
+ listStyles: () => listRecords('styles'),
149
+ deleteStyle: (name) => deleteEntry('styles', name),
150
+ };
151
+ }
@@ -17,8 +17,11 @@ const DEFAULT_MAX_OBJECTS = 5_000;
17
17
  * `gen/` is where media is written now; `media/` is where it was written before
18
18
  * the rename that made URLs read `/media/gen/…` instead of `/media/media/…`.
19
19
  * Both are swept — retention that ignores the existing backlog is not retention.
20
+ * `up/` (signed uploads) is swept on the same schedule: a reference photo worth
21
+ * keeping gets copied into the character library (`lib/`), which is the ONE
22
+ * prefix deliberately absent here — saved characters and styles have no expiry.
20
23
  */
21
- const LEGACY_AND_CURRENT_PREFIXES = ['gen/', 'media/'];
24
+ const LEGACY_AND_CURRENT_PREFIXES = ['gen/', 'media/', 'up/'];
22
25
  /** R2 accepts bulk deletes in batches; 1000 is the documented ceiling. */
23
26
  const DELETE_BATCH = 1_000;
24
27
  /** Days → ms, defaulting to 7 for anything missing or nonsensical. */
@@ -1,4 +1,81 @@
1
1
  import { loadSigningKey, verifyMediaSignature } from './media-url.js';
2
+ /**
3
+ * Human-friendly download name from an object key.
4
+ *
5
+ * Keys look like `gen/<tenant>/2026-07-29/ab12cd34-a-cat.png`; the random component is
6
+ * there to make the key unique, not to be read. Stripping it turns a saved file
7
+ * into `a-cat.png`, which matters because the reliable way to show a user an
8
+ * image in a chat client is for the agent to download it and attach it — and an
9
+ * attachment called `ab12cd34-a-cat.png` looks like a machine artefact.
10
+ */
11
+ export function downloadFilename(key) {
12
+ const last = key.split('/').pop() || 'media';
13
+ const stripped = last.replace(/^[0-9a-f]{6,12}-/i, '');
14
+ return stripped || last;
15
+ }
16
+ /**
17
+ * Parse a single-range `Range: bytes=a-b` header. Multi-range is unsupported,
18
+ * and an unknown object size means no range handling at all (Range is advisory
19
+ * — `undefined` degrades to a full 200, never an error).
20
+ */
21
+ function parseRange(header, size) {
22
+ if (!header || size === undefined)
23
+ return undefined;
24
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
25
+ if (!match)
26
+ return undefined;
27
+ const [, startText, endText] = match;
28
+ if (startText === '' && endText === '')
29
+ return undefined;
30
+ if (startText === '') {
31
+ // Suffix range: the last N bytes.
32
+ const suffix = Number(endText);
33
+ if (!suffix)
34
+ return undefined;
35
+ const offset = Math.max(0, size - suffix);
36
+ return { offset, length: size - offset };
37
+ }
38
+ const offset = Number(startText);
39
+ if (offset >= size)
40
+ return 'unsatisfiable';
41
+ const end = endText === '' ? size - 1 : Number(endText);
42
+ if (end < offset)
43
+ return 'unsatisfiable';
44
+ return { offset, length: end - offset + 1 };
45
+ }
46
+ const MISSING = 'That media object no longer exists — it may have been cleaned up. Re-run the generation to get a fresh one.';
47
+ /**
48
+ * Response headers shared by GET, HEAD and 206. `Content-Disposition` carries a
49
+ * readable filename because the reliable way to show a user an image in a chat
50
+ * client is for the agent to download it and attach it.
51
+ */
52
+ function baseHeaders(key, object, size) {
53
+ const headers = new Headers({
54
+ 'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream',
55
+ // The URL is signed and expiring, so it is safe to cache for its lifetime;
56
+ // `private` keeps shared caches from serving it to anyone else.
57
+ 'cache-control': 'private, max-age=3600',
58
+ 'content-disposition': `inline; filename="${downloadFilename(key)}"`,
59
+ // The bytes are model output being handed to a browser — never let a
60
+ // sniffed content type turn an image response into something executable.
61
+ 'x-content-type-options': 'nosniff',
62
+ // Defense in depth against stored XSS: this route serves user-influenced
63
+ // bytes from the SAME ORIGIN as the connector's OAuth pages, so if a
64
+ // scriptable document type (SVG, HTML) ever slipped past the raster-only
65
+ // upload gates, this neutralizes it — no script, no fetch, and a unique
66
+ // opaque origin via sandbox. Pixels don't need any of what this disables.
67
+ 'content-security-policy': "default-src 'none'; sandbox",
68
+ // Range support is cheap on R2 and is what makes a resumable `curl -C -`
69
+ // work against a large video.
70
+ 'accept-ranges': 'bytes',
71
+ });
72
+ const etag = object.httpEtag ?? object.etag;
73
+ if (etag)
74
+ headers.set('etag', etag);
75
+ if (size !== undefined)
76
+ headers.set('content-length', String(size));
77
+ return headers;
78
+ }
2
79
  function problem(status, message) {
3
80
  return new Response(`${message}\n`, {
4
81
  status,
@@ -49,24 +126,41 @@ export function createMediaHandler(env, basePath = '/media/') {
49
126
  }
50
127
  return problem(403, 'Invalid or missing media signature. Media links must be used exactly as returned by the tool, including the exp and sig parameters.');
51
128
  }
129
+ const rangeHeader = request.headers.get('range');
130
+ // HEAD is what a link-preview fetcher and `curl -I` send first; serve it
131
+ // from a metadata read where the bucket offers one, rather than opening a
132
+ // body we are about to throw away.
133
+ if (request.method === 'HEAD' && env.bucket.head) {
134
+ const meta = await env.bucket.head(key);
135
+ if (!meta)
136
+ return problem(404, MISSING);
137
+ return new Response(null, { status: 200, headers: baseHeaders(key, meta, meta.size) });
138
+ }
139
+ // Fetch whole and slice on the way out rather than pushing the range into
140
+ // R2: the objects here are single images and short clips, and a typed
141
+ // range option is not expressible against both the real binding and a test
142
+ // fake without casting away the very types that make this safe.
52
143
  const object = await env.bucket.get(key);
53
- if (!object) {
54
- return problem(404, 'That media object no longer exists — it may have been cleaned up. Re-run the generation to get a fresh one.');
144
+ if (!object)
145
+ return problem(404, MISSING);
146
+ const totalSize = object.size;
147
+ const range = parseRange(rangeHeader, totalSize);
148
+ if (range === 'unsatisfiable') {
149
+ return new Response('Requested range not satisfiable\n', {
150
+ status: 416,
151
+ headers: { 'content-range': `bytes */${totalSize ?? 0}`, 'content-type': 'text/plain; charset=utf-8' },
152
+ });
55
153
  }
56
- const headers = new Headers({
57
- 'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream',
58
- // The URL is signed and expiring, so it is safe to cache for its lifetime;
59
- // `private` keeps shared caches from serving it to anyone else.
60
- 'cache-control': 'private, max-age=3600',
61
- 'content-disposition': `inline; filename="${key.split('/').pop() ?? 'media'}"`,
62
- // The bytes are model output being handed to a browser — never let a
63
- // sniffed content type turn an image response into something executable.
64
- 'x-content-type-options': 'nosniff',
65
- });
66
- if (object.size !== undefined)
67
- headers.set('content-length', String(object.size));
154
+ const headers = baseHeaders(key, object, totalSize);
68
155
  if (request.method === 'HEAD')
69
156
  return new Response(null, { status: 200, headers });
70
- return new Response(object.body, { status: 200, headers });
157
+ if (range && totalSize !== undefined && object.arrayBuffer) {
158
+ const length = Math.min(range.length, totalSize - range.offset);
159
+ const slice = (await object.arrayBuffer()).slice(range.offset, range.offset + length);
160
+ headers.set('content-length', String(slice.byteLength));
161
+ headers.set('content-range', `bytes ${range.offset}-${range.offset + slice.byteLength - 1}/${totalSize}`);
162
+ return new Response(slice, { status: 206, headers });
163
+ }
164
+ return new Response(object.body ?? null, { status: 200, headers });
71
165
  };
72
166
  }
package/dist/media-url.js CHANGED
@@ -19,14 +19,14 @@
19
19
  export const MEDIA_URL_TTL_MS = 48 * 60 * 60 * 1000;
20
20
  /** KV key holding the lazily-generated signing secret (zero-config path). */
21
21
  export const SIGNING_SECRET_KV_KEY = 'media:url-signing-secret';
22
- function base64UrlEncode(bytes) {
22
+ export function base64UrlEncode(bytes) {
23
23
  const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
24
24
  let binary = '';
25
25
  for (let i = 0; i < view.length; i++)
26
26
  binary += String.fromCharCode(view[i]);
27
27
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
28
28
  }
29
- function base64UrlDecode(text) {
29
+ export function base64UrlDecode(text) {
30
30
  const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=');
31
31
  const binary = atob(padded);
32
32
  const out = new Uint8Array(binary.length);