sliftutils 1.4.5 → 1.4.7
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/index.d.ts +9 -0
- package/package.json +1 -1
- package/storage/FileFolderAPI.tsx +40 -13
- package/storage/remoteFileServer.ts +13 -2
- package/storage/remoteFileStorage.d.ts +9 -0
- package/storage/remoteFileStorage.ts +24 -0
package/index.d.ts
CHANGED
|
@@ -1898,6 +1898,15 @@ declare module "sliftutils/storage/remoteFileStorage" {
|
|
|
1898
1898
|
stats: Connection["stats"];
|
|
1899
1899
|
};
|
|
1900
1900
|
export declare function getRemoteFileStorage(url: string, password: string, options?: RemoteFileStorageOptions): RemoteStorageFactory;
|
|
1901
|
+
export type RemoteProbeResult = {
|
|
1902
|
+
status: "ok";
|
|
1903
|
+
} | {
|
|
1904
|
+
status: "unauthorized";
|
|
1905
|
+
} | {
|
|
1906
|
+
status: "unreachable";
|
|
1907
|
+
error: string;
|
|
1908
|
+
};
|
|
1909
|
+
export declare function probeRemoteConnection(url: string, password: string): Promise<RemoteProbeResult>;
|
|
1901
1910
|
export {};
|
|
1902
1911
|
|
|
1903
1912
|
}
|
package/package.json
CHANGED
|
@@ -7,7 +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
|
+
import { getRemoteFileStorage, probeRemoteConnection } from "./remoteFileStorage";
|
|
11
11
|
import fs from "fs";
|
|
12
12
|
import path from "path";
|
|
13
13
|
|
|
@@ -128,20 +128,32 @@ class DirectoryPrompter extends preact.Component {
|
|
|
128
128
|
// reloads so getFileStorageNested2 picks up the remote storage.
|
|
129
129
|
@observer
|
|
130
130
|
class ServerConnectForm extends preact.Component {
|
|
131
|
-
private obs = observable({ expanded: false, url: "", password: "", error: "", connecting: false });
|
|
131
|
+
private obs = observable({ expanded: false, url: "", password: "", error: "", connecting: false, needsCert: false, showPassword: false });
|
|
132
|
+
private cleanUrl() { return this.obs.url.trim().replace(/\/+$/, ""); }
|
|
132
133
|
private connect = async () => {
|
|
133
134
|
const s = this.obs;
|
|
134
135
|
s.error = "";
|
|
136
|
+
s.needsCert = false;
|
|
135
137
|
s.connecting = true;
|
|
136
138
|
try {
|
|
137
|
-
const url =
|
|
138
|
-
if (!url)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
const url = this.cleanUrl();
|
|
140
|
+
if (!url) { s.error = "Enter a server URL"; return; }
|
|
141
|
+
const result = await probeRemoteConnection(url, s.password.trim());
|
|
142
|
+
if (result.status === "ok") {
|
|
143
|
+
saveRemoteConfig({ url, password: s.password.trim() });
|
|
144
|
+
window.location.reload();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (result.status === "unauthorized") {
|
|
148
|
+
s.error = "The server rejected that password.";
|
|
149
|
+
} else {
|
|
150
|
+
// Reached nothing back — almost always the self-signed certificate isn't trusted yet.
|
|
151
|
+
s.needsCert = true;
|
|
152
|
+
s.error = "Couldn't reach the server.";
|
|
153
|
+
}
|
|
143
154
|
} catch (e) {
|
|
144
155
|
s.error = "Could not connect: " + ((e as Error).message || String(e));
|
|
156
|
+
} finally {
|
|
145
157
|
s.connecting = false;
|
|
146
158
|
}
|
|
147
159
|
};
|
|
@@ -156,14 +168,29 @@ class ServerConnectForm extends preact.Component {
|
|
|
156
168
|
<div className={css.vbox(16).center}>
|
|
157
169
|
<input className={inputCss} placeholder="https://host:8787" value={s.url}
|
|
158
170
|
onInput={e => s.url = (e.target as HTMLInputElement).value} />
|
|
159
|
-
<
|
|
160
|
-
|
|
161
|
-
|
|
171
|
+
<div className={css.hbox(10).center}>
|
|
172
|
+
<input className={inputCss} type={s.showPassword ? "text" : "password"} placeholder="password (six words)" value={s.password}
|
|
173
|
+
onInput={e => s.password = (e.target as HTMLInputElement).value} />
|
|
174
|
+
<button className={css.fontSize(22).pad2(24, 12)} onClick={() => s.showPassword = !s.showPassword}>
|
|
175
|
+
{s.showPassword ? "Hide" : "Show"}
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
{s.error ? <div className={css.fontSize(22).color("red").maxWidth("80vw")}>{s.error}</div> : null}
|
|
179
|
+
{s.needsCert ? (
|
|
180
|
+
<div className={css.vbox(10).center.fontSize(20).maxWidth(620).maxWidth("80vw").textAlign("center").color("hsl(0, 0%, 25%)")}>
|
|
181
|
+
<div>This server uses a self-signed certificate, so your browser has to trust it once:</div>
|
|
182
|
+
<div>1. Open the server in a new tab (button below). 2. Accept the security warning (Advanced → Proceed). 3. Come back and click Retry.</div>
|
|
183
|
+
<button className={css.fontSize(26).pad2(40, 20)}
|
|
184
|
+
onClick={() => { const u = this.cleanUrl(); if (u) window.open(u + "/", "_blank"); }}>
|
|
185
|
+
Open server & accept certificate
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
) : null}
|
|
162
189
|
<div className={css.hbox(16)}>
|
|
163
190
|
<button className={btnCss} disabled={s.connecting} onClick={this.connect}>
|
|
164
|
-
{s.connecting ? "Connecting…" : "Connect"}
|
|
191
|
+
{s.connecting ? "Connecting…" : s.needsCert ? "Retry" : "Connect"}
|
|
165
192
|
</button>
|
|
166
|
-
<button className={btnCss} onClick={() => { s.expanded = false; s.error = ""; }}>Back</button>
|
|
193
|
+
<button className={btnCss} onClick={() => { s.expanded = false; s.error = ""; s.needsCert = false; }}>Back</button>
|
|
167
194
|
</div>
|
|
168
195
|
</div>
|
|
169
196
|
);
|
|
@@ -257,6 +257,11 @@ function formatBytes(n: number): string {
|
|
|
257
257
|
return (n / 1024 / 1024 / 1024).toFixed(2) + "GB";
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
// Public (no-auth) landing page. The browser can't trust a self-signed cert from a background fetch, so
|
|
261
|
+
// the user opens this URL once, accepts the browser's security warning, and the cert becomes trusted for
|
|
262
|
+
// the origin — then the app's fetches work. This page is what they see after accepting.
|
|
263
|
+
const CERT_LANDING_HTML = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>sliftutils file server</title></head><body style="font-family:system-ui,sans-serif;max-width:34em;margin:3em auto;padding:0 1em;line-height:1.5;color:#222"><h2>✓ Certificate accepted</h2><p>This is your <b>sliftutils file server</b>. Your browser now trusts its self-signed certificate for this address.</p><p>Return to the app and click <b>Retry</b> (or <b>Connect</b>) to finish connecting with your password.</p><p style="color:#888;font-size:.9em">You can close this tab.</p></body></html>`;
|
|
264
|
+
|
|
260
265
|
export type RemoteFileServerOptions = {
|
|
261
266
|
root: string;
|
|
262
267
|
port?: number;
|
|
@@ -319,12 +324,18 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
|
|
|
319
324
|
try {
|
|
320
325
|
if (req.method === "OPTIONS") return send(204, {});
|
|
321
326
|
|
|
327
|
+
const url = new URL(req.url || "/", "https://localhost");
|
|
328
|
+
const op = url.pathname;
|
|
329
|
+
|
|
330
|
+
// Public landing page so the user can open the URL once to accept the self-signed cert.
|
|
331
|
+
if (req.method === "GET" && (op === "/" || op === "/index.html")) {
|
|
332
|
+
return send(200, { "Content-Type": "text/html; charset=utf-8" }, CERT_LANDING_HTML);
|
|
333
|
+
}
|
|
334
|
+
|
|
322
335
|
const auth = (req.headers["authorization"] as string) || "";
|
|
323
336
|
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
324
337
|
if (!timingSafeEqualStr(normalizePassword(token), normPassword)) return sendJson(401, { error: "unauthorized" });
|
|
325
338
|
|
|
326
|
-
const url = new URL(req.url || "/", "https://localhost");
|
|
327
|
-
const op = url.pathname;
|
|
328
339
|
const relPath = url.searchParams.get("path") || "";
|
|
329
340
|
const full = safeResolve(root, relPath);
|
|
330
341
|
if (full === undefined) return sendJson(403, { error: "path escapes root" });
|
|
@@ -35,4 +35,13 @@ export type RemoteStorageFactory = ((path: string) => Promise<FileStorage>) & {
|
|
|
35
35
|
stats: Connection["stats"];
|
|
36
36
|
};
|
|
37
37
|
export declare function getRemoteFileStorage(url: string, password: string, options?: RemoteFileStorageOptions): RemoteStorageFactory;
|
|
38
|
+
export type RemoteProbeResult = {
|
|
39
|
+
status: "ok";
|
|
40
|
+
} | {
|
|
41
|
+
status: "unauthorized";
|
|
42
|
+
} | {
|
|
43
|
+
status: "unreachable";
|
|
44
|
+
error: string;
|
|
45
|
+
};
|
|
46
|
+
export declare function probeRemoteConnection(url: string, password: string): Promise<RemoteProbeResult>;
|
|
38
47
|
export {};
|
|
@@ -234,3 +234,27 @@ export function getRemoteFileStorage(url: string, password: string, options: Rem
|
|
|
234
234
|
factory.stats = conn.stats;
|
|
235
235
|
return factory;
|
|
236
236
|
}
|
|
237
|
+
|
|
238
|
+
export type RemoteProbeResult =
|
|
239
|
+
| { status: "ok" }
|
|
240
|
+
| { status: "unauthorized" }
|
|
241
|
+
// Couldn't reach the server at all — in the browser this is usually the self-signed certificate not
|
|
242
|
+
// being trusted yet (fetch throws with no detail), but also covers a wrong URL / server down / CORS.
|
|
243
|
+
| { status: "unreachable"; error: string };
|
|
244
|
+
|
|
245
|
+
// Probes a remote server: distinguishes "connected" from "wrong password" from "couldn't reach it". The
|
|
246
|
+
// browser can't tell a cert-distrust from other network failures (fetch just throws), so the UI treats
|
|
247
|
+
// `unreachable` as "you probably need to accept the self-signed certificate first".
|
|
248
|
+
export async function probeRemoteConnection(url: string, password: string): Promise<RemoteProbeResult> {
|
|
249
|
+
try {
|
|
250
|
+
await (await getRemoteFileStorage(url, password)("")).getKeys();
|
|
251
|
+
return { status: "ok" };
|
|
252
|
+
} catch (e) {
|
|
253
|
+
const msg = (e as Error)?.message || String(e);
|
|
254
|
+
// Our client throws "remote ... failed (NNN)" when it actually got an HTTP response (so the cert
|
|
255
|
+
// is trusted); a thrown fetch with no status means we never connected.
|
|
256
|
+
const m = msg.match(/\((\d{3})\)/);
|
|
257
|
+
if (m) return m[1] === "401" ? { status: "unauthorized" } : { status: "unreachable", error: msg };
|
|
258
|
+
return { status: "unreachable", error: msg };
|
|
259
|
+
}
|
|
260
|
+
}
|