dscs-server 0.0.1 → 0.1.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 +14 -0
- package/dist/db.js +72 -1
- package/dist/http.js +361 -110
- package/dist/index.js +0 -0
- package/dist/sessions.js +160 -9
- package/dist/shared/batches.js +24 -8
- package/dist/shared/blob-batch.js +85 -0
- package/dist/shared/chain.js +14 -1
- package/dist/shared/index.js +1 -0
- package/dist/shared/protocol.js +2 -2
- package/dist/shared/sealed-frame.js +50 -13
- package/package.json +23 -2
package/dist/http.js
CHANGED
|
@@ -4,10 +4,11 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { createServer } from 'node:http';
|
|
5
5
|
import { PROTOCOL_VERSION, errorBody } from './shared/protocol.js';
|
|
6
6
|
import { SyncError, statusOf } from './shared/wire.js';
|
|
7
|
+
import { BLOB_BATCH_CONTENT_TYPE, MAX_BATCH_BLOBS, MAX_BATCH_BODY_BYTES, decodeBlobBatch, encodeBlobBatch } from './shared/blob-batch.js';
|
|
7
8
|
import { deriveMasterKey, newSalt } from './shared/crypto.js';
|
|
8
9
|
import { hasBlob, putBlob, readBlob } from './blobs.js';
|
|
9
10
|
import { DEFAULT_DATASET, SERVER_SCHEMA_VERSION, adminPassword, blobRecord, isAdminInitialized, markAdminInitialized, readMaxBytes, readMeta, recordBlob, readRevision, setAdminPassword, usedBlobBytes, writeMaxBytes } from './db.js';
|
|
10
|
-
import { MAX_APPEND_BODY_BYTES, adminSessions, appendFrames, deleteSession, getManifest,
|
|
11
|
+
import { MAX_APPEND_BODY_BYTES, adminSessions, appendFrames, deleteSession, getManifest, getV2SessionSummary, listV2Batches, listV2Changes, listV2Sessions, sessionCounts } from './sessions.js';
|
|
11
12
|
import { acquireLease, releaseLease, SYNC_RESOURCE, vaultResource } from './leases.js';
|
|
12
13
|
import { appendVaultRecord, readVaultCursor, readVaultStream, validVaultWriter } from './vault.js';
|
|
13
14
|
function readServerVersion() {
|
|
@@ -118,13 +119,93 @@ function clientIdFrom(req) {
|
|
|
118
119
|
}
|
|
119
120
|
return value;
|
|
120
121
|
}
|
|
122
|
+
function idempotencyKeyFrom(req) {
|
|
123
|
+
const raw = req.headers['idempotency-key'];
|
|
124
|
+
const value = Array.isArray(raw) ? raw[0] ?? '' : String(raw ?? '');
|
|
125
|
+
if (!/^[A-Za-z0-9._:-]{1,255}$/u.test(value)) {
|
|
126
|
+
throw new SyncError('bad-request', '需要 1–255 位合法 Idempotency-Key');
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u;
|
|
121
131
|
function sessionIdFrom(pathname, pattern) {
|
|
122
132
|
const match = pattern.exec(pathname);
|
|
123
133
|
if (match === null) throw new SyncError('bad-request', '会话 id 缺失');
|
|
124
134
|
const id = decodeURIComponent(match[1]);
|
|
125
|
-
if (
|
|
135
|
+
if (!SESSION_ID_PATTERN.test(id)) throw new SyncError('bad-request', `会话 id 非法:${id}`);
|
|
126
136
|
return id;
|
|
127
137
|
}
|
|
138
|
+
const MAX_CURSOR_LENGTH = 512;
|
|
139
|
+
function encodeCursor(value) {
|
|
140
|
+
return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
|
|
141
|
+
}
|
|
142
|
+
function decodeCursorObject(raw) {
|
|
143
|
+
if (raw === null || raw === '') return undefined;
|
|
144
|
+
if (raw.length > MAX_CURSOR_LENGTH || !/^[A-Za-z0-9_-]+$/u.test(raw)) {
|
|
145
|
+
throw new SyncError('bad-request', 'cursor 非法');
|
|
146
|
+
}
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
|
|
150
|
+
} catch {
|
|
151
|
+
throw new SyncError('bad-request', 'cursor 非法');
|
|
152
|
+
}
|
|
153
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
154
|
+
throw new SyncError('bad-request', 'cursor 非法');
|
|
155
|
+
}
|
|
156
|
+
return parsed;
|
|
157
|
+
}
|
|
158
|
+
function decodeSessionsCursor(raw) {
|
|
159
|
+
const value = decodeCursorObject(raw);
|
|
160
|
+
if (value === undefined) return undefined;
|
|
161
|
+
if (value['v'] !== 2 || typeof value['after'] !== 'string' || !SESSION_ID_PATTERN.test(value['after'])) {
|
|
162
|
+
throw new SyncError('bad-request', 'cursor 非法');
|
|
163
|
+
}
|
|
164
|
+
return value['after'];
|
|
165
|
+
}
|
|
166
|
+
function decodeChangesCursor(raw) {
|
|
167
|
+
const value = decodeCursorObject(raw);
|
|
168
|
+
if (value === undefined) {
|
|
169
|
+
return {
|
|
170
|
+
revision: 0,
|
|
171
|
+
tombstone: {
|
|
172
|
+
deletedAt: 0,
|
|
173
|
+
sessionId: '',
|
|
174
|
+
createdAt: 0
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const revision = value['revision'];
|
|
179
|
+
const tombstone = value['tombstone'];
|
|
180
|
+
if (value['v'] !== 2 || typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 0 || tombstone === null || typeof tombstone !== 'object' || Array.isArray(tombstone)) {
|
|
181
|
+
throw new SyncError('bad-request', 'cursor 非法');
|
|
182
|
+
}
|
|
183
|
+
const record = tombstone;
|
|
184
|
+
const deletedAt = record['deletedAt'];
|
|
185
|
+
const sessionId = record['sessionId'];
|
|
186
|
+
const createdAt = record['createdAt'];
|
|
187
|
+
const empty = deletedAt === 0 && sessionId === '' && createdAt === 0;
|
|
188
|
+
if (typeof deletedAt !== 'number' || !Number.isSafeInteger(deletedAt) || deletedAt < 0 || typeof sessionId !== 'string' || !empty && !SESSION_ID_PATTERN.test(sessionId) || typeof createdAt !== 'number' || !Number.isSafeInteger(createdAt) || createdAt < 0) {
|
|
189
|
+
throw new SyncError('bad-request', 'cursor 非法');
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
revision,
|
|
193
|
+
tombstone: {
|
|
194
|
+
deletedAt,
|
|
195
|
+
sessionId,
|
|
196
|
+
createdAt
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function etagMatches(header, digest) {
|
|
201
|
+
const raw = Array.isArray(header) ? header.join(',') : header;
|
|
202
|
+
if (raw === undefined) return false;
|
|
203
|
+
const target = `"${digest}"`;
|
|
204
|
+
return raw.split(',').some((entry)=>{
|
|
205
|
+
const value = entry.trim();
|
|
206
|
+
return value === '*' || value === target || value === `W/${target}`;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
128
209
|
function parseAppend(body) {
|
|
129
210
|
if (!Number.isSafeInteger(body.baseIndex)) throw new SyncError('bad-request', 'baseIndex 必须是整数');
|
|
130
211
|
const baseChainHash = body.baseChainHash;
|
|
@@ -165,16 +246,24 @@ function intParam(value, fallback, min, max) {
|
|
|
165
246
|
if (!Number.isSafeInteger(parsed)) throw new SyncError('bad-request', `查询参数必须是整数:${value}`);
|
|
166
247
|
return Math.min(max, Math.max(min, parsed));
|
|
167
248
|
}
|
|
168
|
-
const SYNC_LEASE_PATH = /^\/
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
const
|
|
249
|
+
const SYNC_LEASE_PATH = /^\/v2\/sync\/lease$/u;
|
|
250
|
+
const V2_SESSIONS_PATH = /^\/v2\/sessions$/u;
|
|
251
|
+
const V2_CHANGES_PATH = /^\/v2\/changes$/u;
|
|
252
|
+
const V2_SUMMARY_PATH = /^\/v2\/sessions\/([^/]+)\/summary$/u;
|
|
253
|
+
const V2_BATCHES_PATH = /^\/v2\/sessions\/([^/]+)\/batches$/u;
|
|
254
|
+
const V2_MANIFEST_PATH = /^\/v2\/sessions\/([^/]+)\/manifest$/u;
|
|
255
|
+
const V2_APPEND_PATH = /^\/v2\/sessions\/([^/]+)\/append$/u;
|
|
256
|
+
const V2_DELETE_PATH = /^\/v2\/sessions\/([^/]+)\/delete$/u;
|
|
257
|
+
const DEVICES_PATH = /^\/v2\/devices$/u;
|
|
258
|
+
const DEVICE_PATH = /^\/v2\/devices\/([0-9a-f]+)$/u;
|
|
259
|
+
const BLOB_PATH = /^\/v2\/blobs\/([0-9a-f]{64})$/u;
|
|
260
|
+
const BLOB_BATCH_PATH = /^\/v2\/blobs\/batch$/u;
|
|
261
|
+
const BLOB_BATCH_READ_PATH = /^\/v2\/blobs\/batch\/read$/u;
|
|
262
|
+
const ME_PATH = /^\/v2\/me$/u;
|
|
263
|
+
const VAULT_APPEND_PATH = /^\/v2\/vault\/append$/u;
|
|
264
|
+
const VAULT_LEASE_PATH = /^\/v2\/vault\/lease$/u;
|
|
265
|
+
const VAULT_CURSOR_PATH = /^\/v2\/vault\/cursor$/u;
|
|
266
|
+
const VAULT_STREAM_PATH = /^\/v2\/vault\/stream\/([^/]+)$/u;
|
|
178
267
|
const ADMIN_DEVICE_PATH = /^\/admin\/devices\/([0-9a-f]+)$/u;
|
|
179
268
|
function withBatchFields(value) {
|
|
180
269
|
if (value === null || typeof value !== 'object') return value;
|
|
@@ -341,16 +430,28 @@ export function createHttpServer(options) {
|
|
|
341
430
|
server: {
|
|
342
431
|
version,
|
|
343
432
|
apiVersions: [
|
|
344
|
-
|
|
433
|
+
2
|
|
345
434
|
],
|
|
346
435
|
protocols: [
|
|
347
436
|
PROTOCOL_VERSION
|
|
348
437
|
],
|
|
349
438
|
schemaVersion: SERVER_SCHEMA_VERSION,
|
|
350
439
|
capabilities: [
|
|
440
|
+
'session-summary',
|
|
441
|
+
'changes-cursor',
|
|
442
|
+
'conditional-get',
|
|
443
|
+
'idempotent-append',
|
|
351
444
|
'manifest-digest',
|
|
352
|
-
'batches'
|
|
353
|
-
|
|
445
|
+
'batches',
|
|
446
|
+
'batch-upload',
|
|
447
|
+
'batch-download',
|
|
448
|
+
'zstd-frame'
|
|
449
|
+
],
|
|
450
|
+
limits: {
|
|
451
|
+
batchBytes: MAX_BATCH_BODY_BYTES,
|
|
452
|
+
batchBlobs: MAX_BATCH_BLOBS,
|
|
453
|
+
blobBytes: maxBlobBytes
|
|
454
|
+
}
|
|
354
455
|
},
|
|
355
456
|
now: Date.now()
|
|
356
457
|
});
|
|
@@ -358,19 +459,148 @@ export function createHttpServer(options) {
|
|
|
358
459
|
},
|
|
359
460
|
{
|
|
360
461
|
method: 'GET',
|
|
361
|
-
path:
|
|
362
|
-
handler: async (
|
|
363
|
-
|
|
462
|
+
path: V2_SESSIONS_PATH,
|
|
463
|
+
handler: async (req, res)=>{
|
|
464
|
+
requireDevice(db, req);
|
|
465
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
466
|
+
const after = decodeSessionsCursor(url.searchParams.get('cursor'));
|
|
467
|
+
const limit = intParam(url.searchParams.get('limit'), 200, 1, 1000);
|
|
468
|
+
const page = listV2Sessions(db, after, limit);
|
|
469
|
+
return json(res, 200, {
|
|
364
470
|
ok: true,
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
471
|
+
value: {
|
|
472
|
+
sessions: page.sessions,
|
|
473
|
+
nextCursor: page.nextSessionId === null ? null : encodeCursor({
|
|
474
|
+
v: 2,
|
|
475
|
+
after: page.nextSessionId
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
method: 'GET',
|
|
483
|
+
path: V2_CHANGES_PATH,
|
|
484
|
+
handler: async (req, res)=>{
|
|
485
|
+
requireDevice(db, req);
|
|
486
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
487
|
+
const cursor = decodeChangesCursor(url.searchParams.get('cursor'));
|
|
488
|
+
const limit = intParam(url.searchParams.get('limit'), 200, 1, 1000);
|
|
489
|
+
const page = listV2Changes(db, cursor.revision, cursor.tombstone, limit);
|
|
490
|
+
return json(res, 200, {
|
|
491
|
+
ok: true,
|
|
492
|
+
value: {
|
|
493
|
+
sessions: page.sessions,
|
|
494
|
+
tombstones: page.tombstones,
|
|
495
|
+
nextCursor: encodeCursor({
|
|
496
|
+
v: 2,
|
|
497
|
+
revision: page.nextRevision,
|
|
498
|
+
tombstone: page.nextTombstone
|
|
499
|
+
}),
|
|
500
|
+
hasMore: page.hasMore
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
method: 'GET',
|
|
507
|
+
path: V2_SUMMARY_PATH,
|
|
508
|
+
handler: async (req, res)=>{
|
|
509
|
+
requireDevice(db, req);
|
|
510
|
+
const id = sessionIdFrom(new URL(req.url ?? '/', 'http://localhost').pathname, V2_SUMMARY_PATH);
|
|
511
|
+
const summary = getV2SessionSummary(db, id);
|
|
512
|
+
if (summary === undefined) return json(res, 404, errorBody('not-found', `没有会话 ${id}`));
|
|
513
|
+
res.setHeader('etag', `"${summary.manifestDigest}"`);
|
|
514
|
+
if (etagMatches(req.headers['if-none-match'], summary.manifestDigest)) {
|
|
515
|
+
res.statusCode = 304;
|
|
516
|
+
res.end();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
return json(res, 200, {
|
|
520
|
+
ok: true,
|
|
521
|
+
value: summary
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
method: 'GET',
|
|
527
|
+
path: V2_BATCHES_PATH,
|
|
528
|
+
handler: async (req, res)=>{
|
|
529
|
+
requireDevice(db, req);
|
|
530
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
531
|
+
const id = sessionIdFrom(url.pathname, V2_BATCHES_PATH);
|
|
532
|
+
const from = intParam(url.searchParams.get('from'), 0, 0, Number.MAX_SAFE_INTEGER);
|
|
533
|
+
const limit = intParam(url.searchParams.get('limit'), 500, 1, 1000);
|
|
534
|
+
const expected = url.searchParams.get('expectedManifestDigest');
|
|
535
|
+
if (expected !== null && !/^[0-9a-f]{64}$/u.test(expected)) {
|
|
536
|
+
throw new SyncError('bad-request', 'expectedManifestDigest 必须是 64 位十六进制');
|
|
537
|
+
}
|
|
538
|
+
const page = listV2Batches(db, id, from, limit);
|
|
539
|
+
if (page === undefined) return json(res, 404, errorBody('not-found', `没有会话 ${id}`));
|
|
540
|
+
if (expected !== null && expected !== page.manifestDigest) {
|
|
541
|
+
throw new SyncError('conflict', 'manifest 已变化,请重新读取摘要');
|
|
542
|
+
}
|
|
543
|
+
return json(res, 200, {
|
|
544
|
+
ok: true,
|
|
545
|
+
value: page
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
method: 'POST',
|
|
551
|
+
path: V2_APPEND_PATH,
|
|
552
|
+
handler: async (req, res)=>{
|
|
553
|
+
const device = requireDevice(db, req);
|
|
554
|
+
const clientId = clientIdFrom(req);
|
|
555
|
+
const idempotencyKey = idempotencyKeyFrom(req);
|
|
556
|
+
const id = sessionIdFrom(new URL(req.url ?? '/', 'http://localhost').pathname, V2_APPEND_PATH);
|
|
557
|
+
const body = await readJson(req, MAX_APPEND_BODY_BYTES);
|
|
558
|
+
const parsed = parseAppend(body);
|
|
559
|
+
const value = appendFrames(db, options.dataDir, {
|
|
560
|
+
sessionId: id,
|
|
561
|
+
deviceId: device.deviceId,
|
|
562
|
+
clientId,
|
|
563
|
+
idempotencyKey,
|
|
564
|
+
...parsed
|
|
565
|
+
});
|
|
566
|
+
return json(res, 200, {
|
|
567
|
+
ok: true,
|
|
568
|
+
value
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
method: 'GET',
|
|
574
|
+
path: V2_MANIFEST_PATH,
|
|
575
|
+
handler: async (req, res)=>{
|
|
576
|
+
requireDevice(db, req);
|
|
577
|
+
const id = sessionIdFrom(new URL(req.url ?? '/', 'http://localhost').pathname, V2_MANIFEST_PATH);
|
|
578
|
+
const found = getManifest(db, id);
|
|
579
|
+
if (found === undefined) return json(res, 404, errorBody('not-found', `没有会话 ${id}`));
|
|
580
|
+
return json(res, 200, {
|
|
581
|
+
ok: true,
|
|
582
|
+
value: withBatchFields(found)
|
|
368
583
|
});
|
|
369
584
|
}
|
|
370
585
|
},
|
|
371
586
|
{
|
|
372
587
|
method: 'POST',
|
|
373
|
-
path:
|
|
588
|
+
path: V2_DELETE_PATH,
|
|
589
|
+
handler: async (req, res)=>{
|
|
590
|
+
const device = requireDevice(db, req);
|
|
591
|
+
const clientId = clientIdFrom(req);
|
|
592
|
+
const id = sessionIdFrom(new URL(req.url ?? '/', 'http://localhost').pathname, V2_DELETE_PATH);
|
|
593
|
+
const body = await readJson(req);
|
|
594
|
+
if (!Number.isSafeInteger(body.createdAt)) throw new SyncError('bad-request', 'createdAt 必须是整数');
|
|
595
|
+
return json(res, 200, {
|
|
596
|
+
ok: true,
|
|
597
|
+
value: deleteSession(db, id, device.deviceId, clientId, Number(body.createdAt))
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
method: 'POST',
|
|
603
|
+
path: DEVICES_PATH,
|
|
374
604
|
handler: async (req, res)=>{
|
|
375
605
|
const auth = authenticate(db, req);
|
|
376
606
|
if (auth?.role !== 'admin') return json(res, 401, errorBody('unauthorized', '需要 setup token'));
|
|
@@ -385,7 +615,7 @@ export function createHttpServer(options) {
|
|
|
385
615
|
},
|
|
386
616
|
{
|
|
387
617
|
method: 'GET',
|
|
388
|
-
path:
|
|
618
|
+
path: DEVICES_PATH,
|
|
389
619
|
handler: async (req, res)=>{
|
|
390
620
|
const auth = authenticate(db, req);
|
|
391
621
|
if (auth?.role !== 'admin') return json(res, 401, errorBody('unauthorized', '需要 setup token'));
|
|
@@ -397,11 +627,11 @@ export function createHttpServer(options) {
|
|
|
397
627
|
},
|
|
398
628
|
{
|
|
399
629
|
method: 'DELETE',
|
|
400
|
-
path:
|
|
630
|
+
path: DEVICE_PATH,
|
|
401
631
|
handler: async (req, res)=>{
|
|
402
632
|
const auth = authenticate(db, req);
|
|
403
633
|
if (auth?.role !== 'admin') return json(res, 401, errorBody('unauthorized', '需要 setup token'));
|
|
404
|
-
const match =
|
|
634
|
+
const match = DEVICE_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
|
|
405
635
|
if (match === null) return json(res, 404, errorBody('not-found', '没有这个设备'));
|
|
406
636
|
const revoked = revokeDevice(db, match[1]);
|
|
407
637
|
return revoked ? json(res, 200, {
|
|
@@ -415,7 +645,107 @@ export function createHttpServer(options) {
|
|
|
415
645
|
},
|
|
416
646
|
{
|
|
417
647
|
method: 'POST',
|
|
418
|
-
path:
|
|
648
|
+
path: BLOB_BATCH_PATH,
|
|
649
|
+
handler: async (req, res)=>{
|
|
650
|
+
requireDevice(db, req);
|
|
651
|
+
const body = await readRaw(req, MAX_BATCH_BODY_BYTES);
|
|
652
|
+
const entries = decodeBlobBatch(body);
|
|
653
|
+
const results = [];
|
|
654
|
+
const fresh = new Map();
|
|
655
|
+
for (const entry of entries){
|
|
656
|
+
const actual = createHash('sha256').update(entry.data).digest('hex');
|
|
657
|
+
if (actual !== entry.sha256) {
|
|
658
|
+
throw new SyncError('bad-request', `批量 blob 的哈希不匹配:${entry.sha256}`);
|
|
659
|
+
}
|
|
660
|
+
const existing = blobRecord(db, actual);
|
|
661
|
+
if (existing !== undefined || fresh.has(actual)) {
|
|
662
|
+
results.push({
|
|
663
|
+
sha256: actual,
|
|
664
|
+
bytes: entry.data.length,
|
|
665
|
+
dedup: true
|
|
666
|
+
});
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
fresh.set(actual, {
|
|
670
|
+
kind: entry.kind,
|
|
671
|
+
data: entry.data
|
|
672
|
+
});
|
|
673
|
+
results.push({
|
|
674
|
+
sha256: actual,
|
|
675
|
+
bytes: entry.data.length,
|
|
676
|
+
dedup: false
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
const newBytes = [
|
|
680
|
+
...fresh.values()
|
|
681
|
+
].reduce((sum, entry)=>sum + entry.data.length, 0);
|
|
682
|
+
const used = usedBlobBytes(db);
|
|
683
|
+
const limit = effectiveMaxBytes();
|
|
684
|
+
if (used + newBytes > limit) {
|
|
685
|
+
throw new SyncError('quota', `存储超额:已用 ${used} 字节,本批新增 ${newBytes} 字节,上限 ${limit} 字节`);
|
|
686
|
+
}
|
|
687
|
+
for (const [sha256, entry] of fresh){
|
|
688
|
+
await putBlob(options.dataDir, entry.data);
|
|
689
|
+
recordBlob(db, sha256, entry.data.length, entry.kind);
|
|
690
|
+
}
|
|
691
|
+
return json(res, 200, {
|
|
692
|
+
ok: true,
|
|
693
|
+
value: {
|
|
694
|
+
blobs: results
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
method: 'POST',
|
|
701
|
+
path: BLOB_BATCH_READ_PATH,
|
|
702
|
+
handler: async (req, res)=>{
|
|
703
|
+
requireDevice(db, req);
|
|
704
|
+
const body = await readJson(req);
|
|
705
|
+
const raw = body.sha256;
|
|
706
|
+
if (!Array.isArray(raw) || raw.length < 1 || raw.length > MAX_BATCH_BLOBS) {
|
|
707
|
+
throw new SyncError('bad-request', `sha256 必须是 1–${MAX_BATCH_BLOBS} 个数组项`);
|
|
708
|
+
}
|
|
709
|
+
const hashes = [];
|
|
710
|
+
for (const value of raw){
|
|
711
|
+
if (typeof value !== 'string' || !/^[0-9a-f]{64}$/u.test(value)) {
|
|
712
|
+
throw new SyncError('bad-request', 'sha256 必须是 64 位小写十六进制');
|
|
713
|
+
}
|
|
714
|
+
hashes.push(value);
|
|
715
|
+
}
|
|
716
|
+
const kinds = [];
|
|
717
|
+
for (const sha256 of hashes){
|
|
718
|
+
if (!await hasBlob(options.dataDir, sha256)) return json(res, 404, errorBody('not-found', `没有这个 blob:${sha256}`));
|
|
719
|
+
const record = blobRecord(db, sha256);
|
|
720
|
+
if (record === undefined) return json(res, 404, errorBody('not-found', `没有这个 blob:${sha256}`));
|
|
721
|
+
if (record.kind !== 'frame' && record.kind !== 'attachment' && record.kind !== 'vault') {
|
|
722
|
+
throw new SyncError('bad-request', `blob 类别非法:${record.kind}`);
|
|
723
|
+
}
|
|
724
|
+
kinds.push(record.kind);
|
|
725
|
+
}
|
|
726
|
+
const blobs = [];
|
|
727
|
+
for(let index = 0; index < hashes.length; index += 1){
|
|
728
|
+
blobs.push({
|
|
729
|
+
kind: kinds[index],
|
|
730
|
+
data: await readBlob(options.dataDir, hashes[index])
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
let encoded;
|
|
734
|
+
try {
|
|
735
|
+
encoded = encodeBlobBatch(blobs);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
throw new SyncError('bad-request', error instanceof Error ? error.message : String(error));
|
|
738
|
+
}
|
|
739
|
+
res.statusCode = 200;
|
|
740
|
+
res.setHeader('content-type', BLOB_BATCH_CONTENT_TYPE);
|
|
741
|
+
res.setHeader('content-length', encoded.length);
|
|
742
|
+
res.setHeader('cache-control', 'no-store');
|
|
743
|
+
res.end(encoded);
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
method: 'POST',
|
|
748
|
+
path: /^\/v2\/blobs$/,
|
|
419
749
|
handler: async (req, res)=>{
|
|
420
750
|
const auth = authenticate(db, req);
|
|
421
751
|
if (auth === undefined) return json(res, 401, errorBody('unauthorized', '需要设备或 setup token'));
|
|
@@ -455,7 +785,7 @@ export function createHttpServer(options) {
|
|
|
455
785
|
},
|
|
456
786
|
{
|
|
457
787
|
method: 'HEAD',
|
|
458
|
-
path:
|
|
788
|
+
path: BLOB_PATH,
|
|
459
789
|
handler: async (req, res)=>{
|
|
460
790
|
const auth = authenticate(db, req);
|
|
461
791
|
if (auth === undefined) {
|
|
@@ -463,18 +793,18 @@ export function createHttpServer(options) {
|
|
|
463
793
|
res.end();
|
|
464
794
|
return;
|
|
465
795
|
}
|
|
466
|
-
const match =
|
|
796
|
+
const match = BLOB_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
|
|
467
797
|
res.statusCode = match !== null && await hasBlob(options.dataDir, match[1]) ? 200 : 404;
|
|
468
798
|
res.end();
|
|
469
799
|
}
|
|
470
800
|
},
|
|
471
801
|
{
|
|
472
802
|
method: 'GET',
|
|
473
|
-
path:
|
|
803
|
+
path: BLOB_PATH,
|
|
474
804
|
handler: async (req, res)=>{
|
|
475
805
|
const auth = authenticate(db, req);
|
|
476
806
|
if (auth === undefined) return json(res, 401, errorBody('unauthorized', '需要设备或 setup token'));
|
|
477
|
-
const match =
|
|
807
|
+
const match = BLOB_PATH.exec(new URL(req.url ?? '/', 'http://localhost').pathname);
|
|
478
808
|
if (match === null) return json(res, 404, errorBody('not-found', '没有这个 blob'));
|
|
479
809
|
const sha256 = match[1];
|
|
480
810
|
if (!await hasBlob(options.dataDir, sha256)) return json(res, 404, errorBody('not-found', '没有这个 blob'));
|
|
@@ -511,85 +841,6 @@ export function createHttpServer(options) {
|
|
|
511
841
|
});
|
|
512
842
|
}
|
|
513
843
|
},
|
|
514
|
-
{
|
|
515
|
-
method: 'POST',
|
|
516
|
-
path: APPEND_PATH,
|
|
517
|
-
handler: async (req, res)=>{
|
|
518
|
-
const device = requireDevice(db, req);
|
|
519
|
-
const clientId = clientIdFrom(req);
|
|
520
|
-
const id = sessionIdFrom(new URL(req.url ?? '/', 'http://localhost').pathname, APPEND_PATH);
|
|
521
|
-
const body = await readJson(req, MAX_APPEND_BODY_BYTES);
|
|
522
|
-
const parsed = parseAppend(body);
|
|
523
|
-
const value = appendFrames(db, options.dataDir, {
|
|
524
|
-
sessionId: id,
|
|
525
|
-
deviceId: device.deviceId,
|
|
526
|
-
clientId,
|
|
527
|
-
...parsed
|
|
528
|
-
});
|
|
529
|
-
return json(res, 200, {
|
|
530
|
-
ok: true,
|
|
531
|
-
value: withBatchFields(value)
|
|
532
|
-
});
|
|
533
|
-
}
|
|
534
|
-
},
|
|
535
|
-
{
|
|
536
|
-
method: 'POST',
|
|
537
|
-
path: DELETE_PATH,
|
|
538
|
-
handler: async (req, res)=>{
|
|
539
|
-
const device = requireDevice(db, req);
|
|
540
|
-
const clientId = clientIdFrom(req);
|
|
541
|
-
const id = sessionIdFrom(new URL(req.url ?? '/', 'http://localhost').pathname, DELETE_PATH);
|
|
542
|
-
const body = await readJson(req);
|
|
543
|
-
if (!Number.isSafeInteger(body.createdAt)) throw new SyncError('bad-request', 'createdAt 必须是整数');
|
|
544
|
-
return json(res, 200, {
|
|
545
|
-
ok: true,
|
|
546
|
-
value: deleteSession(db, id, device.deviceId, clientId, Number(body.createdAt))
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
},
|
|
550
|
-
{
|
|
551
|
-
method: 'GET',
|
|
552
|
-
path: MANIFEST_PATH,
|
|
553
|
-
handler: async (req, res)=>{
|
|
554
|
-
requireDevice(db, req);
|
|
555
|
-
const id = sessionIdFrom(new URL(req.url ?? '/', 'http://localhost').pathname, MANIFEST_PATH);
|
|
556
|
-
const found = getManifest(db, id);
|
|
557
|
-
if (found === undefined) return json(res, 404, errorBody('not-found', `没有会话 ${id}`));
|
|
558
|
-
return json(res, 200, {
|
|
559
|
-
ok: true,
|
|
560
|
-
value: withBatchFields(found)
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
},
|
|
564
|
-
{
|
|
565
|
-
method: 'GET',
|
|
566
|
-
path: BATCHES_PATH,
|
|
567
|
-
handler: async (req, res)=>{
|
|
568
|
-
requireDevice(db, req);
|
|
569
|
-
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
570
|
-
const id = sessionIdFrom(url.pathname, BATCHES_PATH);
|
|
571
|
-
const found = listBatches(db, id, intParam(url.searchParams.get('from'), 0, 0, Number.MAX_SAFE_INTEGER), intParam(url.searchParams.get('limit'), 500, 1, 1000));
|
|
572
|
-
if (found === undefined) return json(res, 404, errorBody('not-found', `没有会话 ${id}`));
|
|
573
|
-
return json(res, 200, {
|
|
574
|
-
ok: true,
|
|
575
|
-
value: withBatchFields(found)
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
},
|
|
579
|
-
{
|
|
580
|
-
method: 'GET',
|
|
581
|
-
path: CURSOR_PATH,
|
|
582
|
-
handler: async (req, res)=>{
|
|
583
|
-
requireDevice(db, req);
|
|
584
|
-
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
585
|
-
const since = intParam(url.searchParams.get('since'), 0, 0, Number.MAX_SAFE_INTEGER);
|
|
586
|
-
const limit = intParam(url.searchParams.get('limit'), 200, 1, 1000);
|
|
587
|
-
return json(res, 200, {
|
|
588
|
-
ok: true,
|
|
589
|
-
value: listChanges(db, since, limit)
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
},
|
|
593
844
|
{
|
|
594
845
|
method: 'POST',
|
|
595
846
|
path: VAULT_LEASE_PATH,
|
|
@@ -937,7 +1188,7 @@ export function createHttpServer(options) {
|
|
|
937
1188
|
},
|
|
938
1189
|
{
|
|
939
1190
|
method: 'GET',
|
|
940
|
-
path:
|
|
1191
|
+
path: ME_PATH,
|
|
941
1192
|
handler: async (req, res)=>{
|
|
942
1193
|
const auth = authenticate(db, req);
|
|
943
1194
|
if (auth === undefined) return json(res, 401, errorBody('unauthorized', '未知 token'));
|
package/dist/index.js
CHANGED
|
File without changes
|