@filefreak.io/sdk 0.5.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/README.md +27 -2
- package/dist/cut.d.ts +96 -0
- package/dist/cut.js +220 -0
- package/dist/uploads.d.ts +6 -3
- package/dist/uploads.js +250 -72
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,8 @@ later and in browsers. No dependencies.
|
|
|
5
5
|
|
|
6
6
|
Every endpoint is a function, typed from the same table the API is built
|
|
7
7
|
from. Around them: uploads sent straight to storage in parts and carried on
|
|
8
|
-
after an interruption,
|
|
8
|
+
after an interruption, a new version of a large file sending only what
|
|
9
|
+
changed, downloads fetched in ranges, listings and changes read
|
|
9
10
|
to the end, files under Shard Protection sealed as they are sent and read
|
|
10
11
|
through their maps, what a share link holds saved with no key, and requests sent again only when that
|
|
11
12
|
cannot make anything twice. The reference is at
|
|
@@ -43,6 +44,28 @@ an upload carried on asks which fragments storage has before it sends any.
|
|
|
43
44
|
|
|
44
45
|
Keep keys on a server. A key sent from a web page is refused.
|
|
45
46
|
|
|
47
|
+
## A new version of a large file
|
|
48
|
+
|
|
49
|
+
A file of 16 MiB or more is read through before it is sent and cut where
|
|
50
|
+
its content says, and each part is one of its blocks. A new version of
|
|
51
|
+
it, sent with `onConflict: "replace"`, sends only the blocks the file does
|
|
52
|
+
not have already: those it has are put together with the new ones inside
|
|
53
|
+
storage. The SDK and the CLI cut a file alike, so the next version of a file
|
|
54
|
+
either one sent keeps its blocks whichever sends it.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
await ff.upload(await fromPath("disk.img"), { parentId: folder.id, onConflict: "replace" });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Into a folder under Shard Protection the same holds, with every block
|
|
61
|
+
encrypted before it leaves. A protected file of more than 16 MiB is cut by
|
|
62
|
+
its own key, so that nobody without the key can cut a copy of it the same
|
|
63
|
+
way and compare, and its next version keeps the blocks the last one has
|
|
64
|
+
when the key may read files; a key that may only write starts a new file
|
|
65
|
+
key and sends every block. Each block is read again as it is sent and
|
|
66
|
+
checked against what the upload began with, so a file changed while it is
|
|
67
|
+
sent stops the upload (`source_changed`) rather than storing a mix of two.
|
|
68
|
+
|
|
46
69
|
## In a web page
|
|
47
70
|
|
|
48
71
|
A page is given a token by your server instead. Your server makes it with its
|
|
@@ -190,7 +213,9 @@ await ff.files.delete(file.id, { ifRevision: file.revision });
|
|
|
190
213
|
|
|
191
214
|
What reached storage is kept until the upload expires. Keep the upload's id
|
|
192
215
|
from `onBegun`, and carry on with the same file: only the parts storage does
|
|
193
|
-
not have are sent.
|
|
216
|
+
not have are sent. A file cut by its content is carried on block by block,
|
|
217
|
+
each checked against what the upload began with, so carrying on with another
|
|
218
|
+
file is refused at its first block.
|
|
194
219
|
|
|
195
220
|
```ts
|
|
196
221
|
import { UploadStopped } from "@filefreak.io/sdk";
|
package/dist/cut.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type FileKeys, type OutlineBlock } from "./shards.js";
|
|
2
|
+
/** How a file is cut. One rule for every file, so that a file cut once is cut the same way next time, which is what lets its blocks be found again. */
|
|
3
|
+
export interface Rule {
|
|
4
|
+
/** A smaller plain file is not cut: it goes in one or two parts, and there is nothing to gain. */
|
|
5
|
+
cutFrom: number;
|
|
6
|
+
/** FastCDC's sizes. No piece but a file's last is shorter than least, and none is longer than most. */
|
|
7
|
+
least: number;
|
|
8
|
+
average: number;
|
|
9
|
+
most: number;
|
|
10
|
+
/** FastCDC's patterns at level 1: the stricter until a piece reaches the average, the looser after. Within the low 48 bits. */
|
|
11
|
+
strict: bigint;
|
|
12
|
+
loose: bigint;
|
|
13
|
+
/** Past this size pieces are put together in runs, about four to a run, so that a file of 150 GB is still within the API's 10,000 blocks. */
|
|
14
|
+
groupFrom: number;
|
|
15
|
+
/** A run ends once it is this long, so that one more piece, 16 MiB at most, leaves it within the API's 64 MiB. */
|
|
16
|
+
groupMost: number;
|
|
17
|
+
/** The most blocks the API takes. */
|
|
18
|
+
mostBlocks: number;
|
|
19
|
+
/** How much of a Blob is read at a time. */
|
|
20
|
+
readBytes: number;
|
|
21
|
+
}
|
|
22
|
+
export declare const RULE: Rule;
|
|
23
|
+
/** FastCDC's 256 numbers: whole, for the hash at a cut, and their low 48 bits in halves of 24, for the hash as the bytes go by. */
|
|
24
|
+
export declare class CutTable {
|
|
25
|
+
readonly values: BigUint64Array;
|
|
26
|
+
readonly low: Int32Array<ArrayBuffer>;
|
|
27
|
+
readonly high: Int32Array<ArrayBuffer>;
|
|
28
|
+
constructor(values: BigUint64Array);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The fastcdc crate's own table, which a plain file is cut with: for each
|
|
32
|
+
* value, the first eight bytes, big-endian, of the MD5 of 64 bytes of it
|
|
33
|
+
* (the crate's examples/table64.rs, after the C implementation it follows).
|
|
34
|
+
* Made here rather than copied in.
|
|
35
|
+
*/
|
|
36
|
+
export declare function publicTable(): CutTable;
|
|
37
|
+
/** A protected file's own table: 2,048 bytes of HKDF-SHA256 from its key, with no salt and the info "filefreak-shard-v2 cut", as 256 big-endian numbers. */
|
|
38
|
+
export declare function keyedTable(raw: Uint8Array): Promise<CutTable>;
|
|
39
|
+
/** A piece of a file: how many bytes, and the hash it was cut at. */
|
|
40
|
+
export interface Piece {
|
|
41
|
+
length: number;
|
|
42
|
+
hash: bigint;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The piece `data` begins with, where `data` runs to the end of the largest
|
|
46
|
+
* piece or of the file, whichever comes first. As the crate cuts: the hash
|
|
47
|
+
* starts `least` bytes in; places are tested two at a time, so the last byte
|
|
48
|
+
* of a piece cut short at an odd length is hashed and never tested; and a
|
|
49
|
+
* piece cut at a match ends before the byte that matched.
|
|
50
|
+
*/
|
|
51
|
+
export declare function piece(data: Uint8Array, table: CutTable, rule?: Rule): Piece;
|
|
52
|
+
/**
|
|
53
|
+
* Whether a piece cut at `hash` ends a run, in a file past groupFrom: one in
|
|
54
|
+
* four, decided by the bytes where it was cut, so that a run ends at the
|
|
55
|
+
* same content wherever it moves to. Mixed first, because a hash at a cut
|
|
56
|
+
* matches a pattern and some of its bits are always zero.
|
|
57
|
+
*/
|
|
58
|
+
export declare function endsRun(hash: bigint): boolean;
|
|
59
|
+
/** What is cut: its bytes, or a way to read them a window at a time. */
|
|
60
|
+
export type Bytes = Uint8Array | {
|
|
61
|
+
size: number;
|
|
62
|
+
read(from: number, to: number): Promise<Uint8Array>;
|
|
63
|
+
};
|
|
64
|
+
/** A block of a file: how many bytes, and what names it. */
|
|
65
|
+
export interface Named<T> {
|
|
66
|
+
length: number;
|
|
67
|
+
name: T;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A file cut into blocks with the table given: a piece to a block, or in a
|
|
71
|
+
* file past groupFrom, runs of pieces. `name` is given each block's bytes in
|
|
72
|
+
* order. Null as soon as there are more blocks than the API takes, without
|
|
73
|
+
* reading the rest. What is read is kept only while a piece or an open run
|
|
74
|
+
* still needs it.
|
|
75
|
+
*/
|
|
76
|
+
export declare function cut<T>(bytes: Bytes, table: CutTable, name: (block: Uint8Array) => Promise<T>, rule?: Rule, signal?: AbortSignal): Promise<Array<Named<T>> | null>;
|
|
77
|
+
/** A plain block as beginning an upload takes it. */
|
|
78
|
+
export interface PlainBlock {
|
|
79
|
+
size: number;
|
|
80
|
+
/** In lower-case hex. */
|
|
81
|
+
sha256: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A plain file cut for beginning its upload, each block its size and
|
|
85
|
+
* SHA-256. Null for a file too small to be worth cutting, or cut into more
|
|
86
|
+
* blocks than the API takes: either is sent in parts of one size.
|
|
87
|
+
*/
|
|
88
|
+
export declare function cutPlain(bytes: Bytes, rule?: Rule, signal?: AbortSignal): Promise<PlainBlock[] | null>;
|
|
89
|
+
/**
|
|
90
|
+
* A protected file cut for version 2 of the shard format, with its own key's
|
|
91
|
+
* table, each block its id and length, as uploads.blocks takes them and the
|
|
92
|
+
* outline holds them. Null for a file not cut into two to ten thousand
|
|
93
|
+
* blocks, which version 2 does not take: it is written in version 1.
|
|
94
|
+
*/
|
|
95
|
+
export declare function cutSealed(bytes: Bytes, raw: Uint8Array, keys: FileKeys, rule?: Rule, signal?: AbortSignal): Promise<OutlineBlock[] | null>;
|
|
96
|
+
export declare function hex(bytes: Uint8Array): string;
|
package/dist/cut.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Cutting a file by its content, so that its next version sends only what
|
|
2
|
+
// changed. The rule is the CLI's (cli/crates/transfer/src/blocks.rs), so
|
|
3
|
+
// that of a file either one sent, the other's next version keeps the blocks.
|
|
4
|
+
//
|
|
5
|
+
// Where a piece ends is decided by the bytes there: FastCDC, as the fastcdc
|
|
6
|
+
// crate runs it (version 5, its 2020 form, normalised at level 1), hashes the
|
|
7
|
+
// bytes as it reads and cuts where the hash matches a pattern. A byte put in
|
|
8
|
+
// near the start of a file moves the end of the piece it falls in and leaves
|
|
9
|
+
// every later piece as it was, where parts of one size would all move. A
|
|
10
|
+
// plain file is cut with the crate's own table of numbers and each block
|
|
11
|
+
// named by its SHA-256, as beginning an upload takes them. A file under Shard
|
|
12
|
+
// Protection, written in version 2 of the shard format, is cut with a table
|
|
13
|
+
// made from the file's own key and each block named by its id
|
|
14
|
+
// (docs/reference/shard-format.md, "Cutting a file"): with the public table,
|
|
15
|
+
// whoever has the database could cut a file they hold and compare the
|
|
16
|
+
// lengths. docs/reference/shard-format-v2.json holds both to the crate.
|
|
17
|
+
//
|
|
18
|
+
// The hash runs to 64 bits, more than a JavaScript number holds exactly. The
|
|
19
|
+
// patterns look only at its low 48 bits, which nothing above them changes, so
|
|
20
|
+
// only those are kept as the bytes go by, as two numbers of 24 bits. The
|
|
21
|
+
// whole hash is wanted only at a cut, where it says whether a run ends in a
|
|
22
|
+
// file past 32 GiB, and it depends only on the last 64 bytes hashed: it is
|
|
23
|
+
// worked out again from those, as a BigInt.
|
|
24
|
+
import { Md5 } from "./md5.js";
|
|
25
|
+
import { blockId } from "./shards.js";
|
|
26
|
+
const MIB = 1024 * 1024;
|
|
27
|
+
const HALF = 0xff_ffff;
|
|
28
|
+
export const RULE = {
|
|
29
|
+
cutFrom: 16 * MIB,
|
|
30
|
+
least: MIB,
|
|
31
|
+
average: 4 * MIB,
|
|
32
|
+
most: 16 * MIB,
|
|
33
|
+
strict: 0x0000d93777537000n,
|
|
34
|
+
loose: 0x0000d91767537000n,
|
|
35
|
+
groupFrom: 32 * 1024 * MIB,
|
|
36
|
+
groupMost: 48 * MIB,
|
|
37
|
+
mostBlocks: 10_000,
|
|
38
|
+
readBytes: 32 * MIB,
|
|
39
|
+
};
|
|
40
|
+
/** FastCDC's 256 numbers: whole, for the hash at a cut, and their low 48 bits in halves of 24, for the hash as the bytes go by. */
|
|
41
|
+
export class CutTable {
|
|
42
|
+
values;
|
|
43
|
+
low = new Int32Array(256);
|
|
44
|
+
high = new Int32Array(256);
|
|
45
|
+
constructor(values) {
|
|
46
|
+
this.values = values;
|
|
47
|
+
if (values.length !== 256)
|
|
48
|
+
throw new RangeError(`A cutting table has 256 numbers, not ${values.length}.`);
|
|
49
|
+
for (let i = 0; i < 256; i++) {
|
|
50
|
+
this.low[i] = Number(values[i] & BigInt(HALF));
|
|
51
|
+
this.high[i] = Number((values[i] >> 24n) & BigInt(HALF));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
let crateTable = null;
|
|
56
|
+
/**
|
|
57
|
+
* The fastcdc crate's own table, which a plain file is cut with: for each
|
|
58
|
+
* value, the first eight bytes, big-endian, of the MD5 of 64 bytes of it
|
|
59
|
+
* (the crate's examples/table64.rs, after the C implementation it follows).
|
|
60
|
+
* Made here rather than copied in.
|
|
61
|
+
*/
|
|
62
|
+
export function publicTable() {
|
|
63
|
+
if (!crateTable) {
|
|
64
|
+
const values = new BigUint64Array(256);
|
|
65
|
+
for (let i = 0; i < 256; i++) {
|
|
66
|
+
const digest = new Md5().update(new Uint8Array(64).fill(i)).digest();
|
|
67
|
+
values[i] = new DataView(digest.buffer, digest.byteOffset, 8).getBigUint64(0, false);
|
|
68
|
+
}
|
|
69
|
+
crateTable = new CutTable(values);
|
|
70
|
+
}
|
|
71
|
+
return crateTable;
|
|
72
|
+
}
|
|
73
|
+
/** A protected file's own table: 2,048 bytes of HKDF-SHA256 from its key, with no salt and the info "filefreak-shard-v2 cut", as 256 big-endian numbers. */
|
|
74
|
+
export async function keyedTable(raw) {
|
|
75
|
+
if (raw.length !== 32)
|
|
76
|
+
throw new RangeError(`A shard key is 32 bytes, not ${raw.length}.`);
|
|
77
|
+
const base = await crypto.subtle.importKey("raw", new Uint8Array(raw), "HKDF", false, ["deriveBits"]);
|
|
78
|
+
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("filefreak-shard-v2 cut") }, base, 2048 * 8);
|
|
79
|
+
const view = new DataView(bits);
|
|
80
|
+
const values = new BigUint64Array(256);
|
|
81
|
+
for (let i = 0; i < 256; i++)
|
|
82
|
+
values[i] = view.getBigUint64(i * 8, false);
|
|
83
|
+
return new CutTable(values);
|
|
84
|
+
}
|
|
85
|
+
/** A pattern as two halves of 24 bits, refused if it looks above the 48 bits the hash is kept to. */
|
|
86
|
+
function halves(pattern) {
|
|
87
|
+
if (pattern >> 48n !== 0n)
|
|
88
|
+
throw new RangeError(`A cutting pattern looks only at the low 48 bits of the hash, and ${pattern.toString(16)} does not.`);
|
|
89
|
+
return [Number(pattern & BigInt(HALF)), Number(pattern >> 24n)];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The piece `data` begins with, where `data` runs to the end of the largest
|
|
93
|
+
* piece or of the file, whichever comes first. As the crate cuts: the hash
|
|
94
|
+
* starts `least` bytes in; places are tested two at a time, so the last byte
|
|
95
|
+
* of a piece cut short at an odd length is hashed and never tested; and a
|
|
96
|
+
* piece cut at a match ends before the byte that matched.
|
|
97
|
+
*/
|
|
98
|
+
export function piece(data, table, rule = RULE) {
|
|
99
|
+
const left = data.length;
|
|
100
|
+
if (left <= rule.least)
|
|
101
|
+
return { length: left, hash: 0n };
|
|
102
|
+
const end = Math.min(left, rule.most);
|
|
103
|
+
const strictTo = 2 * Math.floor(Math.min(rule.average, end) / 2);
|
|
104
|
+
const testedTo = 2 * Math.floor(end / 2);
|
|
105
|
+
const low = table.low;
|
|
106
|
+
const high = table.high;
|
|
107
|
+
let [maskLow, maskHigh] = halves(rule.strict);
|
|
108
|
+
const [looseLow, looseHigh] = halves(rule.loose);
|
|
109
|
+
let lo = 0;
|
|
110
|
+
let hi = 0;
|
|
111
|
+
for (let i = rule.least; i < testedTo; i++) {
|
|
112
|
+
if (i === strictTo) {
|
|
113
|
+
maskLow = looseLow;
|
|
114
|
+
maskHigh = looseHigh;
|
|
115
|
+
}
|
|
116
|
+
const b = data[i];
|
|
117
|
+
const sum = 2 * lo + low[b];
|
|
118
|
+
hi = (2 * hi + high[b] + (sum >>> 24)) & HALF;
|
|
119
|
+
lo = sum & HALF;
|
|
120
|
+
if ((lo & maskLow) === 0 && (hi & maskHigh) === 0)
|
|
121
|
+
return { length: i, hash: hashAt(data, i, table, rule, i % 2 === 0) };
|
|
122
|
+
}
|
|
123
|
+
return { length: end, hash: hashAt(data, end - 1, table, rule, false) };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The whole hash once byte `i` is hashed, from the bytes that still count in
|
|
127
|
+
* it: those from `least` on, and of them only the last 64, since each byte
|
|
128
|
+
* doubles it and 64 doublings leave nothing of an earlier one. Doubled again
|
|
129
|
+
* for a match at an even place, as the crate gives it.
|
|
130
|
+
*/
|
|
131
|
+
function hashAt(data, i, table, rule, doubled) {
|
|
132
|
+
let h = 0n;
|
|
133
|
+
for (let j = Math.max(rule.least, i - 63); j <= i; j++)
|
|
134
|
+
h = BigInt.asUintN(64, (h << 1n) + table.values[data[j]]);
|
|
135
|
+
return doubled ? BigInt.asUintN(64, h << 1n) : h;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Whether a piece cut at `hash` ends a run, in a file past groupFrom: one in
|
|
139
|
+
* four, decided by the bytes where it was cut, so that a run ends at the
|
|
140
|
+
* same content wherever it moves to. Mixed first, because a hash at a cut
|
|
141
|
+
* matches a pattern and some of its bits are always zero.
|
|
142
|
+
*/
|
|
143
|
+
export function endsRun(hash) {
|
|
144
|
+
return BigInt.asUintN(64, hash * 0x9e3779b97f4a7c15n) >> 62n === 0n;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* A file cut into blocks with the table given: a piece to a block, or in a
|
|
148
|
+
* file past groupFrom, runs of pieces. `name` is given each block's bytes in
|
|
149
|
+
* order. Null as soon as there are more blocks than the API takes, without
|
|
150
|
+
* reading the rest. What is read is kept only while a piece or an open run
|
|
151
|
+
* still needs it.
|
|
152
|
+
*/
|
|
153
|
+
export async function cut(bytes, table, name, rule = RULE, signal) {
|
|
154
|
+
const reader = bytes instanceof Uint8Array ? null : bytes;
|
|
155
|
+
const size = sizeOf(bytes);
|
|
156
|
+
let buffer = bytes instanceof Uint8Array ? bytes : new Uint8Array(0);
|
|
157
|
+
let bufferAt = 0;
|
|
158
|
+
const grouping = size > rule.groupFrom;
|
|
159
|
+
const blocks = [];
|
|
160
|
+
let runAt = null;
|
|
161
|
+
for (let at = 0; at < size;) {
|
|
162
|
+
signal?.throwIfAborted();
|
|
163
|
+
const want = Math.min(size, at + rule.most);
|
|
164
|
+
if (reader && bufferAt + buffer.length < want) {
|
|
165
|
+
const keep = runAt ?? at;
|
|
166
|
+
const had = bufferAt + buffer.length;
|
|
167
|
+
const upTo = Math.min(size, Math.max(want, had + rule.readBytes));
|
|
168
|
+
const more = await reader.read(had, upTo);
|
|
169
|
+
if (more.length !== upTo - had)
|
|
170
|
+
throw new RangeError(`Reading bytes ${had} to ${upTo} gave ${more.length}: the file has changed since it was opened.`);
|
|
171
|
+
const joined = new Uint8Array(had - keep + more.length);
|
|
172
|
+
joined.set(buffer.subarray(keep - bufferAt));
|
|
173
|
+
joined.set(more, had - keep);
|
|
174
|
+
buffer = joined;
|
|
175
|
+
bufferAt = keep;
|
|
176
|
+
}
|
|
177
|
+
const { length, hash } = piece(buffer.subarray(at - bufferAt, want - bufferAt), table, rule);
|
|
178
|
+
const end = at + length;
|
|
179
|
+
runAt ??= at;
|
|
180
|
+
if (!grouping || end - runAt >= rule.groupMost || endsRun(hash) || end === size) {
|
|
181
|
+
blocks.push({ length: end - runAt, name: await name(buffer.subarray(runAt - bufferAt, end - bufferAt)) });
|
|
182
|
+
if (blocks.length > rule.mostBlocks)
|
|
183
|
+
return null;
|
|
184
|
+
runAt = null;
|
|
185
|
+
}
|
|
186
|
+
at = end;
|
|
187
|
+
}
|
|
188
|
+
return blocks;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* A plain file cut for beginning its upload, each block its size and
|
|
192
|
+
* SHA-256. Null for a file too small to be worth cutting, or cut into more
|
|
193
|
+
* blocks than the API takes: either is sent in parts of one size.
|
|
194
|
+
*/
|
|
195
|
+
export async function cutPlain(bytes, rule = RULE, signal) {
|
|
196
|
+
if (sizeOf(bytes) < rule.cutFrom)
|
|
197
|
+
return null;
|
|
198
|
+
const blocks = await cut(bytes, publicTable(), async (block) => hex(new Uint8Array(await crypto.subtle.digest("SHA-256", own(block)))), rule, signal);
|
|
199
|
+
return blocks && blocks.map((b) => ({ size: b.length, sha256: b.name }));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A protected file cut for version 2 of the shard format, with its own key's
|
|
203
|
+
* table, each block its id and length, as uploads.blocks takes them and the
|
|
204
|
+
* outline holds them. Null for a file not cut into two to ten thousand
|
|
205
|
+
* blocks, which version 2 does not take: it is written in version 1.
|
|
206
|
+
*/
|
|
207
|
+
export async function cutSealed(bytes, raw, keys, rule = RULE, signal) {
|
|
208
|
+
const blocks = await cut(bytes, await keyedTable(raw), (block) => blockId(keys, block), rule, signal);
|
|
209
|
+
return blocks && blocks.length >= 2 ? blocks.map((b) => ({ id: b.name, length: b.length })) : null;
|
|
210
|
+
}
|
|
211
|
+
function sizeOf(bytes) {
|
|
212
|
+
return bytes instanceof Uint8Array ? bytes.length : bytes.size;
|
|
213
|
+
}
|
|
214
|
+
/** The bytes as WebCrypto's types take them, copied only when they are not in an ArrayBuffer of their own (a SharedArrayBuffer's). */
|
|
215
|
+
function own(bytes) {
|
|
216
|
+
return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
217
|
+
}
|
|
218
|
+
export function hex(bytes) {
|
|
219
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
220
|
+
}
|
package/dist/uploads.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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>;
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
*/
|
|
72
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
120
|
-
if (
|
|
121
|
-
return
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
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",
|