abstract-securefiles 0.1.0__py3-none-any.whl

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,3 @@
1
+ """Secure Files: shared core, desktop agent and server relay."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
File without changes
@@ -0,0 +1,86 @@
1
+ """Start the agent at login: systemd --user (Linux), launchd (macOS), Task Scheduler (Windows)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import plistlib
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ LABEL = "com.abstractendeavors.securefiles"
12
+ UNIT = "securefiles-agent.service"
13
+ TASK = "SecureFilesAgent"
14
+
15
+
16
+ def _python(windowless: bool = False) -> str:
17
+ exe = sys.executable
18
+ if windowless and sys.platform == "win32":
19
+ candidate = Path(exe).with_name("pythonw.exe")
20
+ if candidate.exists():
21
+ return str(candidate)
22
+ return exe
23
+
24
+
25
+ def _run(cmd, check=True):
26
+ return subprocess.run(cmd, check=check, capture_output=True, text=True)
27
+
28
+
29
+ def install() -> str:
30
+ if sys.platform.startswith("linux"):
31
+ unit_dir = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
32
+ unit_dir.mkdir(parents=True, exist_ok=True)
33
+ (unit_dir / UNIT).write_text(
34
+ "[Unit]\nDescription=Secure Files agent\nAfter=network-online.target\n\n"
35
+ f"[Service]\nExecStart=\"{_python()}\" -m abstract_securefiles serve\n"
36
+ "Restart=on-failure\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n",
37
+ encoding="utf-8",
38
+ )
39
+ if not shutil.which("systemctl"):
40
+ return f"Wrote {unit_dir / UNIT}, but systemctl is missing; start it with: securefiles serve"
41
+ _run(["systemctl", "--user", "daemon-reload"])
42
+ _run(["systemctl", "--user", "enable", "--now", UNIT])
43
+ return f"Enabled systemd user service {UNIT} (starts at login)."
44
+ if sys.platform == "darwin":
45
+ agents = Path.home() / "Library" / "LaunchAgents"
46
+ agents.mkdir(parents=True, exist_ok=True)
47
+ plist = agents / f"{LABEL}.plist"
48
+ logs = Path.home() / "Library" / "Logs"
49
+ plist.write_bytes(plistlib.dumps({
50
+ "Label": LABEL,
51
+ "ProgramArguments": [_python(), "-m", "abstract_securefiles", "serve"],
52
+ "RunAtLoad": True,
53
+ "KeepAlive": True,
54
+ "StandardOutPath": str(logs / "securefiles-agent.log"),
55
+ "StandardErrorPath": str(logs / "securefiles-agent.log"),
56
+ }))
57
+ _run(["launchctl", "unload", str(plist)], check=False)
58
+ _run(["launchctl", "load", "-w", str(plist)])
59
+ return f"Installed LaunchAgent {plist} (starts at login)."
60
+ if sys.platform == "win32":
61
+ command = f'"{_python(windowless=True)}" -m abstract_securefiles serve'
62
+ _run(["schtasks", "/Create", "/F", "/SC", "ONLOGON", "/RL", "LIMITED", "/TN", TASK, "/TR", command])
63
+ _run(["schtasks", "/Run", "/TN", TASK], check=False)
64
+ return f"Created scheduled task {TASK} (starts at logon)."
65
+ raise RuntimeError(f"Unsupported platform: {sys.platform}")
66
+
67
+
68
+ def uninstall() -> str:
69
+ if sys.platform.startswith("linux"):
70
+ unit = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user" / UNIT
71
+ if shutil.which("systemctl"):
72
+ _run(["systemctl", "--user", "disable", "--now", UNIT], check=False)
73
+ unit.unlink(missing_ok=True)
74
+ if shutil.which("systemctl"):
75
+ _run(["systemctl", "--user", "daemon-reload"], check=False)
76
+ return "Removed the systemd user service."
77
+ if sys.platform == "darwin":
78
+ plist = Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
79
+ _run(["launchctl", "unload", "-w", str(plist)], check=False)
80
+ plist.unlink(missing_ok=True)
81
+ return "Removed the LaunchAgent."
82
+ if sys.platform == "win32":
83
+ _run(["schtasks", "/End", "/TN", TASK], check=False)
84
+ _run(["schtasks", "/Delete", "/F", "/TN", TASK], check=False)
85
+ return "Removed the scheduled task."
86
+ raise RuntimeError(f"Unsupported platform: {sys.platform}")
@@ -0,0 +1,99 @@
1
+ """Per-user agent configuration (JSON) in the platform's config directory."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import platform
7
+ import secrets
8
+ import socket
9
+ import sys
10
+ import threading
11
+ from pathlib import Path
12
+
13
+ DEFAULT_SERVER = "https://api.abstractendeavors.com"
14
+ DEFAULT_PORT = 47821
15
+
16
+
17
+ def config_dir() -> Path:
18
+ if sys.platform == "win32":
19
+ base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
20
+ return base / "SecureFiles"
21
+ if sys.platform == "darwin":
22
+ return Path.home() / "Library" / "Application Support" / "SecureFiles"
23
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "securefiles"
24
+
25
+
26
+ def platform_label() -> str:
27
+ return f"{platform.system()} {platform.release()} ({platform.machine()})"
28
+
29
+
30
+ class Config:
31
+ """Thread-safe JSON config: server, pairing token, published items, UI port."""
32
+
33
+ def __init__(self, path: Path = None):
34
+ self.path = path or config_dir() / "config.json"
35
+ self._lock = threading.Lock()
36
+ self.data = {
37
+ "server": DEFAULT_SERVER,
38
+ "port": DEFAULT_PORT,
39
+ "device_name": socket.gethostname()[:60] or "my-computer",
40
+ "device_id": None,
41
+ "token": None,
42
+ "username": None,
43
+ "items": [],
44
+ }
45
+ if self.path.exists():
46
+ self.data.update(json.loads(self.path.read_text(encoding="utf-8")))
47
+
48
+ def save(self):
49
+ with self._lock:
50
+ self.path.parent.mkdir(parents=True, exist_ok=True)
51
+ tmp = self.path.with_suffix(".tmp")
52
+ tmp.write_text(json.dumps(self.data, indent=2), encoding="utf-8")
53
+ if os.name == "posix":
54
+ os.chmod(tmp, 0o600)
55
+ os.replace(tmp, self.path)
56
+
57
+ @property
58
+ def paired(self) -> bool:
59
+ return bool(self.data.get("token"))
60
+
61
+ def add_item(self, path: str) -> dict:
62
+ real = os.path.realpath(path)
63
+ if not (os.path.isfile(real) or os.path.isdir(real)):
64
+ raise ValueError("Not a file or folder")
65
+ for item in self.data["items"]:
66
+ if item["path"] == real:
67
+ return item
68
+ item = {
69
+ "id": secrets.token_urlsafe(9),
70
+ "path": real,
71
+ "name": os.path.basename(real.rstrip("/\\")) or real,
72
+ "kind": "folder" if os.path.isdir(real) else "file",
73
+ }
74
+ self.data["items"].append(item)
75
+ self.save()
76
+ return item
77
+
78
+ def remove_item(self, item_id: str) -> bool:
79
+ before = len(self.data["items"])
80
+ self.data["items"] = [i for i in self.data["items"] if i["id"] != item_id]
81
+ self.save()
82
+ return len(self.data["items"]) != before
83
+
84
+ def item(self, item_id: str):
85
+ return next((i for i in self.data["items"] if i["id"] == item_id), None)
86
+
87
+ def catalog(self) -> list:
88
+ out = []
89
+ for item in self.data["items"]:
90
+ size = None
91
+ if item["kind"] == "file":
92
+ try:
93
+ size = os.path.getsize(item["path"])
94
+ except OSError:
95
+ continue
96
+ elif not os.path.isdir(item["path"]):
97
+ continue
98
+ out.append({"id": item["id"], "name": item["name"], "kind": item["kind"], "size": size})
99
+ return out
@@ -0,0 +1,139 @@
1
+ """Outbound connection to the Secure Files relay.
2
+
3
+ Keeps one WebSocket open (control only), publishes the catalog of shared
4
+ items, and answers each "send" request by streaming the item (honouring the
5
+ downloader's Range) as an HTTPS upload. Reconnects with backoff forever.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import os
13
+ from typing import Optional
14
+
15
+ import aiohttp
16
+
17
+ from .. import __version__
18
+ from ..core import build_payload
19
+ from .config import Config, platform_label
20
+
21
+ log = logging.getLogger("securefiles.agent")
22
+
23
+ PROTOCOL = 1
24
+
25
+
26
+ class RelayClient:
27
+ def __init__(self, config: Config):
28
+ self.config = config
29
+ self.state = "not paired"
30
+ self.last_error: Optional[str] = None
31
+ self.ws: Optional[aiohttp.ClientWebSocketResponse] = None
32
+ self.session: Optional[aiohttp.ClientSession] = None
33
+ self._wake = asyncio.Event()
34
+ self._task: Optional[asyncio.Task] = None
35
+ self.active_transfers = 0
36
+
37
+ def start(self):
38
+ self._task = asyncio.get_running_loop().create_task(self._run())
39
+
40
+ def reconnect(self):
41
+ """Drop the current connection (e.g. after pairing/unpairing) and connect again."""
42
+ self._wake.set()
43
+ if self.ws is not None and not self.ws.closed:
44
+ asyncio.get_running_loop().create_task(self.ws.close())
45
+
46
+ async def publish_catalog(self):
47
+ if self.ws is not None and not self.ws.closed:
48
+ await self.ws.send_str(json.dumps({"type": "catalog", "items": self.config.catalog()}))
49
+
50
+ async def _run(self):
51
+ backoff = 2
52
+ timeout = aiohttp.ClientTimeout(total=None, sock_connect=20)
53
+ async with aiohttp.ClientSession(timeout=timeout) as session:
54
+ self.session = session
55
+ while True:
56
+ if not self.config.paired:
57
+ self.state = "not paired"
58
+ self._wake.clear()
59
+ await self._wake.wait()
60
+ continue
61
+ server = self.config.data["server"].rstrip("/")
62
+ url = server.replace("https://", "wss://", 1).replace("http://", "ws://", 1) + "/secure-files/relay/agent"
63
+ try:
64
+ self.state = "connecting"
65
+ async with session.ws_connect(url, headers=self._auth(), heartbeat=30) as ws:
66
+ self.ws = ws
67
+ self.state = "connected"
68
+ self.last_error = None
69
+ backoff = 2
70
+ await ws.send_str(json.dumps({
71
+ "type": "hello", "protocol": PROTOCOL, "version": __version__,
72
+ "platform": platform_label(), "items": self.config.catalog(),
73
+ }))
74
+ async for msg in ws:
75
+ if msg.type != aiohttp.WSMsgType.TEXT:
76
+ continue
77
+ data = json.loads(msg.data)
78
+ if data.get("type") == "send":
79
+ asyncio.get_running_loop().create_task(self._transfer(data))
80
+ except aiohttp.WSServerHandshakeError as exc:
81
+ self.last_error = "This device was removed from your account; pair it again." \
82
+ if exc.status == 401 else f"Relay refused the connection ({exc.status})"
83
+ except (aiohttp.ClientError, OSError, asyncio.TimeoutError) as exc:
84
+ self.last_error = f"Cannot reach {server}: {exc}"
85
+ finally:
86
+ self.ws = None
87
+ if self.config.paired:
88
+ self.state = "reconnecting"
89
+ self._wake.clear()
90
+ try:
91
+ await asyncio.wait_for(self._wake.wait(), backoff)
92
+ except asyncio.TimeoutError:
93
+ pass
94
+ backoff = min(backoff * 2, 60)
95
+
96
+ def _auth(self) -> dict:
97
+ return {"Authorization": f"Bearer {self.config.data['token']}"}
98
+
99
+ async def _report(self, transfer: str, status: int, message: str):
100
+ if self.ws is not None and not self.ws.closed:
101
+ await self.ws.send_str(json.dumps({"type": "error", "transfer": transfer,
102
+ "status": status, "message": message}))
103
+
104
+ async def _transfer(self, request: dict):
105
+ transfer = str(request.get("transfer"))
106
+ item = self.config.item(str(request.get("item")))
107
+ if not item or not os.path.exists(item["path"]):
108
+ await self._report(transfer, 404, "This item is no longer shared from that computer.")
109
+ return
110
+ name = f"{item['name']}.tar" if item["kind"] == "folder" else item["name"]
111
+ loop = asyncio.get_running_loop()
112
+ try:
113
+ payload = await loop.run_in_executor(
114
+ None, build_payload, item["path"], name, request.get("range"), request.get("if_range"))
115
+ except OSError as exc:
116
+ await self._report(transfer, 500, f"Could not read the item: {exc}")
117
+ return
118
+
119
+ async def body():
120
+ iterator = iter(payload.body)
121
+ while True:
122
+ chunk = await loop.run_in_executor(None, next, iterator, None)
123
+ if chunk is None:
124
+ return
125
+ yield chunk
126
+
127
+ headers = dict(self._auth())
128
+ headers["X-SF-Status"] = str(payload.status)
129
+ for key, value in payload.headers.items():
130
+ headers[f"X-SF-{key}"] = value
131
+ url = f"{self.config.data['server'].rstrip('/')}/secure-files/relay/upload/{transfer}"
132
+ self.active_transfers += 1
133
+ try:
134
+ async with self.session.post(url, data=body(), headers=headers) as resp:
135
+ await resp.read()
136
+ except (aiohttp.ClientError, OSError) as exc:
137
+ log.info("transfer %s ended early: %s", transfer, exc)
138
+ finally:
139
+ self.active_transfers -= 1
@@ -0,0 +1,156 @@
1
+ """Local control panel for the desktop agent (127.0.0.1 only)."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import string
7
+ import sys
8
+ from importlib import resources
9
+
10
+ import aiohttp
11
+ from aiohttp import web
12
+
13
+ from .. import __version__
14
+ from .config import Config, platform_label
15
+ from .relay_client import RelayClient
16
+
17
+ log = logging.getLogger("securefiles.agent")
18
+
19
+ MAX_LIST = 3000
20
+
21
+
22
+ def _local_only(port: int):
23
+ allowed = {f"127.0.0.1:{port}", f"localhost:{port}", f"[::1]:{port}"}
24
+
25
+ @web.middleware
26
+ async def guard(request: web.Request, handler):
27
+ # Host check defeats DNS rebinding; the custom header on writes defeats
28
+ # cross-site form posts (browsers can't add it without a CORS preflight).
29
+ if request.host not in allowed:
30
+ return web.Response(status=403, text="Secure Files agent: local access only")
31
+ if request.method not in ("GET", "HEAD") and request.headers.get("X-SF-Local") != "1":
32
+ return web.Response(status=403, text="missing X-SF-Local header")
33
+ return await handler(request)
34
+
35
+ return guard
36
+
37
+
38
+ def _roots() -> list:
39
+ if sys.platform == "win32":
40
+ return [f"{d}:\\" for d in string.ascii_uppercase if os.path.exists(f"{d}:\\")]
41
+ roots = ["/"]
42
+ for extra in ("/media", "/mnt", "/Volumes"):
43
+ if os.path.isdir(extra):
44
+ roots.append(extra)
45
+ return roots
46
+
47
+
48
+ def make_app(config: Config, relay: RelayClient) -> web.Application:
49
+ app = web.Application(middlewares=[_local_only(config.data["port"])])
50
+
51
+ async def index(_request):
52
+ html = resources.files("abstract_securefiles").joinpath("agent/ui/index.html").read_text(encoding="utf-8")
53
+ return web.Response(text=html, content_type="text/html")
54
+
55
+ async def state(_request):
56
+ d = config.data
57
+ return web.json_response({
58
+ "version": __version__,
59
+ "platform": platform_label(),
60
+ "server": d["server"],
61
+ "device_name": d["device_name"],
62
+ "username": d.get("username"),
63
+ "paired": config.paired,
64
+ "connection": relay.state,
65
+ "error": relay.last_error,
66
+ "active_transfers": relay.active_transfers,
67
+ "items": d["items"],
68
+ "home": os.path.expanduser("~"),
69
+ "roots": _roots(),
70
+ })
71
+
72
+ async def pair(request):
73
+ body = await request.json()
74
+ code = str(body.get("code") or "").strip().upper().replace(" ", "").replace("-", "")
75
+ server = str(body.get("server") or config.data["server"]).strip().rstrip("/")
76
+ name = str(body.get("device_name") or config.data["device_name"]).strip()[:60]
77
+ if not code:
78
+ return web.json_response({"message": "Enter the pairing code from the website"}, status=400)
79
+ try:
80
+ async with aiohttp.ClientSession() as session:
81
+ async with session.post(f"{server}/secure-files/devices/claim",
82
+ json={"code": code, "name": name, "platform": platform_label()}) as resp:
83
+ data = await resp.json(content_type=None)
84
+ if resp.status != 200:
85
+ return web.json_response({"message": data.get("message") or f"Pairing failed ({resp.status})"},
86
+ status=400)
87
+ except aiohttp.ClientError as exc:
88
+ return web.json_response({"message": f"Cannot reach {server}: {exc}"}, status=502)
89
+ config.data.update(server=server, device_name=data.get("device_name") or name,
90
+ device_id=data["device_id"], token=data["token"], username=data.get("username"))
91
+ config.save()
92
+ relay.reconnect()
93
+ return await state(request)
94
+
95
+ async def unpair(request):
96
+ config.data.update(device_id=None, token=None, username=None)
97
+ config.save()
98
+ relay.reconnect()
99
+ return await state(request)
100
+
101
+ async def browse(request):
102
+ path = request.query.get("path") or os.path.expanduser("~")
103
+ real = os.path.realpath(path)
104
+ if not os.path.isdir(real):
105
+ return web.json_response({"message": "Not a folder"}, status=400)
106
+ dirs, files = [], []
107
+ try:
108
+ entries = sorted(os.scandir(real), key=lambda e: e.name.lower())
109
+ except OSError as exc:
110
+ return web.json_response({"message": str(exc)}, status=403)
111
+ for entry in entries[:MAX_LIST]:
112
+ try:
113
+ if entry.is_dir():
114
+ dirs.append({"name": entry.name, "path": entry.path})
115
+ elif entry.is_file():
116
+ files.append({"name": entry.name, "path": entry.path, "size": entry.stat().st_size})
117
+ except OSError:
118
+ continue
119
+ parent = os.path.dirname(real)
120
+ return web.json_response({"path": real, "parent": parent if parent != real else None,
121
+ "dirs": dirs, "files": files, "truncated": len(entries) > MAX_LIST})
122
+
123
+ async def share(request):
124
+ body = await request.json()
125
+ try:
126
+ config.add_item(str(body.get("path") or ""))
127
+ except ValueError as exc:
128
+ return web.json_response({"message": str(exc)}, status=400)
129
+ await relay.publish_catalog()
130
+ return await state(request)
131
+
132
+ async def unshare(request):
133
+ config.remove_item(request.match_info["item_id"])
134
+ await relay.publish_catalog()
135
+ return await state(request)
136
+
137
+ app.router.add_get("/", index)
138
+ app.router.add_get("/api/state", state)
139
+ app.router.add_post("/api/pair", pair)
140
+ app.router.add_post("/api/unpair", unpair)
141
+ app.router.add_get("/api/browse", browse)
142
+ app.router.add_post("/api/items", share)
143
+ app.router.add_delete("/api/items/{item_id}", unshare)
144
+ return app
145
+
146
+
147
+ async def run(config: Config):
148
+ relay = RelayClient(config)
149
+ relay.start()
150
+ runner = web.AppRunner(make_app(config, relay), access_log=None)
151
+ await runner.setup()
152
+ site = web.TCPSite(runner, "127.0.0.1", config.data["port"])
153
+ await site.start()
154
+ log.info("Secure Files agent %s on http://127.0.0.1:%s", __version__, config.data["port"])
155
+ import asyncio
156
+ await asyncio.Event().wait()
@@ -0,0 +1,125 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Secure Files — this computer</title>
7
+ <style>
8
+ :root { color-scheme: light dark; --b: color-mix(in srgb, currentColor 20%, transparent); --s: color-mix(in srgb, currentColor 6%, transparent); --a: #2563eb; --d: #dc2626; --ok: #16a34a; }
9
+ body { font: 14px/1.45 system-ui, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; }
10
+ h1 { font-size: 1.4em; margin: 0 0 4px; } h2 { font-size: 1.1em; margin: 0 0 10px; }
11
+ .muted { opacity: .7; } .err { color: var(--d); }
12
+ section { border: 1px solid var(--b); border-radius: 10px; padding: 14px 16px; margin: 14px 0; }
13
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
14
+ input { font: inherit; color: inherit; background: var(--s); border: 1px solid var(--b); border-radius: 6px; padding: 6px 10px; }
15
+ button { font: inherit; color: inherit; background: var(--s); border: 1px solid var(--b); border-radius: 6px; padding: 5px 12px; cursor: pointer; }
16
+ button.primary { background: var(--a); border-color: var(--a); color: #fff; }
17
+ button:disabled { opacity: .45; cursor: default; }
18
+ .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--d); margin-right: 6px; }
19
+ .dot.on { background: var(--ok); } .dot.mid { background: #ca8a04; }
20
+ ul { list-style: none; margin: 0; padding: 0; }
21
+ li { display: flex; gap: 10px; align-items: center; padding: 6px 4px; border-bottom: 1px solid var(--b); }
22
+ li:last-child { border-bottom: none; }
23
+ .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
24
+ .tag { font-size: 11px; border: 1px solid var(--b); border-radius: 999px; padding: 0 7px; }
25
+ .browser { max-height: 380px; overflow: auto; border: 1px solid var(--b); border-radius: 6px; }
26
+ .browser li { padding: 4px 8px; }
27
+ .link { background: none; border: none; padding: 0; text-align: left; cursor: pointer; }
28
+ .link:hover { text-decoration: underline; }
29
+ code { font-size: 12px; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <h1>Secure Files — this computer</h1>
34
+ <p class="muted">Share files and folders straight from this computer through your Secure Files account. Nothing is uploaded; when someone downloads, it streams from here through the relay.</p>
35
+
36
+ <section id="conn"></section>
37
+ <section id="shared"></section>
38
+ <section id="add"></section>
39
+
40
+ <script>
41
+ const api = async (path, opts = {}) => {
42
+ const res = await fetch(path, { ...opts, headers: { "Content-Type": "application/json", "X-SF-Local": "1", ...(opts.headers || {}) } });
43
+ const data = await res.json().catch(() => ({}));
44
+ if (!res.ok) throw new Error(data.message || res.statusText);
45
+ return data;
46
+ };
47
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
48
+ const size = (n) => { if (n == null) return ""; const u = ["B","KB","MB","GB","TB"]; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(i ? 1 : 0)} ${u[i]}`; };
49
+ let state = null, browsePath = null;
50
+
51
+ function renderConn() {
52
+ const s = state, el = document.getElementById("conn");
53
+ if (!s.paired) {
54
+ el.innerHTML = `<h2><span class="dot"></span>Not connected to an account</h2>
55
+ <p>On <b>${esc(s.server.replace("api.", ""))}/secure-files</b>, open <i>My computers</i> → <i>Add a computer</i> and enter the code here.</p>
56
+ <div class="row"><input id="code" placeholder="Pairing code" size="12" autocomplete="off">
57
+ <input id="dname" value="${esc(s.device_name)}" placeholder="Name for this computer" size="22">
58
+ <button class="primary" id="pairBtn">Connect</button></div>
59
+ <details class="muted" style="margin-top:8px"><summary>Server</summary><input id="server" value="${esc(s.server)}" size="40"></details>
60
+ <p class="err" id="pairErr"></p>`;
61
+ document.getElementById("pairBtn").onclick = async () => {
62
+ try {
63
+ state = await api("/api/pair", { method: "POST", body: JSON.stringify({ code: document.getElementById("code").value, device_name: document.getElementById("dname").value, server: document.getElementById("server").value }) });
64
+ render();
65
+ } catch (e) { document.getElementById("pairErr").textContent = e.message; }
66
+ };
67
+ return;
68
+ }
69
+ const dot = s.connection === "connected" ? "on" : (s.connection === "connecting" || s.connection === "reconnecting") ? "mid" : "";
70
+ el.innerHTML = `<h2><span class="dot ${dot}"></span>${s.connection === "connected" ? "Connected" : esc(s.connection)}</h2>
71
+ <p>Account <b>${esc(s.username || "?")}</b> · this computer appears as <b>devices/${esc(s.device_name)}</b> in your Secure Files list.
72
+ Manage sharing (links, passwords, limits) at <a href="${esc(s.server.replace("api.", ""))}/secure-files" target="_blank" rel="noopener">${esc(s.server.replace("api.", "").replace("https://", ""))}/secure-files</a>.</p>
73
+ ${s.error ? `<p class="err">${esc(s.error)}</p>` : ""}
74
+ <p class="muted">${s.active_transfers ? `${s.active_transfers} download(s) in progress · ` : ""}${esc(s.platform)} · agent ${esc(s.version)}</p>
75
+ <button id="unpair">Disconnect this computer</button>`;
76
+ document.getElementById("unpair").onclick = async () => { if (confirm("Disconnect this computer from your account?")) { state = await api("/api/unpair", { method: "POST" }); render(); } };
77
+ }
78
+
79
+ function renderShared() {
80
+ const el = document.getElementById("shared"), items = state.items;
81
+ el.innerHTML = `<h2>Shared from this computer</h2>` + (items.length ? `<ul>${items.map((i) => `
82
+ <li><span>${i.kind === "folder" ? "📁" : "📄"}</span><span class="grow" title="${esc(i.path)}"><b>${esc(i.name)}</b> <span class="muted">${esc(i.path)}</span></span>
83
+ ${i.kind === "folder" ? `<span class="tag">downloads as .tar</span>` : ""}<button data-id="${esc(i.id)}">Stop sharing</button></li>`).join("")}</ul>`
84
+ : `<p class="muted">Nothing yet — pick files or folders below.</p>`);
85
+ el.querySelectorAll("button[data-id]").forEach((b) => b.onclick = async () => { state = await api(`/api/items/${b.dataset.id}`, { method: "DELETE" }); render(); });
86
+ }
87
+
88
+ async function renderAdd() {
89
+ const el = document.getElementById("add");
90
+ browsePath = browsePath || state.home;
91
+ let listing;
92
+ try { listing = await api(`/api/browse?path=${encodeURIComponent(browsePath)}`); }
93
+ catch (e) { el.innerHTML = `<h2>Add from this computer</h2><p class="err">${esc(e.message)}</p>`; browsePath = state.home; return; }
94
+ const shared = new Set(state.items.map((i) => i.path));
95
+ el.innerHTML = `<h2>Add from this computer</h2>
96
+ <div class="row" style="margin-bottom:8px"><input id="pathIn" class="grow" value="${esc(listing.path)}">
97
+ <button id="go">Go</button><button id="shareHere" class="primary" ${shared.has(listing.path) ? "disabled" : ""}>Share this folder</button></div>
98
+ <div class="row muted" style="margin-bottom:8px">${state.roots.map((r) => `<button class="link" data-path="${esc(r)}">${esc(r)}</button>`).join(" · ")} · <button class="link" data-path="${esc(state.home)}">Home</button></div>
99
+ <ul class="browser">
100
+ ${listing.parent ? `<li><button class="link" data-path="${esc(listing.parent)}">↩ ..</button></li>` : ""}
101
+ ${listing.dirs.map((d) => `<li><span>📁</span><button class="link grow" data-path="${esc(d.path)}">${esc(d.name)}</button>
102
+ <button data-share="${esc(d.path)}" ${shared.has(d.path) ? "disabled" : ""}>${shared.has(d.path) ? "Shared" : "Share folder"}</button></li>`).join("")}
103
+ ${listing.files.map((f) => `<li><span>📄</span><span class="grow">${esc(f.name)}</span><span class="muted">${size(f.size)}</span>
104
+ <button data-share="${esc(f.path)}" ${shared.has(f.path) ? "disabled" : ""}>${shared.has(f.path) ? "Shared" : "Share"}</button></li>`).join("")}
105
+ ${!listing.dirs.length && !listing.files.length ? `<li class="muted">Empty folder</li>` : ""}
106
+ </ul>${listing.truncated ? `<p class="muted">Showing the first 3000 entries.</p>` : ""}`;
107
+ el.querySelectorAll("[data-path]").forEach((b) => b.onclick = () => { browsePath = b.dataset.path; renderAdd(); });
108
+ el.querySelectorAll("[data-share]").forEach((b) => b.onclick = async () => { state = await api("/api/items", { method: "POST", body: JSON.stringify({ path: b.dataset.share }) }); render(); });
109
+ document.getElementById("go").onclick = () => { browsePath = document.getElementById("pathIn").value; renderAdd(); };
110
+ document.getElementById("shareHere").onclick = async () => { state = await api("/api/items", { method: "POST", body: JSON.stringify({ path: listing.path }) }); render(); };
111
+ }
112
+
113
+ async function render() { renderConn(); renderShared(); await renderAdd(); }
114
+ async function refresh() {
115
+ try {
116
+ const next = await api("/api/state");
117
+ const wasPaired = state && state.paired;
118
+ state = next;
119
+ if (state.paired || wasPaired) renderConn();
120
+ } catch (e) {}
121
+ }
122
+ (async () => { state = await api("/api/state"); await render(); setInterval(refresh, 3000); })();
123
+ </script>
124
+ </body>
125
+ </html>
@@ -0,0 +1,147 @@
1
+ """`securefiles` command line."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import sys
9
+ import time
10
+ import urllib.error
11
+ import urllib.request
12
+ import webbrowser
13
+
14
+ from . import __version__
15
+ from .agent.config import Config, config_dir
16
+
17
+
18
+ def _ui_url(config: Config) -> str:
19
+ return f"http://127.0.0.1:{config.data['port']}/"
20
+
21
+
22
+ def _local(config: Config, path: str, body=None):
23
+ req = urllib.request.Request(
24
+ _ui_url(config).rstrip("/") + path,
25
+ data=json.dumps(body).encode() if body is not None else None,
26
+ headers={"Content-Type": "application/json", "X-SF-Local": "1"},
27
+ method="POST" if body is not None else "GET",
28
+ )
29
+ with urllib.request.urlopen(req, timeout=30) as resp:
30
+ return json.loads(resp.read())
31
+
32
+
33
+ def _running(config: Config) -> bool:
34
+ try:
35
+ _local(config, "/api/state")
36
+ return True
37
+ except (urllib.error.URLError, OSError):
38
+ return False
39
+
40
+
41
+ def cmd_serve(config: Config, _args):
42
+ from .agent.server import run
43
+
44
+ log_file = config_dir() / "agent.log"
45
+ log_file.parent.mkdir(parents=True, exist_ok=True)
46
+ logging.basicConfig(
47
+ level=logging.INFO,
48
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
49
+ handlers=[logging.FileHandler(log_file, encoding="utf-8"), logging.StreamHandler()],
50
+ )
51
+ if _running(config):
52
+ print(f"Already running at {_ui_url(config)}")
53
+ return
54
+ try:
55
+ asyncio.run(run(config))
56
+ except KeyboardInterrupt:
57
+ pass
58
+
59
+
60
+ def cmd_setup(config: Config, args):
61
+ from .agent import autostart
62
+
63
+ config.save()
64
+ print(autostart.install())
65
+ for _ in range(30):
66
+ if _running(config):
67
+ break
68
+ time.sleep(0.5)
69
+ print(f"Secure Files agent: {_ui_url(config)}")
70
+ if not args.no_browser:
71
+ webbrowser.open(_ui_url(config))
72
+
73
+
74
+ def cmd_uninstall(_config: Config, _args):
75
+ from .agent import autostart
76
+
77
+ print(autostart.uninstall())
78
+
79
+
80
+ def cmd_open(config: Config, _args):
81
+ webbrowser.open(_ui_url(config))
82
+ print(_ui_url(config))
83
+
84
+
85
+ def cmd_pair(config: Config, args):
86
+ body = {"code": args.code}
87
+ if args.server:
88
+ body["server"] = args.server
89
+ if args.name:
90
+ body["device_name"] = args.name
91
+ if not _running(config):
92
+ sys.exit("The agent isn't running; start it with `securefiles setup` (or `securefiles serve`).")
93
+ try:
94
+ state = _local(config, "/api/pair", body)
95
+ except urllib.error.HTTPError as exc:
96
+ sys.exit(json.loads(exc.read() or b"{}").get("message") or str(exc))
97
+ print(f"Paired as {state['username']} / {state['device_name']}")
98
+
99
+
100
+ def cmd_unpair(config: Config, _args):
101
+ if _running(config):
102
+ _local(config, "/api/unpair", {})
103
+ else:
104
+ config.data.update(device_id=None, token=None, username=None)
105
+ config.save()
106
+ print("This computer is no longer connected to an account.")
107
+
108
+
109
+ def cmd_status(config: Config, _args):
110
+ if not _running(config):
111
+ print(f"Agent not running (config: {config.path})")
112
+ return
113
+ s = _local(config, "/api/state")
114
+ print(f"Agent {s['version']} at {_ui_url(config)}")
115
+ print(f"Account: {s['username'] or '-'} Device: {s['device_name']} Connection: {s['connection']}")
116
+ if s.get("error"):
117
+ print(f"Last error: {s['error']}")
118
+ for item in s["items"]:
119
+ print(f" shared {item['kind']:6} {item['path']}")
120
+
121
+
122
+ def main(argv=None):
123
+ parser = argparse.ArgumentParser(prog="securefiles", description="Secure Files desktop agent")
124
+ parser.add_argument("--version", action="version", version=__version__)
125
+ sub = parser.add_subparsers(dest="command")
126
+ sub.add_parser("serve", help="run the agent in the foreground")
127
+ setup = sub.add_parser("setup", help="start at login, start now and open the control panel")
128
+ setup.add_argument("--no-browser", action="store_true")
129
+ sub.add_parser("uninstall", help="stop starting at login")
130
+ sub.add_parser("open", help="open the control panel")
131
+ pair = sub.add_parser("pair", help="connect this computer to your account")
132
+ pair.add_argument("code")
133
+ pair.add_argument("--server")
134
+ pair.add_argument("--name")
135
+ sub.add_parser("unpair", help="disconnect this computer from your account")
136
+ sub.add_parser("status", help="show connection and shared items")
137
+ args = parser.parse_args(argv)
138
+ config = Config()
139
+ handler = {
140
+ "serve": cmd_serve, "setup": cmd_setup, "uninstall": cmd_uninstall, "open": cmd_open,
141
+ "pair": cmd_pair, "unpair": cmd_unpair, "status": cmd_status,
142
+ }.get(args.command or "status")
143
+ handler(config, args)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()
@@ -0,0 +1,184 @@
1
+ """Transport core shared by the server and the desktop agent.
2
+
3
+ Folders are delivered as a deterministic, uncompressed PAX tar whose every byte
4
+ position is known up front, so responses carry an exact length and support
5
+ HTTP Range (resume). Single files are served as plain byte ranges.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import re
12
+ import tarfile
13
+ from typing import Callable, Iterator, List, Optional, Tuple
14
+ from urllib.parse import quote
15
+
16
+ CHUNK = 1 << 20
17
+
18
+ Entry = Tuple[str, str] # (absolute path, archive name)
19
+
20
+
21
+ class RangeNotSatisfiable(Exception):
22
+ pass
23
+
24
+
25
+ class TarLayout:
26
+ """A deterministic, uncompressed tar of a fixed file list."""
27
+
28
+ def __init__(self, entries: List[Entry]):
29
+ self.segments: List[Tuple[int, int, Optional[bytes], Optional[str]]] = []
30
+ offset = 0
31
+ digest = hashlib.sha256()
32
+ for path, arcname in entries:
33
+ st = os.stat(path)
34
+ info = tarfile.TarInfo(arcname)
35
+ info.size = st.st_size
36
+ info.mtime = int(st.st_mtime)
37
+ info.mode = 0o644
38
+ info.uid = info.gid = 0
39
+ info.uname = info.gname = ""
40
+ header = info.tobuf(format=tarfile.PAX_FORMAT, encoding="utf-8", errors="surrogateescape")
41
+ for length, data, src in ((len(header), header, None), (st.st_size, None, path),
42
+ ((-st.st_size) % 512, None, None)):
43
+ if length:
44
+ self.segments.append((offset, length, data, src))
45
+ offset += length
46
+ digest.update(f"{arcname}\0{st.st_size}\0{int(st.st_mtime)}\n".encode("utf-8", "surrogateescape"))
47
+ self.segments.append((offset, 1024, None, None))
48
+ self.size = offset + 1024
49
+ self.etag = digest.hexdigest()[:32]
50
+
51
+ def iter_range(self, start: int, stop: int) -> Iterator[bytes]:
52
+ for seg_start, length, data, path in self.segments:
53
+ seg_stop = seg_start + length
54
+ if seg_stop <= start or seg_start >= stop:
55
+ continue
56
+ lo, hi = max(start, seg_start) - seg_start, min(stop, seg_stop) - seg_start
57
+ if path is None:
58
+ yield data[lo:hi] if data is not None else bytes(hi - lo)
59
+ continue
60
+ with open(path, "rb") as fh:
61
+ if os.fstat(fh.fileno()).st_size != length:
62
+ raise IOError(f"{path} changed size during download")
63
+ fh.seek(lo)
64
+ remaining = hi - lo
65
+ while remaining:
66
+ buf = fh.read(min(CHUNK, remaining))
67
+ if not buf:
68
+ raise IOError(f"{path} truncated during download")
69
+ remaining -= len(buf)
70
+ yield buf
71
+
72
+
73
+ def walk_files(folder: str, arc_prefix: str, allow: Optional[Callable[[str], bool]] = None) -> List[Entry]:
74
+ """Every regular file under folder in a stable order, as (real path, archive name).
75
+
76
+ Entries that resolve outside the folder (symlinks), fail allow(), or are
77
+ unreadable are skipped, so a shared folder never leaks anything else.
78
+ """
79
+ base = os.path.realpath(folder)
80
+ entries: List[Entry] = []
81
+ for dirpath, dirnames, filenames in os.walk(folder, followlinks=False):
82
+ dirnames.sort()
83
+ for name in sorted(filenames):
84
+ full = os.path.join(dirpath, name)
85
+ real = os.path.realpath(full)
86
+ if not (allow(real) if allow else within(base, real)):
87
+ continue
88
+ if not os.path.isfile(real) or not os.access(real, os.R_OK):
89
+ continue
90
+ rel = os.path.relpath(full, folder).replace(os.sep, "/")
91
+ entries.append((real, f"{arc_prefix}/{rel}" if arc_prefix else rel))
92
+ return entries
93
+
94
+
95
+ def within(base: str, path: str) -> bool:
96
+ try:
97
+ return os.path.commonpath([base, path]) == base
98
+ except ValueError: # different drives on Windows
99
+ return False
100
+
101
+
102
+ _RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
103
+
104
+
105
+ def parse_range(header: Optional[str], size: int) -> Optional[Tuple[int, int]]:
106
+ """Parse a single-range Range header into [start, stop). None = whole body."""
107
+ if not header:
108
+ return None
109
+ m = _RANGE_RE.match(header.strip())
110
+ if not m or (m.group(1) == "" and m.group(2) == ""):
111
+ return None
112
+ if m.group(1) == "":
113
+ length = int(m.group(2))
114
+ if length == 0:
115
+ raise RangeNotSatisfiable()
116
+ return max(0, size - length), size
117
+ start = int(m.group(1))
118
+ stop = int(m.group(2)) + 1 if m.group(2) else size
119
+ if start >= size:
120
+ raise RangeNotSatisfiable()
121
+ return start, min(stop, size)
122
+
123
+
124
+ def iter_file(path: str, start: int, stop: int) -> Iterator[bytes]:
125
+ with open(path, "rb") as fh:
126
+ fh.seek(start)
127
+ remaining = stop - start
128
+ while remaining:
129
+ buf = fh.read(min(CHUNK, remaining))
130
+ if not buf:
131
+ raise IOError(f"{path} truncated during download")
132
+ remaining -= len(buf)
133
+ yield buf
134
+
135
+
136
+ def content_disposition(name: str) -> str:
137
+ ascii_name = name.encode("ascii", "replace").decode().replace('"', "_").replace("?", "_")
138
+ return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(name)}"
139
+
140
+
141
+ class Payload:
142
+ """Everything needed to answer a (possibly ranged) download of a file or folder."""
143
+
144
+ def __init__(self, status: int, headers: dict, body: Iterator[bytes]):
145
+ self.status = status
146
+ self.headers = headers
147
+ self.body = body
148
+
149
+
150
+ def build_payload(path: str, download_name: str, range_header: Optional[str],
151
+ if_range: Optional[str] = None,
152
+ allow: Optional[Callable[[str], bool]] = None) -> Payload:
153
+ """Response for a file (plain bytes) or a folder (deterministic tar), honouring Range."""
154
+ if os.path.isdir(path):
155
+ prefix = download_name[:-4] if download_name.lower().endswith(".tar") else download_name
156
+ layout = TarLayout(walk_files(path, prefix, allow))
157
+ size, etag, ctype = layout.size, layout.etag, "application/x-tar"
158
+ reader = layout.iter_range
159
+ name = prefix + ".tar"
160
+ else:
161
+ st = os.stat(path)
162
+ size, ctype, name = st.st_size, "application/octet-stream", download_name
163
+ etag = hashlib.sha256(f"{path}\0{st.st_size}\0{st.st_mtime_ns}".encode("utf-8", "surrogateescape")).hexdigest()[:32]
164
+ reader = lambda a, b: iter_file(path, a, b) # noqa: E731
165
+ headers = {
166
+ "Content-Type": ctype,
167
+ "Content-Disposition": content_disposition(name),
168
+ "Accept-Ranges": "bytes",
169
+ "ETag": f'"{etag}"',
170
+ }
171
+ use_range = range_header if (not if_range or if_range.strip('"') == etag) else None
172
+ try:
173
+ bounds = parse_range(use_range, size)
174
+ except RangeNotSatisfiable:
175
+ headers["Content-Range"] = f"bytes */{size}"
176
+ headers["Content-Length"] = "0"
177
+ return Payload(416, headers, iter(()))
178
+ if bounds is None:
179
+ headers["Content-Length"] = str(size)
180
+ return Payload(200, headers, reader(0, size))
181
+ start, stop = bounds
182
+ headers["Content-Range"] = f"bytes {start}-{stop - 1}/{size}"
183
+ headers["Content-Length"] = str(stop - start)
184
+ return Payload(206, headers, reader(start, stop))
File without changes
@@ -0,0 +1,239 @@
1
+ """Secure Files relay.
2
+
3
+ Paired desktop agents keep one outbound WebSocket here (control only). When a
4
+ download of an agent-hosted item arrives, the API calls /internal/fetch; the
5
+ relay asks the agent to stream that item (with the caller's Range) as an HTTPS
6
+ upload to /secure-files/relay/upload/<transfer>, and pipes the upload straight
7
+ into the waiting response. Nothing is stored on the server.
8
+
9
+ Public routes (proxied by nginx): /secure-files/relay/agent (WebSocket)
10
+ /secure-files/relay/upload/<transfer>
11
+ Internal routes (127.0.0.1 only): /internal/fetch, /internal/kick/<id>
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import hashlib
17
+ import json
18
+ import logging
19
+ import os
20
+ import re
21
+ import secrets
22
+ from typing import Dict, Optional
23
+
24
+ from aiohttp import WSMsgType, web
25
+
26
+ log = logging.getLogger("securefiles.relay")
27
+
28
+ DSN = os.environ.get("SECURE_FILES_DB_DSN") or os.environ.get("AUTH_DB_DSN") or "postgresql:///auth_v2"
29
+ HOST = os.environ.get("SECURE_FILES_RELAY_HOST", "127.0.0.1")
30
+ PORT = int(os.environ.get("SECURE_FILES_RELAY_PORT", "6013"))
31
+ TRANSFER_WAIT = 60
32
+ PASS_HEADERS = ("Content-Type", "Content-Disposition", "Content-Length", "Content-Range", "Accept-Ranges", "ETag")
33
+ ITEM_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
34
+
35
+
36
+ def _db(sql: str, params=(), fetch: bool = False):
37
+ import psycopg
38
+ from psycopg.rows import dict_row
39
+
40
+ with psycopg.connect(DSN, autocommit=True, row_factory=dict_row) as conn:
41
+ cur = conn.execute(sql, params)
42
+ return cur.fetchall() if fetch else None
43
+
44
+
45
+ async def db(sql: str, params=(), fetch: bool = False):
46
+ return await asyncio.to_thread(_db, sql, params, fetch)
47
+
48
+
49
+ def token_hash(token: str) -> str:
50
+ return hashlib.sha256(token.encode()).hexdigest()
51
+
52
+
53
+ def _safe_segment(name: str) -> str:
54
+ cleaned = re.sub(r"[\\/\x00-\x1f]+", "_", name).strip().strip(".")
55
+ return cleaned[:120] or "item"
56
+
57
+
58
+ class Agent:
59
+ def __init__(self, ws: web.WebSocketResponse, device: dict):
60
+ self.ws = ws
61
+ self.device = device
62
+ self.lock = asyncio.Lock()
63
+
64
+ async def send(self, message: dict):
65
+ async with self.lock:
66
+ await self.ws.send_str(json.dumps(message))
67
+
68
+
69
+ class Relay:
70
+ def __init__(self):
71
+ self.agents: Dict[int, Agent] = {}
72
+ self.pending: Dict[str, tuple] = {} # transfer -> (device_id, future)
73
+
74
+ async def authenticate(self, request: web.Request) -> Optional[dict]:
75
+ auth = request.headers.get("Authorization", "")
76
+ if not auth.lower().startswith("bearer "):
77
+ return None
78
+ rows = await db(
79
+ "SELECT id, username, name FROM secure_file_devices WHERE token_hash = %s AND revoked_at IS NULL",
80
+ (token_hash(auth[7:].strip()),),
81
+ fetch=True,
82
+ )
83
+ return rows[0] if rows else None
84
+
85
+ # --- agent control channel ------------------------------------------------------------
86
+ async def agent_ws(self, request: web.Request):
87
+ device = await self.authenticate(request)
88
+ if not device:
89
+ return web.json_response({"message": "unknown or revoked device"}, status=401)
90
+ ws = web.WebSocketResponse(heartbeat=30, max_msg_size=8 * 1024 * 1024)
91
+ await ws.prepare(request)
92
+ old = self.agents.get(device["id"])
93
+ if old:
94
+ await old.ws.close(message=b"replaced by a newer connection")
95
+ agent = Agent(ws, device)
96
+ self.agents[device["id"]] = agent
97
+ await db("UPDATE secure_file_devices SET online = TRUE, last_seen = NOW() WHERE id = %s", (device["id"],))
98
+ log.info("agent online: device %s (%s/%s)", device["id"], device["username"], device["name"])
99
+ try:
100
+ async for msg in ws:
101
+ if msg.type != WSMsgType.TEXT:
102
+ continue
103
+ try:
104
+ data = json.loads(msg.data)
105
+ except ValueError:
106
+ continue
107
+ kind = data.get("type")
108
+ if kind in ("hello", "catalog"):
109
+ await self.sync_catalog(device, data.get("items") or [], data)
110
+ elif kind == "error":
111
+ entry = self.pending.pop(str(data.get("transfer")), None)
112
+ if entry and entry[0] == device["id"] and not entry[1].done():
113
+ entry[1].set_result({"error": True, "status": int(data.get("status") or 502),
114
+ "message": str(data.get("message") or "agent error")[:500]})
115
+ elif kind == "ping":
116
+ await agent.send({"type": "pong"})
117
+ finally:
118
+ if self.agents.get(device["id"]) is agent:
119
+ del self.agents[device["id"]]
120
+ await db("UPDATE secure_file_devices SET online = FALSE, last_seen = NOW() WHERE id = %s",
121
+ (device["id"],))
122
+ log.info("agent offline: device %s", device["id"])
123
+ return ws
124
+
125
+ async def sync_catalog(self, device: dict, items: list, hello: dict):
126
+ """Mirror the agent's published items as uploads rows owned by the device's user."""
127
+ if hello.get("type") == "hello":
128
+ await db(
129
+ "UPDATE secure_file_devices SET platform = %s, agent_version = %s WHERE id = %s",
130
+ (str(hello.get("platform") or "")[:64], str(hello.get("version") or "")[:32], device["id"]),
131
+ )
132
+ folder = f"devices/{_safe_segment(device['name'])}"
133
+ existing = {r["device_item"]: r["id"] for r in await db(
134
+ "SELECT id, device_item FROM uploads WHERE device_id = %s", (device["id"],), fetch=True)}
135
+ seen = set()
136
+ for item in items[:5000]:
137
+ item_id = str(item.get("id") or "")
138
+ kind = "folder" if item.get("kind") == "folder" else "file"
139
+ if not ITEM_ID_RE.match(item_id) or item_id in seen:
140
+ continue
141
+ seen.add(item_id)
142
+ name = _safe_segment(str(item.get("name") or item_id))
143
+ filename = f"{name}.tar" if kind == "folder" else name
144
+ size = item.get("size") if isinstance(item.get("size"), int) else None
145
+ if item_id in existing:
146
+ await db(
147
+ "UPDATE uploads SET filename = %s, filepath = %s, device_kind = %s, size_bytes = %s WHERE id = %s",
148
+ (filename, f"{folder}/{filename}", kind, size, existing[item_id]),
149
+ )
150
+ else:
151
+ await db(
152
+ "INSERT INTO uploads (filename, filepath, uploader_id, shareable, download_count, "
153
+ "device_id, device_item, device_kind, size_bytes) VALUES (%s, %s, %s, FALSE, 0, %s, %s, %s, %s)",
154
+ (filename, f"{folder}/{filename}", device["username"], device["id"], item_id, kind, size),
155
+ )
156
+ gone = [rid for item_id, rid in existing.items() if item_id not in seen]
157
+ if gone:
158
+ await db("DELETE FROM uploads WHERE id = ANY(%s)", (gone,))
159
+
160
+ # --- transfers ---------------------------------------------------------------------------
161
+ async def fetch(self, request: web.Request):
162
+ """Internal: called by the API after it has enforced share rules."""
163
+ body = await request.json()
164
+ device_id, item = int(body.get("device_id") or 0), str(body.get("item") or "")
165
+ agent = self.agents.get(device_id)
166
+ if not agent:
167
+ return web.Response(status=503, text="The computer hosting this file is offline.")
168
+ transfer = secrets.token_urlsafe(18)
169
+ future = asyncio.get_running_loop().create_future()
170
+ self.pending[transfer] = (device_id, future)
171
+ try:
172
+ await agent.send({"type": "send", "transfer": transfer, "item": item,
173
+ "range": body.get("range"), "if_range": body.get("if_range")})
174
+ result = await asyncio.wait_for(future, TRANSFER_WAIT)
175
+ except asyncio.TimeoutError:
176
+ return web.Response(status=504, text="The computer hosting this file did not respond.")
177
+ finally:
178
+ self.pending.pop(transfer, None)
179
+ if isinstance(result, dict):
180
+ return web.Response(status=result["status"], text=result["message"])
181
+ upload, done = result
182
+ status = int(upload.headers.get("X-SF-Status") or 200)
183
+ response = web.StreamResponse(status=status)
184
+ for name in PASS_HEADERS:
185
+ value = upload.headers.get(f"X-SF-{name}")
186
+ if value:
187
+ response.headers[name] = value
188
+ try:
189
+ await response.prepare(request)
190
+ async for chunk in upload.content.iter_chunked(1 << 20):
191
+ await response.write(chunk)
192
+ await response.write_eof()
193
+ done.set_result(True)
194
+ except Exception as exc: # downloader or agent went away
195
+ if not done.done():
196
+ done.set_result(False)
197
+ log.info("transfer %s aborted: %s", transfer, exc)
198
+ return response
199
+
200
+ async def upload(self, request: web.Request):
201
+ """Public: the agent streams the requested bytes here."""
202
+ device = await self.authenticate(request)
203
+ entry = self.pending.get(request.match_info["transfer"])
204
+ if not device or not entry or entry[0] != device["id"] or entry[1].done():
205
+ return web.json_response({"message": "unknown transfer"}, status=404)
206
+ done = asyncio.get_running_loop().create_future()
207
+ entry[1].set_result((request, done))
208
+ ok = await done
209
+ return web.json_response({"ok": ok}, status=200 if ok else 499)
210
+
211
+ async def kick(self, request: web.Request):
212
+ agent = self.agents.get(int(request.match_info["device_id"]))
213
+ if agent:
214
+ await agent.ws.close(message=b"device revoked")
215
+ return web.json_response({"ok": True})
216
+
217
+
218
+ def make_app() -> web.Application:
219
+ relay = Relay()
220
+ app = web.Application(client_max_size=0)
221
+ app.router.add_get("/secure-files/relay/agent", relay.agent_ws)
222
+ app.router.add_post("/secure-files/relay/upload/{transfer}", relay.upload)
223
+ app.router.add_post("/internal/fetch", relay.fetch)
224
+ app.router.add_post("/internal/kick/{device_id}", relay.kick)
225
+
226
+ async def on_startup(_app):
227
+ await db("UPDATE secure_file_devices SET online = FALSE")
228
+
229
+ app.on_startup.append(on_startup)
230
+ return app
231
+
232
+
233
+ def main():
234
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
235
+ web.run_app(make_app(), host=HOST, port=PORT, access_log=None)
236
+
237
+
238
+ if __name__ == "__main__":
239
+ main()
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: abstract_securefiles
3
+ Version: 0.1.0
4
+ Summary: Secure Files: share local files and folders through abstractendeavors.com (or your own server) without uploading them. Includes the desktop agent, the shared tar/resume core and the server relay.
5
+ Author-email: putkoff <partners@abstractendeavors.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://abstractendeavors.com/secure-files
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Operating System :: MacOS
11
+ Classifier: Operating System :: Microsoft :: Windows
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: aiohttp>=3.9
16
+ Provides-Extra: relay
17
+ Requires-Dist: psycopg[binary]>=3.1; extra == "relay"
18
+
19
+ # abstract_securefiles
20
+
21
+ Share files and whole folders **straight from your own computer** through
22
+ [Secure Files](https://abstractendeavors.com/secure-files) — nothing is uploaded.
23
+ When someone opens your share link, the file streams from your computer through
24
+ the relay, with the same passwords, download limits and resumable downloads as
25
+ files hosted on the server. Folders download as one uncompressed `.tar`.
26
+
27
+ ## Install (Linux, macOS, Windows)
28
+
29
+ ```bash
30
+ pipx install abstract-securefiles # or: python -m pip install --user abstract-securefiles
31
+ securefiles setup # start at login, start now, open http://127.0.0.1:47821
32
+ ```
33
+
34
+ Then, on abstractendeavors.com/secure-files → **My computers** → **Add a computer**,
35
+ enter the pairing code in the control panel (or `securefiles pair CODE`).
36
+ Pick files/folders to share in the control panel; they appear on the website under
37
+ `devices/<computer name>/`, where you create links, passwords and limits.
38
+
39
+ Commands: `securefiles setup | serve | open | pair CODE | status | unpair | uninstall`.
40
+
41
+ Secure Files is invite-only; ask the administrator for access.
42
+
43
+ ## Contents
44
+
45
+ - `abstract_securefiles.core` — deterministic tar + HTTP Range transport shared by server and agent.
46
+ - `abstract_securefiles.agent` — the desktop agent (local control panel on 127.0.0.1 only).
47
+ - `abstract_securefiles.relay` — the server relay (`pip install abstract-securefiles[relay]`, run `securefiles-relay`).
@@ -0,0 +1,17 @@
1
+ abstract_securefiles/__init__.py,sha256=uHj2wVS0RtNc1BwbLe2EofqKfvE17WTGNOgA_216Lq8,88
2
+ abstract_securefiles/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
3
+ abstract_securefiles/cli.py,sha256=JCcELzb40KxpTslrLF7C6Z_mOAM6JJjfehw2esZq9Sk,4693
4
+ abstract_securefiles/core.py,sha256=eMpksdoy5s9fvrYGBQBrqbI-GUSxtjMuVbnb0U3hGr4,7297
5
+ abstract_securefiles/agent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ abstract_securefiles/agent/autostart.py,sha256=Ox4cja42vV7-ScTLHqGKqIAnro9ecbofyOKl9e5mvfI,3813
7
+ abstract_securefiles/agent/config.py,sha256=koDEn1u8tZhum44ZMw7LnGUPdOfe-1YS5pIDHyWGDSk,3335
8
+ abstract_securefiles/agent/relay_client.py,sha256=9C0As96r7O2NOT3pd8pVfE7lChVK6bkfCPE5jXJPU68,6003
9
+ abstract_securefiles/agent/server.py,sha256=P3aftjILW4gmSGtsGmlfUNbEjwjtj5akm2vttiRj-KM,6242
10
+ abstract_securefiles/agent/ui/index.html,sha256=CtzUhAhgk016Tz0CRg0zjbkZUirprGf_KjtdZ2eWT3k,9084
11
+ abstract_securefiles/relay/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ abstract_securefiles/relay/server.py,sha256=-HiiAqB_XNK7hGNWLtqpcvgg2XDW2DGObr1aFV2JAHw,10594
13
+ abstract_securefiles-0.1.0.dist-info/METADATA,sha256=c3ijGjZHNWV_0rA4xEUzOey-qNgaP8h8jKoYwHtBUKE,2269
14
+ abstract_securefiles-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
15
+ abstract_securefiles-0.1.0.dist-info/entry_points.txt,sha256=n_nfpF4ECD-TAiBb8pBaedqZGvN4lyy9enn6fesxJCA,121
16
+ abstract_securefiles-0.1.0.dist-info/top_level.txt,sha256=fUttafCR3EOs4ZuQ5DoZuZyDC863kQBN1DzvmTsbbJM,21
17
+ abstract_securefiles-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ securefiles = abstract_securefiles.cli:main
3
+ securefiles-relay = abstract_securefiles.relay.server:main
@@ -0,0 +1 @@
1
+ abstract_securefiles