dsh-cindy-host 0.1.20
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/CHANGELOG.md +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- package/src/session-read-model.js +6 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve one file from this machine to a controller: `device-link:media:fetch`.
|
|
3
|
+
*
|
|
4
|
+
* The missing half of attachments. A photo the *phone* sends arrives as a transit
|
|
5
|
+
* reference this Host fetches; an image the **agent** produced goes the other way — it is
|
|
6
|
+
* a file on this machine, and the controller cannot read this machine's disk. The
|
|
7
|
+
* reference implementation is the desktop being controlled
|
|
8
|
+
* (`apps/desktop/src/main/device-link/mediaFetch.ts`: 解析本机媒体 → 上传 OSS → 返回引用):
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* request { url: 'xdt-file://open?path=<abs>&workdir=<abs>&maxBytes=<n>', thumbnail?, skipCache?, prepareOnly? }
|
|
12
|
+
* response { ossKey, mimeType, size } // the controller presign-gets the key
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Two things are deliberately **stricter** than the reference, because this Host is a
|
|
16
|
+
* process on somebody's desktop rather than an app with its own cache directory:
|
|
17
|
+
*
|
|
18
|
+
* 1. **A containment root is mandatory.** The desktop treats a missing `baseDir` as "no
|
|
19
|
+
* constraint" for old controllers; here a request that names no root (neither `baseDir`
|
|
20
|
+
* nor the `workdir` a chat image carries) is refused. Without that, any absolute path
|
|
21
|
+
* on this machine would be servable by anyone who can reach the Host.
|
|
22
|
+
* 2. **Realpath on both sides.** A symlink inside the workspace must not become a way out
|
|
23
|
+
* of it, and `/tmp` vs `/private/tmp` must not make a legitimate file look outside.
|
|
24
|
+
*
|
|
25
|
+
* Malformed constraint parameters are refused rather than ignored: silently degrading to
|
|
26
|
+
* "unconstrained" is how a constraint gets removed by a typo.
|
|
27
|
+
*
|
|
28
|
+
* `thumbnail: true` is honoured when this Host can render one. The reference controlled
|
|
29
|
+
* end downscales with `sharp` and returns the bytes **inline** (`inlineBase64`, with an
|
|
30
|
+
* empty `ossKey`) so the controller skips the whole upload → presign → download round
|
|
31
|
+
* trip; the limits here are copied from it (`mediaFetch.ts`: png/jpeg/webp, input below
|
|
32
|
+
* 48 MiB, 1024 px longest edge, webp q80, 5 s soft timeout, and only results up to
|
|
33
|
+
* 700 KiB are inlined). Rendering is best-effort by design: an unavailable codec, an
|
|
34
|
+
* unrenderable image, or an oversized result all fall back to the plain `ossKey` answer,
|
|
35
|
+
* which the contract has always required the controller to accept.
|
|
36
|
+
*
|
|
37
|
+
* @module dsh-cindy-host/host-media-fetch
|
|
38
|
+
*/
|
|
39
|
+
import { createRequire } from 'node:module';
|
|
40
|
+
import { extname, isAbsolute, relative, resolve as resolvePath } from 'node:path';
|
|
41
|
+
import { constants as fsConstants } from 'node:fs';
|
|
42
|
+
import { open as openImpl, readFile as readFileImpl, realpath as realpathImpl, stat as statImpl } from 'node:fs/promises';
|
|
43
|
+
import { FILE_INLINE_MAX_BYTES, FILE_PEER_MAX_BYTES } from './file-peer-protocol.js';
|
|
44
|
+
|
|
45
|
+
/** Longest file this Host will stage for a controller. */
|
|
46
|
+
export const MEDIA_FETCH_MAX_BYTES = 25 * 1024 * 1024;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Thumbnail limits, mirrored from the reference controlled end.
|
|
50
|
+
*
|
|
51
|
+
* `1024` because the phone's chat bubble is at most ~1080 px wide at 3x; q80 webp
|
|
52
|
+
* because that is what the reference sends; `700 KiB` because above it the inline form
|
|
53
|
+
* would eat the device-link frame budget the controller's 30 s deadline rides on.
|
|
54
|
+
*/
|
|
55
|
+
export const THUMBNAIL_MAX_EDGE = 1024;
|
|
56
|
+
export const THUMBNAIL_WEBP_QUALITY = 80;
|
|
57
|
+
export const THUMBNAIL_INLINE_MAX_BYTES = 700 * 1024;
|
|
58
|
+
export const THUMBNAIL_INPUT_MAX_BYTES = 48 * 1024 * 1024;
|
|
59
|
+
export const THUMBNAIL_RENDER_TIMEOUT_MS = 5_000;
|
|
60
|
+
/** What a rendered thumbnail is served as. */
|
|
61
|
+
export const THUMBNAIL_MIME = 'image/webp';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Longest *original* image this Host will inline when it cannot downscale one.
|
|
65
|
+
*
|
|
66
|
+
* The controller asks for a thumbnail to avoid a whole upload → presign → download round
|
|
67
|
+
* trip; when this Host has no codec for the picture (or the picture is one it must not
|
|
68
|
+
* re-encode), sending small originals inline still removes that round trip. 512 KiB of
|
|
69
|
+
* bytes becomes ≈683 KiB of base64, which is inside the relay's 2 MiB frame ceiling with
|
|
70
|
+
* room to spare and comparable to the reference's 700 KiB inline allowance.
|
|
71
|
+
*/
|
|
72
|
+
export const INLINE_ORIGINAL_MAX_BYTES = 512 * 1024;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* How long a staged object is reused, and how many are remembered.
|
|
76
|
+
*
|
|
77
|
+
* Chat media is resolved from the same path over and over (every time a bubble is rendered
|
|
78
|
+
* after the phone's cache evicts it), and staging is the expensive half. The key carries the
|
|
79
|
+
* file's size and mtime, so a rewritten file is a different entry; `skipCache` bypasses the
|
|
80
|
+
* lookup for the case where the *object* is gone rather than the file changed.
|
|
81
|
+
*/
|
|
82
|
+
export const STAGING_CACHE_TTL_MS = 30 * 60_000;
|
|
83
|
+
export const STAGING_CACHE_MAX = 512;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Images worth downscaling.
|
|
87
|
+
*
|
|
88
|
+
* A GIF thumbnail would be a still frame (a semantic loss) and an SVG is already tiny,
|
|
89
|
+
* so neither is rendered — the reference draws the same line.
|
|
90
|
+
*/
|
|
91
|
+
const THUMBNAILABLE_MIMES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
|
92
|
+
|
|
93
|
+
/** Whether an image of this content type can be downscaled. */
|
|
94
|
+
export function isThumbnailableMime(mimeType) {
|
|
95
|
+
return THUMBNAILABLE_MIMES.has(String(mimeType));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let sharpModule;
|
|
99
|
+
let sharpResolved = false;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve an image codec from the first anchor that has one.
|
|
103
|
+
*
|
|
104
|
+
* Exported with an explicit anchor list so the resolution can be exercised without
|
|
105
|
+
* running inside the harness (the default anchors are what a live Host uses).
|
|
106
|
+
* @param anchors - module specifiers or file URLs to resolve `sharp` from.
|
|
107
|
+
* @returns the codec's callable export, or null when none of them has it.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveThumbnailCodec(anchors = defaultThumbnailAnchors()) {
|
|
110
|
+
for (const anchor of anchors) {
|
|
111
|
+
try {
|
|
112
|
+
const loaded = createRequire(anchor)('sharp');
|
|
113
|
+
const factory = typeof loaded === 'function' ? loaded : loaded?.default;
|
|
114
|
+
if (typeof factory === 'function') return factory;
|
|
115
|
+
} catch {
|
|
116
|
+
// No codec on this anchor: the next one, or no thumbnails at all.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Where to look for the harness's image codec.
|
|
124
|
+
*
|
|
125
|
+
* `sharp` is not a dependency of this plugin — it is a dependency of the harness
|
|
126
|
+
* (`@deepseek-ai/dsh-attachment-local`), which is also what normalizes every image a
|
|
127
|
+
* session stores. Loading the copy the harness already installed is what keeps this Host
|
|
128
|
+
* free of a second native image pipeline.
|
|
129
|
+
*
|
|
130
|
+
* The first anchor is what makes it findable: resolution starts at `process.argv[1]`, the
|
|
131
|
+
* harness entry point when this Host runs inside `dsh web`, and walks up through the
|
|
132
|
+
* installation that actually contains `sharp`. Resolving from this module's own path would
|
|
133
|
+
* look inside *this plugin's* tree, where the codec legitimately does not exist.
|
|
134
|
+
* @returns anchors, best first.
|
|
135
|
+
*/
|
|
136
|
+
function defaultThumbnailAnchors() {
|
|
137
|
+
const anchors = [];
|
|
138
|
+
if (typeof process?.argv?.[1] === 'string' && process.argv[1] !== '') anchors.push(process.argv[1]);
|
|
139
|
+
anchors.push(import.meta.url);
|
|
140
|
+
return anchors;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The memoized codec this process will use, or null when this installation has none. */
|
|
144
|
+
function loadSharp() {
|
|
145
|
+
if (sharpResolved) return sharpModule;
|
|
146
|
+
sharpResolved = true;
|
|
147
|
+
sharpModule = resolveThumbnailCodec();
|
|
148
|
+
return sharpModule;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Forget the cached codec resolution (tests re-probe with a different environment). */
|
|
152
|
+
export function resetThumbnailCodec() {
|
|
153
|
+
sharpModule = undefined;
|
|
154
|
+
sharpResolved = false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Run one render under a soft deadline, so a pathological image cannot hold the invoke. */
|
|
158
|
+
async function withRenderTimeout(work, ms) {
|
|
159
|
+
let timer;
|
|
160
|
+
try {
|
|
161
|
+
return await Promise.race([
|
|
162
|
+
work,
|
|
163
|
+
new Promise((_, reject) => {
|
|
164
|
+
timer = setTimeout(() => reject(new Error('thumbnail render timeout')), ms);
|
|
165
|
+
if (typeof timer?.unref === 'function') timer.unref();
|
|
166
|
+
}),
|
|
167
|
+
]);
|
|
168
|
+
} finally {
|
|
169
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The default renderer: EXIF-rotate → fit inside → webp.
|
|
175
|
+
*
|
|
176
|
+
* @param absPath - the resolved path of the image.
|
|
177
|
+
* @param codec - the codec to render with; defaults to this process's resolved one.
|
|
178
|
+
* @returns the thumbnail bytes, or null when no codec is available or rendering failed.
|
|
179
|
+
*/
|
|
180
|
+
export async function renderThumbnailWithSharp(absPath, codec = loadSharp()) {
|
|
181
|
+
if (typeof codec !== 'function') return null;
|
|
182
|
+
try {
|
|
183
|
+
const buffer = await codec(absPath)
|
|
184
|
+
.rotate()
|
|
185
|
+
.resize({ width: THUMBNAIL_MAX_EDGE, height: THUMBNAIL_MAX_EDGE, fit: 'inside', withoutEnlargement: true })
|
|
186
|
+
.webp({ quality: THUMBNAIL_WEBP_QUALITY })
|
|
187
|
+
.toBuffer();
|
|
188
|
+
return Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Content types by extension.
|
|
196
|
+
*
|
|
197
|
+
* The controller decides how to render from this, so an unknown extension is served as
|
|
198
|
+
* `application/octet-stream` rather than guessed at.
|
|
199
|
+
*/
|
|
200
|
+
const MIME_BY_EXT = Object.freeze({
|
|
201
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
202
|
+
'.webp': 'image/webp', '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.svg': 'image/svg+xml',
|
|
203
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska',
|
|
204
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4',
|
|
205
|
+
'.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown', '.json': 'application/json',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/** The content type one path is served with. */
|
|
209
|
+
export function mimeForMediaPath(path) {
|
|
210
|
+
return MIME_BY_EXT[extname(String(path)).toLowerCase()] ?? 'application/octet-stream';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Names this Host refuses to serve to *any* controller, whatever root it is inside.
|
|
215
|
+
*
|
|
216
|
+
* A workspace is chosen by the user and can be as broad as their home directory, so
|
|
217
|
+
* containment alone is not a secret boundary. These are the credential-shaped paths a
|
|
218
|
+
* remote "show me this file" request must never be able to read.
|
|
219
|
+
*/
|
|
220
|
+
const DEFAULT_BLOCKED = Object.freeze([
|
|
221
|
+
'.ssh', '.aws', '.gnupg', '.docker/config.json',
|
|
222
|
+
'.credentials.yaml', '.openai-codex-auth.json', '.netrc', 'id_rsa', 'id_ed25519', '.env',
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
/** Whether a resolved path is inside a resolved directory (equal counts as inside). */
|
|
226
|
+
export function isInsideDirectory(child, parent) {
|
|
227
|
+
const rel = relative(parent, child);
|
|
228
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Whether a request path is fully qualified (drive-absolute or root-absolute). */
|
|
232
|
+
function isAbsoluteMediaPath(value) {
|
|
233
|
+
return value.startsWith('/') || value.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(value);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Parse one `device-link:media:fetch` url.
|
|
238
|
+
*
|
|
239
|
+
* @param url - the controller's url.
|
|
240
|
+
* @returns `{ ok: true, path, roots, maxBytes }` or `{ ok: false, reason }` — the reason
|
|
241
|
+
* is a bounded token, because it travels back to the controller.
|
|
242
|
+
*/
|
|
243
|
+
export function parseMediaFetchRequest(url) {
|
|
244
|
+
if (typeof url !== 'string' || url === '') return { ok: false, reason: 'missing-url' };
|
|
245
|
+
if (!url.startsWith('xdt-file://') && !url.startsWith('xdt-audio://')) {
|
|
246
|
+
// `xdt-image://` / `xdt-video://` / `cindy-media://` name a *desktop* media cache; this
|
|
247
|
+
// Host has none, so the honest answer is that the scheme is not served here.
|
|
248
|
+
return { ok: false, reason: 'unsupported-scheme' };
|
|
249
|
+
}
|
|
250
|
+
let parsed;
|
|
251
|
+
try {
|
|
252
|
+
parsed = new URL(url);
|
|
253
|
+
} catch {
|
|
254
|
+
return { ok: false, reason: 'malformed-url' };
|
|
255
|
+
}
|
|
256
|
+
const path = parsed.searchParams.get('path');
|
|
257
|
+
if (path === null || path.trim() === '') return { ok: false, reason: 'missing-path' };
|
|
258
|
+
if (!isAbsoluteMediaPath(path)) return { ok: false, reason: 'path-not-absolute' };
|
|
259
|
+
|
|
260
|
+
const roots = [];
|
|
261
|
+
for (const key of ['baseDir', 'workdir']) {
|
|
262
|
+
const value = parsed.searchParams.get(key);
|
|
263
|
+
if (value === null) continue;
|
|
264
|
+
// Present but malformed is refused, never dropped: a constraint that can be disabled
|
|
265
|
+
// by a typo is not a constraint.
|
|
266
|
+
if (value.trim() === '' || !isAbsoluteMediaPath(value)) return { ok: false, reason: `invalid-${key}` };
|
|
267
|
+
roots.push(value);
|
|
268
|
+
}
|
|
269
|
+
if (roots.length === 0) return { ok: false, reason: 'no-root' };
|
|
270
|
+
|
|
271
|
+
let maxBytes = null;
|
|
272
|
+
const rawMaxBytes = parsed.searchParams.get('maxBytes');
|
|
273
|
+
if (rawMaxBytes !== null) {
|
|
274
|
+
const parsedMax = Number(rawMaxBytes);
|
|
275
|
+
if (!Number.isInteger(parsedMax) || parsedMax <= 0) return { ok: false, reason: 'invalid-maxBytes' };
|
|
276
|
+
maxBytes = parsedMax;
|
|
277
|
+
}
|
|
278
|
+
return { ok: true, path, roots, maxBytes };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Whether a resolved path, or any of its segments, is on the never-serve list. */
|
|
282
|
+
export function isBlockedMediaPath(path, blocked = DEFAULT_BLOCKED) {
|
|
283
|
+
const normalized = String(path).replace(/\\/g, '/').toLowerCase();
|
|
284
|
+
const segments = normalized.split('/');
|
|
285
|
+
return blocked.some((entry) => {
|
|
286
|
+
const needle = entry.toLowerCase();
|
|
287
|
+
return needle.includes('/') ? normalized.endsWith(needle) : segments.includes(needle);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Authorize a local file without reading it, shared by media staging and file-peer.
|
|
293
|
+
* Returns { ok: true, real, info, mimeType, cap } or { ok: false, code, message }.
|
|
294
|
+
* The caller URL can lower, but never raise, this resolver's configured maxBytes.
|
|
295
|
+
*/
|
|
296
|
+
export function createLocalFileResolver({
|
|
297
|
+
realpath = realpathImpl,
|
|
298
|
+
stat = statImpl,
|
|
299
|
+
maxBytes = MEDIA_FETCH_MAX_BYTES,
|
|
300
|
+
blocked = DEFAULT_BLOCKED,
|
|
301
|
+
} = {}) {
|
|
302
|
+
return async function resolveFile(url) {
|
|
303
|
+
const request = parseMediaFetchRequest(url);
|
|
304
|
+
if (request.ok !== true) return { ok: false, code: 'BAD_REQUEST', message: `media fetch refused: ${request.reason}` };
|
|
305
|
+
let real;
|
|
306
|
+
try {
|
|
307
|
+
real = await realpath(request.path);
|
|
308
|
+
} catch {
|
|
309
|
+
return { ok: false, code: 'NOT_FOUND', message: 'the requested file does not exist' };
|
|
310
|
+
}
|
|
311
|
+
const allowedRoots = [];
|
|
312
|
+
for (const root of request.roots) {
|
|
313
|
+
try {
|
|
314
|
+
allowedRoots.push(await realpath(root));
|
|
315
|
+
} catch {
|
|
316
|
+
// A root that does not resolve cannot authorise anything; the others still can.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (allowedRoots.length === 0 || !allowedRoots.some((root) => isInsideDirectory(real, root))) {
|
|
320
|
+
return { ok: false, code: 'FORBIDDEN', message: 'the requested file is outside the directory the controller named' };
|
|
321
|
+
}
|
|
322
|
+
if (isBlockedMediaPath(real, blocked)) {
|
|
323
|
+
return { ok: false, code: 'FORBIDDEN', message: 'the requested file is not served to controllers' };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let info;
|
|
327
|
+
try {
|
|
328
|
+
info = await stat(real);
|
|
329
|
+
} catch {
|
|
330
|
+
return { ok: false, code: 'NOT_FOUND', message: 'the requested file could not be read' };
|
|
331
|
+
}
|
|
332
|
+
if (typeof info?.isDirectory === 'function' && info.isDirectory()) {
|
|
333
|
+
return { ok: false, code: 'BAD_REQUEST', message: 'the requested path is a directory' };
|
|
334
|
+
}
|
|
335
|
+
if (typeof info?.isFile === 'function' && !info.isFile()) {
|
|
336
|
+
return { ok: false, code: 'BAD_REQUEST', message: 'the requested path is not a regular file' };
|
|
337
|
+
}
|
|
338
|
+
if (!Number.isSafeInteger(info?.size) || info.size < 0) {
|
|
339
|
+
return { ok: false, code: 'BAD_REQUEST', message: 'the requested file has an invalid size' };
|
|
340
|
+
}
|
|
341
|
+
const cap = request.maxBytes === null ? maxBytes : Math.min(request.maxBytes, maxBytes);
|
|
342
|
+
if (Number.isFinite(info?.size) && info.size > cap) {
|
|
343
|
+
return { ok: false, code: 'BAD_REQUEST', message: `the file is ${info.size} bytes, over the ${cap} byte limit` };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { ok: true, real, info, mimeType: mimeForMediaPath(real), cap };
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Reject file replacement or mutation while a small inline response is being read. */
|
|
351
|
+
function sameFileVersion(before, after) {
|
|
352
|
+
return !(typeof after?.isFile === 'function' && !after.isFile())
|
|
353
|
+
&& !(typeof after?.isDirectory === 'function' && after.isDirectory())
|
|
354
|
+
&& ['size', 'dev', 'ino', 'mtimeMs', 'ctimeMs'].every((key) => before[key] === undefined || before[key] === after?.[key]);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Read at most the authorized size plus one sentinel byte, even if the file grows. */
|
|
358
|
+
async function readPreparedInline(real, info, open, stat) {
|
|
359
|
+
const file = await open(real, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOFOLLOW ?? 0));
|
|
360
|
+
try {
|
|
361
|
+
if (!sameFileVersion(info, await file.stat())) throw new Error('file changed');
|
|
362
|
+
const bytes = Buffer.alloc(info.size + 1);
|
|
363
|
+
let offset = 0;
|
|
364
|
+
while (offset < bytes.length) {
|
|
365
|
+
const { bytesRead } = await file.read(bytes, offset, bytes.length - offset, offset);
|
|
366
|
+
if (!Number.isInteger(bytesRead) || bytesRead < 0 || bytesRead > bytes.length - offset) throw new Error('invalid read');
|
|
367
|
+
if (bytesRead === 0) break;
|
|
368
|
+
offset += bytesRead;
|
|
369
|
+
}
|
|
370
|
+
if (offset !== info.size || !sameFileVersion(info, await file.stat()) || !sameFileVersion(info, await stat(real))) {
|
|
371
|
+
throw new Error('file changed');
|
|
372
|
+
}
|
|
373
|
+
return bytes.subarray(0, offset);
|
|
374
|
+
} finally {
|
|
375
|
+
await file.close();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Build the fetcher behind the channel.
|
|
381
|
+
*
|
|
382
|
+
* @param options - `uploader` (from `host-media.js`), plus injectable filesystem calls,
|
|
383
|
+
* the size cap, the never-serve list, and the thumbnail renderer (injectable so tests
|
|
384
|
+
* never touch a native codec).
|
|
385
|
+
* `prepareOnly` (unless thumbnail is true) uses prepareMaxBytes instead of the legacy
|
|
386
|
+
* staging cap: above 64 KiB it answers metadata with transferRequired, otherwise it
|
|
387
|
+
* inlines any file type, including empty files, using bounded open/read calls.
|
|
388
|
+
* Preparation needs no uploader and never consults or populates the staging cache.
|
|
389
|
+
* @returns `fetchLocalMedia({ url, thumbnail, prepareOnly })` → `{ ok: true, result }` or
|
|
390
|
+
* `{ ok: false, code, message }`.
|
|
391
|
+
*/
|
|
392
|
+
export function createLocalMediaFetcher({
|
|
393
|
+
uploader,
|
|
394
|
+
open = openImpl,
|
|
395
|
+
prepareMaxBytes = FILE_PEER_MAX_BYTES,
|
|
396
|
+
readFile = readFileImpl,
|
|
397
|
+
realpath = realpathImpl,
|
|
398
|
+
stat = statImpl,
|
|
399
|
+
maxBytes = MEDIA_FETCH_MAX_BYTES,
|
|
400
|
+
blocked = DEFAULT_BLOCKED,
|
|
401
|
+
renderThumbnail = renderThumbnailWithSharp,
|
|
402
|
+
thumbnailLimits = {},
|
|
403
|
+
inlineOriginalMaxBytes = INLINE_ORIGINAL_MAX_BYTES,
|
|
404
|
+
cacheTtlMs = STAGING_CACHE_TTL_MS,
|
|
405
|
+
cacheMax = STAGING_CACHE_MAX,
|
|
406
|
+
now = () => Date.now(),
|
|
407
|
+
} = {}) {
|
|
408
|
+
const resolveMedia = createLocalFileResolver({ realpath, stat, maxBytes, blocked });
|
|
409
|
+
const resolvePrepared = createLocalFileResolver({ realpath, stat, maxBytes: prepareMaxBytes, blocked });
|
|
410
|
+
const inlineMaxBytes = Number.isFinite(thumbnailLimits.inlineMaxBytes) ? thumbnailLimits.inlineMaxBytes : THUMBNAIL_INLINE_MAX_BYTES;
|
|
411
|
+
const inputMaxBytes = Number.isFinite(thumbnailLimits.inputMaxBytes) ? thumbnailLimits.inputMaxBytes : THUMBNAIL_INPUT_MAX_BYTES;
|
|
412
|
+
const renderTimeoutMs = Number.isFinite(thumbnailLimits.timeoutMs) ? thumbnailLimits.timeoutMs : THUMBNAIL_RENDER_TIMEOUT_MS;
|
|
413
|
+
/** `path|size|mtime` → the object this Host already staged for it. */
|
|
414
|
+
const staged = new Map();
|
|
415
|
+
|
|
416
|
+
/** The object already staged for this exact file version, if it is still fresh. */
|
|
417
|
+
function lookupStaged(key) {
|
|
418
|
+
const hit = staged.get(key);
|
|
419
|
+
if (hit === undefined) return undefined;
|
|
420
|
+
if (hit.at + cacheTtlMs <= now()) {
|
|
421
|
+
staged.delete(key);
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
return hit;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Remember one staged object, evicting the oldest entries past the bound. */
|
|
428
|
+
function rememberStaged(key, entry) {
|
|
429
|
+
staged.set(key, entry);
|
|
430
|
+
while (staged.size > cacheMax) {
|
|
431
|
+
const oldest = staged.keys().next();
|
|
432
|
+
if (oldest.done === true) break;
|
|
433
|
+
staged.delete(oldest.value);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return async function fetchLocalMedia(input) {
|
|
438
|
+
const prepareOnly = input?.prepareOnly === true && input?.thumbnail !== true;
|
|
439
|
+
// Keep legacy BAD_REQUEST / NOT_AVAILABLE precedence without requiring OSS for preparation.
|
|
440
|
+
if (!prepareOnly && typeof uploader !== 'function') {
|
|
441
|
+
const request = parseMediaFetchRequest(input?.url);
|
|
442
|
+
if (request.ok !== true) return { ok: false, code: 'BAD_REQUEST', message: `media fetch refused: ${request.reason}` };
|
|
443
|
+
return { ok: false, code: 'NOT_AVAILABLE', message: 'this Host cannot stage media for a controller' };
|
|
444
|
+
}
|
|
445
|
+
const resolved = await (prepareOnly ? resolvePrepared : resolveMedia)(input?.url);
|
|
446
|
+
if (resolved.ok !== true) return resolved;
|
|
447
|
+
const { real, info, mimeType, cap } = resolved;
|
|
448
|
+
if (prepareOnly) {
|
|
449
|
+
if (info.size > FILE_INLINE_MAX_BYTES) {
|
|
450
|
+
return { ok: true, result: { ossKey: '', size: info.size, mimeType, transferRequired: true } };
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
const bytes = await readPreparedInline(real, info, open, stat);
|
|
454
|
+
return { ok: true, result: inlineResult(mimeType, bytes) };
|
|
455
|
+
} catch {
|
|
456
|
+
return { ok: false, code: 'INTERNAL', message: 'the requested file changed or could not be read' };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
let bytes;
|
|
460
|
+
try {
|
|
461
|
+
bytes = await readFile(real);
|
|
462
|
+
} catch {
|
|
463
|
+
return { ok: false, code: 'INTERNAL', message: 'the requested file could not be read' };
|
|
464
|
+
}
|
|
465
|
+
if (bytes.length > cap) {
|
|
466
|
+
return { ok: false, code: 'BAD_REQUEST', message: `the file is ${bytes.length} bytes, over the ${cap} byte limit` };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const wantsInline = input?.thumbnail === true;
|
|
470
|
+
// The chat-thumbnail path: the controller asked for a smaller picture, so give it the
|
|
471
|
+
// bytes directly when this Host can produce them inside the frame it has to fit.
|
|
472
|
+
if (wantsInline && isThumbnailableMime(mimeType) && bytes.length <= inputMaxBytes && typeof renderThumbnail === 'function') {
|
|
473
|
+
const thumb = await withRenderTimeout(
|
|
474
|
+
Promise.resolve().then(() => renderThumbnail(real)).catch(() => null),
|
|
475
|
+
renderTimeoutMs,
|
|
476
|
+
).catch(() => null);
|
|
477
|
+
const thumbnail = Buffer.isBuffer(thumb) ? thumb : null;
|
|
478
|
+
if (thumbnail !== null && thumbnail.length > 0 && thumbnail.length <= inlineMaxBytes) {
|
|
479
|
+
return { ok: true, result: inlineResult(THUMBNAIL_MIME, thumbnail) };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// No downscale available (no codec, a format this Host must not re-encode, a failed or
|
|
483
|
+
// oversized render) — but the controller still wanted bytes rather than a key, and a
|
|
484
|
+
// small original inlined beats a full staging round trip. The controller only accepts
|
|
485
|
+
// an `image/*` inline payload (`isValidInlineResult` in `remoteMedia.ts`), so anything
|
|
486
|
+
// else keeps the key path.
|
|
487
|
+
if (wantsInline && mimeType.startsWith('image/') && bytes.length > 0 && bytes.length <= inlineOriginalMaxBytes) {
|
|
488
|
+
return { ok: true, result: inlineResult(mimeType, bytes) };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const cacheKey = `${real}|${info.size}|${Number.isFinite(info.mtimeMs) ? Math.round(info.mtimeMs) : 'x'}`;
|
|
492
|
+
if (input?.skipCache !== true) {
|
|
493
|
+
const remembered = lookupStaged(cacheKey);
|
|
494
|
+
if (remembered !== undefined) {
|
|
495
|
+
return { ok: true, result: { ossKey: remembered.key, mimeType, size: remembered.size } };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const stagedResult = await uploader(bytes, { ext: extname(real).slice(1), contentType: mimeType });
|
|
500
|
+
if (stagedResult?.ok !== true) {
|
|
501
|
+
return { ok: false, code: 'INTERNAL', message: `the file could not be staged for the controller: ${String(stagedResult?.reason ?? 'unknown reason')}` };
|
|
502
|
+
}
|
|
503
|
+
rememberStaged(cacheKey, { key: stagedResult.key, size: bytes.length, at: now() });
|
|
504
|
+
return { ok: true, result: { ossKey: stagedResult.key, mimeType, size: bytes.length } };
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** The inline answer shape: no object exists, so `ossKey` is empty on purpose. */
|
|
509
|
+
function inlineResult(mimeType, bytes) {
|
|
510
|
+
return { ossKey: '', mimeType, size: bytes.length, inlineBase64: bytes.toString('base64') };
|
|
511
|
+
}
|