@putkoff/abstract-securefiles 0.0.51 → 0.0.53

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,162 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import type { SecureFilesApi } from "./api";
3
+ import type { Device } from "./types";
4
+ import { ConfirmButton } from "./ConfirmButton";
5
+ import { CopyButton } from "./CopyButton";
6
+
7
+ const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
8
+
9
+ const UNIX_CMD = "pipx install abstract-securefiles && pipx ensurepath && ~/.local/bin/securefiles setup";
10
+ const WIN_CMD = "py -m pip install --user pipx; py -m pipx install abstract-securefiles; py -m pipx ensurepath";
11
+
12
+ function ago(iso: string | null) {
13
+ if (!iso) return "never";
14
+ const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
15
+ if (secs < 90) return "just now";
16
+ if (secs < 3600) return `${Math.round(secs / 60)} min ago`;
17
+ if (secs < 86400) return `${Math.round(secs / 3600)} h ago`;
18
+ return `${Math.round(secs / 86400)} d ago`;
19
+ }
20
+
21
+ function AddComputer({ api, onPaired }: { api: SecureFilesApi; onPaired: () => void }) {
22
+ const [code, setCode] = useState<string | null>(null);
23
+ const [expiresAt, setExpiresAt] = useState(0);
24
+ const [, setTick] = useState(0);
25
+ const [error, setError] = useState<string | null>(null);
26
+
27
+ useEffect(() => {
28
+ if (!code) return;
29
+ const timer = setInterval(() => {
30
+ if (Date.now() > expiresAt) setCode(null);
31
+ setTick((t) => t + 1);
32
+ onPaired();
33
+ }, 5000);
34
+ return () => clearInterval(timer);
35
+ }, [code, expiresAt, onPaired]);
36
+
37
+ const create = async () => {
38
+ setError(null);
39
+ try {
40
+ const res = await api.pairDevice();
41
+ setCode(res.code);
42
+ setExpiresAt(Date.now() + res.expires_in * 1000);
43
+ } catch (err) {
44
+ setError(errText(err));
45
+ }
46
+ };
47
+ const minutesLeft = Math.max(1, Math.round((expiresAt - Date.now()) / 60000));
48
+
49
+ if (!code) {
50
+ return (
51
+ <div className="sf-row">
52
+ <button type="button" className="sf-btn sf-btn--primary" onClick={create}>
53
+ Add a computer
54
+ </button>
55
+ {error && <span className="sf-error">{error}</span>}
56
+ </div>
57
+ );
58
+ }
59
+ return (
60
+ <div className="sf-card">
61
+ <h4>Connect a computer</h4>
62
+ <ol className="sf-steps">
63
+ <li>
64
+ On that computer, install the agent (needs Python 3.9+):
65
+ <div className="sf-os">
66
+ <b>Linux / macOS</b> (Terminal)
67
+ <div className="sf-row">
68
+ <code className="sf-codeline">{UNIX_CMD}</code>
69
+ <CopyButton text={UNIX_CMD} label="Copy" />
70
+ </div>
71
+ <span className="sf-muted">
72
+ No pipx? <code>python3 -m pip install --user pipx</code> then use <code>python3 -m pipx</code>. On a Mac
73
+ without Python, run <code>python3</code> once and accept Apple's developer-tools install.
74
+ </span>
75
+ </div>
76
+ <div className="sf-os">
77
+ <b>Windows</b> (PowerShell; install Python from python.org first)
78
+ <div className="sf-row">
79
+ <code className="sf-codeline">{WIN_CMD}</code>
80
+ <CopyButton text={WIN_CMD} label="Copy" />
81
+ </div>
82
+ <span className="sf-muted">Then open a new PowerShell window and run <code>securefiles setup</code>.</span>
83
+ </div>
84
+ </li>
85
+ <li>
86
+ In the panel that opens (<code>http://127.0.0.1:47821</code>), enter this code, or run{" "}
87
+ <code>securefiles pair {code}</code>:
88
+ <div className="sf-row">
89
+ <span className="sf-paircode">{code}</span>
90
+ <CopyButton text={code} label="Copy" />
91
+ </div>
92
+ <span className="sf-muted">Valid for about {minutesLeft} more minutes, once.</span>
93
+ </li>
94
+ <li>
95
+ Pick files and folders to share in that panel (or <code>securefiles share PATH</code>). They appear here
96
+ under <code>devices/&lt;computer&gt;/</code>, where you set links, passwords and limits.
97
+ </li>
98
+ </ol>
99
+ <div className="sf-row">
100
+ <button type="button" className="sf-btn" onClick={() => setCode(null)}>
101
+ Done
102
+ </button>
103
+ </div>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ export function MyComputers({
109
+ api,
110
+ devices,
111
+ onChange,
112
+ }: {
113
+ api: SecureFilesApi;
114
+ devices: Device[];
115
+ onChange: () => void;
116
+ }) {
117
+ const [error, setError] = useState<string | null>(null);
118
+ return (
119
+ <details className="sf-admin">
120
+ <summary>
121
+ My computers{" "}
122
+ <span className="sf-muted">
123
+ ({devices.filter((d) => d.online).length} online / {devices.length})
124
+ </span>
125
+ </summary>
126
+ <div className="sf-stack">
127
+ <p className="sf-muted">
128
+ Share files and folders straight from your own computers — nothing is uploaded. Downloads stream from the
129
+ computer through this site with the same links, passwords, limits and resume, while it's online.
130
+ </p>
131
+ {devices.length > 0 && (
132
+ <ul className="sf-roots">
133
+ {devices.map((d) => (
134
+ <li key={d.id}>
135
+ <span>
136
+ <span className={`sf-dot${d.online ? " sf-dot--on" : ""}`} /> <b>{d.name}</b>{" "}
137
+ <span className="sf-muted">
138
+ {d.online ? "online" : `offline · last seen ${ago(d.last_seen)}`}
139
+ {d.platform ? ` · ${d.platform}` : ""}
140
+ </span>
141
+ </span>
142
+ <ConfirmButton
143
+ label="Remove"
144
+ onConfirm={async () => {
145
+ try {
146
+ await api.removeDevice(d.id);
147
+ onChange();
148
+ } catch (err) {
149
+ setError(errText(err));
150
+ }
151
+ }}
152
+ />
153
+ </li>
154
+ ))}
155
+ </ul>
156
+ )}
157
+ {error && <p className="sf-error">{error}</p>}
158
+ <AddComputer api={api} onPaired={onChange} />
159
+ </div>
160
+ </details>
161
+ );
162
+ }
@@ -1,11 +1,12 @@
1
1
  import React, { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { createApi } from "./api";
3
- import type { SecureFile, SecureFilesProps, ShareUpdate } from "./types";
3
+ import type { Device, SecureFile, SecureFilesProps, ShareUpdate } from "./types";
4
4
  import { UploadPanel } from "./UploadPanel";
5
5
  import { ShareDialog } from "./ShareDialog";
6
6
  import { CopyButton } from "./CopyButton";
7
7
  import { ConfirmButton } from "./ConfirmButton";
8
8
  import { ServerFiles } from "./ServerFiles";
9
+ import { MyComputers } from "./MyComputers";
9
10
  import "./styles.css";
10
11
 
11
12
  interface FolderNode {
@@ -40,6 +41,7 @@ export default function SecureFiles({ apiBase }: SecureFilesProps) {
40
41
  const [files, setFiles] = useState<SecureFile[]>([]);
41
42
  const [user, setUser] = useState<string | null>(null);
42
43
  const [isAdmin, setIsAdmin] = useState(false);
44
+ const [devices, setDevices] = useState<Device[]>([]);
43
45
  const [loading, setLoading] = useState(true);
44
46
  const [error, setError] = useState<string | null>(null);
45
47
  const [notice, setNotice] = useState<string | null>(null);
@@ -55,6 +57,7 @@ export default function SecureFiles({ apiBase }: SecureFilesProps) {
55
57
  const data = await api.list();
56
58
  setUser(data.user_name);
57
59
  setIsAdmin(data.is_admin);
60
+ setDevices(data.devices || []);
58
61
  setFiles(data.files);
59
62
  setSelected((prev) => new Set([...prev].filter((id) => data.files.some((f) => f.id === id))));
60
63
  } catch (err) {
@@ -163,7 +166,7 @@ export default function SecureFiles({ apiBase }: SecureFilesProps) {
163
166
  />
164
167
  </td>
165
168
  <td style={{ paddingLeft: 10 + depth * 20 }}>
166
- <span className={f.available ? undefined : "sf-strike"}>{f.filename}</span>
169
+ <span className={f.available || f.device ? undefined : "sf-strike"}>{f.filename}</span>
167
170
  {f.kind === "folder" && (
168
171
  <span className="sf-tag" title="Downloads as one uncompressed, resumable .tar of the whole folder">
169
172
  folder · .tar
@@ -174,7 +177,15 @@ export default function SecureFiles({ apiBase }: SecureFilesProps) {
174
177
  linked
175
178
  </span>
176
179
  )}
177
- {!f.available && (
180
+ {f.device && (
181
+ <span
182
+ className={`sf-tag${f.device.online ? "" : " sf-tag--warn"}`}
183
+ title="Served from your computer through the relay"
184
+ >
185
+ on {f.device.name} · {f.device.online ? "online" : "offline"}
186
+ </span>
187
+ )}
188
+ {!f.available && !f.device && (
178
189
  <span
179
190
  className="sf-tag sf-tag--warn"
180
191
  title={f.linked ? "Source file is gone or its directory is no longer allowed" : "File is missing on disk"}
@@ -213,7 +224,13 @@ export default function SecureFiles({ apiBase }: SecureFilesProps) {
213
224
  >
214
225
  ⚙️
215
226
  </button>
216
- <ConfirmButton label={f.linked ? "Unlink" : "Delete"} disabled={busy} onConfirm={() => remove([f])} />
227
+ {f.device ? (
228
+ <span className="sf-muted sf-small" title="Stop sharing it from that computer's Secure Files panel">
229
+ managed on {f.device.name}
230
+ </span>
231
+ ) : (
232
+ <ConfirmButton label={f.linked ? "Unlink" : "Delete"} disabled={busy} onConfirm={() => remove([f])} />
233
+ )}
217
234
  </div>
218
235
  </td>
219
236
  </tr>
@@ -258,7 +275,9 @@ export default function SecureFiles({ apiBase }: SecureFilesProps) {
258
275
  {user && <span className="sf-muted">Signed in as {user}</span>}
259
276
  </header>
260
277
 
261
- <UploadPanel api={api} folders={folders} onUploaded={load} />
278
+ <UploadPanel api={api} folders={folders.filter((d) => d !== "devices" && !d.startsWith("devices/"))} onUploaded={load} />
279
+
280
+ <MyComputers api={api} devices={devices} onChange={load} />
262
281
 
263
282
  {isAdmin && <ServerFiles api={api} folders={folders} onLinked={load} />}
264
283
 
package/src/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BrowseResult, LinkRoot, RootChallenge, SecureFile, ShareUpdate } from "./types";
1
+ import type { BrowseResult, Device, LinkRoot, RootChallenge, SecureFile, ShareUpdate } from "./types";
2
2
 
3
3
  export const DEFAULT_API_BASE = "https://api.abstractendeavors.com";
4
4
 
@@ -34,7 +34,13 @@ export function createApi(apiBase: string = DEFAULT_API_BASE) {
34
34
  }
35
35
 
36
36
  return {
37
- list: () => json<{ user_name: string; is_admin: boolean; files: SecureFile[] }>("/userInfo"),
37
+ list: () => json<{ user_name: string; is_admin: boolean; files: SecureFile[]; devices: Device[] }>("/userInfo"),
38
+
39
+ devices: () => json<{ devices: Device[] }>("/devices"),
40
+
41
+ pairDevice: () => json<{ code: string; expires_in: number }>("/devices/pair", { method: "POST", body: "{}" }),
42
+
43
+ removeDevice: (id: number) => json<{ devices: Device[] }>(`/devices/${id}`, { method: "DELETE" }),
38
44
 
39
45
  roots: () => json<{ roots: LinkRoot[] }>("/admin/roots"),
40
46
 
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { default as SecureFiles } from "./SecureFiles";
2
2
  export { createApi, DEFAULT_API_BASE, ApiError } from "./api";
3
3
  export type { SecureFilesApi } from "./api";
4
- export type { SecureFile, ShareUpdate, SecureFilesProps, LinkRoot, RootChallenge, BrowseResult } from "./types";
4
+ export type { SecureFile, ShareUpdate, SecureFilesProps, LinkRoot, RootChallenge, BrowseResult, Device } from "./types";
package/src/styles.css CHANGED
@@ -440,3 +440,47 @@
440
440
  border-bottom: none;
441
441
  padding: 0;
442
442
  }
443
+ .sf-dot {
444
+ display: inline-block;
445
+ width: 9px;
446
+ height: 9px;
447
+ border-radius: 50%;
448
+ background: var(--sf-danger);
449
+ vertical-align: middle;
450
+ }
451
+ .sf-dot--on {
452
+ background: #16a34a;
453
+ }
454
+ .sf-steps {
455
+ margin: 0;
456
+ padding-left: 20px;
457
+ display: flex;
458
+ flex-direction: column;
459
+ gap: 10px;
460
+ }
461
+ .sf-steps li > .sf-row {
462
+ margin: 4px 0;
463
+ }
464
+ .sf-codeline {
465
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
466
+ font-size: 12px;
467
+ background: var(--sf-subtle);
468
+ border: 1px solid var(--sf-border);
469
+ border-radius: 6px;
470
+ padding: 4px 8px;
471
+ }
472
+ .sf-paircode {
473
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
474
+ font-size: 22px;
475
+ font-weight: 700;
476
+ letter-spacing: 0.12em;
477
+ }
478
+ .sf-small {
479
+ font-size: 12px;
480
+ }
481
+ .sf-os {
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 4px;
485
+ margin-top: 6px;
486
+ }
package/src/types.ts CHANGED
@@ -15,8 +15,20 @@ export interface SecureFile {
15
15
  /** "folder" entries download as an uncompressed, resumable .tar of the whole directory */
16
16
  kind: "file" | "folder";
17
17
  source_path: string | null;
18
- /** false when the file is missing on disk (or its allowed directory was removed) */
18
+ /** false when the file is missing on disk, its allowed directory was removed, or its computer is offline */
19
19
  available: boolean;
20
+ /** set when the item lives on one of the user's computers and is served through the relay */
21
+ device: Device | null;
22
+ size: number | null;
23
+ }
24
+
25
+ export interface Device {
26
+ id: number;
27
+ name: string;
28
+ platform: string | null;
29
+ agent_version: string | null;
30
+ online: boolean;
31
+ last_seen: string | null;
20
32
  }
21
33
 
22
34
  export interface LinkRoot {