@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.
- package/dist/MyComputers.d.ts +7 -0
- package/dist/api.d.ts +12 -1
- package/dist/index.cjs +107 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +107 -30
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +16 -2
- package/package.json +1 -1
- package/src/MyComputers.tsx +141 -0
- package/src/SecureFiles.tsx +25 -6
- package/src/ShareDialog.tsx +26 -5
- package/src/api.ts +8 -2
- package/src/index.ts +1 -1
- package/src/styles.css +38 -0
- package/src/types.ts +17 -2
|
@@ -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 && 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/<computer>/</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
|
+
}
|
package/src/SecureFiles.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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/ShareDialog.tsx
CHANGED
|
@@ -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
|
-
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|