dsh-reference-anything 0.3.0 → 0.3.2
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/LICENSE +22 -22
- package/NOTICE.md +52 -25
- package/README.md +339 -286
- package/cordis.patch.yml +89 -18
- package/lib/client.js +13538 -591
- package/lib/contract.d.ts +155 -16
- package/lib/contract.js +68 -7
- package/lib/download-directory.d.ts +18 -0
- package/lib/download-directory.js +55 -0
- package/lib/errors.d.ts +4 -0
- package/lib/host.d.ts +96 -8
- package/lib/host.js +88 -19
- package/lib/index.d.ts +22 -2
- package/lib/index.js +57 -7
- package/lib/opencli.d.ts +5 -3
- package/lib/opencli.js +13 -6
- package/lib/openlist/host.d.ts +157 -0
- package/lib/openlist/host.js +873 -0
- package/lib/openlist/index.d.ts +84 -0
- package/lib/openlist/index.js +507 -0
- package/lib/render.d.ts +12 -7
- package/lib/render.js +27 -21
- package/lib/sources/cloud-drive/cache.d.ts +60 -0
- package/lib/sources/cloud-drive/cache.js +107 -0
- package/lib/sources/cloud-drive/index.d.ts +190 -0
- package/lib/sources/cloud-drive/index.js +526 -0
- package/lib/sources/cloud-drive/providers/http.d.ts +27 -0
- package/lib/sources/cloud-drive/providers/http.js +73 -0
- package/lib/sources/cloud-drive/providers/openlist.d.ts +59 -0
- package/lib/sources/cloud-drive/providers/openlist.js +535 -0
- package/lib/sources/cloud-drive/registry.d.ts +53 -0
- package/lib/sources/cloud-drive/registry.js +101 -0
- package/lib/sources/cloud-drive/types.d.ts +169 -0
- package/lib/sources/cloud-drive/types.js +17 -0
- package/lib/sources/file.d.ts +2 -0
- package/lib/sources/file.js +2 -0
- package/lib/sources/local-agent/adapters/claude-code.d.ts +16 -0
- package/lib/sources/local-agent/adapters/claude-code.js +171 -0
- package/lib/sources/local-agent/adapters/codex.d.ts +20 -0
- package/lib/sources/local-agent/adapters/codex.js +204 -0
- package/lib/sources/local-agent/adapters/cursor.d.ts +23 -0
- package/lib/sources/local-agent/adapters/cursor.js +121 -0
- package/lib/sources/local-agent/adapters/gemini-cli.d.ts +20 -0
- package/lib/sources/local-agent/adapters/gemini-cli.js +141 -0
- package/lib/sources/local-agent/adapters/grokbuild.d.ts +22 -0
- package/lib/sources/local-agent/adapters/grokbuild.js +76 -0
- package/lib/sources/local-agent/adapters/hermes.d.ts +19 -0
- package/lib/sources/local-agent/adapters/hermes.js +85 -0
- package/lib/sources/local-agent/adapters/kimi.d.ts +28 -0
- package/lib/sources/local-agent/adapters/kimi.js +261 -0
- package/lib/sources/local-agent/adapters/openclaw.d.ts +21 -0
- package/lib/sources/local-agent/adapters/openclaw.js +120 -0
- package/lib/sources/local-agent/adapters/opencode.d.ts +44 -0
- package/lib/sources/local-agent/adapters/opencode.js +236 -0
- package/lib/sources/local-agent/adapters/pi.d.ts +26 -0
- package/lib/sources/local-agent/adapters/pi.js +242 -0
- package/lib/sources/local-agent/adapters/qoder.d.ts +19 -0
- package/lib/sources/local-agent/adapters/qoder.js +93 -0
- package/lib/sources/local-agent/adapters/reasonix.d.ts +23 -0
- package/lib/sources/local-agent/adapters/reasonix.js +90 -0
- package/lib/sources/local-agent/adapters/shared.d.ts +163 -0
- package/lib/sources/local-agent/adapters/shared.js +415 -0
- package/lib/sources/local-agent/adapters/sqlite-shared.d.ts +92 -0
- package/lib/sources/local-agent/adapters/sqlite-shared.js +215 -0
- package/lib/sources/local-agent/adapters/zcode.d.ts +41 -0
- package/lib/sources/local-agent/adapters/zcode.js +159 -0
- package/lib/sources/local-agent/index.d.ts +222 -0
- package/lib/sources/local-agent/index.js +656 -0
- package/lib/sources/local-agent/page.d.ts +55 -0
- package/lib/sources/local-agent/page.js +83 -0
- package/lib/sources/local-agent/path.d.ts +8 -0
- package/lib/sources/local-agent/path.js +43 -0
- package/lib/sources/local-agent/registry.d.ts +68 -0
- package/lib/sources/local-agent/registry.js +116 -0
- package/lib/sources/local-agent/scan.d.ts +175 -0
- package/lib/sources/local-agent/scan.js +472 -0
- package/lib/sources/local-agent/sqlite.d.ts +45 -0
- package/lib/sources/local-agent/sqlite.js +113 -0
- package/lib/sources/local-agent/store.d.ts +93 -0
- package/lib/sources/local-agent/store.js +113 -0
- package/lib/sources/local-agent/types.d.ts +338 -0
- package/lib/sources/local-agent/types.js +20 -0
- package/lib/sources/web-chat/index.d.ts +5 -1
- package/lib/sources/web-chat/index.js +47 -8
- package/lib/store/spec.d.ts +50 -4
- package/lib/store/spec.js +2 -2
- package/lib/sync/index.d.ts +1 -0
- package/lib/sync/index.js +28 -8
- package/lib/tool.d.ts +5 -0
- package/lib/tool.js +418 -45
- package/lib/typert.js +13 -2
- package/lib/types.d.ts +28 -2
- package/lib/update.d.ts +6 -0
- package/lib/update.js +29 -1
- package/lib/web.js +5 -1
- package/lib/wire.d.ts +80 -9
- package/lib/wire.js +30 -4
- package/opencli-plugin/chatgpt.js +30 -27
- package/opencli-plugin/claude.js +12 -8
- package/opencli-plugin/common.js +266 -187
- package/opencli-plugin/deepseek.js +8 -5
- package/opencli-plugin/gemini.js +42 -38
- package/opencli-plugin/grok.js +22 -21
- package/opencli-plugin/kimi.js +34 -31
- package/opencli-plugin/opencli-plugin.json +6 -6
- package/opencli-plugin/package.json +8 -8
- package/package.json +182 -167
- package/lib/profile-mode.d.ts +0 -30
- package/lib/profile-mode.js +0 -90
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-only OpenList management. Nothing in this module is exported over a
|
|
3
|
+
* Typert remote: endpoints, credentials, and raw URLs stay in the Host.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
6
|
+
import { gunzipSync, inflateRawSync } from 'node:zlib';
|
|
7
|
+
import { chmod, copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { homedir, platform, arch } from 'node:os';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { createServer } from 'node:net';
|
|
11
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
12
|
+
import { ReferenceAnythingError } from "../errors.js";
|
|
13
|
+
/** Deliberately fixed; upgrades are a conscious manifest change. */
|
|
14
|
+
export const OPENLIST_FIXED_VERSION = 'v4.2.2';
|
|
15
|
+
export const OPENLIST_CREDENTIAL_DIRECTORY = join(homedir(), '.dsh', 'reference-anything', 'openlist');
|
|
16
|
+
export const OPENLIST_CREDENTIAL_FILE = join(OPENLIST_CREDENTIAL_DIRECTORY, 'credentials.json');
|
|
17
|
+
/** SHA-256 values were computed from the six named GitHub v4.2.2 release assets. */
|
|
18
|
+
export const OPENLIST_RELEASE = { version: OPENLIST_FIXED_VERSION, assets: [
|
|
19
|
+
{ platform: 'win32', arch: 'x64', archive: 'zip', binary: 'openlist.exe', sha256: '2b8bbf264939392a5608e405e51125136fe7c513260103813f3951c633875b64', url: 'https://github.com/OpenListTeam/OpenList/releases/download/v4.2.2/openlist-windows-amd64.zip' },
|
|
20
|
+
{ platform: 'win32', arch: 'arm64', archive: 'zip', binary: 'openlist.exe', sha256: '2a352401d92c538b2376f37d8cb49eadd34398998e03689515d8d41fac3e7263', url: 'https://github.com/OpenListTeam/OpenList/releases/download/v4.2.2/openlist-windows-arm64.zip' },
|
|
21
|
+
{ platform: 'linux', arch: 'x64', archive: 'tar.gz', binary: 'openlist', sha256: 'c4781f22ffd6bc6854bfbc0ecba7b0aaf5e6cf416c9d36d87fb335df14ab8cb6', url: 'https://github.com/OpenListTeam/OpenList/releases/download/v4.2.2/openlist-linux-amd64.tar.gz' },
|
|
22
|
+
{ platform: 'linux', arch: 'arm64', archive: 'tar.gz', binary: 'openlist', sha256: '04b2a4894c9228407e1a9b6cedefc7d6d948f1f07827878325c02462ae2274f3', url: 'https://github.com/OpenListTeam/OpenList/releases/download/v4.2.2/openlist-linux-arm64.tar.gz' },
|
|
23
|
+
{ platform: 'darwin', arch: 'x64', archive: 'tar.gz', binary: 'openlist', sha256: '9d0fef008bea91dda99428895df92bb476386f90af4a06fe7c041dd13ca5b7a3', url: 'https://github.com/OpenListTeam/OpenList/releases/download/v4.2.2/openlist-darwin-amd64.tar.gz' },
|
|
24
|
+
{ platform: 'darwin', arch: 'arm64', archive: 'tar.gz', binary: 'openlist', sha256: '19998745ff530db36c3a6f307b1c9c864d6db78d2084b35ffdec0f3ea524bad4', url: 'https://github.com/OpenListTeam/OpenList/releases/download/v4.2.2/openlist-darwin-arm64.tar.gz' },
|
|
25
|
+
] };
|
|
26
|
+
/**
|
|
27
|
+
* Process-local ownership of executable replacement transactions. A recovery
|
|
28
|
+
* probe must only repair state left by a dead process, never state that a live
|
|
29
|
+
* installer in this process still owns.
|
|
30
|
+
*/
|
|
31
|
+
const activeExecutableReplacements = new Map();
|
|
32
|
+
/**
|
|
33
|
+
* `admin random` mutates the database before its output can be observed. Keep
|
|
34
|
+
* ownership until the operating-system child handle confirms `close`, even
|
|
35
|
+
* when the caller has already received a timeout error.
|
|
36
|
+
*/
|
|
37
|
+
const activeAdminMutations = new Map();
|
|
38
|
+
function acquireExecutableReplacement(target) {
|
|
39
|
+
if (activeExecutableReplacements.has(target))
|
|
40
|
+
throw new ReferenceAnythingError('OpenList executable replacement is already in progress', 'REFERENCE_SYNC_IN_PROGRESS');
|
|
41
|
+
const owner = Symbol(target);
|
|
42
|
+
activeExecutableReplacements.set(target, owner);
|
|
43
|
+
return owner;
|
|
44
|
+
}
|
|
45
|
+
function releaseExecutableReplacement(target, owner) {
|
|
46
|
+
if (activeExecutableReplacements.get(target) === owner)
|
|
47
|
+
activeExecutableReplacements.delete(target);
|
|
48
|
+
}
|
|
49
|
+
function acquireAdminMutation(dataDirectory) {
|
|
50
|
+
const key = resolve(dataDirectory);
|
|
51
|
+
if (activeAdminMutations.has(key))
|
|
52
|
+
throw new ReferenceAnythingError('OpenList administrator password change is already in progress', 'REFERENCE_SYNC_IN_PROGRESS');
|
|
53
|
+
const owner = Symbol(key);
|
|
54
|
+
activeAdminMutations.set(key, owner);
|
|
55
|
+
return { key, owner };
|
|
56
|
+
}
|
|
57
|
+
function releaseAdminMutation(key, owner) {
|
|
58
|
+
if (activeAdminMutations.get(key) === owner)
|
|
59
|
+
activeAdminMutations.delete(key);
|
|
60
|
+
}
|
|
61
|
+
/** Exchange external username/password for the raw OpenList token. */
|
|
62
|
+
export async function authenticateOpenList(endpoint, username, password, fetchImpl = fetch, signal) {
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetchImpl(`${validateOpenListEndpoint(endpoint)}/api/auth/login`, { method: 'POST', signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
|
|
65
|
+
if (!response.ok)
|
|
66
|
+
throw new Error('login rejected');
|
|
67
|
+
const payload = object(await response.json());
|
|
68
|
+
const data = object(payload.data ?? payload);
|
|
69
|
+
if (typeof payload.code === 'number' && payload.code !== 200)
|
|
70
|
+
throw new Error('login rejected');
|
|
71
|
+
if (typeof data.token !== 'string' || data.token === '')
|
|
72
|
+
throw new Error('missing token');
|
|
73
|
+
return data.token;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (isAbortError(error, signal))
|
|
77
|
+
throw abortError(signal);
|
|
78
|
+
throw new ReferenceAnythingError('OpenList authentication failed', 'SOURCE_UNAVAILABLE');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Download only a manifest-pinned binary after validating its SHA-256. */
|
|
82
|
+
export async function installOpenList(dataRoot, manifest = OPENLIST_RELEASE, seams = {}, os = platform(), cpu = arch(), signal, retainBackup = false) {
|
|
83
|
+
signal?.throwIfAborted();
|
|
84
|
+
const asset = selectOpenListAsset(manifest, os, cpu);
|
|
85
|
+
const fetchImpl = seams.fetch ?? fetch;
|
|
86
|
+
let response;
|
|
87
|
+
try {
|
|
88
|
+
response = await fetchImpl(asset.url, { signal });
|
|
89
|
+
}
|
|
90
|
+
catch (cause) {
|
|
91
|
+
if (isAbortError(cause, signal))
|
|
92
|
+
throw abortError(signal);
|
|
93
|
+
throw new ReferenceAnythingError('OpenList download failed', 'REFERENCE_READ_FAILED', { cause });
|
|
94
|
+
}
|
|
95
|
+
if (!response.ok)
|
|
96
|
+
throw new ReferenceAnythingError(`OpenList download failed with HTTP ${response.status}`, 'REFERENCE_READ_FAILED');
|
|
97
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
98
|
+
signal?.throwIfAborted();
|
|
99
|
+
if (!verifyOpenListAsset(bytes, asset.sha256))
|
|
100
|
+
throw new ReferenceAnythingError('OpenList download SHA-256 verification failed', 'REFERENCE_READ_FAILED');
|
|
101
|
+
const binary = unpackOpenListBinary(bytes, asset);
|
|
102
|
+
const directory = join(dataRoot, 'bin', manifest.version);
|
|
103
|
+
const target = join(directory, asset.binary);
|
|
104
|
+
await (seams.mkdir ?? mkdir)(directory, { recursive: true, mode: 0o700 });
|
|
105
|
+
await securePath(directory, true, seams.chmod ?? chmod, seams, os === 'win32');
|
|
106
|
+
const temporary = join(directory, `.${asset.binary}-${process.pid}-${randomBytes(8).toString('hex')}.tmp`);
|
|
107
|
+
const backup = join(directory, `.${asset.binary}.replacement-backup`);
|
|
108
|
+
const marker = join(directory, `.${asset.binary}.replacement-pending`);
|
|
109
|
+
const simulatedFilesystem = seams.writeFile !== undefined && seams.rename === undefined;
|
|
110
|
+
const move = seams.rename ?? (simulatedFilesystem ? async () => undefined : rename);
|
|
111
|
+
const remove = seams.unlink ?? (simulatedFilesystem ? async () => undefined : unlink);
|
|
112
|
+
const owner = acquireExecutableReplacement(target);
|
|
113
|
+
let ownershipRetained = false;
|
|
114
|
+
let backedUp = false;
|
|
115
|
+
let committed = false;
|
|
116
|
+
try {
|
|
117
|
+
await (seams.writeFile ?? writeFile)(temporary, binary, { mode: 0o700, flag: 'wx' });
|
|
118
|
+
await (seams.fsyncFile ?? (seams.writeFile === undefined ? fsyncPath : async () => undefined))(temporary);
|
|
119
|
+
await secureExecutable(temporary, seams.chmod ?? chmod, seams, os === 'win32');
|
|
120
|
+
signal?.throwIfAborted();
|
|
121
|
+
await recoverInterruptedReplacement(target, backup, marker, seams, os === 'win32', owner);
|
|
122
|
+
if (os === 'win32') {
|
|
123
|
+
try {
|
|
124
|
+
await (seams.writeFile ?? writeFile)(marker, 'pending', { mode: 0o600, flag: 'wx' });
|
|
125
|
+
await securePath(marker, false, seams.chmod ?? chmod, seams, true);
|
|
126
|
+
await move(target, backup);
|
|
127
|
+
backedUp = true;
|
|
128
|
+
}
|
|
129
|
+
catch (cause) {
|
|
130
|
+
if (!isMissing(cause))
|
|
131
|
+
throw cause;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
try {
|
|
136
|
+
await (seams.copyFile ?? copyFile)(target, backup);
|
|
137
|
+
backedUp = true;
|
|
138
|
+
}
|
|
139
|
+
catch (cause) {
|
|
140
|
+
if (!isMissing(cause))
|
|
141
|
+
throw cause;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
await move(temporary, target);
|
|
145
|
+
committed = true;
|
|
146
|
+
// Rename preserves the already-restricted mode/ACL of the temporary file.
|
|
147
|
+
// Avoid a second post-commit permission operation that could fail after
|
|
148
|
+
// the replacement has become visible.
|
|
149
|
+
if (!retainBackup) {
|
|
150
|
+
if (backedUp)
|
|
151
|
+
await remove(backup);
|
|
152
|
+
if (os === 'win32')
|
|
153
|
+
await remove(marker).catch(cause => { if (!isMissing(cause))
|
|
154
|
+
throw cause; });
|
|
155
|
+
}
|
|
156
|
+
ownershipRetained = retainBackup;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
await remove(temporary).catch(() => undefined);
|
|
160
|
+
if (backedUp) {
|
|
161
|
+
if (!committed)
|
|
162
|
+
await move(backup, target).catch(() => undefined);
|
|
163
|
+
else
|
|
164
|
+
await move(backup, target).catch(() => undefined);
|
|
165
|
+
}
|
|
166
|
+
throw new ReferenceAnythingError('Could not atomically install OpenList', 'REFERENCE_READ_FAILED');
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
if (!ownershipRetained)
|
|
170
|
+
releaseExecutableReplacement(target, owner);
|
|
171
|
+
}
|
|
172
|
+
let finishState = 'active';
|
|
173
|
+
let rollbackRestored = false;
|
|
174
|
+
const finish = async (success) => {
|
|
175
|
+
if (!retainBackup)
|
|
176
|
+
return;
|
|
177
|
+
if (finishState !== 'active')
|
|
178
|
+
return;
|
|
179
|
+
try {
|
|
180
|
+
if (!success) {
|
|
181
|
+
if (backedUp && !rollbackRestored) {
|
|
182
|
+
if (os === 'win32')
|
|
183
|
+
await remove(target).catch(cause => { if (!isMissing(cause))
|
|
184
|
+
throw cause; });
|
|
185
|
+
await move(backup, target);
|
|
186
|
+
rollbackRestored = true;
|
|
187
|
+
}
|
|
188
|
+
if (os === 'win32')
|
|
189
|
+
await remove(marker).catch(cause => { if (!isMissing(cause))
|
|
190
|
+
throw cause; });
|
|
191
|
+
finishState = 'rolled-back';
|
|
192
|
+
releaseExecutableReplacement(target, owner);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (os === 'win32') {
|
|
196
|
+
// Removing the durable pending marker is the Windows commit point.
|
|
197
|
+
// Until this succeeds the backup must remain available to finish(false).
|
|
198
|
+
await remove(marker).catch(cause => { if (!isMissing(cause))
|
|
199
|
+
throw cause; });
|
|
200
|
+
finishState = 'committed';
|
|
201
|
+
releaseExecutableReplacement(target, owner);
|
|
202
|
+
// The replacement is durable now. A locked backup is harmless stale
|
|
203
|
+
// cleanup state and must never make the caller roll back the new target.
|
|
204
|
+
if (backedUp)
|
|
205
|
+
await remove(backup).catch(() => undefined);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (backedUp)
|
|
209
|
+
await remove(backup);
|
|
210
|
+
finishState = 'committed';
|
|
211
|
+
releaseExecutableReplacement(target, owner);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
throw new ReferenceAnythingError(success ? 'Could not finalize OpenList executable replacement' : 'Could not restore OpenList executable', 'REFERENCE_READ_FAILED');
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
return { version: manifest.version, path: target, finish };
|
|
218
|
+
}
|
|
219
|
+
/** Extract only the expected executable; archive paths never reach the filesystem. */
|
|
220
|
+
export function unpackOpenListBinary(archive, asset) {
|
|
221
|
+
if (asset.binary === '' || asset.binary.includes('/') || asset.binary.includes('\\') || asset.binary.includes('..'))
|
|
222
|
+
throw new ReferenceAnythingError('OpenList manifest binary name is unsafe', 'REFERENCE_INVALID_CONFIG');
|
|
223
|
+
const bytes = asset.archive === 'tar.gz' ? tarEntry(new Uint8Array(gunzipSync(archive)), asset.binary) : zipEntry(archive, asset.binary);
|
|
224
|
+
if (bytes === undefined)
|
|
225
|
+
throw new ReferenceAnythingError('OpenList archive did not contain its expected binary', 'REFERENCE_READ_FAILED');
|
|
226
|
+
return bytes;
|
|
227
|
+
}
|
|
228
|
+
/** Select exactly the release asset for this OS and CPU architecture. */
|
|
229
|
+
export function selectOpenListAsset(manifest, os = platform(), cpu = arch()) {
|
|
230
|
+
if (manifest.version !== OPENLIST_FIXED_VERSION)
|
|
231
|
+
throw new ReferenceAnythingError(`OpenList manifest must pin ${OPENLIST_FIXED_VERSION}`, 'REFERENCE_INVALID_CONFIG');
|
|
232
|
+
const asset = manifest.assets.find(item => item.platform === os && item.arch === cpu);
|
|
233
|
+
if (asset === undefined)
|
|
234
|
+
throw new ReferenceAnythingError(`OpenList ${manifest.version} has no verified asset for ${os}/${cpu}`, 'REFERENCE_INVALID_CONFIG');
|
|
235
|
+
if (!/^[a-f0-9]{64}$/i.test(asset.sha256))
|
|
236
|
+
throw new ReferenceAnythingError('OpenList asset has no verifiable SHA-256', 'REFERENCE_INVALID_CONFIG');
|
|
237
|
+
return asset;
|
|
238
|
+
}
|
|
239
|
+
export function sha256(bytes) { return createHash('sha256').update(bytes).digest('hex'); }
|
|
240
|
+
export function verifyOpenListAsset(bytes, expected) { return /^[a-f0-9]{64}$/i.test(expected) && sha256(bytes).toLowerCase() === expected.toLowerCase(); }
|
|
241
|
+
/** Validate an endpoint without leaking its full value in a diagnostic. */
|
|
242
|
+
export function validateOpenListEndpoint(value, managed = false) {
|
|
243
|
+
let url;
|
|
244
|
+
try {
|
|
245
|
+
url = new URL(value);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
throw new ReferenceAnythingError('OpenList endpoint is invalid', 'REFERENCE_INVALID_CONFIG');
|
|
249
|
+
}
|
|
250
|
+
if (url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '')
|
|
251
|
+
throw new ReferenceAnythingError('OpenList endpoint must not contain credentials or a query', 'REFERENCE_INVALID_CONFIG');
|
|
252
|
+
const loopback = isLoopback(url.hostname);
|
|
253
|
+
if (managed && !loopback)
|
|
254
|
+
throw new ReferenceAnythingError('Managed OpenList must listen only on loopback', 'REFERENCE_INVALID_CONFIG');
|
|
255
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
|
256
|
+
throw new ReferenceAnythingError('Remote OpenList endpoints require HTTPS (HTTP is allowed only on loopback)', 'REFERENCE_INVALID_CONFIG');
|
|
257
|
+
}
|
|
258
|
+
return url.href.replace(/\/$/, '');
|
|
259
|
+
}
|
|
260
|
+
export function isLoopback(host) { return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(host.toLowerCase()); }
|
|
261
|
+
/** Ask the kernel for a loopback port. Start immediately afterwards to minimize the normal port race. */
|
|
262
|
+
export async function chooseOpenListPort() {
|
|
263
|
+
return await new Promise((resolve, reject) => {
|
|
264
|
+
const server = createServer();
|
|
265
|
+
server.once('error', reject);
|
|
266
|
+
server.listen(0, '127.0.0.1', () => {
|
|
267
|
+
const address = server.address();
|
|
268
|
+
if (address === null || typeof address === 'string') {
|
|
269
|
+
server.close();
|
|
270
|
+
reject(new Error('could not allocate OpenList port'));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
server.close(error => error === undefined ? resolve(address.port) : reject(error));
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
export async function readOpenListCredentials(path = OPENLIST_CREDENTIAL_FILE, seams = {}) {
|
|
278
|
+
try {
|
|
279
|
+
const text = await (seams.readFile ?? readFile)(path, 'utf8');
|
|
280
|
+
const data = JSON.parse(text);
|
|
281
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data))
|
|
282
|
+
return undefined;
|
|
283
|
+
const row = data;
|
|
284
|
+
if (typeof row.endpoint !== 'string')
|
|
285
|
+
return undefined;
|
|
286
|
+
const endpoint = validateOpenListEndpoint(row.endpoint);
|
|
287
|
+
if (typeof row.token !== 'string' || row.token === '')
|
|
288
|
+
return undefined;
|
|
289
|
+
return { endpoint, token: row.token, ...(row.mode === 'managed' || row.mode === 'external' ? { mode: row.mode } : {}), ...(typeof row.password === 'string' && row.password !== '' ? { password: row.password } : {}), ...(typeof row.version === 'string' && /^v\d+\.\d+\.\d+$/.test(row.version) ? { version: row.version } : {}) };
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
export async function writeOpenListCredentials(credentials, path = OPENLIST_CREDENTIAL_FILE, seams = {}, signal) {
|
|
296
|
+
const endpoint = validateOpenListEndpoint(credentials.endpoint);
|
|
297
|
+
if (credentials.token === undefined || credentials.token === '')
|
|
298
|
+
throw new ReferenceAnythingError('OpenList token is required', 'REFERENCE_INVALID_CONFIG');
|
|
299
|
+
const makeDir = seams.mkdir ?? mkdir;
|
|
300
|
+
const write = seams.writeFile ?? writeFile;
|
|
301
|
+
const mode = seams.chmod ?? chmod;
|
|
302
|
+
const directory = dirname(path);
|
|
303
|
+
await makeDir(directory, { recursive: true, mode: 0o700 });
|
|
304
|
+
await securePath(directory, true, mode, seams);
|
|
305
|
+
// Rename is the credential commit boundary, so readers see either the old
|
|
306
|
+
// complete token or the new complete token, never a truncated JSON file.
|
|
307
|
+
signal?.throwIfAborted();
|
|
308
|
+
const temporary = join(directory, `.credentials-${process.pid}-${randomBytes(8).toString('hex')}.tmp`);
|
|
309
|
+
await write(temporary, JSON.stringify({ endpoint, token: credentials.token, ...(credentials.mode === undefined ? {} : { mode: credentials.mode }), ...(credentials.password === undefined ? {} : { password: credentials.password }), ...(credentials.version === undefined ? {} : { version: credentials.version }) }), { encoding: 'utf8', mode: 0o600 });
|
|
310
|
+
await securePath(temporary, false, mode, seams);
|
|
311
|
+
signal?.throwIfAborted();
|
|
312
|
+
try {
|
|
313
|
+
await (seams.rename ?? rename)(temporary, path);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
await (seams.unlink ?? unlink)(temporary).catch(() => undefined);
|
|
317
|
+
throw new ReferenceAnythingError('Could not persist OpenList credentials', 'REFERENCE_READ_FAILED');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** A Host-private authenticated API wrapper with redacted errors and safe mount shapes. */
|
|
321
|
+
export class OpenListHostClient {
|
|
322
|
+
credentials;
|
|
323
|
+
refreshToken;
|
|
324
|
+
managed;
|
|
325
|
+
fetchImpl;
|
|
326
|
+
secretDefaults = new Map();
|
|
327
|
+
constructor(credentials, seams = {}, refreshToken, managed = false) {
|
|
328
|
+
this.credentials = credentials;
|
|
329
|
+
this.refreshToken = refreshToken;
|
|
330
|
+
this.managed = managed;
|
|
331
|
+
validateOpenListEndpoint(credentials.endpoint);
|
|
332
|
+
this.fetchImpl = seams.fetch ?? fetch;
|
|
333
|
+
}
|
|
334
|
+
async connect(signal) {
|
|
335
|
+
const me = object(await this.request('/api/me', 'GET', undefined, signal));
|
|
336
|
+
// OpenList's administrator is the built-in user id 1. Older responses may
|
|
337
|
+
// omit it, but an explicit non-admin identity must never be accepted.
|
|
338
|
+
if (typeof me.id === 'number' && me.id !== 1 || typeof me.role === 'number' && me.role !== 2 || typeof me.role === 'string' && me.role.toLowerCase() !== 'admin') {
|
|
339
|
+
throw new ReferenceAnythingError('OpenList administrator access is required', 'SOURCE_UNAVAILABLE');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async drivers(signal) { return (await this.driverInfo(signal)).map(driver => driver.name); }
|
|
343
|
+
async driverInfo(signal) {
|
|
344
|
+
const data = await this.request('/api/admin/driver/list', 'GET', undefined, signal);
|
|
345
|
+
const root = object(data);
|
|
346
|
+
const list = Array.isArray(data) ? data : Array.isArray(root.content) ? root.content : Object.entries(root).map(([name, value]) => ({ name, ...object(value) }));
|
|
347
|
+
return list.flatMap((value) => {
|
|
348
|
+
if (typeof value === 'string')
|
|
349
|
+
return [{ name: value, quickAuth: isApiPagesQuickDriver(value), fields: [] }];
|
|
350
|
+
const row = object(value);
|
|
351
|
+
if (typeof row.name !== 'string' || row.name === '')
|
|
352
|
+
return [];
|
|
353
|
+
// In OpenList v4, `common` describes the storage model and `config`
|
|
354
|
+
// describes the driver itself. Only `additional` is serialized into a
|
|
355
|
+
// storage's addition JSON.
|
|
356
|
+
const rawFields = asArray(row.additional);
|
|
357
|
+
const driverSecretDefaults = new Map();
|
|
358
|
+
const fields = rawFields.flatMap((field) => {
|
|
359
|
+
const info = object(field);
|
|
360
|
+
const name = typeof info.name === 'string' ? info.name : typeof info.key === 'string' ? info.key : '';
|
|
361
|
+
if (name === '')
|
|
362
|
+
return [];
|
|
363
|
+
const rawType = typeof info.type === 'string' ? info.type.toLowerCase() : 'text';
|
|
364
|
+
const numeric = /^(number|integer|int|int32|int64|uint|uint32|uint64|float|float32|float64)$/.test(rawType);
|
|
365
|
+
const secret = rawType === 'password' || rawType === 'secret' || isSensitiveDriverField(name);
|
|
366
|
+
const type = secret && (rawType === 'password' || rawType === 'secret') ? 'password' : numeric ? 'number' : rawType === 'boolean' || rawType === 'bool' ? 'boolean' : rawType === 'select' || rawType === 'enum' ? 'select' : 'text';
|
|
367
|
+
const options = parseDriverOptions(info.options ?? info.values);
|
|
368
|
+
const typedDefault = typedDriverDefault(type, info.default ?? info.default_value ?? info.value);
|
|
369
|
+
if (secret && typedDefault !== undefined)
|
|
370
|
+
driverSecretDefaults.set(name, typedDefault);
|
|
371
|
+
return [{ name, label: typeof info.label === 'string' ? info.label : typeof info.title === 'string' ? info.title : name, type, secret, required: info.required === true || info.required === 'true', ...(secret && typedDefault !== undefined ? { hasDefault: true } : {}), ...(secret || typedDefault === undefined ? {} : { default: typedDefault }), ...(options.length === 0 ? {} : { options }) }];
|
|
372
|
+
});
|
|
373
|
+
this.secretDefaults.set(row.name, driverSecretDefaults);
|
|
374
|
+
return [{ name: row.name, ...(typeof row.description === 'string' ? { description: row.description } : {}), quickAuth: isApiPagesQuickDriver(row.name), fields }];
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
fillSecretDefaults(driver, supplied) { return { ...Object.fromEntries(this.secretDefaults.get(driver) ?? []), ...supplied }; }
|
|
378
|
+
async mounts(signal) {
|
|
379
|
+
const rows = this.mountRows(await this.request('/api/admin/storage/list', 'GET', undefined, signal));
|
|
380
|
+
const progress = await this.indexProgress(signal).catch(() => undefined);
|
|
381
|
+
return progress === undefined ? rows : rows.map(row => ({ ...row, ...progress }));
|
|
382
|
+
}
|
|
383
|
+
async createMount(input, signal) { const data = object(await this.request('/api/admin/storage/create', 'POST', storageBody(input, {}, this.managed), signal)); return { id: typeof data.id === 'number' || typeof data.id === 'string' ? String(data.id) : '', name: input.mountPath, driver: input.driver, enabled: true, status: 'error', error: 'OpenList storage is starting' }; }
|
|
384
|
+
async updateMount(id, input, signal) { const parsedId = positiveStorageId(id); await this.request('/api/admin/storage/update', 'POST', { id: parsedId, ...storageBody(input) }, signal); return { id: String(parsedId), name: input.mountPath, driver: input.driver, enabled: true }; }
|
|
385
|
+
/** Reauthentication patches only credential fields onto the full host-side row. */
|
|
386
|
+
async updateMountPatch(id, additionPatch, signal) {
|
|
387
|
+
const parsedId = positiveStorageId(id);
|
|
388
|
+
const row = await this.storageRow(parsedId, signal);
|
|
389
|
+
const currentAddition = parseStorageAddition(row.addition);
|
|
390
|
+
const input = {
|
|
391
|
+
mountPath: typeof row.mount_path === 'string' ? row.mount_path : typeof row.name === 'string' ? row.name : '',
|
|
392
|
+
driver: typeof row.driver === 'string' ? row.driver : '',
|
|
393
|
+
addition: { ...currentAddition, ...additionPatch },
|
|
394
|
+
...(finiteNumber(row.order) === undefined ? {} : { order: finiteNumber(row.order) }),
|
|
395
|
+
...(typeof row.remark === 'string' ? { remark: row.remark } : {}),
|
|
396
|
+
};
|
|
397
|
+
if (input.mountPath === '' || input.driver === '')
|
|
398
|
+
throw new ReferenceAnythingError('OpenList storage is malformed', 'REFERENCE_READ_FAILED');
|
|
399
|
+
// The full row never crosses the Remote boundary. Preserve all upstream
|
|
400
|
+
// v4 fields (including future fields) and replace only the safe patch.
|
|
401
|
+
await this.request('/api/admin/storage/update', 'POST', { ...row, id: parsedId, ...storageBody(input, row) }, signal);
|
|
402
|
+
return this.mountRow(row);
|
|
403
|
+
}
|
|
404
|
+
async removeMount(id, signal) { await this.request(`/api/admin/storage/delete?id=${encodeURIComponent(id)}`, 'POST', undefined, signal); }
|
|
405
|
+
async disableMount(id, disabled = true, signal) { positiveStorageId(id); await this.request(`/api/admin/storage/${disabled ? 'disable' : 'enable'}?id=${encodeURIComponent(id)}`, 'POST', undefined, signal); }
|
|
406
|
+
async configureManagedIndex(signal) {
|
|
407
|
+
const data = await this.request('/api/admin/setting/get?keys=search_index,auto_update_index', 'GET', undefined, signal);
|
|
408
|
+
const items = (Array.isArray(data) ? data : Array.isArray(object(data).content) ? object(data).content : []).map(object);
|
|
409
|
+
const desired = new Map([['search_index', 'database'], ['auto_update_index', 'true']]);
|
|
410
|
+
const saved = items.filter(item => typeof item.key === 'string' && desired.has(item.key)).map(item => ({ ...item, value: desired.get(String(item.key)) }));
|
|
411
|
+
for (const [key, value] of desired)
|
|
412
|
+
if (!saved.some(item => item.key === key))
|
|
413
|
+
saved.push({ key, value });
|
|
414
|
+
await this.request('/api/admin/setting/save', 'POST', saved, signal);
|
|
415
|
+
}
|
|
416
|
+
async updateIndex(mountPath, signal) {
|
|
417
|
+
const result = object(await this.request('/api/admin/index/update', 'POST', { paths: [mountPath], max_depth: 20 }, signal));
|
|
418
|
+
if (result.__index_running === true)
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
async reindexMount(_id, signal) {
|
|
422
|
+
const result = object(await this.request('/api/admin/index/build', 'POST', undefined, signal));
|
|
423
|
+
if (result.__index_running === true)
|
|
424
|
+
return { supported: true };
|
|
425
|
+
return { supported: true };
|
|
426
|
+
}
|
|
427
|
+
async indexProgress(signal) {
|
|
428
|
+
const row = object(await this.request('/api/admin/index/progress', 'GET', undefined, signal));
|
|
429
|
+
const count = finiteNumber(row.obj_count);
|
|
430
|
+
const done = typeof row.is_done === 'boolean' ? row.is_done : undefined;
|
|
431
|
+
const failed = typeof row.error === 'string' && row.error.trim() !== '';
|
|
432
|
+
if (count === undefined && done === undefined && !failed)
|
|
433
|
+
return undefined;
|
|
434
|
+
return { indexStatus: failed ? 'failed' : done === true ? 'complete' : 'running', indexProgress: done === true ? 1 : 0, ...(count === undefined ? {} : { indexCount: count }) };
|
|
435
|
+
}
|
|
436
|
+
async request(path, method, body, signal, retried = false) {
|
|
437
|
+
if (this.credentials.token === undefined)
|
|
438
|
+
throw new ReferenceAnythingError('OpenList token is missing', 'SOURCE_UNAVAILABLE');
|
|
439
|
+
try {
|
|
440
|
+
const response = await this.fetchImpl(`${validateOpenListEndpoint(this.credentials.endpoint)}${path}`, { method, signal, headers: { Authorization: this.credentials.token, ...method === 'POST' ? { 'Content-Type': 'application/json' } : {} }, ...body === undefined ? {} : { body: JSON.stringify(body) } });
|
|
441
|
+
if (response.status === 401 && !retried && this.refreshToken !== undefined) {
|
|
442
|
+
this.credentials = await this.refreshToken(signal);
|
|
443
|
+
return this.request(path, method, body, signal, true);
|
|
444
|
+
}
|
|
445
|
+
const payload = await response.json().catch(() => ({}));
|
|
446
|
+
const row = object(payload);
|
|
447
|
+
if (row.code === 401 && !retried && this.refreshToken !== undefined) {
|
|
448
|
+
this.credentials = await this.refreshToken(signal);
|
|
449
|
+
return this.request(path, method, body, signal, true);
|
|
450
|
+
}
|
|
451
|
+
const indexRunning = path.startsWith('/api/admin/index/') && (response.status === 400 || row.code === 400) && typeof row.message === 'string' && /(running|building|indexing|in progress)/i.test(row.message);
|
|
452
|
+
if (indexRunning)
|
|
453
|
+
return { __index_running: true };
|
|
454
|
+
if (!response.ok)
|
|
455
|
+
throw new Error(`HTTP ${response.status}`);
|
|
456
|
+
if (typeof row.code === 'number' && row.code !== 200)
|
|
457
|
+
throw new Error('API rejected request');
|
|
458
|
+
return row.data ?? payload;
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
if (isAbortError(error, signal))
|
|
462
|
+
throw abortError(signal);
|
|
463
|
+
throw new ReferenceAnythingError(`OpenList ${path.split('?')[0]} failed`, 'REFERENCE_READ_FAILED');
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
mountRows(data) { const content = Array.isArray(data) ? data : Array.isArray(object(data).content) ? object(data).content : []; return content.map((row) => this.mountRow(row)); }
|
|
467
|
+
async storageRow(id, signal) {
|
|
468
|
+
const data = await this.request('/api/admin/storage/list', 'GET', undefined, signal);
|
|
469
|
+
const rows = Array.isArray(data) ? data : Array.isArray(object(data).content) ? object(data).content : [];
|
|
470
|
+
const row = rows.map(object).find(value => String(value.id) === String(id));
|
|
471
|
+
if (row === undefined)
|
|
472
|
+
throw new ReferenceAnythingError('OpenList storage was not found', 'REFERENCE_NOT_FOUND');
|
|
473
|
+
return row;
|
|
474
|
+
}
|
|
475
|
+
mountRow(value) {
|
|
476
|
+
const row = object(value);
|
|
477
|
+
const details = object(row.mount_details);
|
|
478
|
+
const disabled = row.disabled === true;
|
|
479
|
+
const capacityUsed = finiteNumber(row.used) ?? finiteNumber(row.capacity_used) ?? finiteNumber(details.used_space);
|
|
480
|
+
const capacityTotal = finiteNumber(row.total) ?? finiteNumber(row.capacity_total) ?? finiteNumber(details.total_space);
|
|
481
|
+
const progress = finiteNumber(row.index_progress);
|
|
482
|
+
const indexStatus = indexState(row.index_status);
|
|
483
|
+
const status = typeof row.status === 'string' ? row.status : '';
|
|
484
|
+
return { id: typeof row.id === 'string' || typeof row.id === 'number' ? String(row.id) : '', name: typeof row.mount_path === 'string' ? row.mount_path : typeof row.name === 'string' ? row.name : '', driver: typeof row.driver === 'string' ? row.driver : '', enabled: !disabled, ...(disabled ? { status: 'disabled' } : status === 'work' ? { status: 'ready' } : { status: 'error', error: 'OpenList storage needs attention' }), ...(capacityUsed === undefined ? {} : { capacityUsed }), ...(capacityTotal === undefined ? {} : { capacityTotal }), ...(indexStatus === undefined ? {} : { indexStatus }), ...(progress === undefined ? {} : { indexProgress: Math.min(1, Math.max(0, progress > 1 ? progress / 100 : progress)) }) };
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/** Owns only a process it launched; it never kills a user-managed OpenList. */
|
|
488
|
+
export class ManagedOpenListRuntime {
|
|
489
|
+
seams;
|
|
490
|
+
child;
|
|
491
|
+
constructor(seams = {}) {
|
|
492
|
+
this.seams = seams;
|
|
493
|
+
}
|
|
494
|
+
get running() { return this.child !== undefined && this.child.exitCode === null; }
|
|
495
|
+
async start(binary, dataDirectory, port, signal) {
|
|
496
|
+
signal?.throwIfAborted();
|
|
497
|
+
if (this.running)
|
|
498
|
+
throw new ReferenceAnythingError('Managed OpenList is already running', 'REFERENCE_SYNC_IN_PROGRESS');
|
|
499
|
+
const selectedPort = port ?? await chooseOpenListPort();
|
|
500
|
+
const endpoint = validateOpenListEndpoint(`http://127.0.0.1:${selectedPort}`, true);
|
|
501
|
+
await writeManagedOpenListConfig(dataDirectory, selectedPort, this.seams, signal);
|
|
502
|
+
signal?.throwIfAborted();
|
|
503
|
+
let spawned;
|
|
504
|
+
try {
|
|
505
|
+
spawned = (this.seams.spawn ?? nodeSpawn)(binary, ['server', '--data', dataDirectory], { stdio: 'ignore', windowsHide: true });
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
throw new ReferenceAnythingError('Managed OpenList could not be started', 'REFERENCE_READ_FAILED');
|
|
509
|
+
}
|
|
510
|
+
this.child = spawned;
|
|
511
|
+
spawned.once('exit', () => { if (this.child === spawned)
|
|
512
|
+
this.child = undefined; });
|
|
513
|
+
try {
|
|
514
|
+
await Promise.race([waitForOpenListReady(endpoint, spawned, this.seams.fetch ?? fetch, this.seams), childFailure(spawned), waitForAbort(signal)]);
|
|
515
|
+
signal?.throwIfAborted();
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
if (this.child === spawned)
|
|
519
|
+
await this.stop().catch(() => undefined);
|
|
520
|
+
if (isAbortError(error, signal))
|
|
521
|
+
throw abortError(signal);
|
|
522
|
+
throw new ReferenceAnythingError('Managed OpenList did not become ready', 'REFERENCE_READ_FAILED');
|
|
523
|
+
}
|
|
524
|
+
return endpoint;
|
|
525
|
+
}
|
|
526
|
+
/** Generate an administrator password without exposing it in argv. */
|
|
527
|
+
async setAdminPassword(binary, dataDirectory, _password, signal, mutationState) {
|
|
528
|
+
// Cancellation is safe only before spawning. `admin random` mutates the
|
|
529
|
+
// database, so after spawn we must learn and return the committed password.
|
|
530
|
+
signal?.throwIfAborted();
|
|
531
|
+
const mutation = acquireAdminMutation(dataDirectory);
|
|
532
|
+
let child;
|
|
533
|
+
try {
|
|
534
|
+
child = (this.seams.spawn ?? nodeSpawn)(binary, ['admin', 'random', '--data', dataDirectory], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
releaseAdminMutation(mutation.key, mutation.owner);
|
|
538
|
+
throw new ReferenceAnythingError('Could not generate OpenList admin password', 'REFERENCE_READ_FAILED');
|
|
539
|
+
}
|
|
540
|
+
// From successful spawn until a password is parsed, the database mutation
|
|
541
|
+
// may have committed even when the process later fails or times out.
|
|
542
|
+
mutationState?.('unknown');
|
|
543
|
+
// Only `close` proves that all process resources are gone. In particular,
|
|
544
|
+
// neither kill() returning true nor an `error` event releases the lock.
|
|
545
|
+
child.once('close', () => releaseAdminMutation(mutation.key, mutation.owner));
|
|
546
|
+
const output = collectChildOutput(child);
|
|
547
|
+
try {
|
|
548
|
+
await waitForChildClose(child);
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
try {
|
|
552
|
+
child.kill();
|
|
553
|
+
}
|
|
554
|
+
catch { }
|
|
555
|
+
try {
|
|
556
|
+
await waitForChildClose(child, 500);
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
try {
|
|
560
|
+
child.kill('SIGKILL');
|
|
561
|
+
}
|
|
562
|
+
catch { }
|
|
563
|
+
try {
|
|
564
|
+
await waitForChildClose(child, 500);
|
|
565
|
+
}
|
|
566
|
+
catch { }
|
|
567
|
+
}
|
|
568
|
+
throw new ReferenceAnythingError('Could not generate OpenList admin password', 'REFERENCE_READ_FAILED');
|
|
569
|
+
}
|
|
570
|
+
const password = parseRandomAdminPassword(await output);
|
|
571
|
+
if (password === undefined)
|
|
572
|
+
throw new ReferenceAnythingError('Could not parse generated OpenList admin password', 'REFERENCE_READ_FAILED');
|
|
573
|
+
mutationState?.('committed');
|
|
574
|
+
return password;
|
|
575
|
+
}
|
|
576
|
+
async stop() {
|
|
577
|
+
const child = this.child;
|
|
578
|
+
if (child === undefined)
|
|
579
|
+
return;
|
|
580
|
+
if (child.exitCode === null)
|
|
581
|
+
child.kill();
|
|
582
|
+
try {
|
|
583
|
+
await waitForProcessTermination(child, 500);
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
if (child.exitCode === null)
|
|
587
|
+
child.kill('SIGKILL');
|
|
588
|
+
try {
|
|
589
|
+
await waitForProcessTermination(child, 500);
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
throw new ReferenceAnythingError('Managed OpenList did not exit', 'REFERENCE_READ_FAILED');
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (this.child === child)
|
|
596
|
+
this.child = undefined;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/** v4.2.2 reads `scheme.address` and `scheme.http_port` from data/config.json. */
|
|
600
|
+
export async function writeManagedOpenListConfig(dataDirectory, port, seams = {}, signal) {
|
|
601
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
602
|
+
throw new ReferenceAnythingError('OpenList port is invalid', 'REFERENCE_INVALID_CONFIG');
|
|
603
|
+
await (seams.mkdir ?? mkdir)(dataDirectory, { recursive: true, mode: 0o700 });
|
|
604
|
+
const configPath = join(dataDirectory, 'config.json');
|
|
605
|
+
const existing = await readJsonObject(configPath, seams);
|
|
606
|
+
const existingScheme = object(existing.scheme);
|
|
607
|
+
const config = { ...existing, jwt_secret: typeof existing.jwt_secret === 'string' && existing.jwt_secret !== '' ? existing.jwt_secret : randomBytes(24).toString('hex'), token_expires_in: typeof existing.token_expires_in === 'number' ? existing.token_expires_in : 48, database: Object.keys(object(existing.database)).length === 0 ? { type: 'sqlite3', db_file: join(dataDirectory, 'data.db'), table_prefix: 'x_' } : existing.database, scheme: { ...existingScheme, address: '127.0.0.1', http_port: port, https_port: -1, force_https: false }, temp_dir: typeof existing.temp_dir === 'string' ? existing.temp_dir : join(dataDirectory, 'temp'), bleve_dir: typeof existing.bleve_dir === 'string' ? existing.bleve_dir : join(dataDirectory, 'bleve') };
|
|
608
|
+
await securePath(dataDirectory, true, seams.chmod ?? chmod, seams);
|
|
609
|
+
const temporary = join(dataDirectory, `.config-${process.pid}-${randomBytes(8).toString('hex')}.tmp`);
|
|
610
|
+
await (seams.writeFile ?? writeFile)(temporary, JSON.stringify(config), { encoding: 'utf8', mode: 0o600 });
|
|
611
|
+
await securePath(temporary, false, seams.chmod ?? chmod, seams);
|
|
612
|
+
signal?.throwIfAborted();
|
|
613
|
+
try {
|
|
614
|
+
await (seams.rename ?? rename)(temporary, configPath);
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
throw new ReferenceAnythingError('Could not replace OpenList config', 'REFERENCE_READ_FAILED');
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
|
|
621
|
+
async function fsyncPath(path) { const handle = await import('node:fs/promises').then(module => module.open(path, 'r+')); try {
|
|
622
|
+
await handle.sync();
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
await handle.close();
|
|
626
|
+
} }
|
|
627
|
+
function isMissing(cause) { return typeof cause === 'object' && cause !== null && cause.code === 'ENOENT'; }
|
|
628
|
+
function collectChildOutput(child) { return new Promise(resolve => { let value = ''; const append = (chunk) => { if (value.length >= 16_384)
|
|
629
|
+
return; value += String(chunk).slice(0, 16_384 - value.length); }; child.stdout?.on('data', append); child.stderr?.on('data', append); child.once('close', () => resolve(value)); }); }
|
|
630
|
+
export function parseRandomAdminPassword(output) { const matches = output.split(/\r?\n/).flatMap(line => { const match = line.match(/^password:\s*(\S+)\s*$/); return match?.[1] && match[1].length >= 8 ? [match[1]] : []; }); return matches.at(-1); }
|
|
631
|
+
function finiteNumber(value) { return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined; }
|
|
632
|
+
function typedDriverDefault(type, value) {
|
|
633
|
+
if (type === 'boolean') {
|
|
634
|
+
if (typeof value === 'boolean')
|
|
635
|
+
return value;
|
|
636
|
+
if (typeof value === 'string' && /^(true|false)$/i.test(value.trim()))
|
|
637
|
+
return value.trim().toLowerCase() === 'true';
|
|
638
|
+
return undefined;
|
|
639
|
+
}
|
|
640
|
+
if (type === 'number') {
|
|
641
|
+
const parsed = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN;
|
|
642
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
643
|
+
}
|
|
644
|
+
return typeof value === 'string' ? value : undefined;
|
|
645
|
+
}
|
|
646
|
+
function asArray(value) { return Array.isArray(value) ? value : value !== null && typeof value === 'object' ? Object.entries(value).map(([name, field]) => ({ name, ...object(field) })) : []; }
|
|
647
|
+
function parseDriverOptions(value) {
|
|
648
|
+
const raw = typeof value === 'string' ? value.split(',') : asArray(value);
|
|
649
|
+
return raw.flatMap((option) => typeof option === 'string' ? option.trim() === '' ? [] : [{ label: option.trim(), value: option.trim() }] : typeof object(option).value === 'string' ? [{ label: typeof object(option).label === 'string' ? object(option).label : object(option).value, value: object(option).value }] : []);
|
|
650
|
+
}
|
|
651
|
+
export function isSensitiveDriverField(name) {
|
|
652
|
+
const normalized = name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
653
|
+
return /(pwd|pass|token|secret|cookie|auth|credential|private|key|salt|sign|captcha|code)/.test(normalized);
|
|
654
|
+
}
|
|
655
|
+
function indexState(value) { return value === 'idle' || value === 'running' || value === 'complete' || value === 'failed' ? value : undefined; }
|
|
656
|
+
function storageBody(input, existing = {}, managedDefault = false) {
|
|
657
|
+
if (!input.mountPath.startsWith('/') || input.mountPath.includes('..'))
|
|
658
|
+
throw new ReferenceAnythingError('OpenList mount path must be absolute', 'REFERENCE_INVALID_CONFIG');
|
|
659
|
+
return { mount_path: input.mountPath, driver: input.driver, addition: JSON.stringify(input.addition), order: input.order ?? finiteNumber(existing.order) ?? 0, remark: input.remark ?? (typeof existing.remark === 'string' ? existing.remark : ''), cache_expiration: finiteNumber(existing.cache_expiration) ?? 30, ...(Object.hasOwn(existing, 'web_proxy') ? { web_proxy: existing.web_proxy === true } : managedDefault ? { web_proxy: true } : {}), webdav_policy: typeof existing.webdav_policy === 'string' ? existing.webdav_policy : 'native_proxy', down_proxy_url: typeof existing.down_proxy_url === 'string' ? existing.down_proxy_url : '', extract_folder: typeof existing.extract_folder === 'string' ? existing.extract_folder : 'front', enable_sign: existing.enable_sign === true, order_by: typeof existing.order_by === 'string' ? existing.order_by : 'name', order_direction: typeof existing.order_direction === 'string' ? existing.order_direction : 'asc' };
|
|
660
|
+
}
|
|
661
|
+
const API_PAGES_QUICK_DRIVERS = new Set(['onedrive', 'onedriveapp', 'aliyundrive', 'aliyundriveopen', 'aliyunpan', 'baidu', 'baidunetdisk', 'baiduphoto', 'quark', 'quarktv', '115', '115cloud', '123pan', '123panopen', '123panlink', 'dropbox', 'googledrive', 'googlephoto', 'googlephotos', 'yandex', 'yandexdisk']);
|
|
662
|
+
export function isApiPagesQuickDriver(name) { return API_PAGES_QUICK_DRIVERS.has(name.toLowerCase().replace(/[^a-z0-9]/g, '')); }
|
|
663
|
+
function parseStorageAddition(value) {
|
|
664
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value))
|
|
665
|
+
return value;
|
|
666
|
+
if (typeof value !== 'string')
|
|
667
|
+
return {};
|
|
668
|
+
try {
|
|
669
|
+
return object(JSON.parse(value));
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
return {};
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function tarEntry(bytes, binary) {
|
|
676
|
+
let offset = 0;
|
|
677
|
+
while (offset + 512 <= bytes.byteLength) {
|
|
678
|
+
const header = bytes.subarray(offset, offset + 512);
|
|
679
|
+
if (header.every(byte => byte === 0))
|
|
680
|
+
return undefined;
|
|
681
|
+
const name = ascii(header.subarray(0, 100));
|
|
682
|
+
const size = Number.parseInt(ascii(header.subarray(124, 136)).trim(), 8);
|
|
683
|
+
if (!Number.isSafeInteger(size) || size < 0)
|
|
684
|
+
throw new ReferenceAnythingError('OpenList archive is malformed', 'REFERENCE_READ_FAILED');
|
|
685
|
+
const data = offset + 512;
|
|
686
|
+
const end = data + size;
|
|
687
|
+
if (end > bytes.byteLength)
|
|
688
|
+
throw new ReferenceAnythingError('OpenList archive is truncated', 'REFERENCE_READ_FAILED');
|
|
689
|
+
if (name.split('/').at(-1) === binary && !name.includes('..'))
|
|
690
|
+
return bytes.slice(data, end);
|
|
691
|
+
offset = data + Math.ceil(size / 512) * 512;
|
|
692
|
+
}
|
|
693
|
+
return undefined;
|
|
694
|
+
}
|
|
695
|
+
function zipEntry(bytes, binary) {
|
|
696
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
697
|
+
let offset = 0;
|
|
698
|
+
while (offset + 30 <= bytes.byteLength && view.getUint32(offset, true) === 0x04034b50) {
|
|
699
|
+
const flags = view.getUint16(offset + 6, true);
|
|
700
|
+
const method = view.getUint16(offset + 8, true);
|
|
701
|
+
const compressed = view.getUint32(offset + 18, true);
|
|
702
|
+
const plain = view.getUint32(offset + 22, true);
|
|
703
|
+
const nameLength = view.getUint16(offset + 26, true);
|
|
704
|
+
const extraLength = view.getUint16(offset + 28, true);
|
|
705
|
+
if ((flags & 8) !== 0)
|
|
706
|
+
throw new ReferenceAnythingError('OpenList zip uses unsupported data descriptors', 'REFERENCE_READ_FAILED');
|
|
707
|
+
const start = offset + 30 + nameLength + extraLength;
|
|
708
|
+
const end = start + compressed;
|
|
709
|
+
if (end > bytes.byteLength)
|
|
710
|
+
throw new ReferenceAnythingError('OpenList archive is truncated', 'REFERENCE_READ_FAILED');
|
|
711
|
+
const name = new TextDecoder().decode(bytes.subarray(offset + 30, offset + 30 + nameLength));
|
|
712
|
+
const data = bytes.subarray(start, end);
|
|
713
|
+
if (name.split('/').at(-1) === binary && !name.includes('..'))
|
|
714
|
+
return method === 0 ? data.slice() : method === 8 ? new Uint8Array(inflateRawSync(data)) : (() => { throw new ReferenceAnythingError('OpenList zip compression is unsupported', 'REFERENCE_READ_FAILED'); })();
|
|
715
|
+
if (plain < 0)
|
|
716
|
+
throw new ReferenceAnythingError('OpenList archive is malformed', 'REFERENCE_READ_FAILED');
|
|
717
|
+
offset = end;
|
|
718
|
+
}
|
|
719
|
+
return undefined;
|
|
720
|
+
}
|
|
721
|
+
function ascii(bytes) { return new TextDecoder().decode(bytes).replace(/\0.*$/, ''); }
|
|
722
|
+
function positiveStorageId(value) { if (!/^\d+$/.test(value))
|
|
723
|
+
throw new ReferenceAnythingError('OpenList storage id is invalid', 'REFERENCE_INVALID_CONFIG'); const id = Number(value); if (!Number.isSafeInteger(id) || id < 1)
|
|
724
|
+
throw new ReferenceAnythingError('OpenList storage id is invalid', 'REFERENCE_INVALID_CONFIG'); return id; }
|
|
725
|
+
async function readJsonObject(path, seams) {
|
|
726
|
+
let text;
|
|
727
|
+
try {
|
|
728
|
+
text = await (seams.readFile ?? readFile)(path, 'utf8');
|
|
729
|
+
}
|
|
730
|
+
catch (cause) {
|
|
731
|
+
if (typeof cause === 'object' && cause !== null && cause.code === 'ENOENT')
|
|
732
|
+
return {};
|
|
733
|
+
throw new ReferenceAnythingError('Could not read existing OpenList config', 'REFERENCE_READ_FAILED');
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
const value = JSON.parse(text);
|
|
737
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
738
|
+
throw new Error('not object');
|
|
739
|
+
return value;
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
throw new ReferenceAnythingError('Existing OpenList config is malformed', 'REFERENCE_INVALID_CONFIG');
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
async function waitForOpenListReady(endpoint, child, fetchImpl, seams) {
|
|
746
|
+
// A first OpenList start initializes every bundled offline-download driver.
|
|
747
|
+
// Some of those drivers probe unavailable localhost services and can make a
|
|
748
|
+
// perfectly healthy start take close to a minute on Windows. Keep the
|
|
749
|
+
// individual probes short, but allow enough total time for that one-time
|
|
750
|
+
// initialization to finish.
|
|
751
|
+
for (let attempt = 0; attempt < 180; attempt += 1) {
|
|
752
|
+
if (child.exitCode !== null)
|
|
753
|
+
throw new Error('child exited');
|
|
754
|
+
try {
|
|
755
|
+
const ping = await fetchImpl(`${endpoint}/ping`, { method: 'GET', signal: AbortSignal.timeout(500) });
|
|
756
|
+
if (ping.ok && (await ping.text()) === 'pong' && child.exitCode === null)
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
catch { }
|
|
760
|
+
// A refused connection returns immediately, so the delay (rather than the
|
|
761
|
+
// 500 ms request timeout) defines the real wall-clock startup window.
|
|
762
|
+
await (seams.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))))(500);
|
|
763
|
+
}
|
|
764
|
+
throw new Error('timeout');
|
|
765
|
+
}
|
|
766
|
+
function childFailure(child) { return new Promise((_, reject) => { child.once('error', reject); child.once('exit', code => reject(new Error(`exit ${code ?? 'unknown'}`))); }); }
|
|
767
|
+
function waitForAbort(signal) {
|
|
768
|
+
if (signal === undefined)
|
|
769
|
+
return new Promise(() => undefined);
|
|
770
|
+
if (signal.aborted)
|
|
771
|
+
return Promise.reject(abortError(signal));
|
|
772
|
+
return new Promise((_, reject) => signal.addEventListener('abort', () => reject(abortError(signal)), { once: true }));
|
|
773
|
+
}
|
|
774
|
+
function waitForChildClose(child, timeoutMs = 15_000) {
|
|
775
|
+
return new Promise((resolve, reject) => {
|
|
776
|
+
let settled = false;
|
|
777
|
+
const finish = (callback) => { if (settled)
|
|
778
|
+
return; settled = true; clearTimeout(timeout); child.removeListener('error', onError); child.removeListener('close', onClose); callback(); };
|
|
779
|
+
const timeout = setTimeout(() => finish(() => reject(new Error('admin password command timed out'))), timeoutMs);
|
|
780
|
+
const onError = () => finish(() => reject(new Error('spawn error')));
|
|
781
|
+
const onClose = (code) => finish(() => code === 0 ? resolve() : reject(new Error(`exit ${code ?? 'unknown'}`)));
|
|
782
|
+
child.once('error', onError);
|
|
783
|
+
child.once('close', onClose);
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
function waitForProcessTermination(child, timeoutMs) {
|
|
787
|
+
if (child.exitCode !== null)
|
|
788
|
+
return Promise.resolve();
|
|
789
|
+
return new Promise((resolve, reject) => {
|
|
790
|
+
const timeout = setTimeout(() => { cleanup(); reject(new Error('process exit timed out')); }, timeoutMs);
|
|
791
|
+
const cleanup = () => { clearTimeout(timeout); child.removeListener('exit', onExit); child.removeListener('error', onError); };
|
|
792
|
+
const onExit = () => { cleanup(); resolve(); };
|
|
793
|
+
const onError = () => { cleanup(); reject(new Error('process exit failed')); };
|
|
794
|
+
child.once('exit', onExit);
|
|
795
|
+
child.once('error', onError);
|
|
796
|
+
// Avoid missing a synchronous exit between the first check and listeners.
|
|
797
|
+
if (child.exitCode !== null)
|
|
798
|
+
onExit();
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
function abortError(signal) { return signal?.reason instanceof Error ? signal.reason : new DOMException('The operation was aborted', 'AbortError'); }
|
|
802
|
+
function isAbortError(error, signal) { return signal?.aborted === true || error instanceof DOMException && error.name === 'AbortError' || error instanceof Error && error.name === 'AbortError'; }
|
|
803
|
+
async function securePath(path, directory, chmodImpl, seams, windows = platform() === 'win32') {
|
|
804
|
+
if (windows) {
|
|
805
|
+
if (seams.securePermissions === undefined)
|
|
806
|
+
throw new ReferenceAnythingError('OpenList credential storage requires a Windows ACL seam', 'REFERENCE_READ_FAILED');
|
|
807
|
+
await seams.securePermissions(path, directory).catch(() => { throw new ReferenceAnythingError('Could not secure OpenList credentials', 'REFERENCE_READ_FAILED'); });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
await chmodImpl(path, directory ? 0o700 : 0o600).catch(() => { throw new ReferenceAnythingError('Could not secure OpenList credentials', 'REFERENCE_READ_FAILED'); });
|
|
811
|
+
}
|
|
812
|
+
async function secureExecutable(path, chmodImpl, seams, windows) {
|
|
813
|
+
if (windows) {
|
|
814
|
+
if (seams.securePermissions === undefined)
|
|
815
|
+
throw new ReferenceAnythingError('OpenList executable requires a Windows ACL seam', 'REFERENCE_READ_FAILED');
|
|
816
|
+
await seams.securePermissions(path, false).catch(() => { throw new ReferenceAnythingError('Could not secure OpenList executable', 'REFERENCE_READ_FAILED'); });
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
await chmodImpl(path, 0o700).catch(() => { throw new ReferenceAnythingError('Could not secure OpenList executable', 'REFERENCE_READ_FAILED'); });
|
|
820
|
+
}
|
|
821
|
+
/** Recover the only crash window in Windows' two-rename executable replacement. */
|
|
822
|
+
export async function recoverInterruptedReplacement(target, backup, marker, seams = {}, windows = process.platform === 'win32', owner) {
|
|
823
|
+
if (!windows)
|
|
824
|
+
return;
|
|
825
|
+
const activeOwner = activeExecutableReplacements.get(target);
|
|
826
|
+
if (activeOwner !== undefined && activeOwner !== owner)
|
|
827
|
+
return;
|
|
828
|
+
const read = seams.readFile ?? readFile;
|
|
829
|
+
const move = seams.rename ?? rename;
|
|
830
|
+
const remove = seams.unlink ?? unlink;
|
|
831
|
+
try {
|
|
832
|
+
await read(marker, 'utf8');
|
|
833
|
+
}
|
|
834
|
+
catch (cause) {
|
|
835
|
+
if (isMissing(cause)) {
|
|
836
|
+
// A backup without a pending marker belongs to a replacement whose
|
|
837
|
+
// commit point completed but whose best-effort cleanup was interrupted.
|
|
838
|
+
try {
|
|
839
|
+
await remove(backup);
|
|
840
|
+
}
|
|
841
|
+
catch (cleanupCause) {
|
|
842
|
+
if (!isMissing(cleanupCause))
|
|
843
|
+
throw new ReferenceAnythingError('Could not clean up a committed OpenList executable replacement', 'REFERENCE_READ_FAILED');
|
|
844
|
+
}
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
throw new ReferenceAnythingError('Could not inspect OpenList replacement recovery state', 'REFERENCE_READ_FAILED');
|
|
848
|
+
}
|
|
849
|
+
try {
|
|
850
|
+
try {
|
|
851
|
+
await read(target);
|
|
852
|
+
}
|
|
853
|
+
catch (cause) {
|
|
854
|
+
if (!isMissing(cause))
|
|
855
|
+
throw cause;
|
|
856
|
+
try {
|
|
857
|
+
await move(backup, target);
|
|
858
|
+
}
|
|
859
|
+
catch (restoreCause) {
|
|
860
|
+
// A fresh install can crash after writing the marker but before any
|
|
861
|
+
// previous target existed. There is nothing to restore in that case.
|
|
862
|
+
if (!isMissing(restoreCause))
|
|
863
|
+
throw restoreCause;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
await remove(marker);
|
|
867
|
+
await remove(backup).catch(cause => { if (!isMissing(cause))
|
|
868
|
+
throw cause; });
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
throw new ReferenceAnythingError('Could not recover interrupted OpenList executable replacement', 'REFERENCE_READ_FAILED');
|
|
872
|
+
}
|
|
873
|
+
}
|