@tnnevol/dsh-fnos 0.1.1-rc.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/compatibility.json +24 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +50259 -0
- package/lib/index.d.ts +125 -0
- package/lib/index.js +800 -0
- package/lib/style.css +1 -0
- package/package.json +116 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { access, readdir, realpath, stat } from "node:fs/promises";
|
|
5
|
+
import { posix } from "node:path";
|
|
6
|
+
import { request } from "node:http";
|
|
7
|
+
//#region src/fnos-api.ts
|
|
8
|
+
/** Minimal server-side client for the fnOS open API gateway. */
|
|
9
|
+
const FNOS_API_SOCKET = "/var/run/trim_open_gateway_apiscope.socket";
|
|
10
|
+
const FNOS_API_PATH = "/api/v1/trimapp";
|
|
11
|
+
const RESPONSE_LIMIT = 131072;
|
|
12
|
+
const REQUEST_TIMEOUT_MS = 1e4;
|
|
13
|
+
var FnOsApiError = class extends Error {
|
|
14
|
+
apiCode;
|
|
15
|
+
constructor(apiCode, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.apiCode = apiCode;
|
|
18
|
+
this.name = "FnOsApiError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function apiMessage(value) {
|
|
22
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim().slice(0, 300) : "fnOS API request failed";
|
|
23
|
+
}
|
|
24
|
+
function requestId() {
|
|
25
|
+
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
26
|
+
}
|
|
27
|
+
/** Call one fnOS API without persisting or exposing TRIM_API_TOKEN. */
|
|
28
|
+
async function callFnOsApi(req, data = {}, options = {}) {
|
|
29
|
+
const token = (options.token ?? process.env.TRIM_API_TOKEN)?.trim();
|
|
30
|
+
if (token === void 0 || token.length === 0) throw new FnOsApiError(void 0, "fnOS API token is unavailable");
|
|
31
|
+
const appName = options.appName?.trim() || process.env.TRIM_APPNAME?.trim() || "fn-deepseek-harness";
|
|
32
|
+
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
33
|
+
const payload = JSON.stringify({
|
|
34
|
+
reqId: requestId(),
|
|
35
|
+
req,
|
|
36
|
+
appName,
|
|
37
|
+
data
|
|
38
|
+
});
|
|
39
|
+
const response = await new Promise((resolve, reject) => {
|
|
40
|
+
const reqHandle = request({
|
|
41
|
+
socketPath: options.socketPath ?? FNOS_API_SOCKET,
|
|
42
|
+
path: FNOS_API_PATH,
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
accept: "application/json",
|
|
46
|
+
"content-type": "application/json",
|
|
47
|
+
"content-length": Buffer.byteLength(payload),
|
|
48
|
+
authorization: `Bearer ${token}`
|
|
49
|
+
}
|
|
50
|
+
}, (responseMessage) => {
|
|
51
|
+
let body = "";
|
|
52
|
+
let size = 0;
|
|
53
|
+
responseMessage.setEncoding("utf8");
|
|
54
|
+
responseMessage.on("data", (chunk) => {
|
|
55
|
+
size += Buffer.byteLength(chunk);
|
|
56
|
+
if (size <= RESPONSE_LIMIT) body += chunk;
|
|
57
|
+
});
|
|
58
|
+
responseMessage.on("end", () => {
|
|
59
|
+
if (size > RESPONSE_LIMIT) {
|
|
60
|
+
reject(new FnOsApiError(void 0, "fnOS API response is too large"));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
resolve({
|
|
64
|
+
statusCode: responseMessage.statusCode ?? 0,
|
|
65
|
+
body
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
responseMessage.on("error", reject);
|
|
69
|
+
});
|
|
70
|
+
reqHandle.setTimeout(timeoutMs, () => {
|
|
71
|
+
reqHandle.destroy(/* @__PURE__ */ new Error("fnOS API request timed out"));
|
|
72
|
+
});
|
|
73
|
+
reqHandle.on("error", reject);
|
|
74
|
+
reqHandle.end(payload);
|
|
75
|
+
});
|
|
76
|
+
if (response.statusCode < 200 || response.statusCode >= 300) throw new FnOsApiError(void 0, `fnOS API returned HTTP ${String(response.statusCode)}`);
|
|
77
|
+
let envelope;
|
|
78
|
+
try {
|
|
79
|
+
envelope = JSON.parse(response.body);
|
|
80
|
+
} catch {
|
|
81
|
+
throw new FnOsApiError(void 0, "fnOS API returned invalid JSON");
|
|
82
|
+
}
|
|
83
|
+
const code = typeof envelope.code === "number" ? envelope.code : void 0;
|
|
84
|
+
if (code !== 0) throw new FnOsApiError(code, apiMessage(envelope.msg));
|
|
85
|
+
return envelope.data;
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/authorized-directories-contract.ts
|
|
89
|
+
/** Browser/Host contract for fnOS shared-directory management. */
|
|
90
|
+
/** Settings namespace used to pair the Host namespace with the Client card. */
|
|
91
|
+
const FNOS_AUTHORIZED_DIRECTORIES_SETTINGS_NAMESPACE = "dsh-fnos-authorized-directories";
|
|
92
|
+
/** Same-origin route that lists the directories currently authorized for the app. */
|
|
93
|
+
const FNOS_AUTHORIZED_DIRECTORIES_PATH = "/plugins/dsh-fnos/authorized-directories";
|
|
94
|
+
/** Same-origin route that removes one application directory ACL. */
|
|
95
|
+
const FNOS_AUTHORIZED_DIRECTORIES_DELETE_PATH = "/plugins/dsh-fnos/authorized-directories/delete";
|
|
96
|
+
/** Same-origin route that converts internal fnOS paths to readable paths. */
|
|
97
|
+
const FNOS_PATH_CONVERSION_PATH = "/plugins/dsh-fnos/paths/convert";
|
|
98
|
+
/** Same-origin route that validates a DSH path before fnOS opens it. */
|
|
99
|
+
const FNOS_PATH_OPEN_VALIDATION_PATH = "/plugins/dsh-fnos/paths/open/validate";
|
|
100
|
+
/** Same-origin route that lists one level below an authorized NAS directory. */
|
|
101
|
+
const FNOS_AUTHORIZED_ENTRIES_PATH = "/plugins/dsh-fnos/authorized-directories/entries";
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/authorized-directories.ts
|
|
104
|
+
const BODY_LIMIT = 65536;
|
|
105
|
+
/** Paths removed during this process must not be reintroduced from a stale env snapshot. */
|
|
106
|
+
const removedAccessiblePaths = /* @__PURE__ */ new Set();
|
|
107
|
+
/** fnOS supplies user-authorized application paths through this variable. */
|
|
108
|
+
const FNOS_ACCESSIBLE_PATHS_ENV = "TRIM_DATA_ACCESSIBLE_PATHS";
|
|
109
|
+
/** fnOS supplies the application's declared shared data paths through this variable. */
|
|
110
|
+
const FNOS_DATA_SHARE_PATHS_ENV = "TRIM_DATA_SHARE_PATHS";
|
|
111
|
+
const AUTHORIZED_ENTRIES_LIMIT = 500;
|
|
112
|
+
var AuthorizedEntriesError = class extends Error {
|
|
113
|
+
code;
|
|
114
|
+
status;
|
|
115
|
+
constructor(code, status, message = code) {
|
|
116
|
+
super(message);
|
|
117
|
+
this.code = code;
|
|
118
|
+
this.status = status;
|
|
119
|
+
this.name = "AuthorizedEntriesError";
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
function header(req, name) {
|
|
123
|
+
const value = req.headers[name];
|
|
124
|
+
return Array.isArray(value) ? value[0] : value;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Read the user identity supplied by the fnOS unified gateway.
|
|
128
|
+
*
|
|
129
|
+
* `TRIM_UID` is the application service user and must not be used here. The
|
|
130
|
+
* current browser user is carried by `X-Trim-Userid` on gateway requests.
|
|
131
|
+
*/
|
|
132
|
+
function gatewayUserId(req) {
|
|
133
|
+
const value = req === void 0 ? void 0 : header(req, "x-trim-userid")?.trim();
|
|
134
|
+
if (value === void 0 || !/^\d+$/u.test(value)) return void 0;
|
|
135
|
+
const uid = Number(value);
|
|
136
|
+
return Number.isSafeInteger(uid) && uid >= 0 ? uid : void 0;
|
|
137
|
+
}
|
|
138
|
+
function firstForwarded(value) {
|
|
139
|
+
return value?.split(",")[0]?.trim() || void 0;
|
|
140
|
+
}
|
|
141
|
+
function requestOrigin(req) {
|
|
142
|
+
const host = firstForwarded(header(req, "x-forwarded-host")) ?? header(req, "host");
|
|
143
|
+
if (host === void 0) return void 0;
|
|
144
|
+
const proto = firstForwarded(header(req, "x-forwarded-proto")) ?? (req.socket.encrypted === true ? "https" : "http");
|
|
145
|
+
try {
|
|
146
|
+
return new URL(`${proto}://${host}`).origin;
|
|
147
|
+
} catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function normalizeOrigin(raw) {
|
|
152
|
+
try {
|
|
153
|
+
return new URL(raw).origin;
|
|
154
|
+
} catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function localPeer(req) {
|
|
159
|
+
const remote = req.socket.remoteAddress;
|
|
160
|
+
return remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
161
|
+
}
|
|
162
|
+
/** Accept the NAS gateway's same-origin requests, but reject cross-site calls. */
|
|
163
|
+
function trustedRequest(req) {
|
|
164
|
+
if (header(req, "sec-fetch-site")?.trim().toLowerCase() === "cross-site") return false;
|
|
165
|
+
const origin = header(req, "origin");
|
|
166
|
+
if (origin !== void 0) return requestOrigin(req) === normalizeOrigin(origin);
|
|
167
|
+
return localPeer(req);
|
|
168
|
+
}
|
|
169
|
+
function json(res, status, value) {
|
|
170
|
+
res.writeHead(status, {
|
|
171
|
+
"content-type": "application/json; charset=utf-8",
|
|
172
|
+
"cache-control": "no-store",
|
|
173
|
+
"x-content-type-options": "nosniff"
|
|
174
|
+
});
|
|
175
|
+
res.end(JSON.stringify(value));
|
|
176
|
+
}
|
|
177
|
+
function readJsonBody(req) {
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
let body = "";
|
|
180
|
+
let size = 0;
|
|
181
|
+
let finished = false;
|
|
182
|
+
req.setEncoding("utf8");
|
|
183
|
+
req.on("data", (chunk) => {
|
|
184
|
+
if (finished) return;
|
|
185
|
+
size += Buffer.byteLength(chunk);
|
|
186
|
+
if (size > BODY_LIMIT) {
|
|
187
|
+
finished = true;
|
|
188
|
+
reject(/* @__PURE__ */ new Error("request body is too large"));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
body += chunk;
|
|
192
|
+
});
|
|
193
|
+
req.on("end", () => {
|
|
194
|
+
if (finished) return;
|
|
195
|
+
finished = true;
|
|
196
|
+
try {
|
|
197
|
+
resolve(JSON.parse(body));
|
|
198
|
+
} catch {
|
|
199
|
+
reject(/* @__PURE__ */ new Error("request body is not valid JSON"));
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
req.on("error", (error) => {
|
|
203
|
+
if (finished) return;
|
|
204
|
+
finished = true;
|
|
205
|
+
reject(error);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** Normalize one fnOS volume path for display and exact delete matching. */
|
|
210
|
+
function normalizeAuthorizedPath(value) {
|
|
211
|
+
if (typeof value !== "string") return void 0;
|
|
212
|
+
const path = value.trim();
|
|
213
|
+
if (path.length === 0 || !path.startsWith("/") || path.includes("\0")) return void 0;
|
|
214
|
+
if (path === "/") return path;
|
|
215
|
+
return path.replace(/\/+$/u, "");
|
|
216
|
+
}
|
|
217
|
+
/** Normalize a path used for the open authorization boundary. */
|
|
218
|
+
function normalizePathForAuthorization(value) {
|
|
219
|
+
const path = normalizeAuthorizedPath(value);
|
|
220
|
+
if (path === void 0) return void 0;
|
|
221
|
+
return normalizeAuthorizedPath(posix.normalize(path));
|
|
222
|
+
}
|
|
223
|
+
/** Check a target path against authorized roots without confusing path prefixes. */
|
|
224
|
+
function isPathWithinAuthorizedDirectory(pathValue, rootsValue) {
|
|
225
|
+
const path = normalizePathForAuthorization(pathValue);
|
|
226
|
+
if (path === void 0) return false;
|
|
227
|
+
return normalizeAuthorizedPaths(rootsValue).map(normalizePathForAuthorization).filter((root) => root !== void 0).some((root) => root === "/" || path === root || path.startsWith(`${root}/`));
|
|
228
|
+
}
|
|
229
|
+
/** Keep API order while removing malformed and duplicate paths. */
|
|
230
|
+
function normalizeAuthorizedPaths(value) {
|
|
231
|
+
if (!Array.isArray(value)) return [];
|
|
232
|
+
const paths = [];
|
|
233
|
+
const seen = /* @__PURE__ */ new Set();
|
|
234
|
+
for (const entry of value) {
|
|
235
|
+
const path = normalizeAuthorizedPath(entry);
|
|
236
|
+
if (path === void 0 || seen.has(path)) continue;
|
|
237
|
+
seen.add(path);
|
|
238
|
+
paths.push(path);
|
|
239
|
+
}
|
|
240
|
+
return paths;
|
|
241
|
+
}
|
|
242
|
+
function userAclEntries(value) {
|
|
243
|
+
if (Array.isArray(value)) return value.filter((entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry));
|
|
244
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return [];
|
|
245
|
+
const record = value;
|
|
246
|
+
for (const key of [
|
|
247
|
+
"result",
|
|
248
|
+
"paths",
|
|
249
|
+
"data"
|
|
250
|
+
]) {
|
|
251
|
+
const entries = userAclEntries(record[key]);
|
|
252
|
+
if (entries.length > 0) return entries;
|
|
253
|
+
}
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
function userAclResult(value) {
|
|
257
|
+
const readable = /* @__PURE__ */ new Set();
|
|
258
|
+
for (const entry of userAclEntries(value)) {
|
|
259
|
+
if (entry.readable !== true) continue;
|
|
260
|
+
const path = normalizePathForAuthorization(entry.path);
|
|
261
|
+
if (path !== void 0) readable.add(path);
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
available: true,
|
|
265
|
+
readable
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Check the current gateway user instead of treating an application ACL root
|
|
270
|
+
* as proof that every user can read every child path.
|
|
271
|
+
*
|
|
272
|
+
* Direct localhost development requests do not carry a NAS user header. In
|
|
273
|
+
* that case the real process-level fs check remains the only meaningful check
|
|
274
|
+
* and the fnOS `openFile()` bridge is still the final host-side operation.
|
|
275
|
+
*/
|
|
276
|
+
const checkCurrentUserAcl = async (req, paths) => {
|
|
277
|
+
const uid = gatewayUserId(req);
|
|
278
|
+
const normalizedPaths = normalizeAuthorizedPaths(paths);
|
|
279
|
+
if (uid === void 0 || normalizedPaths.length === 0) return {
|
|
280
|
+
available: true,
|
|
281
|
+
readable: new Set(normalizedPaths)
|
|
282
|
+
};
|
|
283
|
+
try {
|
|
284
|
+
return userAclResult(await callFnOsApi("trim.file.checkUserACL", {
|
|
285
|
+
uid,
|
|
286
|
+
path: normalizedPaths.length === 1 ? normalizedPaths[0] : normalizedPaths
|
|
287
|
+
}));
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.warn("[dsh-fnos] unable to check current fnOS user ACL", error);
|
|
290
|
+
return {
|
|
291
|
+
available: false,
|
|
292
|
+
readable: /* @__PURE__ */ new Set()
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
/** Parse a fnOS colon-separated path environment variable. */
|
|
297
|
+
function splitPathEnvironment(value) {
|
|
298
|
+
if (typeof value !== "string") return [];
|
|
299
|
+
return value.split(":").map((value) => value.trim()).filter((value) => value.length > 0);
|
|
300
|
+
}
|
|
301
|
+
/** Read user-authorized paths from the lifecycle environment and de-duplicate them. */
|
|
302
|
+
function accessiblePathsFromEnvironment(env = process.env) {
|
|
303
|
+
return normalizeAuthorizedPaths(splitPathEnvironment(env[FNOS_ACCESSIBLE_PATHS_ENV]));
|
|
304
|
+
}
|
|
305
|
+
/** Read declared application data shares for display and de-duplicate them. */
|
|
306
|
+
function dataSharePathsFromEnvironment(env = process.env) {
|
|
307
|
+
return normalizeAuthorizedPaths(splitPathEnvironment(env[FNOS_DATA_SHARE_PATHS_ENV]));
|
|
308
|
+
}
|
|
309
|
+
/** Combine path sources without changing the first-seen order. */
|
|
310
|
+
function mergeAuthorizedPaths(...values) {
|
|
311
|
+
return normalizeAuthorizedPaths(values.flatMap((value) => Array.isArray(value) ? value : splitPathEnvironment(value)));
|
|
312
|
+
}
|
|
313
|
+
/** Keep a just-removed ACL out of the process-local merged configuration. */
|
|
314
|
+
function markAuthorizedPathRemoved(value) {
|
|
315
|
+
const path = normalizeAuthorizedPath(value);
|
|
316
|
+
if (path !== void 0) removedAccessiblePaths.add(path);
|
|
317
|
+
}
|
|
318
|
+
function requestLanguage(req) {
|
|
319
|
+
const value = header(req, "accept-language")?.split(",")[0]?.trim().replace(/_/gu, "-");
|
|
320
|
+
if (value !== void 0 && /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/u.test(value)) return value;
|
|
321
|
+
return "en-US";
|
|
322
|
+
}
|
|
323
|
+
function readableFallbackPath(path, language) {
|
|
324
|
+
const volume = /^\/vol(\d+)(?:\/(.*))?$/u.exec(path);
|
|
325
|
+
if (volume !== null) {
|
|
326
|
+
const prefix = language.toLowerCase().startsWith("zh") ? `存储空间${volume[1]}` : `Storage ${volume[1]}`;
|
|
327
|
+
return volume[2] === void 0 || volume[2].length === 0 ? prefix : `${prefix}/${volume[2]}`;
|
|
328
|
+
}
|
|
329
|
+
if (path === "/") return language.toLowerCase().startsWith("zh") ? "根目录" : "Root";
|
|
330
|
+
return path;
|
|
331
|
+
}
|
|
332
|
+
function convertedPathMap(value) {
|
|
333
|
+
const result = /* @__PURE__ */ new Map();
|
|
334
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return result;
|
|
335
|
+
const envelope = value;
|
|
336
|
+
const nested = typeof envelope.data === "object" && envelope.data !== null && !Array.isArray(envelope.data) ? envelope.data : void 0;
|
|
337
|
+
const entries = Array.isArray(envelope.result) ? envelope.result : nested?.result;
|
|
338
|
+
if (!Array.isArray(entries)) return result;
|
|
339
|
+
for (const entry of entries) {
|
|
340
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
|
341
|
+
const path = normalizeAuthorizedPath(entry.path);
|
|
342
|
+
const rawSemanticPath = entry.semanticPath;
|
|
343
|
+
const semanticPath = typeof rawSemanticPath === "string" ? rawSemanticPath.trim() : "";
|
|
344
|
+
if (path !== void 0 && semanticPath.length > 0 && semanticPath !== path) result.set(path, semanticPath);
|
|
345
|
+
}
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
/** Pair internal paths with fnOS semantic paths, falling back per entry. */
|
|
349
|
+
async function convertPathsForDisplay(pathsValue, language = "en-US") {
|
|
350
|
+
const paths = normalizeAuthorizedPaths(pathsValue);
|
|
351
|
+
if (paths.length === 0) return [];
|
|
352
|
+
let convertedValue;
|
|
353
|
+
try {
|
|
354
|
+
convertedValue = await callFnOsApi("trim.file.convertPath", {
|
|
355
|
+
path: paths,
|
|
356
|
+
language
|
|
357
|
+
});
|
|
358
|
+
} catch (error) {
|
|
359
|
+
console.warn("[dsh-fnos] unable to convert selected fnOS paths", error);
|
|
360
|
+
}
|
|
361
|
+
const converted = convertedPathMap(convertedValue);
|
|
362
|
+
return paths.map((path) => ({
|
|
363
|
+
path,
|
|
364
|
+
semanticPath: converted.get(path) ?? readableFallbackPath(path, language)
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Query the current ACL. The environment is a fallback for hosts that expose
|
|
369
|
+
* the ACL to the app process but temporarily reject the read API; a successful
|
|
370
|
+
* API response remains authoritative so a just-removed path is not resurrected
|
|
371
|
+
* from a stale process environment.
|
|
372
|
+
*/
|
|
373
|
+
async function loadAuthorizedDirectoryPaths() {
|
|
374
|
+
const environmentPaths = accessiblePathsFromEnvironment();
|
|
375
|
+
try {
|
|
376
|
+
const apiPaths = normalizeAuthorizedPaths((await callFnOsApi("trim.file.getSharedAccessibleFolders"))?.paths);
|
|
377
|
+
for (const path of apiPaths) removedAccessiblePaths.delete(path);
|
|
378
|
+
return mergeAuthorizedPaths(apiPaths, environmentPaths).filter((path) => !removedAccessiblePaths.has(path));
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (environmentPaths.length === 0) throw error;
|
|
381
|
+
console.warn("[dsh-fnos] using TRIM_DATA_ACCESSIBLE_PATHS because the fnOS ACL query failed");
|
|
382
|
+
return environmentPaths.filter((path) => !removedAccessiblePaths.has(path));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async function readablePathStat(path) {
|
|
386
|
+
try {
|
|
387
|
+
const pathStat = await stat(path);
|
|
388
|
+
const mode = pathStat.isDirectory() ? constants.R_OK | constants.X_OK : constants.R_OK;
|
|
389
|
+
await access(path, mode);
|
|
390
|
+
return pathStat;
|
|
391
|
+
} catch {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async function existingAuthorizedRoots(rootsValue) {
|
|
396
|
+
const roots = normalizeAuthorizedPaths(rootsValue);
|
|
397
|
+
return (await Promise.all(roots.map(async (root) => {
|
|
398
|
+
try {
|
|
399
|
+
const resolvedRoot = normalizePathForAuthorization(await realpath(root));
|
|
400
|
+
if (resolvedRoot === void 0) return void 0;
|
|
401
|
+
return (await readablePathStat(resolvedRoot))?.isDirectory() === true ? resolvedRoot : void 0;
|
|
402
|
+
} catch {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
}))).filter((root) => root !== void 0);
|
|
406
|
+
}
|
|
407
|
+
async function currentAuthorizedRoots(options = {}) {
|
|
408
|
+
if (options.roots !== void 0) return existingAuthorizedRoots(options.roots);
|
|
409
|
+
return resolvedAuthorizedRoots();
|
|
410
|
+
}
|
|
411
|
+
async function validateReadableAuthorizedPath(req, path, roots, checkUserAcl) {
|
|
412
|
+
let pathStat;
|
|
413
|
+
try {
|
|
414
|
+
pathStat = await stat(path);
|
|
415
|
+
} catch {
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
failure: "fnos-path-not-found"
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
const mode = pathStat.isDirectory() ? constants.R_OK | constants.X_OK : constants.R_OK;
|
|
423
|
+
await access(path, mode);
|
|
424
|
+
} catch {
|
|
425
|
+
return {
|
|
426
|
+
ok: false,
|
|
427
|
+
failure: "fnos-path-not-readable"
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (!await isResolvedPathWithinAuthorizedRoots(path, roots)) return {
|
|
431
|
+
ok: false,
|
|
432
|
+
failure: "fnos-path-not-authorized"
|
|
433
|
+
};
|
|
434
|
+
const userAcl = await checkUserAcl(req, [path]);
|
|
435
|
+
if (!userAcl.available) return {
|
|
436
|
+
ok: false,
|
|
437
|
+
failure: "fnos-user-permission-unavailable"
|
|
438
|
+
};
|
|
439
|
+
if (!userAcl.readable.has(path)) return {
|
|
440
|
+
ok: false,
|
|
441
|
+
failure: "fnos-user-permission-denied"
|
|
442
|
+
};
|
|
443
|
+
return { ok: true };
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Validate a DSH file/directory target using all applicable permission layers:
|
|
447
|
+
* the app's real shared roots, the process's actual fs access, and the
|
|
448
|
+
* current NAS user ACL supplied by the unified gateway.
|
|
449
|
+
*/
|
|
450
|
+
async function validatePathForOpen(pathValue, req, options = {}) {
|
|
451
|
+
const path = normalizePathForAuthorization(pathValue);
|
|
452
|
+
if (path === void 0) return {
|
|
453
|
+
ok: false,
|
|
454
|
+
failure: "fnos-path-not-authorized"
|
|
455
|
+
};
|
|
456
|
+
return validateReadableAuthorizedPath(req, path, await currentAuthorizedRoots(options), options.checkUserAcl ?? checkCurrentUserAcl);
|
|
457
|
+
}
|
|
458
|
+
/** Backward-compatible boolean helper for callers that only need a verdict. */
|
|
459
|
+
async function isAuthorizedPathForOpen(pathValue, req) {
|
|
460
|
+
return (await validatePathForOpen(pathValue, req)).ok;
|
|
461
|
+
}
|
|
462
|
+
async function convertDirectories(paths, language, readOnlyPaths = []) {
|
|
463
|
+
const readOnly = new Set(normalizeAuthorizedPaths(readOnlyPaths));
|
|
464
|
+
return (await convertPathsForDisplay(paths, language)).map((entry) => ({
|
|
465
|
+
...entry,
|
|
466
|
+
removable: !readOnly.has(entry.path)
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
async function loadAuthorizedDirectories(req) {
|
|
470
|
+
const readOnlyPaths = dataSharePathsFromEnvironment();
|
|
471
|
+
let accessiblePaths = [];
|
|
472
|
+
try {
|
|
473
|
+
accessiblePaths = await loadAuthorizedDirectoryPaths();
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (readOnlyPaths.length === 0) throw error;
|
|
476
|
+
console.warn("[dsh-fnos] using TRIM_DATA_SHARE_PATHS because the fnOS ACL query failed", error);
|
|
477
|
+
}
|
|
478
|
+
return convertDirectories(mergeAuthorizedPaths(accessiblePaths, readOnlyPaths), requestLanguage(req), readOnlyPaths);
|
|
479
|
+
}
|
|
480
|
+
function errorResponse(res, error) {
|
|
481
|
+
if (error instanceof FnOsApiError && error.apiCode === 1) {
|
|
482
|
+
json(res, 403, { error: "fnos-authorized-directory-permission-denied" });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
json(res, 502, { error: "fnos-authorized-directory-request-failed" });
|
|
486
|
+
}
|
|
487
|
+
function deletePath(value) {
|
|
488
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
489
|
+
return normalizeAuthorizedPath(value.path);
|
|
490
|
+
}
|
|
491
|
+
function conversionPaths(value) {
|
|
492
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
493
|
+
const valuePaths = value.paths;
|
|
494
|
+
if (!Array.isArray(valuePaths)) return void 0;
|
|
495
|
+
return normalizeAuthorizedPaths(valuePaths);
|
|
496
|
+
}
|
|
497
|
+
function openPathValue(value) {
|
|
498
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
499
|
+
return normalizePathForAuthorization(value.path);
|
|
500
|
+
}
|
|
501
|
+
/** Read the optional directory field used by the authorized-entry listing. */
|
|
502
|
+
function listingPathValue(value) {
|
|
503
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
504
|
+
const raw = value.path;
|
|
505
|
+
if (raw === void 0) return void 0;
|
|
506
|
+
return normalizePathForAuthorization(raw) ?? null;
|
|
507
|
+
}
|
|
508
|
+
async function resolvedAuthorizedRoots() {
|
|
509
|
+
let roots;
|
|
510
|
+
try {
|
|
511
|
+
roots = mergeAuthorizedPaths(await loadAuthorizedDirectoryPaths(), dataSharePathsFromEnvironment());
|
|
512
|
+
} catch (error) {
|
|
513
|
+
const shares = dataSharePathsFromEnvironment();
|
|
514
|
+
if (shares.length === 0) throw error;
|
|
515
|
+
roots = shares;
|
|
516
|
+
}
|
|
517
|
+
return existingAuthorizedRoots(roots);
|
|
518
|
+
}
|
|
519
|
+
async function isResolvedPathWithinAuthorizedRoots(path, roots) {
|
|
520
|
+
try {
|
|
521
|
+
const resolvedPath = normalizePathForAuthorization(await realpath(path));
|
|
522
|
+
if (resolvedPath === void 0) return false;
|
|
523
|
+
return roots.some((root) => root === "/" || resolvedPath === root || resolvedPath.startsWith(`${root}/`));
|
|
524
|
+
} catch {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function authorizedEntriesErrorForValidation(result) {
|
|
529
|
+
switch (result.failure) {
|
|
530
|
+
case "fnos-path-not-found": return new AuthorizedEntriesError("authorized-directory-not-found", 404);
|
|
531
|
+
case "fnos-path-not-readable":
|
|
532
|
+
case "fnos-user-permission-denied": return new AuthorizedEntriesError("authorized-directory-not-readable", 403);
|
|
533
|
+
case "fnos-user-permission-unavailable": return new AuthorizedEntriesError("fnos-user-permission-check-unavailable", 503);
|
|
534
|
+
default: return new AuthorizedEntriesError("fnos-path-not-authorized", 403);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
function sortAuthorizedEntries(entries) {
|
|
538
|
+
return entries.sort((left, right) => {
|
|
539
|
+
if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1;
|
|
540
|
+
return left.semanticPath.localeCompare(right.semanticPath, void 0, {
|
|
541
|
+
numeric: true,
|
|
542
|
+
sensitivity: "base"
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* List only existing, readable authorized roots or one authorized directory
|
|
548
|
+
* level. The app ACL root is only the first boundary: every returned entry is
|
|
549
|
+
* also checked with the real filesystem and, when the request came through
|
|
550
|
+
* the fnOS gateway, the current user's ACL.
|
|
551
|
+
*/
|
|
552
|
+
async function loadAuthorizedEntries(req, pathValue) {
|
|
553
|
+
const roots = await resolvedAuthorizedRoots();
|
|
554
|
+
if (pathValue === void 0) {
|
|
555
|
+
const userAcl = await checkCurrentUserAcl(req, roots);
|
|
556
|
+
if (!userAcl.available) throw new AuthorizedEntriesError("fnos-user-permission-check-unavailable", 503);
|
|
557
|
+
return { entries: (await convertPathsForDisplay(roots.filter((root) => userAcl.readable.has(root)), requestLanguage(req))).map((entry) => ({
|
|
558
|
+
...entry,
|
|
559
|
+
kind: "directory"
|
|
560
|
+
})) };
|
|
561
|
+
}
|
|
562
|
+
const pathValidation = await validateReadableAuthorizedPath(req, pathValue, roots, checkCurrentUserAcl);
|
|
563
|
+
if (!pathValidation.ok) throw authorizedEntriesErrorForValidation(pathValidation);
|
|
564
|
+
if (!(await stat(pathValue)).isDirectory()) throw new AuthorizedEntriesError("authorized-path-not-directory", 400);
|
|
565
|
+
let children;
|
|
566
|
+
try {
|
|
567
|
+
children = await readdir(pathValue, { withFileTypes: true });
|
|
568
|
+
} catch {
|
|
569
|
+
throw new AuthorizedEntriesError("authorized-directory-not-readable", 403);
|
|
570
|
+
}
|
|
571
|
+
const candidates = [];
|
|
572
|
+
for (const child of children) {
|
|
573
|
+
const childPath = normalizePathForAuthorization(posix.join(pathValue, child.name));
|
|
574
|
+
if (childPath === void 0) continue;
|
|
575
|
+
try {
|
|
576
|
+
const childStat = await readablePathStat(childPath);
|
|
577
|
+
if (childStat === void 0) continue;
|
|
578
|
+
if (!await isResolvedPathWithinAuthorizedRoots(childPath, roots)) continue;
|
|
579
|
+
const candidate = {
|
|
580
|
+
path: childPath,
|
|
581
|
+
kind: childStat.isDirectory() ? "directory" : "file",
|
|
582
|
+
modifiedAt: childStat.mtimeMs
|
|
583
|
+
};
|
|
584
|
+
if (childStat.isFile()) candidate.size = childStat.size;
|
|
585
|
+
candidates.push(candidate);
|
|
586
|
+
} catch {}
|
|
587
|
+
}
|
|
588
|
+
const userAcl = await checkCurrentUserAcl(req, candidates.map((entry) => entry.path));
|
|
589
|
+
if (!userAcl.available) throw new AuthorizedEntriesError("fnos-user-permission-check-unavailable", 503);
|
|
590
|
+
const readableCandidates = candidates.filter((entry) => userAcl.readable.has(entry.path));
|
|
591
|
+
const truncated = readableCandidates.length > AUTHORIZED_ENTRIES_LIMIT;
|
|
592
|
+
const selected = readableCandidates.slice(0, AUTHORIZED_ENTRIES_LIMIT);
|
|
593
|
+
const readable = await convertPathsForDisplay([pathValue, ...selected.map((entry) => entry.path)], requestLanguage(req));
|
|
594
|
+
const readableByPath = new Map(readable.map((entry) => [entry.path, entry.semanticPath]));
|
|
595
|
+
return {
|
|
596
|
+
directory: {
|
|
597
|
+
path: pathValue,
|
|
598
|
+
semanticPath: readableByPath.get(pathValue) ?? readableFallbackPath(pathValue, requestLanguage(req))
|
|
599
|
+
},
|
|
600
|
+
entries: sortAuthorizedEntries(selected.map((entry) => ({
|
|
601
|
+
...entry,
|
|
602
|
+
semanticPath: readableByPath.get(entry.path) ?? readableFallbackPath(entry.path, requestLanguage(req))
|
|
603
|
+
}))),
|
|
604
|
+
truncated
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Path conversion is a display helper, not an authorization bypass. Reject
|
|
609
|
+
* arbitrary or stale paths before returning their semantic names.
|
|
610
|
+
*/
|
|
611
|
+
async function validatePathsForConversion(req, paths) {
|
|
612
|
+
const roots = await resolvedAuthorizedRoots();
|
|
613
|
+
const userAcl = await checkCurrentUserAcl(req, paths);
|
|
614
|
+
if (!userAcl.available) throw new AuthorizedEntriesError("fnos-user-permission-check-unavailable", 503);
|
|
615
|
+
let failure;
|
|
616
|
+
for (const path of paths) {
|
|
617
|
+
let pathStat;
|
|
618
|
+
try {
|
|
619
|
+
pathStat = await stat(path);
|
|
620
|
+
} catch {
|
|
621
|
+
failure ??= "fnos-path-not-found";
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
const mode = pathStat.isDirectory() ? constants.R_OK | constants.X_OK : constants.R_OK;
|
|
626
|
+
await access(path, mode);
|
|
627
|
+
} catch {
|
|
628
|
+
failure ??= "fnos-path-not-readable";
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
if (!await isResolvedPathWithinAuthorizedRoots(path, roots)) {
|
|
632
|
+
failure ??= "fnos-path-not-authorized";
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
if (!userAcl.readable.has(path)) failure ??= "fnos-user-permission-denied";
|
|
636
|
+
}
|
|
637
|
+
if (failure !== void 0) throw authorizedEntriesErrorForValidation({
|
|
638
|
+
ok: false,
|
|
639
|
+
failure
|
|
640
|
+
});
|
|
641
|
+
return [...paths];
|
|
642
|
+
}
|
|
643
|
+
/** Register list/delete routes only on the DSH Web profile. */
|
|
644
|
+
function registerAuthorizedDirectoryRoutes(ctx) {
|
|
645
|
+
ctx.effect(() => {
|
|
646
|
+
const authorize = (req, res) => {
|
|
647
|
+
if (trustedRequest(req)) return true;
|
|
648
|
+
json(res, 403, { error: "remote-web-origin-not-trusted" });
|
|
649
|
+
return false;
|
|
650
|
+
};
|
|
651
|
+
const routes = [
|
|
652
|
+
ctx.webServer.register({
|
|
653
|
+
kind: "exact",
|
|
654
|
+
path: FNOS_AUTHORIZED_DIRECTORIES_PATH,
|
|
655
|
+
handler: async (req, res) => {
|
|
656
|
+
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
657
|
+
if (!authorize(req, res)) return;
|
|
658
|
+
try {
|
|
659
|
+
json(res, 200, { directories: await loadAuthorizedDirectories(req) });
|
|
660
|
+
} catch (error) {
|
|
661
|
+
errorResponse(res, error);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}),
|
|
665
|
+
ctx.webServer.register({
|
|
666
|
+
kind: "exact",
|
|
667
|
+
path: FNOS_AUTHORIZED_DIRECTORIES_DELETE_PATH,
|
|
668
|
+
handler: async (req, res) => {
|
|
669
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
670
|
+
if (!authorize(req, res)) return;
|
|
671
|
+
try {
|
|
672
|
+
const path = deletePath(await readJsonBody(req));
|
|
673
|
+
if (path === void 0) return json(res, 400, { error: "invalid-authorized-directory-path" });
|
|
674
|
+
const directory = (await loadAuthorizedDirectories(req)).find((candidate) => candidate.path === path);
|
|
675
|
+
if (directory === void 0) return json(res, 409, { error: "authorized-directory-not-found" });
|
|
676
|
+
if (!directory.removable) return json(res, 409, { error: "authorized-directory-not-removable" });
|
|
677
|
+
if ((await callFnOsApi("trim.file.delSharedAccessibleFolder", { path }))?.suc === false) return json(res, 502, { error: "fnos-authorized-directory-request-failed" });
|
|
678
|
+
markAuthorizedPathRemoved(path);
|
|
679
|
+
json(res, 200, { ok: true });
|
|
680
|
+
} catch (error) {
|
|
681
|
+
errorResponse(res, error);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}),
|
|
685
|
+
ctx.webServer.register({
|
|
686
|
+
kind: "exact",
|
|
687
|
+
path: FNOS_PATH_CONVERSION_PATH,
|
|
688
|
+
handler: async (req, res) => {
|
|
689
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
690
|
+
if (!authorize(req, res)) return;
|
|
691
|
+
try {
|
|
692
|
+
const paths = conversionPaths(await readJsonBody(req));
|
|
693
|
+
if (paths === void 0) return json(res, 400, { error: "invalid-fnos-paths" });
|
|
694
|
+
json(res, 200, { paths: await convertPathsForDisplay(await validatePathsForConversion(req, paths), requestLanguage(req)) });
|
|
695
|
+
} catch (error) {
|
|
696
|
+
if (error instanceof AuthorizedEntriesError) {
|
|
697
|
+
json(res, error.status, { error: error.code });
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
errorResponse(res, error);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}),
|
|
704
|
+
ctx.webServer.register({
|
|
705
|
+
kind: "exact",
|
|
706
|
+
path: FNOS_PATH_OPEN_VALIDATION_PATH,
|
|
707
|
+
handler: async (req, res) => {
|
|
708
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
709
|
+
if (!authorize(req, res)) return;
|
|
710
|
+
try {
|
|
711
|
+
const path = openPathValue(await readJsonBody(req));
|
|
712
|
+
if (path === void 0) return json(res, 400, { error: "invalid-fnos-path" });
|
|
713
|
+
const validation = await validatePathForOpen(path, req);
|
|
714
|
+
if (!validation.ok) return json(res, validation.failure === "fnos-path-not-found" ? 404 : validation.failure === "fnos-user-permission-unavailable" ? 503 : 403, { error: validation.failure });
|
|
715
|
+
json(res, 200, { ok: true });
|
|
716
|
+
} catch (error) {
|
|
717
|
+
errorResponse(res, error);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}),
|
|
721
|
+
ctx.webServer.register({
|
|
722
|
+
kind: "exact",
|
|
723
|
+
path: FNOS_AUTHORIZED_ENTRIES_PATH,
|
|
724
|
+
handler: async (req, res) => {
|
|
725
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
726
|
+
if (!authorize(req, res)) return;
|
|
727
|
+
try {
|
|
728
|
+
const path = listingPathValue(await readJsonBody(req));
|
|
729
|
+
if (path === null) return json(res, 400, { error: "invalid-fnos-path" });
|
|
730
|
+
json(res, 200, await loadAuthorizedEntries(req, path));
|
|
731
|
+
} catch (error) {
|
|
732
|
+
if (error instanceof AuthorizedEntriesError) {
|
|
733
|
+
json(res, error.status, { error: error.code });
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
errorResponse(res, error);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
})
|
|
740
|
+
];
|
|
741
|
+
return () => {
|
|
742
|
+
for (const dispose of routes) dispose();
|
|
743
|
+
};
|
|
744
|
+
}, "dsh-fnos: authorized directory routes");
|
|
745
|
+
}
|
|
746
|
+
//#endregion
|
|
747
|
+
//#region src/theme-contract.ts
|
|
748
|
+
const FNOS_SYSTEM_THEME_FIELD = "systemTheme";
|
|
749
|
+
function isFnosTheme(value) {
|
|
750
|
+
return value === "light" || value === "dark";
|
|
751
|
+
}
|
|
752
|
+
//#endregion
|
|
753
|
+
//#region src/theme-bootstrap.ts
|
|
754
|
+
/** Initial-theme helpers for the Host-side index transform. */
|
|
755
|
+
/**
|
|
756
|
+
* Resolve the cached preference that is safe to use before the Client tree
|
|
757
|
+
* starts. Explicit DSH preferences remain authoritative and never use the
|
|
758
|
+
* fnOS cache.
|
|
759
|
+
*/
|
|
760
|
+
function cachedFnosThemeForBoot(preference, systemTheme) {
|
|
761
|
+
if (preference !== "system" || !isFnosTheme(systemTheme)) return null;
|
|
762
|
+
return systemTheme;
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Replace the preference literal emitted by DSH's official ui-theme bootstrap.
|
|
766
|
+
* The fnOS bundle is appended after ui-theme in the profile composition, so
|
|
767
|
+
* the official script is already present when this transform runs.
|
|
768
|
+
*/
|
|
769
|
+
function injectCachedFnosTheme(html, preference, systemTheme) {
|
|
770
|
+
const cachedTheme = cachedFnosThemeForBoot(preference, systemTheme);
|
|
771
|
+
if (cachedTheme === null) return html;
|
|
772
|
+
return html.replace(/(const\s+preference\s*=\s*)"system"/, `$1${JSON.stringify(cachedTheme)}`);
|
|
773
|
+
}
|
|
774
|
+
//#endregion
|
|
775
|
+
//#region src/index.ts
|
|
776
|
+
/** Stable Host bundle name. */
|
|
777
|
+
const name = "@tnnevol/dsh-fnos";
|
|
778
|
+
/** Settings back the fnOS card and the cached pre-plugin theme bootstrap. */
|
|
779
|
+
const FnosSettingsSchema = z.object({ [FNOS_SYSTEM_THEME_FIELD]: z.union(["light", "dark"]) });
|
|
780
|
+
const FNOS_AUTHORIZED_DIRECTORIES_SETTINGS_NS = settingsNamespace(FNOS_AUTHORIZED_DIRECTORIES_SETTINGS_NAMESPACE);
|
|
781
|
+
const DSH_THEME_SETTINGS_NS = settingsNamespace("ui-theme");
|
|
782
|
+
/** Host services required by the fnOS settings namespace and Web routes. */
|
|
783
|
+
const inject = ["webServer", "settings"];
|
|
784
|
+
function apply(ctx) {
|
|
785
|
+
ctx.settings.register(FNOS_AUTHORIZED_DIRECTORIES_SETTINGS_NS, FnosSettingsSchema);
|
|
786
|
+
registerAuthorizedDirectoryRoutes(ctx);
|
|
787
|
+
ctx.inject(["webServer"], (httpCtx) => {
|
|
788
|
+
httpCtx.effect(() => httpCtx.webServer.tapIndex((html) => injectCachedFnosTheme(html, readDshThemePreference(ctx), readCachedFnosTheme(ctx))), "dsh-fnos: cached fnOS theme bootstrap");
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
function readDshThemePreference(ctx) {
|
|
792
|
+
const section = ctx.settings.get(DSH_THEME_SETTINGS_NS);
|
|
793
|
+
return section?.preference === "light" || section?.preference === "dark" || section?.preference === "system" ? section.preference : "system";
|
|
794
|
+
}
|
|
795
|
+
function readCachedFnosTheme(ctx) {
|
|
796
|
+
const section = ctx.settings.get(FNOS_AUTHORIZED_DIRECTORIES_SETTINGS_NS);
|
|
797
|
+
return isFnosTheme(section?.["systemTheme"]) ? section[FNOS_SYSTEM_THEME_FIELD] : null;
|
|
798
|
+
}
|
|
799
|
+
//#endregion
|
|
800
|
+
export { FNOS_ACCESSIBLE_PATHS_ENV, FNOS_AUTHORIZED_DIRECTORIES_DELETE_PATH, FNOS_AUTHORIZED_DIRECTORIES_PATH, FNOS_AUTHORIZED_DIRECTORIES_SETTINGS_NAMESPACE, FNOS_AUTHORIZED_DIRECTORIES_SETTINGS_NS, FNOS_AUTHORIZED_ENTRIES_PATH, FNOS_DATA_SHARE_PATHS_ENV, FNOS_PATH_CONVERSION_PATH, FNOS_PATH_OPEN_VALIDATION_PATH, FnosSettingsSchema, accessiblePathsFromEnvironment, apply, convertPathsForDisplay, dataSharePathsFromEnvironment, gatewayUserId, inject, isAuthorizedPathForOpen, isPathWithinAuthorizedDirectory, loadAuthorizedEntries, markAuthorizedPathRemoved, mergeAuthorizedPaths, name, normalizeAuthorizedPath, normalizeAuthorizedPaths, normalizePathForAuthorization, splitPathEnvironment, validatePathForOpen };
|