@chrischall/gemini-mcp 1.5.1 → 1.6.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
@@ -1,7 +1,10 @@
1
1
  import { basename } from 'node:path';
2
2
  import { readEnvVar, McpToolError, ApiError, createApiClient, formatApiError, fileBlob } from '@chrischall/mcp-utils';
3
3
  import { resolveModel, filterImageModels, DEFAULT_VIDEO_MODEL, DEFAULT_MUSIC_MODEL } from './models.js';
4
- import { createDiskSink } from './storage/media.js';
4
+ import { createDiskSink, createR2Sink, tenantIdFor } from './storage/media.js';
5
+ import { createR2Library } from './library.js';
6
+ import { createUploadUrlMinter } from './upload-url.js';
7
+ import { blobStoreFromEnv } from './blob-store.js';
5
8
  import { SessionState } from './session.js';
6
9
  import { fetchRemoteImage } from './fetch-image.js';
7
10
  // NOTE: this module must stay SIDE-EFFECT-FREE at module scope — no I/O, no
@@ -746,5 +749,60 @@ export class GeminiClient {
746
749
  return { images, videos, audios, text: textParts.join('\n') || undefined, grounding };
747
750
  }
748
751
  }
752
+ /**
753
+ * Everything that needs somewhere to put bytes, when the host offers a blob
754
+ * store (`MCP_BLOB_BASE_URL` / `MCP_BLOB_SIGNING_KEY`, injected by mcp-host).
755
+ *
756
+ * This is the hosted shape of what the retired Cloudflare Worker did with its
757
+ * own R2 bucket: generated media becomes signed URLs instead of local paths,
758
+ * `gemini_get_upload_url` can mint a signed PUT so a shell can push a reference
759
+ * photo without base64-ing it through the conversation, and the character/style
760
+ * library has somewhere permanent to live.
761
+ *
762
+ * Absent (every local stdio install), this returns `{}` and the client falls
763
+ * back to the disk sink with no library and no upload URLs — the tools gate on
764
+ * presence, so they simply do not register rather than half-working.
765
+ *
766
+ * Side-effect-free at module scope: reading two env vars and closing over them.
767
+ */
768
+ function hostedStorage() {
769
+ const blob = blobStoreFromEnv();
770
+ if (!blob)
771
+ return {};
772
+ // Namespaced by API key like the connector was. One registration is one
773
+ // user here, so this is belt-and-braces rather than the tenancy boundary —
774
+ // that is the blob store's per-registration key. Lazy because the key is
775
+ // read at request time, not at construction (deferred-config-error).
776
+ const tenant = () => tenantIdFor(readEnvVar('GEMINI_API_KEY') ?? 'local');
777
+ return {
778
+ mediaSink: createR2Sink(blob.bucket, {
779
+ signedBaseUrl: blob.baseUrl,
780
+ sign: blob.signRead,
781
+ urlTtlMs: MEDIA_URL_TTL_MS,
782
+ // Hard ceiling on any PER-PERSIST override. `gemini_image_set` asks for
783
+ // a 7-day link (SET_URL_TTL_MS) — reasonable against a bucket we own,
784
+ // but the blob store refuses any signature over 24h, so without this
785
+ // clamp every set URL and bundle_url would be minted already-invalid and
786
+ // 403 on first click. The sink clamps with Math.min, so this only ever
787
+ // shortens.
788
+ maxUrlTtlMs: BLOB_MAX_TTL_MS,
789
+ // Signed uploads (`up/`) and the library (`lib/`) are readable by the
790
+ // session that owns them; writes still go only under `gen/`.
791
+ readPrefixes: ['up', 'lib'],
792
+ tenant,
793
+ }),
794
+ library: createR2Library(blob.bucket, { tenant }),
795
+ uploadUrls: createUploadUrlMinter({ baseUrl: blob.baseUrl, sign: blob.signWrite, tenant }),
796
+ };
797
+ }
798
+ /** An hour, well inside the ceiling below. */
799
+ const MEDIA_URL_TTL_MS = 60 * 60 * 1000;
800
+ /**
801
+ * The blob store's own limit on how far ahead a signature may expire
802
+ * (mcp-host docs/BLOB-STORE.md). Keep in step with it: signing past this is
803
+ * refused at the door, so a longer link is not a longer link — it is a broken
804
+ * one.
805
+ */
806
+ const BLOB_MAX_TTL_MS = 24 * 60 * 60 * 1000;
749
807
  /** Module-level singleton shared by every tool module (deferred-config-error). */
