@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/uploads.js CHANGED
@@ -16,7 +16,17 @@
16
16
  // answers 202 until it has, and is asked again when it says.
17
17
  //
18
18
  // A part is read into memory whole to be hashed and sent, so the memory an
19
- // upload needs is about partBytes times the parts sent at once.
19
+ // upload needs is about the size of a part times the parts sent at once.
20
+ //
21
+ // CUT BY CONTENT, as the CLI cuts a file (cut.ts). A file of 16 MiB or more
22
+ // is read through once before it is begun, cut where its content says, and
23
+ // begun with its blocks, each its size and SHA-256. Each part is then a
24
+ // block, and a block the file it replaces has already, or one an earlier
25
+ // part repeats, is not sent: the API lists those in reused. So a new version
26
+ // of a large file sends only the blocks that changed. Each block is hashed
27
+ // again as it is sent and refused if it is not the block that was declared,
28
+ // since the file has changed. A block is 16 MiB at most, or 64 MiB in a file
29
+ // past 32 GiB.
20
30
  //
21
31
  // SEALED AS IT IS SENT (plan 04, F6 step 4, answer 46). Every begin says the
22
32
  // SDK can seal (protectable), as the website and the CLI do. Into a place
@@ -30,9 +40,21 @@
30
40
  // Carrying on, every fragment not recorded is confirmed before it is sent
31
41
  // again, since storage may hold it already. A fragment is up to 64 MiB and
32
42
  // sealed in memory, so the memory a sealed upload needs is the same.
43
+ //
44
+ // Every begin also says the SDK writes version 2 of the shard format, in
45
+ // which a protected file of more than 16 MiB is answered. Its fragments are
46
+ // blocks: the file is cut again with a table made from its own key, each
47
+ // block named by its id under the key (cutSealed), and the blocks declared
48
+ // (uploads.blocks). Those the file's last version has under the same key are
49
+ // kept and never sent, so a new version of a large protected file sends only
50
+ // what changed, sealed here. Each block is checked against the id declared
51
+ // for its place as it is sealed, and completing sends the outline's MAC. A
52
+ // file not cut into two to ten thousand blocks, which version 2 does not
53
+ // take, is begun again in version 1.
54
+ import { cutPlain, cutSealed, hex, RULE } from "./cut.js";
33
55
  import { canDrawFrames, drawFrame, sendFrame, takesFrame } from "./frames.js";
34
56
  import { base64, Md5 } from "./md5.js";
35
- import { sealFragment, ShardError, shardKey, ShardLayout } from "./shards.js";
57
+ import { BlockLayout, fileKeys, outlineMac, sealBlock, sealFragment, ShardError, shardKey, ShardLayout } from "./shards.js";
36
58
  import { FileFreakError, sleep } from "./transport.js";
37
59
  const MIB = 1024 * 1024;
38
60
  /** The API's bounds on a part. */
@@ -54,6 +76,8 @@ const CONFIRM_MOST = 100;
54
76
  const CONFIRM_PATIENCE_MS = 5 * 60_000;
55
77
  /** How many times fragments storage did not keep are sealed again and sent before the upload stops. */
56
78
  const SEALED_ROUNDS = 4;
79
+ /** The versions of the shard format this writes. */
80
+ const WRITES_FORMATS = [1, 2];
57
81
  /** An upload that stopped part way. What arrived stays in storage until the upload expires: carry on with `resume: uploadId`, or cancel it. */
58
82
  export class UploadStopped extends Error {
59
83
  uploadId;
@@ -64,33 +88,106 @@ export class UploadStopped extends Error {
64
88
  this.name = "UploadStopped";
65
89
  }
66
90
  }
