dsh-remote-bridge 2.2.4 → 2.3.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.en.md +27 -10
- package/README.md +32 -18
- package/docs/api/asyncapi.yaml +1 -1
- package/docs/api/openapi.yaml +2 -2
- package/kotlin-sdk/build.gradle.kts +1 -1
- package/lib/client.cjs +960 -280
- package/lib/index.d.ts +114 -2
- package/lib/index.js +483 -43
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path, { join } from "node:path";
|
|
2
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
2
|
import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
4
|
import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { DatabaseSync } from "node:sqlite";
|
|
6
6
|
import os, { homedir, hostname, networkInterfaces } from "node:os";
|
|
@@ -1013,20 +1013,32 @@ var WorkspaceDatabase = class {
|
|
|
1013
1013
|
close() {
|
|
1014
1014
|
this.db.close();
|
|
1015
1015
|
}
|
|
1016
|
+
/**
|
|
1017
|
+
* 导出 `roots` 的建表语句(只读,诊断与迁移测试用)。
|
|
1018
|
+
*
|
|
1019
|
+
* 存在的理由:迁移要判断「UNIQUE 还在不在」,单看 `PRAGMA table_info` 看不出来 ——
|
|
1020
|
+
* 表结构读出来一模一样,区别在约束上。这个方法让「重建真的发生了」可以被断言,
|
|
1021
|
+
* 而不是靠迁移没报错来推测。
|
|
1022
|
+
*/
|
|
1023
|
+
dumpSchema() {
|
|
1024
|
+
return this.db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'roots'").get()?.sql ?? "";
|
|
1025
|
+
}
|
|
1016
1026
|
getSetting(key) {
|
|
1017
1027
|
return this.db.prepare("SELECT value FROM settings WHERE key = ?").get(key)?.value;
|
|
1018
1028
|
}
|
|
1019
1029
|
setSetting(key, value) {
|
|
1020
1030
|
this.db.prepare("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value);
|
|
1021
1031
|
}
|
|
1022
|
-
async addRoot(inputPath, label) {
|
|
1032
|
+
async addRoot(inputPath, label, options) {
|
|
1023
1033
|
const realPath = await canonicalRoot(inputPath);
|
|
1024
|
-
|
|
1025
|
-
|
|
1034
|
+
if (options?.allowDuplicate !== true) {
|
|
1035
|
+
const duplicate = this.db.prepare("SELECT * FROM roots WHERE real_path = ?").get(realPath);
|
|
1036
|
+
if (duplicate !== void 0) return rootFromRow(duplicate);
|
|
1037
|
+
}
|
|
1026
1038
|
const id = randomUUID();
|
|
1027
1039
|
const createdAt = Date.now();
|
|
1028
1040
|
const resolvedLabel = label?.trim() || path.basename(realPath) || realPath;
|
|
1029
|
-
this.db.prepare("INSERT INTO roots(id,label,real_path,created_at) VALUES(
|
|
1041
|
+
this.db.prepare("INSERT INTO roots(id,label,real_path,created_at,builtin) VALUES(?,?,?,?,0)").run(id, resolvedLabel, realPath, createdAt);
|
|
1030
1042
|
this.audit("admin", "loopback-admin", "root.add", id, void 0, { label: resolvedLabel });
|
|
1031
1043
|
return {
|
|
1032
1044
|
id,
|
|
@@ -1036,13 +1048,52 @@ var WorkspaceDatabase = class {
|
|
|
1036
1048
|
};
|
|
1037
1049
|
}
|
|
1038
1050
|
listRoots() {
|
|
1039
|
-
return this.db.prepare("SELECT * FROM roots ORDER BY created_at, id").all().map(rootFromRow);
|
|
1051
|
+
return this.db.prepare("SELECT * FROM roots ORDER BY builtin DESC, created_at, id").all().map(rootFromRow);
|
|
1040
1052
|
}
|
|
1041
1053
|
getRoot(id) {
|
|
1042
1054
|
const row = this.db.prepare("SELECT * FROM roots WHERE id = ?").get(id);
|
|
1043
1055
|
return row === void 0 ? void 0 : rootFromRow(row);
|
|
1044
1056
|
}
|
|
1057
|
+
/**
|
|
1058
|
+
* 登记「DSH 工作区」根 —— **幂等**。
|
|
1059
|
+
*
|
|
1060
|
+
* 用户要求:当前 DSH 的工作目录要显示成一个授权根,**不可删除、但可以正常访问**。
|
|
1061
|
+
* 它不是另起一张表,而是**一条普通的根**加一个 `builtin` 标记:配对、`files.*`
|
|
1062
|
+
* 授权、回收站、写盘全走现成逻辑,少一套需要同步维护的规则。
|
|
1063
|
+
*
|
|
1064
|
+
* ## 同目录已有一条普通根时,另起一条而不是复用它
|
|
1065
|
+
*
|
|
1066
|
+
* 这是实测踩到的现实情况:用户早于这个功能**自己**加过同一条目录(本机那条是
|
|
1067
|
+
* 10-08 手工加的),路径和 DSH 工作区一模一样。
|
|
1068
|
+
*
|
|
1069
|
+
* 初版在这里「尊重用户选择」直接返回,结果就是**内置项永远登记不上** —— 因为它
|
|
1070
|
+
* 每次都撞上那条已有的普通根然后什么都不做。界面上那个「不可删除」也就永远出不来。
|
|
1071
|
+
* 用户的选择和功能的正确性在这里是冲突的:用户要的是「这条要不可删」,不是
|
|
1072
|
+
* 「这条不能变」。
|
|
1073
|
+
*
|
|
1074
|
+
* 所以改成**另起一条**:用户原来那条保持可删(那是他的目录,他有权删),内置那条
|
|
1075
|
+
* 独立存在。代价是列表里同一个路径出现两行 —— 这是「两条记录、两种权限」的必然
|
|
1076
|
+
* 结果,比让功能失效或擅自改他的配置都更可接受。
|
|
1077
|
+
*
|
|
1078
|
+
* 幂等不再靠 UNIQUE(它已经被去掉了),而是靠「已存在 `builtin = 1` 的同路径就
|
|
1079
|
+
* 直接返回」。插件**每次启动都可以无脑调一次**。
|
|
1080
|
+
*
|
|
1081
|
+
* 只有传入路径确实存在才登记:工作区可能被用户删掉,这时留一条指向虚空的授权根,
|
|
1082
|
+
* 手机浏览时会一路报错,比不显示更糟。
|
|
1083
|
+
*/
|
|
1084
|
+
async ensureBuiltinRoot(realPath, label) {
|
|
1085
|
+
const builtin = this.db.prepare("SELECT * FROM roots WHERE real_path = ? AND builtin = 1").get(realPath);
|
|
1086
|
+
if (builtin !== void 0) return rootFromRow(builtin);
|
|
1087
|
+
const created = await this.addRoot(realPath, label, { allowDuplicate: true });
|
|
1088
|
+
this.db.prepare("UPDATE roots SET builtin = 1 WHERE id = ?").run(created.id);
|
|
1089
|
+
this.audit("admin", "loopback-admin", "root.builtin", created.id, void 0, { label });
|
|
1090
|
+
return {
|
|
1091
|
+
...created,
|
|
1092
|
+
builtin: true
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1045
1095
|
removeRoot(id) {
|
|
1096
|
+
if (this.getRoot(id)?.builtin === true) throw new ApiError(403, "ROOT_BUILTIN", "The DSH workspace root cannot be removed.");
|
|
1046
1097
|
if (this.db.prepare("DELETE FROM roots WHERE id = ?").run(id).changes === 0) throw new ApiError(404, "ROOT_NOT_FOUND", "The authorized root does not exist.");
|
|
1047
1098
|
this.audit("admin", "loopback-admin", "root.remove", id);
|
|
1048
1099
|
}
|
|
@@ -1121,7 +1172,7 @@ var WorkspaceDatabase = class {
|
|
|
1121
1172
|
};
|
|
1122
1173
|
}
|
|
1123
1174
|
listDevices() {
|
|
1124
|
-
const rows = this.db.prepare("SELECT * FROM devices ORDER BY created_at DESC").all();
|
|
1175
|
+
const rows = this.db.prepare("SELECT * FROM devices WHERE revoked_at IS NULL ORDER BY created_at DESC").all();
|
|
1125
1176
|
const rootQuery = this.db.prepare("SELECT root_id FROM device_roots WHERE device_id = ? ORDER BY root_id");
|
|
1126
1177
|
return rows.map((row) => ({
|
|
1127
1178
|
id: row.id,
|
|
@@ -1159,9 +1210,60 @@ var WorkspaceDatabase = class {
|
|
|
1159
1210
|
if (device === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The device does not exist.");
|
|
1160
1211
|
return device;
|
|
1161
1212
|
}
|
|
1213
|
+
/**
|
|
1214
|
+
* 撤销设备 = **直接删除这一行**(用户要求:已撤销的不显示,并自动从数据库中删除)。
|
|
1215
|
+
*
|
|
1216
|
+
* 原先是打 `revoked_at` 标记保留行,两处都不好:
|
|
1217
|
+
* - 界面上会出现一长串灰掉的「已撤销」,本机实测堆到了 32 条,全是历史测试与旧设备;
|
|
1218
|
+
* - 行留着就等于凭据的哈希还在库里,是个长期负担 —— 令牌已经没用了,留着没有意义。
|
|
1219
|
+
*
|
|
1220
|
+
* 删除是安全的,因为:
|
|
1221
|
+
* - `device_roots` 是 `ON DELETE CASCADE`,授权行自动清掉;
|
|
1222
|
+
* - `audit.actor_id` 是**普通字符串不是外键**,审计留痕(谁在什么时候撤了谁)一条不少;
|
|
1223
|
+
* - `authenticate` 仍保留 `revoked_at IS NULL` 作为兜底,万一有别处留下的标记行,
|
|
1224
|
+
* 它们依然登不进来 —— 两层保险不冲突。
|
|
1225
|
+
*/
|
|
1162
1226
|
revokeDevice(id) {
|
|
1163
|
-
|
|
1164
|
-
|
|
1227
|
+
const active = this.getActiveDevice(id);
|
|
1228
|
+
if (active === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
|
|
1229
|
+
this.audit("admin", "loopback-admin", "device.revoke", void 0, void 0, {
|
|
1230
|
+
deviceId: id,
|
|
1231
|
+
name: active.name
|
|
1232
|
+
});
|
|
1233
|
+
if (this.db.prepare("DELETE FROM devices WHERE id = ?").run(id).changes === 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* 一次性清理历史遗留的「标记式」撤销设备(`revoked_at IS NOT NULL` 的行)。
|
|
1237
|
+
*
|
|
1238
|
+
* 撤销改成直接删除之前,每次撤销都只是打个标记,于是库越用越臃肿 —— 本机实测堆到
|
|
1239
|
+
* **32 条**,全是历史测试探针和早已弃用的旧手机,界面上就是一长串灰字。
|
|
1240
|
+
*
|
|
1241
|
+
* 幂等:没有这种行时返回 0,不报错。插件每次启动调一次即可。
|
|
1242
|
+
* 走 `revokeDevice` 的删除语义,但**不能**借它写 `device.revoke` 审计 ——
|
|
1243
|
+
* 那会把「系统清理」记成管理员逐个撤销,审计会失真。清理本身只写一条汇总。
|
|
1244
|
+
*/
|
|
1245
|
+
/**
|
|
1246
|
+
* 仅供测试:造一行「撤销改成删除之前」的标记式撤销数据。
|
|
1247
|
+
*
|
|
1248
|
+
* 真实场景来自历史库(每次撤销只打 `revoked_at` 标记,于是越积越多)。测试需要个
|
|
1249
|
+
* 干净的办法制造这种行,而它不该走生产代码路径 —— 所以显式开一个测试专用入口,
|
|
1250
|
+
* 并起一个自见其明的名字,免得有人把它当成正常 API。
|
|
1251
|
+
*/
|
|
1252
|
+
markRevokedForTest(id) {
|
|
1253
|
+
this.db.prepare("UPDATE devices SET revoked_at = ? WHERE id = ?").run(Date.now(), id);
|
|
1254
|
+
}
|
|
1255
|
+
purgeRevokedDevices() {
|
|
1256
|
+
if (this.db.prepare("SELECT id FROM devices WHERE revoked_at IS NOT NULL").all().length === 0) return 0;
|
|
1257
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1258
|
+
try {
|
|
1259
|
+
const result = this.db.prepare("DELETE FROM devices WHERE revoked_at IS NOT NULL").run();
|
|
1260
|
+
this.db.exec("COMMIT");
|
|
1261
|
+
this.audit("admin", "loopback-admin", "device.purge", void 0, void 0, { purged: result.changes });
|
|
1262
|
+
return Number(result.changes);
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
this.db.exec("ROLLBACK");
|
|
1265
|
+
throw error;
|
|
1266
|
+
}
|
|
1165
1267
|
}
|
|
1166
1268
|
insertTrash(record) {
|
|
1167
1269
|
this.db.prepare("INSERT INTO trash(id,root_id,relative_path,payload_path,kind,size,digest,created_at,status) VALUES(?,?,?,?,?,?,?,?,?)").run(record.id, record.rootId, record.relativePath, record.payloadPath, record.kind, record.size, record.digest, record.createdAt, record.status);
|
|
@@ -1199,7 +1301,7 @@ var WorkspaceDatabase = class {
|
|
|
1199
1301
|
this.db.exec(`
|
|
1200
1302
|
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
1201
1303
|
CREATE TABLE IF NOT EXISTS roots(
|
|
1202
|
-
id TEXT PRIMARY KEY, label TEXT NOT NULL, real_path TEXT NOT NULL
|
|
1304
|
+
id TEXT PRIMARY KEY, label TEXT NOT NULL, real_path TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
1203
1305
|
);
|
|
1204
1306
|
CREATE TABLE IF NOT EXISTS devices(
|
|
1205
1307
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE,
|
|
@@ -1230,6 +1332,50 @@ var WorkspaceDatabase = class {
|
|
|
1230
1332
|
CREATE INDEX IF NOT EXISTS audit_time_idx ON audit(time DESC);
|
|
1231
1333
|
CREATE INDEX IF NOT EXISTS trash_root_idx ON trash(root_id, created_at DESC);
|
|
1232
1334
|
`);
|
|
1335
|
+
if (!this.hasRootColumn("builtin")) this.db.exec("ALTER TABLE roots ADD COLUMN builtin INTEGER NOT NULL DEFAULT 0");
|
|
1336
|
+
if (this.rootsPathIsUnique()) this.dropRootsUniqueConstraint();
|
|
1337
|
+
}
|
|
1338
|
+
/** `roots.real_path` 上是否还带 UNIQUE 约束。 */
|
|
1339
|
+
rootsPathIsUnique() {
|
|
1340
|
+
const row = this.db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'roots'").get();
|
|
1341
|
+
return /real_path\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i.test(row?.sql ?? "");
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* 重建 `roots` 去掉 UNIQUE。
|
|
1345
|
+
*
|
|
1346
|
+
* 这是一次**有真实数据风险**的操作,所以每一步都写清楚理由:
|
|
1347
|
+
* - `PRAGMA foreign_keys=OFF`:`device_roots.root_id` 引用 `roots(id)`,而重建要
|
|
1348
|
+
* DROP 掉被引用的那张表。开着外键会直接失败。
|
|
1349
|
+
* - 事务:`new_roots` 建好、数据搬完、**最后**才 DROP 老表并改名。中途失败会整体
|
|
1350
|
+
* 回滚,原表原封不动。
|
|
1351
|
+
* - 搬数据用显式列名而不是 `SELECT *`:老表可能还没有 `builtin` 列(两种迁移顺序
|
|
1352
|
+
* 都可能出现),按列名搬才不会在某一台机器上错位。
|
|
1353
|
+
*/
|
|
1354
|
+
dropRootsUniqueConstraint() {
|
|
1355
|
+
const columns = this.hasRootColumn("builtin") ? "id, label, real_path, created_at, builtin" : "id, label, real_path, created_at, 0 AS builtin";
|
|
1356
|
+
this.db.exec("PRAGMA foreign_keys=OFF");
|
|
1357
|
+
try {
|
|
1358
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1359
|
+
this.db.exec(`
|
|
1360
|
+
CREATE TABLE roots_migrated(
|
|
1361
|
+
id TEXT PRIMARY KEY, label TEXT NOT NULL, real_path TEXT NOT NULL,
|
|
1362
|
+
created_at INTEGER NOT NULL, builtin INTEGER NOT NULL DEFAULT 0
|
|
1363
|
+
);
|
|
1364
|
+
INSERT INTO roots_migrated(${columns}) SELECT ${columns} FROM roots;
|
|
1365
|
+
DROP TABLE roots;
|
|
1366
|
+
ALTER TABLE roots_migrated RENAME TO roots;
|
|
1367
|
+
`);
|
|
1368
|
+
this.db.exec("COMMIT");
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
this.db.exec("ROLLBACK");
|
|
1371
|
+
throw error;
|
|
1372
|
+
} finally {
|
|
1373
|
+
this.db.exec("PRAGMA foreign_keys=ON");
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
/** `roots` 表是否已有指定列。 */
|
|
1377
|
+
hasRootColumn(column) {
|
|
1378
|
+
return this.db.prepare("PRAGMA table_info(roots)").all().some((entry) => entry.name === column);
|
|
1233
1379
|
}
|
|
1234
1380
|
};
|
|
1235
1381
|
function hashSecret(secret) {
|
|
@@ -1251,7 +1397,8 @@ function rootFromRow(row) {
|
|
|
1251
1397
|
id: row.id,
|
|
1252
1398
|
label: row.label,
|
|
1253
1399
|
realPath: row.real_path,
|
|
1254
|
-
createdAt: row.created_at
|
|
1400
|
+
createdAt: row.created_at,
|
|
1401
|
+
...row.builtin === 1 ? { builtin: true } : {}
|
|
1255
1402
|
};
|
|
1256
1403
|
}
|
|
1257
1404
|
function trashFromRow(row) {
|
|
@@ -1268,6 +1415,239 @@ function trashFromRow(row) {
|
|
|
1268
1415
|
};
|
|
1269
1416
|
}
|
|
1270
1417
|
//#endregion
|
|
1418
|
+
//#region src/host/image-handoff.ts
|
|
1419
|
+
/**
|
|
1420
|
+
* 交接目录的**出厂默认**。
|
|
1421
|
+
*
|
|
1422
|
+
* 设置面板里「当前默认」那一栏展示的就是这个字面量,且是只读的 —— 它是这份代码的
|
|
1423
|
+
* 契约(历史交接消息、识图工具、手机端都按它写过),不是一个可以随风改的偏好项。
|
|
1424
|
+
* 用户可以改的是设置的**当前值**(存在数据库里),默认值本身不跟着走。
|
|
1425
|
+
*/
|
|
1426
|
+
const DEFAULT_HANDOFF_SUBDIR = ".dsh-images";
|
|
1427
|
+
/** settings 表里的键名。 */
|
|
1428
|
+
const IMAGE_SUBDIR_SETTING = "image_handoff_subdir";
|
|
1429
|
+
/**
|
|
1430
|
+
* 只读图片路由。刻意放在 `/plugin/` 下而不是版本化 REST 前缀里:
|
|
1431
|
+
* 它不参与 REST 契约、不需要设备 token,将来 REST 升版时这条也不该跟着动。
|
|
1432
|
+
*/
|
|
1433
|
+
const IMAGE_ROUTE = "/plugin/dsh-remote-bridge/raw";
|
|
1434
|
+
/** 目录名上限:它要拼进路径,太长的名字只是拿来坑自己的。 */
|
|
1435
|
+
const MAX_SUBDIR_CHARS = 64;
|
|
1436
|
+
/**
|
|
1437
|
+
* 把用户填的目录名收成一个安全值。
|
|
1438
|
+
*
|
|
1439
|
+
* 只收**单段**目录名:`/`、`\`、NUL 一律拒绝(否则它就不是「目录名」而是「路径」,
|
|
1440
|
+
* 会把落盘位置带出工作目录);`.` 与 `..` 拒绝(前者是当前目录、后者是往上爬);
|
|
1441
|
+
* 允许点开头 —— 默认值 `.dsh-images` 自己就是点目录。
|
|
1442
|
+
*/
|
|
1443
|
+
function normalizeHandoffSubdir(value) {
|
|
1444
|
+
if (typeof value !== "string") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name must be a string.");
|
|
1445
|
+
const trimmed = value.trim();
|
|
1446
|
+
if (trimmed === "" || trimmed.length > MAX_SUBDIR_CHARS) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", `The image directory name must be 1-${MAX_SUBDIR_CHARS} characters.`);
|
|
1447
|
+
if (trimmed === "." || trimmed === "..") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name cannot be \".\" or \"..\".");
|
|
1448
|
+
if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name may only contain letters, digits, dots, underscores, and hyphens.");
|
|
1449
|
+
return trimmed;
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* 读出当前生效的目录名。
|
|
1453
|
+
*
|
|
1454
|
+
* 数据库里那条坏掉(手改过 / 旧版本写过)就退回默认并**顺手修掉**:一个坏值不该让
|
|
1455
|
+
* 「发图」这个动作从此失败,而每次读都只回退不修正会把坏值永远留着。
|
|
1456
|
+
*/
|
|
1457
|
+
function readHandoffSubdir(database) {
|
|
1458
|
+
const stored = database.getSetting(IMAGE_SUBDIR_SETTING);
|
|
1459
|
+
if (stored === void 0) return DEFAULT_HANDOFF_SUBDIR;
|
|
1460
|
+
try {
|
|
1461
|
+
return normalizeHandoffSubdir(stored);
|
|
1462
|
+
} catch {
|
|
1463
|
+
database.setSetting(IMAGE_SUBDIR_SETTING, DEFAULT_HANDOFF_SUBDIR);
|
|
1464
|
+
return DEFAULT_HANDOFF_SUBDIR;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
/** 校验并保存,返回落库后的值。 */
|
|
1468
|
+
function saveHandoffSubdir(database, value) {
|
|
1469
|
+
const normalized = normalizeHandoffSubdir(value);
|
|
1470
|
+
database.setSetting(IMAGE_SUBDIR_SETTING, normalized);
|
|
1471
|
+
return normalized;
|
|
1472
|
+
}
|
|
1473
|
+
/** 设置面板要的那三个值。 */
|
|
1474
|
+
function imageHandoffView(database, fallbackDir) {
|
|
1475
|
+
return {
|
|
1476
|
+
subdir: readHandoffSubdir(database),
|
|
1477
|
+
defaultSubdir: DEFAULT_HANDOFF_SUBDIR,
|
|
1478
|
+
fallbackDir
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
/** 交接文件名的白名单:`<sha256>.<图片扩展名>`,路径穿越在名字这一层就死掉。 */
|
|
1482
|
+
const HANDOFF_FILE_NAME = /^[0-9a-f]{64}\.(png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
|
|
1483
|
+
/** 扩展名 → 该扩展名必须用内容自证的身份。 */
|
|
1484
|
+
const HANDOFF_MEDIA_TYPES = {
|
|
1485
|
+
png: "image/png",
|
|
1486
|
+
jpg: "image/jpeg",
|
|
1487
|
+
jpeg: "image/jpeg",
|
|
1488
|
+
webp: "image/webp",
|
|
1489
|
+
gif: "image/gif",
|
|
1490
|
+
heic: "image/heic",
|
|
1491
|
+
heif: "image/heif",
|
|
1492
|
+
bmp: "image/bmp",
|
|
1493
|
+
avif: "image/avif"
|
|
1494
|
+
};
|
|
1495
|
+
/** 浏览器只会显示预览,超过这个就别端过去了。 */
|
|
1496
|
+
const MAX_BYTES = 33554432;
|
|
1497
|
+
/** 病态长的入参在碰文件系统之前就退掉。 */
|
|
1498
|
+
const MAX_NAME_CHARS = 4096;
|
|
1499
|
+
/** 按扩展名取声明的媒体类型;不支持的扩展名返回 undefined。 */
|
|
1500
|
+
function declaredMediaType(name) {
|
|
1501
|
+
const cut = name.lastIndexOf(".");
|
|
1502
|
+
if (cut === -1) return void 0;
|
|
1503
|
+
return HANDOFF_MEDIA_TYPES[name.slice(cut + 1).toLowerCase()];
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* 从文件头认出真实格式。扩展名只是**声明**,这里才是内容自证。
|
|
1507
|
+
* @param head - 文件前至少 12 个字节。
|
|
1508
|
+
*/
|
|
1509
|
+
function sniffMediaType(head) {
|
|
1510
|
+
const ascii = (offset, text) => {
|
|
1511
|
+
if (head.length < offset + text.length) return false;
|
|
1512
|
+
for (let index = 0; index < text.length; index += 1) if (head[offset + index] !== text.charCodeAt(index)) return false;
|
|
1513
|
+
return true;
|
|
1514
|
+
};
|
|
1515
|
+
const bytes = (...expected) => {
|
|
1516
|
+
if (head.length < expected.length) return false;
|
|
1517
|
+
return expected.every((byte, index) => head[index] === byte);
|
|
1518
|
+
};
|
|
1519
|
+
if (bytes(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
|
|
1520
|
+
if (bytes(255, 216, 255)) return "image/jpeg";
|
|
1521
|
+
if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
|
|
1522
|
+
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
|
|
1523
|
+
if (bytes(66, 77)) return "image/bmp";
|
|
1524
|
+
if (ascii(4, "ftyp")) {
|
|
1525
|
+
const brand = head.length >= 12 ? String.fromCharCode(...head.subarray(8, 12)) : "";
|
|
1526
|
+
if (brand.startsWith("avi")) return "image/avif";
|
|
1527
|
+
if (brand.startsWith("hei") || brand.startsWith("hev") || brand.startsWith("mif")) return "image/heic";
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
/** 请求的 `Host` 是否指向回环(DNS 重绑定防护)。 */
|
|
1531
|
+
function loopbackHost(req) {
|
|
1532
|
+
const host = req.headers.host;
|
|
1533
|
+
if (typeof host !== "string" || host === "") return false;
|
|
1534
|
+
if (host.startsWith("[")) return host.startsWith("[::1]");
|
|
1535
|
+
const name = host.split(":")[0];
|
|
1536
|
+
return name === "127.0.0.1" || name === "localhost";
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* 请求是否来自某个插件自己服务的页面。
|
|
1540
|
+
*
|
|
1541
|
+
* 沙箱化的画布跑在不透明 origin 里,它自己的请求不带可用的 origin,但会带来源页 URL
|
|
1542
|
+
* —— 这是 HTML 产物也能显示工作区图片的原因。
|
|
1543
|
+
*/
|
|
1544
|
+
function pluginPageReferer(req) {
|
|
1545
|
+
const referer = req.headers.referer;
|
|
1546
|
+
if (typeof referer !== "string" || referer === "") return false;
|
|
1547
|
+
try {
|
|
1548
|
+
const url = new URL(referer);
|
|
1549
|
+
if (url.host !== req.headers.host) return false;
|
|
1550
|
+
return url.pathname.startsWith("/plugin/");
|
|
1551
|
+
} catch {
|
|
1552
|
+
return false;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* 请求是否可以被回答。
|
|
1557
|
+
*
|
|
1558
|
+
* 浏览器一定发 `Sec-Fetch-Site`,跨站一律拒绝;没有这个头的调用方不是浏览器页面
|
|
1559
|
+
* (curl、测试),此时回环可达性本身就是访问模型 —— 与本地 GUI 其余部分假定的信任一致。
|
|
1560
|
+
*/
|
|
1561
|
+
function sameOrigin(req) {
|
|
1562
|
+
if (!loopbackHost(req)) return false;
|
|
1563
|
+
if (pluginPageReferer(req)) return true;
|
|
1564
|
+
const site = req.headers["sec-fetch-site"];
|
|
1565
|
+
if (typeof site === "string") return site === "same-origin" || site === "none";
|
|
1566
|
+
const origin = req.headers.origin;
|
|
1567
|
+
if (typeof origin !== "string" || origin === "") return true;
|
|
1568
|
+
try {
|
|
1569
|
+
return new URL(origin).host === req.headers.host;
|
|
1570
|
+
} catch {
|
|
1571
|
+
return false;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
/** 用一段纯文本说出拒绝理由:不回显调用方给的路径,也不做任何目录列举。 */
|
|
1575
|
+
function refuse(res, status, reason) {
|
|
1576
|
+
const body = `${reason}\n`;
|
|
1577
|
+
res.writeHead(status, {
|
|
1578
|
+
"content-type": "text/plain; charset=utf-8",
|
|
1579
|
+
"content-length": String(Buffer.byteLength(body)),
|
|
1580
|
+
"cache-control": "no-store",
|
|
1581
|
+
"x-content-type-options": "nosniff"
|
|
1582
|
+
});
|
|
1583
|
+
res.end(body);
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* 这条路由的处理器工厂。
|
|
1587
|
+
* @param options - 取图实现。
|
|
1588
|
+
* @returns 一个 `(req, res) => Promise<void>`,可直接交给 `webServer.register`。
|
|
1589
|
+
*/
|
|
1590
|
+
function createImageRouteHandler(options) {
|
|
1591
|
+
return async function serveImage(req, res) {
|
|
1592
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1593
|
+
refuse(res, 405, "method not allowed");
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
if (!sameOrigin(req)) {
|
|
1597
|
+
refuse(res, 403, "cross-site request refused");
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1601
|
+
const sessionId = (url.searchParams.get("sessionId") ?? "").trim();
|
|
1602
|
+
const rawName = url.searchParams.get("name") ?? "";
|
|
1603
|
+
if (sessionId === "" || rawName === "" || rawName.length > MAX_NAME_CHARS) {
|
|
1604
|
+
refuse(res, 400, "sessionId and name are required");
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
const name = rawName.split(/[/\\]/).pop() ?? rawName;
|
|
1608
|
+
if (HANDOFF_FILE_NAME.exec(name) === null) {
|
|
1609
|
+
refuse(res, 404, "no such handoff image");
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
const declared = declaredMediaType(name);
|
|
1613
|
+
let image;
|
|
1614
|
+
try {
|
|
1615
|
+
image = await options.read(sessionId, name);
|
|
1616
|
+
} catch {
|
|
1617
|
+
refuse(res, 404, "no such handoff image");
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
if (image.bytes.byteLength === 0) {
|
|
1621
|
+
refuse(res, 404, "empty file");
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
if (image.bytes.byteLength > MAX_BYTES) {
|
|
1625
|
+
refuse(res, 413, "image is too large to display");
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
const sniffed = sniffMediaType(image.bytes.subarray(0, 12));
|
|
1629
|
+
if (sniffed === void 0) {
|
|
1630
|
+
refuse(res, 415, "content is not a supported image");
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
if (declared !== void 0 && sniffed !== declared) {
|
|
1634
|
+
refuse(res, 415, "content does not match the file extension");
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
res.writeHead(200, {
|
|
1638
|
+
"content-type": sniffed,
|
|
1639
|
+
"content-length": String(image.bytes.byteLength),
|
|
1640
|
+
"cache-control": "private, max-age=300",
|
|
1641
|
+
"x-content-type-options": "nosniff"
|
|
1642
|
+
});
|
|
1643
|
+
if (req.method === "HEAD") {
|
|
1644
|
+
res.end();
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
res.end(Buffer.from(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength));
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
//#endregion
|
|
1271
1651
|
//#region src/host/dsh-0.2-bridge.ts
|
|
1272
1652
|
/**
|
|
1273
1653
|
* DSH 0.2.x host-service bridge.
|
|
@@ -1834,7 +2214,6 @@ function sessionIdOf(summary) {
|
|
|
1834
2214
|
* - 解析不出工作目录时退回 DSH 目录([DEFAULT_IMAGE_HANDOFF_DIR])——
|
|
1835
2215
|
* 宁可落到私有目录也不能让「发消息」本身失败。
|
|
1836
2216
|
*/
|
|
1837
|
-
const HANDOFF_SUBDIR = ".dsh-images";
|
|
1838
2217
|
const DEFAULT_IMAGE_HANDOFF_DIR = join(homedir(), ".dsh", "image-handoff");
|
|
1839
2218
|
const IMAGE_EXTENSIONS = {
|
|
1840
2219
|
"image/png": "png",
|
|
@@ -1880,33 +2259,30 @@ function writeHandoffFile(dir, workspace, mediaType, data) {
|
|
|
1880
2259
|
};
|
|
1881
2260
|
}
|
|
1882
2261
|
/** 交接文件名只允许「内容哈希 + 图片扩展名」:挡住一切路径穿越。 */
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
webp: "image/webp",
|
|
1889
|
-
gif: "image/gif",
|
|
1890
|
-
heic: "image/heic",
|
|
1891
|
-
heif: "image/heif",
|
|
1892
|
-
bmp: "image/bmp",
|
|
1893
|
-
avif: "image/avif"
|
|
1894
|
-
};
|
|
2262
|
+
/**
|
|
2263
|
+
* 交接文件名的白名单与媒体类型表现在住在 `image-handoff.ts` —— 设置面板、只读图片路由
|
|
2264
|
+
* 和这里读的是**同一份**判据,分开写两份迟早漂移。这里只是就近取个别名。
|
|
2265
|
+
*/
|
|
2266
|
+
const HANDOFF_NAME = HANDOFF_FILE_NAME;
|
|
1895
2267
|
/**
|
|
1896
2268
|
* 读出一张交接图片的字节(供手机端把它**显示**出来)。
|
|
1897
2269
|
*
|
|
1898
|
-
* 只接受裸文件名,目录永远由「会话工作目录 +
|
|
2270
|
+
* 只接受裸文件名,目录永远由「会话工作目录 + <识图路径>」(或兜底目录)推出,
|
|
1899
2271
|
* 客户端给绝对路径也不认 —— 否则这条读文件的口子会变成任意文件读取。
|
|
1900
|
-
* 工作目录与 [writeHandoffFile]
|
|
2272
|
+
* 工作目录与 [writeHandoffFile] 用的是同一套解析,保证「写在哪、从哪读」;
|
|
2273
|
+
* `<识图路径>` 也来自同一个地方(设置里的当前值),改设置后写读同步跟着走。
|
|
2274
|
+
*
|
|
2275
|
+
* **导出**是给同源只读图片路由(`image-handoff.ts`)用的:那条路由不校验设备 token,
|
|
2276
|
+
* 所以它更需要「目录不由客户端给」这条约束 —— 直接复用这里,而不是另写一套解析。
|
|
1901
2277
|
*/
|
|
1902
|
-
async function readHandoffImageFile(host, roots, fallbackDir, sessionId, name) {
|
|
2278
|
+
async function readHandoffImageFile(host, roots, fallbackDir, subdir, sessionId, name) {
|
|
1903
2279
|
const fileName = name.split(/[/\\]/).pop() ?? name;
|
|
1904
2280
|
const match = HANDOFF_NAME.exec(fileName);
|
|
1905
2281
|
if (match === null) throw new Error("BAD_HANDOFF_NAME");
|
|
1906
2282
|
const ext = (match[1] ?? "").toLowerCase();
|
|
1907
2283
|
const mediaType = HANDOFF_MEDIA_TYPES[ext] ?? "application/octet-stream";
|
|
1908
2284
|
const workspace = await sessionWorkspaceDir(host, roots, sessionId);
|
|
1909
|
-
const target = [...workspace !== void 0 ? [join(workspace,
|
|
2285
|
+
const target = [...workspace !== void 0 ? [join(workspace, subdir, fileName)] : [], join(fallbackDir, fileName)].find((candidate) => existsSync(candidate));
|
|
1910
2286
|
if (target === void 0) throw new Error("HANDOFF_IMAGE_NOT_FOUND");
|
|
1911
2287
|
return {
|
|
1912
2288
|
bytes: new Uint8Array(readFileSync(target)),
|
|
@@ -1979,12 +2355,12 @@ function handoffLine(index, name, relative, locale) {
|
|
|
1979
2355
|
if (locale === "en") return `🖼️ Image ${index} "${name}" (this model can't see images, saved to ${relative}; to view it, open the path below with an image-reading tool)`;
|
|
1980
2356
|
return `🖼️ 图片 ${index}「${name}」(当前模型看不到图,已存到 ${relative};要看图,请用识图工具打开下面那行的路径)`;
|
|
1981
2357
|
}
|
|
1982
|
-
async function handoffImagesForTextOnlyModel(host, roots, fallbackDir, locale, sessionId, content) {
|
|
2358
|
+
async function handoffImagesForTextOnlyModel(host, roots, fallbackDir, subdir, locale, sessionId, content) {
|
|
1983
2359
|
if (!content.some((part) => part.type === "image")) return content;
|
|
1984
2360
|
try {
|
|
1985
2361
|
if (!await modelIsConfirmedTextOnly(host, sessionId)) return content;
|
|
1986
2362
|
const workspace = await sessionWorkspaceDir(host, roots, sessionId);
|
|
1987
|
-
const dir = workspace !== void 0 ? join(workspace,
|
|
2363
|
+
const dir = workspace !== void 0 ? join(workspace, subdir) : fallbackDir;
|
|
1988
2364
|
let index = 0;
|
|
1989
2365
|
return content.map((part) => {
|
|
1990
2366
|
if (part.type !== "image") return part;
|
|
@@ -2015,8 +2391,13 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2015
2391
|
const mux = new FrameHub();
|
|
2016
2392
|
const hostFrames = new FrameHub();
|
|
2017
2393
|
const imageHandoff = options.imageHandoff !== false;
|
|
2018
|
-
const
|
|
2394
|
+
const handoffRootsOf = () => {
|
|
2395
|
+
const value = options.handoffRoots;
|
|
2396
|
+
if (value === void 0) return [];
|
|
2397
|
+
return typeof value === "function" ? value() : value;
|
|
2398
|
+
};
|
|
2019
2399
|
const imageHandoffDir = options.imageHandoffDir ?? DEFAULT_IMAGE_HANDOFF_DIR;
|
|
2400
|
+
const handoffSubdirOf = options.handoffSubdir ?? (() => ".dsh-images");
|
|
2020
2401
|
const fileUploadService = optionalService(host, "fileUploads");
|
|
2021
2402
|
const approvals = new ApprovalBridge(host, mux);
|
|
2022
2403
|
approvals.attach();
|
|
@@ -2120,7 +2501,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2120
2501
|
const requestId = randomUUID();
|
|
2121
2502
|
const { locale, ...kernelPayload } = request.payload;
|
|
2122
2503
|
const receiptIds = request.payload.content.filter((part) => part.type === "file").map((part) => part.receiptId);
|
|
2123
|
-
const content = imageHandoff ? await handoffImagesForTextOnlyModel(host,
|
|
2504
|
+
const content = imageHandoff ? await handoffImagesForTextOnlyModel(host, handoffRootsOf(), imageHandoffDir, handoffSubdirOf(), locale ?? "zh", request.payload.sessionId, request.payload.content) : request.payload.content;
|
|
2124
2505
|
const binding = receiptIds.length === 0 ? void 0 : await (async () => {
|
|
2125
2506
|
if (fileUploadService == null) throw new Error("FILE_UPLOADS_UNAVAILABLE: this DSH build provides no ctx.fileUploads service, so file attachments cannot be sent");
|
|
2126
2507
|
return fileUploadService.bindPrompt(await agentFor(request.payload.sessionId), receiptIds, requestId);
|
|
@@ -2157,7 +2538,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2157
2538
|
}),
|
|
2158
2539
|
readHandoffImage: (request) => call(async () => {
|
|
2159
2540
|
const { sessionId, name } = request.payload;
|
|
2160
|
-
return readHandoffImageFile(host,
|
|
2541
|
+
return readHandoffImageFile(host, handoffRootsOf(), imageHandoffDir, handoffSubdirOf(), sessionId, name);
|
|
2161
2542
|
}),
|
|
2162
2543
|
cancel: (request) => call(() => host.sessionController.cancel(request.payload)),
|
|
2163
2544
|
rename: (request) => call(() => host.sessionController.rename(request.payload)),
|
|
@@ -3041,7 +3422,7 @@ async function inspectResolvedPath(root, wirePath) {
|
|
|
3041
3422
|
}
|
|
3042
3423
|
//#endregion
|
|
3043
3424
|
//#region src/shared/version.ts
|
|
3044
|
-
const PLUGIN_VERSION = "2.
|
|
3425
|
+
const PLUGIN_VERSION = "2.3.0";
|
|
3045
3426
|
//#endregion
|
|
3046
3427
|
//#region src/host/router.ts
|
|
3047
3428
|
var ApiRouter = class {
|
|
@@ -3639,6 +4020,17 @@ var ApiRouter = class {
|
|
|
3639
4020
|
sendJson(res, 200, { items: this.options.database.listAudit(optionalInteger(url.searchParams.get("limit")) ?? 200) });
|
|
3640
4021
|
return;
|
|
3641
4022
|
}
|
|
4023
|
+
if (method === "GET" && pathname === "/manage/image") {
|
|
4024
|
+
sendJson(res, 200, imageHandoffView(this.options.database, this.imageFallbackDir()));
|
|
4025
|
+
return;
|
|
4026
|
+
}
|
|
4027
|
+
if (method === "PUT" && pathname === "/manage/image") {
|
|
4028
|
+
const body = await readJson(req, 16384);
|
|
4029
|
+
const subdir = saveHandoffSubdir(this.options.database, body.subdir);
|
|
4030
|
+
this.options.database.audit("admin", "loopback-admin", "image.subdir", void 0, void 0, { subdir }, requestId);
|
|
4031
|
+
sendJson(res, 200, imageHandoffView(this.options.database, this.imageFallbackDir()));
|
|
4032
|
+
return;
|
|
4033
|
+
}
|
|
3642
4034
|
const purgeRoute = pathname.match(/^\/manage\/trash\/([^/]+)$/);
|
|
3643
4035
|
if (method === "DELETE" && purgeRoute !== null) {
|
|
3644
4036
|
await this.options.files.purgeTrash(decodeURIComponent(purgeRoute[1]));
|
|
@@ -3736,6 +4128,10 @@ var ApiRouter = class {
|
|
|
3736
4128
|
const roots = this.options.database.listRoots();
|
|
3737
4129
|
return principal.rootIds === "all" ? roots : roots.filter((root) => principal.rootIds.has(root.id));
|
|
3738
4130
|
}
|
|
4131
|
+
/** 兜底交接目录:没配就用状态目录下的 `image-handoff`(与 `index.ts` 的默认一致)。 */
|
|
4132
|
+
imageFallbackDir() {
|
|
4133
|
+
return this.options.imageFallbackDir ?? path.join(this.options.database.dataDir, "image-handoff");
|
|
4134
|
+
}
|
|
3739
4135
|
audit(principal, action, rootId, relativePath, requestId, details = {}) {
|
|
3740
4136
|
this.options.database.audit(principal.kind, principal.id, action, rootId, relativePath, details, requestId);
|
|
3741
4137
|
}
|
|
@@ -4213,12 +4609,12 @@ var RemoteApiServer = class {
|
|
|
4213
4609
|
}
|
|
4214
4610
|
}
|
|
4215
4611
|
};
|
|
4216
|
-
function attachEmbeddedRoutes(webServer, router, remote) {
|
|
4612
|
+
function attachEmbeddedRoutes(webServer, router, remote, extraRoutes = []) {
|
|
4217
4613
|
const disposers = [webServer.register({
|
|
4218
4614
|
kind: "prefix",
|
|
4219
4615
|
path: "/dsh-workspace-api",
|
|
4220
4616
|
handler: (req, res) => router.handle(req, res, "/dsh-workspace-api")
|
|
4221
|
-
})];
|
|
4617
|
+
}), ...extraRoutes.map((route) => webServer.register(route))];
|
|
4222
4618
|
return () => {
|
|
4223
4619
|
for (const dispose of disposers.reverse()) dispose();
|
|
4224
4620
|
};
|
|
@@ -4660,15 +5056,25 @@ async function apply(ctx, config = {}) {
|
|
|
4660
5056
|
const maxUploadBytes = config.maxUploadBytes ?? 20971520;
|
|
4661
5057
|
if (!Number.isSafeInteger(maxUploadBytes) || maxUploadBytes < 1) throw new Error("dsh-remote-bridge: maxUploadBytes must be positive");
|
|
4662
5058
|
const database = new WorkspaceDatabase(dataDir);
|
|
5059
|
+
try {
|
|
5060
|
+
const purged = database.purgeRevokedDevices();
|
|
5061
|
+
if (purged > 0) ctx.logger.info(`dsh-remote-bridge: purged ${purged} revoked device(s)`);
|
|
5062
|
+
} catch (error) {
|
|
5063
|
+
ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: could not purge revoked devices: ${String(error)}`));
|
|
5064
|
+
}
|
|
5065
|
+
await registerBuiltinWorkspaceRoot(ctx, database);
|
|
4663
5066
|
const events = new WorkspaceEventBus();
|
|
4664
5067
|
const auth = new AuthService(database);
|
|
4665
5068
|
const files = new FileService(database, (event) => events.emit(event));
|
|
5069
|
+
const imageHandoffDir = path.join(dataDir, "image-handoff");
|
|
5070
|
+
const handoffRootsOf = () => database.listRoots().map((root) => ({
|
|
5071
|
+
id: root.id,
|
|
5072
|
+
realPath: root.realPath
|
|
5073
|
+
}));
|
|
4666
5074
|
const apiProxy = createDsh02ApiProxy(ctx, {
|
|
4667
|
-
handoffRoots:
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
})),
|
|
4671
|
-
imageHandoffDir: path.join(dataDir, "image-handoff")
|
|
5075
|
+
handoffRoots: handoffRootsOf,
|
|
5076
|
+
imageHandoffDir,
|
|
5077
|
+
handoffSubdir: () => readHandoffSubdir(database)
|
|
4672
5078
|
});
|
|
4673
5079
|
const chat = new DshChatAdapter(apiProxy, database, {
|
|
4674
5080
|
agents: ctx.agents,
|
|
@@ -4692,7 +5098,8 @@ async function apply(ctx, config = {}) {
|
|
|
4692
5098
|
chat,
|
|
4693
5099
|
settings,
|
|
4694
5100
|
remote,
|
|
4695
|
-
maxUploadBytes
|
|
5101
|
+
maxUploadBytes,
|
|
5102
|
+
imageFallbackDir: imageHandoffDir
|
|
4696
5103
|
});
|
|
4697
5104
|
try {
|
|
4698
5105
|
await remote.start();
|
|
@@ -4700,7 +5107,12 @@ async function apply(ctx, config = {}) {
|
|
|
4700
5107
|
database.close();
|
|
4701
5108
|
throw error;
|
|
4702
5109
|
}
|
|
4703
|
-
const
|
|
5110
|
+
const imageRoute = createImageRouteHandler({ read: (sessionId, name) => readHandoffImageFile(ctx, handoffRootsOf(), imageHandoffDir, readHandoffSubdir(database), sessionId, name) });
|
|
5111
|
+
const disposeEmbedded = attachEmbeddedRoutes(ctx.webServer, router, remote, [{
|
|
5112
|
+
kind: "prefix",
|
|
5113
|
+
path: IMAGE_ROUTE,
|
|
5114
|
+
handler: imageRoute
|
|
5115
|
+
}]);
|
|
4704
5116
|
ctx.effect(() => async () => {
|
|
4705
5117
|
disposeEmbedded();
|
|
4706
5118
|
await remote.stop();
|
|
@@ -4709,5 +5121,33 @@ async function apply(ctx, config = {}) {
|
|
|
4709
5121
|
const status = remote.status();
|
|
4710
5122
|
ctx.logger.info(`dsh-remote-bridge v${PLUGIN_VERSION}: ${status.host}:${status.port} (${status.remoteEnabled ? "remote enabled" : "loopback only"})`);
|
|
4711
5123
|
}
|
|
5124
|
+
/**
|
|
5125
|
+
* 登记「DSH 工作区」为内置授权根。
|
|
5126
|
+
*
|
|
5127
|
+
* 取 `ctx.workspaceRegistry.list()` 的**第一个**工作区(用户明确要「只显示第一个」),
|
|
5128
|
+
* 登记成一条 `builtin` 根。四个必须静默失败的情形 —— 插件启动不该因为一个「锦上添花的
|
|
5129
|
+
* 默认根」而整个起不来:
|
|
5130
|
+
*
|
|
5131
|
+
* - 没有工作区(用户从没建过):什么都不做;
|
|
5132
|
+
* - 第一个工作区的路径已经不存在(用户把它删了):什么都不做,否则留下一条指向虚空的
|
|
5133
|
+
* 授权根,手机浏览时一路报错,比不显示更糟;
|
|
5134
|
+
* - 注册表自己抛了:同上,记一条 warn 就走。
|
|
5135
|
+
*
|
|
5136
|
+
* 目录存在性用 `stat` 判,且必须是**目录**而不是文件 —— `canonicalRoot` 之后如果指向一个
|
|
5137
|
+
* 普通文件,后续列目录会一路报错。
|
|
5138
|
+
*/
|
|
5139
|
+
async function registerBuiltinWorkspaceRoot(ctx, database) {
|
|
5140
|
+
try {
|
|
5141
|
+
const workspace = ctx.workspaceRegistry.list()[0];
|
|
5142
|
+
if (workspace === void 0) return;
|
|
5143
|
+
const target = workspace.path?.trim();
|
|
5144
|
+
if (target === void 0 || target === "") return;
|
|
5145
|
+
if (!(await stat(target)).isDirectory()) return;
|
|
5146
|
+
const root = await database.ensureBuiltinRoot(target, workspace.title?.trim() || "DSH 工作区");
|
|
5147
|
+
if (root !== void 0) ctx.logger.info(`dsh-remote-bridge: registered the DSH workspace as a built-in root: ${root.realPath}`);
|
|
5148
|
+
} catch (error) {
|
|
5149
|
+
ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: could not register the DSH workspace root: ${String(error)}`));
|
|
5150
|
+
}
|
|
5151
|
+
}
|
|
4712
5152
|
//#endregion
|
|
4713
5153
|
export { apply, apply as default, inject, name };
|