@chrischall/gemini-mcp 1.5.1 → 1.6.1
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +28 -54
- package/dist/blob-store.js +176 -0
- package/dist/bundle.js +1177 -491
- package/dist/client.js +74 -2
- package/dist/index.js +20 -4
- package/dist/jobs.js +1 -1
- package/dist/media-name.js +20 -0
- package/dist/tools/files.js +1 -1
- package/dist/tools/shared.js +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -15
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +4 -4
- package/dist/connector-origin.js +0 -34
- package/dist/media-cleanup.js +0 -78
- package/dist/media-endpoint.js +0 -166
- package/dist/put-endpoint.js +0 -122
- package/dist/upload-endpoint.js +0 -79
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,74 @@ 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: BLOB_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
|
+
/**
|
|
799
|
+
* How long a freshly-minted media link lives.
|
|
800
|
+
*
|
|
801
|
+
* Named for the blob store, NOT `MEDIA_URL_TTL_MS` — `media-url.ts` already
|
|
802
|
+
* exports a constant by that name with a different value (48h, the retired
|
|
803
|
+
* connector's window), and having two of them in one repo is how a reader ends
|
|
804
|
+
* up reasoning about the wrong number.
|
|
805
|
+
*
|
|
806
|
+
* Set to the ceiling rather than something shorter, because the ceiling is not
|
|
807
|
+
* ours to raise: the store refuses any signature over 24h, so this is simply
|
|
808
|
+
* the longest a link can legally live. The re-host therefore shortens links
|
|
809
|
+
* — 48h defaults and the 7-day `gemini_image_set` window are both impossible
|
|
810
|
+
* now — and `gemini_sign_media` is the answer to an expired one, which is what
|
|
811
|
+
* it was built for.
|
|
812
|
+
*/
|
|
813
|
+
const BLOB_URL_TTL_MS = 24 * 60 * 60 * 1000;
|
|
814
|
+
/**
|
|
815
|
+
* The blob store's own limit on how far ahead a signature may expire
|
|
816
|
+
* (mcp-host docs/BLOB-STORE.md). Keep in step with it: signing past this is
|
|
817
|
+
* refused at the door, so a longer link is not a longer link — it is a broken
|
|
818
|
+
* one. Clamps any PER-PERSIST override, notably `gemini_image_set`'s 7 days.
|
|
819
|
+
*/
|
|
820
|
+
const BLOB_MAX_TTL_MS = 24 * 60 * 60 * 1000;
|
|
749
821
|
/** Module-level singleton shared by every tool module (deferred-config-error). */
|
|
750
|
-
export const client = new GeminiClient();
|
|
822
|
+
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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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: [
|
|
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-
|
|
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
|
+
}
|
package/dist/tools/files.js
CHANGED
|
@@ -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-
|
|
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.
|
package/dist/tools/shared.js
CHANGED
|
@@ -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-
|
|
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.
|
|
2
|
+
export const VERSION = '1.6.1'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/gemini-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
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
|
|
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.
|
|
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.
|
|
9
|
+
"version": "1.6.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.6.1",
|
|
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
|
|
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://<
|
|
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
|
|
182
|
+
**Reuse one photo across many generations (hosted hosted deployment, agent with a shell):**
|
|
183
183
|
```
|
|
184
|
-
$ curl -X POST https://<
|
|
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
|
|
package/dist/connector-origin.js
DELETED
|
@@ -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
|
-
}
|
package/dist/media-cleanup.js
DELETED
|
@@ -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
|
-
}
|
package/dist/media-endpoint.js
DELETED
|
@@ -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
|
-
}
|