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.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ require("typenode");
4
+ require("../storage/remoteFileServer").runFileHoster();
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.3",
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
- "bin": {
32
- "build-nodejs": "./builders/nodeJSBuildRun.js",
33
- "buildnodejs": "./builders/nodeJSBuildRun.js",
34
- "build-extension": "./builders/extensionBuildRun.js",
35
- "buildextension": "./builders/extensionBuildRun.js",
36
- "build-web": "./builders/webBuildRun.js",
37
- "buildweb": "./builders/webBuildRun.js",
38
- "build-electron": "./builders/electronBuildRun.js",
39
- "buildelectron": "./builders/electronBuildRun.js",
40
- "slift-watch": "./builders/watchRun.js",
41
- "sliftwatch": "./builders/watchRun.js",
42
- "slift-setup": "./builders/setupRun.js",
43
- "sliftsetup": "./builders/setupRun.js"
44
- },
45
- "dependencies": {
46
- "@types/chrome": "^0.0.237",
47
- "@types/node-forge": "^1.3.11",
48
- "@types/shell-quote": "^1.7.5",
49
- "acme-client": "^5.0.0",
50
- "js-sha256": "^0.11.1",
51
- "mobx": "^6.13.3",
52
- "preact-old-types": "^10.28.1",
53
- "shell-quote": "^1.8.3",
54
- "socket-function": "^1.1.42",
55
- "typenode": "*",
56
- "typesafecss": "*",
57
- "ws": "^8.18.3",
58
- "yargs": "15.4.1"
59
- },
60
- "resolutions": {
61
- "preact": "npm:preact-old-types@*"
62
- },
63
- "devDependencies": {
64
- "@types/yargs": "15.0.19",
65
- "debugbreak": "^0.9.9",
66
- "electron": "^33.2.1",
67
- "open": "^8.4.0",
68
- "typedev": "^0.1.1"
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
- onClick={e => {
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
+ }