@filefreak.io/sdk 0.4.0 → 0.6.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/dist/links.js CHANGED
@@ -17,6 +17,7 @@
17
17
  // API call is counted, since no key is used.
18
18
  import { fetchRanges } from "./ranges.js";
19
19
  import { asItIsKept, readProtected } from "./reader.js";
20
+ import { READS_FORMATS, SHARD_VERSION } from "./shards.js";
20
21
  import { backoff, FileFreakError, sleep } from "./transport.js";
21
22
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
23
  /** As the API's route takes a token (api/src/routes/public.ts). */
@@ -106,8 +107,30 @@ class Reader {
106
107
  this.fail();
107
108
  if (!isCount(v.sizeBytes) || !isCount(v.fragmentBytes) || !isCount(v.count) || !Array.isArray(v.fragments))
108
109
  this.fail();
110
+ // A site from before version 2 of the format says no version, and every file it has is in version 1.
111
+ const format = v.format === undefined ? SHARD_VERSION : v.format;
112
+ if (!isCount(format))
113
+ this.fail();
114
+ const outline = v.outline === undefined || v.outline === null ? null : this.outline(v.outline);
109
115
  const fragments = v.fragments.map((f) => (isObject(f) && isCount(f.place) && isWebAddress(f.url) ? { place: f.place, url: f.url } : this.fail()));
110
- return { contentId: v.contentId, sizeBytes: v.sizeBytes, fragmentBytes: v.fragmentBytes, count: v.count, key: v.key, fragments, expiresAt: v.expiresAt, grant: v.grant };
116
+ return {
117
+ contentId: v.contentId,
118
+ format,
119
+ sizeBytes: v.sizeBytes,
120
+ fragmentBytes: v.fragmentBytes,
121
+ count: v.count,
122
+ key: v.key,
123
+ outline,
124
+ fragments,
125
+ expiresAt: v.expiresAt,
126
+ grant: v.grant,
127
+ };
128
+ }
129
+ outline(v) {
130
+ if (!isObject(v) || typeof v.mac !== "string" || !Array.isArray(v.blocks))
131
+ this.fail();
132
+ const blocks = v.blocks.map((b) => (isObject(b) && typeof b.id === "string" && isCount(b.length) ? { id: b.id, length: b.length } : this.fail()));
133
+ return { mac: v.mac, blocks };
111
134
  }
112
135
  }
113
136
  /** The link's own site, asked with nothing that says who is asking. */
@@ -216,7 +239,7 @@ async function openShare(site, token, password) {
216
239
  downloadsLeft: view.downloadsLeft,
217
240
  list: async (folder, offset) => site.read.listing(await site.ask(`${base}/items?parent=${folder}&offset=${offset}&limit=${PAGE}`, { share })),
218
241
  address: async (id) => site.read.address(await site.ask(`${base}/files/${id}/url`, { share })),
219
- page: async (id, from, read, signal) => site.read.map(await site.ask(`${base}/files/${id}/shards?from=${from}`, { share, read }, undefined, signal)),
242
+ page: async (id, from, read, signal) => site.read.map(await site.ask(`${base}/files/${id}/shards?from=${from}&formats=${READS_FORMATS}`, { share, read }, undefined, signal)),
220
243
  };
221
244
  }
222
245
  async function openPublic(site, id, username) {
@@ -232,7 +255,7 @@ async function openPublic(site, id, username) {
232
255
  downloadsLeft: null,
233
256
  list: async (folder, offset) => site.read.listing(await site.ask(`/api/public/files/${folder}/items?offset=${offset}&limit=${PAGE}`)),
234
257
  address: async (file) => site.read.address(await site.ask(`/api/public/files/${file}/url`)),
235
- page: async (file, from, read, signal) => site.read.map(await site.ask(`/api/public/files/${file}/shards?from=${from}`, { read }, undefined, signal)),
258
+ page: async (file, from, read, signal) => site.read.map(await site.ask(`/api/public/files/${file}/shards?from=${from}&formats=${READS_FORMATS}`, { read }, undefined, signal)),
236
259
  };
237
260
  }
