feat(api): bind encrypted workspaces to accounts and devices
This commit is contained in:
1 parent
019b15041a
commit
c7d8ef15bc
49 files changed
+1904
-278
No files matched your search
+19
-43
@@ -1,4 +1,4 @@
|
||||
import { Flags, MAX_FRAME_DATA, RAW_MAX_PAYLOAD, type ItemKind } from './constants.js';
|
||||
import { Flags, MAX_FRAME_DATA, type ItemKind } from './constants.js';
|
||||
import { concat, readU16, toBytes, tryDecodeUtf8, utf8, writeU16, toBase64Url, type Bytes } from './bytes.js';
|
||||
import { IntegrityError, LimitError } from './errors.js';
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface ItemMeta {
|
||||
ts: number;
|
||||
/** 设备名。 */
|
||||
dev?: string;
|
||||
/** WebDrop 频道中的 thread ID,加密元数据。 */
|
||||
threadId?: string;
|
||||
/** 文件所在的虚拟文件夹 ID,加密元数据。 */
|
||||
folderId?: string;
|
||||
}
|
||||
|
||||
const KINDS: readonly ItemKind[] = ['text', 'json', 'file', 'manifest', 'other'];
|
||||
@@ -26,6 +30,8 @@ export function encodeMeta(meta: ItemMeta): Bytes {
|
||||
if (meta.size !== undefined) o.size = meta.size;
|
||||
o.ts = meta.ts;
|
||||
if (meta.dev !== undefined) o.dev = meta.dev;
|
||||
if (meta.threadId !== undefined) o.thread_id = meta.threadId;
|
||||
if (meta.folderId !== undefined) o.folder_id = meta.folderId;
|
||||
|
||||
const json = utf8(JSON.stringify(o));
|
||||
if (json.length > 0xffff) throw new LimitError('条目元数据过大');
|
||||
@@ -64,6 +70,12 @@ export function decodeFirstFrame(data: Uint8Array): { meta: ItemMeta; content: B
|
||||
if (typeof v !== 'string') throw new IntegrityError('bad_meta');
|
||||
meta[key] = v;
|
||||
}
|
||||
for (const [wire, key] of [['thread_id', 'threadId'], ['folder_id', 'folderId']] as const) {
|
||||
const v = o[wire];
|
||||
if (v === undefined) continue;
|
||||
if (typeof v !== 'string' || v.length > 80) throw new IntegrityError('bad_meta');
|
||||
meta[key] = v;
|
||||
}
|
||||
if (o.size !== undefined) {
|
||||
if (typeof o.size !== 'number' || !Number.isSafeInteger(o.size) || o.size < 0) throw new IntegrityError('bad_meta');
|
||||
meta.size = o.size;
|
||||
@@ -76,6 +88,7 @@ export function decodeFirstFrame(data: Uint8Array): { meta: ItemMeta; content: B
|
||||
export type ContentSource = Uint8Array | Blob | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>;
|
||||
|
||||
export interface PlanOptions {
|
||||
control?: boolean;
|
||||
/** 指定条目 id(重试、续传时必须复用同一个);默认随机生成。 */
|
||||
itemId?: Uint8Array;
|
||||
/** 作为频道的「最新快照」。快照必须是单帧,所以内容不能超过一帧。 */
|
||||
@@ -109,7 +122,7 @@ export interface ItemPlan {
|
||||
* 要等第 0 帧产出之后才确定,所以请在拿到第 0 帧之后再读取它和 frame0Headers。
|
||||
*/
|
||||
readonly gated: boolean;
|
||||
/** 第 0 帧要带的附加请求头(X-WD-Max-Downloads / X-WD-Label)。 */
|
||||
/** 第 0 帧要带的附加请求头(X-WD-Max-Downloads)。 */
|
||||
frame0Headers: Record<string, string>;
|
||||
/** 按序产出加密后的帧。同一个计划只能迭代一次。 */
|
||||
frames: AsyncGenerator<EncodedFrame, void, void>;
|
||||
@@ -256,11 +269,12 @@ function resolveGating(opts: PlanOptions, fitsInOneFrame: boolean | undefined):
|
||||
* enc 模式:把内容加密成一个条目(protocol §7)。
|
||||
* 小内容单帧、非 GATED;放不下就拆成多帧并标记 GATED。第 0 帧携带元数据。
|
||||
*/
|
||||
export function planEncItem(
|
||||
export function planItem(
|
||||
seal: (p: { flags: number; itemId: Uint8Array; index: number; data: Uint8Array }) => Promise<Bytes>,
|
||||
source: ContentSource,
|
||||
meta: ItemMeta,
|
||||
opts: PlanOptions = {},
|
||||
encrypted = true,
|
||||
): ItemPlan {
|
||||
const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId();
|
||||
const maxData = opts.maxFrameData ?? MAX_FRAME_DATA;
|
||||
@@ -281,9 +295,10 @@ export function planEncItem(
|
||||
if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads);
|
||||
|
||||
const snapshot = opts.snapshot === true;
|
||||
if (opts.control && (snapshot || gated)) throw new RangeError('控制条目必须是非快照的单帧');
|
||||
|
||||
async function* frames(): AsyncGenerator<EncodedFrame, void, void> {
|
||||
const base = (gatedNow: boolean) => (gatedNow ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | Flags.Enc;
|
||||
const base = (gatedNow: boolean) => (gatedNow ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | (opts.control ? Flags.Control : 0) | (encrypted ? Flags.Enc : 0);
|
||||
const emit = async (index: number, last: boolean, data: Uint8Array, gatedNow: boolean): Promise<EncodedFrame> => ({
|
||||
index,
|
||||
final: last,
|
||||
@@ -326,42 +341,3 @@ export function planEncItem(
|
||||
return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() };
|
||||
}
|
||||
|
||||
/**
|
||||
* raw 模式:负载原样,没有加密、没有元数据。单帧放不下时拆成 GATED 多帧。
|
||||
* 注意 raw 内容对服务端完全可见。
|
||||
*/
|
||||
export function planRawItem(
|
||||
build: (p: { flags: number; itemId: Uint8Array; index: number; payload: Uint8Array }) => Bytes,
|
||||
source: ContentSource,
|
||||
opts: PlanOptions & { label?: string } = {},
|
||||
): ItemPlan {
|
||||
const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId();
|
||||
const cap = Math.min(opts.maxFrameData ?? RAW_MAX_PAYLOAD, RAW_MAX_PAYLOAD);
|
||||
const snapshot = opts.snapshot === true;
|
||||
|
||||
const size = source instanceof Uint8Array ? source.length : typeof Blob !== 'undefined' && source instanceof Blob ? source.size : undefined;
|
||||
let gated = resolveGating(opts, size === undefined ? undefined : size <= cap);
|
||||
|
||||
const frame0Headers: Record<string, string> = {};
|
||||
if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads);
|
||||
if (opts.label !== undefined) frame0Headers['X-WD-Label'] = opts.label;
|
||||
|
||||
async function* frames(): AsyncGenerator<EncodedFrame, void, void> {
|
||||
let index = 0;
|
||||
for await (const chunk of rechunk(source, cap, cap)) {
|
||||
if (index === 0) {
|
||||
if (chunk.last && opts.gated !== true && opts.maxDownloads === undefined) gated = false;
|
||||
else if (!chunk.last) {
|
||||
if (snapshot) throw new LimitError('快照必须是单帧,内容超过了一帧');
|
||||
gated = true;
|
||||
}
|
||||
}
|
||||
const flags = (gated ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | (chunk.last ? Flags.Final : 0);
|
||||
yield { index, final: chunk.last, bytes: build({ flags, itemId: idBytes, index, payload: chunk.data }) };
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user