750
- export const client = new GeminiClient();
808
+ export const client = new GeminiClient(hostedStorage());
package/dist/index.js CHANGED
@@ -10,10 +10,12 @@ import { registerVideoTools } from './tools/video.js';
10
10
  import { registerMusicTools } from './tools/music.js';
11
11
  import { registerJobTools } from './tools/jobs.js';
12
12
  import { registerFileTools } from './tools/files.js';
13
+ import { registerUploadUrlTools } from './tools/uploads.js';
14
+ import { registerLibraryTools } from './tools/library.js';
13
15
  import { client } from './client.js';
14
- // Load `.env` here rather than in client.ts, which the Cloudflare Worker
15
- // imports: `import.meta.url` + async I/O at module scope crashes the isolate at
16
- // startup (see src/dotenv.ts, guarded by tests/connector-boot.test.ts).
16
+ // Load `.env` here rather than in client.ts: `import.meta.url` + async I/O at
17
+ // module scope is not safe in every runtime this module graph reaches (see
18
+ // src/dotenv.ts).
17
19
  //
18
20
  // Ordering is deliberately NOT load-bearing: GeminiClient reads
19
21
  // $GEMINI_API_KEY at request time, so it does not matter that the `client`
@@ -31,6 +33,20 @@ await runMcp({
31
33
  name: 'gemini-mcp',
32
34
  version: VERSION,
33
35
  banner: '[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
34
- tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools, registerFileTools],
36
+ tools: [
37
+ registerModelTools,
38
+ registerGenerateTools,
39
+ registerSetTools,
40
+ registerInteractTools,
41
+ registerVideoTools,
42
+ registerMusicTools,
43
+ registerJobTools,
44
+ registerFileTools,
45
+ // Both gate on the client having somewhere to put bytes: hosted with a
46
+ // blob store they register, and on a local install they no-op rather than
47
+ // advertising a tool that cannot work.
48
+ registerUploadUrlTools,
49
+ registerLibraryTools,
50
+ ],
35
51
  deps: client,
36
52
  });
package/dist/jobs.js CHANGED
@@ -1,6 +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
+ import { downloadFilename } from './media-name.js';
4
4
  /** Recently-completed jobs are reusable by key for this long, then evicted. */
5
5
  const JOB_TTL_MS = 600_000; // 10 min
