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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.5",
3
+ "version": "1.4.7",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -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 = 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();
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
- <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}
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). &nbsp; 2. Accept the security warning (Advanced → Proceed). &nbsp; 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 &amp; 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>&#10003; 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
+ }