@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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +76 -3
- package/dist/bundle.js +523 -51
- package/dist/client.js +24 -0
- package/dist/inputs.js +37 -1
- package/dist/jobs.js +122 -2
- package/dist/library.js +151 -0
- package/dist/media-cleanup.js +4 -1
- package/dist/media-endpoint.js +109 -15
- package/dist/media-url.js +2 -2
- package/dist/put-endpoint.js +122 -0
- package/dist/storage/media.js +53 -4
- package/dist/tools/files.js +61 -3
- package/dist/tools/generate.js +75 -30
- 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 +76 -12
- package/dist/tools/shared.js +202 -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,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), `
|
|
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
|
-
|
|
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
|
/**
|
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
|
@@ -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,
|
|
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 =
|
|
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
|
-
|
|
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);
|