67
- /**
68
- * The key and the cut of a sealed upload, from what uploads.get says, with
69
- * every fragment it has not recorded waiting to be confirmed: one sent
70
- * before, by this process or another, may be in storage already.
71
- */
72
- async function sealingOf(state, size) {
91
+ /** A part that is not what was declared for it: the file has changed since it was cut. */
92
+ function changed(part) {
93
+ return new FileFreakError(0, "source_changed", `Part ${part} of the file is not what it was when the upload began: the file has changed since.`);
94
+ }
95
+ /** A sealed upload's key, from what uploads.get says, and what it says of the file. */
96
+ function keyOf(state) {
73
97
  const shards = state.shards;
74
98
  if (!shards)
75
99
  throw new FileFreakError(0, "unreadable", "The API began a sealed upload and then gave no key for it.");
76
- let layout;
77
100
  let raw;
78
101
  try {
79
- layout = new ShardLayout(size, shards.fragmentBytes);
80
102
  raw = Uint8Array.from(atob(shards.key), (c) => c.charCodeAt(0));
81
103
  }
82
104
  catch (e) {
83
- throw Object.assign(new FileFreakError(0, "unreadable", "The API answered with a sealed upload whose cut or key could not be read."), { cause: e });
105
+ throw Object.assign(new FileFreakError(0, "unreadable", "The API answered with a sealed upload whose key could not be read."), { cause: e });
106
+ }
107
+ if (raw.length !== 32)
108
+ throw new FileFreakError(0, "unreadable", `The API answered with a key of ${raw.length} bytes, where a file's key is 32.`);
109
+ return { raw, shards };
110
+ }
111
+ /**
112
+ * The key and the cut of an upload sealed in version 1, from what
113
+ * uploads.get says, with every fragment it has not recorded waiting to be
114
+ * confirmed: one sent before, by this process or another, may be in storage
115
+ * already.
116
+ */
117
+ async function sealingOf(state, size) {
118
+ const { raw, shards } = keyOf(state);
119
+ let layout;
120
+ try {
121
+ layout = new ShardLayout(size, shards.fragmentBytes);
122
+ }
123
+ catch (e) {
124
+ throw Object.assign(new FileFreakError(0, "unreadable", "The API answered with a sealed upload whose cut could not be read."), { cause: e });
84
125
  }
85
- if (layout.count !== shards.count || layout.count !== state.upload.partCount || raw.length !== 32) {
126
+ if (layout.count !== shards.count || layout.count !== state.upload.partCount) {
86
127
  throw new FileFreakError(0, "layout", `The API cut this file into ${shards.count} fragments, where ${size} bytes in fragments of ${shards.fragmentBytes} make ${layout.count}.`);
87
128
  }
129
+ const key = await shardKey(raw);
130
+ const contentId = shards.contentId;
88
131
  const recorded = new Set(state.uploadedParts);
89
132
  const waiting = new Set();
90
133
  for (let n = 0; n < layout.count; n++)
91
134
  if (!recorded.has(n))
92
135
  waiting.add(n);
93
- return { key: await shardKey(raw), contentId: shards.contentId, layout, recorded, waiting };
136
+ return {
137
+ layout,
138
+ seal: async (place, plain) => {
139
+ try {
140
+ return await sealFragment(key, { contentId, layout, place }, plain);
141
+ }
142
+ catch (e) {
143
+ if (e instanceof ShardError)
144
+ throw Object.assign(new FileFreakError(0, "layout", e.message), { cause: e });
145
+ throw e;
146
+ }
147
+ },
148
+ outline: null,
149
+ recorded,
150
+ waiting,
151
+ };
152
+ }
153
+ /** What a file in version 2 is sealed with: each block checked, as it is sealed, against the id declared for its place. */
154
+ async function blockSealing(keys, outline, recorded) {
155
+ let layout;
156
+ try {
157
+ layout = new BlockLayout(outline);
158
+ }
159
+ catch (e) {
160
+ if (e instanceof ShardError)
161
+ throw Object.assign(new FileFreakError(0, "layout", e.message), { cause: e });
162
+ throw e;
163
+ }
164
+ return {
165
+ layout,
166
+ seal: async (place, plain) => {
167
+ const { id, blob } = await sealBlock(keys, plain);
168
+ if (base64(id) !== base64(outline.blocks[place].id))
169
+ throw changed(place);
170
+ return blob;
171
+ },
172
+ outline: base64(await outlineMac(keys, outline)),
173
+ recorded,
174
+ waiting: new Set(),
175
+ };
176
+ }
177
+ /**
178
+ * Version 2: the file cut with its own key's table, and its blocks declared,
179
+ * with what to seal them with. Null for a file not cut into two to ten
180
+ * thousand blocks, which version 2 does not take.
181
+ */
182
+ async function declare(calls, state, source, size, rule, signal) {
183
+ const { raw, shards } = keyOf(state);
184
+ const keys = await fileKeys(raw);
185
+ const blocks = await cutSealed(cuttable(source, size), raw, keys, rule, signal);
186
+ if (!blocks)
187
+ return null;
188
+ const declared = await calls.blocks(state.upload.id, { blocks: blocks.map((b) => ({ size: b.length, id: base64(b.id) })) }, { signal });
189
+ const sealing = await blockSealing(keys, { contentId: shards.contentId, size, blocks }, new Set(declared.reused));
190
+ return { upload: declared.upload, parts: declared.parts, sealing };
94
191
  }
95
192
  /** The part size to ask for: 32 MiB, halved while that would leave fewer parts than are sent at once. */
96
193
  export function partSizeFor(fileBytes, atOnce) {
@@ -109,44 +206,64 @@ async function bytesOf(source, from, to) {
109
206
  return new Uint8Array(source.slice(from, to));
110
207
  return source.slice(from, to);
111
208
  }
112
- export async function upload(calls, fetcher, source, options = {}) {
209
+ /** Bytes of the file, or why they could not be read. */
210
+ async function read(source, from, to) {
211
+ try {
212
+ return await bytesOf(source, from, to);
213
+ }
214
+ catch (e) {
215
+ // Browsers and Node both refuse to read a file that changed after it was
216
+ // opened, which is what keeps one from going up half old and half new.
217
+ throw Object.assign(new FileFreakError(0, "unreadable", "The file could not be read. It may have been changed, moved or deleted since it was opened: open it again, unchanged, to carry on."), { cause: e });
218
+ }
219
+ }
220
+ /** The file as the cutter reads it: where it is, when it is in memory, and a window at a time from a Blob. */
221
+ function cuttable(source, size) {
222
+ if (source instanceof Blob)
223
+ return { size, read: (from, to) => read(source, from, to) };
224
+ return source instanceof ArrayBuffer ? new Uint8Array(source) : source;
225
+ }
226
+ /** Parts of one size, the last whatever remains. */
227
+ function evenly(upload, size) {
228
+ return (n) => ({ start: n * upload.partBytes, end: Math.min(size, (n + 1) * upload.partBytes) });
229
+ }
230
+ /** Parts that are blocks of their own sizes. */
231
+ function blocksOf(sizes) {
232
+ const starts = [0];
233
+ for (const size of sizes)
234
+ starts.push(starts[starts.length - 1] + size);
235
+ return (n) => ({ start: starts[n], end: starts[n + 1] });
236
+ }
237
+ /** A job for an upload sealed as it is sent: each part is a fragment, where its layout says. */
238
+ function sealedJob(source, size, upload, sealing, addresses, done) {
239
+ return { source, size, upload, range: (n) => sealing.layout.range(n), hashes: null, addresses: new Map(addresses.map((p) => [p.number, p])), fetching: null, done: new Set(done), sentBytes: 0, sealing };
240
+ }
241
+ export async function upload(calls, fetcher, source, options = {}, rule = RULE) {
113
242
  const signal = options.signal;
114
243
  const atOnce = Math.max(1, Math.min(16, options.concurrency ?? AT_ONCE));
115
244
  const size = sizeOf(source);
116
- const call = { signal };
117
245
  let job;
118
246
  if (options.resume) {
119
- const state = await calls.get(options.resume, call);
120
- if (state.upload.status === "completed" && state.upload.fileId)
121
- return (await calls.file(state.upload.fileId, call)).item;
122
- if (state.upload.status !== "pending") {
123
- throw new FileFreakError(410, `upload_${state.upload.status}`, `That upload is ${state.upload.status}, so it cannot be carried on. Begin it again.`);
124
- }
125
- if (state.upload.sizeBytes !== size) {
126
- throw new FileFreakError(400, "size_mismatch", `That upload is for ${state.upload.sizeBytes} bytes, and this source has ${size}. Carry it on with the same file it began with.`);
127
- }
128
- // Begun by a program that cut the file by its content, as filefreak does: its
129
- // parts are blocks of different sizes, and this reads part n at n times
130
- // partBytes, so every part would be sent from the wrong place and the
131
- // upload could never complete.
132
- if (state.blocks) {
133
- throw new FileFreakError(400, "cut_by_content", "That upload was begun with its file cut by content, which this SDK does not do. Carry it on with the program that began it, or cancel it and begin again.");
134
- }
135
- const sealing = state.shards ? await sealingOf(state, size) : null;
136
- job = { source, size, upload: state.upload, addresses: new Map(), fetching: null, done: new Set(state.uploadedParts), sentBytes: 0, sealing };
247
+ const carried = await carryOn(calls, source, size, options.resume, rule, signal);
248
+ if (!("upload" in carried))
249
+ return carried;
250
+ job = carried;
137
251
  }
138
252
  else {
139
253
  const name = options.name ?? (typeof File !== "undefined" && source instanceof File ? source.name : undefined);
140
254
  if (!name)
141
255
  throw new TypeError("Give the upload a name: { name: \"photo.jpg\" }.");
142
256
  const type = options.contentType ?? (source instanceof Blob && source.type ? source.type : undefined);
257
+ // Cut by its content first, as the CLI cuts a file, so that the blocks its next version still has are not sent again.
258
+ const blocks = await cutPlain(cuttable(source, size), rule, signal);
143
259
  const body = {
144
260
  name,
145
261
  sizeBytes: size,
146
262
  parentId: options.parentId ?? null,
147
- partBytes: Math.max(LEAST_PART, Math.min(MOST_PART, options.partBytes ?? partSizeFor(size, atOnce))),
263
+ ...(blocks ? { blocks } : { partBytes: Math.max(LEAST_PART, Math.min(MOST_PART, options.partBytes ?? partSizeFor(size, atOnce))) }),
148
264
  // Into a protected place, the parts are fragments this sends sealed.
149
265
  protectable: true,
266
+ shardFormats: WRITES_FORMATS,
150
267
  };
151
268
  if (options.onConflict)
152
269
  body.onConflict = options.onConflict;
@@ -156,19 +273,7 @@ export async function upload(calls, fetcher, source, options = {}) {
156
273
  body.contentType = type;
157
274
  if (options.imageSizes)
158
275
  body.imageSizes = options.imageSizes;
159
- const begun = await calls.begin(body, { signal, ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}) });
160
- job = { source, size, upload: begun.upload, addresses: new Map(begun.parts.map((p) => [p.number, p])), fetching: null, done: new Set(), sentBytes: 0, sealing: null };
161
- if (begun.shards) {
162
- // Begin's answer is kept by the API under its Idempotency-Key, so the key is asked for here.
163
- try {
164
- job.sealing = await sealingOf(await calls.get(begun.upload.id, call), size);
165
- }
166
- catch (e) {
167
- throw new UploadStopped(begun.upload.id, e);
168
- }
169
- // Nothing of this upload can have been sent yet.
170
- job.sealing.waiting.clear();
171
- }
276
+ job = await begin(calls, source, size, body, blocks, options.idempotencyKey, rule, signal);
172
277
  }
173
278
  options.onBegun?.(job.upload);
174
279
  let item;
@@ -191,10 +296,93 @@ export async function upload(calls, fetcher, source, options = {}) {
191
296
  }
192
297
  return item;
193
298
  }
299
+ /**
300
+ * Begin it. Sent as it is, its parts are its blocks when it was cut, and
301
+ * those the API has already are not sent. Sealed, its key is asked for, and
302
+ * in version 2 its blocks are cut and declared, or if it cannot be written
303
+ * in version 2 it is begun again in version 1.
304
+ */
305
+ async function begin(calls, source, size, body, blocks, idempotencyKey, rule, signal) {
306
+ const begun = await calls.begin(body, { signal, ...(idempotencyKey ? { idempotencyKey } : {}) });
307
+ const addresses = new Map(begun.parts.map((p) => [p.number, p]));
308
+ if (!begun.shards) {
309
+ const range = blocks ? blocksOf(blocks.map((b) => b.size)) : evenly(begun.upload, size);
310
+ return { source, size, upload: begun.upload, range, hashes: blocks && blocks.map((b) => b.sha256), addresses, fetching: null, done: new Set(begun.reused), sentBytes: 0, sealing: null };
311
+ }
312
+ let job;
313
+ try {
314
+ // Begin's answer is kept by the API under its Idempotency-Key, so the key is asked for here.
315
+ const state = await calls.get(begun.upload.id, { signal });
316
+ if (state.shards?.format === 2) {
317
+ // Begun again in version 1 below, and answered in version 2 all the same: going on would begin again for ever.
318
+ if (!body.shardFormats?.includes(2))
319
+ throw new FileFreakError(0, "format_unsupported", "The API began this upload in version 2 of the shard format, which it was not offered.");
320
+ const declared = await declare(calls, state, source, size, rule, signal);
321
+ job = declared && sealedJob(source, size, declared.upload, declared.sealing, declared.parts, declared.sealing.recorded);
322
+ if (!job)
323
+ await calls.cancel(begun.upload.id, { signal });
324
+ }
325
+ else {
326
+ const sealing = await sealingOf(state, size);
327
+ // Nothing of this upload can have been sent yet.
328
+ sealing.waiting.clear();
329
+ job = sealedJob(source, size, begun.upload, sealing, begun.parts, []);
330
+ }
331
+ }
332
+ catch (e) {
333
+ throw new UploadStopped(begun.upload.id, e);
334
+ }
335
+ if (job)
336
+ return job;
337
+ // Not two to ten thousand blocks, which version 2 does not take. The API
338
+ // refuses one Idempotency-Key for two bodies, so this begin goes with the
339
+ // transport's own.
340
+ return await begin(calls, source, size, { ...body, shardFormats: [1] }, blocks, undefined, rule, signal);
341
+ }
342
+ /** An upload begun before, ready to send what storage does not have: or its file, if it was finished. */
343
+ async function carryOn(calls, source, size, id, rule, signal) {
344
+ const state = await calls.get(id, { signal });
345
+ if (state.upload.status === "completed" && state.upload.fileId)
346
+ return (await calls.file(state.upload.fileId, { signal })).item;
347
+ if (state.upload.status !== "pending") {
348
+ throw new FileFreakError(410, `upload_${state.upload.status}`, `That upload is ${state.upload.status}, so it cannot be carried on. Begin it again.`);
349
+ }
350
+ if (state.upload.sizeBytes !== size) {
351
+ throw new FileFreakError(400, "size_mismatch", `That upload is for ${state.upload.sizeBytes} bytes, and this source has ${size}. Carry it on with the same file it began with.`);
352
+ }
353
+ if (state.shards?.format === 2) {
354
+ if (state.shards.blocks) {
355
+ // Declared before. What the file keeps from its last version, and what was
356
+ // recorded, are filled; anything else may be in storage, and is asked about first.
357
+ const blocks = state.shards.blocks.map((b) => ({ id: Uint8Array.from(atob(b.id), (c) => c.charCodeAt(0)), length: b.size }));
358
+ const sealing = await blockSealing(await fileKeys(keyOf(state).raw), { contentId: state.shards.contentId, size, blocks }, new Set(state.uploadedParts));
359
+ for (let n = 0; n < blocks.length; n++)
360
+ if (!sealing.recorded.has(n))
361
+ sealing.waiting.add(n);
362
+ return sealedJob(source, size, state.upload, sealing, [], state.uploadedParts);
363
+ }
364
+ // Begun and never declared: nothing of it can have been sent.
365
+ const declared = await declare(calls, state, source, size, rule, signal);
366
+ if (declared)
367
+ return sealedJob(source, size, declared.upload, declared.sealing, declared.parts, declared.sealing.recorded);
368
+ await calls.cancel(id, { signal });
369
+ throw new FileFreakError(410, "upload_aborted", "That upload cannot be carried on: the file is not cut into 2 to 10,000 blocks, which version 2 of the shard format needs, so it has been cancelled. Begin it again, and it is written in version 1.");
370
+ }
371
+ if (state.shards)
372
+ return sealedJob(source, size, state.upload, await sealingOf(state, size), [], state.uploadedParts);
373
+ // Begun cut by content: each part is its block, checked against its SHA-256 as it is sent.
374
+ const range = state.blocks ? blocksOf(state.blocks.map((b) => b.size)) : evenly(state.upload, size);
375
+ const hashes = state.blocks && state.blocks.map((b) => b.sha256.toLowerCase());
376
+ return { source, size, upload: state.upload, range, hashes, addresses: new Map(), fetching: null, done: new Set([...state.uploadedParts, ...state.reused]), sentBytes: 0, sealing: null };
377
+ }
194
378
  function tally(job, options) {
195
379
  let sent = 0;
196
- for (const n of job.done)
197
- sent += Math.min(job.size, (n + 1) * job.upload.partBytes) - n * job.upload.partBytes;
380
+ for (const n of job.done) {
381
+ if (n < 0 || n >= job.upload.partCount)
382
+ continue;
383
+ const { start, end } = job.range(n);
384
+ sent += end - start;
385
+ }
198
386
  job.sentBytes = sent;
199
387
  options.onProgress?.(sent, job.size);
200
388
  }
@@ -300,27 +488,12 @@ async function sendParts(calls, fetcher, job, atOnce, options) {
300
488
  }
301
489
  }
302
490
  async function sendPart(calls, fetcher, job, n, signal) {
303
- const sealing = job.sealing;
304
- const { start: from, end: to } = sealing ? sealing.layout.range(n) : { start: n * job.upload.partBytes, end: Math.min(job.size, (n + 1) * job.upload.partBytes) };
305
- let bytes;
306
- try {
307
- bytes = await bytesOf(job.source, from, to);
308
- }
309
- catch (e) {
310
- // Browsers and Node both refuse to read a file that changed after it was
311
- // opened, which is what keeps one from going up half old and half new.
312
- throw Object.assign(new FileFreakError(0, "unreadable", "The file could not be read. It may have been changed, moved or deleted since it was opened: open it again, unchanged, to carry on."), { cause: e });
313
- }
314
- if (sealing) {
315
- try {
316
- bytes = await sealFragment(sealing.key, { contentId: sealing.contentId, layout: sealing.layout, place: n }, bytes);
317
- }
318
- catch (e) {
319
- if (e instanceof ShardError)
320
- throw Object.assign(new FileFreakError(0, "layout", e.message), { cause: e });
321
- throw e;
322
- }
323
- }
491
+ const { start: from, end: to } = job.range(n);
492
+ let bytes = await read(job.source, from, to);
493
+ if (job.hashes && hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))) !== job.hashes[n])
494
+ throw changed(n);
495
+ if (job.sealing)
496
+ bytes = await job.sealing.seal(n, bytes);
324
497
  const md5 = base64(new Md5().update(bytes).digest());
