@nanhara/hara 0.121.0 → 0.122.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/CHANGELOG.md +85 -0
- package/README.md +40 -6
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +158 -21
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +20 -6
- package/dist/config.js +62 -26
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +5 -9
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +640 -219
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +150 -0
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +112 -28
- package/dist/session/store.js +337 -44
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +61 -23
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +364 -64
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +83 -7
- package/package.json +1 -1
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, open, readdir, realpath, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
5
|
+
/** A single inbound attachment must never consume unbounded memory or disk. */
|
|
6
|
+
export const INBOUND_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
|
|
7
|
+
export const INBOUND_MEDIA_MAX_FILES = 4;
|
|
8
|
+
export const INBOUND_MEDIA_TIMEOUT_MS = 60_000;
|
|
9
|
+
export const INBOUND_MEDIA_MAX_ACTIVE = 8;
|
|
10
|
+
export const INBOUND_MEDIA_MAX_ACTIVE_PER_PLATFORM = 2;
|
|
11
|
+
export const INBOUND_MEDIA_MAX_RETAINED_BYTES = 160 * 1024 * 1024;
|
|
12
|
+
export const INBOUND_MEDIA_MAX_RETAINED_FILES = 32;
|
|
13
|
+
export const INBOUND_MEDIA_MAX_RETAINED_BYTES_PER_PLATFORM = 40 * 1024 * 1024;
|
|
14
|
+
export const INBOUND_MEDIA_MAX_RETAINED_FILES_PER_PLATFORM = 8;
|
|
15
|
+
export class MediaSizeLimitError extends Error {
|
|
16
|
+
limit;
|
|
17
|
+
constructor(limit) {
|
|
18
|
+
super(`inbound media exceeds ${limit} byte limit`);
|
|
19
|
+
this.limit = limit;
|
|
20
|
+
this.name = "MediaSizeLimitError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const OWNED_MEDIA_NAME = /^(?:\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.part|[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.[a-z0-9]{1,10})$/i;
|
|
24
|
+
export const INBOUND_MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
const LEGACY_MEDIA_NAME = {
|
|
26
|
+
telegram: /^tg_\d{10,17}_[^/]{1,255}$/,
|
|
27
|
+
discord: /^dc_\d{10,17}_[^/]{1,255}$/,
|
|
28
|
+
feishu: /^fs_\d{10,17}_[a-z0-9_-]{1,32}\.(?:jpg|bin)$/i,
|
|
29
|
+
slack: /^sl_\d{10,17}_[^/]{1,255}$/,
|
|
30
|
+
mattermost: /^mm_\d{10,17}_[^/]{1,255}$/,
|
|
31
|
+
matrix: /^mx_\d{10,17}_[^/]{1,64}$/,
|
|
32
|
+
signal: /^sig_\d{10,17}_[^/]{1,64}\.(?:png|jpe?g|gif|webp|bin)$/i,
|
|
33
|
+
wecom: /^wc_\d{10,17}_[^/]{1,255}$/,
|
|
34
|
+
weixin: /^(?:img_[0-9a-f]{8}\.jpg|audio_[0-9a-f]{8}\.silk|[0-9a-f]{8}_[^/]{1,80})$/i,
|
|
35
|
+
};
|
|
36
|
+
const activeByPlatform = new Map();
|
|
37
|
+
let activeDownloads = 0;
|
|
38
|
+
const retainedPaths = new Map();
|
|
39
|
+
const retainedByPlatform = new Map();
|
|
40
|
+
const reservedByPlatform = new Map();
|
|
41
|
+
let retainedBytes = 0;
|
|
42
|
+
let retainedFiles = 0;
|
|
43
|
+
let reservedBytes = 0;
|
|
44
|
+
let reservedFiles = 0;
|
|
45
|
+
function checkedLimit(value) {
|
|
46
|
+
const limit = value ?? INBOUND_MEDIA_MAX_BYTES;
|
|
47
|
+
if (!Number.isSafeInteger(limit) || limit <= 0)
|
|
48
|
+
throw new TypeError("maxBytes must be a positive safe integer");
|
|
49
|
+
return limit;
|
|
50
|
+
}
|
|
51
|
+
function checkedPlatform(value) {
|
|
52
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/i.test(value))
|
|
53
|
+
throw new TypeError("invalid media platform name");
|
|
54
|
+
return value.toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
function extensionFromContentType(contentType) {
|
|
57
|
+
const mime = String(contentType ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
58
|
+
const known = {
|
|
59
|
+
"image/png": ".png",
|
|
60
|
+
"image/jpeg": ".jpg",
|
|
61
|
+
"image/gif": ".gif",
|
|
62
|
+
"image/webp": ".webp",
|
|
63
|
+
"audio/mpeg": ".mp3",
|
|
64
|
+
"audio/ogg": ".ogg",
|
|
65
|
+
"audio/wav": ".wav",
|
|
66
|
+
"video/mp4": ".mp4",
|
|
67
|
+
"application/pdf": ".pdf",
|
|
68
|
+
"text/plain": ".txt",
|
|
69
|
+
};
|
|
70
|
+
return known[mime] ?? ".bin";
|
|
71
|
+
}
|
|
72
|
+
/** Keep only a short conventional extension; the remote filename never becomes part of the local basename. */
|
|
73
|
+
export function safeMediaExtension(filenameHint, contentType) {
|
|
74
|
+
const candidate = extname(basename(String(filenameHint ?? ""))).toLowerCase();
|
|
75
|
+
return /^\.[a-z0-9]{1,10}$/.test(candidate) ? candidate : extensionFromContentType(contentType);
|
|
76
|
+
}
|
|
77
|
+
async function checkedDirectory(path, label) {
|
|
78
|
+
const info = await lstat(path);
|
|
79
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
80
|
+
throw new Error(`unsafe ${label} directory: ${path}`);
|
|
81
|
+
}
|
|
82
|
+
async function assertContainedDirectory(root, platformDir, mediaDir) {
|
|
83
|
+
const [rootReal, platformReal, mediaReal] = await Promise.all([realpath(root), realpath(platformDir), realpath(mediaDir)]);
|
|
84
|
+
if (platformReal !== join(rootReal, basename(platformDir)) || mediaReal !== join(platformReal, "media")) {
|
|
85
|
+
throw new Error(`media directory escapes its private root: ${mediaDir}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function ensurePrivateMediaDir(platform, baseDir = join(homedir(), ".hara")) {
|
|
89
|
+
const root = resolve(baseDir);
|
|
90
|
+
const platformDir = join(root, checkedPlatform(platform));
|
|
91
|
+
const dir = join(platformDir, "media");
|
|
92
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
93
|
+
await checkedDirectory(root, "media root");
|
|
94
|
+
await mkdir(platformDir, { mode: 0o700 }).catch((error) => {
|
|
95
|
+
if (error?.code !== "EEXIST")
|
|
96
|
+
throw error;
|
|
97
|
+
});
|
|
98
|
+
await checkedDirectory(platformDir, "platform media");
|
|
99
|
+
await mkdir(dir, { mode: 0o700 }).catch((error) => {
|
|
100
|
+
if (error?.code !== "EEXIST")
|
|
101
|
+
throw error;
|
|
102
|
+
});
|
|
103
|
+
await checkedDirectory(dir, "media");
|
|
104
|
+
await assertContainedDirectory(root, platformDir, dir);
|
|
105
|
+
// Existing directories may predate private media storage. Tighten every component before writing secrets.
|
|
106
|
+
await Promise.all([chmod(root, 0o700), chmod(platformDir, 0o700), chmod(dir, 0o700)]);
|
|
107
|
+
return dir;
|
|
108
|
+
}
|
|
109
|
+
function abortReason(signal) {
|
|
110
|
+
if (signal.reason instanceof Error)
|
|
111
|
+
return signal.reason;
|
|
112
|
+
const error = new Error("inbound media download aborted");
|
|
113
|
+
error.name = "AbortError";
|
|
114
|
+
return error;
|
|
115
|
+
}
|
|
116
|
+
function throwIfAborted(signal) {
|
|
117
|
+
if (signal?.aborted)
|
|
118
|
+
throw abortReason(signal);
|
|
119
|
+
}
|
|
120
|
+
async function readWithSignal(reader, signal) {
|
|
121
|
+
throwIfAborted(signal);
|
|
122
|
+
if (!signal)
|
|
123
|
+
return reader.read();
|
|
124
|
+
return await new Promise((resolveRead, rejectRead) => {
|
|
125
|
+
const onAbort = () => rejectRead(abortReason(signal));
|
|
126
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
127
|
+
void reader.read().then(resolveRead, rejectRead).finally(() => signal.removeEventListener("abort", onAbort));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function cancelWithoutBlocking(reader, reason) {
|
|
131
|
+
void reader.cancel(reason).catch(() => undefined);
|
|
132
|
+
}
|
|
133
|
+
async function settleWithSignal(promise, signal) {
|
|
134
|
+
throwIfAborted(signal);
|
|
135
|
+
return await new Promise((resolveValue, rejectValue) => {
|
|
136
|
+
const onAbort = () => rejectValue(abortReason(signal));
|
|
137
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
138
|
+
void promise.then(resolveValue, rejectValue).finally(() => signal.removeEventListener("abort", onAbort));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function deadlineSignal(parent, timeoutMs = INBOUND_MEDIA_TIMEOUT_MS) {
|
|
142
|
+
const controller = new AbortController();
|
|
143
|
+
const timer = setTimeout(() => controller.abort(new Error(`inbound media download timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
144
|
+
timer.unref?.();
|
|
145
|
+
const onAbort = () => controller.abort(parent ? abortReason(parent) : undefined);
|
|
146
|
+
if (parent?.aborted)
|
|
147
|
+
onAbort();
|
|
148
|
+
else
|
|
149
|
+
parent?.addEventListener("abort", onAbort, { once: true });
|
|
150
|
+
return {
|
|
151
|
+
signal: controller.signal,
|
|
152
|
+
dispose: () => {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
parent?.removeEventListener("abort", onAbort);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function usage(map, platform) {
|
|
159
|
+
return map.get(platform) ?? { bytes: 0, files: 0 };
|
|
160
|
+
}
|
|
161
|
+
function adjustUsage(map, platform, bytes, files) {
|
|
162
|
+
const current = usage(map, platform);
|
|
163
|
+
const next = { bytes: current.bytes + bytes, files: current.files + files };
|
|
164
|
+
if (next.bytes > 0 || next.files > 0)
|
|
165
|
+
map.set(platform, next);
|
|
166
|
+
else
|
|
167
|
+
map.delete(platform);
|
|
168
|
+
}
|
|
169
|
+
function claimRetention(platform, requestedBytes) {
|
|
170
|
+
const retainedPlatform = usage(retainedByPlatform, platform);
|
|
171
|
+
const reservedPlatform = usage(reservedByPlatform, platform);
|
|
172
|
+
if (retainedFiles + reservedFiles >= INBOUND_MEDIA_MAX_RETAINED_FILES
|
|
173
|
+
|| retainedPlatform.files + reservedPlatform.files >= INBOUND_MEDIA_MAX_RETAINED_FILES_PER_PLATFORM)
|
|
174
|
+
return undefined;
|
|
175
|
+
const available = Math.min(requestedBytes, INBOUND_MEDIA_MAX_RETAINED_BYTES - retainedBytes - reservedBytes, INBOUND_MEDIA_MAX_RETAINED_BYTES_PER_PLATFORM - retainedPlatform.bytes - reservedPlatform.bytes);
|
|
176
|
+
if (available <= 0)
|
|
177
|
+
return undefined;
|
|
178
|
+
reservedBytes += available;
|
|
179
|
+
reservedFiles++;
|
|
180
|
+
adjustUsage(reservedByPlatform, platform, available, 1);
|
|
181
|
+
return { platform, bytes: available, active: true };
|
|
182
|
+
}
|
|
183
|
+
function releaseClaim(claim) {
|
|
184
|
+
if (!claim.active)
|
|
185
|
+
return;
|
|
186
|
+
claim.active = false;
|
|
187
|
+
reservedBytes -= claim.bytes;
|
|
188
|
+
reservedFiles--;
|
|
189
|
+
adjustUsage(reservedByPlatform, claim.platform, -claim.bytes, -1);
|
|
190
|
+
}
|
|
191
|
+
function retainClaimedPath(claim, path, bytes) {
|
|
192
|
+
if (!claim.active || bytes <= 0 || bytes > claim.bytes || retainedPaths.has(path))
|
|
193
|
+
return false;
|
|
194
|
+
releaseClaim(claim);
|
|
195
|
+
retainedPaths.set(path, { platform: claim.platform, bytes });
|
|
196
|
+
retainedBytes += bytes;
|
|
197
|
+
retainedFiles++;
|
|
198
|
+
adjustUsage(retainedByPlatform, claim.platform, bytes, 1);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
function releaseRetainedPath(path) {
|
|
202
|
+
const retained = retainedPaths.get(path);
|
|
203
|
+
if (!retained)
|
|
204
|
+
return;
|
|
205
|
+
retainedPaths.delete(path);
|
|
206
|
+
retainedBytes -= retained.bytes;
|
|
207
|
+
retainedFiles--;
|
|
208
|
+
adjustUsage(retainedByPlatform, retained.platform, -retained.bytes, -1);
|
|
209
|
+
}
|
|
210
|
+
/** Diagnostic visibility for tests and operators; paths themselves are deliberately not exposed. */
|
|
211
|
+
export function inboundMediaQuotaUsage() {
|
|
212
|
+
return { retainedBytes, retainedFiles, reservedBytes, reservedFiles };
|
|
213
|
+
}
|
|
214
|
+
/** Per-message budget plus process-wide admission control. Claims count attempts, not successes, so invalid
|
|
215
|
+
* attachment lists cannot generate unlimited network requests. Saturated slots fail closed instead of queueing. */
|
|
216
|
+
export class InboundMediaBudget {
|
|
217
|
+
parentSignal;
|
|
218
|
+
baseDir;
|
|
219
|
+
timeoutMs;
|
|
220
|
+
attempts = 0;
|
|
221
|
+
bytes = 0;
|
|
222
|
+
platform;
|
|
223
|
+
constructor(platform, parentSignal, baseDir, timeoutMs = INBOUND_MEDIA_TIMEOUT_MS) {
|
|
224
|
+
this.parentSignal = parentSignal;
|
|
225
|
+
this.baseDir = baseDir;
|
|
226
|
+
this.timeoutMs = timeoutMs;
|
|
227
|
+
this.platform = checkedPlatform(platform);
|
|
228
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
|
|
229
|
+
throw new TypeError("timeoutMs must be a positive safe integer");
|
|
230
|
+
}
|
|
231
|
+
get remainingBytes() {
|
|
232
|
+
return Math.max(0, INBOUND_MEDIA_MAX_BYTES - this.bytes);
|
|
233
|
+
}
|
|
234
|
+
get attemptedFiles() {
|
|
235
|
+
return this.attempts;
|
|
236
|
+
}
|
|
237
|
+
async download(save) {
|
|
238
|
+
if (this.attempts >= INBOUND_MEDIA_MAX_FILES || this.remainingBytes <= 0 || this.parentSignal?.aborted)
|
|
239
|
+
return null;
|
|
240
|
+
this.attempts++;
|
|
241
|
+
const platformActive = activeByPlatform.get(this.platform) ?? 0;
|
|
242
|
+
if (activeDownloads >= INBOUND_MEDIA_MAX_ACTIVE || platformActive >= INBOUND_MEDIA_MAX_ACTIVE_PER_PLATFORM)
|
|
243
|
+
return null;
|
|
244
|
+
const claim = claimRetention(this.platform, this.remainingBytes);
|
|
245
|
+
if (!claim)
|
|
246
|
+
return null;
|
|
247
|
+
activeDownloads++;
|
|
248
|
+
activeByPlatform.set(this.platform, platformActive + 1);
|
|
249
|
+
const deadline = deadlineSignal(this.parentSignal, this.timeoutMs);
|
|
250
|
+
let path = null;
|
|
251
|
+
let operationSettled = false;
|
|
252
|
+
let deferredRelease = false;
|
|
253
|
+
const releaseSlot = () => {
|
|
254
|
+
activeDownloads--;
|
|
255
|
+
const next = (activeByPlatform.get(this.platform) ?? 1) - 1;
|
|
256
|
+
if (next > 0)
|
|
257
|
+
activeByPlatform.set(this.platform, next);
|
|
258
|
+
else
|
|
259
|
+
activeByPlatform.delete(this.platform);
|
|
260
|
+
};
|
|
261
|
+
const operation = Promise.resolve()
|
|
262
|
+
.then(() => save({ maxBytes: claim.bytes, signal: deadline.signal }))
|
|
263
|
+
.finally(() => {
|
|
264
|
+
operationSettled = true;
|
|
265
|
+
});
|
|
266
|
+
try {
|
|
267
|
+
path = await settleWithSignal(operation, deadline.signal);
|
|
268
|
+
if (!path)
|
|
269
|
+
return null;
|
|
270
|
+
throwIfAborted(deadline.signal);
|
|
271
|
+
const owned = await ownedMediaPath(this.platform, path, this.baseDir);
|
|
272
|
+
if (!owned) {
|
|
273
|
+
await cleanupTransientMedia(this.platform, [path], this.baseDir);
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const info = await lstat(owned);
|
|
277
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size <= 0 || info.size > claim.bytes) {
|
|
278
|
+
await cleanupTransientMedia(this.platform, [path], this.baseDir);
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
if (!retainClaimedPath(claim, owned, info.size)) {
|
|
282
|
+
await cleanupTransientMedia(this.platform, [owned], this.baseDir);
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
this.bytes += info.size;
|
|
286
|
+
return owned;
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
if (!operationSettled) {
|
|
290
|
+
// Fetch/stream implementations that honor AbortSignal normally settle immediately; give them a short
|
|
291
|
+
// grace window so callers observe an already-removed partial file. SDKs that ignore abort keep both their
|
|
292
|
+
// active and retained reservation until they really settle, bounding abandoned work.
|
|
293
|
+
const grace = await Promise.race([
|
|
294
|
+
operation.then((latePath) => ({ settled: true, latePath }), () => ({ settled: true, latePath: null })),
|
|
295
|
+
new Promise((resolveGrace) => setTimeout(() => resolveGrace({ settled: false }), 250)),
|
|
296
|
+
]);
|
|
297
|
+
if (grace.settled) {
|
|
298
|
+
if (grace.latePath)
|
|
299
|
+
await cleanupTransientMedia(this.platform, [grace.latePath], this.baseDir);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
deferredRelease = true;
|
|
303
|
+
void operation
|
|
304
|
+
.then((latePath) => latePath ? cleanupTransientMedia(this.platform, [latePath], this.baseDir) : undefined)
|
|
305
|
+
.catch(() => undefined)
|
|
306
|
+
.finally(() => {
|
|
307
|
+
releaseClaim(claim);
|
|
308
|
+
releaseSlot();
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (path)
|
|
313
|
+
await cleanupTransientMedia(this.platform, [path], this.baseDir);
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
deadline.dispose();
|
|
318
|
+
if (!deferredRelease) {
|
|
319
|
+
releaseClaim(claim);
|
|
320
|
+
releaseSlot();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function sourceReader(source) {
|
|
326
|
+
if (source && typeof source.getReader === "function") {
|
|
327
|
+
const reader = source.getReader();
|
|
328
|
+
return {
|
|
329
|
+
read: () => reader.read(),
|
|
330
|
+
cancel: async (reason) => {
|
|
331
|
+
await reader.cancel(reason).catch(() => undefined);
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (source && typeof source[Symbol.asyncIterator] === "function") {
|
|
336
|
+
const iterable = source;
|
|
337
|
+
const iterator = iterable[Symbol.asyncIterator]();
|
|
338
|
+
return {
|
|
339
|
+
read: async () => {
|
|
340
|
+
const result = await iterator.next();
|
|
341
|
+
return { done: result.done === true, value: result.value };
|
|
342
|
+
},
|
|
343
|
+
cancel: async (reason) => {
|
|
344
|
+
const error = reason instanceof Error ? reason : undefined;
|
|
345
|
+
try {
|
|
346
|
+
iterable.destroy?.(error);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
/* cleanup must continue even if a custom stream throws from destroy */
|
|
350
|
+
}
|
|
351
|
+
await iterator.return?.().catch(() => undefined);
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
throw new TypeError("media response has no readable stream");
|
|
356
|
+
}
|
|
357
|
+
function asBytes(value) {
|
|
358
|
+
if (value instanceof Uint8Array)
|
|
359
|
+
return value;
|
|
360
|
+
if (typeof value === "string")
|
|
361
|
+
return Buffer.from(value);
|
|
362
|
+
throw new TypeError("media stream emitted a non-byte chunk");
|
|
363
|
+
}
|
|
364
|
+
async function writeFully(handle, bytes) {
|
|
365
|
+
let offset = 0;
|
|
366
|
+
while (offset < bytes.byteLength) {
|
|
367
|
+
const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset);
|
|
368
|
+
if (bytesWritten <= 0)
|
|
369
|
+
throw new Error("failed to write inbound media");
|
|
370
|
+
offset += bytesWritten;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Stream an attachment to a same-directory private temporary file, enforce the hard cap while reading, then
|
|
375
|
+
* atomically publish it. Callers only ever observe complete files; failures leave neither partial nor final data.
|
|
376
|
+
*/
|
|
377
|
+
export async function savePrivateMedia(source, options) {
|
|
378
|
+
const limit = checkedLimit(options.maxBytes);
|
|
379
|
+
throwIfAborted(options.signal);
|
|
380
|
+
const dir = await ensurePrivateMediaDir(options.platform, options.baseDir);
|
|
381
|
+
const extension = safeMediaExtension(options.filenameHint, options.contentType);
|
|
382
|
+
const id = randomUUID();
|
|
383
|
+
const temporaryPath = join(dir, `.${id}.part`);
|
|
384
|
+
const finalPath = join(dir, `${id}${extension}`);
|
|
385
|
+
const reader = sourceReader(source);
|
|
386
|
+
let handle;
|
|
387
|
+
let published = false;
|
|
388
|
+
let total = 0;
|
|
389
|
+
const onAbort = () => cancelWithoutBlocking(reader, options.signal ? abortReason(options.signal) : undefined);
|
|
390
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
391
|
+
try {
|
|
392
|
+
handle = await open(temporaryPath, "wx", 0o600);
|
|
393
|
+
while (true) {
|
|
394
|
+
const chunk = await readWithSignal(reader, options.signal);
|
|
395
|
+
if (chunk.done)
|
|
396
|
+
break;
|
|
397
|
+
const bytes = asBytes(chunk.value);
|
|
398
|
+
if (bytes.byteLength > limit - total) {
|
|
399
|
+
const error = new MediaSizeLimitError(limit);
|
|
400
|
+
cancelWithoutBlocking(reader, error);
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
await writeFully(handle, bytes);
|
|
404
|
+
total += bytes.byteLength;
|
|
405
|
+
}
|
|
406
|
+
throwIfAborted(options.signal);
|
|
407
|
+
if (total === 0)
|
|
408
|
+
throw new Error("inbound media is empty");
|
|
409
|
+
await handle.sync();
|
|
410
|
+
await handle.close();
|
|
411
|
+
handle = undefined;
|
|
412
|
+
await rename(temporaryPath, finalPath);
|
|
413
|
+
published = true;
|
|
414
|
+
await chmod(finalPath, 0o600);
|
|
415
|
+
// Best-effort directory fsync makes the atomic rename durable without making unsupported filesystems fail.
|
|
416
|
+
try {
|
|
417
|
+
const directory = await open(dir, "r");
|
|
418
|
+
try {
|
|
419
|
+
await directory.sync();
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
await directory.close();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
/* durability enhancement only */
|
|
427
|
+
}
|
|
428
|
+
return finalPath;
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
if (handle)
|
|
432
|
+
await handle.close().catch(() => undefined);
|
|
433
|
+
await unlink(published ? finalPath : temporaryPath).catch(() => undefined);
|
|
434
|
+
cancelWithoutBlocking(reader, error);
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function declaredLength(response) {
|
|
442
|
+
const raw = response.headers.get("content-length")?.trim();
|
|
443
|
+
if (!raw || !/^\d+$/.test(raw))
|
|
444
|
+
return undefined;
|
|
445
|
+
const length = Number(raw);
|
|
446
|
+
return Number.isSafeInteger(length) ? length : undefined;
|
|
447
|
+
}
|
|
448
|
+
/** Save a fetch response without ever materializing the whole response in memory. */
|
|
449
|
+
export async function savePrivateResponse(response, options) {
|
|
450
|
+
if (!response.ok)
|
|
451
|
+
throw new Error(`media download HTTP ${response.status}`);
|
|
452
|
+
if (!response.body)
|
|
453
|
+
throw new Error("media response has no body");
|
|
454
|
+
const limit = checkedLimit(options.maxBytes);
|
|
455
|
+
const length = declaredLength(response);
|
|
456
|
+
if (length !== undefined && length > limit) {
|
|
457
|
+
void response.body.cancel(new MediaSizeLimitError(limit)).catch(() => undefined);
|
|
458
|
+
throw new MediaSizeLimitError(limit);
|
|
459
|
+
}
|
|
460
|
+
return savePrivateMedia(response.body, {
|
|
461
|
+
...options,
|
|
462
|
+
maxBytes: limit,
|
|
463
|
+
contentType: options.contentType ?? response.headers.get("content-type"),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
/** Read a response into a bounded buffer for protocols that must decrypt the complete ciphertext. */
|
|
467
|
+
export async function readResponseBytesLimited(response, maxBytes = INBOUND_MEDIA_MAX_BYTES, signal) {
|
|
468
|
+
if (!response.ok)
|
|
469
|
+
throw new Error(`media download HTTP ${response.status}`);
|
|
470
|
+
if (!response.body)
|
|
471
|
+
throw new Error("media response has no body");
|
|
472
|
+
const limit = checkedLimit(maxBytes);
|
|
473
|
+
const length = declaredLength(response);
|
|
474
|
+
if (length !== undefined && length > limit) {
|
|
475
|
+
void response.body.cancel(new MediaSizeLimitError(limit)).catch(() => undefined);
|
|
476
|
+
throw new MediaSizeLimitError(limit);
|
|
477
|
+
}
|
|
478
|
+
const reader = sourceReader(response.body);
|
|
479
|
+
const chunks = [];
|
|
480
|
+
let total = 0;
|
|
481
|
+
const onAbort = () => cancelWithoutBlocking(reader, signal ? abortReason(signal) : undefined);
|
|
482
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
483
|
+
try {
|
|
484
|
+
while (true) {
|
|
485
|
+
const chunk = await readWithSignal(reader, signal);
|
|
486
|
+
if (chunk.done)
|
|
487
|
+
break;
|
|
488
|
+
const bytes = asBytes(chunk.value);
|
|
489
|
+
if (bytes.byteLength > limit - total) {
|
|
490
|
+
const error = new MediaSizeLimitError(limit);
|
|
491
|
+
cancelWithoutBlocking(reader, error);
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
chunks.push(Buffer.from(bytes));
|
|
495
|
+
total += bytes.byteLength;
|
|
496
|
+
}
|
|
497
|
+
if (total === 0)
|
|
498
|
+
throw new Error("inbound media is empty");
|
|
499
|
+
return Buffer.concat(chunks, total);
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
cancelWithoutBlocking(reader, error);
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
finally {
|
|
506
|
+
signal?.removeEventListener("abort", onAbort);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/** Decode protocol-mandated base64 only after bounding its encoded and decoded size. */
|
|
510
|
+
export function decodeBase64Media(value, maxBytes = INBOUND_MEDIA_MAX_BYTES) {
|
|
511
|
+
const limit = checkedLimit(maxBytes);
|
|
512
|
+
const raw = String(value ?? "").trim().replace(/^data:[^,]*,/, "");
|
|
513
|
+
// Check before whitespace compaction so a whitespace bomb cannot create a second huge allocation.
|
|
514
|
+
if (raw.length > Math.ceil(limit / 3) * 4 + 4096)
|
|
515
|
+
throw new MediaSizeLimitError(limit);
|
|
516
|
+
const compact = raw.replace(/\s+/g, "");
|
|
517
|
+
if (!compact || compact.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) {
|
|
518
|
+
throw new Error("invalid base64 media");
|
|
519
|
+
}
|
|
520
|
+
const padding = compact.endsWith("==") ? 2 : compact.endsWith("=") ? 1 : 0;
|
|
521
|
+
const estimated = Math.floor((compact.length * 3) / 4) - padding;
|
|
522
|
+
if (estimated > limit)
|
|
523
|
+
throw new MediaSizeLimitError(limit);
|
|
524
|
+
const decoded = Buffer.from(compact, "base64");
|
|
525
|
+
if (!decoded.length)
|
|
526
|
+
throw new Error("inbound media is empty");
|
|
527
|
+
if (decoded.length > limit)
|
|
528
|
+
throw new MediaSizeLimitError(limit);
|
|
529
|
+
return decoded;
|
|
530
|
+
}
|
|
531
|
+
export async function savePrivateMediaBytes(bytes, options) {
|
|
532
|
+
const limit = checkedLimit(options.maxBytes);
|
|
533
|
+
if (bytes.byteLength > limit)
|
|
534
|
+
throw new MediaSizeLimitError(limit);
|
|
535
|
+
async function* oneChunk() {
|
|
536
|
+
yield bytes;
|
|
537
|
+
}
|
|
538
|
+
return savePrivateMedia(oneChunk(), { ...options, maxBytes: limit });
|
|
539
|
+
}
|
|
540
|
+
function mediaDirectory(platform, baseDir = join(homedir(), ".hara")) {
|
|
541
|
+
return join(resolve(baseDir), checkedPlatform(platform), "media");
|
|
542
|
+
}
|
|
543
|
+
async function existingPrivateMediaDir(platform, baseDir) {
|
|
544
|
+
const root = resolve(baseDir ?? join(homedir(), ".hara"));
|
|
545
|
+
const platformDir = join(root, checkedPlatform(platform));
|
|
546
|
+
const dir = join(platformDir, "media");
|
|
547
|
+
try {
|
|
548
|
+
await checkedDirectory(root, "media root");
|
|
549
|
+
await checkedDirectory(platformDir, "platform media");
|
|
550
|
+
await checkedDirectory(dir, "media");
|
|
551
|
+
await assertContainedDirectory(root, platformDir, dir);
|
|
552
|
+
return dir;
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
if (error?.code === "ENOENT")
|
|
556
|
+
return undefined;
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
async function ownedMediaPath(platform, candidate, baseDir) {
|
|
561
|
+
if (typeof candidate !== "string" || !candidate)
|
|
562
|
+
return undefined;
|
|
563
|
+
const dir = await existingPrivateMediaDir(platform, baseDir);
|
|
564
|
+
if (!dir)
|
|
565
|
+
return undefined;
|
|
566
|
+
const path = resolve(candidate);
|
|
567
|
+
if (dirname(path) !== dir || !OWNED_MEDIA_NAME.test(basename(path)))
|
|
568
|
+
return undefined;
|
|
569
|
+
// Re-check the candidate's parent rather than trusting lexical containment through an ancestor symlink.
|
|
570
|
+
if ((await realpath(dirname(path))) !== (await realpath(dir)))
|
|
571
|
+
return undefined;
|
|
572
|
+
return path;
|
|
573
|
+
}
|
|
574
|
+
function isLegacyMediaName(platform, name) {
|
|
575
|
+
return LEGACY_MEDIA_NAME[checkedPlatform(platform)]?.test(name) ?? false;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Remove only files created by this module for the current inbound event. `transientFiles` is part of the
|
|
579
|
+
* adapter boundary and may be supplied by a third-party adapter, so arbitrary paths and symlinks fail closed.
|
|
580
|
+
*/
|
|
581
|
+
export async function cleanupTransientMedia(platform, paths, baseDir) {
|
|
582
|
+
let removed = 0;
|
|
583
|
+
for (const candidate of new Set(paths ?? [])) {
|
|
584
|
+
let path;
|
|
585
|
+
try {
|
|
586
|
+
path = await ownedMediaPath(platform, candidate, baseDir);
|
|
587
|
+
if (!path) {
|
|
588
|
+
const trackedPath = resolve(String(candidate));
|
|
589
|
+
if (retainedPaths.get(trackedPath)?.platform === checkedPlatform(platform)) {
|
|
590
|
+
try {
|
|
591
|
+
await lstat(trackedPath);
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
if (error?.code === "ENOENT")
|
|
595
|
+
releaseRetainedPath(trackedPath);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const info = await lstat(path);
|
|
601
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
602
|
+
releaseRetainedPath(path);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
await unlink(path);
|
|
606
|
+
releaseRetainedPath(path);
|
|
607
|
+
removed++;
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
if (path && error?.code === "ENOENT")
|
|
611
|
+
releaseRetainedPath(path);
|
|
612
|
+
// Cleanup is deliberately per-file best effort: one hostile/broken entry must not strand the rest.
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return removed;
|
|
616
|
+
}
|
|
617
|
+
/** Best-effort startup janitor for media left behind by a crash or hard kill. */
|
|
618
|
+
export async function pruneStaleMedia(platform, options = {}) {
|
|
619
|
+
const dir = mediaDirectory(platform, options.baseDir);
|
|
620
|
+
const maxAgeMs = options.maxAgeMs ?? INBOUND_MEDIA_TTL_MS;
|
|
621
|
+
if (!Number.isSafeInteger(maxAgeMs) || maxAgeMs < 0)
|
|
622
|
+
throw new TypeError("maxAgeMs must be a non-negative safe integer");
|
|
623
|
+
let names;
|
|
624
|
+
try {
|
|
625
|
+
const checked = await existingPrivateMediaDir(platform, options.baseDir);
|
|
626
|
+
if (!checked)
|
|
627
|
+
return 0;
|
|
628
|
+
await chmod(checked, 0o700);
|
|
629
|
+
names = await readdir(dir);
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
if (error?.code === "ENOENT")
|
|
633
|
+
return 0;
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
const cutoff = (options.now ?? Date.now()) - maxAgeMs;
|
|
637
|
+
let removed = 0;
|
|
638
|
+
for (const name of names) {
|
|
639
|
+
try {
|
|
640
|
+
let path;
|
|
641
|
+
if (OWNED_MEDIA_NAME.test(name))
|
|
642
|
+
path = await ownedMediaPath(platform, join(dir, name), options.baseDir);
|
|
643
|
+
else if (isLegacyMediaName(platform, name))
|
|
644
|
+
path = join(dir, name);
|
|
645
|
+
if (!path)
|
|
646
|
+
continue;
|
|
647
|
+
const info = await lstat(path);
|
|
648
|
+
if (!info.isFile() || info.isSymbolicLink() || info.mtimeMs > cutoff)
|
|
649
|
+
continue;
|
|
650
|
+
await unlink(path);
|
|
651
|
+
releaseRetainedPath(path);
|
|
652
|
+
removed++;
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
// Continue pruning even if a concurrently replaced or unreadable entry fails.
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return removed;
|
|
659
|
+
}
|