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,678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch a controller's uploaded attachment out of the Cindy media staging area.
|
|
3
|
+
*
|
|
4
|
+
* A phone does not send image bytes over device-link. It uploads them to the
|
|
5
|
+
* account's OSS staging area and puts an opaque **transit reference** in the
|
|
6
|
+
* message; resolving it is the controlled end's job. The contract is
|
|
7
|
+
* `packages/device-link/src/attachmentOssRef.ts` in the Cindy repo:
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* cindy-oss-attach://m/<base64url(JSON { ossKey, mimeType?, originalName?, size?, sha256? })>
|
|
11
|
+
* xdt-oss-attach://m/… // the pre-rebrand scheme: senders still use it, so the
|
|
12
|
+
* // parsing face must accept both, permanently
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* and the reference implementation of the receiving half is the desktop being
|
|
16
|
+
* controlled (`apps/desktop/src/main/maker-ipc/normalizeAttachments.ts:336`):
|
|
17
|
+
* `parseAttachmentOssRef` → `presign-get` → download → verify → hand the agent a
|
|
18
|
+
* real file, then remove the staging object. This module is the same flow for this
|
|
19
|
+
* Host, over the same account API the device directory already uses
|
|
20
|
+
* (`apps/desktop/src/main/device-link/mediaTransfer.ts:53,574`):
|
|
21
|
+
*
|
|
22
|
+
* ```
|
|
23
|
+
* POST {apiBase}/media/presign-get { key } → { getUrl, expiresAt }
|
|
24
|
+
* GET getUrl → bytes (never through the relay)
|
|
25
|
+
* DELETE {apiBase}/media { key } → the staging object, after use
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* The account token is the one the relay socket already authenticated with, which
|
|
29
|
+
* is exactly why this Host can serve attachments at all: a media object is scoped
|
|
30
|
+
* to the account, and this Host is logged into that account.
|
|
31
|
+
*
|
|
32
|
+
* @module dsh-cindy-host/host-media
|
|
33
|
+
*/
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
35
|
+
import { request as httpRequest } from 'node:http';
|
|
36
|
+
import { request as httpsRequest } from 'node:https';
|
|
37
|
+
|
|
38
|
+
/** Reference schemes a controlled end must accept. */
|
|
39
|
+
export const ATTACH_OSS_SCHEMES = Object.freeze(['cindy-oss-attach', 'xdt-oss-attach']);
|
|
40
|
+
|
|
41
|
+
const PREFIXES = ATTACH_OSS_SCHEMES.map((scheme) => `${scheme}://m/`);
|
|
42
|
+
const SCHEME_PREFIXES = ATTACH_OSS_SCHEMES.map((scheme) => `${scheme}://`);
|
|
43
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether a string is an attachment transit reference, either scheme.
|
|
47
|
+
*
|
|
48
|
+
* Recognition is deliberately **broader** than parsing, exactly as the client's own
|
|
49
|
+
* `isAttachmentOssRef` is: anything under the scheme is "a reference this Host must
|
|
50
|
+
* account for", and a malformed one is then reported as `malformed-ref` instead of
|
|
51
|
+
* being mistaken for a path or silently discarded.
|
|
52
|
+
*/
|
|
53
|
+
export function isAttachmentOssRef(value) {
|
|
54
|
+
return typeof value === 'string' && SCHEME_PREFIXES.some((prefix) => value.startsWith(prefix));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse an attachment transit reference.
|
|
59
|
+
*
|
|
60
|
+
* Mirrors the client's own rules exactly: an unknown scheme, a non-object payload, a
|
|
61
|
+
* missing `ossKey`, or a **half** integrity claim (one of `size`/`sha256` without the
|
|
62
|
+
* other) is not a usable reference. A reference with no integrity claim is still
|
|
63
|
+
* accepted — old senders wrote one, and refusing them would break a mixed rollout.
|
|
64
|
+
* @param value - the `path` the controller sent.
|
|
65
|
+
* @returns the parsed reference, or null when it is not one.
|
|
66
|
+
*/
|
|
67
|
+
export function parseAttachmentOssRef(value) {
|
|
68
|
+
if (typeof value !== 'string') return null;
|
|
69
|
+
const prefix = PREFIXES.find((candidate) => value.startsWith(candidate));
|
|
70
|
+
if (prefix === undefined) return null;
|
|
71
|
+
const segment = value.slice(prefix.length).split('/')[0];
|
|
72
|
+
if (segment === '') return null;
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(Buffer.from(segment.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
80
|
+
if (typeof parsed.ossKey !== 'string' || parsed.ossKey === '') return null;
|
|
81
|
+
|
|
82
|
+
const hasSize = parsed.size !== undefined;
|
|
83
|
+
const hasSha = parsed.sha256 !== undefined;
|
|
84
|
+
// Half a claim is no claim: the pair is what makes verification possible.
|
|
85
|
+
if (hasSize !== hasSha) return null;
|
|
86
|
+
if (hasSize && !(Number.isSafeInteger(parsed.size) && parsed.size > 0 && typeof parsed.sha256 === 'string' && SHA256_HEX.test(parsed.sha256))) return null;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
ossKey: parsed.ossKey,
|
|
90
|
+
...(typeof parsed.mimeType === 'string' && parsed.mimeType !== '' ? { mimeType: parsed.mimeType } : {}),
|
|
91
|
+
...(typeof parsed.originalName === 'string' && parsed.originalName !== '' ? { originalName: parsed.originalName } : {}),
|
|
92
|
+
...(hasSize ? { size: parsed.size, sha256: parsed.sha256 } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The account API base this Host's media and directory calls live under.
|
|
98
|
+
*
|
|
99
|
+
* Derived from the relay URL rather than configured separately, because that is the
|
|
100
|
+
* deployment fact the Host already has: `…/api/device-link/ws` → `…/api/device-link`,
|
|
101
|
+
* whose siblings are `/devices` (already used) and `/media/*`. A second setting could
|
|
102
|
+
* only ever disagree with the socket that is actually connected.
|
|
103
|
+
* @param relayUrl - the relay WebSocket URL.
|
|
104
|
+
* @returns the HTTP base, without a trailing slash.
|
|
105
|
+
*/
|
|
106
|
+
export function mediaApiBaseUrl(relayUrl) {
|
|
107
|
+
return String(relayUrl).replace(/^ws/, 'http').replace(/\/ws$/, '');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Headers every media call needs: the account bearer, and JSON in/out. */
|
|
111
|
+
function mediaHeaders(session) {
|
|
112
|
+
return {
|
|
113
|
+
Authorization: `Bearer ${session.accessToken}`,
|
|
114
|
+
'content-type': 'application/json',
|
|
115
|
+
accept: 'application/json',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A usable account session, or null. */
|
|
120
|
+
function usableSession(getSession) {
|
|
121
|
+
const session = typeof getSession === 'function' ? getSession() : null;
|
|
122
|
+
if (session === null || typeof session !== 'object') return null;
|
|
123
|
+
return typeof session.accessToken === 'string' && session.accessToken !== '' ? session : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The `<Code>` an object store put in its XML refusal, as `': Code'`, or `''`.
|
|
128
|
+
*
|
|
129
|
+
* Only the code is kept: the body can be a page of XML, and the code is the whole
|
|
130
|
+
* diagnosis (`ApkDownloadForbidden`, `SignatureDoesNotMatch`, `NoSuchKey`, …). Best-effort
|
|
131
|
+
* — a body that cannot be read costs nothing but the detail.
|
|
132
|
+
*/
|
|
133
|
+
async function ossRefusalCode(response) {
|
|
134
|
+
if (typeof response?.text !== 'function') return '';
|
|
135
|
+
try {
|
|
136
|
+
const body = await response.text();
|
|
137
|
+
const code = /<Code>([^<]{1,64})<\/Code>/.exec(String(body))?.[1];
|
|
138
|
+
return typeof code === 'string' && code !== '' ? `: ${code}` : '';
|
|
139
|
+
} catch {
|
|
140
|
+
return '';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build the resolver the attachment materializer calls for a transit reference.
|
|
146
|
+
*
|
|
147
|
+
* The failure vocabulary is closed and small (`no-credential`, `presign-failed`,
|
|
148
|
+
* `download-failed[: <object-store code>]`, `oversize`, `size-mismatch`,
|
|
149
|
+
* `sha256-mismatch`, `aborted`): the
|
|
150
|
+
* controller is told *why* an attachment did not arrive, and the Host never guesses.
|
|
151
|
+
*
|
|
152
|
+
* @param options - `apiBaseUrl`, `getSession` (the live account session), plus
|
|
153
|
+
* injectable `fetchImpl`, `maxBytes`, and `timeoutMs`.
|
|
154
|
+
* @returns `resolve(ref, { signal })` → `{ ok: true, buffer, mimeType, name }` or `{ ok: false, reason }`.
|
|
155
|
+
*/
|
|
156
|
+
export function createMediaRefResolver({ apiBaseUrl, getSession, fetchImpl = fetch, maxBytes = 20 * 1024 * 1024, timeoutMs = 30_000, onUnauthorized } = {}) {
|
|
157
|
+
async function presignGet(key, session, signal) {
|
|
158
|
+
const response = await fetchImpl(`${apiBaseUrl}/media/presign-get`, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: mediaHeaders(session),
|
|
161
|
+
body: JSON.stringify({ key }),
|
|
162
|
+
signal,
|
|
163
|
+
});
|
|
164
|
+
if (response.ok !== true) {
|
|
165
|
+
const error = new Error(`presign-get answered ${response.status}`);
|
|
166
|
+
// The status is what distinguishes "this credential rotated" from every other refusal.
|
|
167
|
+
error.status = response.status;
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
const body = await response.json();
|
|
171
|
+
const url = typeof body?.getUrl === 'string' ? body.getUrl : '';
|
|
172
|
+
if (url === '') throw new Error('presign-get answered no getUrl');
|
|
173
|
+
return url;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return async function resolveAttachmentRef(ref, { signal } = {}) {
|
|
177
|
+
const declared = Number.isFinite(ref?.size) ? ref.size : null;
|
|
178
|
+
// A declared size above the cap is refused before anything is downloaded.
|
|
179
|
+
if (declared !== null && declared > maxBytes) return { ok: false, reason: 'oversize' };
|
|
180
|
+
const bound = signal ?? (typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined);
|
|
181
|
+
let refreshed = false;
|
|
182
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
183
|
+
const session = usableSession(getSession);
|
|
184
|
+
// No credential means no media: said plainly rather than attempted and failed.
|
|
185
|
+
if (session === null) return { ok: false, reason: 'no-credential' };
|
|
186
|
+
try {
|
|
187
|
+
const url = await presignGet(ref.ossKey, session, bound);
|
|
188
|
+
const response = await fetchImpl(url, { method: 'GET', signal: bound });
|
|
189
|
+
// A refusal names itself: OSS answers XML with `<Code>ApkDownloadForbidden</Code>`
|
|
190
|
+
// and nothing else on the wire carries that. A bare `download-failed` cost an
|
|
191
|
+
// afternoon of probing; the code costs a glance.
|
|
192
|
+
if (response.ok !== true) return { ok: false, reason: `download-failed${await ossRefusalCode(response)}` };
|
|
193
|
+
// The staged length, when the object store states one, is checked before the
|
|
194
|
+
// bytes are pulled into memory rather than after.
|
|
195
|
+
const stated = Number(response.headers?.get?.('content-length'));
|
|
196
|
+
if (Number.isFinite(stated) && stated > maxBytes) return { ok: false, reason: 'oversize' };
|
|
197
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
198
|
+
if (buffer.length > maxBytes) return { ok: false, reason: 'oversize' };
|
|
199
|
+
if (declared !== null && buffer.length !== declared) return { ok: false, reason: 'size-mismatch' };
|
|
200
|
+
if (typeof ref.sha256 === 'string') {
|
|
201
|
+
const digest = createHash('sha256').update(buffer).digest('hex');
|
|
202
|
+
if (digest !== ref.sha256) return { ok: false, reason: 'sha256-mismatch' };
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
ok: true,
|
|
206
|
+
buffer,
|
|
207
|
+
mimeType: typeof ref.mimeType === 'string' ? ref.mimeType : '',
|
|
208
|
+
name: typeof ref.originalName === 'string' ? ref.originalName : '',
|
|
209
|
+
};
|
|
210
|
+
} catch (error) {
|
|
211
|
+
// Same one-refusal-worth-retrying rule as the uploader: a rotated credential, refreshed
|
|
212
|
+
// by the caller, gets exactly one retry with the new session.
|
|
213
|
+
if (error?.status === 401 && refreshed === false && typeof onUnauthorized === 'function') {
|
|
214
|
+
refreshed = true;
|
|
215
|
+
if (await onUnauthorized() === true) continue;
|
|
216
|
+
}
|
|
217
|
+
return { ok: false, reason: bound?.aborted === true ? 'aborted' : `media-request-failed: ${String(error?.message ?? error)}` };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { ok: false, reason: 'media-request-failed: presign-get answered 401' };
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Build the staging-object remover.
|
|
226
|
+
*
|
|
227
|
+
* The object is a **staging** copy: once its bytes are a durable DSH attachment the
|
|
228
|
+
* phone's own thumbnail and DSH's own copy are what remain, and leaving it behind
|
|
229
|
+
* accumulates orphans until the bucket's lifecycle rules run. Best-effort by
|
|
230
|
+
* contract — a failed removal is never worth failing a prompt that already landed.
|
|
231
|
+
*
|
|
232
|
+
* @param options - `apiBaseUrl`, `getSession`, and an injectable `fetchImpl`.
|
|
233
|
+
* @returns `remove(ossKey)` → `true` when the server confirmed it.
|
|
234
|
+
*/
|
|
235
|
+
/**
|
|
236
|
+
* Extension and Content-Type the account's OSS endpoint refuses to serve over the public
|
|
237
|
+
* `*.aliyuncs.com` host, whatever the signature or object ACL says.
|
|
238
|
+
*
|
|
239
|
+
* Measured against the live staging bucket (`tools/probe-oss-ext.mjs`,
|
|
240
|
+
* `tools/probe-apk-export-roundtrip.mjs`): **either** trigger alone makes an object
|
|
241
|
+
* undownloadable by anyone — the staging **PUT still succeeds**, `presign-get` still
|
|
242
|
+
* answers 200 with a valid url, and then the controller's GET meets
|
|
243
|
+
*
|
|
244
|
+
* 400 <Code>ApkDownloadForbidden</Code>
|
|
245
|
+
* <Message>The APK file is not allowed to be distributed in a public network using
|
|
246
|
+
* the OSS endpoint, please use CNAME instead.</Message>
|
|
247
|
+
*
|
|
248
|
+
* within ~100 ms:
|
|
249
|
+
*
|
|
250
|
+
* key suffix `.apk` / `.ipa` (any casing — the server lower-cases it) → refused
|
|
251
|
+
* object Content-Type exactly `application/vnd.android.package-archive` → refused
|
|
252
|
+
* `application/octet-stream` / `zip` / `x-itunes-ipa` / the same mime with
|
|
253
|
+
* `; charset=binary` → served
|
|
254
|
+
*
|
|
255
|
+
* This is why an exported `.apk` arrived as 下载失败 in under five seconds while every
|
|
256
|
+
* test looked green: the export really did finish, and only the last hop was refused.
|
|
257
|
+
*
|
|
258
|
+
* The staging descriptor is not user-visible. The controller names its own copy from the
|
|
259
|
+
* name of the file it browsed (`apps/mobile/app/files/[sessionId].tsx`:
|
|
260
|
+
* `downloadRemoteMediaShareTemp(url, mime, item.name)`), and mime for the share sheet
|
|
261
|
+
* comes from that same name — so an app package staged as opaque bytes still arrives as
|
|
262
|
+
* `Cindy-Verify-…apk` and installs normally.
|
|
263
|
+
*/
|
|
264
|
+
const UNSTAGEABLE_EXTS = new Set(['apk', 'ipa']);
|
|
265
|
+
const UNSTAGEABLE_CONTENT_TYPES = new Set(['application/vnd.android.package-archive']);
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* What the staging PUT should declare for this object.
|
|
269
|
+
*
|
|
270
|
+
* An app package — by suffix or by declared mime — is staged as opaque bytes: a neutral
|
|
271
|
+
* extension *and* `application/octet-stream`, because neutralizing only the suffix leaves
|
|
272
|
+
* the Content-Type trigger armed (measured).
|
|
273
|
+
*
|
|
274
|
+
* @param ext - Caller-declared extension, with or without a leading dot.
|
|
275
|
+
* @param contentType - Caller-declared mime, or empty.
|
|
276
|
+
* @returns `{ ext, contentType }` the object store will serve, with the mime never empty.
|
|
277
|
+
*/
|
|
278
|
+
export function stageableStaging(ext, contentType) {
|
|
279
|
+
const cleanedExt = typeof ext === 'string' ? ext.trim().replace(/^\.+/, '').toLowerCase() : '';
|
|
280
|
+
const declared = typeof contentType === 'string' && contentType.trim() !== '' ? contentType : 'application/octet-stream';
|
|
281
|
+
const bare = declared.split(';')[0].trim().toLowerCase();
|
|
282
|
+
const blockedExt = UNSTAGEABLE_EXTS.has(cleanedExt);
|
|
283
|
+
const appPackage = blockedExt || UNSTAGEABLE_CONTENT_TYPES.has(bare);
|
|
284
|
+
return {
|
|
285
|
+
ext: blockedExt ? 'bin' : cleanedExt,
|
|
286
|
+
contentType: appPackage ? 'application/octet-stream' : declared,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Build the staging-object uploader.
|
|
292
|
+
*
|
|
293
|
+
* The other half of the same staging area: an image the **agent** produced lives on this
|
|
294
|
+
* machine, the controller cannot reach this machine's disk, and the reference design is to
|
|
295
|
+
* PUT it into the account's staging area and answer with the key
|
|
296
|
+
* (`apps/desktop/src/main/device-link/mediaFetch.ts`: 解析本机媒体 → 上传 OSS → 返回引用).
|
|
297
|
+
* The controller then presign-gets it itself, exactly as this Host does for a photo the
|
|
298
|
+
* phone uploaded.
|
|
299
|
+
*
|
|
300
|
+
* `x-oss-object-acl: private` is **signed into** the presigned URL — omitting it is a 403,
|
|
301
|
+
* not a default-ACL upload. Measured while building `tools/attachment-probe.mjs`.
|
|
302
|
+
*
|
|
303
|
+
* @param options - `apiBaseUrl`, `getSession`, and injectable `fetchImpl`/`timeoutMs`.
|
|
304
|
+
* @returns `upload(bytes, { ext, contentType })` → `{ ok: true, key, size }` or `{ ok: false, reason }`.
|
|
305
|
+
*/
|
|
306
|
+
export function createMediaUploader({ apiBaseUrl, getSession, fetchImpl = fetch, timeoutMs = 30_000, onUnauthorized } = {}) {
|
|
307
|
+
return async function uploadMedia(bytes, { ext, contentType } = {}) {
|
|
308
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) return { ok: false, reason: 'empty' };
|
|
309
|
+
// What is declared here is what the object store stores and later judges; see
|
|
310
|
+
// `stageableStaging` for why an app package must be staged as opaque bytes.
|
|
311
|
+
const staging = stageableStaging(ext, contentType);
|
|
312
|
+
let refreshed = false;
|
|
313
|
+
// Two attempts at most: the second only happens after a 401 and a successful refresh.
|
|
314
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
315
|
+
// Re-read every attempt: a refresh updates the caller's session, and the whole point of the
|
|
316
|
+
// retry is to sign with the new one.
|
|
317
|
+
const session = usableSession(getSession);
|
|
318
|
+
if (session === null) return { ok: false, reason: 'no-credential' };
|
|
319
|
+
const bound = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined;
|
|
320
|
+
try {
|
|
321
|
+
const presign = await fetchImpl(`${apiBaseUrl}/media/presign-put`, {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: mediaHeaders(session),
|
|
324
|
+
body: JSON.stringify({ size: bytes.length, ext: staging.ext, contentType: staging.contentType }),
|
|
325
|
+
signal: bound,
|
|
326
|
+
});
|
|
327
|
+
if (presign.ok !== true) {
|
|
328
|
+
// A rotated credential is the one refusal worth retrying: this process reads the store
|
|
329
|
+
// once, so a session refreshed elsewhere (the settings card, a tool, another process)
|
|
330
|
+
// is invisible here — measured as an export that 401s until the Host is restarted.
|
|
331
|
+
if (presign.status === 401 && refreshed === false && typeof onUnauthorized === 'function') {
|
|
332
|
+
refreshed = true;
|
|
333
|
+
if (await onUnauthorized() === true) continue;
|
|
334
|
+
}
|
|
335
|
+
return { ok: false, reason: `presign-put answered ${presign.status}` };
|
|
336
|
+
}
|
|
337
|
+
const body = await presign.json();
|
|
338
|
+
const putUrl = typeof body?.putUrl === 'string' ? body.putUrl : typeof body?.url === 'string' ? body.url : '';
|
|
339
|
+
const key = typeof body?.key === 'string' ? body.key : '';
|
|
340
|
+
if (putUrl === '' || key === '') return { ok: false, reason: 'presign-put answered no putUrl/key' };
|
|
341
|
+
const put = await fetchImpl(putUrl, {
|
|
342
|
+
method: 'PUT',
|
|
343
|
+
// The stored Content-Type comes from this header, and OSS judges it later.
|
|
344
|
+
headers: { 'Content-Type': staging.contentType, 'x-oss-object-acl': 'private' },
|
|
345
|
+
body: bytes,
|
|
346
|
+
signal: bound,
|
|
347
|
+
});
|
|
348
|
+
if (put.ok !== true) {
|
|
349
|
+
if (put.status === 401 && refreshed === false && typeof onUnauthorized === 'function') {
|
|
350
|
+
refreshed = true;
|
|
351
|
+
if (await onUnauthorized() === true) continue;
|
|
352
|
+
}
|
|
353
|
+
return { ok: false, reason: `staging upload answered ${put.status}` };
|
|
354
|
+
}
|
|
355
|
+
return { ok: true, key, size: bytes.length };
|
|
356
|
+
} catch (error) {
|
|
357
|
+
return { ok: false, reason: bound?.aborted === true ? 'aborted' : `media-upload-failed: ${String(error?.message ?? error)}` };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return { ok: false, reason: 'presign-put answered 401' };
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const FILE_UPLOAD_LIMIT = 2 * 1024 * 1024 * 1024;
|
|
365
|
+
const FILE_UPLOAD_CHUNK = 64 * 1024;
|
|
366
|
+
|
|
367
|
+
class FileUploadError extends Error {
|
|
368
|
+
constructor(code, stage) {
|
|
369
|
+
super(code.toLowerCase().replaceAll('_', '-'));
|
|
370
|
+
this.code = code;
|
|
371
|
+
this.stage = stage;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Race even uncooperative test transports/credential providers, and consume late
|
|
376
|
+
// rejections. A source fd remains exclusively owned by the Export job manager.
|
|
377
|
+
function fileUploadAwait(operation, signal) {
|
|
378
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
379
|
+
return new Promise((resolve, reject) => {
|
|
380
|
+
const abort = () => {
|
|
381
|
+
signal.removeEventListener('abort', abort);
|
|
382
|
+
reject(signal.reason);
|
|
383
|
+
};
|
|
384
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
385
|
+
Promise.resolve().then(() => {
|
|
386
|
+
signal.throwIfAborted();
|
|
387
|
+
return operation();
|
|
388
|
+
}).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function discardMediaResponse(response) {
|
|
393
|
+
try { Promise.resolve(response?.body?.cancel?.()).catch(() => {}); } catch { /* best effort */ }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Native PUT deliberately avoids fetch/undici's fixed response-header deadline:
|
|
398
|
+
* a continuously progressing upload may take more than ten minutes. No global
|
|
399
|
+
* dispatcher/agent mutation, redirects, retries, or total-upload timer. Each
|
|
400
|
+
* request owns its socket; the uploader supplies the resettable idle deadline.
|
|
401
|
+
* Awaiting each write callback bounds the writable queue to one <=64KiB chunk
|
|
402
|
+
* (and naturally honors backpressure). Response bodies are not retained.
|
|
403
|
+
*/
|
|
404
|
+
function nativeFileMediaPut(url, { headers, body, signal }, requestImpl) {
|
|
405
|
+
return new Promise((resolve, reject) => {
|
|
406
|
+
let settled = false;
|
|
407
|
+
let request;
|
|
408
|
+
const finish = (error, response) => {
|
|
409
|
+
if (settled) return;
|
|
410
|
+
settled = true;
|
|
411
|
+
signal.removeEventListener('abort', abort);
|
|
412
|
+
if (error) reject(error); else resolve(response);
|
|
413
|
+
request?.destroy();
|
|
414
|
+
};
|
|
415
|
+
const abort = () => finish(signal.reason);
|
|
416
|
+
if (signal.aborted) { reject(signal.reason); return; }
|
|
417
|
+
try {
|
|
418
|
+
const send = requestImpl ?? (new URL(url).protocol === 'https:' ? httpsRequest : httpRequest);
|
|
419
|
+
request = send(url, { method: 'PUT', headers, agent: false, timeout: 0 }, (response) => {
|
|
420
|
+
const status = response.statusCode;
|
|
421
|
+
response.destroy();
|
|
422
|
+
finish(null, { ok: status >= 200 && status < 300, status });
|
|
423
|
+
});
|
|
424
|
+
request.on('error', (error) => finish(error));
|
|
425
|
+
request.on('close', () => {
|
|
426
|
+
if (!settled) finish(new Error('PUT closed before response'));
|
|
427
|
+
});
|
|
428
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
429
|
+
if (signal.aborted) { abort(); return; }
|
|
430
|
+
(async () => {
|
|
431
|
+
for await (const chunk of body) {
|
|
432
|
+
if (settled) break;
|
|
433
|
+
await fileUploadAwait(() => new Promise((written, failed) => {
|
|
434
|
+
request.write(chunk, (error) => error ? failed(error) : written());
|
|
435
|
+
}), signal);
|
|
436
|
+
}
|
|
437
|
+
if (!settled) request.end();
|
|
438
|
+
})().catch((error) => finish(error));
|
|
439
|
+
} catch (error) {
|
|
440
|
+
finish(error);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Authorized-file uploader; independent of the legacy Buffer uploader above.
|
|
447
|
+
* source = {size, read(buffer, position): Promise<number>, verify(): Promise<void>}
|
|
448
|
+
* is an already-authorized, already-open source; this module NEVER closes it.
|
|
449
|
+
* All sizes 0..2GiB stream. Caller maxBytes/path/owner admission belongs to jobs.
|
|
450
|
+
*
|
|
451
|
+
* getCredential(owner) MUST validate the captured owner and return {session,lease}
|
|
452
|
+
* or null. refreshCredential(capturedCredential) MUST enforce same-owner lineage
|
|
453
|
+
* and CAS itself; a new returned opaque lease is allowed. No live getSession.
|
|
454
|
+
* The manager must abort signal when that owner/controller becomes invalid.
|
|
455
|
+
* Only failure cleanup reacquires a credential, with that SAME captured owner.
|
|
456
|
+
*
|
|
457
|
+
* putImpl(url,{headers,body,signal}) is an optional controlled transport returning
|
|
458
|
+
* {ok,status}; it must incrementally consume the one-shot async iterable body and
|
|
459
|
+
* honor signal. Default native transport has no hidden fixed header deadline.
|
|
460
|
+
* requestImpl optionally injects Node's request signature for transport tests.
|
|
461
|
+
* setTimeoutImpl/clearTimeoutImpl inject timers (normal defaults otherwise).
|
|
462
|
+
*
|
|
463
|
+
* uploadFile(source,{owner,signal,ext,contentType,onProgress,onEvent}) returns
|
|
464
|
+
* {ok:true,key,size,sha256}, or sanitized {ok:false,code,reason,stage}. Progress
|
|
465
|
+
* counts bytes handed to the HTTP stack, NOT peer receipt/network confirmation.
|
|
466
|
+
* Events contain only {type,stage,code?}; callbacks are advisory and isolated.
|
|
467
|
+
*/
|
|
468
|
+
export function createFileMediaUploader({
|
|
469
|
+
apiBaseUrl, fetchImpl = fetch, getCredential, refreshCredential,
|
|
470
|
+
stageTimeoutMs = 30_000, idleTimeoutMs = 60_000, cleanupTimeoutMs = 5_000,
|
|
471
|
+
putImpl, requestImpl, setTimeoutImpl = setTimeout, clearTimeoutImpl = clearTimeout,
|
|
472
|
+
} = {}) {
|
|
473
|
+
const validDuration = (value) => Number.isSafeInteger(value) && value > 0 && value <= 2_147_483_647;
|
|
474
|
+
const credentialUsable = (value) => value?.lease != null
|
|
475
|
+
&& typeof value?.session?.accessToken === 'string' && value.session.accessToken !== '';
|
|
476
|
+
const put = putImpl ?? ((url, init) => nativeFileMediaPut(url, init, requestImpl));
|
|
477
|
+
|
|
478
|
+
return async function uploadFile(source, opts = {}) {
|
|
479
|
+
const invalid = (code = 'INVALID_ARGUMENT') => ({ ok: false, code, reason: code.toLowerCase().replaceAll('_', '-'), stage: 'validate' });
|
|
480
|
+
if (!opts || typeof opts !== 'object' || !source || typeof source.read !== 'function' || typeof source.verify !== 'function') return invalid();
|
|
481
|
+
const size = source.size;
|
|
482
|
+
if (!Number.isSafeInteger(size) || size < 0) return invalid();
|
|
483
|
+
if (size > FILE_UPLOAD_LIMIT) return invalid('OVERSIZE');
|
|
484
|
+
const { owner, signal, ext, contentType, onProgress, onEvent } = opts;
|
|
485
|
+
if (owner == null || typeof getCredential !== 'function' || typeof fetchImpl !== 'function'
|
|
486
|
+
|| typeof put !== 'function' || typeof apiBaseUrl !== 'string' || !apiBaseUrl
|
|
487
|
+
|| ![stageTimeoutMs, idleTimeoutMs, cleanupTimeoutMs].every(validDuration)
|
|
488
|
+
|| (signal != null && !(signal instanceof AbortSignal))
|
|
489
|
+
|| (ext !== undefined && (typeof ext !== 'string' || ext.length > 64 || !/^[.a-zA-Z0-9_-]*$/.test(ext)))
|
|
490
|
+
|| (contentType !== undefined && (typeof contentType !== 'string' || contentType.length > 256 || /[^\x20-\x7e]/.test(contentType)))) return invalid();
|
|
491
|
+
const staging = stageableStaging(ext, contentType);
|
|
492
|
+
const controller = new AbortController();
|
|
493
|
+
const bound = controller.signal;
|
|
494
|
+
let stage = 'verify';
|
|
495
|
+
let timer;
|
|
496
|
+
let key;
|
|
497
|
+
let body;
|
|
498
|
+
let complete = false;
|
|
499
|
+
let sha256;
|
|
500
|
+
const fault = (code) => new FileUploadError(code, stage);
|
|
501
|
+
const emit = (type, code) => {
|
|
502
|
+
try { onEvent?.({ type, stage, ...(code ? { code } : {}) }); } catch { /* diagnostics cannot change transfer */ }
|
|
503
|
+
};
|
|
504
|
+
const arm = (ms, code) => {
|
|
505
|
+
clearTimeoutImpl(timer);
|
|
506
|
+
timer = setTimeoutImpl(() => controller.abort(fault(code)), ms);
|
|
507
|
+
};
|
|
508
|
+
const abort = () => controller.abort(fault('ABORTED'));
|
|
509
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
510
|
+
if (signal?.aborted) abort();
|
|
511
|
+
const wait = (operation) => fileUploadAwait(operation, bound);
|
|
512
|
+
const verify = async () => {
|
|
513
|
+
try { await wait(() => source.verify()); }
|
|
514
|
+
catch { throw bound.aborted ? bound.reason : fault('SOURCE_CHANGED'); }
|
|
515
|
+
};
|
|
516
|
+
let cleanupPromise;
|
|
517
|
+
const cleanup = () => cleanupPromise ??= (async () => {
|
|
518
|
+
if (!key) return;
|
|
519
|
+
const cleanupController = new AbortController();
|
|
520
|
+
const cleanupTimer = setTimeoutImpl(() => cleanupController.abort(), Math.min(cleanupTimeoutMs, 5_000));
|
|
521
|
+
const cleanupWait = (operation) => fileUploadAwait(operation, cleanupController.signal);
|
|
522
|
+
try {
|
|
523
|
+
// Never refresh here, nor use the new account's unbound live session.
|
|
524
|
+
const credential = await cleanupWait(() => getCredential(owner));
|
|
525
|
+
if (!credentialUsable(credential)) { emit('cleanup_skipped_owner'); return; }
|
|
526
|
+
const response = await cleanupWait(() => fetchImpl(`${apiBaseUrl}/media`, {
|
|
527
|
+
method: 'DELETE', headers: mediaHeaders(credential.session),
|
|
528
|
+
body: JSON.stringify({ key }), signal: cleanupController.signal,
|
|
529
|
+
}));
|
|
530
|
+
discardMediaResponse(response);
|
|
531
|
+
emit(response?.ok === true ? 'cleanup_done' : 'cleanup_failed');
|
|
532
|
+
} catch { emit('cleanup_failed'); }
|
|
533
|
+
finally { clearTimeoutImpl(cleanupTimer); cleanupController.abort(); }
|
|
534
|
+
})();
|
|
535
|
+
try {
|
|
536
|
+
arm(stageTimeoutMs, 'STAGE_TIMEOUT');
|
|
537
|
+
await verify();
|
|
538
|
+
stage = 'credential';
|
|
539
|
+
let credential;
|
|
540
|
+
try { credential = await wait(() => getCredential(owner)); }
|
|
541
|
+
catch { throw bound.aborted ? bound.reason : fault('OWNER_UNVERIFIED'); }
|
|
542
|
+
if (!credentialUsable(credential)) throw fault('OWNER_UNVERIFIED');
|
|
543
|
+
let signed;
|
|
544
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
545
|
+
stage = 'presign';
|
|
546
|
+
emit('presign');
|
|
547
|
+
const response = await wait(() => fetchImpl(`${apiBaseUrl}/media/presign-put`, {
|
|
548
|
+
method: 'POST', headers: mediaHeaders(credential.session),
|
|
549
|
+
body: JSON.stringify({ size, ext: staging.ext, contentType: staging.contentType }), signal: bound,
|
|
550
|
+
}));
|
|
551
|
+
if (response?.ok === true) {
|
|
552
|
+
if (typeof response.body?.getReader === 'function') {
|
|
553
|
+
const reader = response.body.getReader();
|
|
554
|
+
const decoder = new TextDecoder();
|
|
555
|
+
let bytes = 0;
|
|
556
|
+
let text = '';
|
|
557
|
+
try {
|
|
558
|
+
for (;;) {
|
|
559
|
+
const chunk = await wait(() => reader.read());
|
|
560
|
+
if (chunk.done) break;
|
|
561
|
+
bytes += chunk.value.byteLength;
|
|
562
|
+
if (bytes > 64 * 1024) throw fault('PRESIGN_FAILED');
|
|
563
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
564
|
+
}
|
|
565
|
+
signed = JSON.parse(text + decoder.decode());
|
|
566
|
+
} finally {
|
|
567
|
+
try { Promise.resolve(reader.cancel()).catch(() => {}); } catch {}
|
|
568
|
+
try { reader.releaseLock(); } catch {}
|
|
569
|
+
}
|
|
570
|
+
} else {
|
|
571
|
+
// Injected response doubles need no network reader.
|
|
572
|
+
signed = await wait(() => response.json());
|
|
573
|
+
}
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
discardMediaResponse(response);
|
|
577
|
+
if (response?.status !== 401) throw fault('PRESIGN_FAILED');
|
|
578
|
+
if (attempt !== 0 || typeof refreshCredential !== 'function') throw fault('AUTH_FAILED');
|
|
579
|
+
stage = 'refresh';
|
|
580
|
+
emit('refresh');
|
|
581
|
+
try { credential = await wait(() => refreshCredential(credential)); }
|
|
582
|
+
catch { throw bound.aborted ? bound.reason : fault('OWNER_UNVERIFIED'); }
|
|
583
|
+
if (!credentialUsable(credential)) throw fault('OWNER_UNVERIFIED');
|
|
584
|
+
}
|
|
585
|
+
if (typeof signed?.key !== 'string' || !signed.key || signed.key.length > 4096 || /[\x00-\x1f\x7f]/.test(signed.key)) throw fault('PRESIGN_FAILED');
|
|
586
|
+
key = signed.key; // Retain a known unpublished key even if the URL is invalid.
|
|
587
|
+
const putUrl = typeof signed.putUrl === 'string' ? signed.putUrl : signed.url;
|
|
588
|
+
if (typeof putUrl !== 'string' || putUrl.length > 16 * 1024) throw fault('PRESIGN_FAILED');
|
|
589
|
+
let target;
|
|
590
|
+
try { target = new URL(putUrl); } catch { throw fault('PRESIGN_FAILED'); }
|
|
591
|
+
if (!['http:', 'https:'].includes(target.protocol) || target.username || target.password) throw fault('PRESIGN_FAILED');
|
|
592
|
+
bound.throwIfAborted();
|
|
593
|
+
stage = 'put';
|
|
594
|
+
arm(idleTimeoutMs, 'IDLE_TIMEOUT');
|
|
595
|
+
emit('put');
|
|
596
|
+
const hash = createHash('sha256');
|
|
597
|
+
const read = async (buffer, position) => {
|
|
598
|
+
let count;
|
|
599
|
+
try { count = await wait(() => source.read(buffer, position)); }
|
|
600
|
+
catch { throw bound.aborted ? bound.reason : fault('SOURCE_READ_FAILED'); }
|
|
601
|
+
bound.throwIfAborted();
|
|
602
|
+
if (!Number.isInteger(count) || count < 0 || count > buffer.length) throw fault('SOURCE_READ_FAILED');
|
|
603
|
+
return count;
|
|
604
|
+
};
|
|
605
|
+
body = (async function* () {
|
|
606
|
+
try {
|
|
607
|
+
let position = 0;
|
|
608
|
+
while (position < size) {
|
|
609
|
+
const buffer = Buffer.allocUnsafe(Math.min(FILE_UPLOAD_CHUNK, size - position));
|
|
610
|
+
const count = await read(buffer, position);
|
|
611
|
+
if (count === 0) throw fault('SIZE_MISMATCH');
|
|
612
|
+
const chunk = buffer.subarray(0, count);
|
|
613
|
+
hash.update(chunk);
|
|
614
|
+
yield chunk;
|
|
615
|
+
bound.throwIfAborted();
|
|
616
|
+
position += count;
|
|
617
|
+
arm(idleTimeoutMs, 'IDLE_TIMEOUT');
|
|
618
|
+
try { onProgress?.(position); } catch { /* advisory only */ }
|
|
619
|
+
}
|
|
620
|
+
if (await read(Buffer.allocUnsafe(1), size) !== 0) throw fault('SIZE_MISMATCH');
|
|
621
|
+
await verify();
|
|
622
|
+
bound.throwIfAborted();
|
|
623
|
+
sha256 = hash.digest('hex');
|
|
624
|
+
complete = true;
|
|
625
|
+
} catch (error) {
|
|
626
|
+
// Invalidate BEFORE transport can race a successful HTTP response.
|
|
627
|
+
controller.abort(error);
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
})();
|
|
631
|
+
const response = await wait(() => put(putUrl, {
|
|
632
|
+
headers: { 'Content-Type': staging.contentType, 'Content-Length': String(size), 'x-oss-object-acl': 'private' },
|
|
633
|
+
body, signal: bound,
|
|
634
|
+
}));
|
|
635
|
+
discardMediaResponse(response);
|
|
636
|
+
bound.throwIfAborted();
|
|
637
|
+
if (response?.ok !== true) throw fault('PUT_FAILED');
|
|
638
|
+
if (!complete) throw fault('INCOMPLETE_UPLOAD');
|
|
639
|
+
await verify(); // Includes changes while awaiting the final HTTP response.
|
|
640
|
+
emit('complete');
|
|
641
|
+
bound.throwIfAborted();
|
|
642
|
+
// The job publisher may still lose a cancellation race before exposing key.
|
|
643
|
+
return { ok: true, key, size, sha256, discard: cleanup };
|
|
644
|
+
} catch (error) {
|
|
645
|
+
const failure = bound.aborted ? bound.reason : error;
|
|
646
|
+
const safe = failure instanceof FileUploadError ? failure : fault('NETWORK_ERROR');
|
|
647
|
+
controller.abort(safe);
|
|
648
|
+
clearTimeoutImpl(timer);
|
|
649
|
+
if (body) await body.return().catch(() => {});
|
|
650
|
+
emit('error', safe.code);
|
|
651
|
+
await cleanup();
|
|
652
|
+
return { ok: false, code: safe.code, reason: safe.message, stage: safe.stage };
|
|
653
|
+
} finally {
|
|
654
|
+
clearTimeoutImpl(timer);
|
|
655
|
+
signal?.removeEventListener('abort', abort);
|
|
656
|
+
controller.abort();
|
|
657
|
+
if (body) await body.return().catch(() => {});
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export function createMediaReleaser({ apiBaseUrl, getSession, fetchImpl = fetch } = {}) {
|
|
663
|
+
return async function removeRemoteMedia(ossKey) {
|
|
664
|
+
if (typeof ossKey !== 'string' || ossKey === '') return false;
|
|
665
|
+
const session = usableSession(getSession);
|
|
666
|
+
if (session === null) return false;
|
|
667
|
+
try {
|
|
668
|
+
const response = await fetchImpl(`${apiBaseUrl}/media`, {
|
|
669
|
+
method: 'DELETE',
|
|
670
|
+
headers: mediaHeaders(session),
|
|
671
|
+
body: JSON.stringify({ key: ossKey }),
|
|
672
|
+
});
|
|
673
|
+
return response.ok === true;
|
|
674
|
+
} catch {
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
}
|