238
261
  /** Every file in a shared folder, with the folders it is in, read folder by folder. */
package/dist/reader.d.ts CHANGED
@@ -10,11 +10,22 @@ export declare const NOT_FETCHED = "Part of this file could not be fetched from
10
10
  /** A page of a protected file's map, as the reader uses it: files.shards's answer, or a link's. */
11
11
  export interface MapPage {
12
12
  contentId: string;
13
+ /** The version of the shard format the file is in, 1 or 2. */
14
+ format: number;
13
15
  sizeBytes: number;
16
+ /** In version 2, the largest block. */
14
17
  fragmentBytes: number;
15
18
  count: number;
16
19
  /** The file's key, in base64. */
17
20
  key: string;
21
+ /** In version 2, on the first page only: the outline's MAC, and each block's id and length, in base64. */
22
+ outline: {
23
+ mac: string;
24
+ blocks: ReadonlyArray<{
25
+ id: string;
26
+ length: number;
27
+ }>;
28
+ } | null;
18
29
  fragments: ReadonlyArray<{
19
30
  place: number;
20
31
  url: string;
package/dist/reader.js CHANGED
@@ -28,7 +28,13 @@
28
28
  //
29
29
  // The key is a WebCrypto key that cannot be exported, held inside one read
30
30
  // and let go of with it. No error here carries it.
31
- import { openFragment, ShardError, shardKey, ShardLayout, SHARD_OVERHEAD } from "./shards.js";
31
+ //
32
+ // A file in version 2 of the format is read the same way, a block at each
33
+ // place, once its outline is checked: the map's first page carries it, and
34
+ // nothing is fetched until its MAC holds under the file's key, since a map
35
+ // whose blocks were reordered or swapped would otherwise be written out in
36
+ // the wrong order with every block passing its own check.
37
+ import { BlockLayout, checkOutline, fileKeys, openBlock, openFragment, ShardError, shardKey, ShardLayout, SHARD_OVERHEAD, SHARD_VERSION, SHARD_VERSION_2, } from "./shards.js";
32
38
  import { backoff, FileFreakError, sleep } from "./transport.js";
33
39
  const MIB = 1024 * 1024;
34
40
  /** The most bytes of fragments being fetched, or fetched and not yet written, at once in this program: five of the largest. */
@@ -131,11 +137,17 @@ function unreadable(what) {
131
137
  const damaged = (cause) => stopped("fragment_damaged", NOT_WHAT_WAS_STORED, cause);
132
138
  const notFetched = (cause) => stopped("fragment_unavailable", NOT_FETCHED, cause);
133
139
  /**
134
- * A page whose count, or one of whose places, the file's size and fragment
135
- * size do not allow: a map that does not add up is not read from.
140
+ * A page whose count, or one of whose places, the file's layout does not
141
+ * allow: a map that does not add up is not read from. In version 2 its
142
+ * count and fragment size are the outline's, its block count and largest.
136
143
  */
137
144
  function agrees(page, layout) {
138
- if (page.count !== layout.count) {
145
+ if (layout instanceof BlockLayout) {
146
+ if (page.count !== layout.count || page.fragmentBytes !== layout.fragmentSize) {
147
+ throw stopped("layout", `This file's map does not add up: it says ${page.count} blocks of at most ${page.fragmentBytes} bytes, and its outline names ${layout.count} of at most ${layout.fragmentSize}. Nothing was saved.`);
148
+ }
149
+ }
150
+ else if (page.count !== layout.count) {
139
151
  throw stopped("layout", `This file's map does not add up: it says ${page.count} fragments, where ${layout.size} bytes in fragments of ${layout.fragmentSize} make ${layout.count}. Nothing was saved.`);
140
152
  }
141
153
  const wrong = page.fragments.find((f) => !Number.isInteger(f.place) || f.place < 0 || f.place >= layout.count);
@@ -143,17 +155,50 @@ function agrees(page, layout) {
143
155
  throw stopped("layout", `This file's map names fragment ${wrong.place}, and the file has ${layout.count}, numbered from 0. Nothing was saved.`);
144
156
  }
145
157
  }
146
- async function keyOf(text) {
147
- let raw;
158
+ /** Base64 from the map, as bytes, or the map is unreadable. */
159
+ function bytesOf(text, what) {
148
160
  try {
149
- raw = Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
161
+ return Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
150
162
  }
151
163
  catch {
152
- throw unreadable("key");
164
+ throw unreadable(what);
153
165
  }
166
+ }
167
+ /**
168
+ * The file's pieces as its first page describes them. In version 2 the
169
+ * outline is checked under the key here, before a block is fetched, and one
170
+ * that fails is the same failure as a fragment that fails its check.
171
+ */
172
+ async function piecesOf(first) {
173
+ const raw = bytesOf(first.key, "key");
154
174
  if (raw.length !== 32)
155
175
  throw unreadable("key");
156
- return await shardKey(raw);
176
+ try {
177
+ if (first.format === SHARD_VERSION) {
178
+ const layout = new ShardLayout(first.sizeBytes, first.fragmentBytes);
179
+ const key = await shardKey(raw);
180
+ return { layout, open: (place, blob) => openFragment(key, { contentId: first.contentId, layout, place }, blob) };
181
+ }
182
+ if (first.format === SHARD_VERSION_2) {
183
+ if (!first.outline)
184
+ throw unreadable("outline");
185
+ const outline = {
186
+ contentId: first.contentId,
187
+ size: first.sizeBytes,
188
+ blocks: first.outline.blocks.map((b) => ({ id: bytesOf(b.id, "outline"), length: b.length })),
189
+ };
190
+ const layout = new BlockLayout(outline);
191
+ const keys = await fileKeys(raw);
192
+ await checkOutline(keys, outline, bytesOf(first.outline.mac, "outline"));
193
+ return { layout, open: (place, blob) => openBlock(keys, outline.blocks[place], blob) };
194
+ }
195
+ }
196
+ catch (e) {
197
+ if (e instanceof ShardError)
198
+ throw e.code === "layout" ? stopped("layout", `${e.message} Nothing was saved.`) : damaged(e);
199
+ throw e;
200
+ }
201
+ throw stopped("format_unsupported", `This file is in version ${first.format} of the shard format, which this version of the SDK does not read. Update it to read the file.`);
157
202
  }
158
203
  /**
159
204
  * Read the protected file whose map `pages` gives into `sink`. Resolves once
@@ -172,19 +217,11 @@ export async function readProtected(options) {
172
217
  const first = await options.pages(0, stop.signal);
173
218
  if (options.sizeBytes !== undefined && first.sizeBytes !== options.sizeBytes)
174
219
  throw stopped("content_changed", CHANGED);
175
- let layout;
176
- try {
177
- layout = new ShardLayout(first.sizeBytes, first.fragmentBytes);
178
- }
179
- catch (e) {
180
- if (e instanceof ShardError)
181
- throw stopped("layout", `${e.message} Nothing was saved.`);
182
- throw e;
183
- }
184
- agrees(first, layout);
185
220
  if (!UUID.test(first.contentId))
186
221
  throw unreadable("content id");
187
- const reading = new Reading(options, stop, layout, first.contentId, await keyOf(first.key));
222
+ const pieces = await piecesOf(first);
223
+ agrees(first, pieces.layout);
224
+ const reading = new Reading(options, stop, pieces, first.contentId, first.format);
188
225
  reading.hold(first);
189
226
  await reading.fill();
190
227
  }
@@ -216,9 +253,9 @@ export async function asItIsKept(isProtected, plain, mapped) {
216
253
  class Reading {
217
254
  options;
218
255
  stop;
219
- layout;
256
+ pieces;
220
257
  contentId;
221
- key;
258
+ format;
222
259
  addresses = new Map();
223
260
  /** Held while a page is asked for, so that fragments waiting on the same page ask once. */
224
261
  asking = new Gate(1);
@@ -230,13 +267,15 @@ class Reading {
230
267
  wait;
231
268
  now;
232
269
  quietMs;
270
+ layout;
233
271
  written = 0;
234
- constructor(options, stop, layout, contentId, key) {
272
+ constructor(options, stop, pieces, contentId, format) {
235
273
  this.options = options;
236
274
  this.stop = stop;
237
- this.layout = layout;
275
+ this.pieces = pieces;
238
276
  this.contentId = contentId;
239
- this.key = key;
277
+ this.format = format;
278
+ this.layout = pieces.layout;
240
279
  this.budget = options.budget ?? shared();
241
280
  this.fetcher = options.fetch ?? fetch.bind(globalThis);
242
281
  this.wait = options.sleep ?? sleep;
@@ -248,7 +287,10 @@ class Reading {
248
287
  }
249
288
  /** Keep a page's addresses, once it is known to be a page of this file's map. */
250
289
  hold(page) {
251
- if (page.contentId !== this.contentId || page.sizeBytes !== this.layout.size || page.fragmentBytes !== this.layout.fragmentSize) {
290
+ if (page.contentId !== this.contentId ||
291
+ page.format !== this.format ||
292
+ page.sizeBytes !== this.layout.size ||
293
+ page.fragmentBytes !== this.layout.fragmentSize) {
252
294
  throw stopped("content_changed", CHANGED);
253
295
  }
254
296
  agrees(page, this.layout);
@@ -424,7 +466,7 @@ class Reading {
424
466
  }
425
467
  let plain;
426
468
  try {
427
- plain = await openFragment(this.key, { contentId: this.contentId, layout: this.layout, place }, blob);
469
+ plain = await this.pieces.open(place, blob);
428
470
  }
429
471
  catch (e) {
430
472
  throw damaged(e);
package/dist/shards.d.ts CHANGED
@@ -25,6 +25,8 @@ export declare class ShardLayout {
25
25
  };
26
26
  /** How many bytes of the file fragment `place` holds. */
27
27
  length(place: number): number;
28
+ /** The place of the fragment that holds byte `offset` of the file. */
29
+ placeAt(offset: number): number;
28
30
  }
29
31
  /**
30
32
  * A content's key as WebCrypto holds it. Named through crypto.subtle and not
@@ -59,6 +61,8 @@ export declare function sealWithNonce(key: ShardKey, at: FragmentAt, plaintext:
59
61
  */
60
62
  export declare function openFragment(key: ShardKey, at: FragmentAt, blob: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
61
63
  export declare const SHARD_VERSION_2 = 2;
64
+ /** The versions of the format this reads, as a reader asks for a map in them (formats). */
65
+ export declare const READS_FORMATS = "1,2";
62
66
  /** The most bytes of a file one block holds, as the API takes blocks. */
63
67
  export declare const MOST_BLOCK_BYTES: number;
64
68
  /** A block id: the HMAC-SHA256 of its bytes under the file's block key. */
@@ -108,6 +112,33 @@ export declare function sealBlockWithNonce(keys: FileKeys, id: Uint8Array, plain
108
112
  * returned.
109
113
  */
110
114
  export declare function openBlock(keys: FileKeys, block: OutlineBlock, blob: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
115
+ /**
116
+ * Where each block of a file in version 2 is, from its outline: what
117
+ * ShardLayout is for version 1, to a reader that writes each block at its
118
+ * place in the file, or reads a range from the blocks that hold it. It
119
+ * refuses an outline that describes no file, before anything is fetched by
120
+ * it; that the outline is the file's is checkOutline's to say.
121
+ */
122
+ export declare class BlockLayout {
123
+ readonly size: number;
124
+ readonly count: number;
125
+ /** The largest block, which is what a map of a file in version 2 gives as its fragment size. */
126
+ readonly fragmentSize: number;
127
+ /** Where each block begins, then the file's size: count + 1 offsets, rising. */
128
+ private readonly starts;
129
+ constructor(outline: Outline);
130
+ /** Where in the file block `place` begins, and where it ends (exclusive). */
131
+ range(place: number): {
132
+ start: number;
133
+ end: number;
134
+ };
135
+ /** How many bytes of the file block `place` holds. */
136
+ length(place: number): number;
137
+ /** The place of the block that holds byte `offset` of the file: the last that begins at or before it. */
138
+ placeAt(offset: number): number;
139
+ }
140
+ /** Where each piece of a file is, in either version: the reader's view of it, whichever the map says. */
141
+ export type Layout = ShardLayout | BlockLayout;
111
142
  /** The outline's MAC, which the writer sends with the upload's completion and every reader checks before it releases a byte. */
112
143
  export declare function outlineMac(keys: FileKeys, outline: Outline): Promise<Uint8Array<ArrayBuffer>>;
113
144
  /**
package/dist/shards.js CHANGED
@@ -60,6 +60,13 @@ export class ShardLayout {
60
60
  const { start, end } = this.range(place);
61
61
  return end - start;
62
62
  }
63
+ /** The place of the fragment that holds byte `offset` of the file. */
64
+ placeAt(offset) {
65
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset >= this.size) {
66
+ throw new RangeError(`There is no byte ${offset}: this file has ${this.size}, numbered from 0.`);
67
+ }
68
+ return Math.floor(offset / this.fragmentSize);
69
+ }
63
70
  }
64
71
  /** A content's key, 32 bytes, made usable for its fragments and nothing else. */
65
72
  export function shardKey(raw) {
@@ -136,6 +143,8 @@ export async function openFragment(key, at, blob) {
136
143
  // Version 2: blocks cut by content, under a file's key that versions share
137
144
  // ---------------------------------------------------------------------------
138
145
  export const SHARD_VERSION_2 = 2;
146
+ /** The versions of the format this reads, as a reader asks for a map in them (formats). */
147
+ export const READS_FORMATS = `${SHARD_VERSION},${SHARD_VERSION_2}`;
139
148
  /** The most bytes of a file one block holds, as the API takes blocks. */
140
149
  export const MOST_BLOCK_BYTES = 64 * 1024 * 1024;
141
150
  /** A block id: the HMAC-SHA256 of its bytes under the file's block key. */
@@ -249,6 +258,61 @@ function checkShape(outline) {
249
258
  if (total !== size)
250
259
  throw new ShardError("layout", `The blocks hold ${total} bytes, and the file is ${size}.`);
251
260
  }
261
+ /**
262
+ * Where each block of a file in version 2 is, from its outline: what
263
+ * ShardLayout is for version 1, to a reader that writes each block at its
264
+ * place in the file, or reads a range from the blocks that hold it. It
265
+ * refuses an outline that describes no file, before anything is fetched by
266
+ * it; that the outline is the file's is checkOutline's to say.
267
+ */
268
+ export class BlockLayout {
269
+ size;
270
+ count;
271
+ /** The largest block, which is what a map of a file in version 2 gives as its fragment size. */
272
+ fragmentSize;
273
+ /** Where each block begins, then the file's size: count + 1 offsets, rising. */
274
+ starts;
275
+ constructor(outline) {
276
+ checkShape(outline);
277
+ this.size = outline.size;
278
+ this.count = outline.blocks.length;
279
+ this.starts = [0];
280
+ let largest = 0;
281
+ for (const b of outline.blocks) {
282
+ this.starts.push(this.starts[this.starts.length - 1] + b.length);
283
+ largest = Math.max(largest, b.length);
284
+ }
285
+ this.fragmentSize = largest;
286
+ }
287
+ /** Where in the file block `place` begins, and where it ends (exclusive). */
288
+ range(place) {
289
+ if (!Number.isInteger(place) || place < 0 || place >= this.count) {
290
+ throw new RangeError(`There is no block ${place}: this file has ${this.count}, numbered from 0.`);
291
+ }
292
+ return { start: this.starts[place], end: this.starts[place + 1] };
293
+ }
294
+ /** How many bytes of the file block `place` holds. */
295
+ length(place) {
296
+ const { start, end } = this.range(place);
297
+ return end - start;
298
+ }
299
+ /** The place of the block that holds byte `offset` of the file: the last that begins at or before it. */
300
+ placeAt(offset) {
301
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset >= this.size) {
302
+ throw new RangeError(`There is no byte ${offset}: this file has ${this.size}, numbered from 0.`);
303
+ }
304
+ let low = 0;
305
+ let high = this.count - 1;
306
+ while (low < high) {
307
+ const middle = Math.ceil((low + high) / 2);
308
+ if (this.starts[middle] <= offset)
309
+ low = middle;
310
+ else
311
+ high = middle - 1;
312
+ }
313
+ return low;
314
+ }
315
+ }
252
316
  /** The bytes the outline's MAC is over: the label, the content id, the size, the count, and each block's id and length in order. */
253
317
  function outlineBytes(outline) {
254
318
  const head = OUTLINE_LABEL.length + 16 + 8 + 4;
package/dist/uploads.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { FilesFrameAnswer, FilesGetAnswer, Item, Upload, UploadsBeginAnswer, UploadsBeginBody, UploadsCompleteAnswer, UploadsCompleteBody, UploadsFragmentsAnswer, UploadsFragmentsBody, UploadsGetAnswer, UploadsPartsAnswer, UploadsPartsQuery } from "./generated.js";
1
+ import { type Rule } from "./cut.js";
2
+ import type { FilesFrameAnswer, FilesGetAnswer, Item, Upload, UploadsBeginAnswer, UploadsBeginBody, UploadsBlocksAnswer, UploadsBlocksBody, UploadsCancelAnswer, UploadsCompleteAnswer, UploadsCompleteBody, UploadsFragmentsAnswer, UploadsFragmentsBody, UploadsGetAnswer, UploadsPartsAnswer, UploadsPartsQuery } from "./generated.js";
2
3
  import { type CallOptions } from "./transport.js";
3
4
  /** What can be uploaded. A File's name and type are used when none are given. */
4
5
  export type Source = Blob | ArrayBuffer | Uint8Array;
@@ -18,7 +19,7 @@ export interface UploadOptions {
18
19
  contentType?: string;
19
20
  /** Smaller copies to make of an image as soon as it is stored. */
20
21
  imageSizes?: number[];
21
- /** Bytes in each part, from 8 MiB to 64 MiB. Left out, 32 MiB, or less for a small file. */
22
+ /** Bytes in each part, from 8 MiB to 64 MiB, for a file not cut by its content: one under 16 MiB, or one of more blocks than the API takes. Left out, 32 MiB, or less for a small file. */
22
23
  partBytes?: number;
23
24
  /** Parts sent at once. */
24
25
  concurrency?: number;
@@ -52,11 +53,13 @@ export interface UploadCalls {
52
53
  begin(body: UploadsBeginBody, options?: CallOptions): Promise<UploadsBeginAnswer>;
53
54
  get(id: string, options?: CallOptions): Promise<UploadsGetAnswer>;
54
55
  parts(id: string, query?: UploadsPartsQuery, options?: CallOptions): Promise<UploadsPartsAnswer>;
56
+ blocks(id: string, body: UploadsBlocksBody, options?: CallOptions): Promise<UploadsBlocksAnswer>;
55
57
  fragments(id: string, body: UploadsFragmentsBody, options?: CallOptions): Promise<UploadsFragmentsAnswer>;
56
58
  complete(id: string, body?: UploadsCompleteBody, options?: CallOptions): Promise<UploadsCompleteAnswer>;
59
+ cancel(id: string, options?: CallOptions): Promise<UploadsCancelAnswer>;
57
60
  file(id: string, options?: CallOptions): Promise<FilesGetAnswer>;
58
61
  frame(id: string, options?: CallOptions): Promise<FilesFrameAnswer>;
59
62
  }
60
63
  /** The part size to ask for: 32 MiB, halved while that would leave fewer parts than are sent at once. */
61
64
  export declare function partSizeFor(fileBytes: number, atOnce: number): number;
62
- export declare function upload(calls: UploadCalls, fetcher: typeof fetch, source: Source, options?: UploadOptions): Promise<Item>;
65
+ export declare function upload(calls: UploadCalls, fetcher: typeof fetch, source: Source, options?: UploadOptions, rule?: Rule): Promise<Item>;