@putkoff/abstract-securefiles 0.0.50 → 0.0.52

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,141 @@
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
+ function ago(iso: string | null) {
10
+ if (!iso) return "never";
11
+ const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
12
+ if (secs < 90) return "just now";
13
+ if (secs < 3600) return `${Math.round(secs / 60)} min ago`;
14
+ if (secs < 86400) return `${Math.round(secs / 3600)} h ago`;
15
+ return `${Math.round(secs / 86400)} d ago`;
16
+ }
17
+
18
+ function AddComputer({ api, onPaired }: { api: SecureFilesApi; onPaired: () => void }) {
19
+ const [code, setCode] = useState<string | null>(null);
20
+ const [expiresAt, setExpiresAt] = useState(0);
21
+ const [, setTick] = useState(0);
22
+ const [error, setError] = useState<string | null>(null);
23
+
24
+ useEffect(() => {
25
+ if (!code) return;
26
+ const timer = setInterval(() => {
27
+ if (Date.now() > expiresAt) setCode(null);
28
+ setTick((t) => t + 1);
29
+ onPaired();
30
+ }, 5000);
31
+ return () => clearInterval(timer);
32
+ }, [code, expiresAt, onPaired]);
33
+
34
+ const create = async () => {
35
+ setError(null);
36
+ try {
37
+ const res = await api.pairDevice();
38
+ setCode(res.code);
39
+ setExpiresAt(Date.now() + res.expires_in * 1000);
40
+ } catch (err) {
41
+ setError(errText(err));
42
+ }
43
+ };
44
+ const minutesLeft = Math.max(1, Math.round((expiresAt - Date.now()) / 60000));
45
+
46
+ if (!code) {
47
+ return (
48
+ <div className="sf-row">
49
+ <button type="button" className="sf-btn sf-btn--primary" onClick={create}>
50
+ Add a computer
51
+ </button>
52
+ {error && <span className="sf-error">{error}</span>}
53
+ </div>
54
+ );
55
+ }
56
+ return (
57
+ <div className="sf-card">
58
+ <h4>Connect a computer</h4>
59
+ <ol className="sf-steps">
60
+ <li>
61
+ On that computer, install the agent (Linux, macOS or Windows with Python 3.9+):
62
+ <div className="sf-row">
63
+ <code className="sf-codeline">pipx install abstract-securefiles &amp;&amp; securefiles setup</code>
64
+ <CopyButton text="pipx install abstract-securefiles && securefiles setup" label="Copy" />
65
+ </div>
66
+ <span className="sf-muted">No pipx? Use <code>python3 -m pip install --user abstract-securefiles</code> then <code>securefiles setup</code>.</span>
67
+ </li>
68
+ <li>
69
+ In the panel that opens (<code>http://127.0.0.1:47821</code>), enter this code:
70
+ <div className="sf-row">
71
+ <span className="sf-paircode">{code}</span>
72
+ <CopyButton text={code} label="Copy" />
73
+ </div>
74
+ <span className="sf-muted">Valid for about {minutesLeft} more minutes, once.</span>
75
+ </li>
76
+ <li>Pick the files and folders to share there. They appear here under <code>devices/&lt;computer&gt;/</code>.</li>
77
+ </ol>
78
+ <div className="sf-row">
79
+ <button type="button" className="sf-btn" onClick={() => setCode(null)}>
80
+ Done
81
+ </button>
82
+ </div>
83
+ </div>
84
+ );
85
+ }
86
+
87
+ export function MyComputers({
88
+ api,
89
+ devices,
90
+ onChange,
91
+ }: {
92
+ api: SecureFilesApi;
93
+ devices: Device[];
94
+ onChange: () => void;
95
+ }) {
96
+ const [error, setError] = useState<string | null>(null);
97
+ return (
98
+ <details className="sf-admin">
99
+ <summary>
100
+ My computers{" "}
101
+ <span className="sf-muted">
102
+ ({devices.filter((d) => d.online).length} online / {devices.length})
103
+ </span>
104
+ </summary>
105
+ <div className="sf-stack">
106
+ <p className="sf-muted">
107
+ Share files and folders straight from your own computers — nothing is uploaded. Downloads stream from the
108
+ computer through this site with the same links, passwords, limits and resume, while it's online.
109
+ </p>
110
+ {devices.length > 0 && (
111
+ <ul className="sf-roots">
112
+ {devices.map((d) => (
113
+ <li key={d.id}>
114
+ <span>
115
+ <span className={`sf-dot${d.online ? " sf-dot--on" : ""}`} /> <b>{d.name}</b>{" "}
116
+ <span className="sf-muted">
117
+ {d.online ? "online" : `offline · last seen ${ago(d.last_seen)}`}
118
+ {d.platform ? ` · ${d.platform}` : ""}
119
+ </span>
120
+ </span>
121
+ <ConfirmButton
122
+ label="Remove"
123
+ onConfirm={async () => {
124
+ try {
125
+ await api.removeDevice(d.id);
126
+ onChange();
127
+ } catch (err) {
128
+ setError(errText(err));
129
+ }
130
+ }}
131
+ />
132
+ </li>
133
+ ))}
134
+ </ul>
135
+ )}
136
+ {error && <p className="sf-error">{error}</p>}
137
+ <AddComputer api={api} onPaired={onChange} />
138
+ </div>
139
+ </details>
140
+ );
141
+ }
@@ -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"}
@@ -203,7 +214,7 @@ export default function SecureFiles({ apiBase }: SecureFilesProps) {
203
214
  <button type="button" className="sf-btn" disabled={busy || !f.available} onClick={() => download([f])}>
204
215
  Download
205
216
  </button>
206
- {f.shareable && f.available && <CopyButton text={f.share_url} />}
217
+ {f.shareable && f.available && f.share_url && <CopyButton text={f.share_url} />}
207
218
  <button
208
219
  type="button"
209
220
  className="sf-icon-btn"
@@ -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
 
@@ -1,6 +1,7 @@
1
1
  import React, { useEffect, useState } from "react";
2
2
  import type { SecureFile, ShareUpdate } from "./types";
3
3
  import { CopyButton } from "./CopyButton";
4
+ import { ConfirmButton } from "./ConfirmButton";
4
5
 
5
6
  type PasswordMode = "keep" | "set" | "remove";
6
7
 
@@ -30,6 +31,18 @@ export function ShareDialog({
30
31
  const limitValid = limit === "" || /^\d+$/.test(limit);
31
32
  const passwordValid = pwMode !== "set" || password.length > 0;
32
33
 
34
+ const newLink = async () => {
35
+ setSaving(true);
36
+ setError(null);
37
+ try {
38
+ await onSave({ id: file.id, shareable: true, regenerate_link: true });
39
+ } catch (err) {
40
+ setError(err instanceof Error ? err.message : String(err));
41
+ } finally {
42
+ setSaving(false);
43
+ }
44
+ };
45
+
33
46
  const save = async () => {
34
47
  setSaving(true);
35
48
  setError(null);
@@ -119,11 +132,19 @@ export function ShareDialog({
119
132
 
120
133
  <div className="sf-field">
121
134
  <span>Share link</span>
122
- <div className="sf-row">
123
- <input className="sf-input" readOnly value={file.share_url} onFocus={(e) => e.target.select()} />
124
- <CopyButton text={file.share_url} />
125
- </div>
126
- {!file.shareable && <span className="sf-muted">The link works once you save with sharing on.</span>}
135
+ {file.share_url ? (
136
+ <div className="sf-row">
137
+ <input className="sf-input" readOnly value={file.share_url} onFocus={(e) => e.target.select()} />
138
+ <CopyButton text={file.share_url} />
139
+ <ConfirmButton label="New link" disabled={saving} onConfirm={newLink} />
140
+ </div>
141
+ ) : (
142
+ <span className="sf-muted">A private link is created when you save with sharing on.</span>
143
+ )}
144
+ {file.share_url && !file.shareable && (
145
+ <span className="sf-muted">The link works once you save with sharing on.</span>
146
+ )}
147
+ <span className="sf-muted">"New link" invalidates the current one immediately.</span>
127
148
  </div>
128
149
  </fieldset>
129
150
 
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,41 @@
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
+ }
package/src/types.ts CHANGED
@@ -8,14 +8,27 @@ export interface SecureFile {
8
8
  download_count: number;
9
9
  download_limit: number | null;
10
10
  created_at: string | null;
11
- share_url: string;
11
+ /** unguessable public link (/secure-files/s/<token>); null until the file is first shared */
12
+ share_url: string | null;
12
13
  /** true when the entry points at a file elsewhere on the server instead of an upload */
13
14
  linked: boolean;
14
15
  /** "folder" entries download as an uncompressed, resumable .tar of the whole directory */
15
16
  kind: "file" | "folder";
16
17
  source_path: string | null;
17
- /** 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 */
18
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;
19
32
  }
20
33
 
21
34
  export interface LinkRoot {
@@ -48,6 +61,8 @@ export interface ShareUpdate {
48
61
  /** null/undefined = leave unchanged, "" = remove, otherwise the new password */
49
62
  password?: string | null;
50
63
  reset_count?: boolean;
64
+ /** issue a new share link; the old one stops working */
65
+ regenerate_link?: boolean;
51
66
  }
52
67
 
53
68
  export interface SecureFilesProps {