325
498
  for (let attempt = 1;; attempt++) {
326
499
  const part = await address(calls, job, n, signal);
@@ -383,7 +556,11 @@ async function address(calls, job, n, signal) {
383
556
  }
384
557
  async function finish(calls, fetcher, job, atOnce, options) {
385
558
  const started = Date.now();
386
- const body = options.sha256 ? { sha256: options.sha256 } : {};
559
+ const body = {};
560
+ if (options.sha256)
561
+ body.sha256 = options.sha256;
562
+ if (job.sealing?.outline)
563
+ body.outline = job.sealing.outline;
387
564
  for (let round = 0;;) {
388
565
  try {
389
566
  const result = await calls.complete(job.upload.id, body, { signal: options.signal });
@@ -396,7 +573,8 @@ async function finish(calls, fetcher, job, atOnce, options) {
396
573
  await sleep(Math.max(1, result.retryAfterSeconds) * 1000, options.signal);
397
574
  }
398
575
  catch (e) {
399
- // Storage does not have every part after all. Send what is missing.
576
+ // Storage does not have every part after all, or in version 2 a block the
577
+ // file kept went with the last version that had it. Send what is missing.
400
578
  if (e instanceof FileFreakError && e.code === "upload_incomplete" && round++ < 2) {
401
579
  const have = Array.isArray(e.body?.uploadedParts) ? e.body.uploadedParts.filter((n) => typeof n === "number") : [];
402
580
  job.done = new Set(have);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@filefreak.io/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "FileFreak's developer API for Node and the browser: every endpoint, uploads straight to storage in parts, downloads in ranges, and the changes to follow.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://filefreak.io/developers/reference",