@filefreak.io/sdk 0.1.0 → 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/README.md +39 -3
- package/dist/client.d.ts +3 -1
- package/dist/client.js +15 -11
- package/dist/generated.d.ts +76 -21
- package/dist/generated.js +30 -19
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/links.d.ts +72 -0
- package/dist/links.js +355 -0
- package/dist/node.d.ts +20 -0
- package/dist/node.js +79 -6
- package/dist/reader.d.ts +76 -0
- package/dist/reader.js +510 -0
- package/dist/shards.d.ts +1 -1
- package/dist/uploads.js +1 -1
- package/package.json +1 -1
package/dist/links.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type Sink } from "./ranges.js";
|
|
2
|
+
/** A file a link holds, as get() hands it to the function that makes its sink. */
|
|
3
|
+
export interface LinkedFile {
|
|
4
|
+
id: string;
|
|
5
|
+
/**
|
|
6
|
+
* Its name, as whoever shared it wrote it. A link can name any site, and
|
|
7
|
+
* that site can say anything, so never use it as a path unchecked:
|
|
8
|
+
* safeName and toFolder, in "@filefreak.io/sdk/node", check it.
|
|
9
|
+
*/
|
|
10
|
+
name: string;
|
|
11
|
+
/** The folders it is in, from the shared folder, by its name, down. Empty for a link to one file. As unchecked as `name`. */
|
|
12
|
+
folders: string[];
|
|
13
|
+
sizeBytes: number;
|
|
14
|
+
contentType: string | null;
|
|
15
|
+
/** Under Shard Protection: put back together from its map, each part checked as it arrives. */
|
|
16
|
+
protected: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** Where get() puts what a link holds: one Sink, for a link to one file, or a function that makes one for each file. */
|
|
19
|
+
export type Into = Sink | ((file: LinkedFile) => Sink | Promise<Sink>);
|
|
20
|
+
export interface GetOptions {
|
|
21
|
+
/** The link's password, for a link that has one. */
|
|
22
|
+
password?: string;
|
|
23
|
+
/** Stops the saving, and aborts the sink of the file being saved. */
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
/** Called as bytes are written, with how many have been and how many there are in all. */
|
|
26
|
+
onProgress?: (writtenBytes: number, totalBytes: number) => void;
|
|
27
|
+
/** Another fetch, for a runtime without one or for tests. */
|
|
28
|
+
fetch?: typeof fetch;
|
|
29
|
+
}
|
|
30
|
+
export interface Got {
|
|
31
|
+
/** What the link is to. */
|
|
32
|
+
kind: "file" | "folder";
|
|
33
|
+
/** Its name, as its sharer wrote it. */
|
|
34
|
+
name: string;
|
|
35
|
+
/** Every file saved, in the order they were saved. */
|
|
36
|
+
files: LinkedFile[];
|
|
37
|
+
/** Their bytes, together. */
|
|
38
|
+
sizeBytes: number;
|
|
39
|
+
}
|
|
40
|
+
/** What a link is to, and the site to ask about it. */
|
|
41
|
+
export type Link = {
|
|
42
|
+
origin: string;
|
|
43
|
+
} & ({
|
|
44
|
+
kind: "share";
|
|
45
|
+
token: string;
|
|
46
|
+
} | {
|
|
47
|
+
kind: "public";
|
|
48
|
+
id: string;
|
|
49
|
+
username: string | null;
|
|
50
|
+
});
|
|
51
|
+
export declare const NOT_A_LINK = "That is not a FileFreak share link or public file address.";
|
|
52
|
+
/**
|
|
53
|
+
* The link a web address is: `/share/<token>`, `/public/<username>/<id>`,
|
|
54
|
+
* or a public file's direct address, `/api/public/files/<id>/raw`. On https,
|
|
55
|
+
* or on http for the local stack only.
|
|
56
|
+
*/
|
|
57
|
+
export declare function linkOf(url: string | URL): Link;
|
|
58
|
+
/**
|
|
59
|
+
* Save what a share link or a public page holds, with no key: one file into
|
|
60
|
+
* `into`, a Sink, or a folder and everything in it, each file into the Sink
|
|
61
|
+
* that `into`, a function, makes for it. Resolves once every file has been
|
|
62
|
+
* saved and its sink closed.
|
|
63
|
+
*
|
|
64
|
+
* Takes `https://<host>/share/<token>`, `https://<host>/public/<username>/<id>`
|
|
65
|
+
* and a public file's direct address. A protected file is put back together
|
|
66
|
+
* from its map and checked as it arrives. A failure is a FileFreakError, with
|
|
67
|
+
* the site's code and sentence or one of get's own: not_a_link, not_a_file
|
|
68
|
+
* (a folder's link given one Sink), password_required, not_enough_downloads,
|
|
69
|
+
* too_deep, fragment_damaged or fragment_unavailable. The file being saved
|
|
70
|
+
* when it stops has its sink aborted; files saved before it stay saved.
|
|
71
|
+
*/
|
|
72
|
+
export declare function get(url: string | URL, into: Into, options?: GetOptions): Promise<Got>;
|
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/node.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LinkedFile } from "./links.js";
|
|
1
2
|
import type { Sink } from "./ranges.js";
|
|
2
3
|
/**
|
|
3
4
|
* A file on disk, to upload: read a part at a time as it is sent, never held
|
|
@@ -14,3 +15,22 @@ export declare function fromPath(path: string, options?: {
|
|
|
14
15
|
* where it was meant to be.
|
|
15
16
|
*/
|
|
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
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
// Files on disk, for a program in Node: one to upload,
|
|
2
|
-
//
|
|
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.
|
|
3
4
|
//
|
|
4
|
-
// import { fromPath, toPath } from "@filefreak.io/sdk/node";
|
|
5
|
+
// import { fromPath, toFolder, toPath } from "@filefreak.io/sdk/node";
|
|
5
6
|
// const item = await ff.upload(await fromPath("film.mp4"));
|
|
6
7
|
// await ff.download(item.id, await toPath("copy.mp4"));
|
|
8
|
+
// await get("https://filefreak.link/share/...", toFolder("downloads"));
|
|
7
9
|
import { openAsBlob } from "node:fs";
|
|
8
|
-
import { open, rename, rm } from "node:fs/promises";
|
|
9
|
-
import { basename } from "node:path";
|
|
10
|
+
import { mkdir, open, rename, rm } from "node:fs/promises";
|
|
11
|
+
import { basename, extname, join, resolve, sep } from "node:path";
|
|
10
12
|
/**
|
|
11
13
|
* A file on disk, to upload: read a part at a time as it is sent, never held
|
|
12
14
|
* whole. Node refuses to read it if it changes on disk in the meantime, so a
|
|
@@ -23,7 +25,7 @@ export async function fromPath(path, options = {}) {
|
|
|
23
25
|
* where it was meant to be.
|
|
24
26
|
*/
|
|
25
27
|
export async function toPath(path) {
|
|
26
|
-
const partial =
|
|
28
|
+
const partial = partialOf(path);
|
|
27
29
|
const handle = await open(partial, "w");
|
|
28
30
|
let shut = false;
|
|
29
31
|
const close = async () => {
|
|
@@ -49,3 +51,74 @@ export async function toPath(path) {
|
|
|
49
51
|
},
|
|
50
52
|
};
|
|
51
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
|
+
}
|
package/dist/reader.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Sink } from "./ranges.js";
|
|
2
|
+
/** The most bytes of fragments being fetched, or fetched and not yet written, at once in this program: five of the largest. */
|
|
3
|
+
export declare const MOST_IN_FLIGHT_BYTES: number;
|
|
4
|
+
/** Fragments fetched at once from one storage host. */
|
|
5
|
+
export declare const PER_HOST = 6;
|
|
6
|
+
/** Said when a fragment arrives and fails its check. */
|
|
7
|
+
export declare const NOT_WHAT_WAS_STORED = "Part of this file is not what was stored. Nothing was saved.";
|
|
8
|
+
/** Said when a fragment cannot be had from storage. */
|
|
9
|
+
export declare const NOT_FETCHED = "Part of this file could not be fetched from storage. Nothing was saved; try again later.";
|
|
10
|
+
/** A page of a protected file's map, as the reader uses it: files.shards's answer, or a link's. */
|
|
11
|
+
export interface MapPage {
|
|
12
|
+
contentId: string;
|
|
13
|
+
sizeBytes: number;
|
|
14
|
+
fragmentBytes: number;
|
|
15
|
+
count: number;
|
|
16
|
+
/** The file's key, in base64. */
|
|
17
|
+
key: string;
|
|
18
|
+
fragments: ReadonlyArray<{
|
|
19
|
+
place: number;
|
|
20
|
+
url: string;
|
|
21
|
+
}>;
|
|
22
|
+
/** When this page's addresses stop working. */
|
|
23
|
+
expiresAt: string;
|
|
24
|
+
}
|
|
25
|
+
/** Asks for the page of a map that begins at a place. A function, so that a map is read the same way whoever gives it. */
|
|
26
|
+
export type Pages = (from: number, signal: AbortSignal) => Promise<MapPage>;
|
|
27
|
+
/**
|
|
28
|
+
* A number of units, taken and given back, first come first served: a
|
|
29
|
+
* budget of bytes, or turns at a host. A taker who asks for more than there
|
|
30
|
+
* ever is waits for all of it, and goes alone.
|
|
31
|
+
*/
|
|
32
|
+
export declare class Gate {
|
|
33
|
+
private readonly most;
|
|
34
|
+
private free;
|
|
35
|
+
private readonly queue;
|
|
36
|
+
constructor(most: number);
|
|
37
|
+
/** Wait for `units`. Answers the way to give them back, which may be called more than once. */
|
|
38
|
+
take(units: number, signal: AbortSignal): Promise<() => void>;
|
|
39
|
+
private giver;
|
|
40
|
+
private next;
|
|
41
|
+
}
|
|
42
|
+
export interface ReadOptions {
|
|
43
|
+
pages: Pages;
|
|
44
|
+
sink: Sink;
|
|
45
|
+
/**
|
|
46
|
+
* The file's size as it was listed. A map of another size is of a file
|
|
47
|
+
* replaced since, and is not read: a sink made for the size listed, as
|
|
48
|
+
* bytes() makes one, could not hold it.
|
|
49
|
+
*/
|
|
50
|
+
sizeBytes?: number;
|
|
51
|
+
signal?: AbortSignal | undefined;
|
|
52
|
+
/** How many bytes of the file have been written, after each fragment. */
|
|
53
|
+
progress?: ((writtenBytes: number) => void) | undefined;
|
|
54
|
+
fetch?: typeof fetch;
|
|
55
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
56
|
+
now?: () => number;
|
|
57
|
+
/** Bytes of fragments in flight: the program's, unless a test gives a read its own. */
|
|
58
|
+
budget?: Gate;
|
|
59
|
+
/** Fragments fetched at once from one storage host. */
|
|
60
|
+
perHost?: number;
|
|
61
|
+
quietMs?: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Read the protected file whose map `pages` gives into `sink`. Resolves once
|
|
65
|
+
* every fragment has been written; the caller closes the sink, or aborts it
|
|
66
|
+
* when this rejects, as with fetchRanges. Rejects only once every fragment
|
|
67
|
+
* still in flight has stopped, so nothing is written to the sink after.
|
|
68
|
+
*/
|
|
69
|
+
export declare function readProtected(options: ReadOptions): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Fetch a file the way it is kept: through its map when it is protected,
|
|
72
|
+
* from its address when not. One converted since it was listed is refused
|
|
73
|
+
* the way the listing chose (409 not_protected, or shard_protected) before
|
|
74
|
+
* anything is written, and is fetched the other way, once.
|
|
75
|
+
*/
|
|
76
|
+
export declare function asItIsKept(isProtected: boolean, plain: () => Promise<void>, mapped: () => Promise<void>): Promise<void>;
|