sliftutils 1.4.3 → 1.4.4
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/bin/filehoster.js +4 -0
- package/index.d.ts +85 -0
- package/package.json +72 -70
- package/render-utils/FullscreenModal.tsx +5 -1
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +14 -0
- package/storage/BulkDatabase2/BulkDatabase2.ts +6 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +9 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +15 -0
- package/storage/FileFolderAPI.tsx +81 -0
- package/storage/remoteFileServer.d.ts +16 -0
- package/storage/remoteFileServer.ts +439 -0
- package/storage/remoteFileStorage.d.ts +38 -0
- package/storage/remoteFileStorage.ts +236 -0
package/index.d.ts
CHANGED
|
@@ -849,6 +849,20 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
|
|
|
849
849
|
getColumnInfo(): Promise<BulkColumnInfo[]>;
|
|
850
850
|
/** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
|
|
851
851
|
getReaderInfo(): Promise<BulkReaderInfo>;
|
|
852
|
+
/**
|
|
853
|
+
* Per-file breakdown of the on-disk files, read fresh from disk each call (latest sizes, including
|
|
854
|
+
* stream files still being appended). `bytes` is the actual on-disk size. Good for showing collection
|
|
855
|
+
* size/fragmentation and deciding whether to call tryMergeNow()/compact().
|
|
856
|
+
*/
|
|
857
|
+
getFileInfo(): Promise<{
|
|
858
|
+
files: {
|
|
859
|
+
name: string;
|
|
860
|
+
type: "bulk" | "stream";
|
|
861
|
+
bytes: number;
|
|
862
|
+
}[];
|
|
863
|
+
count: number;
|
|
864
|
+
totalBytes: number;
|
|
865
|
+
}>;
|
|
852
866
|
/** Consolidate on-disk files. Optional to call; the database also does this in the background. */
|
|
853
867
|
compact(): Promise<void>;
|
|
854
868
|
/**
|
|
@@ -1010,6 +1024,15 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
1010
1024
|
byteSize: number;
|
|
1011
1025
|
}[];
|
|
1012
1026
|
}>;
|
|
1027
|
+
getFileInfo(): Promise<{
|
|
1028
|
+
files: {
|
|
1029
|
+
name: string;
|
|
1030
|
+
type: "bulk" | "stream";
|
|
1031
|
+
bytes: number;
|
|
1032
|
+
}[];
|
|
1033
|
+
count: number;
|
|
1034
|
+
totalBytes: number;
|
|
1035
|
+
}>;
|
|
1013
1036
|
}
|
|
1014
1037
|
|
|
1015
1038
|
}
|
|
@@ -1817,6 +1840,68 @@ declare module "sliftutils/storage/fileSystemPointer" {
|
|
|
1817
1840
|
|
|
1818
1841
|
}
|
|
1819
1842
|
|
|
1843
|
+
declare module "sliftutils/storage/remoteFileServer" {
|
|
1844
|
+
export declare function generatePassword(wordCount: number): string;
|
|
1845
|
+
export type RemoteFileServerOptions = {
|
|
1846
|
+
root: string;
|
|
1847
|
+
port?: number;
|
|
1848
|
+
host?: string;
|
|
1849
|
+
password?: string;
|
|
1850
|
+
logAccess?: boolean;
|
|
1851
|
+
};
|
|
1852
|
+
export type RemoteFileServerHandle = {
|
|
1853
|
+
port: number;
|
|
1854
|
+
password: string;
|
|
1855
|
+
url: string;
|
|
1856
|
+
close: () => Promise<void>;
|
|
1857
|
+
};
|
|
1858
|
+
export declare function startRemoteFileServer(options: RemoteFileServerOptions): Promise<RemoteFileServerHandle>;
|
|
1859
|
+
export declare function runFileHoster(): Promise<void>;
|
|
1860
|
+
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
declare module "sliftutils/storage/remoteFileStorage" {
|
|
1864
|
+
/// <reference types="node" />
|
|
1865
|
+
/// <reference types="node" />
|
|
1866
|
+
/// <reference types="node" />
|
|
1867
|
+
import https from "https";
|
|
1868
|
+
import type { FileStorage } from "./FileFolderAPI";
|
|
1869
|
+
export type RemoteFileStorageOptions = {
|
|
1870
|
+
chunkBytes?: number;
|
|
1871
|
+
cacheBytes?: number;
|
|
1872
|
+
latencyMs?: number;
|
|
1873
|
+
};
|
|
1874
|
+
type Connection = {
|
|
1875
|
+
url: string;
|
|
1876
|
+
password: string;
|
|
1877
|
+
latencyMs: number;
|
|
1878
|
+
agent: https.Agent | undefined;
|
|
1879
|
+
cache: RangeCache;
|
|
1880
|
+
stats: {
|
|
1881
|
+
requestCount: number;
|
|
1882
|
+
bytesFetched: number;
|
|
1883
|
+
};
|
|
1884
|
+
};
|
|
1885
|
+
declare class RangeCache {
|
|
1886
|
+
private chunkBytes;
|
|
1887
|
+
private budget;
|
|
1888
|
+
private chunks;
|
|
1889
|
+
private bytes;
|
|
1890
|
+
constructor(chunkBytes: number, budget: number);
|
|
1891
|
+
private key;
|
|
1892
|
+
private peek;
|
|
1893
|
+
private store;
|
|
1894
|
+
invalidate(path: string): void;
|
|
1895
|
+
getRange(conn: Connection, path: string, start: number, end: number): Promise<Buffer | undefined>;
|
|
1896
|
+
}
|
|
1897
|
+
export type RemoteStorageFactory = ((path: string) => Promise<FileStorage>) & {
|
|
1898
|
+
stats: Connection["stats"];
|
|
1899
|
+
};
|
|
1900
|
+
export declare function getRemoteFileStorage(url: string, password: string, options?: RemoteFileStorageOptions): RemoteStorageFactory;
|
|
1901
|
+
export {};
|
|
1902
|
+
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1820
1905
|
declare module "sliftutils/storage/storage" {
|
|
1821
1906
|
|
|
1822
1907
|
|
package/package.json
CHANGED
|
@@ -1,70 +1,72 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "sliftutils",
|
|
3
|
-
"version": "1.4.
|
|
4
|
-
"main": "index.js",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"files": [
|
|
7
|
-
"**/*",
|
|
8
|
-
".gitignore"
|
|
9
|
-
],
|
|
10
|
-
"scripts": {
|
|
11
|
-
"type": "yarn tsc --noEmit",
|
|
12
|
-
"emit-dts": "yarn tsc --project tsconfig.declarations.json || true",
|
|
13
|
-
"generate-index-dts": "typenode ./builders/generateIndexDts.ts",
|
|
14
|
-
"update-types": "yarn emit-dts && yarn generate-index-dts",
|
|
15
|
-
"prepublishOnly": "yarn update-types",
|
|
16
|
-
"run-nodejs": "node ./build-nodejs/server.js",
|
|
17
|
-
"run-nodejs-dev": "typenode ./nodejs/server.ts --npminstall",
|
|
18
|
-
"run-web": "node ./builders/webRun.js",
|
|
19
|
-
"run-electron": "node ./node_modules/electron/cli.js ./build-electron/electronMain.js",
|
|
20
|
-
"build-nodejs": "node ./builders/nodeJSBuildRun.js",
|
|
21
|
-
"build-web": "node ./builders/webBuildRun.js",
|
|
22
|
-
"build-extension": "node ./builders/extensionBuildRun.js",
|
|
23
|
-
"build-electron": "node ./builders/electronBuildRun.js",
|
|
24
|
-
"watch-nodejs": "node ./builders/watchRun.js --port 9876 \"nodejs/*.ts\" \"nodejs/*.tsx\" \"yarn build-nodejs\"",
|
|
25
|
-
"watch-web": "node ./builders/watchRun.js --port 9877 \"web/*.ts\" \"web/*.tsx\" \"yarn build-web\"",
|
|
26
|
-
"watch-extension": "node ./builders/watchRun.js --port 9878 \"extension/*.ts\" \"extension/*.tsx\" \"yarn build-extension\"",
|
|
27
|
-
"watch-electron": "node ./builders/watchRun.js --port 9879 \"electron/*.ts\" \"electron/*.tsx\" \"yarn build-electron\"",
|
|
28
|
-
"notes": "mobx, preact, socket-function, typenode SHOULD be peerDependencies. But we want to use yarn (better dependency deduplication), so we can't use peerDependencies (as they aren't installed by default, which makes them a nightmare to use). If you want to override the versions, feel free to use overrides/resolutions.",
|
|
29
|
-
"test": "typenode ./test.ts"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
"build-
|
|
35
|
-
"
|
|
36
|
-
"build-
|
|
37
|
-
"
|
|
38
|
-
"build-
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"slift-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"@types/
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
|
|
70
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "sliftutils",
|
|
3
|
+
"version": "1.4.4",
|
|
4
|
+
"main": "index.js",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
"**/*",
|
|
8
|
+
".gitignore"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"type": "yarn tsc --noEmit",
|
|
12
|
+
"emit-dts": "yarn tsc --project tsconfig.declarations.json || true",
|
|
13
|
+
"generate-index-dts": "typenode ./builders/generateIndexDts.ts",
|
|
14
|
+
"update-types": "yarn emit-dts && yarn generate-index-dts",
|
|
15
|
+
"prepublishOnly": "yarn update-types",
|
|
16
|
+
"run-nodejs": "node ./build-nodejs/server.js",
|
|
17
|
+
"run-nodejs-dev": "typenode ./nodejs/server.ts --npminstall",
|
|
18
|
+
"run-web": "node ./builders/webRun.js",
|
|
19
|
+
"run-electron": "node ./node_modules/electron/cli.js ./build-electron/electronMain.js",
|
|
20
|
+
"build-nodejs": "node ./builders/nodeJSBuildRun.js",
|
|
21
|
+
"build-web": "node ./builders/webBuildRun.js",
|
|
22
|
+
"build-extension": "node ./builders/extensionBuildRun.js",
|
|
23
|
+
"build-electron": "node ./builders/electronBuildRun.js",
|
|
24
|
+
"watch-nodejs": "node ./builders/watchRun.js --port 9876 \"nodejs/*.ts\" \"nodejs/*.tsx\" \"yarn build-nodejs\"",
|
|
25
|
+
"watch-web": "node ./builders/watchRun.js --port 9877 \"web/*.ts\" \"web/*.tsx\" \"yarn build-web\"",
|
|
26
|
+
"watch-extension": "node ./builders/watchRun.js --port 9878 \"extension/*.ts\" \"extension/*.tsx\" \"yarn build-extension\"",
|
|
27
|
+
"watch-electron": "node ./builders/watchRun.js --port 9879 \"electron/*.ts\" \"electron/*.tsx\" \"yarn build-electron\"",
|
|
28
|
+
"notes": "mobx, preact, socket-function, typenode SHOULD be peerDependencies. But we want to use yarn (better dependency deduplication), so we can't use peerDependencies (as they aren't installed by default, which makes them a nightmare to use). If you want to override the versions, feel free to use overrides/resolutions.",
|
|
29
|
+
"test": "typenode ./test.ts",
|
|
30
|
+
"filehoster": "node ./bin/filehoster.js"
|
|
31
|
+
},
|
|
32
|
+
"bin": {
|
|
33
|
+
"filehoster": "./bin/filehoster.js",
|
|
34
|
+
"build-nodejs": "./builders/nodeJSBuildRun.js",
|
|
35
|
+
"buildnodejs": "./builders/nodeJSBuildRun.js",
|
|
36
|
+
"build-extension": "./builders/extensionBuildRun.js",
|
|
37
|
+
"buildextension": "./builders/extensionBuildRun.js",
|
|
38
|
+
"build-web": "./builders/webBuildRun.js",
|
|
39
|
+
"buildweb": "./builders/webBuildRun.js",
|
|
40
|
+
"build-electron": "./builders/electronBuildRun.js",
|
|
41
|
+
"buildelectron": "./builders/electronBuildRun.js",
|
|
42
|
+
"slift-watch": "./builders/watchRun.js",
|
|
43
|
+
"sliftwatch": "./builders/watchRun.js",
|
|
44
|
+
"slift-setup": "./builders/setupRun.js",
|
|
45
|
+
"sliftsetup": "./builders/setupRun.js"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@types/chrome": "^0.0.237",
|
|
49
|
+
"@types/node-forge": "^1.3.11",
|
|
50
|
+
"@types/shell-quote": "^1.7.5",
|
|
51
|
+
"acme-client": "^5.0.0",
|
|
52
|
+
"js-sha256": "^0.11.1",
|
|
53
|
+
"mobx": "^6.13.3",
|
|
54
|
+
"preact-old-types": "^10.28.1",
|
|
55
|
+
"shell-quote": "^1.8.3",
|
|
56
|
+
"socket-function": "^1.1.42",
|
|
57
|
+
"typenode": "*",
|
|
58
|
+
"typesafecss": "*",
|
|
59
|
+
"ws": "^8.18.3",
|
|
60
|
+
"yargs": "15.4.1"
|
|
61
|
+
},
|
|
62
|
+
"resolutions": {
|
|
63
|
+
"preact": "npm:preact-old-types@*"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@types/yargs": "15.0.19",
|
|
67
|
+
"debugbreak": "^0.9.9",
|
|
68
|
+
"electron": "^33.2.1",
|
|
69
|
+
"open": "^8.4.0",
|
|
70
|
+
"typedev": "^0.1.1"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -48,7 +48,11 @@ export class FullscreenModal extends preact.Component<{
|
|
|
48
48
|
cursor: "pointer",
|
|
49
49
|
...this.props.outerStyle,
|
|
50
50
|
}}
|
|
51
|
-
|
|
51
|
+
// Close on mouse DOWN on the backdrop, not click — otherwise a drag that starts
|
|
52
|
+
// inside the modal (e.g. selecting text) and releases out on the backdrop fires a
|
|
53
|
+
// click on the backdrop and wrongly closes it. currentTarget === target means the
|
|
54
|
+
// press began on the backdrop itself, not bubbled up from the modal content.
|
|
55
|
+
onMouseDown={e => {
|
|
52
56
|
if (e.currentTarget === e.target) {
|
|
53
57
|
if (parentState) parentState.open = false;
|
|
54
58
|
this.props.onCancel?.();
|
|
@@ -84,6 +84,20 @@ export interface IBulkDatabase2<T extends {
|
|
|
84
84
|
getColumnInfo(): Promise<BulkColumnInfo[]>;
|
|
85
85
|
/** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
|
|
86
86
|
getReaderInfo(): Promise<BulkReaderInfo>;
|
|
87
|
+
/**
|
|
88
|
+
* Per-file breakdown of the on-disk files, read fresh from disk each call (latest sizes, including
|
|
89
|
+
* stream files still being appended). `bytes` is the actual on-disk size. Good for showing collection
|
|
90
|
+
* size/fragmentation and deciding whether to call tryMergeNow()/compact().
|
|
91
|
+
*/
|
|
92
|
+
getFileInfo(): Promise<{
|
|
93
|
+
files: {
|
|
94
|
+
name: string;
|
|
95
|
+
type: "bulk" | "stream";
|
|
96
|
+
bytes: number;
|
|
97
|
+
}[];
|
|
98
|
+
count: number;
|
|
99
|
+
totalBytes: number;
|
|
100
|
+
}>;
|
|
87
101
|
/** Consolidate on-disk files. Optional to call; the database also does this in the background. */
|
|
88
102
|
compact(): Promise<void>;
|
|
89
103
|
/**
|
|
@@ -69,6 +69,12 @@ export interface IBulkDatabase2<T extends { key: string }> {
|
|
|
69
69
|
getColumnInfo(): Promise<BulkColumnInfo[]>;
|
|
70
70
|
/** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
|
|
71
71
|
getReaderInfo(): Promise<BulkReaderInfo>;
|
|
72
|
+
/**
|
|
73
|
+
* Per-file breakdown of the on-disk files, read fresh from disk each call (latest sizes, including
|
|
74
|
+
* stream files still being appended). `bytes` is the actual on-disk size. Good for showing collection
|
|
75
|
+
* size/fragmentation and deciding whether to call tryMergeNow()/compact().
|
|
76
|
+
*/
|
|
77
|
+
getFileInfo(): Promise<{ files: { name: string; type: "bulk" | "stream"; bytes: number }[]; count: number; totalBytes: number }>;
|
|
72
78
|
|
|
73
79
|
/** Consolidate on-disk files. Optional to call; the database also does this in the background. */
|
|
74
80
|
compact(): Promise<void>;
|
|
@@ -127,4 +127,13 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
127
127
|
byteSize: number;
|
|
128
128
|
}[];
|
|
129
129
|
}>;
|
|
130
|
+
getFileInfo(): Promise<{
|
|
131
|
+
files: {
|
|
132
|
+
name: string;
|
|
133
|
+
type: "bulk" | "stream";
|
|
134
|
+
bytes: number;
|
|
135
|
+
}[];
|
|
136
|
+
count: number;
|
|
137
|
+
totalBytes: number;
|
|
138
|
+
}>;
|
|
130
139
|
}
|
|
@@ -1183,6 +1183,21 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1183
1183
|
columns: reader.columns,
|
|
1184
1184
|
};
|
|
1185
1185
|
}
|
|
1186
|
+
|
|
1187
|
+
// Per-file breakdown of the collection's on-disk files, read FRESH from disk each call (so it
|
|
1188
|
+
// reflects the latest sizes, including stream files still being appended). `bytes` is the actual
|
|
1189
|
+
// on-disk (compressed, for bulk) size. Useful for showing collection size / fragmentation, and to
|
|
1190
|
+
// decide whether to call tryMergeNow()/compact().
|
|
1191
|
+
public async getFileInfo(): Promise<{ files: { name: string; type: "bulk" | "stream"; bytes: number }[]; count: number; totalBytes: number }> {
|
|
1192
|
+
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
1193
|
+
const storage = await this.storage();
|
|
1194
|
+
const sizeOf = async (name: string) => { try { return (await storage.getInfo(name))?.size ?? 0; } catch { return 0; } };
|
|
1195
|
+
const files = [
|
|
1196
|
+
...await Promise.all(bulkFiles.map(async f => ({ name: f.fileName, type: "bulk" as const, bytes: await sizeOf(f.fileName) }))),
|
|
1197
|
+
...await Promise.all(streamFiles.map(async f => ({ name: f.fileName, type: "stream" as const, bytes: await sizeOf(f.fileName) }))),
|
|
1198
|
+
];
|
|
1199
|
+
return { files, count: files.length, totalBytes: files.reduce((a, f) => a + f.bytes, 0) };
|
|
1200
|
+
}
|
|
1186
1201
|
}
|
|
1187
1202
|
|
|
1188
1203
|
// The merged, time-resolved view over all readers. getColumn/getSingleField return the resolved value
|
|
@@ -7,6 +7,7 @@ import { css, isNode } from "typesafecss";
|
|
|
7
7
|
import { IStorageRaw } from "./IStorage";
|
|
8
8
|
import { runInSerial } from "socket-function/src/batching";
|
|
9
9
|
import { getFileStorageIndexDB } from "./IndexedDBFileFolderAPI";
|
|
10
|
+
import { getRemoteFileStorage } from "./remoteFileStorage";
|
|
10
11
|
import fs from "fs";
|
|
11
12
|
import path from "path";
|
|
12
13
|
|
|
@@ -78,6 +79,32 @@ export function setFileAPIKey(key: string) {
|
|
|
78
79
|
fileAPIKey = key;
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
// ---- remote (server) storage config ----
|
|
83
|
+
// Instead of a local folder, the user can point at a remoteFileServer.js instance (URL + password).
|
|
84
|
+
// When configured, getFileStorageNested2 serves everything from that server. Persisted in localStorage.
|
|
85
|
+
type RemoteConfig = { url: string; password: string };
|
|
86
|
+
function remoteConfigKey() { return getFileAPIKey() + ":remote"; }
|
|
87
|
+
function loadRemoteConfig(): RemoteConfig | undefined {
|
|
88
|
+
try {
|
|
89
|
+
const s = localStorage.getItem(remoteConfigKey());
|
|
90
|
+
if (!s) return undefined;
|
|
91
|
+
const c = JSON.parse(s);
|
|
92
|
+
if (c && typeof c.url === "string" && typeof c.password === "string") return c;
|
|
93
|
+
} catch { /* ignore */ }
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
function saveRemoteConfig(c: RemoteConfig) { localStorage.setItem(remoteConfigKey(), JSON.stringify(c)); }
|
|
97
|
+
|
|
98
|
+
// One shared factory (and therefore one shared range cache) per remote config.
|
|
99
|
+
let remoteFactory: { key: string; factory: ReturnType<typeof getRemoteFileStorage> } | undefined;
|
|
100
|
+
function getRemoteFactory(remote: RemoteConfig) {
|
|
101
|
+
const key = remote.url + "\0" + remote.password;
|
|
102
|
+
if (!remoteFactory || remoteFactory.key !== key) {
|
|
103
|
+
remoteFactory = { key, factory: getRemoteFileStorage(remote.url, remote.password) };
|
|
104
|
+
}
|
|
105
|
+
return remoteFactory.factory;
|
|
106
|
+
}
|
|
107
|
+
|
|
81
108
|
@observer
|
|
82
109
|
class DirectoryPrompter extends preact.Component {
|
|
83
110
|
render() {
|
|
@@ -96,6 +123,53 @@ class DirectoryPrompter extends preact.Component {
|
|
|
96
123
|
}
|
|
97
124
|
}
|
|
98
125
|
|
|
126
|
+
// "Connect to a server" option for the directory prompt: collapses to a button, expands to URL +
|
|
127
|
+
// password fields. On connect it validates the credentials against the server, persists the config, and
|
|
128
|
+
// reloads so getFileStorageNested2 picks up the remote storage.
|
|
129
|
+
@observer
|
|
130
|
+
class ServerConnectForm extends preact.Component {
|
|
131
|
+
private obs = observable({ expanded: false, url: "", password: "", error: "", connecting: false });
|
|
132
|
+
private connect = async () => {
|
|
133
|
+
const s = this.obs;
|
|
134
|
+
s.error = "";
|
|
135
|
+
s.connecting = true;
|
|
136
|
+
try {
|
|
137
|
+
const url = s.url.trim().replace(/\/+$/, "");
|
|
138
|
+
if (!url) throw new Error("Enter a server URL");
|
|
139
|
+
// A successful listing proves the URL is reachable and the password is correct.
|
|
140
|
+
await (await getRemoteFileStorage(url, s.password.trim())("")).getKeys();
|
|
141
|
+
saveRemoteConfig({ url, password: s.password.trim() });
|
|
142
|
+
window.location.reload();
|
|
143
|
+
} catch (e) {
|
|
144
|
+
s.error = "Could not connect: " + ((e as Error).message || String(e));
|
|
145
|
+
s.connecting = false;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
render() {
|
|
149
|
+
const s = this.obs;
|
|
150
|
+
const inputCss = css.fontSize(28).pad2(24, 14).width(560).maxWidth("80vw");
|
|
151
|
+
const btnCss = css.fontSize(32).pad2(60, 30);
|
|
152
|
+
if (!s.expanded) {
|
|
153
|
+
return <button className={btnCss} onClick={() => s.expanded = true}>Connect to a server</button>;
|
|
154
|
+
}
|
|
155
|
+
return (
|
|
156
|
+
<div className={css.vbox(16).center}>
|
|
157
|
+
<input className={inputCss} placeholder="https://host:8787" value={s.url}
|
|
158
|
+
onInput={e => s.url = (e.target as HTMLInputElement).value} />
|
|
159
|
+
<input className={inputCss} type="password" placeholder="password (six words)" value={s.password}
|
|
160
|
+
onInput={e => s.password = (e.target as HTMLInputElement).value} />
|
|
161
|
+
{s.error ? <div className={css.fontSize(20).color("red").maxWidth("80vw")}>{s.error}</div> : null}
|
|
162
|
+
<div className={css.hbox(16)}>
|
|
163
|
+
<button className={btnCss} disabled={s.connecting} onClick={this.connect}>
|
|
164
|
+
{s.connecting ? "Connecting…" : "Connect"}
|
|
165
|
+
</button>
|
|
166
|
+
<button className={btnCss} onClick={() => { s.expanded = false; s.error = ""; }}>Back</button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
99
173
|
export class NodeJSFileHandleWrapper implements FileWrapper {
|
|
100
174
|
constructor(private filePath: string) {
|
|
101
175
|
}
|
|
@@ -317,6 +391,7 @@ export const getDirectoryHandle = lazy(async function getDirectoryHandle(): Prom
|
|
|
317
391
|
>
|
|
318
392
|
Pick Data Directory
|
|
319
393
|
</button>
|
|
394
|
+
<ServerConnectForm />
|
|
320
395
|
<button
|
|
321
396
|
className={css.fontSize(40).pad2(80, 40)}
|
|
322
397
|
onClick={() => {
|
|
@@ -359,6 +434,7 @@ export const getDirectoryHandle = lazy(async function getDirectoryHandle(): Prom
|
|
|
359
434
|
>
|
|
360
435
|
Pick Data Directory
|
|
361
436
|
</button>
|
|
437
|
+
<ServerConnectForm />
|
|
362
438
|
<button
|
|
363
439
|
className={css.fontSize(40).pad2(80, 40)}
|
|
364
440
|
onClick={() => {
|
|
@@ -388,6 +464,10 @@ export const getFileStorageNested = cache(async function getFileStorage(path: st
|
|
|
388
464
|
export const getFileStorageNested2 = cache(async function getFileStorage(pathStr: string): Promise<FileStorage> {
|
|
389
465
|
let base: DirectoryWrapper;
|
|
390
466
|
pathStr = pathStr.replaceAll("\\", "/");
|
|
467
|
+
if (!isNode()) {
|
|
468
|
+
const remote = loadRemoteConfig();
|
|
469
|
+
if (remote) return getRemoteFactory(remote)(pathStr);
|
|
470
|
+
}
|
|
391
471
|
if (isNode()) {
|
|
392
472
|
if (path.isAbsolute(pathStr)) {
|
|
393
473
|
return wrapHandle(new NodeJSDirectoryHandleWrapper(pathStr));
|
|
@@ -424,6 +504,7 @@ export const getFileStorage = lazy(async function getFileStorage(): Promise<File
|
|
|
424
504
|
});
|
|
425
505
|
export function resetStorageLocation() {
|
|
426
506
|
localStorage.removeItem(getFileAPIKey());
|
|
507
|
+
try { localStorage.removeItem(remoteConfigKey()); } catch { /* ignore */ }
|
|
427
508
|
window.location.reload();
|
|
428
509
|
}
|
|
429
510
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare function generatePassword(wordCount: number): string;
|
|
2
|
+
export type RemoteFileServerOptions = {
|
|
3
|
+
root: string;
|
|
4
|
+
port?: number;
|
|
5
|
+
host?: string;
|
|
6
|
+
password?: string;
|
|
7
|
+
logAccess?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export type RemoteFileServerHandle = {
|
|
10
|
+
port: number;
|
|
11
|
+
password: string;
|
|
12
|
+
url: string;
|
|
13
|
+
close: () => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
export declare function startRemoteFileServer(options: RemoteFileServerOptions): Promise<RemoteFileServerHandle>;
|
|
16
|
+
export declare function runFileHoster(): Promise<void>;
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import https from "https";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "http";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import crypto from "crypto";
|
|
6
|
+
import os from "os";
|
|
7
|
+
import { execFileSync } from "child_process";
|
|
8
|
+
import { getExternalIP } from "socket-function/src/networking";
|
|
9
|
+
import { forwardPort } from "socket-function/src/forwardPort";
|
|
10
|
+
|
|
11
|
+
// Remote file server for sliftutils getRemoteFileStorage / BulkDatabase2. Serves one folder on disk over
|
|
12
|
+
// self-signed HTTPS, authenticated with an auto-generated 6-word password (sent as
|
|
13
|
+
// `Authorization: Bearer <password>`). Run it with `yarn filehoster <folder>` (or the `filehoster` bin).
|
|
14
|
+
//
|
|
15
|
+
// SECURITY: the cert is self-signed, so a browser must accept it once (open the printed https URL and
|
|
16
|
+
// click through), and the Node client connects with cert verification disabled. The password is what
|
|
17
|
+
// authorizes access; keep it secret.
|
|
18
|
+
|
|
19
|
+
// Common, memorable words (frequency-ordered subset) for passphrase generation.
|
|
20
|
+
const WORDS: string[] = [
|
|
21
|
+
"that","with","which","this","from","have","they","were","their","there","when","them","said","been",
|
|
22
|
+
"would","will","what","more","then","into","some","could","time","very","than","your","other","upon",
|
|
23
|
+
"about","only","little","like","these","great","after","well","made","before","such","over","should",
|
|
24
|
+
"first","good","must","much","down","where","know","most","here","come","those","never","life","long",
|
|
25
|
+
"came","being","many","through","even","himself","back","every","shall","again","make","without","might",
|
|
26
|
+
"while","same","under","just","still","people","place","think","house","take","last","found","away",
|
|
27
|
+
"hand","went","thought","also","though","three","another","eyes","years","work","right","once","night",
|
|
28
|
+
"young","nothing","against","small","head","left","part","ever","world","each","father","give","between",
|
|
29
|
+
"face","king","things","love","because","took","always","tell","called","water","both","side","look",
|
|
30
|
+
"having","room","mind","half","heart","name","home","country","whole","however","find","among","going",
|
|
31
|
+
"thing","lord","looked","seen","mother","general","done","seemed","told","whom","days","soon","better",
|
|
32
|
+
"letter","woman","heard","asked","course","thus","moment","knew","light","enough","white","almost",
|
|
33
|
+
"until","quite","hands","words","death","large","taken","since","gave","given","best","state","brought",
|
|
34
|
+
"does","whose","door","others","power","perhaps","present","next","morning","poor","lady","four","high",
|
|
35
|
+
"year","turned","less","word","full","during","rather","want","order","near","feet","true","miss",
|
|
36
|
+
"matter","began","cannot","used","known","felt","above","round","thou","voice","till","case","nature",
|
|
37
|
+
"indeed","church","kind","certain","fire","often","stood","fact","friend","girl","five","land","says",
|
|
38
|
+
"john","myself","along","point","dear","wife","city","within","sent","times","keep","passed","form",
|
|
39
|
+
"second","body","money","believe","hundred","open","several","means","child","english","herself","sure",
|
|
40
|
+
"looking","women","already","black","alone","least","gone","held","itself","whether","hope","river",
|
|
41
|
+
"ground","either","number","chapter","england","leave","rest","town","hear","greek","friends","book",
|
|
42
|
+
"hour","short","cried","read","behind","became","making","family","earth","captain","around","dead",
|
|
43
|
+
"reason","call","become","lost","line","replied","help","coming","speak","manner","french","twenty",
|
|
44
|
+
"spirit","really","early","story","hard","close","human","public","truth","strong","master","care",
|
|
45
|
+
"towards","history","kept","later","states","dark","able","mean","return","brother","person","subject",
|
|
46
|
+
"soul","party","arms","thee","seems","common","fell","fine","feel","show","table","turn","wish","evening",
|
|
47
|
+
"free","cause","south","ready","north","across","rose","live","account","doubt","company","miles","road",
|
|
48
|
+
"bring","london","sense","horse","carried","hold","sight","fear","answer","idea","force","need","deep",
|
|
49
|
+
"further","nearly","past","army","blood","street","court","reached","view","school","sort","taking",
|
|
50
|
+
"else","chief","hours","beyond","cold","none","longer","strange","fellow","clear","service","natural",
|
|
51
|
+
"suppose","late","talk","front","stand","purpose","seem","neither","ought","west","real","except","sound",
|
|
52
|
+
"gold","forward","feeling","added","boys","self","peace","happy","living","husband","toward","spoke",
|
|
53
|
+
"fair","france","trees","effect","latter","length","change","died","green","united","fall","pretty",
|
|
54
|
+
"placed","meet","forth","office","comes","pass","written","ship","enemy","saying","tree","foot","blue",
|
|
55
|
+
"note","prince","hair","heaven","wild","play","entered","society","laid","wind","doing","paper","opened",
|
|
56
|
+
"bear","third","queen","mine","greater","various","faith","wanted","boat","stone","lived","george",
|
|
57
|
+
"window","doctor","action","books","tried","letters","makes","minutes","parts","wood","period","duty",
|
|
58
|
+
"york","instead","heavy","persons","battle","field","british","object","century","island","beauty",
|
|
59
|
+
"christ","sister","glad","below","east","opinion","save","places","months","food","sweet","trouble",
|
|
60
|
+
"system","born","desire","works","mary","chance","william","seven","single","hardly","sleep","mouth",
|
|
61
|
+
"horses","silence","ancient","broken","hall","send","rich","girls","besides","lips","henry","figure",
|
|
62
|
+
"slowly","hill","wall","future","lines","follow","german","march","filled","brown","deal","eight",
|
|
63
|
+
"covered","smile","former","week","drew","easy","paris","camp","stay","cross","seeing","result","started",
|
|
64
|
+
"caught","houses","appear","wrote","giving","simple","arrived","formed","bright","wide","uncle","piece",
|
|
65
|
+
"walked","knows","carry","middle","village","holy","merely","getting","raised","afraid","struck",
|
|
66
|
+
"charles","board","visit","royal","spring","dinner","evil","outside","wrong","summer","moved","danger",
|
|
67
|
+
"mark","fresh","plain","thirty","scene","quickly","wonder","jack","indian","walk","learned","class",
|
|
68
|
+
"secret","wait","command","easily","garden","loved","married","fight","leaving","music","usual","cases",
|
|
69
|
+
"winter","respect","value","quiet","please","stopped","write","laughed","america","chair","paid","duke",
|
|
70
|
+
"leaves","lower","colonel","perfect","james","worth","author","finally","youth","regard","glass",
|
|
71
|
+
"picture","built","modern","success","race","showed","tears","unless","mere","lives","grew","charge",
|
|
72
|
+
"beside","remain","waiting","study","hath","shot","unto","tone","iron","watch","grace","flowers","killed",
|
|
73
|
+
"allowed","news","step","proper","sitting","floor","justice","noble","goes","walls","laws","meant",
|
|
74
|
+
"bound","forms","fifty","drawn","private","fast","indians","judge","meeting","usually","sudden","gives",
|
|
75
|
+
"reach","bank","attempt","shore","stop","plan","silent","special","spot","officer","silver","passing",
|
|
76
|
+
"broke","lake","soft","journey","beneath","shown","turning","members","enter","lead","trade","names",
|
|
77
|
+
"escape","troops","bill","corner","rule","ladies","species","rock","similar","running","rate","cast",
|
|
78
|
+
"nation","post","likely","simply","orders","dress","fish","minute","birds","europe","passage","surface",
|
|
79
|
+
"snow","attack","higher","rise","grand","reply","honour","notice","speech","trying","game","writing",
|
|
80
|
+
"closed","rome","example","aunt","learn","morrow","offered","peter","equal","space","page","wished",
|
|
81
|
+
"changed","animal","social","twelve","appears","receive","valley","degree","train","steps","fixed",
|
|
82
|
+
"count","forest","matters","foreign","native","broad","moral","animals","safe","roman","break","pale",
|
|
83
|
+
"memory","warm","trust","yellow","sides","main","bird","reading","coast","pain","grave","forget","move",
|
|
84
|
+
"fortune","madame","proved","forty","lying","quick","wise","jesus","working","surely","looks","seat",
|
|
85
|
+
"divine","touch","loss","paul","heads","spent","ordered","report","caused","thomas","ones","drawing",
|
|
86
|
+
"talking","breath","meaning","month","union","pleased","powers","emperor","laugh","affairs","narrow",
|
|
87
|
+
"square","science","begin","promise","takes","conduct","serious","thank","curious","pieces","health",
|
|
88
|
+
"exactly","ways","upper","decided","nine","stream","wine","engaged","points","amount","named","song",
|
|
89
|
+
"worse","size","castle","crown","mass","liberty","stage","guard","servant","greatly","price","weeks",
|
|
90
|
+
"neck","ears","drink","crowd","thick","fallen","hills","spoken","golden","capital","sign","spite","terms",
|
|
91
|
+
"sake","council","threw","ships","portion","support","clothes","effort","fully","holding","majesty",
|
|
92
|
+
"glory","pure","facts","sword","sorry","fate","bread","prove","station","sharp","dream","vain","major",
|
|
93
|
+
"smiled","instant","spread","serve","sick","june","ideas","thrown","start","weather","gray","courage",
|
|
94
|
+
"anxious","woods","path","rising","grass","moon","gate","forced","fancy","bottom","expect","remains",
|
|
95
|
+
"shook","bishop","watched","settled","events","rain","quarter","heat","palace","glance","kingdom",
|
|
96
|
+
"papers","aside","worthy","taste","pride","july","opening","daily","leading","wounded","famous","offer",
|
|
97
|
+
"group","distant","weight","highest","vast","popular","passion","knowing","allow","sought","lies",
|
|
98
|
+
"marked","series","growing","measure","hearts","robert","obliged","western","hence","style","dropped",
|
|
99
|
+
"nations","grown","honor","edge","pray","tall","frank","poet","streets","season","pointed","mighty",
|
|
100
|
+
"temple","extent","thin","blow","freedom","entire","keeping","prevent","shut","storm","lose","college",
|
|
101
|
+
"cost","marry","spirits","worked","colour","ring","listen","fruit","waters","fashion","share","hung",
|
|
102
|
+
"proud","fingers","method","served","grow","fort","draw","dollars","quietly","yours","vessel","direct",
|
|
103
|
+
"refused","bridge","gods","inside","title","advance","priest","becomes","dick","double","seek","guess",
|
|
104
|
+
"spanish","larger","ball","finding","edward","removed","brave","tired","agreed","sacred","sons","guns",
|
|
105
|
+
"played","richard","devil","pounds","honest","false","cloth","soldier","tongue","smith","wealth","failed",
|
|
106
|
+
"height","faces","equally","legs","pocket","twice","volume","prayer","county","notes","louis","unknown",
|
|
107
|
+
"delight","rights","nice","taught","coat","dare","slight","rocks","enemies","control","forces","germany",
|
|
108
|
+
"kill","process","david","rapidly","comfort","islands","centre","salt","april","windows","noticed",
|
|
109
|
+
"truly","color","fields","date","august","desired","breast","skin","gentle","smoke","search","joined",
|
|
110
|
+
"nobody","results","needed","weak","type","stones","follows","theory","shape","waited","telling","relief",
|
|
111
|
+
"bearing","bent","mile","dressed","birth","spain","female","clearly","moving","drive","crossed","member",
|
|
112
|
+
"saved","teeth","flesh","cover","shows","farther","stands","figures","numbers","bodies","supply","motion",
|
|
113
|
+
"grey","bell","alive","seized","shadow","produce","touched","driven","talked","empire","sunday","pair",
|
|
114
|
+
"fourth","slave","regular","dozen","beat","cousin","sand","soil","rough","claim","angry","pity","walking",
|
|
115
|
+
"acts","pope","rooms","task","stock","tender","throw","reader","india","hotel","fifteen","arrival",
|
|
116
|
+
"plate","stars","willing","stories","saint","amongst","virtue","chamber","civil","yards","habit","writer",
|
|
117
|
+
"putting","origin","italy","hearing","genius","milk","busy","fool","burning","duties","brain","slow",
|
|
118
|
+
"belief","bore","mention","grant","library","inches","press","calm","total","ride","lands","labor",
|
|
119
|
+
"parties","clean","catch","treated","rode","whilst","level","efforts","minds","welcome","wisdom","supper",
|
|
120
|
+
"final","mercy","aware","lovely","empty","wants","midst","central","rank","proof","burst","term","farm",
|
|
121
|
+
"applied","loud","nearer","harry","closely","kings","explain","deck","noise","hurt","advice","absence",
|
|
122
|
+
"circle","objects","secure","plants","parents","falling","base","fellows","sorrow","flat","flower",
|
|
123
|
+
"calling","imagine","range","sold","favour","happen","showing","earl","hole","careful","dust","prison",
|
|
124
|
+
"useful","accept","firm","sing","port","seated","custom","wore","doors","lifted","edition","tale",
|
|
125
|
+
"affair","policy","roof","express","ahead","worship","partly","address","highly","victory","ocean",
|
|
126
|
+
"begun","buried","content","smiling","liked","active","quality","philip","current","divided","growth",
|
|
127
|
+
"huge","moments","article","speed","poetry","machine","join","nose","unable","ceased","gained","worn",
|
|
128
|
+
"eastern","safety","ages","vessels","hopes","corn","slaves","drop","plant","evident","local","police",
|
|
129
|
+
"october","obtain","italian","deeply","dying","wholly","dogs","playing","plenty","apart","suffer","maid",
|
|
130
|
+
"record","fairly","kindly","terror","drove","ends","sugar","capable","irish","message","chosen","flight",
|
|
131
|
+
"reasons","couple","meat","printed","blind","energy","bitter","baby","mistake","tower","ireland","trial",
|
|
132
|
+
"wear","brief","market","band","causes","clouds","goods","admit","cities","earlier","tells","stated",
|
|
133
|
+
"pressed","crime","fought","guide","hoped","list","banks","knight","fleet","pulled","tail","patient",
|
|
134
|
+
"mental","boats","kinds","stairs","star","cattle","rare","wings","disease","section","actual","bare",
|
|
135
|
+
"extreme","cruel","worst","escaped","dance","strike","views","eggs","needs","store","choice","fond",
|
|
136
|
+
"adopted","suit","singing","fail","souls","thanks","hidden","shame","faint","shop","older","joseph",
|
|
137
|
+
"knees","gently","blessed","yard","cent","grief","male","sooner","january","excited","region","praise",
|
|
138
|
+
"throne","classes","effects","demand","gain","fault","labour","variety","loose","cook","devoted","ended",
|
|
139
|
+
"changes","witness","bought","lover","visited","club","sheep","cloud","enjoy","asleep","cool","dull",
|
|
140
|
+
"painted","arose","armed","dignity","chiefly","voices","sail","event","signs","hero","schools","branch",
|
|
141
|
+
"hurried","arthur","kitchen","stick","coffee","thinks","eager","natives","flying","boston","eternal",
|
|
142
|
+
"source","fill","anger","vision","murder","viii","park","avoid","awful","raise","desert","plans","pardon",
|
|
143
|
+
"assured","wound","finger","bible","rear","details","cabin","whence","reign","weary","slavery","visible",
|
|
144
|
+
"shouted","seldom","skill","hast","throat","reality","leader","egypt","credit","smaller","severe","calls",
|
|
145
|
+
"younger","shell","sprang","anybody","handed","despair","asking","inch","mystery","manners","knife",
|
|
146
|
+
"design","sounds","wishes","stepped","towns","sixty","immense","china","favor","latin","hate","setting",
|
|
147
|
+
"excuse","chinese","behold","rushed","choose","ease","lights","paused","mission","voyage","gift","bold",
|
|
148
|
+
"meal","britain","smooth","mounted","utterly","lest","helped","picked","falls","pipe","bones","earnest",
|
|
149
|
+
"granted","swept","anxiety","teach","waves","softly","savage","hunting","defence","rapid","artist",
|
|
150
|
+
"recent","supreme","russian","created","habits","exist","guilty","pages","crew","hell","remark","request",
|
|
151
|
+
"harm","solemn","observe","trail","copy","violent","dreams","proceed","luck","steel","sell","temper",
|
|
152
|
+
"appeal","hide","fled","belong","triumph","abroad","waste","consent","writers","possess","jews","require",
|
|
153
|
+
"priests","railway","founded","pushed","host","solid","maybe","degrees","cheeks","mount","ruin","owing",
|
|
154
|
+
"fierce","reduced","mankind","text","swift","riding","silk","shade","career","wicked","afford","alas",
|
|
155
|
+
"rules","treaty","correct","spend","foolish","methods","remarks","horror","shining","enjoyed","tribes",
|
|
156
|
+
"lack","frame","gets","eagerly","russia","alike","somehow","nodded","problem","fever","sees","stern",
|
|
157
|
+
"dawn","forgive","flag","managed","fame","travel","estate","cotton","hurry","agree","feared","kiss",
|
|
158
|
+
"million","martin","mixed","risk","butter","jane","assumed","pause","benefit","unhappy","lighted",
|
|
159
|
+
"scheme","slept","plainly","forgot","delay","merry","rush","wooden","secured","poem","rolled","angel",
|
|
160
|
+
"guests","farmer","humble","glanced","shortly","rope","image","lately","africa","fired","retired","bull",
|
|
161
|
+
"steam","keen","loving","borne","readily","beloved","average","teacher","attend","flame","area","burned",
|
|
162
|
+
"thrust","negro","nurse","spare","fifth","thence","roads","refuse","tent","kissed","gates","gaze","fatal",
|
|
163
|
+
"israel","verse","scenes","confess","uttered","build","acted","hanging","reward","issue","utmost",
|
|
164
|
+
"fathers","noted","widow","related","urged","mode","failure","nervous","render","masters","tribe",
|
|
165
|
+
"colored","turns","alarm","rivers","using","eleven","rage","hers","lamp","retreat","amid","gospel",
|
|
166
|
+
"exposed","johnson","marks","tide","emotion","destroy","inner","sisters","lion","helen","tied","grounds",
|
|
167
|
+
"acid","wheel","issued","invited","gazed","senate","finds","seed","regret","clay","chain","crowded",
|
|
168
|
+
"bosom","limited","steady","chap","anne","francis","cavalry","freely","charm","faced","ideal","useless",
|
|
169
|
+
"warning","shelter","vote","xpage","cottage","beast","outer","folks","misery","anyone","expense","firmly",
|
|
170
|
+
"eating","lonely","owner","trace","coal","hastily","elder","utter","begins","chapel","nights","dared",
|
|
171
|
+
"signal","stared","track","lords","speaks","bottle","blame","claims","shoes","sigh","ghost","folk",
|
|
172
|
+
"dutch","flung","flew","cheek","staff","walter","clever","acting","hungry","poems","cutting","forever",
|
|
173
|
+
"exact","haste","dancing","trip","lincoln","pull","vice","slipped","thine","loves","permit","mill",
|
|
174
|
+
"engine","hollow","seeking","bowed","largely","charged","painful","madam","roll","leaf","prayers",
|
|
175
|
+
"element","agent","cries","songs","theatre","creek","match","ashamed","runs","aspect","canada","treat",
|
|
176
|
+
"legal","arts","corps","noon","nearest","scale","hunt","shadows","winds","landed","beings","tiny",
|
|
177
|
+
"gardens","bless","clerk","doth","derived","hang","heavens","cape","ours","brow","test","senses",
|
|
178
|
+
"opposed","error","driving","nest","headed","groups","princes","feast","admiral","poured","brings",
|
|
179
|
+
"pursued","germans","bears","lesson","journal","unusual","marched","wing","remove","studied","passes",
|
|
180
|
+
"actions","burden","colony","scott","bride","yield","crying","forming","rifle","powder","rested","shake",
|
|
181
|
+
"guest","begged","occur","altar","sorts","beaten","wave","papa","sang","depth","aloud","contact",
|
|
182
|
+
"intense","marble","poverty","detail","writes","root","metal","jean","existed","ability","purple",
|
|
183
|
+
"counsel","lane","wives","sending","heavily","leaders","queer","tales","sins","mexico","protect","clock",
|
|
184
|
+
"stuff","jones","pine","records","cave","baron","medical","pick","stayed","magic","seventy","pour",
|
|
185
|
+
"saddle","sank","dread","deny","wire","rude","holds","strain","autumn","shed","shoot","settle","basis",
|
|
186
|
+
"readers","route","beach","safely","oxford","notion","thunder","sale","saints","pound","shock","elected",
|
|
187
|
+
"signed","whereas","maiden","courts","rarely","wolf","mamma","billy","student","charity","impulse",
|
|
188
|
+
"tones","mortal","raising","wedding","belongs","chest","sharply","sounded","poets","lawyer","hugh",
|
|
189
|
+
"plays","awake","landing","debt","alice","venture","idle","uniform","contain","tobacco","boots","fears",
|
|
190
|
+
"leaned","studies","customs","prize","stir","obey","oath","badly","pursuit","resting",
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
export function generatePassword(wordCount: number): string {
|
|
194
|
+
const words: string[] = [];
|
|
195
|
+
for (let i = 0; i < wordCount; i++) words.push(WORDS[crypto.randomInt(WORDS.length)]);
|
|
196
|
+
return words.join(" ");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The password is always a list of words, so we compare case-insensitively and ignore any non-letter
|
|
200
|
+
// characters. That makes it forgiving of voice dictation and mobile autocorrect (spacing, capitals,
|
|
201
|
+
// stray punctuation) without meaningfully weakening a 6-word passphrase.
|
|
202
|
+
function normalizePassword(p: string): string {
|
|
203
|
+
return String(p || "").toLowerCase().replace(/[^a-z]/g, "");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Generate (once) and cache a self-signed key + cert via openssl, outside the served folder so its
|
|
207
|
+
// private key is never reachable through the API.
|
|
208
|
+
function getCert(): { key: Buffer; cert: Buffer } {
|
|
209
|
+
const dir = path.join(os.homedir(), ".sliftutils-remote");
|
|
210
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
211
|
+
const keyPath = path.join(dir, "key.pem");
|
|
212
|
+
const certPath = path.join(dir, "cert.pem");
|
|
213
|
+
if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
|
|
214
|
+
try {
|
|
215
|
+
execFileSync("openssl", [
|
|
216
|
+
"req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
|
217
|
+
"-keyout", keyPath, "-out", certPath, "-days", "3650",
|
|
218
|
+
"-subj", "/CN=sliftutils-remote",
|
|
219
|
+
"-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1",
|
|
220
|
+
], { stdio: "ignore" });
|
|
221
|
+
} catch (e) {
|
|
222
|
+
throw new Error("Failed to generate a self-signed certificate with openssl (is openssl installed?): " + (e as Error).message);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function timingSafeEqualStr(a: string, b: string): boolean {
|
|
229
|
+
const ab = Buffer.from(a || "", "utf8");
|
|
230
|
+
const bb = Buffer.from(b || "", "utf8");
|
|
231
|
+
if (ab.length !== bb.length) return false;
|
|
232
|
+
return crypto.timingSafeEqual(ab, bb);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Resolve a client-supplied relative path against the root, refusing anything that escapes it.
|
|
236
|
+
function safeResolve(root: string, rel: string | null): string | undefined {
|
|
237
|
+
if (rel == null) rel = "";
|
|
238
|
+
if (rel.indexOf("\0") >= 0) return undefined;
|
|
239
|
+
const full = path.resolve(root, "." + path.sep + rel.replace(/\\/g, "/"));
|
|
240
|
+
if (full !== root && !full.startsWith(root + path.sep)) return undefined;
|
|
241
|
+
return full;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readBody(req: IncomingMessage): Promise<Buffer> {
|
|
245
|
+
return new Promise((resolve, reject) => {
|
|
246
|
+
const chunks: Buffer[] = [];
|
|
247
|
+
req.on("data", c => chunks.push(c as Buffer));
|
|
248
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
249
|
+
req.on("error", reject);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function formatBytes(n: number): string {
|
|
254
|
+
if (n < 1024) return n + "B";
|
|
255
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + "KB";
|
|
256
|
+
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + "MB";
|
|
257
|
+
return (n / 1024 / 1024 / 1024).toFixed(2) + "GB";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export type RemoteFileServerOptions = {
|
|
261
|
+
root: string;
|
|
262
|
+
port?: number;
|
|
263
|
+
host?: string;
|
|
264
|
+
password?: string;
|
|
265
|
+
// When true, log batched per-(client, file) access totals every 5s. The CLI turns this on; the
|
|
266
|
+
// library (tests) leaves it off.
|
|
267
|
+
logAccess?: boolean;
|
|
268
|
+
};
|
|
269
|
+
export type RemoteFileServerHandle = { port: number; password: string; url: string; close: () => Promise<void> };
|
|
270
|
+
|
|
271
|
+
export function startRemoteFileServer(options: RemoteFileServerOptions): Promise<RemoteFileServerHandle> {
|
|
272
|
+
const root = path.resolve(options.root);
|
|
273
|
+
if (!fs.existsSync(root)) fs.mkdirSync(root, { recursive: true });
|
|
274
|
+
const port = options.port ?? 8787;
|
|
275
|
+
const host = options.host || "0.0.0.0";
|
|
276
|
+
const password = options.password || generatePassword(6);
|
|
277
|
+
const normPassword = normalizePassword(password);
|
|
278
|
+
const { key, cert } = getCert();
|
|
279
|
+
|
|
280
|
+
// Batched access log: aggregate per (ip, op, file) so hammering one file logs once per 5s with the
|
|
281
|
+
// total request count and bytes, instead of a line per request.
|
|
282
|
+
type Acc = { ip: string; op: string; path: string; count: number; bytes: number };
|
|
283
|
+
const accessLog = new Map<string, Acc>();
|
|
284
|
+
let flushTimer: ReturnType<typeof setInterval> | undefined;
|
|
285
|
+
const recordAccess = (ip: string, op: string, p: string, bytes: number) => {
|
|
286
|
+
if (!options.logAccess) return;
|
|
287
|
+
const k = ip + "|" + op + "|" + p;
|
|
288
|
+
let e = accessLog.get(k);
|
|
289
|
+
if (!e) { e = { ip, op, path: p, count: 0, bytes: 0 }; accessLog.set(k, e); }
|
|
290
|
+
e.count++;
|
|
291
|
+
e.bytes += bytes;
|
|
292
|
+
};
|
|
293
|
+
if (options.logAccess) {
|
|
294
|
+
flushTimer = setInterval(() => {
|
|
295
|
+
if (!accessLog.size) return;
|
|
296
|
+
const rows = [...accessLog.values()].sort((a, b) => b.bytes - a.bytes);
|
|
297
|
+
accessLog.clear();
|
|
298
|
+
for (const e of rows) console.log(` [${e.op}] ${e.ip} ${e.path} ${e.count}x ${formatBytes(e.bytes)}`);
|
|
299
|
+
}, 5000);
|
|
300
|
+
flushTimer.unref?.();
|
|
301
|
+
}
|
|
302
|
+
const clientIp = (req: IncomingMessage) => (req.socket.remoteAddress || "?").replace(/^::ffff:/, "");
|
|
303
|
+
|
|
304
|
+
const server = https.createServer({ key, cert }, async (req: IncomingMessage, res: ServerResponse) => {
|
|
305
|
+
const origin = (req.headers["origin"] as string) || "*";
|
|
306
|
+
const cors: Record<string, string> = {
|
|
307
|
+
"Access-Control-Allow-Origin": origin,
|
|
308
|
+
"Access-Control-Allow-Methods": "GET, PUT, POST, DELETE, OPTIONS",
|
|
309
|
+
"Access-Control-Allow-Headers": "authorization, content-type",
|
|
310
|
+
"Access-Control-Max-Age": "86400",
|
|
311
|
+
"Vary": "Origin",
|
|
312
|
+
};
|
|
313
|
+
const send = (status: number, headers: Record<string, string>, body?: Buffer | string) => {
|
|
314
|
+
res.writeHead(status, Object.assign({}, cors, headers));
|
|
315
|
+
res.end(body);
|
|
316
|
+
};
|
|
317
|
+
const sendJson = (status: number, obj: unknown) => send(status, { "Content-Type": "application/json" }, JSON.stringify(obj));
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
if (req.method === "OPTIONS") return send(204, {});
|
|
321
|
+
|
|
322
|
+
const auth = (req.headers["authorization"] as string) || "";
|
|
323
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
324
|
+
if (!timingSafeEqualStr(normalizePassword(token), normPassword)) return sendJson(401, { error: "unauthorized" });
|
|
325
|
+
|
|
326
|
+
const url = new URL(req.url || "/", "https://localhost");
|
|
327
|
+
const op = url.pathname;
|
|
328
|
+
const relPath = url.searchParams.get("path") || "";
|
|
329
|
+
const full = safeResolve(root, relPath);
|
|
330
|
+
if (full === undefined) return sendJson(403, { error: "path escapes root" });
|
|
331
|
+
|
|
332
|
+
if (req.method === "GET" && op === "/list") {
|
|
333
|
+
const wantFolders = url.searchParams.get("folders") === "1";
|
|
334
|
+
let entries: fs.Dirent[];
|
|
335
|
+
try { entries = fs.readdirSync(full, { withFileTypes: true }); }
|
|
336
|
+
catch (e) { return (e as NodeJS.ErrnoException).code === "ENOENT" ? sendJson(200, []) : sendJson(500, { error: (e as Error).message }); }
|
|
337
|
+
return sendJson(200, entries.filter(d => wantFolders ? d.isDirectory() : d.isFile()).map(d => d.name));
|
|
338
|
+
}
|
|
339
|
+
if (req.method === "GET" && op === "/info") {
|
|
340
|
+
let st: fs.Stats;
|
|
341
|
+
try { st = fs.statSync(full); } catch { return sendJson(404, { error: "not found" }); }
|
|
342
|
+
return sendJson(200, { size: st.size, lastModified: st.mtimeMs });
|
|
343
|
+
}
|
|
344
|
+
if (req.method === "GET" && op === "/hasDir") {
|
|
345
|
+
let st: fs.Stats; try { st = fs.statSync(full); } catch { return sendJson(200, { exists: false }); }
|
|
346
|
+
return sendJson(200, { exists: st.isDirectory() });
|
|
347
|
+
}
|
|
348
|
+
if (req.method === "GET" && op === "/read") {
|
|
349
|
+
let st: fs.Stats;
|
|
350
|
+
try { st = fs.statSync(full); } catch { return sendJson(404, { error: "not found" }); }
|
|
351
|
+
let start = parseInt(url.searchParams.get("start") || "0", 10);
|
|
352
|
+
let end = url.searchParams.get("end") != null ? parseInt(url.searchParams.get("end") as string, 10) : st.size;
|
|
353
|
+
start = Math.min(Math.max(start, 0), st.size);
|
|
354
|
+
end = Math.min(Math.max(end, start), st.size);
|
|
355
|
+
recordAccess(clientIp(req), "read", relPath, end - start);
|
|
356
|
+
res.writeHead(200, Object.assign({}, cors, { "Content-Type": "application/octet-stream", "Content-Length": String(end - start) }));
|
|
357
|
+
if (end === start) return res.end();
|
|
358
|
+
fs.createReadStream(full, { start, end: end - 1 }).on("error", () => res.end()).pipe(res);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if ((req.method === "PUT" || req.method === "POST") && (op === "/append" || op === "/set")) {
|
|
362
|
+
const body = await readBody(req);
|
|
363
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
364
|
+
if (op === "/append") fs.appendFileSync(full, body);
|
|
365
|
+
else fs.writeFileSync(full, body);
|
|
366
|
+
recordAccess(clientIp(req), "write", relPath, body.length);
|
|
367
|
+
return sendJson(200, { ok: true });
|
|
368
|
+
}
|
|
369
|
+
if (req.method === "DELETE" && op === "/remove") {
|
|
370
|
+
try { fs.unlinkSync(full); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") return sendJson(500, { error: (e as Error).message }); }
|
|
371
|
+
return sendJson(200, { ok: true });
|
|
372
|
+
}
|
|
373
|
+
if (req.method === "DELETE" && op === "/removeDir") {
|
|
374
|
+
try { fs.rmSync(full, { recursive: true, force: true }); } catch (e) { return sendJson(500, { error: (e as Error).message }); }
|
|
375
|
+
return sendJson(200, { ok: true });
|
|
376
|
+
}
|
|
377
|
+
if (req.method === "POST" && op === "/reset") {
|
|
378
|
+
try { for (const name of fs.readdirSync(full)) fs.rmSync(path.join(full, name), { recursive: true, force: true }); }
|
|
379
|
+
catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") return sendJson(500, { error: (e as Error).message }); }
|
|
380
|
+
return sendJson(200, { ok: true });
|
|
381
|
+
}
|
|
382
|
+
return sendJson(404, { error: "unknown endpoint" });
|
|
383
|
+
} catch (e) {
|
|
384
|
+
try { sendJson(500, { error: String((e as Error)?.message || e) }); } catch { /* response already sent */ }
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
return new Promise<RemoteFileServerHandle>((resolve, reject) => {
|
|
389
|
+
server.on("error", reject);
|
|
390
|
+
server.listen(port, host, () => {
|
|
391
|
+
const addr = server.address();
|
|
392
|
+
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
393
|
+
resolve({
|
|
394
|
+
port: actualPort,
|
|
395
|
+
password,
|
|
396
|
+
url: `https://localhost:${actualPort}`,
|
|
397
|
+
close: () => new Promise<void>(r => { if (flushTimer) clearInterval(flushTimer); server.close(() => r()); }),
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// CLI entry (invoked by bin/filehoster.js or `yarn filehoster <folder>`). Starts the server, logs the
|
|
404
|
+
// password + local/public URLs + batched access, and keeps the UPnP port mapping alive.
|
|
405
|
+
export async function runFileHoster(): Promise<void> {
|
|
406
|
+
const args = process.argv.slice(2);
|
|
407
|
+
const root = args.find(a => !a.startsWith("--") && !a.endsWith(".ts") && !a.endsWith(".js"));
|
|
408
|
+
if (!root) {
|
|
409
|
+
console.error("Usage: yarn filehoster <folder> [--port N] (set a fixed password with PASSWORD=...)");
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
|
412
|
+
const portIdx = args.indexOf("--port");
|
|
413
|
+
const port = portIdx >= 0 ? parseInt(args[portIdx + 1], 10) : 8787;
|
|
414
|
+
|
|
415
|
+
const info = await startRemoteFileServer({ root, port, password: process.env.PASSWORD, logAccess: true });
|
|
416
|
+
let externalIP: string | undefined;
|
|
417
|
+
try { externalIP = (await getExternalIP()).trim(); } catch { /* offline / unreachable */ }
|
|
418
|
+
|
|
419
|
+
console.log("");
|
|
420
|
+
console.log(" Serving: " + path.resolve(root));
|
|
421
|
+
console.log(" Password: " + info.password);
|
|
422
|
+
console.log(" Local: " + info.url);
|
|
423
|
+
if (externalIP) console.log(" Public: https://" + externalIP + ":" + info.port + " (once port-forwarding succeeds)");
|
|
424
|
+
console.log("");
|
|
425
|
+
console.log(" In the app, choose \"Connect to a server\" and enter the URL + password.");
|
|
426
|
+
console.log(" (Self-signed cert: open the URL once in your browser and accept it first.)");
|
|
427
|
+
console.log("");
|
|
428
|
+
|
|
429
|
+
// Keep the UPnP port mapping alive — leases expire ~hourly, so refresh well within that. No-op on
|
|
430
|
+
// Linux (forwardPort returns early there).
|
|
431
|
+
const refresh = async () => {
|
|
432
|
+
try { await forwardPort({ externalPort: info.port, internalPort: info.port }); }
|
|
433
|
+
catch (e) { console.warn(" port forwarding failed:", (e as Error).message); }
|
|
434
|
+
};
|
|
435
|
+
await refresh();
|
|
436
|
+
setInterval(refresh, 30 * 60 * 1000);
|
|
437
|
+
|
|
438
|
+
console.log(" [access] request logging on (batched every 5s)\n");
|
|
439
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
/// <reference types="node" />
|
|
4
|
+
import https from "https";
|
|
5
|
+
import type { FileStorage } from "./FileFolderAPI";
|
|
6
|
+
export type RemoteFileStorageOptions = {
|
|
7
|
+
chunkBytes?: number;
|
|
8
|
+
cacheBytes?: number;
|
|
9
|
+
latencyMs?: number;
|
|
10
|
+
};
|
|
11
|
+
type Connection = {
|
|
12
|
+
url: string;
|
|
13
|
+
password: string;
|
|
14
|
+
latencyMs: number;
|
|
15
|
+
agent: https.Agent | undefined;
|
|
16
|
+
cache: RangeCache;
|
|
17
|
+
stats: {
|
|
18
|
+
requestCount: number;
|
|
19
|
+
bytesFetched: number;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
declare class RangeCache {
|
|
23
|
+
private chunkBytes;
|
|
24
|
+
private budget;
|
|
25
|
+
private chunks;
|
|
26
|
+
private bytes;
|
|
27
|
+
constructor(chunkBytes: number, budget: number);
|
|
28
|
+
private key;
|
|
29
|
+
private peek;
|
|
30
|
+
private store;
|
|
31
|
+
invalidate(path: string): void;
|
|
32
|
+
getRange(conn: Connection, path: string, start: number, end: number): Promise<Buffer | undefined>;
|
|
33
|
+
}
|
|
34
|
+
export type RemoteStorageFactory = ((path: string) => Promise<FileStorage>) & {
|
|
35
|
+
stats: Connection["stats"];
|
|
36
|
+
};
|
|
37
|
+
export declare function getRemoteFileStorage(url: string, password: string, options?: RemoteFileStorageOptions): RemoteStorageFactory;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { isNode } from "typesafecss";
|
|
2
|
+
import https from "https";
|
|
3
|
+
import type { FileStorage, NestedFileStorage } from "./FileFolderAPI";
|
|
4
|
+
|
|
5
|
+
// Client for remoteFileServer.js: exposes a remote folder as a FileStorage (so BulkDatabase2 runs over
|
|
6
|
+
// the network unchanged). One HTTP round trip per operation. A pure data-level range cache (below)
|
|
7
|
+
// fetches in large aligned chunks and reuses them, because over the network latency dominates — reading
|
|
8
|
+
// 1MB costs about the same as 64KB, so fewer round trips wins.
|
|
9
|
+
|
|
10
|
+
const EMPTY = Buffer.alloc(0);
|
|
11
|
+
|
|
12
|
+
// Over the network, fetch reads in chunks this big and cache them. Big because each request pays a
|
|
13
|
+
// full round trip; over-reading a bit is far cheaper than another round trip.
|
|
14
|
+
const DEFAULT_CHUNK_BYTES = 1024 * 1024;
|
|
15
|
+
const DEFAULT_CACHE_BYTES = 128 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
export type RemoteFileStorageOptions = {
|
|
18
|
+
// Bytes per cached chunk (aligned). Reads coalesce/serve from these.
|
|
19
|
+
chunkBytes?: number;
|
|
20
|
+
// Max total bytes held in the range cache (LRU eviction).
|
|
21
|
+
cacheBytes?: number;
|
|
22
|
+
// Artificial per-request delay, for simulating network latency in tests.
|
|
23
|
+
latencyMs?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Connection = {
|
|
27
|
+
url: string;
|
|
28
|
+
password: string;
|
|
29
|
+
latencyMs: number;
|
|
30
|
+
agent: https.Agent | undefined;
|
|
31
|
+
cache: RangeCache;
|
|
32
|
+
// Observable stats (handy for tests / diagnostics).
|
|
33
|
+
stats: { requestCount: number; bytesFetched: number };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
|
|
37
|
+
|
|
38
|
+
// One HTTP request. Node uses the https module (self-signed cert → verification disabled); the browser
|
|
39
|
+
// uses fetch (the user accepts the self-signed cert once). Returns the raw status + body bytes.
|
|
40
|
+
async function httpRequest(conn: Connection, method: string, op: string, params: Record<string, string>, body?: Buffer): Promise<{ status: number; body: Buffer }> {
|
|
41
|
+
if (conn.latencyMs > 0) await sleep(conn.latencyMs);
|
|
42
|
+
conn.stats.requestCount++;
|
|
43
|
+
const qs = new URLSearchParams(params).toString();
|
|
44
|
+
const fullUrl = conn.url.replace(/\/+$/, "") + op + (qs ? "?" + qs : "");
|
|
45
|
+
const headers: Record<string, string> = { authorization: "Bearer " + conn.password };
|
|
46
|
+
if (body) headers["content-type"] = "application/octet-stream";
|
|
47
|
+
|
|
48
|
+
if (isNode()) {
|
|
49
|
+
return await new Promise((resolve, reject) => {
|
|
50
|
+
const u = new URL(fullUrl);
|
|
51
|
+
const req = https.request({
|
|
52
|
+
hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers, agent: conn.agent,
|
|
53
|
+
}, res => {
|
|
54
|
+
const chunks: Buffer[] = [];
|
|
55
|
+
res.on("data", d => chunks.push(d as Buffer));
|
|
56
|
+
res.on("end", () => resolve({ status: res.statusCode || 0, body: Buffer.concat(chunks) }));
|
|
57
|
+
});
|
|
58
|
+
req.on("error", reject);
|
|
59
|
+
if (body) req.write(body);
|
|
60
|
+
req.end();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const res = await fetch(fullUrl, { method, headers, body: body ? new Uint8Array(body) : undefined });
|
|
64
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
65
|
+
return { status: res.status, body: buf };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readRange(conn: Connection, path: string, start: number, end: number): Promise<Buffer | undefined> {
|
|
69
|
+
const r = await httpRequest(conn, "GET", "/read", { path, start: String(start), end: String(end) });
|
|
70
|
+
if (r.status === 404) return undefined;
|
|
71
|
+
if (r.status !== 200) throw new Error(`remote read failed (${r.status}): ${r.body.toString("utf8").slice(0, 200)}`);
|
|
72
|
+
conn.stats.bytesFetched += r.body.length;
|
|
73
|
+
return r.body;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Caches the longest-known prefix of each aligned chunk per path. Safe because every file here is either
|
|
77
|
+
// immutable (bulk files, written once under a unique name) or append-only (stream files): the bytes at a
|
|
78
|
+
// given offset never change, only more are added. So a cached [0, n) of a chunk is always valid; a read
|
|
79
|
+
// that needs MORE than we have refetches (and picks up appended bytes). It never distinguishes file
|
|
80
|
+
// types — it only does range reads.
|
|
81
|
+
class RangeCache {
|
|
82
|
+
private chunks = new Map<string, Buffer>(); // key: path + "" + chunkIndex (insertion-ordered → LRU)
|
|
83
|
+
private bytes = 0;
|
|
84
|
+
constructor(private chunkBytes: number, private budget: number) { }
|
|
85
|
+
|
|
86
|
+
private key(path: string, c: number) { return path + "" + c; }
|
|
87
|
+
private peek(path: string, c: number): Buffer | undefined {
|
|
88
|
+
const k = this.key(path, c);
|
|
89
|
+
const v = this.chunks.get(k);
|
|
90
|
+
if (v) { this.chunks.delete(k); this.chunks.set(k, v); } // bump to most-recent
|
|
91
|
+
return v;
|
|
92
|
+
}
|
|
93
|
+
private store(path: string, c: number, buf: Buffer) {
|
|
94
|
+
const k = this.key(path, c);
|
|
95
|
+
const ex = this.chunks.get(k);
|
|
96
|
+
if (ex && ex.length >= buf.length) { this.chunks.delete(k); this.chunks.set(k, ex); return; } // keep longer prefix
|
|
97
|
+
if (ex) this.bytes -= ex.length;
|
|
98
|
+
this.chunks.delete(k);
|
|
99
|
+
this.chunks.set(k, buf);
|
|
100
|
+
this.bytes += buf.length;
|
|
101
|
+
while (this.bytes > this.budget && this.chunks.size > 0) {
|
|
102
|
+
const oldest = this.chunks.keys().next().value as string;
|
|
103
|
+
this.bytes -= (this.chunks.get(oldest) as Buffer).length;
|
|
104
|
+
this.chunks.delete(oldest);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
invalidate(path: string) {
|
|
108
|
+
const prefix = path + "";
|
|
109
|
+
for (const k of [...this.chunks.keys()]) {
|
|
110
|
+
if (k.startsWith(prefix)) { this.bytes -= (this.chunks.get(k) as Buffer).length; this.chunks.delete(k); }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async getRange(conn: Connection, path: string, start: number, end: number): Promise<Buffer | undefined> {
|
|
115
|
+
if (end <= start) return EMPTY;
|
|
116
|
+
const CHUNK = this.chunkBytes;
|
|
117
|
+
const firstChunk = Math.floor(start / CHUNK);
|
|
118
|
+
const lastChunk = Math.floor((end - 1) / CHUNK);
|
|
119
|
+
// Find the contiguous span of chunks we don't have enough of, and fetch it in one request.
|
|
120
|
+
let fetchFrom = -1, fetchTo = -1;
|
|
121
|
+
for (let c = firstChunk; c <= lastChunk; c++) {
|
|
122
|
+
const cStart = c * CHUNK;
|
|
123
|
+
const needEnd = Math.min(end, cStart + CHUNK) - cStart;
|
|
124
|
+
const have = this.peek(path, c);
|
|
125
|
+
if (!have || have.length < needEnd) { if (fetchFrom < 0) fetchFrom = c; fetchTo = c; }
|
|
126
|
+
}
|
|
127
|
+
if (fetchFrom >= 0) {
|
|
128
|
+
const bytes = await readRange(conn, path, fetchFrom * CHUNK, (fetchTo + 1) * CHUNK);
|
|
129
|
+
if (bytes === undefined) return undefined; // file missing
|
|
130
|
+
for (let c = fetchFrom; c <= fetchTo; c++) {
|
|
131
|
+
const off = (c - fetchFrom) * CHUNK;
|
|
132
|
+
if (off >= bytes.length) break; // hit EOF — no bytes for this chunk
|
|
133
|
+
this.store(path, c, bytes.subarray(off, Math.min(off + CHUNK, bytes.length)));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const parts: Buffer[] = [];
|
|
137
|
+
for (let c = firstChunk; c <= lastChunk; c++) {
|
|
138
|
+
const cStart = c * CHUNK;
|
|
139
|
+
const from = Math.max(start, cStart) - cStart;
|
|
140
|
+
const to = Math.min(end, cStart + CHUNK) - cStart;
|
|
141
|
+
const chunk = this.peek(path, c);
|
|
142
|
+
if (!chunk || chunk.length < to) {
|
|
143
|
+
// File ended before the requested end — return what's available (callers read within EOF).
|
|
144
|
+
if (chunk && chunk.length > from) parts.push(chunk.subarray(from, chunk.length));
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
parts.push(chunk.subarray(from, to));
|
|
148
|
+
}
|
|
149
|
+
return parts.length === 1 ? parts[0] : Buffer.concat(parts);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function makeStorage(conn: Connection, basePath: string): FileStorage {
|
|
154
|
+
const rel = (key: string) => basePath ? basePath + "/" + key : key;
|
|
155
|
+
|
|
156
|
+
const folder: NestedFileStorage = {
|
|
157
|
+
async hasKey(key: string): Promise<boolean> {
|
|
158
|
+
const r = await httpRequest(conn, "GET", "/hasDir", { path: rel(key) });
|
|
159
|
+
if (r.status !== 200) return false;
|
|
160
|
+
return !!JSON.parse(r.body.toString("utf8")).exists;
|
|
161
|
+
},
|
|
162
|
+
async getStorage(key: string): Promise<FileStorage> {
|
|
163
|
+
return makeStorage(conn, rel(key));
|
|
164
|
+
},
|
|
165
|
+
async removeStorage(key: string): Promise<void> {
|
|
166
|
+
await httpRequest(conn, "DELETE", "/removeDir", { path: rel(key) });
|
|
167
|
+
conn.cache.invalidate(rel(key));
|
|
168
|
+
},
|
|
169
|
+
async getKeys(): Promise<string[]> {
|
|
170
|
+
const r = await httpRequest(conn, "GET", "/list", { path: basePath, folders: "1" });
|
|
171
|
+
if (r.status !== 200) throw new Error(`remote list failed (${r.status})`);
|
|
172
|
+
return JSON.parse(r.body.toString("utf8"));
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
async getInfo(key: string) {
|
|
178
|
+
const r = await httpRequest(conn, "GET", "/info", { path: rel(key) });
|
|
179
|
+
if (r.status === 404) return undefined;
|
|
180
|
+
if (r.status !== 200) throw new Error(`remote info failed (${r.status})`);
|
|
181
|
+
return JSON.parse(r.body.toString("utf8")) as { size: number; lastModified: number };
|
|
182
|
+
},
|
|
183
|
+
async get(key: string) {
|
|
184
|
+
const info = await this.getInfo(key);
|
|
185
|
+
if (!info) return undefined;
|
|
186
|
+
return this.getRange(key, { start: 0, end: info.size });
|
|
187
|
+
},
|
|
188
|
+
async getRange(key: string, config: { start: number; end: number }) {
|
|
189
|
+
return conn.cache.getRange(conn, rel(key), config.start, config.end);
|
|
190
|
+
},
|
|
191
|
+
async append(key: string, value: Buffer) {
|
|
192
|
+
const r = await httpRequest(conn, "PUT", "/append", { path: rel(key) }, value);
|
|
193
|
+
if (r.status !== 200) throw new Error(`remote append failed (${r.status})`);
|
|
194
|
+
// No invalidation needed: append-only files keep their prefix; a read past it refetches.
|
|
195
|
+
},
|
|
196
|
+
async set(key: string, value: Buffer) {
|
|
197
|
+
const r = await httpRequest(conn, "PUT", "/set", { path: rel(key) }, value);
|
|
198
|
+
if (r.status !== 200) throw new Error(`remote set failed (${r.status})`);
|
|
199
|
+
conn.cache.invalidate(rel(key));
|
|
200
|
+
},
|
|
201
|
+
async remove(key: string) {
|
|
202
|
+
await httpRequest(conn, "DELETE", "/remove", { path: rel(key) });
|
|
203
|
+
conn.cache.invalidate(rel(key));
|
|
204
|
+
},
|
|
205
|
+
async getKeys(includeFolders: boolean = false) {
|
|
206
|
+
const r = await httpRequest(conn, "GET", "/list", { path: basePath, folders: includeFolders ? "1" : "0" });
|
|
207
|
+
if (r.status !== 200) throw new Error(`remote list failed (${r.status})`);
|
|
208
|
+
return JSON.parse(r.body.toString("utf8"));
|
|
209
|
+
},
|
|
210
|
+
async reset() {
|
|
211
|
+
await httpRequest(conn, "POST", "/reset", { path: basePath });
|
|
212
|
+
conn.cache.invalidate(basePath);
|
|
213
|
+
},
|
|
214
|
+
folder,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type RemoteStorageFactory = ((path: string) => Promise<FileStorage>) & { stats: Connection["stats"] };
|
|
219
|
+
|
|
220
|
+
// Returns a StorageFactory (path -> FileStorage) backed by a remoteFileServer.js instance. Drop-in for
|
|
221
|
+
// getFileStorageNested2 in BulkDatabase2. `password` is the 6-word password the server printed.
|
|
222
|
+
export function getRemoteFileStorage(url: string, password: string, options: RemoteFileStorageOptions = {}): RemoteStorageFactory {
|
|
223
|
+
const conn: Connection = {
|
|
224
|
+
url,
|
|
225
|
+
password,
|
|
226
|
+
latencyMs: options.latencyMs || 0,
|
|
227
|
+
// Self-signed cert: skip verification in Node. (The browser path uses fetch, where the user has
|
|
228
|
+
// already accepted the cert.) The password is what authorizes access.
|
|
229
|
+
agent: isNode() ? new https.Agent({ rejectUnauthorized: false }) : undefined,
|
|
230
|
+
cache: new RangeCache(options.chunkBytes || DEFAULT_CHUNK_BYTES, options.cacheBytes || DEFAULT_CACHE_BYTES),
|
|
231
|
+
stats: { requestCount: 0, bytesFetched: 0 },
|
|
232
|
+
};
|
|
233
|
+
const factory = ((path: string) => Promise.resolve(makeStorage(conn, path.replace(/^\/+|\/+$/g, "")))) as RemoteStorageFactory;
|
|
234
|
+
factory.stats = conn.stats;
|
|
235
|
+
return factory;
|
|
236
|
+
}
|