@chrischall/gemini-mcp 1.4.0 → 1.5.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 +64 -1
- package/dist/bundle.js +334 -30
- package/dist/client.js +24 -3
- package/dist/inputs.js +37 -1
- package/dist/jobs.js +46 -2
- package/dist/library.js +151 -0
- package/dist/media-cleanup.js +4 -1
- package/dist/media-endpoint.js +6 -0
- package/dist/media-url.js +2 -2
- package/dist/put-endpoint.js +122 -0
- package/dist/storage/media.js +23 -12
- package/dist/tools/generate.js +34 -10
- package/dist/tools/interact.js +3 -2
- package/dist/tools/library.js +186 -0
- package/dist/tools/music.js +3 -2
- package/dist/tools/set.js +37 -9
- package/dist/tools/shared.js +195 -2
- package/dist/tools/uploads.js +69 -0
- package/dist/tools/video.js +3 -2
- package/dist/upload-url.js +138 -0
- package/dist/version.js +1 -1
- package/dist/zip.js +113 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/client.js
CHANGED
|
@@ -153,13 +153,30 @@ 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;
|
|
158
164
|
// A replayed idempotent result must not hand back an expired media URL, so
|
|
159
165
|
// the job registry needs a way to re-sign from the stable r2_key.
|
|
160
166
|
this.session.jobs.resigner = this.mediaSink;
|
|
161
167
|
this.explicitApiKey = opts.apiKey?.trim() || undefined;
|
|
162
|
-
|
|
168
|
+
// Never store the global `fetch` raw on the instance: it would be invoked
|
|
169
|
+
// as `this.fetchImpl(...)`, handing native fetch this GeminiClient as its
|
|
170
|
+
// receiver — workerd rejects any non-global receiver with "Illegal
|
|
171
|
+
// invocation: function called with incorrect 'this' reference", while
|
|
172
|
+
// Node's fetch does not care, so only production could see the crash
|
|
173
|
+
// (it did: every hosted generation whose reference image took the >6MB
|
|
174
|
+
// Files-API promotion). The arrow pins the receiver to the global scope.
|
|
175
|
+
// An INJECTED impl is stored as-is, so tests can emulate workerd's
|
|
176
|
+
// receiver check and catch any `this.fetchImpl(...)` call site — which is
|
|
177
|
+
// also why call sites must alias it to a local before calling (see
|
|
178
|
+
// uploadToFilesApi). Guarded by tests/tools/hosted-reference-forms.test.ts.
|
|
179
|
+
this.fetchImpl = opts.fetchImpl ?? ((...args) => fetch(...args));
|
|
163
180
|
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
164
181
|
}
|
|
165
182
|
/**
|
|
@@ -337,9 +354,13 @@ export class GeminiClient {
|
|
|
337
354
|
*/
|
|
338
355
|
async uploadToFilesApi(body, mimeType, displayName, contentLength) {
|
|
339
356
|
const key = this.requireKey();
|
|
357
|
+
// Local alias so the calls below carry NO receiver: `this.fetchImpl(...)`
|
|
358
|
+
// would hand native fetch this GeminiClient as `this`, which workerd
|
|
359
|
+
// rejects with an Illegal invocation (see the constructor note).
|
|
360
|
+
const doFetch = this.fetchImpl;
|
|
340
361
|
// 1. start — establish the resumable upload session
|
|
341
362
|
const startPath = '/files';
|
|
342
|
-
const startRes = await
|
|
363
|
+
const startRes = await doFetch(`${UPLOAD_BASE_URL}${startPath}`, {
|
|
343
364
|
method: 'POST',
|
|
344
365
|
headers: {
|
|
345
366
|
'x-goog-api-key': key,
|
|
@@ -369,7 +390,7 @@ export class GeminiClient {
|
|
|
369
390
|
// falls back to chunked encoding, which the resumable protocol does not
|
|
370
391
|
// accept (this half is docs-derived; see docs/GEMINI-API.md).
|
|
371
392
|
const streaming = !(body instanceof Blob);
|
|
372
|
-
const upRes = await
|
|
393
|
+
const upRes = await doFetch(uploadUrl, {
|
|
373
394
|
method: 'POST',
|
|
374
395
|
headers: {
|
|
375
396
|
'X-Goog-Upload-Offset': '0',
|
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), `
|
|
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
|
@@ -86,6 +86,25 @@ async function refreshMedia(result, sink) {
|
|
|
86
86
|
body.media = entries;
|
|
87
87
|
if (mediaKeyName)
|
|
88
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
|
+
}
|
|
89
108
|
body.media_urls_refreshed = true;
|
|
90
109
|
return { ...result, content: [{ ...first, text: JSON.stringify(body, null, 2) }, ...(result.content ?? []).slice(1)] };
|
|
91
110
|
}
|
|
@@ -119,6 +138,19 @@ function jobHandle(jobId, status) {
|
|
|
119
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).`,
|
|
120
139
|
});
|
|
121
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
|
+
}
|
|
122
154
|
/**
|
|
123
155
|
* One session's jobs. Construct one per session and never share it — see the
|
|
124
156
|
* cross-tenant note at the top of this file. Reachable as
|
|
@@ -181,7 +213,7 @@ export class JobRegistry {
|
|
|
181
213
|
* different registry and therefore a genuinely separate generation.
|
|
182
214
|
*/
|
|
183
215
|
async dispatch(opts, work) {
|
|
184
|
-
const { toolName, fingerprint, idempotencyKey, async } = opts;
|
|
216
|
+
const { toolName, fingerprint, idempotencyKey, async, waitMs } = opts;
|
|
185
217
|
const now = Date.now();
|
|
186
218
|
let hit;
|
|
187
219
|
if (idempotencyKey !== undefined) {
|
|
@@ -199,10 +231,15 @@ export class JobRegistry {
|
|
|
199
231
|
if (hit) {
|
|
200
232
|
if (async)
|
|
201
233
|
return jobHandle(hit.jobId, hit.status);
|
|
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');
|
|
202
239
|
// Refresh before annotating: a recorded result can outlive the signed
|
|
203
240
|
// URLs inside it, and replaying an expired link is its own kind of wrong
|
|
204
241
|
// answer — it looks like success.
|
|
205
|
-
return annotateReused(await refreshMedia(
|
|
242
|
+
return annotateReused(await refreshMedia(settled, this.resigner), hit.jobId);
|
|
206
243
|
}
|
|
207
244
|
const jobId = randomUUID();
|
|
208
245
|
this.evict(now);
|
|
@@ -222,6 +259,13 @@ export class JobRegistry {
|
|
|
222
259
|
});
|
|
223
260
|
if (async)
|
|
224
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
|
+
}
|
|
225
269
|
return promise;
|
|
226
270
|
}
|
|
227
271
|
/**
|
package/dist/library.js
ADDED
|
@@ -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
|
+
}
|
package/dist/media-cleanup.js
CHANGED
|
@@ -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. */
|
package/dist/media-endpoint.js
CHANGED
|
@@ -59,6 +59,12 @@ function baseHeaders(key, object, size) {
|
|
|
59
59
|
// The bytes are model output being handed to a browser — never let a
|
|
60
60
|
// sniffed content type turn an image response into something executable.
|
|
61
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",
|
|
62
68
|
// Range support is cheap on R2 and is what makes a resumable `curl -C -`
|
|
63
69
|
// work against a large video.
|
|
64
70
|
'accept-ranges': 'bytes',
|
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);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { loadSigningKey } from './media-url.js';
|
|
2
|
+
import { verifyUploadSignature, UPLOAD_MAX_BYTES, UPLOAD_KEY_PREFIX, acceptableUploadType } from './upload-url.js';
|
|
3
|
+
import { wholeMb } from './bytes.js';
|
|
4
|
+
function json(body, status) {
|
|
5
|
+
return new Response(JSON.stringify(body, null, 2), {
|
|
6
|
+
status,
|
|
7
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
/** Every error names the fix — the caller here is a shell, not a model. */
|
|
11
|
+
function fail(status, error, hint) {
|
|
12
|
+
return json({ error, hint }, status);
|
|
13
|
+
}
|
|
14
|
+
/** A key this endpoint is willing to even verify a signature for: the upload
|
|
15
|
+
* prefix, safe segment charset, no dot-segments, no empty segments. */
|
|
16
|
+
export function wellFormedUploadKey(key) {
|
|
17
|
+
if (!key.startsWith(`${UPLOAD_KEY_PREFIX}/`))
|
|
18
|
+
return false;
|
|
19
|
+
const segments = key.split('/');
|
|
20
|
+
if (segments.length < 2)
|
|
21
|
+
return false;
|
|
22
|
+
return segments.every((s) => s.length > 0 && s !== '.' && s !== '..' && /^[A-Za-z0-9._-]+$/.test(s));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build the `/put` handler. `basePath` is stripped from the pathname to get the
|
|
26
|
+
* R2 key, mirroring the `/media` handler so the two never drift.
|
|
27
|
+
*/
|
|
28
|
+
export function createSignedPutHandler(env, basePath = '/put/') {
|
|
29
|
+
const maxBytes = env.maxBytes ?? UPLOAD_MAX_BYTES;
|
|
30
|
+
return async function handleSignedPut(request) {
|
|
31
|
+
if (request.method === 'OPTIONS') {
|
|
32
|
+
return new Response(null, { status: 204, headers: { allow: 'PUT, OPTIONS' } });
|
|
33
|
+
}
|
|
34
|
+
if (request.method !== 'PUT') {
|
|
35
|
+
return fail(405, `Method ${request.method} not allowed; signed upload URLs are PUT-only.`, 'Send the file bytes with: curl -sS -X PUT -H "Content-Type: <the signed type>" --data-binary @photo.jpg "<upload_url>"');
|
|
36
|
+
}
|
|
37
|
+
const url = new URL(request.url);
|
|
38
|
+
if (!url.pathname.startsWith(basePath))
|
|
39
|
+
return fail(404, 'Not found.', 'Use the exact upload_url returned by gemini_get_upload_url.');
|
|
40
|
+
const key = url.pathname
|
|
41
|
+
.slice(basePath.length)
|
|
42
|
+
.split('/')
|
|
43
|
+
.map((segment) => {
|
|
44
|
+
try {
|
|
45
|
+
return decodeURIComponent(segment);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return segment;
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
.join('/');
|
|
52
|
+
// Charset-checked BEFORE signature verification: the cross-protocol safety
|
|
53
|
+
// argument in upload-url.ts rests on keys never containing control chars.
|
|
54
|
+
if (!wellFormedUploadKey(key)) {
|
|
55
|
+
return fail(404, 'Not a valid upload key.', 'Use the exact upload_url returned by gemini_get_upload_url — the key path cannot be altered.');
|
|
56
|
+
}
|
|
57
|
+
const ct = url.searchParams.get('ct');
|
|
58
|
+
if (ct && !acceptableUploadType(ct)) {
|
|
59
|
+
return fail(415, `Unsupported content type "${ct}" — signed uploads accept raster image types only ` +
|
|
60
|
+
'(jpeg/png/webp/gif/avif/heic/heif/bmp/tiff; not SVG, which can carry scripts).', 'Mint the URL with a raster image content_type (e.g. image/jpeg).');
|
|
61
|
+
}
|
|
62
|
+
const now = env.now?.() ?? Date.now();
|
|
63
|
+
// Read-only key load: verification must never CREATE a signing secret (see
|
|
64
|
+
// loadSigningKey — a stale KV read would clobber the secret every
|
|
65
|
+
// outstanding media link was signed with).
|
|
66
|
+
const signingKey = await loadSigningKey(env.secret, env.store);
|
|
67
|
+
const check = signingKey
|
|
68
|
+
? await verifyUploadSignature(signingKey, key, ct, url.searchParams.get('exp'), url.searchParams.get('sig'), now)
|
|
69
|
+
: { ok: false, reason: 'invalid' };
|
|
70
|
+
if (!check.ok) {
|
|
71
|
+
if (check.reason === 'expired') {
|
|
72
|
+
return fail(410, 'This upload URL has expired (they are valid for ~10 minutes).', 'Mint a fresh one with gemini_get_upload_url and retry.');
|
|
73
|
+
}
|
|
74
|
+
return fail(403, 'Invalid or missing upload signature.', 'Use the upload_url exactly as returned by gemini_get_upload_url, including the ct, exp and sig query parameters.');
|
|
75
|
+
}
|
|
76
|
+
const sentType = (request.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
77
|
+
if (sentType !== ct) {
|
|
78
|
+
return fail(415, `Content-Type "${sentType || '(none)'}" does not match the type this URL was minted for ("${ct}").`, `Send -H "Content-Type: ${ct}", or mint a new URL with the right content_type.`);
|
|
79
|
+
}
|
|
80
|
+
const declared = Number(request.headers.get('content-length'));
|
|
81
|
+
if (!request.headers.get('content-length') || !Number.isFinite(declared) || declared <= 0) {
|
|
82
|
+
return fail(411, 'Content-Length is required (and must be > 0).', 'curl sets it automatically with --data-binary @file.');
|
|
83
|
+
}
|
|
84
|
+
if (declared > maxBytes) {
|
|
85
|
+
return fail(413, `Upload is ${wholeMb(declared)}MB; the cap is ${wholeMb(maxBytes)}MB.`, 'Downscale the image before uploading — a reference photo does not need to be full-resolution.');
|
|
86
|
+
}
|
|
87
|
+
if (!request.body) {
|
|
88
|
+
return fail(400, 'Request had no body.', 'Send the file bytes as the request body, e.g. --data-binary @photo.jpg.');
|
|
89
|
+
}
|
|
90
|
+
// Enforce the cap on bytes actually READ, not the declared length — a
|
|
91
|
+
// hostile client can under-declare and stream more.
|
|
92
|
+
const chunks = [];
|
|
93
|
+
let total = 0;
|
|
94
|
+
const reader = request.body.getReader();
|
|
95
|
+
for (;;) {
|
|
96
|
+
const { done, value } = await reader.read();
|
|
97
|
+
if (done)
|
|
98
|
+
break;
|
|
99
|
+
total += value.byteLength;
|
|
100
|
+
if (total > maxBytes) {
|
|
101
|
+
await reader.cancel().catch(() => { });
|
|
102
|
+
return fail(413, `Upload exceeded the ${wholeMb(maxBytes)}MB cap while being read.`, 'Downscale the image before uploading.');
|
|
103
|
+
}
|
|
104
|
+
chunks.push(value);
|
|
105
|
+
}
|
|
106
|
+
const body = new Uint8Array(total);
|
|
107
|
+
let offset = 0;
|
|
108
|
+
for (const chunk of chunks) {
|
|
109
|
+
body.set(chunk, offset);
|
|
110
|
+
offset += chunk.byteLength;
|
|
111
|
+
}
|
|
112
|
+
await env.bucket.put(key, body, { httpMetadata: { contentType: ct ?? 'application/octet-stream' } });
|
|
113
|
+
return json({
|
|
114
|
+
r2_key: key,
|
|
115
|
+
size_bytes: total,
|
|
116
|
+
content_type: ct,
|
|
117
|
+
hint: `Uploaded. Reference it by r2_key: pass it in images_r2_keys on a generation tool (master_images_r2_keys on gemini_image_set), ` +
|
|
118
|
+
`save it permanently with gemini_save_character, or convert it to a ~48h Gemini Files API reference with gemini_upload_file({ r2_key }). ` +
|
|
119
|
+
`Uploads follow the connector's media retention schedule (MEDIA_TTL_DAYS, default 7 days); saved characters are kept indefinitely.`,
|
|
120
|
+
}, 201);
|
|
121
|
+
};
|
|
122
|
+
}
|
package/dist/storage/media.js
CHANGED
|
@@ -45,7 +45,7 @@ export async function tenantIdFor(secret) {
|
|
|
45
45
|
// prefix would make every URL read `/media/media/…`.
|
|
46
46
|
const KEY_PREFIX_DEFAULT = 'gen';
|
|
47
47
|
/** Object-key-safe name: no slashes, no dot-segments, never empty. */
|
|
48
|
-
function safeKeySegment(base) {
|
|
48
|
+
export function safeKeySegment(base) {
|
|
49
49
|
const cleaned = base
|
|
50
50
|
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
51
51
|
.replace(/\.{2,}/g, '-')
|
|
@@ -67,18 +67,27 @@ export function createR2Sink(bucket, opts) {
|
|
|
67
67
|
const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
|
|
68
68
|
const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
|
|
69
69
|
const ttlMs = opts.urlTtlMs ?? 0;
|
|
70
|
-
|
|
71
|
-
let
|
|
72
|
-
const
|
|
73
|
-
|
|
70
|
+
const readPrefixes = [prefix, ...(opts.readPrefixes ?? [])];
|
|
71
|
+
let tenantCache;
|
|
72
|
+
const tenantId = () => (tenantCache ??= Promise.resolve(typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant));
|
|
73
|
+
/** `<prefix>/` or `<prefix>/<tenant>/` — where every WRITE goes. */
|
|
74
|
+
const ownPrefix = async () => {
|
|
75
|
+
const tenant = await tenantId();
|
|
74
76
|
return tenant ? `${prefix}/${tenant}/` : `${prefix}/`;
|
|
75
|
-
}
|
|
77
|
+
};
|
|
76
78
|
/**
|
|
77
|
-
* Ownership gate for caller-supplied keys
|
|
78
|
-
*
|
|
79
|
-
*
|
|
79
|
+
* Ownership gate for caller-supplied keys: this session's own namespace
|
|
80
|
+
* under any readable prefix (`gen/<tenant>/…`, plus e.g. `up/`/`lib/` on the
|
|
81
|
+
* Worker). A foreign key is refused without touching storage, so the refusal
|
|
82
|
+
* is indistinguishable from a swept object. With no tenant configured there
|
|
83
|
+
* is nothing to enforce (single-user shapes).
|
|
80
84
|
*/
|
|
81
|
-
const owned = async (key) =>
|
|
85
|
+
const owned = async (key) => {
|
|
86
|
+
if (!opts.tenant)
|
|
87
|
+
return true;
|
|
88
|
+
const tenant = await tenantId();
|
|
89
|
+
return readPrefixes.some((p) => key.startsWith(`${p}/${tenant}/`));
|
|
90
|
+
};
|
|
82
91
|
return {
|
|
83
92
|
kind: 'r2',
|
|
84
93
|
persistsFiles: false,
|
|
@@ -102,14 +111,16 @@ export function createR2Sink(bucket, opts) {
|
|
|
102
111
|
return undefined;
|
|
103
112
|
return describe(key, now().getTime() + ttlMs);
|
|
104
113
|
},
|
|
105
|
-
async persist(items,
|
|
114
|
+
async persist(items, persistOpts) {
|
|
106
115
|
// `mediaExt` is pure string work but lives in images.ts next to node:fs
|
|
107
116
|
// imports; the dynamic import keeps that module off a Worker's eager
|
|
108
117
|
// module graph.
|
|
109
118
|
const { mediaExt } = await import('../images.js');
|
|
110
119
|
const keyBase = await ownPrefix();
|
|
111
120
|
const day = now().toISOString().slice(0, 10);
|
|
112
|
-
|
|
121
|
+
// Per-persist TTL override, clamped so a link never outlives its object.
|
|
122
|
+
const effectiveTtlMs = Math.min(persistOpts.urlTtlMs ?? ttlMs, opts.maxUrlTtlMs ?? Infinity);
|
|
123
|
+
const expiresAtMs = now().getTime() + effectiveTtlMs;
|
|
113
124
|
const refs = [];
|
|
114
125
|
for (const it of items) {
|
|
115
126
|
const key = `${keyBase}${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
|