6
6
  /** Hard cap on retained entries (oldest-settled evicted first). */
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The human filename behind a stored media key.
3
+ *
4
+ * Keys look like `gen/<tenant>/2026-07-29/ab12cd34-a-cat.png`; the random
5
+ * component is there to make the key unique, not to be read. Stripping it turns
6
+ * a saved file into `a-cat.png`, which matters because the reliable way to show
7
+ * a user an image in a chat client is for the agent to download it and attach
8
+ * it — and an attachment called `ab12cd34-a-cat.png` looks like a machine
9
+ * artefact.
10
+ *
11
+ * Lives on its own because it is pure string work that both the tools and the
12
+ * job registry need. It used to sit in `media-endpoint.ts` alongside the
13
+ * Worker's `/media` route handler; that route is the host's job now
14
+ * (mcp-host's blob store serves signed GETs), but the naming rule is ours.
15
+ */
16
+ export function downloadFilename(key) {
17
+ const last = key.split('/').pop() || 'media';
18
+ const stripped = last.replace(/^[0-9a-f]{6,12}-/i, '');
19
+ return stripped || last;
20
+ }
@@ -3,7 +3,7 @@ import { McpToolError, textResult } from '@chrischall/mcp-utils';
3
3
  import { withProgressHeartbeat } from './shared.js';
4
4
  import { base64ToBytes, wholeMb } from '../bytes.js';
5
5
  import { fileNameFromUrl } from '../fetch-image.js';
6
- import { downloadFilename } from '../media-endpoint.js';
6
+ import { downloadFilename } from '../media-name.js';
7
7
  import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
8
8
  /**
9
9
  * First-class Gemini Files API access.
@@ -4,7 +4,7 @@ import { resolveVideoPath, videoMimeType } from '../images.js';
4
4
  import { IMAGE_URL_MAX_BYTES, IMAGE_INLINE_MAX_BYTES } from '../fetch-image.js';
5
5
  import { bytesToBase64, base64ToBytes, wholeMb } from '../bytes.js';
6
6
  import { createDiskSink } from '../storage/media.js';
7
- import { downloadFilename } from '../media-endpoint.js';
7
+ import { downloadFilename } from '../media-name.js';
8
8
  import { buildZip } from '../zip.js';
9
9
  /**
10
10
  * Fallback sink for call sites that pass no explicit one (every pre-existing
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Single source of the server version. release-please bumps the literal below. */
2
- export const VERSION = '1.5.1'; // x-release-please-version
2
+ export const VERSION = '1.6.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "mcpName": "io.github.chrischall/gemini-mcp",
5
5
  "description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -39,32 +39,22 @@
39
39
  "build": "tsc && npm run bundle",
40
40
  "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
41
41
  "dev": "node dist/index.js",
42
- "test": "npm run typecheck && vitest run && npm run worker:test",
42
+ "test": "npm run typecheck && vitest run",
43
43
  "test:watch": "vitest",
44
44
  "test:coverage": "vitest run --coverage",
45
- "typecheck": "tsc -p tsconfig.test.json",
46
- "worker:dev": "wrangler dev",
47
- "worker:deploy": "wrangler deploy",
48
- "worker:test": "npm run worker:typecheck && vitest run -c vitest.workers.config.ts",
49
- "worker:typecheck": "tsc -p tsconfig.worker.json"
45
+ "typecheck": "tsc -p tsconfig.test.json"
50
46
  },
51
47
  "dependencies": {
52
- "@chrischall/mcp-utils": "^0.13.0",
48
+ "@chrischall/mcp-utils": "^0.14.0",
53
49
  "@modelcontextprotocol/sdk": "^1.29.0",
54
50
  "dotenv": "^17.4.0",
55
51
  "zod": "^4.4.2"
56
52
  },
57
53
  "devDependencies": {
58
- "@chrischall/mcp-connector": "^1.1.1",
59
- "@cloudflare/vitest-pool-workers": "^0.18.6",
60
- "@cloudflare/workers-oauth-provider": "^0.8.2",
61
- "@cloudflare/workers-types": "^5.20260719.1",
62
54
  "@types/node": "^26.0.0",
63
55
  "@vitest/coverage-v8": "^4.1.2",
64
- "agents": "^0.19.0",
65
56
  "esbuild": "^0.28.0",
66
57
  "typescript": "^7.0.2",
67
- "vitest": "^4.1.2",
68
- "wrangler": "^4.112.0"
58
+ "vitest": "^4.1.2"
69
59
  }
70
60
  }
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/gemini-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.5.1",
9
+ "version": "1.6.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.5.1",
14
+ "version": "1.6.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -126,11 +126,11 @@ not an error.
126
126
  | `gemini_list_files(page_size?)` | List current uploads with MIME types and expiry |
127
127
  | `gemini_delete_file(file_uri, confirm)` | Delete an upload before its ~48h expiry |
128
128
 
129
- On the **hosted connector** there is also `POST /upload`, behind the same OAuth token as
129
+ On the **hosted hosted deployment** there is also `POST /upload`, behind the same OAuth token as
130
130
  `/mcp` — the zero-base64 path for an agent with a shell:
131
131
 
132
132
  ```bash
133
- curl -X POST https://<connector>/upload \
133
+ curl -X POST https://<hosted deployment>/upload \
134
134
  -H "Authorization: Bearer $ACCESS_TOKEN" \
135
135
  -H "Content-Type: image/jpeg" \
136
136
  --data-binary @photo.jpg
@@ -179,9 +179,9 @@ gemini_image_edit(prompt: "make the background blue", images_url: ["https://exam
179
179
  → the server fetches the URL itself; returns path to edited PNG
180
180
  ```
