@filefreak.io/sdk 0.3.2 → 0.5.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 +39 -1
- package/dist/client.d.ts +7 -0
- package/dist/client.js +9 -3
- package/dist/frames.d.ts +21 -0
- package/dist/frames.js +124 -0
- package/dist/generated.d.ts +102 -9
- package/dist/generated.js +22 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/links.js +26 -3
- package/dist/reader.d.ts +11 -0
- package/dist/reader.js +69 -27
- package/dist/shards.d.ts +31 -0
- package/dist/shards.js +64 -0
- package/dist/uploads.d.ts +9 -1
- package/dist/uploads.js +11 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,7 +33,9 @@ const bytes = await ff.bytes(file.id); // for a file small enough to hold in mem
|
|
|
33
33
|
than memory is fine. `toPath` writes beside the file and gives it its name
|
|
34
34
|
only once every byte is there: a download that fails leaves nothing behind.
|
|
35
35
|
A file under Shard Protection has no address, and `download` and `bytes`
|
|
36
|
-
read it through its map instead, each fragment checked as it arrives.
|
|
36
|
+
read it through its map instead, each fragment checked as it arrives. One
|
|
37
|
+
written in version 2 of the shard format, in blocks cut by its content, is
|
|
38
|
+
read the same way, its outline checked before any block is fetched.
|
|
37
39
|
Into a folder under Shard Protection, `upload` seals each part with the
|
|
38
40
|
file's own key before it is sent, so none of the file is ever plain in
|
|
39
41
|
storage. The key is asked of the API for the upload and kept nowhere, and
|
|
@@ -74,6 +76,42 @@ folder asked for, for a minute to an hour. It is checked against its key on
|
|
|
74
76
|
every request, so revoking the key stops its tokens at once. A token cannot
|
|
75
77
|
make another.
|
|
76
78
|
|
|
79
|
+
## Previews
|
|
80
|
+
|
|
81
|
+
Once the service has made them, within a few minutes of an upload, a file
|
|
82
|
+
has `thumbnailUrl`, a small WebP picture of an image, a photo from an
|
|
83
|
+
iPhone, a PDF's first page, the cover in an audio file's tags or a frame of
|
|
84
|
+
a video; or `previewTextUrl`, the first lines of a text or code file or a
|
|
85
|
+
document, as text. Both are addresses on storage itself that need no key.
|
|
86
|
+
Each is the same all day, so a browser keeps what it fetched, and fetching
|
|
87
|
+
one counts as nothing. They are null until made, and for a file with none.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const { items } = await ff.files.list({ parent: folder.id });
|
|
91
|
+
for (const item of items) {
|
|
92
|
+
if (item.thumbnailUrl) gallery.append(Object.assign(new Image(), { src: item.thumbnailUrl, alt: item.name }));
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The service never reads a video, so a video's picture is a frame of it that
|
|
97
|
+
you send. In a web page, `upload` draws one about a second in from the file
|
|
98
|
+
the page has, and sends it once the file is stored, without the upload
|
|
99
|
+
waiting for it; `frame: false` sends none. On a server, make a JPEG of a
|
|
100
|
+
frame with anything that can, and send that:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { execFile } from "node:child_process";
|
|
104
|
+
import { readFile } from "node:fs/promises";
|
|
105
|
+
import { promisify } from "node:util";
|
|
106
|
+
|
|
107
|
+
// The frame a second in, 960 pixels wide, as a JPEG.
|
|
108
|
+
await promisify(execFile)("ffmpeg", ["-ss", "1", "-i", "film.mp4", "-frames:v", "1", "-vf", "scale=960:-2", "-y", "frame.jpg"]);
|
|
109
|
+
await ff.files.sendFrame(file.id, new Blob([await readFile("frame.jpg")]));
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A video's picture is a JPEG of at most 2 MB, sent once. A file under Shard
|
|
113
|
+
Protection has no previews.
|
|
114
|
+
|
|
77
115
|
## Saving from a link
|
|
78
116
|
|
|
79
117
|
`get` saves what a share link or a public page holds. It needs no key, and
|
package/dist/client.d.ts
CHANGED
|
@@ -36,6 +36,13 @@ export declare class FileFreak {
|
|
|
36
36
|
readonly files: Api["files"] & {
|
|
37
37
|
/** Everything in a folder, read to the end a page at a time. */
|
|
38
38
|
each(query?: Omit<FilesListQuery, "cursor">, options?: CallOptions): AsyncIterable<Item>;
|
|
39
|
+
/**
|
|
40
|
+
* Give a video its picture in listings: a JPEG of one of its frames, at
|
|
41
|
+
* most 2 MB, which the service makes small and gives as the video's
|
|
42
|
+
* thumbnailUrl within a few minutes. Once for each video. In a web page,
|
|
43
|
+
* upload() does it itself.
|
|
44
|
+
*/
|
|
45
|
+
sendFrame(id: string, jpeg: Blob, options?: CallOptions): Promise<void>;
|
|
39
46
|
};
|
|
40
47
|
readonly folders: Api["folders"];
|
|
41
48
|
readonly tree: Api["tree"] & {
|
package/dist/client.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// otherwise write around them: sending a file in parts, fetching one in
|
|
4
4
|
// ranges, and reading a listing or the changes to the end.
|
|
5
5
|
import { bind, OPERATIONS, } from "./generated.js";
|
|
6
|
+
import { sendFrame } from "./frames.js";
|
|
6
7
|
import { fetchRanges } from "./ranges.js";
|
|
7
8
|
import { asItIsKept, readProtected } from "./reader.js";
|
|
9
|
+
import { READS_FORMATS } from "./shards.js";
|
|
8
10
|
import { FileFreakError, sleep, Transport } from "./transport.js";
|
|
9
11
|
import { upload } from "./uploads.js";
|
|
10
12
|
/** How long a follow of the changes waits for one before asking again, unless told. */
|
|
@@ -57,7 +59,11 @@ export class FileFreak {
|
|
|
57
59
|
constructor(options) {
|
|
58
60
|
this.transport = new Transport(options);
|
|
59
61
|
const api = bind((operation, input, callOptions) => this.call(operation, input, callOptions));
|
|
60
|
-
this.files = {
|
|
62
|
+
this.files = {
|
|
63
|
+
...api.files,
|
|
64
|
+
each: (query = {}, callOptions) => pages(api.files.list, query, callOptions),
|
|
65
|
+
sendFrame: (id, jpeg, callOptions) => sendFrame(api.files.frame, this.transport.fetch, id, jpeg, callOptions),
|
|
66
|
+
};
|
|
61
67
|
this.folders = api.folders;
|
|
62
68
|
this.tree = { ...api.tree, walk: (query = {}, callOptions) => new Walk(api.tree.list, query, callOptions) };
|
|
63
69
|
this.changes = { ...api.changes, follow: (cursor, followOptions = {}) => follow(api.changes.list, cursor ?? null, followOptions) };
|
|
@@ -94,7 +100,7 @@ export class FileFreak {
|
|
|
94
100
|
* to storage. See UploadOptions, and UploadStopped for carrying on.
|
|
95
101
|
*/
|
|
96
102
|
upload(source, options = {}) {
|
|
97
|
-
return upload({ ...this.uploads, file: this.files.get }, this.transport.fetch, source, options);
|
|
103
|
+
return upload({ ...this.uploads, file: this.files.get, frame: this.files.frame }, this.transport.fetch, source, options);
|
|
98
104
|
}
|
|
99
105
|
/**
|
|
100
106
|
* Fetch a file straight from storage into `sink`: as up to six ranges at
|
|
@@ -134,7 +140,7 @@ export class FileFreak {
|
|
|
134
140
|
const { url } = await this.files.downloadUrl(item.id, {}, { signal });
|
|
135
141
|
await fetchRanges({ url, sizeBytes: item.sizeBytes, sink, signal, progress, ranges: options.ranges, fetch: this.transport.fetch, sleep: (ms) => sleep(ms, signal) });
|
|
136
142
|
}, () => readProtected({
|
|
137
|
-
pages: (from, pageSignal) => this.files.shards(item.id, { from }, { signal: pageSignal }),
|
|
143
|
+
pages: (from, pageSignal) => this.files.shards(item.id, { from, formats: READS_FORMATS }, { signal: pageSignal }),
|
|
138
144
|
sink,
|
|
139
145
|
sizeBytes: item.sizeBytes,
|
|
140
146
|
signal,
|
package/dist/frames.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FilesFrameAnswer } from "./generated.js";
|
|
2
|
+
import { type CallOptions } from "./transport.js";
|
|
3
|
+
/** The most the service keeps of a picture of a video. Anything larger is not made into one. */
|
|
4
|
+
export declare const FRAME_MAX_BYTES = 2000000;
|
|
5
|
+
/** Whether the service takes a picture for a file of this type and name. Its word is final: files.frame refuses any other with 409 no_frame_wanted. */
|
|
6
|
+
export declare function takesFrame(contentType: string | null, name: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Sends a JPEG of a video's frame to be its picture: asks files.frame where,
|
|
9
|
+
* and puts it there. Throws if it is not a JPEG of at most FRAME_MAX_BYTES,
|
|
10
|
+
* if the service wants none for the file (409 no_frame_wanted: not a video,
|
|
11
|
+
* protected, or given one already), or if storage refuses it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function sendFrame(frame: (id: string, options?: CallOptions) => Promise<FilesFrameAnswer>, fetcher: typeof fetch, id: string, jpeg: Blob, options?: CallOptions): Promise<void>;
|
|
14
|
+
/** Whether this is a web page, which can draw a video. */
|
|
15
|
+
export declare function canDrawFrames(): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* A frame about a second in, or halfway through a shorter video, as a JPEG,
|
|
18
|
+
* or null where this browser cannot play the video or draw it. A second in
|
|
19
|
+
* rather than the first frame, which is black more often than not.
|
|
20
|
+
*/
|
|
21
|
+
export declare function drawFrame(video: Blob): Promise<Blob | null>;
|
package/dist/frames.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// A picture of a video, for its place in a listing (an item's thumbnailUrl).
|
|
2
|
+
//
|
|
3
|
+
// The service never reads a video: making a picture of one would take a
|
|
4
|
+
// video decoder reading anybody's upload where the service's keys are.
|
|
5
|
+
// Whoever has the video in hand draws a frame and sends it, as the website
|
|
6
|
+
// does; files.frame says where, and the service makes it small and keeps
|
|
7
|
+
// it. In a web page, upload() does this itself once a video is stored, from
|
|
8
|
+
// the file the page already has, so nothing is fetched. Anywhere else, a
|
|
9
|
+
// program that can make a JPEG of a frame (with ffmpeg, say) sends it with
|
|
10
|
+
// files.sendFrame.
|
|
11
|
+
import { FileFreakError } from "./transport.js";
|
|
12
|
+
/** The most the service keeps of a picture of a video. Anything larger is not made into one. */
|
|
13
|
+
export const FRAME_MAX_BYTES = 2_000_000;
|
|
14
|
+
/** The longest side of a frame drawn here, in pixels. The service makes it smaller again. */
|
|
15
|
+
const EDGE = 960;
|
|
16
|
+
/** Long enough for a browser to load the start of a video it can play. One it cannot is given up on. */
|
|
17
|
+
const WAIT_MS = 15_000;
|
|
18
|
+
/**
|
|
19
|
+
* Videos by name, as the service knows them, for one typed as nothing in
|
|
20
|
+
* particular. Not by video/mp2t, which is how browsers type a TypeScript
|
|
21
|
+
* file (.ts).
|
|
22
|
+
*/
|
|
23
|
+
const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "webm", "mov", "mkv", "avi", "ogv"]);
|
|
24
|
+
/** Whether the service takes a picture for a file of this type and name. Its word is final: files.frame refuses any other with 409 no_frame_wanted. */
|
|
25
|
+
export function takesFrame(contentType, name) {
|
|
26
|
+
const type = (contentType ?? "").toLowerCase();
|
|
27
|
+
const dot = name.lastIndexOf(".");
|
|
28
|
+
const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
29
|
+
return (type.startsWith("video/") && type !== "video/mp2t") || VIDEO_EXTENSIONS.has(ext);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Sends a JPEG of a video's frame to be its picture: asks files.frame where,
|
|
33
|
+
* and puts it there. Throws if it is not a JPEG of at most FRAME_MAX_BYTES,
|
|
34
|
+
* if the service wants none for the file (409 no_frame_wanted: not a video,
|
|
35
|
+
* protected, or given one already), or if storage refuses it.
|
|
36
|
+
*/
|
|
37
|
+
export async function sendFrame(frame, fetcher, id, jpeg, options = {}) {
|
|
38
|
+
const start = new Uint8Array(await jpeg.slice(0, 3).arrayBuffer());
|
|
39
|
+
if (jpeg.size > FRAME_MAX_BYTES || start[0] !== 0xff || start[1] !== 0xd8 || start[2] !== 0xff) {
|
|
40
|
+
throw new TypeError(`A video's picture is a JPEG of at most ${FRAME_MAX_BYTES} bytes.`);
|
|
41
|
+
}
|
|
42
|
+
const to = await frame(id, options);
|
|
43
|
+
let res;
|
|
44
|
+
try {
|
|
45
|
+
res = await fetcher(to.url, { method: "PUT", headers: to.headers, body: jpeg, signal: options.signal });
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
if (options.signal?.aborted)
|
|
49
|
+
throw options.signal.reason;
|
|
50
|
+
throw new FileFreakError(0, "network_error", `Storage could not be reached: ${e instanceof Error ? e.message : String(e)}.`);
|
|
51
|
+
}
|
|
52
|
+
await res.body?.cancel().catch(() => undefined);
|
|
53
|
+
if (!res.ok)
|
|
54
|
+
throw new FileFreakError(res.status, "storage_refused", `Storage refused the picture with ${res.status}.`);
|
|
55
|
+
}
|
|
56
|
+
/** The page this runs in, or null outside a web page: in Node, or in a worker. */
|
|
57
|
+
function page() {
|
|
58
|
+
const document = globalThis.document;
|
|
59
|
+
return typeof document?.createElement === "function" ? document : null;
|
|
60
|
+
}
|
|
61
|
+
/** Whether this is a web page, which can draw a video. */
|
|
62
|
+
export function canDrawFrames() {
|
|
63
|
+
return page() !== null && typeof URL.createObjectURL === "function";
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A frame about a second in, or halfway through a shorter video, as a JPEG,
|
|
67
|
+
* or null where this browser cannot play the video or draw it. A second in
|
|
68
|
+
* rather than the first frame, which is black more often than not.
|
|
69
|
+
*/
|
|
70
|
+
export async function drawFrame(video) {
|
|
71
|
+
const document = page();
|
|
72
|
+
if (!document)
|
|
73
|
+
return null;
|
|
74
|
+
const element = document.createElement("video");
|
|
75
|
+
const src = URL.createObjectURL(video);
|
|
76
|
+
element.muted = true;
|
|
77
|
+
element.playsInline = true;
|
|
78
|
+
element.preload = "auto";
|
|
79
|
+
try {
|
|
80
|
+
await within(new Promise((resolve, reject) => {
|
|
81
|
+
element.addEventListener("loadedmetadata", () => resolve(), { once: true });
|
|
82
|
+
element.addEventListener("error", () => reject(new Error("cannot play")), { once: true });
|
|
83
|
+
element.src = src;
|
|
84
|
+
}));
|
|
85
|
+
const at = Number.isFinite(element.duration) && element.duration > 0 ? Math.min(1, element.duration / 2) : 0;
|
|
86
|
+
await within(new Promise((resolve, reject) => {
|
|
87
|
+
element.addEventListener("seeked", () => resolve(), { once: true });
|
|
88
|
+
element.addEventListener("error", () => reject(new Error("cannot seek")), { once: true });
|
|
89
|
+
element.currentTime = at;
|
|
90
|
+
}));
|
|
91
|
+
if (!element.videoWidth || !element.videoHeight)
|
|
92
|
+
return null;
|
|
93
|
+
const scale = Math.min(1, EDGE / Math.max(element.videoWidth, element.videoHeight));
|
|
94
|
+
const canvas = document.createElement("canvas");
|
|
95
|
+
canvas.width = Math.max(1, Math.round(element.videoWidth * scale));
|
|
96
|
+
canvas.height = Math.max(1, Math.round(element.videoHeight * scale));
|
|
97
|
+
const context = canvas.getContext("2d");
|
|
98
|
+
if (!context)
|
|
99
|
+
return null;
|
|
100
|
+
context.drawImage(element, 0, 0, canvas.width, canvas.height);
|
|
101
|
+
return await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.85));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// A video this browser cannot play.
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
element.removeAttribute("src");
|
|
109
|
+
element.load();
|
|
110
|
+
URL.revokeObjectURL(src);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function within(work) {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
const timer = setTimeout(() => reject(new Error("too slow")), WAIT_MS);
|
|
116
|
+
work.then((value) => {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
resolve(value);
|
|
119
|
+
}, (e) => {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
package/dist/generated.d.ts
CHANGED
|
@@ -23,6 +23,10 @@ export interface Item {
|
|
|
23
23
|
revision: number;
|
|
24
24
|
/** The SHA-256 the uploader gave when it completed the upload of this content, in lower-case hex, or null. The uploader's word: this service never sees a file's bytes and does not check it. */
|
|
25
25
|
clientSha256: string | null;
|
|
26
|
+
/** A small picture of the file, as WebP no more than 480 pixels on its longest side, once the service has made one: of an image, a photo from an iPhone, a PDF's first page, the cover in an audio file's tags, or a frame of a video, sent with files.frame. Made within a few minutes of an upload. The address is on storage itself and needs no key. It is the same for the rest of the day, so that it can be cached, and works for a day after that. Fetching it counts as nothing. Null for a folder, and for a file with none. */
|
|
27
|
+
thumbnailUrl: string | null;
|
|
28
|
+
/** The first lines of a text or code file, a Word or OpenDocument text, a deck's first slide or the top left of a spreadsheet's first sheet, as UTF-8 text, once the service has made them, at an address like thumbnailUrl's. Never of a file whose name says it holds keys or passwords. Null otherwise. */
|
|
29
|
+
previewTextUrl: string | null;
|
|
26
30
|
/** Whether the file is under Shard Protection: kept as sealed fragments, and read through its map (files.shards) rather than from an address. */
|
|
27
31
|
protected: boolean;
|
|
28
32
|
/** Its own Shard Protection setting. inherit follows the nearest folder above it that has one, and with none the drive's default: the app's, which its owner turns on from the app's page, or for a device the account's. The nearest setting wins. A file is converted within a few minutes of a change, and protected says when it has been. */
|
|
@@ -153,11 +157,13 @@ export interface SendTo {
|
|
|
153
157
|
}
|
|
154
158
|
/** A page of a protected file's map: its key, and where each fragment is. */
|
|
155
159
|
export interface ShardMap {
|
|
156
|
-
/** The content's own id, which each fragment's check is bound to with its place and the count. */
|
|
160
|
+
/** The content's own id, which each fragment's check is bound to with its place and the count, and in version 2 the outline. */
|
|
157
161
|
contentId: string;
|
|
162
|
+
/** The version of the shard format the file is written in: 1, or 2, in which the file is cut into blocks by its content and its next version keeps the blocks that did not change. Only a version named in formats is answered. */
|
|
163
|
+
format: number;
|
|
158
164
|
/** The file's size. */
|
|
159
165
|
sizeBytes: number;
|
|
160
|
-
/** How many bytes of the file each fragment holds, except the last, which holds what remains. */
|
|
166
|
+
/** How many bytes of the file each fragment holds, except the last, which holds what remains. In version 2, the largest block's: each block's own length is in the outline. */
|
|
161
167
|
fragmentBytes: number;
|
|
162
168
|
/** How many fragments the file is. */
|
|
163
169
|
count: number;
|
|
@@ -167,11 +173,23 @@ export interface ShardMap {
|
|
|
167
173
|
contentType: string | null;
|
|
168
174
|
/** The file's revision whose content this is. Its fragments never change, however late they are read. */
|
|
169
175
|
revision: number;
|
|
176
|
+
/** Version 2, on the first page: every block of the file and the MAC over them. Null on a later page, and in version 1. */
|
|
177
|
+
outline: {
|
|
178
|
+
/** The MAC over the content id, the size and every block's id and length, in base64. Check it with the file's key, as the SDK's checkOutline does, before using a byte of the file. */
|
|
179
|
+
mac: string;
|
|
180
|
+
/** Every block of the file, in order of place. */
|
|
181
|
+
blocks: Array<{
|
|
182
|
+
/** The block's id in base64: 32 bytes, the HMAC of its bytes under the file's key. Each block is checked against it once opened. */
|
|
183
|
+
id: string;
|
|
184
|
+
/** How many bytes of the file the block holds. */
|
|
185
|
+
length: number;
|
|
186
|
+
}>;
|
|
187
|
+
} | null;
|
|
170
188
|
/** The first place on this page. */
|
|
171
189
|
from: number;
|
|
172
190
|
/** Up to 1000, in order of place. */
|
|
173
191
|
fragments: Array<{
|
|
174
|
-
/** Counted from zero.
|
|
192
|
+
/** Counted from zero. In version 1 its bytes of the file begin at place times fragmentBytes; in version 2 after the blocks before it in the outline. */
|
|
175
193
|
place: number;
|
|
176
194
|
/** Where to fetch the stored fragment, with a plain GET and no key: the address is the permission. */
|
|
177
195
|
url: string;
|
|
@@ -310,6 +328,8 @@ export type UploadsBeginBody = {
|
|
|
310
328
|
}>;
|
|
311
329
|
/** true when this program can seal fragments as it sends them, as the SDK and the CLI do. Into a protected place the answer's shards is then set, and each part is a fragment. */
|
|
312
330
|
protectable?: boolean;
|
|
331
|
+
/** With protectable: the versions of the shard format this program can write. Left out, 1 alone. With 2, a sealed file of more than 16 MiB is written in version 2. */
|
|
332
|
+
shardFormats?: Array<number>;
|
|
313
333
|
};
|
|
314
334
|
/** What POST /v1/uploads answers with. */
|
|
315
335
|
export type UploadsBeginAnswer = {
|
|
@@ -320,11 +340,13 @@ export type UploadsBeginAnswer = {
|
|
|
320
340
|
reused: Array<number>;
|
|
321
341
|
/** Set when the upload is sealed as it is sent: how the file is cut into fragments. The key to seal them with is given by uploads.get. Null for an upload sent as it is. */
|
|
322
342
|
shards: {
|
|
323
|
-
/** The
|
|
343
|
+
/** The version of the shard format to write it in: 1, or 2 when shardFormats names it and the file is large enough. */
|
|
344
|
+
format: number;
|
|
345
|
+
/** The content's own id, which each fragment's seal is bound to with its place and the count, and in version 2 the outline. */
|
|
324
346
|
contentId: string;
|
|
325
|
-
/** How many bytes of the file each fragment holds, except the last, which holds what remains. */
|
|
347
|
+
/** How many bytes of the file each fragment holds, except the last, which holds what remains. In version 2, 0: the blocks are declared with uploads.blocks. */
|
|
326
348
|
fragmentBytes: number;
|
|
327
|
-
/** How many fragments, one to each part. */
|
|
349
|
+
/** How many fragments, one to each part. In version 2, 0 until the blocks are declared. */
|
|
328
350
|
count: number;
|
|
329
351
|
} | null;
|
|
330
352
|
};
|
|
@@ -342,16 +364,42 @@ export type UploadsGetAnswer = {
|
|
|
342
364
|
reused: Array<number>;
|
|
343
365
|
/** For an upload sealed as it is sent: how the file is cut, and the key to seal each fragment with. Null for an upload sent as it is, and for one that has ended. */
|
|
344
366
|
shards: {
|
|
345
|
-
/** The
|
|
367
|
+
/** The version of the shard format to write it in. */
|
|
368
|
+
format: number;
|
|
369
|
+
/** The content's own id, which each fragment's seal is bound to with its place and the count, and in version 2 the outline. */
|
|
346
370
|
contentId: string;
|
|
347
371
|
/** The file's own key, in base64: 32 bytes, which open its fragments. In this answer and nowhere else. */
|
|
348
372
|
key: string;
|
|
349
|
-
/** How many bytes of the file each fragment holds, except the last, which holds what remains. */
|
|
373
|
+
/** How many bytes of the file each fragment holds, except the last, which holds what remains. In version 2, the largest block's once they are declared, and 0 before. */
|
|
350
374
|
fragmentBytes: number;
|
|
351
|
-
/** How many fragments, one to each part. */
|
|
375
|
+
/** How many fragments, one to each part. In version 2, how many blocks once they are declared, and 0 before. */
|
|
352
376
|
count: number;
|
|
377
|
+
/** Version 2: the blocks as declared, in order, or null until they are. Null in version 1. */
|
|
378
|
+
blocks: Array<{
|
|
379
|
+
size: number;
|
|
380
|
+
/** Its id, in base64. */
|
|
381
|
+
id: string;
|
|
382
|
+
}> | null;
|
|
353
383
|
} | null;
|
|
354
384
|
};
|
|
385
|
+
/** What POST /v1/uploads/:id/blocks takes as its body. */
|
|
386
|
+
export type UploadsBlocksBody = {
|
|
387
|
+
/** Every block of the file, in order. */
|
|
388
|
+
blocks: Array<{
|
|
389
|
+
/** How many bytes of the file the block holds. */
|
|
390
|
+
size: number;
|
|
391
|
+
/** The block's id, in base64: 32 bytes. */
|
|
392
|
+
id: string;
|
|
393
|
+
}>;
|
|
394
|
+
};
|
|
395
|
+
/** What POST /v1/uploads/:id/blocks answers with. */
|
|
396
|
+
export type UploadsBlocksAnswer = {
|
|
397
|
+
upload: Upload;
|
|
398
|
+
/** The places whose block the file has already, under its key. Never sent, and counted as arrived. */
|
|
399
|
+
reused: Array<number>;
|
|
400
|
+
/** Addresses for the first blocks to send, each at its place. Places in reused are not among them. */
|
|
401
|
+
parts: Array<Part>;
|
|
402
|
+
};
|
|
355
403
|
/** What GET /v1/uploads/:id/parts takes in its query. */
|
|
356
404
|
export type UploadsPartsQuery = {
|
|
357
405
|
/** The first part wanted, counted from zero. */
|
|
@@ -380,6 +428,8 @@ export type UploadsFragmentsAnswer = {
|
|
|
380
428
|
export type UploadsCompleteBody = {
|
|
381
429
|
/** The file's SHA-256 in hex, as you worked it out. Kept with the file as your word, since this service never sees the bytes, and given back as clientSha256, so a copy can be checked later without reading the file again. */
|
|
382
430
|
sha256?: string;
|
|
431
|
+
/** For an upload written in version 2 of the shard format, and required for one: the outline's MAC in base64, as the SDK's outlineMac makes it from the content id, the size and the declared blocks. Checked with the file's key. */
|
|
432
|
+
outline?: string;
|
|
383
433
|
};
|
|
384
434
|
/** What POST /v1/uploads/:id/complete answers with. */
|
|
385
435
|
export type UploadsCompleteAnswer = {
|
|
@@ -387,6 +437,15 @@ export type UploadsCompleteAnswer = {
|
|
|
387
437
|
} | Pending;
|
|
388
438
|
/** What DELETE /v1/uploads/:id answers with. */
|
|
389
439
|
export type UploadsCancelAnswer = Ok;
|
|
440
|
+
/** What POST /v1/files/:id/frame answers with. */
|
|
441
|
+
export type FilesFrameAnswer = {
|
|
442
|
+
/** Where to PUT the JPEG. Needs no key: the address is the permission. */
|
|
443
|
+
url: string;
|
|
444
|
+
/** Headers the request must carry. */
|
|
445
|
+
headers: Record<string, string>;
|
|
446
|
+
/** When the address stops working. */
|
|
447
|
+
expiresAt: string;
|
|
448
|
+
};
|
|
390
449
|
/** What GET /v1/files/:id/download takes in its query. */
|
|
391
450
|
export type FilesDownloadUrlQuery = {
|
|
392
451
|
/** true to have a browser show it, for the types that are safe to show. Anything else is saved, whatever is asked. */
|
|
@@ -404,6 +463,8 @@ export type FilesDownloadUrlAnswer = {
|
|
|
404
463
|
export type FilesShardsQuery = {
|
|
405
464
|
/** The first fragment wanted, counted from zero: 0, or the `next` of the page before. */
|
|
406
465
|
from?: number;
|
|
466
|
+
/** The versions of the shard format this program reads, as 1,2. Left out, 1 alone. */
|
|
467
|
+
formats?: string;
|
|
407
468
|
};
|
|
408
469
|
/** What GET /v1/files/:id/shards answers with. */
|
|
409
470
|
export type FilesShardsAnswer = ShardMap;
|
|
@@ -676,6 +737,12 @@ export declare const OPERATIONS: {
|
|
|
676
737
|
readonly idempotent: false;
|
|
677
738
|
readonly repeatable: false;
|
|
678
739
|
};
|
|
740
|
+
readonly "uploads.blocks": {
|
|
741
|
+
readonly method: "POST";
|
|
742
|
+
readonly path: "/v1/uploads/:id/blocks";
|
|
743
|
+
readonly idempotent: false;
|
|
744
|
+
readonly repeatable: true;
|
|
745
|
+
};
|
|
679
746
|
readonly "uploads.parts": {
|
|
680
747
|
readonly method: "GET";
|
|
681
748
|
readonly path: "/v1/uploads/:id/parts";
|
|
@@ -700,6 +767,12 @@ export declare const OPERATIONS: {
|
|
|
700
767
|
readonly idempotent: false;
|
|
701
768
|
readonly repeatable: false;
|
|
702
769
|
};
|
|
770
|
+
readonly "files.frame": {
|
|
771
|
+
readonly method: "POST";
|
|
772
|
+
readonly path: "/v1/files/:id/frame";
|
|
773
|
+
readonly idempotent: false;
|
|
774
|
+
readonly repeatable: false;
|
|
775
|
+
};
|
|
703
776
|
readonly "files.downloadUrl": {
|
|
704
777
|
readonly method: "GET";
|
|
705
778
|
readonly path: "/v1/files/:id/download";
|
|
@@ -870,6 +943,14 @@ export declare function bind(call: Call): {
|
|
|
870
943
|
* POST /v1/files/:id/restore. Needs the scope files:write.
|
|
871
944
|
*/
|
|
872
945
|
restore: (id: string, options?: CallOptions) => Promise<FilesRestoreAnswer>;
|
|
946
|
+
/**
|
|
947
|
+
* Give a video its picture.
|
|
948
|
+
*
|
|
949
|
+
* The service never reads a video, so a video's picture (its thumbnailUrl) is a frame of it that a program sends. This answers where to send one: PUT a JPEG of at most 2 MB to url, with headers, within 2 minutes, so have it ready before asking. The service makes it small and keeps it, and the video's thumbnailUrl gives it within a few minutes. Anything else sent is not made into a picture. Once for each video: one that has been sent a picture, or was given an address for one in the last few minutes, is answered with 409 no_frame_wanted. In a web page, the SDK's upload draws a frame about a second in and sends it itself; anywhere else, the SDK's files.sendFrame sends a JPEG made by anything that can make one, ffmpeg for one. A file under Shard Protection is given no picture, and is answered with 409 no_frame_wanted.
|
|
950
|
+
*
|
|
951
|
+
* POST /v1/files/:id/frame. Needs the scope files:write.
|
|
952
|
+
*/
|
|
953
|
+
frame: (id: string, options?: CallOptions) => Promise<FilesFrameAnswer>;
|
|
873
954
|
/**
|
|
874
955
|
* An address to download a file from.
|
|
875
956
|
*
|
|
@@ -883,6 +964,8 @@ export declare function bind(call: Call): {
|
|
|
883
964
|
*
|
|
884
965
|
* A file under Shard Protection is kept as fragments, each sealed with the file's own key and stored across several storage accounts. Its map says where each fragment is, up to 1,000 at a time, with the key. Fetch each fragment from its address (no key: the address is the permission), check and open it as the SDK's openFragment does, and write it at its place. The first page counts the file's size toward the month's downloads; later pages count nothing. Addresses work for four hours: before then, ask for the page again from the first fragment not yet fetched. The key is in this answer and nowhere else. This API never keeps it, and neither should a program, beyond the read.
|
|
885
966
|
*
|
|
967
|
+
* A file in version 2 of the shard format (format 2) is kept as blocks of their own lengths, cut by its content. The first page carries its outline: check it with the key before using a byte, open each block as the SDK's openBlock does, and write it after the blocks before it. A program says which versions it reads with formats, and a file in another is answered with 409 format_unsupported, so a program made before version 2 is told to update rather than failing on the file.
|
|
968
|
+
*
|
|
886
969
|
* GET /v1/files/:id/shards. Needs the scope files:read.
|
|
887
970
|
*/
|
|
888
971
|
shards: (id: string, query?: FilesShardsQuery, options?: CallOptions) => Promise<FilesShardsAnswer>;
|
|
@@ -939,6 +1022,8 @@ export declare function bind(call: Call): {
|
|
|
939
1022
|
*
|
|
940
1023
|
* Into a place under Shard Protection, an upload begun with protectable true is sealed as it is sent. A place is protected by the setting nearest it (files.update's protection), or with none by the drive's default: an app's, which its owner turns on from the app's page, or for a device the account's. The answer's shards then says how the file is cut, and uploads.get gives its key. Each part is a fragment: the file's bytes from its number times fragmentBytes, sealed with that key as the SDK's sealFragment does, under a nonce of its own, and sent to the part's address. uploads.fragments confirms them, and completing makes the file, protected from its first byte, with a null clientSha256. A sealed upload sets blocks aside: none is kept, and none reused. Begun without protectable, a file lands as an ordinary file and the service protects it within a few minutes; from then on it is read through its map (files.shards).
|
|
941
1024
|
*
|
|
1025
|
+
* A program that writes version 2 of the shard format says so with shardFormats. A sealed file of more than 16 MiB is then written in it: shards says format 2, with no fragments and no parts yet. Get the key from uploads.get, cut the file into blocks by its content with it, and declare them with uploads.blocks, which answers with the parts to send. Replacing a file whose content is in version 2, with a key that may read the file, the key is the file's own, and the blocks the file has already are not sent again. Complete it with outline, the outline's MAC.
|
|
1026
|
+
*
|
|
942
1027
|
* POST /v1/uploads. Needs the scope files:write. Honours an Idempotency-Key.
|
|
943
1028
|
*/
|
|
944
1029
|
begin: (body: UploadsBeginBody, options?: CallOptions) => Promise<UploadsBeginAnswer>;
|
|
@@ -950,6 +1035,14 @@ export declare function bind(call: Call): {
|
|
|
950
1035
|
* GET /v1/uploads/:id. Needs the scope files:write.
|
|
951
1036
|
*/
|
|
952
1037
|
get: (id: string, options?: CallOptions) => Promise<UploadsGetAnswer>;
|
|
1038
|
+
/**
|
|
1039
|
+
* Declare a sealed upload's blocks.
|
|
1040
|
+
*
|
|
1041
|
+
* For an upload written in version 2 of the shard format (its shards say format 2): the blocks the file is cut into by its content with the file's key, in order, each its size and its id, the HMAC of its bytes under the file's key. From 2 to 10,000 blocks of 1 byte to 64 MiB, adding up to sizeBytes. Answers with the places not to send, whose block the file has already under its key, and addresses for the first of the rest: ask for more with uploads.parts, and confirm them with uploads.fragments. Each part is its block, sealed as the SDK's sealBlock does. Declared once: the same blocks again are answered the same, and other blocks are refused with 409 blocks_declared.
|
|
1042
|
+
*
|
|
1043
|
+
* POST /v1/uploads/:id/blocks. Needs the scope files:write. Safe to send again.
|
|
1044
|
+
*/
|
|
1045
|
+
blocks: (id: string, body: UploadsBlocksBody, options?: CallOptions) => Promise<UploadsBlocksAnswer>;
|
|
953
1046
|
/**
|
|
954
1047
|
* Addresses for more parts.
|
|
955
1048
|
*
|
package/dist/generated.js
CHANGED
|
@@ -16,10 +16,12 @@ export const OPERATIONS = {
|
|
|
16
16
|
"changes.list": { method: "GET", path: "/v1/changes", idempotent: false, repeatable: false },
|
|
17
17
|
"uploads.begin": { method: "POST", path: "/v1/uploads", idempotent: true, repeatable: false },
|
|
18
18
|
"uploads.get": { method: "GET", path: "/v1/uploads/:id", idempotent: false, repeatable: false },
|
|
19
|
+
"uploads.blocks": { method: "POST", path: "/v1/uploads/:id/blocks", idempotent: false, repeatable: true },
|
|
19
20
|
"uploads.parts": { method: "GET", path: "/v1/uploads/:id/parts", idempotent: false, repeatable: false },
|
|
20
21
|
"uploads.fragments": { method: "POST", path: "/v1/uploads/:id/fragments", idempotent: false, repeatable: true },
|
|
21
22
|
"uploads.complete": { method: "POST", path: "/v1/uploads/:id/complete", idempotent: false, repeatable: true },
|
|
22
23
|
"uploads.cancel": { method: "DELETE", path: "/v1/uploads/:id", idempotent: false, repeatable: false },
|
|
24
|
+
"files.frame": { method: "POST", path: "/v1/files/:id/frame", idempotent: false, repeatable: false },
|
|
23
25
|
"files.downloadUrl": { method: "GET", path: "/v1/files/:id/download", idempotent: false, repeatable: false },
|
|
24
26
|
"files.shards": { method: "GET", path: "/v1/files/:id/shards", idempotent: false, repeatable: false },
|
|
25
27
|
"files.image": { method: "GET", path: "/v1/files/:id/image", idempotent: false, repeatable: false },
|
|
@@ -93,6 +95,14 @@ export function bind(call) {
|
|
|
93
95
|
* POST /v1/files/:id/restore. Needs the scope files:write.
|
|
94
96
|
*/
|
|
95
97
|
restore: (id, options) => call("files.restore", { params: { id } }, options),
|
|
98
|
+
/**
|
|
99
|
+
* Give a video its picture.
|
|
100
|
+
*
|
|
101
|
+
* The service never reads a video, so a video's picture (its thumbnailUrl) is a frame of it that a program sends. This answers where to send one: PUT a JPEG of at most 2 MB to url, with headers, within 2 minutes, so have it ready before asking. The service makes it small and keeps it, and the video's thumbnailUrl gives it within a few minutes. Anything else sent is not made into a picture. Once for each video: one that has been sent a picture, or was given an address for one in the last few minutes, is answered with 409 no_frame_wanted. In a web page, the SDK's upload draws a frame about a second in and sends it itself; anywhere else, the SDK's files.sendFrame sends a JPEG made by anything that can make one, ffmpeg for one. A file under Shard Protection is given no picture, and is answered with 409 no_frame_wanted.
|
|
102
|
+
*
|
|
103
|
+
* POST /v1/files/:id/frame. Needs the scope files:write.
|
|
104
|
+
*/
|
|
105
|
+
frame: (id, options) => call("files.frame", { params: { id } }, options),
|
|
96
106
|
/**
|
|
97
107
|
* An address to download a file from.
|
|
98
108
|
*
|
|
@@ -106,6 +116,8 @@ export function bind(call) {
|
|
|
106
116
|
*
|
|
107
117
|
* A file under Shard Protection is kept as fragments, each sealed with the file's own key and stored across several storage accounts. Its map says where each fragment is, up to 1,000 at a time, with the key. Fetch each fragment from its address (no key: the address is the permission), check and open it as the SDK's openFragment does, and write it at its place. The first page counts the file's size toward the month's downloads; later pages count nothing. Addresses work for four hours: before then, ask for the page again from the first fragment not yet fetched. The key is in this answer and nowhere else. This API never keeps it, and neither should a program, beyond the read.
|
|
108
118
|
*
|
|
119
|
+
* A file in version 2 of the shard format (format 2) is kept as blocks of their own lengths, cut by its content. The first page carries its outline: check it with the key before using a byte, open each block as the SDK's openBlock does, and write it after the blocks before it. A program says which versions it reads with formats, and a file in another is answered with 409 format_unsupported, so a program made before version 2 is told to update rather than failing on the file.
|
|
120
|
+
*
|
|
109
121
|
* GET /v1/files/:id/shards. Needs the scope files:read.
|
|
110
122
|
*/
|
|
111
123
|
shards: (id, query, options) => call("files.shards", { params: { id }, query }, options),
|
|
@@ -162,6 +174,8 @@ export function bind(call) {
|
|
|
162
174
|
*
|
|
163
175
|
* Into a place under Shard Protection, an upload begun with protectable true is sealed as it is sent. A place is protected by the setting nearest it (files.update's protection), or with none by the drive's default: an app's, which its owner turns on from the app's page, or for a device the account's. The answer's shards then says how the file is cut, and uploads.get gives its key. Each part is a fragment: the file's bytes from its number times fragmentBytes, sealed with that key as the SDK's sealFragment does, under a nonce of its own, and sent to the part's address. uploads.fragments confirms them, and completing makes the file, protected from its first byte, with a null clientSha256. A sealed upload sets blocks aside: none is kept, and none reused. Begun without protectable, a file lands as an ordinary file and the service protects it within a few minutes; from then on it is read through its map (files.shards).
|
|
164
176
|
*
|
|
177
|
+
* A program that writes version 2 of the shard format says so with shardFormats. A sealed file of more than 16 MiB is then written in it: shards says format 2, with no fragments and no parts yet. Get the key from uploads.get, cut the file into blocks by its content with it, and declare them with uploads.blocks, which answers with the parts to send. Replacing a file whose content is in version 2, with a key that may read the file, the key is the file's own, and the blocks the file has already are not sent again. Complete it with outline, the outline's MAC.
|
|
178
|
+
*
|
|
165
179
|
* POST /v1/uploads. Needs the scope files:write. Honours an Idempotency-Key.
|
|
166
180
|
*/
|
|
167
181
|
begin: (body, options) => call("uploads.begin", { body }, options),
|
|
@@ -173,6 +187,14 @@ export function bind(call) {
|
|
|
173
187
|
* GET /v1/uploads/:id. Needs the scope files:write.
|
|
174
188
|
*/
|
|
175
189
|
get: (id, options) => call("uploads.get", { params: { id } }, options),
|
|
190
|
+
/**
|
|
191
|
+
* Declare a sealed upload's blocks.
|
|
192
|
+
*
|
|
193
|
+
* For an upload written in version 2 of the shard format (its shards say format 2): the blocks the file is cut into by its content with the file's key, in order, each its size and its id, the HMAC of its bytes under the file's key. From 2 to 10,000 blocks of 1 byte to 64 MiB, adding up to sizeBytes. Answers with the places not to send, whose block the file has already under its key, and addresses for the first of the rest: ask for more with uploads.parts, and confirm them with uploads.fragments. Each part is its block, sealed as the SDK's sealBlock does. Declared once: the same blocks again are answered the same, and other blocks are refused with 409 blocks_declared.
|
|
194
|
+
*
|
|
195
|
+
* POST /v1/uploads/:id/blocks. Needs the scope files:write. Safe to send again.
|
|
196
|
+
*/
|
|
197
|
+
blocks: (id, body, options) => call("uploads.blocks", { params: { id }, body }, options),
|
|
176
198
|
/**
|
|
177
199
|
* Addresses for more parts.
|
|
178
200
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { FileFreak, Walk, type DownloadOptions, type FollowOptions } from "./client.js";
|
|
2
2
|
export * from "./generated.js";
|
|
3
|
+
export { FRAME_MAX_BYTES, takesFrame } from "./frames.js";
|
|
3
4
|
export { get, type GetOptions, type Got, type Into, type LinkedFile } from "./links.js";
|
|
4
5
|
export { type Sink } from "./ranges.js";
|
|
5
|
-
export { checkOutline, fileKeys, MOST_BLOCK_BYTES, openBlock, openFragment, outlineMac, sealBlock, sealFragment, SHARD_OVERHEAD, SHARD_VERSION, SHARD_VERSION_2, ShardError, shardKey, ShardLayout, type FileKeys, type FragmentAt, type Outline, type OutlineBlock, type ShardErrorCode, type ShardKey, } from "./shards.js";
|
|
6
|
+
export { BlockLayout, checkOutline, fileKeys, MOST_BLOCK_BYTES, openBlock, openFragment, outlineMac, READS_FORMATS, sealBlock, sealFragment, SHARD_OVERHEAD, SHARD_VERSION, SHARD_VERSION_2, ShardError, shardKey, ShardLayout, type FileKeys, type FragmentAt, type Layout, type Outline, type OutlineBlock, type ShardErrorCode, type ShardKey, } from "./shards.js";
|
|
6
7
|
export { FileFreakError, type CallOptions, type ClientOptions, type TokenSource } from "./transport.js";
|
|
7
8
|
export { UploadStopped, type Source, type UploadOptions } from "./uploads.js";
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// await get("https://filefreak.link/share/...", toFolder("downloads"));
|
|
12
12
|
export { FileFreak, Walk } from "./client.js";
|
|
13
13
|
export * from "./generated.js";
|
|
14
|
+
export { FRAME_MAX_BYTES, takesFrame } from "./frames.js";
|
|
14
15
|
export { get } from "./links.js";
|
|
15
|
-
export { checkOutline, fileKeys, MOST_BLOCK_BYTES, openBlock, openFragment, outlineMac, sealBlock, sealFragment, SHARD_OVERHEAD, SHARD_VERSION, SHARD_VERSION_2, ShardError, shardKey, ShardLayout, } from "./shards.js";
|
|
16
|
+
export { BlockLayout, checkOutline, fileKeys, MOST_BLOCK_BYTES, openBlock, openFragment, outlineMac, READS_FORMATS, sealBlock, sealFragment, SHARD_OVERHEAD, SHARD_VERSION, SHARD_VERSION_2, ShardError, shardKey, ShardLayout, } from "./shards.js";
|
|
16
17
|
export { FileFreakError } from "./transport.js";
|
|
17
18
|
export { UploadStopped } from "./uploads.js";
|
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 {
|
|
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
|
-
|
|
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
|
|
135
|
-
*
|
|
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 (
|
|
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
|
-
|
|
147
|
-
|
|
158
|
+
/** Base64 from the map, as bytes, or the map is unreadable. */
|
|
159
|
+
function bytesOf(text, what) {
|
|
148
160
|
try {
|
|
149
|
-
|
|
161
|
+
return Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
|
|
150
162
|
}
|
|
151
163
|
catch {
|
|
152
|
-
throw unreadable(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
256
|
+
pieces;
|
|
220
257
|
contentId;
|
|
221
|
-
|
|
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,
|
|
272
|
+
constructor(options, stop, pieces, contentId, format) {
|
|
235
273
|
this.options = options;
|
|
236
274
|
this.stop = stop;
|
|
237
|
-
this.
|
|
275
|
+
this.pieces = pieces;
|
|
238
276
|
this.contentId = contentId;
|
|
239
|
-
this.
|
|
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 ||
|
|
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
|
|
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,4 @@
|
|
|
1
|
-
import type { FilesGetAnswer, Item, Upload, UploadsBeginAnswer, UploadsBeginBody, UploadsCompleteAnswer, UploadsCompleteBody, UploadsFragmentsAnswer, UploadsFragmentsBody, UploadsGetAnswer, UploadsPartsAnswer, UploadsPartsQuery } from "./generated.js";
|
|
1
|
+
import type { FilesFrameAnswer, FilesGetAnswer, Item, Upload, UploadsBeginAnswer, UploadsBeginBody, UploadsCompleteAnswer, UploadsCompleteBody, UploadsFragmentsAnswer, UploadsFragmentsBody, UploadsGetAnswer, UploadsPartsAnswer, UploadsPartsQuery } from "./generated.js";
|
|
2
2
|
import { type CallOptions } from "./transport.js";
|
|
3
3
|
/** What can be uploaded. A File's name and type are used when none are given. */
|
|
4
4
|
export type Source = Blob | ArrayBuffer | Uint8Array;
|
|
@@ -34,6 +34,13 @@ export interface UploadOptions {
|
|
|
34
34
|
signal?: AbortSignal;
|
|
35
35
|
/** For beginning: the caller's own key, so that a begin sent again makes one upload. */
|
|
36
36
|
idempotencyKey?: string;
|
|
37
|
+
/**
|
|
38
|
+
* In a web page, a video is given its picture in listings (its
|
|
39
|
+
* thumbnailUrl): a frame about a second in, drawn from the file here and
|
|
40
|
+
* sent once it is stored, without the upload waiting for it. false sends
|
|
41
|
+
* none. Anywhere else nothing is drawn: send one with files.sendFrame.
|
|
42
|
+
*/
|
|
43
|
+
frame?: boolean;
|
|
37
44
|
}
|
|
38
45
|
/** An upload that stopped part way. What arrived stays in storage until the upload expires: carry on with `resume: uploadId`, or cancel it. */
|
|
39
46
|
export declare class UploadStopped extends Error {
|
|
@@ -48,6 +55,7 @@ export interface UploadCalls {
|
|
|
48
55
|
fragments(id: string, body: UploadsFragmentsBody, options?: CallOptions): Promise<UploadsFragmentsAnswer>;
|
|
49
56
|
complete(id: string, body?: UploadsCompleteBody, options?: CallOptions): Promise<UploadsCompleteAnswer>;
|
|
50
57
|
file(id: string, options?: CallOptions): Promise<FilesGetAnswer>;
|
|
58
|
+
frame(id: string, options?: CallOptions): Promise<FilesFrameAnswer>;
|
|
51
59
|
}
|
|
52
60
|
/** The part size to ask for: 32 MiB, halved while that would leave fewer parts than are sent at once. */
|
|
53
61
|
export declare function partSizeFor(fileBytes: number, atOnce: number): number;
|
package/dist/uploads.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// Carrying on, every fragment not recorded is confirmed before it is sent
|
|
31
31
|
// again, since storage may hold it already. A fragment is up to 64 MiB and
|
|
32
32
|
// sealed in memory, so the memory a sealed upload needs is the same.
|
|
33
|
+
import { canDrawFrames, drawFrame, sendFrame, takesFrame } from "./frames.js";
|
|
33
34
|
import { base64, Md5 } from "./md5.js";
|
|
34
35
|
import { sealFragment, ShardError, shardKey, ShardLayout } from "./shards.js";
|
|
35
36
|
import { FileFreakError, sleep } from "./transport.js";
|
|
@@ -170,16 +171,25 @@ export async function upload(calls, fetcher, source, options = {}) {
|
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
173
|
options.onBegun?.(job.upload);
|
|
174
|
+
let item;
|
|
173
175
|
try {
|
|
174
176
|
tally(job, options);
|
|
175
177
|
await deliver(calls, fetcher, job, atOnce, options);
|
|
176
|
-
|
|
178
|
+
item = await finish(calls, fetcher, job, atOnce, options);
|
|
177
179
|
}
|
|
178
180
|
catch (e) {
|
|
179
181
|
if (signal?.aborted)
|
|
180
182
|
throw signal.reason;
|
|
181
183
|
throw new UploadStopped(job.upload.id, e);
|
|
182
184
|
}
|
|
185
|
+
if (options.frame !== false && source instanceof Blob && !item.protected && takesFrame(item.contentType, item.name) && canDrawFrames()) {
|
|
186
|
+
// Not waited for, and nothing said if it fails: the file is stored, and
|
|
187
|
+
// without a picture a video is listed as it was before there were any.
|
|
188
|
+
void drawFrame(source)
|
|
189
|
+
.then((jpeg) => (jpeg ? sendFrame(calls.frame, fetcher, item.id, jpeg) : undefined))
|
|
190
|
+
.catch(() => undefined);
|
|
191
|
+
}
|
|
192
|
+
return item;
|
|
183
193
|
}
|
|
184
194
|
function tally(job, options) {
|
|
185
195
|
let sent = 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@filefreak.io/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|