dscs-server 0.1.4 → 0.1.5
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 +3 -1
- package/dist/http.js +5 -4
- package/dist/session-admin.js +20 -6
- package/dist/sessions.js +31 -1
- package/dist/shared/blob-batch.js +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
DSH Session CloudSync 的自部署服务端。
|
|
4
4
|
|
|
5
|
-
当前发布版本:**0.1.
|
|
5
|
+
当前发布版本:**0.1.5**(协议版本 3,设备 API 只有 `/v2/*`;数据库结构版本 6)。
|
|
6
6
|
|
|
7
7
|
## 能做什么
|
|
8
8
|
|
|
@@ -10,6 +10,7 @@ DSH Session CloudSync 的自部署服务端。
|
|
|
10
10
|
- **私有部署**:数据留在自己的机器或 NAS,不经过第三方;
|
|
11
11
|
- **端到端加密**:服务端只保存不透明密文和同步所需的基本信息,看不到会话内容;
|
|
12
12
|
- **弱网可用**:断点续传、批量传输、加密前压缩,网络恢复后继续;
|
|
13
|
+
- **附件通道**:加密附件(图片/文件)与按批次的附件索引有独立的 blob 类型,会话移除时一并释放;插件侧接入后生效;
|
|
13
14
|
- **不会互相覆盖**:同一时间只允许一台设备上传,避免多端同时写乱;
|
|
14
15
|
- **设备管理**:签发/改名/吊销设备,查看最后在线时间,为设备设置颜色;
|
|
15
16
|
- **会话管理**:存档、移除并释放空间、重置后重新上传、恢复存档;
|
|
@@ -70,6 +71,7 @@ printf '%s' "$NEW_PASSWORD" | dscs-server admin reset-password --data-dir /var/l
|
|
|
70
71
|
- 默认只监听 `127.0.0.1`,公网部署请放在 TLS 反代后;
|
|
71
72
|
- 数据目录包含 SQLite、blob 文件与迁移备份,升级前请先备份;
|
|
72
73
|
- 数据库结构版本 6,支持从旧版本自动迁移,迁移前会写本地备份;
|
|
74
|
+
- 0.1.5 不迁移数据库,可直接回滚;旧插件不受影响(附件功能按 `/health` 的 `attachment-index` capability 门控);
|
|
73
75
|
- 旧的变更流接口已移除;0.1.0 插件不兼容,需要插件 >= 0.1.1;
|
|
74
76
|
- 服务端不持有明文会话内容,只存不透明密文和同步元数据;
|
|
75
77
|
- 已有未压缩会话不会自动压缩;需要清空数据目录后由插件全量重传;
|
package/dist/http.js
CHANGED
|
@@ -485,7 +485,8 @@ export function createHttpServer(options) {
|
|
|
485
485
|
'zstd-frame',
|
|
486
486
|
'me-device-info',
|
|
487
487
|
'me-counts',
|
|
488
|
-
'device-rename'
|
|
488
|
+
'device-rename',
|
|
489
|
+
'attachment-index'
|
|
489
490
|
],
|
|
490
491
|
limits: {
|
|
491
492
|
batchBytes: MAX_BATCH_BODY_BYTES,
|
|
@@ -745,7 +746,7 @@ export function createHttpServer(options) {
|
|
|
745
746
|
if (!await hasBlob(options.dataDir, sha256)) return json(res, 404, errorBody('not-found', `没有这个 blob:${sha256}`));
|
|
746
747
|
const record = blobRecord(db, sha256);
|
|
747
748
|
if (record === undefined) return json(res, 404, errorBody('not-found', `没有这个 blob:${sha256}`));
|
|
748
|
-
if (record.kind !== 'frame' && record.kind !== 'attachment' && record.kind !== 'vault') {
|
|
749
|
+
if (record.kind !== 'frame' && record.kind !== 'attachment' && record.kind !== 'attachment-index' && record.kind !== 'vault') {
|
|
749
750
|
throw new SyncError('bad-request', `blob 类别非法:${record.kind}`);
|
|
750
751
|
}
|
|
751
752
|
kinds.push(record.kind);
|
|
@@ -778,8 +779,8 @@ export function createHttpServer(options) {
|
|
|
778
779
|
if (auth === undefined) return json(res, 401, errorBody('unauthorized', '需要设备或 setup token'));
|
|
779
780
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
780
781
|
const kind = url.searchParams.get('kind') ?? 'frame';
|
|
781
|
-
if (kind !== 'frame' && kind !== 'attachment' && kind !== 'vault') {
|
|
782
|
-
return json(res, 400, errorBody('bad-request', 'kind 只能是 frame、attachment 或 vault'));
|
|
782
|
+
if (kind !== 'frame' && kind !== 'attachment' && kind !== 'attachment-index' && kind !== 'vault') {
|
|
783
|
+
return json(res, 400, errorBody('bad-request', 'kind 只能是 frame、attachment、attachment-index 或 vault'));
|
|
783
784
|
}
|
|
784
785
|
const bytes = await readRaw(req, maxBlobBytes);
|
|
785
786
|
if (bytes.length === 0) return json(res, 400, errorBody('bad-request', 'blob 不能为空'));
|
package/dist/session-admin.js
CHANGED
|
@@ -13,11 +13,28 @@ function requireTarget(db, sessionId, createdAt) {
|
|
|
13
13
|
if (row.created_at !== createdAt) throw new SyncError('conflict', 'createdAt 与当前生命周期不符');
|
|
14
14
|
return row;
|
|
15
15
|
}
|
|
16
|
+
function manifestBlobRefs(manifest) {
|
|
17
|
+
const frames = Array.isArray(manifest.frames) ? manifest.frames : [];
|
|
18
|
+
const hashes = [];
|
|
19
|
+
for (const frame of frames){
|
|
20
|
+
if (frame === null || typeof frame !== 'object') continue;
|
|
21
|
+
const record = frame;
|
|
22
|
+
if (typeof record.blobSha256 === 'string' && record.blobSha256 !== '') hashes.push(record.blobSha256);
|
|
23
|
+
if (typeof record.attachmentIndexSha256 === 'string' && record.attachmentIndexSha256 !== '') {
|
|
24
|
+
hashes.push(record.attachmentIndexSha256);
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(record.attachmentBlobs)) {
|
|
27
|
+
for (const sha of record.attachmentBlobs){
|
|
28
|
+
if (typeof sha === 'string' && sha !== '') hashes.push(sha);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return hashes;
|
|
33
|
+
}
|
|
16
34
|
function purgeSessionBlobs(db, dataDir, sessionId, manifestJson) {
|
|
17
35
|
let hashes;
|
|
18
36
|
try {
|
|
19
|
-
|
|
20
|
-
hashes = (Array.isArray(manifest.frames) ? manifest.frames : []).map((frame)=>frame !== null && typeof frame === 'object' ? frame.blobSha256 : undefined).filter((hash)=>typeof hash === 'string' && hash !== '');
|
|
37
|
+
hashes = manifestBlobRefs(JSON.parse(manifestJson));
|
|
21
38
|
} catch {
|
|
22
39
|
throw new SyncError('conflict', '会话 manifest 损坏,拒绝清理');
|
|
23
40
|
}
|
|
@@ -32,10 +49,7 @@ function purgeSessionBlobs(db, dataDir, sessionId, manifestJson) {
|
|
|
32
49
|
const otherRows = db.handle.prepare('SELECT manifest_json FROM sessions WHERE dataset = ? AND session_id <> ?').all(DEFAULT_DATASET, sessionId);
|
|
33
50
|
for (const row of otherRows){
|
|
34
51
|
try {
|
|
35
|
-
const
|
|
36
|
-
for (const frame of Array.isArray(manifest.frames) ? manifest.frames : []){
|
|
37
|
-
if (frame !== null && typeof frame === 'object' && typeof frame.blobSha256 === 'string') otherHashes.add(frame.blobSha256);
|
|
38
|
-
}
|
|
52
|
+
for (const hash of manifestBlobRefs(JSON.parse(row.manifest_json)))otherHashes.add(hash);
|
|
39
53
|
} catch {
|
|
40
54
|
for (const hash of unique)otherHashes.add(hash);
|
|
41
55
|
}
|
package/dist/sessions.js
CHANGED
|
@@ -3,12 +3,13 @@ import { assertChain, buildManifest, manifestDigest, stableStringify } from './s
|
|
|
3
3
|
import { sha256 } from './shared/crypto.js';
|
|
4
4
|
import { SyncError } from './shared/wire.js';
|
|
5
5
|
import { blobPath } from './blobs.js';
|
|
6
|
-
import { DEFAULT_DATASET, bumpRevision, readAppendRequest, withTransaction, writeAppendRequest } from './db.js';
|
|
6
|
+
import { DEFAULT_DATASET, blobRecord, bumpRevision, readAppendRequest, withTransaction, writeAppendRequest } from './db.js';
|
|
7
7
|
import { requireLease as requireResourceLease, SYNC_RESOURCE } from './leases.js';
|
|
8
8
|
import { recordSessionEvent } from './session-history.js';
|
|
9
9
|
export const MAX_APPEND_BODY_BYTES = 8 * 1024 * 1024;
|
|
10
10
|
const HASH = /^[0-9a-f]{64}$/u;
|
|
11
11
|
const KEY_ID = /^[0-9a-f]{16}$/u;
|
|
12
|
+
export const MAX_FRAME_ATTACHMENT_BLOBS = 256;
|
|
12
13
|
function assertRef(frame, expectedIndex) {
|
|
13
14
|
if (frame.index !== expectedIndex) throw new SyncError('bad-request', `帧下标应为 ${expectedIndex},实际是 ${frame.index}`);
|
|
14
15
|
if (!HASH.test(frame.chainHash) || !HASH.test(frame.blobSha256)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的哈希非法`);
|
|
@@ -28,6 +29,27 @@ function assertRef(frame, expectedIndex) {
|
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
if (!Number.isSafeInteger(frame.seqFrom) || !Number.isSafeInteger(frame.seqTo)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的 seq 非法`);
|
|
32
|
+
if (frame.attachmentIndexSha256 !== undefined && !HASH.test(frame.attachmentIndexSha256)) {
|
|
33
|
+
throw new SyncError('bad-request', `帧 ${expectedIndex} 的附件索引哈希非法`);
|
|
34
|
+
}
|
|
35
|
+
const attachments = frame.attachmentBlobs;
|
|
36
|
+
if (attachments !== undefined) {
|
|
37
|
+
if (!Array.isArray(attachments) || attachments.length < 1 || attachments.length > MAX_FRAME_ATTACHMENT_BLOBS) {
|
|
38
|
+
throw new SyncError('bad-request', `帧 ${expectedIndex} 的附件列表必须是 1–${MAX_FRAME_ATTACHMENT_BLOBS} 个哈希`);
|
|
39
|
+
}
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
for (const sha of attachments){
|
|
42
|
+
if (typeof sha !== 'string' || !HASH.test(sha)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的附件哈希非法`);
|
|
43
|
+
if (seen.has(sha)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的附件列表重复:${sha}`);
|
|
44
|
+
seen.add(sha);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function requireStoredBlob(db, dataDir, sha256, kind, label) {
|
|
49
|
+
if (!existsSync(blobPath(dataDir, sha256))) throw new SyncError('bad-request', `${label} ${sha256} 尚未上传`);
|
|
50
|
+
const record = blobRecord(db, sha256);
|
|
51
|
+
if (record === undefined) throw new SyncError('bad-request', `${label} ${sha256} 没有登记`);
|
|
52
|
+
if (record.kind !== kind) throw new SyncError('bad-request', `${label} ${sha256} 的类别是 ${record.kind},应为 ${kind}`);
|
|
31
53
|
}
|
|
32
54
|
function toChain(frames) {
|
|
33
55
|
return frames.map((frame)=>({
|
|
@@ -199,6 +221,14 @@ export function appendFrames(db, dataDir, input, now = Date.now()) {
|
|
|
199
221
|
if (!existsSync(blobPath(dataDir, frame.blobSha256))) throw new SyncError('bad-request', `帧 ${frame.index} 的 blob 尚未上传`);
|
|
200
222
|
}
|
|
201
223
|
input.frames.forEach((frame, offset)=>assertRef(frame, (row === undefined || replacingLifecycle ? 0 : input.baseIndex + 1) + offset));
|
|
224
|
+
for (const frame of input.frames){
|
|
225
|
+
if (frame.attachmentIndexSha256 !== undefined) {
|
|
226
|
+
requireStoredBlob(db, dataDir, frame.attachmentIndexSha256, 'attachment-index', `帧 ${frame.index} 的附件索引`);
|
|
227
|
+
}
|
|
228
|
+
for (const sha of frame.attachmentBlobs ?? []){
|
|
229
|
+
requireStoredBlob(db, dataDir, sha, 'attachment', `帧 ${frame.index} 的附件`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
202
232
|
const { manifest, digest, headChainHash } = manifestOf(input.sessionId, formatVersion, createdAt, now, combined, input.tailOpen, input.safeThroughSeq, workspaceKey, parentSession, origin, delegationDepth);
|
|
203
233
|
const revision = bumpRevision(db);
|
|
204
234
|
const stats = statsOf(combined);
|
|
@@ -10,12 +10,14 @@ const VERSION = 1;
|
|
|
10
10
|
const KIND_TO_CODE = {
|
|
11
11
|
frame: 1,
|
|
12
12
|
attachment: 2,
|
|
13
|
-
vault: 3
|
|
13
|
+
vault: 3,
|
|
14
|
+
'attachment-index': 4
|
|
14
15
|
};
|
|
15
16
|
const CODE_TO_KIND = {
|
|
16
17
|
1: 'frame',
|
|
17
18
|
2: 'attachment',
|
|
18
|
-
3: 'vault'
|
|
19
|
+
3: 'vault',
|
|
20
|
+
4: 'attachment-index'
|
|
19
21
|
};
|
|
20
22
|
export function encodeBlobBatch(entries) {
|
|
21
23
|
if (entries.length < 1 || entries.length > MAX_BATCH_BLOBS) {
|