181
181
 
182
- **Reuse one photo across many generations (hosted connector, agent with a shell):**
182
+ **Reuse one photo across many generations (hosted hosted deployment, agent with a shell):**
183
183
  ```
184
- $ curl -X POST https://<connector>/upload -H "Authorization: Bearer $TOKEN" \
184
+ $ curl -X POST https://<hosted deployment>/upload -H "Authorization: Bearer $TOKEN" \
185
185
  -H "Content-Type: image/jpeg" --data-binary @photo.jpg
186
186
  → {"file_uri": "files/abc123"}
187
187
 
@@ -1,34 +0,0 @@
1
- /**
2
- * Tell an MCP session which hostname it was reached on.
3
- *
4
- * This is the single load-bearing link in the zero-config media-URL path: the
5
- * origin captured here becomes `https://<host>/media/…` for every generated
6
- * image. If it goes missing, generations silently stop coming back with
7
- * openable links — the exact failure the media work exists to end — so it lives
8
- * in its own module, free of any Cloudflare import, purely so it can be tested.
9
- * (`src/worker.ts` pulls in `agents` and cannot load under Node at all.)
10
- *
11
- * Deriving the origin per request rather than configuring it is what makes a
12
- * fork, a `*.workers.dev` preview and the custom domain all mint correct links
13
- * with nothing to set. It rides on `ctx.props` — the object the OAuth provider
14
- * has already populated with the decrypted grant — rather than a module-level
15
- * variable, because one isolate serves many authenticated sessions (session.ts).
16
- */
17
- /** Property name the session reads back off its props. */
18
- export const CONNECTOR_ORIGIN_PROP = 'connectorOrigin';
19
- /**
20
- * Wrap a handler so `ctx.props.connectorOrigin` is set before it runs.
21
- *
22
- * Existing props are preserved — they carry the user's API key, and dropping
23
- * them would break authentication rather than merely lose a URL.
24
- */
25
- export function withConnectorOrigin(inner) {
26
- return {
27
- fetch(request, env, ctx) {
28
- const carrier = (ctx ?? {});
29
- const existing = (typeof carrier.props === 'object' && carrier.props !== null ? carrier.props : {});
30
- carrier.props = { ...existing, [CONNECTOR_ORIGIN_PROP]: new URL(request.url).origin };
31
- return inner.fetch(request, env, ctx);
32
- },
33
- };
34
- }
@@ -1,78 +0,0 @@
1
- /**
2
- * Retention for generated media.
3
- *
4
- * Every hosted generation writes an object to R2 and nothing ever removed it,
5
- * so the bucket grew without bound — and the signed links pointing into it are
6
- * only useful for a couple of days anyway. A scheduled sweep keeps the two in
7
- * step: objects live `MEDIA_TTL_DAYS` (default 7), and a signed URL's lifetime
8
- * is clamped to that, so a link never outlives the object behind it.
9
- *
10
- * R2 lifecycle rules would be the cheaper mechanism, but they are configured on
11
- * the bucket rather than in `wrangler.jsonc`, so a fresh deploy of this repo
12
- * would silently have no retention at all. A cron in the Worker ships with the
13
- * code.
14
- */
15
- const DEFAULT_MAX_OBJECTS = 5_000;
16
- /**
17
- * `gen/` is where media is written now; `media/` is where it was written before
18
- * the rename that made URLs read `/media/gen/…` instead of `/media/media/…`.
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.
23
- */
24
- const LEGACY_AND_CURRENT_PREFIXES = ['gen/', 'media/', 'up/'];
25
- /** R2 accepts bulk deletes in batches; 1000 is the documented ceiling. */
26
- const DELETE_BATCH = 1_000;
27
- /** Days → ms, defaulting to 7 for anything missing or nonsensical. */
28
- export function retentionMsFromDays(days) {
29
- const n = Number(days);
30
- return (Number.isFinite(n) && n > 0 ? n : 7) * 24 * 60 * 60 * 1000;
31
- }
32
- /**
33
- * Delete generated media older than `retentionMs`. Returns counts rather than
34
- * logging them, so the caller decides what to do with the numbers and the sweep
35
- * itself stays testable.
36
- */
37
- export async function cleanupExpiredMedia(bucket, retentionMs, opts = {}) {
38
- const now = opts.now?.() ?? Date.now();
39
- const cutoff = now - retentionMs;
40
- const maxObjects = opts.maxObjects ?? DEFAULT_MAX_OBJECTS;
41
- const prefixes = opts.prefixes ?? LEGACY_AND_CURRENT_PREFIXES;
42
- let scanned = 0;
43
- let deleted = 0;
44
- let truncated = false;
45
- const doomed = [];
46
- const flush = async (all) => {
47
- while (doomed.length >= (all ? 1 : DELETE_BATCH)) {
48
- const batch = doomed.splice(0, DELETE_BATCH);
49
- await bucket.delete(batch);
50
- deleted += batch.length;
51
- }
52
- };
53
- for (const prefix of prefixes) {
54
- let cursor;
55
- for (;;) {
56
- if (scanned >= maxObjects) {
57
- truncated = true;
58
- break;
59
- }
60
- const page = await bucket.list({ prefix, cursor, limit: Math.min(1000, maxObjects - scanned) });
61
- for (const object of page.objects) {
62
- scanned++;
63
- if (object.uploaded.getTime() < cutoff)
64
- doomed.push(object.key);
65
- }
66
- await flush(false);
67
- if (!page.truncated)
68
- break;
69
- if (scanned >= maxObjects) {
70
- truncated = true;
71
- break;
72
- }
73
- cursor = page.cursor;
74
- }
75
- }
76
- await flush(true);
77
- return { scanned, deleted, truncated };
78
- }
@@ -1,166 +0,0 @@
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
- }
79
- function problem(status, message) {
80
- return new Response(`${message}\n`, {
81
- status,
82
- headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
83
- });
84
- }
85
- /**
86
- * Build the `/media` handler. `basePath` is stripped from the pathname to get
87
- * the R2 key, so the key keeps its own prefix and the two never drift.
88
- */
89
- export function createMediaHandler(env, basePath = '/media/') {
90
- return async function handleMedia(request) {
91
- if (request.method !== 'GET' && request.method !== 'HEAD') {
92
- return problem(405, `Method ${request.method} not allowed; media links are GET-only.`);
93
- }
94
- const url = new URL(request.url);
95
- if (!url.pathname.startsWith(basePath))
96
- return problem(404, 'Not found');
97
- // decodeURIComponent per segment, mirroring how the URL was built. A key
98
- // cannot contain `..` (the sink sanitises every segment), and R2 has no
99
- // path traversal to exploit anyway — a key is an opaque string, not a path.
100
- const key = url.pathname
101
- .slice(basePath.length)
102
- .split('/')
103
- .map((segment) => {
104
- try {
105
- return decodeURIComponent(segment);
106
- }
107
- catch {
108
- return segment;
109
- }
110
- })
111
- .join('/');
112
- if (!key)
113
- return problem(404, 'Not found');
114
- const now = env.now?.() ?? Date.now();
115
- // Read-only: verification must never CREATE a signing secret. A stale KV
116
- // read here would otherwise clobber the secret every outstanding link was
117
- // signed with (see loadSigningKey). No secret ⇒ nothing was legitimately
118
- // signed ⇒ refuse, without touching storage or the bucket.
119
- const signingKey = await loadSigningKey(env.secret, env.store);
120
- const check = signingKey
121
- ? await verifyMediaSignature(signingKey, key, url.searchParams.get('exp'), url.searchParams.get('sig'), now)
122
- : { ok: false, reason: 'invalid' };
123
- if (!check.ok) {
124
- if (check.reason === 'expired') {
125
- return problem(410, 'This media link has expired. Generated media is retained for a limited time — re-run the generation to get a fresh link.');
126
- }
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.');
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.
143
- const object = await env.bucket.get(key);
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
- });
153
- }
154
- const headers = baseHeaders(key, object, totalSize);
155
- if (request.method === 'HEAD')
156
- return new Response(null, { 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 });
165
- };
166
- }