@cjhyy/code-shell-server 0.9.10 → 0.9.12
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/dist/index.panels.d.ts +8 -0
- package/dist/index.panels.js +8 -0
- package/dist/panels/bridge-contract.d.ts +72 -0
- package/dist/panels/bridge-contract.js +92 -0
- package/dist/panels/connections.d.ts +31 -0
- package/dist/panels/connections.js +118 -0
- package/dist/panels/process-entry.d.ts +2 -0
- package/dist/panels/process-entry.js +52 -0
- package/dist/panels/process-service.d.ts +61 -0
- package/dist/panels/process-service.js +367 -33
- package/dist/panels/process-state.d.ts +58 -0
- package/dist/panels/process-state.js +87 -0
- package/dist/panels/resources/directories.d.ts +12 -0
- package/dist/panels/resources/directories.js +89 -0
- package/dist/panels/resources/library.d.ts +63 -0
- package/dist/panels/resources/library.js +423 -0
- package/dist/panels/resources/service.d.ts +45 -0
- package/dist/panels/resources/service.js +317 -0
- package/dist/panels/resources/storage.d.ts +10 -0
- package/dist/panels/resources/storage.js +102 -0
- package/dist/panels/resources/types.d.ts +16 -0
- package/dist/panels/resources/types.js +1 -0
- package/dist/panels/resources/uploads.d.ts +64 -0
- package/dist/panels/resources/uploads.js +527 -0
- package/dist/panels/runtime.d.ts +1 -1
- package/dist/panels/runtime.js +137 -15
- package/dist/panels/tool-executor.d.ts +58 -0
- package/dist/panels/tool-executor.js +421 -0
- package/dist/panels/tool-jobs-storage.d.ts +16 -0
- package/dist/panels/tool-jobs-storage.js +169 -0
- package/dist/panels/tool-jobs.d.ts +103 -0
- package/dist/panels/tool-jobs.js +530 -0
- package/package.json +5 -5
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export const processLimits = Object.freeze({
|
|
2
|
+
maxArguments: 256,
|
|
3
|
+
maxArgumentLength: 8_192,
|
|
4
|
+
maxArgumentBytes: 64 * 1024,
|
|
5
|
+
maxConcurrentProcesses: 3,
|
|
6
|
+
maxFileArguments: 8,
|
|
7
|
+
maxEventChars: 16_384,
|
|
8
|
+
maxOutputBytes: 4 * 1024 * 1024,
|
|
9
|
+
maxLifetimeMs: 24 * 60 * 60 * 1_000,
|
|
10
|
+
receiptTtlMs: 5 * 60 * 1_000,
|
|
11
|
+
maxRetainedProcesses: 128,
|
|
12
|
+
maxRetainedProcessesPerGuest: 32,
|
|
13
|
+
maxEventsPerProcess: 256,
|
|
14
|
+
maxRetainedOutputBytes: 256 * 1024,
|
|
15
|
+
maxStdinChunkBytes: 16 * 1024,
|
|
16
|
+
maxStdinBytes: 2 * 1024 * 1024 + 8 * 1024,
|
|
17
|
+
maxStdinPendingBytes: 64 * 1024,
|
|
18
|
+
maxStdinPendingWrites: 4,
|
|
19
|
+
stdinWriteTimeoutMs: 10_000,
|
|
20
|
+
maxEntryBytes: 16 * 1024 * 1024,
|
|
21
|
+
maxEntryHandlesPerGuest: 64,
|
|
22
|
+
});
|
|
23
|
+
export function sameProcessOwner(a, b) {
|
|
24
|
+
return a.guestId === b.guestId && a.appId === b.appId && a.revision === b.revision;
|
|
25
|
+
}
|
|
26
|
+
/** Only terminal receipts are retained here; live process admission has its own limit. */
|
|
27
|
+
export class ProcessReceipts {
|
|
28
|
+
now;
|
|
29
|
+
records = new Map();
|
|
30
|
+
timer;
|
|
31
|
+
constructor(now = Date.now) {
|
|
32
|
+
this.now = now;
|
|
33
|
+
}
|
|
34
|
+
get(id) {
|
|
35
|
+
this.prune();
|
|
36
|
+
return this.records.get(id);
|
|
37
|
+
}
|
|
38
|
+
add(record) {
|
|
39
|
+
this.prune();
|
|
40
|
+
record.expiresAt = this.now() + processLimits.receiptTtlMs;
|
|
41
|
+
this.records.set(record.processId, record);
|
|
42
|
+
const owned = [...this.records.values()].filter((item) => item.owner.guestId === record.owner.guestId);
|
|
43
|
+
while (owned.length > processLimits.maxRetainedProcessesPerGuest)
|
|
44
|
+
this.records.delete(owned.shift().processId);
|
|
45
|
+
while (this.records.size > processLimits.maxRetainedProcesses)
|
|
46
|
+
this.records.delete(this.records.keys().next().value);
|
|
47
|
+
this.arm();
|
|
48
|
+
}
|
|
49
|
+
revokeGuest(guestId) {
|
|
50
|
+
for (const [id, record] of this.records)
|
|
51
|
+
if (record.owner.guestId === guestId)
|
|
52
|
+
this.records.delete(id);
|
|
53
|
+
this.arm();
|
|
54
|
+
}
|
|
55
|
+
close() {
|
|
56
|
+
clearTimeout(this.timer);
|
|
57
|
+
this.timer = undefined;
|
|
58
|
+
this.records.clear();
|
|
59
|
+
}
|
|
60
|
+
prune() {
|
|
61
|
+
for (const [id, record] of this.records)
|
|
62
|
+
if (record.expiresAt <= this.now())
|
|
63
|
+
this.records.delete(id);
|
|
64
|
+
}
|
|
65
|
+
arm() {
|
|
66
|
+
clearTimeout(this.timer);
|
|
67
|
+
const first = this.records.values().next().value;
|
|
68
|
+
if (!first)
|
|
69
|
+
return;
|
|
70
|
+
this.timer = setTimeout(() => {
|
|
71
|
+
this.prune();
|
|
72
|
+
this.arm();
|
|
73
|
+
}, Math.max(1, first.expiresAt - this.now()));
|
|
74
|
+
this.timer.unref();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function appendProcessEvent(record, event, payload) {
|
|
78
|
+
const sequence = ++record.sequence;
|
|
79
|
+
const item = { event, sequence, payload: { ...payload, sequence } };
|
|
80
|
+
record.events.push(item);
|
|
81
|
+
record.eventBytes += Buffer.byteLength(JSON.stringify(item));
|
|
82
|
+
while (record.events.length > processLimits.maxEventsPerProcess ||
|
|
83
|
+
record.eventBytes > processLimits.maxRetainedOutputBytes) {
|
|
84
|
+
record.eventBytes -= Buffer.byteLength(JSON.stringify(record.events.shift()));
|
|
85
|
+
}
|
|
86
|
+
return item;
|
|
87
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Stats } from "node:fs";
|
|
2
|
+
export declare function resourceRelativePath(value: unknown): string;
|
|
3
|
+
export declare function sameResourceIdentity(a: Pick<Stats, "dev" | "ino">, b: Pick<Stats, "dev" | "ino">): boolean;
|
|
4
|
+
/** Hold every ancestor and verify its name still refers to that exact directory. */
|
|
5
|
+
export declare function openResourceDirectory(root: string, path: string, create: boolean, authorize: () => Promise<void>): Promise<{
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
location: (file: string) => string;
|
|
9
|
+
rootIdentity: Stats;
|
|
10
|
+
verify: () => Promise<void>;
|
|
11
|
+
close: () => Promise<void>;
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, realpath } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
export function resourceRelativePath(value) {
|
|
5
|
+
if (typeof value !== "string" ||
|
|
6
|
+
!value ||
|
|
7
|
+
value.length > 1024 ||
|
|
8
|
+
value.split("/").length > 16 ||
|
|
9
|
+
value
|
|
10
|
+
.split("/")
|
|
11
|
+
.some((part) => !part ||
|
|
12
|
+
part === "." ||
|
|
13
|
+
part === ".." ||
|
|
14
|
+
part.length > 240 ||
|
|
15
|
+
/[\\\u0000-\u001f\u007f:]/.test(part) ||
|
|
16
|
+
/[. ]$/.test(part) ||
|
|
17
|
+
/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(part)))
|
|
18
|
+
throw new Error("Invalid relative resource path");
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
export function sameResourceIdentity(a, b) {
|
|
22
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
23
|
+
}
|
|
24
|
+
/** Hold every ancestor and verify its name still refers to that exact directory. */
|
|
25
|
+
export async function openResourceDirectory(root, path, create, authorize) {
|
|
26
|
+
if (!isAbsolute(root) || root.includes("\0"))
|
|
27
|
+
throw new Error("Invalid resource directory grant");
|
|
28
|
+
resourceRelativePath(path);
|
|
29
|
+
const parts = path.split("/");
|
|
30
|
+
const name = parts.pop();
|
|
31
|
+
const held = [];
|
|
32
|
+
const close = async () => {
|
|
33
|
+
await Promise.all(held.map((entry) => entry.handle.close().catch(() => { })));
|
|
34
|
+
};
|
|
35
|
+
const verify = async () => {
|
|
36
|
+
await authorize();
|
|
37
|
+
for (const entry of held) {
|
|
38
|
+
const current = await lstat(entry.path);
|
|
39
|
+
if (!current.isDirectory() ||
|
|
40
|
+
current.isSymbolicLink() ||
|
|
41
|
+
!sameResourceIdentity(current, entry.identity))
|
|
42
|
+
throw new Error("Resource directory changed while it was in use");
|
|
43
|
+
}
|
|
44
|
+
await authorize();
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
await authorize();
|
|
48
|
+
const original = await lstat(root);
|
|
49
|
+
if (!original.isDirectory() || original.isSymbolicLink())
|
|
50
|
+
throw new Error("Resource directory must not be a symlink");
|
|
51
|
+
let current = await realpath(root);
|
|
52
|
+
for (let i = -1; i < parts.length; i++) {
|
|
53
|
+
if (i >= 0) {
|
|
54
|
+
await verify();
|
|
55
|
+
current = join(current, parts[i]);
|
|
56
|
+
}
|
|
57
|
+
const parent = held.at(-1);
|
|
58
|
+
const location = parent && process.platform === "linux"
|
|
59
|
+
? `/proc/self/fd/${parent.handle.fd}/${parts[i]}`
|
|
60
|
+
: current;
|
|
61
|
+
if (i >= 0 && create)
|
|
62
|
+
await mkdir(location, { mode: 0o700 }).catch((error) => {
|
|
63
|
+
if (error.code !== "EEXIST")
|
|
64
|
+
throw error;
|
|
65
|
+
});
|
|
66
|
+
const handle = await open(location, constants.O_RDONLY |
|
|
67
|
+
(constants.O_DIRECTORY ?? 0) |
|
|
68
|
+
(constants.O_NOFOLLOW ?? 0) |
|
|
69
|
+
(constants.O_NONBLOCK ?? 0));
|
|
70
|
+
const identity = await handle.stat().catch(async (error) => {
|
|
71
|
+
await handle.close();
|
|
72
|
+
throw error;
|
|
73
|
+
});
|
|
74
|
+
held.push({ path: current, handle, identity });
|
|
75
|
+
if (!identity.isDirectory() || (i === -1 && !sameResourceIdentity(identity, original)))
|
|
76
|
+
throw new Error("Resource directory changed while it was opened");
|
|
77
|
+
}
|
|
78
|
+
await verify();
|
|
79
|
+
const parent = held.at(-1);
|
|
80
|
+
const location = (file) => process.platform === "linux"
|
|
81
|
+
? `/proc/self/fd/${parent.handle.fd}/${file}`
|
|
82
|
+
: join(parent.path, file);
|
|
83
|
+
return { name, path: location(name), location, rootIdentity: held[0].identity, verify, close };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
await close();
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
import type { MediaAsset, MediaScope } from "./types.js";
|
|
3
|
+
export interface MediaLibraryOptions {
|
|
4
|
+
rootDirectory: string;
|
|
5
|
+
maxFileBytes?: number;
|
|
6
|
+
now?: () => number;
|
|
7
|
+
isScopeAuthorized?: (scope: MediaScope) => boolean | Promise<boolean>;
|
|
8
|
+
}
|
|
9
|
+
export interface MediaImportOptions {
|
|
10
|
+
name?: string;
|
|
11
|
+
mimeType?: string;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
onProgress?: (copiedBytes: number, totalBytes: number) => void;
|
|
14
|
+
/** Identity captured by Host selection, before a job can queue or survive restart. */
|
|
15
|
+
expectedSource?: MediaSourceIdentity;
|
|
16
|
+
expectedBytes?: number;
|
|
17
|
+
expectedSha256?: string;
|
|
18
|
+
/** Host grant/ancestor identity checks during copy and before publication. */
|
|
19
|
+
assertAuthorized?: () => void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export interface MediaSourceIdentity {
|
|
22
|
+
dev: number;
|
|
23
|
+
ino: number;
|
|
24
|
+
size: number;
|
|
25
|
+
mtimeMs: number;
|
|
26
|
+
ctimeMs: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function mediaSourceIdentity(info: MediaSourceIdentity): MediaSourceIdentity;
|
|
29
|
+
export interface MediaReadResult {
|
|
30
|
+
status: 200 | 206 | 405 | 416;
|
|
31
|
+
headers: Record<string, string>;
|
|
32
|
+
body: Readable | null;
|
|
33
|
+
}
|
|
34
|
+
export declare function resourceName(value: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Host-owned immutable, scope-local media. importFile accepts only paths already
|
|
37
|
+
* authorized by the caller (for example native file selection); guest APIs must
|
|
38
|
+
* never pass unchecked paths through this internal method.
|
|
39
|
+
*/
|
|
40
|
+
export declare class ResourceLibrary {
|
|
41
|
+
private readonly root;
|
|
42
|
+
private readonly maxFileBytes;
|
|
43
|
+
private readonly now;
|
|
44
|
+
private readonly isScopeAuthorized?;
|
|
45
|
+
private readonly writes;
|
|
46
|
+
constructor(options: MediaLibraryOptions);
|
|
47
|
+
private authorize;
|
|
48
|
+
private directory;
|
|
49
|
+
importFile(scope: MediaScope, sourcePath: string, options?: MediaImportOptions): Promise<MediaAsset>;
|
|
50
|
+
get(scope: MediaScope, id: string): Promise<MediaAsset>;
|
|
51
|
+
list(scope: MediaScope): Promise<MediaAsset[]>;
|
|
52
|
+
/** Host-only path resolution for registered processors, not a renderer DTO. */
|
|
53
|
+
resolvePath(scope: MediaScope, id: string): Promise<string>;
|
|
54
|
+
/** Revalidate bytes before reuse/hand-off rather than trusting metadata alone. */
|
|
55
|
+
verifyContent(scope: MediaScope, id: string, check?: () => void | Promise<void>): Promise<void>;
|
|
56
|
+
openRead(scope: MediaScope, id: string, options?: {
|
|
57
|
+
range?: string;
|
|
58
|
+
method?: string;
|
|
59
|
+
}): Promise<MediaReadResult>;
|
|
60
|
+
}
|
|
61
|
+
export { ResourceLibrary as MediaLibrary };
|
|
62
|
+
export type ResourceLibraryOptions = MediaLibraryOptions;
|
|
63
|
+
export type ResourceImportOptions = MediaImportOptions;
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, readdir, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { basename, extname, join } from "node:path";
|
|
5
|
+
import { mediaDirectory, mediaScopeKey, readMediaJson, writeMediaJson } from "./storage.js";
|
|
6
|
+
const ASSET_ID = /^asset-[a-f0-9]{64}$/;
|
|
7
|
+
const MIME_BY_EXTENSION = {
|
|
8
|
+
".mp4": "video/mp4",
|
|
9
|
+
".mov": "video/quicktime",
|
|
10
|
+
".m4v": "video/mp4",
|
|
11
|
+
".webm": "video/webm",
|
|
12
|
+
".mkv": "video/x-matroska",
|
|
13
|
+
".avi": "video/x-msvideo",
|
|
14
|
+
".mp3": "audio/mpeg",
|
|
15
|
+
".wav": "audio/wav",
|
|
16
|
+
".m4a": "audio/mp4",
|
|
17
|
+
".aac": "audio/aac",
|
|
18
|
+
".flac": "audio/flac",
|
|
19
|
+
".ogg": "audio/ogg",
|
|
20
|
+
".png": "image/png",
|
|
21
|
+
".jpg": "image/jpeg",
|
|
22
|
+
".jpeg": "image/jpeg",
|
|
23
|
+
".webp": "image/webp",
|
|
24
|
+
".gif": "image/gif",
|
|
25
|
+
".avif": "image/avif",
|
|
26
|
+
".json": "application/json",
|
|
27
|
+
".srt": "application/x-subrip",
|
|
28
|
+
".opus": "audio/ogg",
|
|
29
|
+
".bmp": "image/bmp",
|
|
30
|
+
".tiff": "image/tiff",
|
|
31
|
+
".vtt": "text/vtt",
|
|
32
|
+
".pdf": "application/pdf",
|
|
33
|
+
".doc": "application/msword",
|
|
34
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
35
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
36
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
37
|
+
".odt": "application/vnd.oasis.opendocument.text",
|
|
38
|
+
".txt": "text/plain",
|
|
39
|
+
".md": "text/markdown",
|
|
40
|
+
".csv": "text/csv",
|
|
41
|
+
".tsv": "text/tab-separated-values",
|
|
42
|
+
".woff": "font/woff",
|
|
43
|
+
".woff2": "font/woff2",
|
|
44
|
+
".ttf": "font/ttf",
|
|
45
|
+
".otf": "font/otf",
|
|
46
|
+
".zip": "application/zip",
|
|
47
|
+
};
|
|
48
|
+
const SAFE_MIME_TYPES = new Set([
|
|
49
|
+
...Object.values(MIME_BY_EXTENSION),
|
|
50
|
+
"audio/webm",
|
|
51
|
+
"application/octet-stream",
|
|
52
|
+
]);
|
|
53
|
+
export function mediaSourceIdentity(info) {
|
|
54
|
+
return {
|
|
55
|
+
dev: info.dev,
|
|
56
|
+
ino: info.ino,
|
|
57
|
+
size: info.size,
|
|
58
|
+
mtimeMs: info.mtimeMs,
|
|
59
|
+
ctimeMs: info.ctimeMs,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function resourceName(value) {
|
|
63
|
+
return (basename(value)
|
|
64
|
+
.replace(/[\x00-\x1f\x7f]/g, "_")
|
|
65
|
+
.slice(0, 240) || "media");
|
|
66
|
+
}
|
|
67
|
+
function assetRecord(raw, id) {
|
|
68
|
+
const value = raw;
|
|
69
|
+
if (!value ||
|
|
70
|
+
value.id !== id ||
|
|
71
|
+
!ASSET_ID.test(id) ||
|
|
72
|
+
value.sha256 !== id.slice(6) ||
|
|
73
|
+
typeof value.name !== "string" ||
|
|
74
|
+
value.name !== resourceName(value.name) ||
|
|
75
|
+
!SAFE_MIME_TYPES.has(value.mimeType) ||
|
|
76
|
+
!Number.isSafeInteger(value.bytes) ||
|
|
77
|
+
value.bytes <= 0 ||
|
|
78
|
+
!Number.isSafeInteger(value.createdAt) ||
|
|
79
|
+
value.createdAt < 0)
|
|
80
|
+
throw new Error("Invalid stored media asset");
|
|
81
|
+
return {
|
|
82
|
+
id,
|
|
83
|
+
name: value.name,
|
|
84
|
+
mimeType: value.mimeType,
|
|
85
|
+
bytes: value.bytes,
|
|
86
|
+
sha256: value.sha256,
|
|
87
|
+
createdAt: value.createdAt,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function rangeBounds(range, size) {
|
|
91
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
92
|
+
if (!match || (!match[1] && !match[2]))
|
|
93
|
+
return null;
|
|
94
|
+
const first = match[1] ? Number(match[1]) : undefined;
|
|
95
|
+
const last = match[2] ? Number(match[2]) : undefined;
|
|
96
|
+
if ((first !== undefined && !Number.isSafeInteger(first)) ||
|
|
97
|
+
(last !== undefined && !Number.isSafeInteger(last)))
|
|
98
|
+
return null;
|
|
99
|
+
if (first === undefined) {
|
|
100
|
+
if (!last || last <= 0)
|
|
101
|
+
return null;
|
|
102
|
+
return { start: Math.max(0, size - last), end: size - 1 };
|
|
103
|
+
}
|
|
104
|
+
if (first >= size || (last !== undefined && last < first))
|
|
105
|
+
return null;
|
|
106
|
+
return { start: first, end: Math.min(last ?? size - 1, size - 1) };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Host-owned immutable, scope-local media. importFile accepts only paths already
|
|
110
|
+
* authorized by the caller (for example native file selection); guest APIs must
|
|
111
|
+
* never pass unchecked paths through this internal method.
|
|
112
|
+
*/
|
|
113
|
+
export class ResourceLibrary {
|
|
114
|
+
root;
|
|
115
|
+
maxFileBytes;
|
|
116
|
+
now;
|
|
117
|
+
isScopeAuthorized;
|
|
118
|
+
writes = new Map();
|
|
119
|
+
constructor(options) {
|
|
120
|
+
this.root = options.rootDirectory;
|
|
121
|
+
this.maxFileBytes = options.maxFileBytes ?? 20 * 1024 ** 3;
|
|
122
|
+
this.now = options.now ?? Date.now;
|
|
123
|
+
this.isScopeAuthorized = options.isScopeAuthorized;
|
|
124
|
+
if (!Number.isSafeInteger(this.maxFileBytes) || this.maxFileBytes <= 0)
|
|
125
|
+
throw new Error("Invalid media file budget");
|
|
126
|
+
}
|
|
127
|
+
async authorize(scope) {
|
|
128
|
+
mediaScopeKey(scope);
|
|
129
|
+
if (this.isScopeAuthorized && !(await this.isScopeAuthorized(scope)))
|
|
130
|
+
throw new Error("Resource authorization was revoked");
|
|
131
|
+
}
|
|
132
|
+
directory(scope, segments, create = true) {
|
|
133
|
+
return mediaDirectory(this.root, ["scopes", mediaScopeKey(scope), "assets", ...segments], create);
|
|
134
|
+
}
|
|
135
|
+
async importFile(scope, sourcePath, options = {}) {
|
|
136
|
+
const verify = async () => {
|
|
137
|
+
options.signal?.throwIfAborted();
|
|
138
|
+
await this.authorize(scope);
|
|
139
|
+
await options.assertAuthorized?.();
|
|
140
|
+
options.signal?.throwIfAborted();
|
|
141
|
+
};
|
|
142
|
+
await verify();
|
|
143
|
+
if (options.expectedBytes !== undefined &&
|
|
144
|
+
(!Number.isSafeInteger(options.expectedBytes) ||
|
|
145
|
+
options.expectedBytes < 1 ||
|
|
146
|
+
options.expectedBytes > this.maxFileBytes))
|
|
147
|
+
throw new Error("Invalid expected resource size");
|
|
148
|
+
if (options.expectedSha256 !== undefined && !/^[a-f0-9]{64}$/.test(options.expectedSha256))
|
|
149
|
+
throw new Error("Invalid expected resource digest");
|
|
150
|
+
const scopeKey = mediaScopeKey(scope);
|
|
151
|
+
const staging = await mediaDirectory(this.root, ["scopes", scopeKey, "imports"]);
|
|
152
|
+
const stagingIdentity = await lstat(staging);
|
|
153
|
+
const verifyStaging = async () => {
|
|
154
|
+
const currentPath = await mediaDirectory(this.root, ["scopes", scopeKey, "imports"], false);
|
|
155
|
+
const current = await lstat(currentPath);
|
|
156
|
+
if (current.dev !== stagingIdentity.dev || current.ino !== stagingIdentity.ino)
|
|
157
|
+
throw new Error("Resource staging directory changed during import");
|
|
158
|
+
};
|
|
159
|
+
const temporary = join(staging, `${randomUUID()}.partial`);
|
|
160
|
+
const source = await open(sourcePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
161
|
+
let destination;
|
|
162
|
+
try {
|
|
163
|
+
const initial = await source.stat();
|
|
164
|
+
if (!initial.isFile() || initial.size <= 0 || initial.size > this.maxFileBytes)
|
|
165
|
+
throw new Error("Source must be a nonempty regular file within the media file budget");
|
|
166
|
+
if (options.expectedSource &&
|
|
167
|
+
Object.entries(mediaSourceIdentity(initial)).some(([key, value]) => options.expectedSource[key] !== value))
|
|
168
|
+
throw new Error("Source changed since it was selected; select the media file again");
|
|
169
|
+
if (options.expectedBytes !== undefined && initial.size !== options.expectedBytes)
|
|
170
|
+
throw new Error("Resource size does not match the expected size");
|
|
171
|
+
const verifySource = async () => {
|
|
172
|
+
await verify();
|
|
173
|
+
await verifyStaging();
|
|
174
|
+
const current = await lstat(sourcePath);
|
|
175
|
+
if (current.isSymbolicLink() ||
|
|
176
|
+
Object.entries(mediaSourceIdentity(initial)).some(([key, value]) => current[key] !== value))
|
|
177
|
+
throw new Error("Source changed during media import; select the file again");
|
|
178
|
+
};
|
|
179
|
+
await verifySource();
|
|
180
|
+
const digest = createHash("sha256");
|
|
181
|
+
let copied = 0;
|
|
182
|
+
destination = await open(temporary, "wx", 0o600);
|
|
183
|
+
// A fixed-size buffer keeps large imports streaming while retaining both
|
|
184
|
+
// file descriptors for post-copy identity checks and an explicit fsync.
|
|
185
|
+
const buffer = Buffer.allocUnsafe(256 * 1024);
|
|
186
|
+
while (true) {
|
|
187
|
+
await verifySource();
|
|
188
|
+
const { bytesRead } = await source.read(buffer, 0, buffer.length, copied);
|
|
189
|
+
if (!bytesRead)
|
|
190
|
+
break;
|
|
191
|
+
if (copied + bytesRead > this.maxFileBytes)
|
|
192
|
+
throw new Error("Media file budget exceeded");
|
|
193
|
+
digest.update(buffer.subarray(0, bytesRead));
|
|
194
|
+
let written = 0;
|
|
195
|
+
while (written < bytesRead) {
|
|
196
|
+
options.signal?.throwIfAborted();
|
|
197
|
+
const { bytesWritten } = await destination.write(buffer, written, bytesRead - written, copied + written);
|
|
198
|
+
if (!bytesWritten)
|
|
199
|
+
throw new Error("Media copy stopped making progress");
|
|
200
|
+
written += bytesWritten;
|
|
201
|
+
}
|
|
202
|
+
copied += bytesRead;
|
|
203
|
+
try {
|
|
204
|
+
options.onProgress?.(copied, initial.size);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* Detached guests do not abort copies. */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const after = await source.stat();
|
|
211
|
+
if (copied !== initial.size ||
|
|
212
|
+
after.size !== initial.size ||
|
|
213
|
+
after.mtimeMs !== initial.mtimeMs ||
|
|
214
|
+
after.ctimeMs !== initial.ctimeMs ||
|
|
215
|
+
after.ino !== initial.ino ||
|
|
216
|
+
after.dev !== initial.dev) {
|
|
217
|
+
throw new Error("Source changed during media import; select the file again");
|
|
218
|
+
}
|
|
219
|
+
await verifySource();
|
|
220
|
+
await destination.sync();
|
|
221
|
+
await destination.close();
|
|
222
|
+
destination = undefined;
|
|
223
|
+
options.signal?.throwIfAborted();
|
|
224
|
+
const sha256 = digest.digest("hex");
|
|
225
|
+
if (options.expectedSha256 !== undefined && options.expectedSha256 !== sha256)
|
|
226
|
+
throw new Error("Resource digest does not match the expected digest");
|
|
227
|
+
const id = `asset-${sha256}`;
|
|
228
|
+
const name = resourceName(options.name ?? sourcePath);
|
|
229
|
+
const requestedMime = options.mimeType?.split(";", 1)[0].toLowerCase();
|
|
230
|
+
const mimeType = requestedMime && SAFE_MIME_TYPES.has(requestedMime)
|
|
231
|
+
? requestedMime
|
|
232
|
+
: (MIME_BY_EXTENSION[extname(name).toLowerCase()] ?? "application/octet-stream");
|
|
233
|
+
const record = {
|
|
234
|
+
id,
|
|
235
|
+
name,
|
|
236
|
+
mimeType,
|
|
237
|
+
bytes: copied,
|
|
238
|
+
sha256,
|
|
239
|
+
createdAt: this.now(),
|
|
240
|
+
};
|
|
241
|
+
const key = `${scopeKey}:${id}`;
|
|
242
|
+
const operation = (this.writes.get(key) ?? Promise.resolve())
|
|
243
|
+
.catch(() => { })
|
|
244
|
+
.then(async () => {
|
|
245
|
+
await verifySource();
|
|
246
|
+
try {
|
|
247
|
+
const existing = await this.get(scope, id);
|
|
248
|
+
await this.verifyContent(scope, id, verifySource);
|
|
249
|
+
return existing;
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (error.code !== "ENOENT")
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
const parent = await this.directory(scope, []);
|
|
256
|
+
const parentIdentity = await lstat(parent);
|
|
257
|
+
const directory = join(parent, id);
|
|
258
|
+
const committed = join(staging, `${randomUUID()}.commit`);
|
|
259
|
+
let published = false;
|
|
260
|
+
let publishedIdentity;
|
|
261
|
+
try {
|
|
262
|
+
await mkdir(committed, { mode: 0o700 });
|
|
263
|
+
publishedIdentity = await lstat(committed);
|
|
264
|
+
await rename(temporary, join(committed, "content"));
|
|
265
|
+
await writeMediaJson(join(committed, "asset.json"), record);
|
|
266
|
+
await verifySource();
|
|
267
|
+
const currentParent = await lstat(await this.directory(scope, [], false));
|
|
268
|
+
if (currentParent.dev !== parentIdentity.dev ||
|
|
269
|
+
currentParent.ino !== parentIdentity.ino)
|
|
270
|
+
throw new Error("Resource storage directory changed during import");
|
|
271
|
+
try {
|
|
272
|
+
await rename(committed, directory);
|
|
273
|
+
published = true;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (!["EEXIST", "ENOTEMPTY"].includes(error.code ?? ""))
|
|
277
|
+
throw error;
|
|
278
|
+
await this.verifyContent(scope, id, verifySource);
|
|
279
|
+
return this.get(scope, id);
|
|
280
|
+
}
|
|
281
|
+
await verify();
|
|
282
|
+
return structuredClone(record);
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
if (published && publishedIdentity) {
|
|
286
|
+
const current = await lstat(directory).catch(() => undefined);
|
|
287
|
+
if (current?.dev === publishedIdentity.dev && current?.ino === publishedIdentity.ino)
|
|
288
|
+
await rm(directory, { recursive: true, force: true }).catch(() => { });
|
|
289
|
+
}
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
await rm(committed, { recursive: true, force: true }).catch(() => { });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
this.writes.set(key, operation);
|
|
297
|
+
try {
|
|
298
|
+
return await operation;
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
if (this.writes.get(key) === operation)
|
|
302
|
+
this.writes.delete(key);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
await source.close().catch(() => { });
|
|
307
|
+
await destination?.close().catch(() => { });
|
|
308
|
+
await rm(temporary, { force: true }).catch(() => { });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async get(scope, id) {
|
|
312
|
+
await this.authorize(scope);
|
|
313
|
+
if (!ASSET_ID.test(id))
|
|
314
|
+
throw new Error("Invalid media asset ID");
|
|
315
|
+
const directory = await this.directory(scope, [id], false);
|
|
316
|
+
const result = assetRecord(await readMediaJson(join(directory, "asset.json")), id);
|
|
317
|
+
await this.authorize(scope);
|
|
318
|
+
return result;
|
|
319
|
+
}
|
|
320
|
+
async list(scope) {
|
|
321
|
+
await this.authorize(scope);
|
|
322
|
+
let directory;
|
|
323
|
+
try {
|
|
324
|
+
directory = await this.directory(scope, [], false);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (error.code === "ENOENT")
|
|
328
|
+
return [];
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
const assets = [];
|
|
332
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
333
|
+
if (!ASSET_ID.test(entry.name))
|
|
334
|
+
continue;
|
|
335
|
+
assets.push(await this.get(scope, entry.name));
|
|
336
|
+
}
|
|
337
|
+
return assets.sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
|
|
338
|
+
}
|
|
339
|
+
/** Host-only path resolution for registered processors, not a renderer DTO. */
|
|
340
|
+
async resolvePath(scope, id) {
|
|
341
|
+
const asset = await this.get(scope, id);
|
|
342
|
+
const directory = await this.directory(scope, [id], false);
|
|
343
|
+
const path = join(directory, "content");
|
|
344
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
345
|
+
try {
|
|
346
|
+
const info = await handle.stat();
|
|
347
|
+
if (!info.isFile() || info.size !== asset.bytes)
|
|
348
|
+
throw new Error("Stored media content is missing or changed");
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
await handle.close();
|
|
352
|
+
}
|
|
353
|
+
return path;
|
|
354
|
+
}
|
|
355
|
+
/** Revalidate bytes before reuse/hand-off rather than trusting metadata alone. */
|
|
356
|
+
async verifyContent(scope, id, check) {
|
|
357
|
+
const asset = await this.get(scope, id);
|
|
358
|
+
const source = await this.openRead(scope, id);
|
|
359
|
+
const hash = createHash("sha256");
|
|
360
|
+
let bytes = 0;
|
|
361
|
+
try {
|
|
362
|
+
for await (const chunk of source.body) {
|
|
363
|
+
await this.authorize(scope);
|
|
364
|
+
await check?.();
|
|
365
|
+
bytes += chunk.length;
|
|
366
|
+
if (bytes > asset.bytes)
|
|
367
|
+
throw new Error("Stored media content changed");
|
|
368
|
+
hash.update(chunk);
|
|
369
|
+
}
|
|
370
|
+
if (bytes !== asset.bytes || hash.digest("hex") !== asset.sha256)
|
|
371
|
+
throw new Error("Stored media content changed");
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
source.body?.destroy();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async openRead(scope, id, options = {}) {
|
|
378
|
+
const method = options.method ?? "GET";
|
|
379
|
+
if (method !== "GET" && method !== "HEAD")
|
|
380
|
+
return { status: 405, headers: { Allow: "GET, HEAD" }, body: null };
|
|
381
|
+
const asset = await this.get(scope, id);
|
|
382
|
+
const path = await this.resolvePath(scope, id);
|
|
383
|
+
const headers = {
|
|
384
|
+
"Content-Type": asset.mimeType,
|
|
385
|
+
"Accept-Ranges": "bytes",
|
|
386
|
+
"Cache-Control": "private, no-store",
|
|
387
|
+
"X-Content-Type-Options": "nosniff",
|
|
388
|
+
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
389
|
+
ETag: `"${asset.sha256}"`,
|
|
390
|
+
};
|
|
391
|
+
if (!/^(?:audio|video|image)\//.test(asset.mimeType))
|
|
392
|
+
headers["Content-Disposition"] =
|
|
393
|
+
`attachment; filename*=UTF-8''${encodeURIComponent(asset.name).replace(/['()*!]/g, (value) => `%${value.charCodeAt(0).toString(16).toUpperCase()}`)}`;
|
|
394
|
+
const bounds = options.range
|
|
395
|
+
? rangeBounds(options.range, asset.bytes)
|
|
396
|
+
: { start: 0, end: asset.bytes - 1 };
|
|
397
|
+
if (!bounds)
|
|
398
|
+
return {
|
|
399
|
+
status: 416,
|
|
400
|
+
headers: { ...headers, "Content-Range": `bytes */${asset.bytes}` },
|
|
401
|
+
body: null,
|
|
402
|
+
};
|
|
403
|
+
headers["Content-Length"] = String(bounds.end - bounds.start + 1);
|
|
404
|
+
if (options.range)
|
|
405
|
+
headers["Content-Range"] = `bytes ${bounds.start}-${bounds.end}/${asset.bytes}`;
|
|
406
|
+
const status = options.range ? 206 : 200;
|
|
407
|
+
if (method === "HEAD")
|
|
408
|
+
return { status, headers, body: null };
|
|
409
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
410
|
+
try {
|
|
411
|
+
const info = await handle.stat();
|
|
412
|
+
if (!info.isFile() || info.size !== asset.bytes)
|
|
413
|
+
throw new Error("Stored media content changed");
|
|
414
|
+
await this.authorize(scope);
|
|
415
|
+
return { status, headers, body: handle.createReadStream({ ...bounds, autoClose: true }) };
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
await handle.close();
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
export { ResourceLibrary as MediaLibrary };
|