@filefreak.io/sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/links.js ADDED
@@ -0,0 +1,355 @@
1
+ // Saving what a share link or a public page holds, with no key: one file, or
2
+ // a folder and everything in it (docs/reference/shard-links.md). For a
3
+ // program that has been handed a link, as a person is, and has no account of
4
+ // its own to use.
5
+ //
6
+ // It reads the link as the link's own page does, through the website's public
7
+ // answers under /api/public at the link's own origin, which only ever grow
8
+ // (api/src/routes/public.ts). It never sends a key, a token or a cookie: a
9
+ // link can name any site, and that site is told nothing about whoever follows
10
+ // it. For the same reason every answer is checked for the fields this reads
11
+ // before anything is done with it. A plain file is fetched from its address in
12
+ // ranges (ranges.ts), and a protected one is put back together from its map,
13
+ // each part checked as it arrives (reader.ts).
14
+ //
15
+ // Each file saved counts as the link's page counts it: one of the link's
16
+ // downloads, when it has a limit, and its size toward its owner's month. No
17
+ // API call is counted, since no key is used.
18
+ import { fetchRanges } from "./ranges.js";
19
+ import { asItIsKept, readProtected } from "./reader.js";
20
+ import { backoff, FileFreakError, sleep } from "./transport.js";
21
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ /** As the API's route takes a token (api/src/routes/public.ts). */
23
+ const TOKEN = /^[A-Za-z0-9_-]{20,80}$/;
24
+ /** As the API's usernameSchema allows one, in either case: the website reads them without case. */
25
+ const USERNAME = /^[A-Za-z0-9_]{3,30}$/;
26
+ /** The local stack's two names, the only ones reached without https. */
27
+ const LOCAL = new Set(["localhost", "127.0.0.1"]);
28
+ /** Items asked for at once from a folder's listing: the most the API gives. */
29
+ const PAGE = 200;
30
+ /** How deep FileFreak lets folders go (api/src/services/removal.ts, MAX_DEPTH). A listing deeper than this is not FileFreak's. */
31
+ const MAX_DEPTH = 32;
32
+ const RETRIES = 4;
33
+ /** The longest wait between tries, whatever Retry-After says. */
34
+ const MOST_WAIT_MS = 60_000;
35
+ /** A sleeping database takes about a minute to start. Five minutes of asking is plenty. */
36
+ const WAKING_TRIES = 60;
37
+ export const NOT_A_LINK = "That is not a FileFreak share link or public file address.";
38
+ /**
39
+ * The link a web address is: `/share/<token>`, `/public/<username>/<id>`,
40
+ * or a public file's direct address, `/api/public/files/<id>/raw`. On https,
41
+ * or on http for the local stack only.
42
+ */
43
+ export function linkOf(url) {
44
+ const notALink = (message = NOT_A_LINK) => new FileFreakError(0, "not_a_link", message);
45
+ let u;
46
+ try {
47
+ u = new URL(String(url));
48
+ }
49
+ catch {
50
+ throw notALink();
51
+ }
52
+ const secure = u.protocol === "https:" || (u.protocol === "http:" && LOCAL.has(u.hostname));
53
+ if (!secure || u.username || u.password)
54
+ throw notALink();
55
+ const [first, second, third, fourth, fifth, ...more] = u.pathname.split("/").filter((p) => p !== "");
56
+ if (more.length > 0)
57
+ throw notALink();
58
+ if (first === "share" && second && TOKEN.test(second) && third === undefined)
59
+ return { origin: u.origin, kind: "share", token: second };
60
+ if (first === "public" && second && USERNAME.test(second) && third && UUID.test(third) && fourth === undefined) {
61
+ return { origin: u.origin, kind: "public", id: third.toLowerCase(), username: second };
62
+ }
63
+ if (first === "public" && second && third === undefined)
64
+ throw notALink("That is somebody's public profile. Give the address of one of its files or folders.");
65
+ if (first === "api" && second === "public" && third === "files" && fourth && UUID.test(fourth) && fifth === "raw") {
66
+ return { origin: u.origin, kind: "public", id: fourth.toLowerCase(), username: null };
67
+ }
68
+ throw notALink();
69
+ }
70
+ function unreadable(origin) {
71
+ return new FileFreakError(0, "unreadable", `${origin} answered with something FileFreak would not: it may not be FileFreak, or not a version this SDK knows.`);
72
+ }
73
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
74
+ const isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
75
+ /** Storage's addresses are https, or http on the local stack. Anything else a site sends, data: or file: among them, is not fetched. */
76
+ const isWebAddress = (v) => typeof v === "string" && /^https?:\/\//i.test(v);
77
+ /** The answers come from whatever site the link names, so each is checked before it is used. */
78
+ class Reader {
79
+ origin;
80
+ constructor(origin) {
81
+ this.origin = origin;
82
+ }
83
+ fail() {
84
+ throw unreadable(this.origin);
85
+ }
86
+ item(v) {
87
+ if (!isObject(v) || typeof v.id !== "string" || !UUID.test(v.id) || (v.kind !== "file" && v.kind !== "folder") || typeof v.name !== "string")
88
+ this.fail();
89
+ if (v.kind === "file" && !isCount(v.sizeBytes))
90
+ this.fail();
91
+ const contentType = typeof v.contentType === "string" ? v.contentType : null;
92
+ return { id: v.id, kind: v.kind, name: v.name, sizeBytes: v.kind === "file" ? v.sizeBytes : 0, contentType, protected: v.protected === true };
93
+ }
94
+ listing(v) {
95
+ if (!isObject(v) || !Array.isArray(v.items) || !(v.nextOffset === null || isCount(v.nextOffset)))
96
+ this.fail();
97
+ return { items: v.items.map((i) => this.item(i)), nextOffset: v.nextOffset };
98
+ }
99
+ address(v) {
100
+ if (!isObject(v) || !isWebAddress(v.url))
101
+ this.fail();
102
+ return v.url;
103
+ }
104
+ map(v) {
105
+ if (!isObject(v) || typeof v.contentId !== "string" || typeof v.key !== "string" || typeof v.expiresAt !== "string" || typeof v.grant !== "string")
106
+ this.fail();
107
+ if (!isCount(v.sizeBytes) || !isCount(v.fragmentBytes) || !isCount(v.count) || !Array.isArray(v.fragments))
108
+ this.fail();
109
+ const fragments = v.fragments.map((f) => (isObject(f) && isCount(f.place) && isWebAddress(f.url) ? { place: f.place, url: f.url } : this.fail()));
110
+ return { contentId: v.contentId, sizeBytes: v.sizeBytes, fragmentBytes: v.fragmentBytes, count: v.count, key: v.key, fragments, expiresAt: v.expiresAt, grant: v.grant };
111
+ }
112
+ }
113
+ /** The link's own site, asked with nothing that says who is asking. */
114
+ class Site {
115
+ origin;
116
+ fetcher;
117
+ signal;
118
+ read;
119
+ constructor(origin, fetcher, signal) {
120
+ this.origin = origin;
121
+ this.fetcher = fetcher;
122
+ this.signal = signal;
123
+ this.read = new Reader(origin);
124
+ }
125
+ /**
126
+ * One of the public answers. As the client's transport asks (transport.ts),
127
+ * but a request is sent again only when it is a read: a password sent twice
128
+ * is two tries against the link.
129
+ */
130
+ async ask(path, grants = {}, body, signal = this.signal) {
131
+ const method = body === undefined ? "GET" : "POST";
132
+ const headers = { Accept: "application/json" };
133
+ if (grants.share)
134
+ headers["X-Share-Grant"] = grants.share;
135
+ if (grants.read)
136
+ headers["X-Read-Grant"] = grants.read;
137
+ if (body !== undefined)
138
+ headers["Content-Type"] = "application/json";
139
+ let waking = 0;
140
+ for (let attempt = 0;; attempt++) {
141
+ let res;
142
+ try {
143
+ res = await this.fetcher(`${this.origin}${path}`, {
144
+ method,
145
+ headers,
146
+ body: body === undefined ? undefined : JSON.stringify(body),
147
+ credentials: "omit",
148
+ signal,
149
+ });
150
+ }
151
+ catch (e) {
152
+ if (signal?.aborted)
153
+ throw signal.reason;
154
+ if (method !== "GET" || attempt >= RETRIES)
155
+ throw new FileFreakError(0, "network_error", `${this.origin} could not be reached: ${e instanceof Error ? e.message : String(e)}`);
156
+ await sleep(backoff(attempt), signal);
157
+ continue;
158
+ }
159
+ const text = await res.text();
160
+ let parsed = null;
161
+ try {
162
+ parsed = text ? JSON.parse(text) : null;
163
+ }
164
+ catch {
165
+ parsed = null;
166
+ }
167
+ if (res.ok) {
168
+ if (!isObject(parsed))
169
+ throw unreadable(this.origin);
170
+ return parsed;
171
+ }
172
+ const error = isObject(parsed) ? parsed : null;
173
+ const code = typeof error?.error === "string" ? error.error : `http_${res.status}`;
174
+ const message = typeof error?.message === "string" ? error.message : `${this.origin} answered ${res.status}.`;
175
+ // Not Number(header) alone: a missing header is null, and Number(null) is 0.
176
+ const header = res.headers.get("retry-after");
177
+ const after = header === null ? NaN : Number(header);
178
+ const retryAfter = Number.isFinite(after) && after >= 0 ? after : null;
179
+ const refusal = new FileFreakError(res.status, code, message, error, retryAfter);
180
+ if (res.status === 503 && code === "waking_up" && waking < WAKING_TRIES) {
181
+ waking++;
182
+ attempt--;
183
+ await sleep(Math.min(MOST_WAIT_MS, (retryAfter ?? 5) * 1000), signal);
184
+ continue;
185
+ }
186
+ const passing = method === "GET" && (res.status === 429 || res.status === 502 || res.status === 503 || res.status === 504);
187
+ if (!passing || attempt >= RETRIES)
188
+ throw refusal;
189
+ await sleep(retryAfter !== null ? Math.min(MOST_WAIT_MS, retryAfter * 1000) : backoff(attempt), signal);
190
+ }
191
+ }
192
+ }
193
+ async function openShare(site, token, password) {
194
+ const base = `/api/public/shares/${token}`;
195
+ const describe = async (share) => {
196
+ const v = await site.ask(base, { share });
197
+ if (!isObject(v) || typeof v.locked !== "boolean" || !(v.downloadsLeft === null || isCount(v.downloadsLeft)))
198
+ throw unreadable(site.origin);
199
+ return { locked: v.locked, downloadsLeft: v.downloadsLeft, item: v.locked ? null : site.read.item(v.item) };
200
+ };
201
+ let share = null;
202
+ let view = await describe(null);
203
+ if (view.locked) {
204
+ if (!password)
205
+ throw new FileFreakError(401, "password_required", "This link needs a password. Give it as the password option.");
206
+ const unlocked = await site.ask(`${base}/unlock`, {}, { password });
207
+ if (!isObject(unlocked) || typeof unlocked.grant !== "string")
208
+ throw unreadable(site.origin);
209
+ share = unlocked.grant;
210
+ view = await describe(share);
211
+ if (view.locked)
212
+ throw unreadable(site.origin);
213
+ }
214
+ return {
215
+ root: view.item,
216
+ downloadsLeft: view.downloadsLeft,
217
+ list: async (folder, offset) => site.read.listing(await site.ask(`${base}/items?parent=${folder}&offset=${offset}&limit=${PAGE}`, { share })),
218
+ 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)),
220
+ };
221
+ }
222
+ async function openPublic(site, id, username) {
223
+ const v = await site.ask(`/api/public/files/${id}`);
224
+ if (!isObject(v) || !isObject(v.owner) || typeof v.owner.username !== "string")
225
+ throw unreadable(site.origin);
226
+ // As the website's page: under another person's name, it is not there.
227
+ if (username !== null && v.owner.username.toLowerCase() !== username.toLowerCase()) {
228
+ throw new FileFreakError(404, "not_found", "That file is not public, or does not exist.");
229
+ }
230
+ return {
231
+ root: site.read.item(v.item),
232
+ downloadsLeft: null,
233
+ list: async (folder, offset) => site.read.listing(await site.ask(`/api/public/files/${folder}/items?offset=${offset}&limit=${PAGE}`)),
234
+ 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)),
236
+ };
237
+ }
238
+ /** Every file in a shared folder, with the folders it is in, read folder by folder. */
239
+ async function walk(opened) {
240
+ const found = [];
241
+ const seen = new Set([opened.root.id]);
242
+ const visit = async (folder, folders, depth) => {
243
+ if (depth > MAX_DEPTH) {
244
+ throw new FileFreakError(0, "too_deep", `This folder has folders more than ${MAX_DEPTH} deep, which FileFreak's never do. Nothing was saved.`);
245
+ }
246
+ for (let offset = 0; offset !== null;) {
247
+ const page = await opened.list(folder.id, offset);
248
+ for (const item of page.items) {
249
+ // Listed twice, as when something was added between two pages: saved once.
250
+ if (seen.has(item.id))
251
+ continue;
252
+ seen.add(item.id);
253
+ if (item.kind === "folder")
254
+ await visit(item, [...folders, item.name], depth + 1);
255
+ else
256
+ found.push({ item, folders });
257
+ }
258
+ // A next page that does not move on would be asked for for ever.
259
+ offset = page.nextOffset !== null && page.nextOffset > offset ? page.nextOffset : null;
260
+ }
261
+ };
262
+ await visit(opened.root, [opened.root.name], 0);
263
+ return found;
264
+ }
265
+ function plural(n, one, many) {
266
+ return `${n} ${n === 1 ? one : many}`;
267
+ }
268
+ /**
269
+ * Save what a share link or a public page holds, with no key: one file into
270
+ * `into`, a Sink, or a folder and everything in it, each file into the Sink
271
+ * that `into`, a function, makes for it. Resolves once every file has been
272
+ * saved and its sink closed.
273
+ *
274
+ * Takes `https://<host>/share/<token>`, `https://<host>/public/<username>/<id>`
275
+ * and a public file's direct address. A protected file is put back together
276
+ * from its map and checked as it arrives. A failure is a FileFreakError, with
277
+ * the site's code and sentence or one of get's own: not_a_link, not_a_file
278
+ * (a folder's link given one Sink), password_required, not_enough_downloads,
279
+ * too_deep, fragment_damaged or fragment_unavailable. The file being saved
280
+ * when it stops has its sink aborted; files saved before it stay saved.
281
+ */
282
+ export async function get(url, into, options = {}) {
283
+ const make = typeof into === "function" ? into : null;
284
+ const given = typeof into === "function" ? null : into;
285
+ const signal = options.signal;
286
+ try {
287
+ const link = linkOf(url);
288
+ const fetcher = options.fetch ?? fetch.bind(globalThis);
289
+ const site = new Site(link.origin, fetcher, signal);
290
+ const opened = link.kind === "share" ? await openShare(site, link.token, options.password) : await openPublic(site, link.id, link.username);
291
+ const { root } = opened;
292
+ if (root.kind === "folder" && given) {
293
+ throw new FileFreakError(400, "not_a_file", 'That link is to a folder. Give get() a function that makes a sink for each file, as toFolder in "@filefreak.io/sdk/node" does.');
294
+ }
295
+ const found = root.kind === "file" ? [{ item: root, folders: [] }] : await walk(opened);
296
+ // Each file saved is one of the link's downloads, as on its page. Saving
297
+ // part of a folder and using the link up doing it helps nobody.
298
+ const counted = found.filter((f) => f.item.sizeBytes > 0).length;
299
+ const left = opened.downloadsLeft;
300
+ if (left !== null && counted > left) {
301
+ if (left === 0)
302
+ throw new FileFreakError(410, "link_used_up", "This link has been used as many times as it allows.");
303
+ throw new FileFreakError(410, "not_enough_downloads", `This link allows ${plural(left, "more download", "more downloads")}, and saving everything in it takes ${counted}: each file is one. Nothing was saved.`);
304
+ }
305
+ const total = found.reduce((n, f) => n + f.item.sizeBytes, 0);
306
+ const saved = [];
307
+ let before = 0;
308
+ for (const { item, folders } of found) {
309
+ const file = { id: item.id, name: item.name, folders, sizeBytes: item.sizeBytes, contentType: item.contentType, protected: item.protected };
310
+ const sink = given ?? (await make(file));
311
+ try {
312
+ await saveOne(opened, item, sink, fetcher, signal, (written) => options.onProgress?.(before + written, total));
313
+ await sink.close?.();
314
+ }
315
+ catch (e) {
316
+ if (!given)
317
+ await sink.abort?.(e).catch(() => undefined);
318
+ throw e;
319
+ }
320
+ before += item.sizeBytes;
321
+ saved.push(file);
322
+ }
323
+ return { kind: root.kind, name: root.name, files: saved, sizeBytes: total };
324
+ }
325
+ catch (e) {
326
+ await given?.abort?.(e).catch(() => undefined);
327
+ throw e;
328
+ }
329
+ }
330
+ /** One file of a link into its sink: from its address, or through its map. */
331
+ async function saveOne(opened, item, sink, fetcher, signal, progress) {
332
+ // Nothing to fetch, and asking would count a download of nothing.
333
+ if (item.sizeBytes === 0)
334
+ return;
335
+ await asItIsKept(item.protected, async () => {
336
+ const url = await opened.address(item.id);
337
+ await fetchRanges({ url, sizeBytes: item.sizeBytes, sink, signal, progress, fetch: fetcher, sleep: (ms) => sleep(ms, signal) });
338
+ }, async () => {
339
+ // One reading: the first page counts it, and each page after carries
340
+ // the newest read grant on, held here in memory and nowhere else.
341
+ let read = null;
342
+ await readProtected({
343
+ pages: async (from, pageSignal) => {
344
+ const page = await opened.page(item.id, from, read, pageSignal);
345
+ read = page.grant;
346
+ return page;
347
+ },
348
+ sink,
349
+ signal,
350
+ progress,
351
+ fetch: fetcher,
352
+ sizeBytes: item.sizeBytes,
353
+ });
354
+ });
355
+ }
package/dist/md5.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ export declare class Md5 {
2
+ private a;
3
+ private b;
4
+ private c;
5
+ private d;
6
+ /** Bytes waiting for a whole 64-byte block. */
7
+ private readonly tail;
8
+ private tailLength;
9
+ private total;
10
+ private readonly words;
11
+ private done;
12
+ update(data: Uint8Array): this;
13
+ /** The 16-byte digest. The hash cannot be fed after this. */
14
+ digest(): Uint8Array;
15
+ private block;
16
+ }
17
+ /** Bytes as base64, which is how Content-MD5 carries a digest. */
18
+ export declare function base64(bytes: Uint8Array): string;
package/dist/md5.js ADDED
@@ -0,0 +1,130 @@
1
+ // MD5, for the Content-MD5 header on each part of an upload.
2
+ //
3
+ // Storage checks a part against the Content-MD5 it was sent with and refuses
4
+ // it if they differ, so a part damaged on the way, or a file that changed
5
+ // while it was being sent, is never stored as it arrived. WebCrypto offers no
6
+ // MD5, and this package runs in browsers as well as in Node, so it carries
7
+ // its own: the one the website's uploads use (web/lib/md5.ts), checked
8
+ // against the same examples.
9
+ //
10
+ // RFC 1321. Fed in pieces, so a part never has to be held whole to be hashed.
11
+ /** The sine table, written out rather than computed: Math.sin is not required to give the same last bit everywhere. */
12
+ const K = new Int32Array([
13
+ 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
14
+ 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
15
+ 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
16
+ 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
17
+ 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
18
+ 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
19
+ ]);
20
+ /** How far each step rotates, four to a round. */
21
+ const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21];
22
+ export class Md5 {
23
+ a = 0x67452301;
24
+ b = 0xefcdab89 | 0;
25
+ c = 0x98badcfe | 0;
26
+ d = 0x10325476;
27
+ /** Bytes waiting for a whole 64-byte block. */
28
+ tail = new Uint8Array(64);
29
+ tailLength = 0;
30
+ total = 0;
31
+ words = new Int32Array(16);
32
+ done = false;
33
+ update(data) {
34
+ if (this.done)
35
+ throw new Error("This MD5 has already been finished.");
36
+ this.total += data.length;
37
+ let i = 0;
38
+ if (this.tailLength > 0) {
39
+ const take = Math.min(64 - this.tailLength, data.length);
40
+ this.tail.set(data.subarray(0, take), this.tailLength);
41
+ this.tailLength += take;
42
+ i = take;
43
+ if (this.tailLength < 64)
44
+ return this;
45
+ this.block(this.tail, 0);
46
+ this.tailLength = 0;
47
+ }
48
+ for (; i + 64 <= data.length; i += 64)
49
+ this.block(data, i);
50
+ if (i < data.length) {
51
+ this.tail.set(data.subarray(i));
52
+ this.tailLength = data.length - i;
53
+ }
54
+ return this;
55
+ }
56
+ /** The 16-byte digest. The hash cannot be fed after this. */
57
+ digest() {
58
+ if (this.done)
59
+ throw new Error("This MD5 has already been finished.");
60
+ const bits = this.total * 8;
61
+ const pad = new Uint8Array(this.tailLength < 56 ? 64 - this.tailLength : 128 - this.tailLength);
62
+ pad[0] = 0x80;
63
+ // The length in bits, little-endian, in the last eight bytes. A file over
64
+ // 2^32 bits (512 MiB) needs the high word as well.
65
+ const end = pad.length - 8;
66
+ const low = bits >>> 0;
67
+ const high = Math.floor(bits / 0x100000000) >>> 0;
68
+ for (let k = 0; k < 4; k++) {
69
+ pad[end + k] = (low >>> (8 * k)) & 0xff;
70
+ pad[end + 4 + k] = (high >>> (8 * k)) & 0xff;
71
+ }
72
+ this.total -= pad.length;
73
+ this.update(pad);
74
+ this.done = true;
75
+ const out = new Uint8Array(16);
76
+ for (const [n, word] of [this.a, this.b, this.c, this.d].entries()) {
77
+ for (let k = 0; k < 4; k++)
78
+ out[n * 4 + k] = (word >>> (8 * k)) & 0xff;
79
+ }
80
+ return out;
81
+ }
82
+ block(bytes, at) {
83
+ const w = this.words;
84
+ for (let j = 0; j < 16; j++) {
85
+ const o = at + j * 4;
86
+ w[j] = bytes[o] | (bytes[o + 1] << 8) | (bytes[o + 2] << 16) | (bytes[o + 3] << 24);
87
+ }
88
+ let a = this.a;
89
+ let b = this.b;
90
+ let c = this.c;
91
+ let d = this.d;
92
+ for (let j = 0; j < 64; j++) {
93
+ let f;
94
+ let g;
95
+ if (j < 16) {
96
+ f = (b & c) | (~b & d);
97
+ g = j;
98
+ }
99
+ else if (j < 32) {
100
+ f = (d & b) | (~d & c);
101
+ g = (5 * j + 1) & 15;
102
+ }
103
+ else if (j < 48) {
104
+ f = b ^ c ^ d;
105
+ g = (3 * j + 5) & 15;
106
+ }
107
+ else {
108
+ f = c ^ (b | ~d);
109
+ g = (7 * j) & 15;
110
+ }
111
+ const s = S[(j >> 4) * 4 + (j & 3)];
112
+ const sum = (a + f + K[j] + w[g]) | 0;
113
+ a = d;
114
+ d = c;
115
+ c = b;
116
+ b = (b + ((sum << s) | (sum >>> (32 - s)))) | 0;
117
+ }
118
+ this.a = (this.a + a) | 0;
119
+ this.b = (this.b + b) | 0;
120
+ this.c = (this.c + c) | 0;
121
+ this.d = (this.d + d) | 0;
122
+ }
123
+ }
124
+ /** Bytes as base64, which is how Content-MD5 carries a digest. */
125
+ export function base64(bytes) {
126
+ let text = "";
127
+ for (const byte of bytes)
128
+ text += String.fromCharCode(byte);
129
+ return btoa(text);
130
+ }
package/dist/node.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { LinkedFile } from "./links.js";
2
+ import type { Sink } from "./ranges.js";
3
+ /**
4
+ * A file on disk, to upload: read a part at a time as it is sent, never held
5
+ * whole. Node refuses to read it if it changes on disk in the meantime, so a
6
+ * file is never sent half old and half new.
7
+ */
8
+ export declare function fromPath(path: string, options?: {
9
+ type?: string;
10
+ }): Promise<File>;
11
+ /**
12
+ * A file on disk to download into. The bytes are written beside it, under
13
+ * the same name and ".part", and it takes the name only once every byte is
14
+ * there: a download that fails leaves nothing, and never a file with holes
15
+ * where it was meant to be.
16
+ */
17
+ export declare function toPath(path: string): Promise<Sink>;
18
+ /**
19
+ * A name from a link, as a name this computer can hold that cannot step out
20
+ * of the folder it is written in: the part after its last slash or
21
+ * backslash, with what cannot be in a name written as "_". An empty name,
22
+ * "." or ".." is `fallback`. The CLI's filefreak get keeps names by the same
23
+ * rule.
24
+ */
25
+ export declare function safeName(name: string, fallback: string, windows?: boolean): string;
26
+ /**
27
+ * Where get() saves a link's files: each in `dir`, in the folders the link
28
+ * has it in, made as they are needed. Every name is the sharer's, so each is
29
+ * made safe first (safeName), and names FileFreak keeps apart can then come
30
+ * out as one ("a\u0001" and "a\u0002" are both "a_"). Compared without
31
+ * case, since many disks do, folders that come out as one name are one
32
+ * folder, and a file that comes out as the name of another file or a folder
33
+ * is told apart as "name (2).ext", as filefreak get does. A file's ".part"
34
+ * is its place too while it is written. Nothing is written outside `dir`.
35
+ */
36
+ export declare function toFolder(dir: string): (file: LinkedFile) => Promise<Sink>;
package/dist/node.js ADDED
@@ -0,0 +1,124 @@
1
+ // Files on disk, for a program in Node: one to upload, one to download into,
2
+ // and a folder to save a link's files in. Apart from the main entry, which
3
+ // runs in browsers too.
4
+ //
5
+ // import { fromPath, toFolder, toPath } from "@filefreak.io/sdk/node";
6
+ // const item = await ff.upload(await fromPath("film.mp4"));
7
+ // await ff.download(item.id, await toPath("copy.mp4"));
8
+ // await get("https://filefreak.link/share/...", toFolder("downloads"));
9
+ import { openAsBlob } from "node:fs";
10
+ import { mkdir, open, rename, rm } from "node:fs/promises";
11
+ import { basename, extname, join, resolve, sep } from "node:path";
12
+ /**
13
+ * A file on disk, to upload: read a part at a time as it is sent, never held
14
+ * whole. Node refuses to read it if it changes on disk in the meantime, so a
15
+ * file is never sent half old and half new.
16
+ */
17
+ export async function fromPath(path, options = {}) {
18
+ const blob = await openAsBlob(path, options.type ? { type: options.type } : {});
19
+ return new File([blob], basename(path), { type: blob.type });
20
+ }
21
+ /**
22
+ * A file on disk to download into. The bytes are written beside it, under
23
+ * the same name and ".part", and it takes the name only once every byte is
24
+ * there: a download that fails leaves nothing, and never a file with holes
25
+ * where it was meant to be.
26
+ */
27
+ export async function toPath(path) {
28
+ const partial = partialOf(path);
29
+ const handle = await open(partial, "w");
30
+ let shut = false;
31
+ const close = async () => {
32
+ if (shut)
33
+ return;
34
+ shut = true;
35
+ await handle.close();
36
+ };
37
+ return {
38
+ async write(position, data) {
39
+ for (let at = 0; at < data.byteLength;) {
40
+ const { bytesWritten } = await handle.write(data, at, data.byteLength - at, position + at);
41
+ at += bytesWritten;
42
+ }
43
+ },
44
+ async close() {
45
+ await close();
46
+ await rename(partial, path);
47
+ },
48
+ async abort() {
49
+ await close();
50
+ await rm(partial, { force: true });
51
+ },
52
+ };
53
+ }
54
+ /** Where toPath writes a file until every byte is there. */
55
+ const partialOf = (path) => `${path}.part`;
56
+ /** Characters that are never kept in a name from a link: controls, and the ones that turn text around so ".exe" can read as ".pdf". */
57
+ const UNSEEN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
58
+ /** What Windows cannot hold in a name. */
59
+ const NOT_ON_WINDOWS = /[<>:"|?*]/g;
60
+ /** Names Windows keeps for devices, with or without an extension. */
61
+ const DEVICE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/i;
62
+ /**
63
+ * A name from a link, as a name this computer can hold that cannot step out
64
+ * of the folder it is written in: the part after its last slash or
65
+ * backslash, with what cannot be in a name written as "_". An empty name,
66
+ * "." or ".." is `fallback`. The CLI's filefreak get keeps names by the same
67
+ * rule.
68
+ */
69
+ export function safeName(name, fallback, windows = process.platform === "win32") {
70
+ let out = (name.split(/[/\\]/).pop() ?? "").replace(UNSEEN, "_");
71
+ if (windows) {
72
+ out = out.replace(NOT_ON_WINDOWS, "_").replace(/[ .]+$/, "");
73
+ if (DEVICE.test(out))
74
+ out = `_${out}`;
75
+ }
76
+ return out === "" || out === "." || out === ".." ? fallback : out;
77
+ }
78
+ /**
79
+ * Where get() saves a link's files: each in `dir`, in the folders the link
80
+ * has it in, made as they are needed. Every name is the sharer's, so each is
81
+ * made safe first (safeName), and names FileFreak keeps apart can then come
82
+ * out as one ("a\u0001" and "a\u0002" are both "a_"). Compared without
83
+ * case, since many disks do, folders that come out as one name are one
84
+ * folder, and a file that comes out as the name of another file or a folder
85
+ * is told apart as "name (2).ext", as filefreak get does. A file's ".part"
86
+ * is its place too while it is written. Nothing is written outside `dir`.
87
+ */
88
+ export function toFolder(dir) {
89
+ const root = resolve(dir);
90
+ // Each place given, in lower case, as a file's or a folder's.
91
+ const taken = new Map();
92
+ // Each of the link's folders, by its names from the top, and where it is here.
93
+ const made = new Map();
94
+ const given = (path, kind) => kind === "folder" ? taken.get(path.toLowerCase()) === "file" : taken.has(path.toLowerCase()) || taken.has(partialOf(path).toLowerCase());
95
+ const place = (under, name, kind) => {
96
+ const ext = kind === "file" ? extname(name) : "";
97
+ const stem = name.slice(0, name.length - ext.length);
98
+ let path = join(under, name);
99
+ for (let n = 2; given(path, kind); n++)
100
+ path = join(under, `${stem} (${n})${ext}`);
101
+ taken.set(path.toLowerCase(), kind);
102
+ if (kind === "file")
103
+ taken.set(partialOf(path).toLowerCase(), "file");
104
+ return path;
105
+ };
106
+ return async (file) => {
107
+ let folder = root;
108
+ for (let i = 0; i < file.folders.length; i++) {
109
+ const key = JSON.stringify(file.folders.slice(0, i + 1));
110
+ let here = made.get(key);
111
+ if (here === undefined) {
112
+ here = place(folder, safeName(file.folders[i], "folder"), "folder");
113
+ made.set(key, here);
114
+ }
115
+ folder = here;
116
+ }
117
+ const path = place(folder, safeName(file.name, "download"), "file");
118
+ // safeName already keeps every part inside; this is the rule said once more where it matters.
119
+ if (!path.startsWith(root + sep))
120
+ throw new Error(`${JSON.stringify(file.name)} would be saved outside ${root}.`);
121
+ await mkdir(folder, { recursive: true });
122
+ return await toPath(path);
123
+ };
124
+ }