abstract-securefiles 0.1.0__tar.gz

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.
Files changed (22) hide show
  1. abstract_securefiles-0.1.0/PKG-INFO +47 -0
  2. abstract_securefiles-0.1.0/README.md +29 -0
  3. abstract_securefiles-0.1.0/pyproject.toml +36 -0
  4. abstract_securefiles-0.1.0/setup.cfg +4 -0
  5. abstract_securefiles-0.1.0/src/abstract_securefiles/__init__.py +3 -0
  6. abstract_securefiles-0.1.0/src/abstract_securefiles/__main__.py +3 -0
  7. abstract_securefiles-0.1.0/src/abstract_securefiles/agent/__init__.py +0 -0
  8. abstract_securefiles-0.1.0/src/abstract_securefiles/agent/autostart.py +86 -0
  9. abstract_securefiles-0.1.0/src/abstract_securefiles/agent/config.py +99 -0
  10. abstract_securefiles-0.1.0/src/abstract_securefiles/agent/relay_client.py +139 -0
  11. abstract_securefiles-0.1.0/src/abstract_securefiles/agent/server.py +156 -0
  12. abstract_securefiles-0.1.0/src/abstract_securefiles/agent/ui/index.html +125 -0
  13. abstract_securefiles-0.1.0/src/abstract_securefiles/cli.py +147 -0
  14. abstract_securefiles-0.1.0/src/abstract_securefiles/core.py +184 -0
  15. abstract_securefiles-0.1.0/src/abstract_securefiles/relay/__init__.py +0 -0
  16. abstract_securefiles-0.1.0/src/abstract_securefiles/relay/server.py +239 -0
  17. abstract_securefiles-0.1.0/src/abstract_securefiles.egg-info/PKG-INFO +47 -0
  18. abstract_securefiles-0.1.0/src/abstract_securefiles.egg-info/SOURCES.txt +20 -0
  19. abstract_securefiles-0.1.0/src/abstract_securefiles.egg-info/dependency_links.txt +1 -0
  20. abstract_securefiles-0.1.0/src/abstract_securefiles.egg-info/entry_points.txt +3 -0
  21. abstract_securefiles-0.1.0/src/abstract_securefiles.egg-info/requires.txt +4 -0
  22. abstract_securefiles-0.1.0/src/abstract_securefiles.egg-info/top_level.txt +1 -0
@@ -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,29 @@
1
+ # abstract_securefiles
2
+
3
+ Share files and whole folders **straight from your own computer** through
4
+ [Secure Files](https://abstractendeavors.com/secure-files) — nothing is uploaded.
5
+ When someone opens your share link, the file streams from your computer through
6
+ the relay, with the same passwords, download limits and resumable downloads as
7
+ files hosted on the server. Folders download as one uncompressed `.tar`.
8
+
9
+ ## Install (Linux, macOS, Windows)
10
+
11
+ ```bash
12
+ pipx install abstract-securefiles # or: python -m pip install --user abstract-securefiles
13
+ securefiles setup # start at login, start now, open http://127.0.0.1:47821
14
+ ```
15
+
16
+ Then, on abstractendeavors.com/secure-files → **My computers** → **Add a computer**,
17
+ enter the pairing code in the control panel (or `securefiles pair CODE`).
18
+ Pick files/folders to share in the control panel; they appear on the website under
19
+ `devices/<computer name>/`, where you create links, passwords and limits.
20
+
21
+ Commands: `securefiles setup | serve | open | pair CODE | status | unpair | uninstall`.
22
+
23
+ Secure Files is invite-only; ask the administrator for access.
24
+
25
+ ## Contents
26
+
27
+ - `abstract_securefiles.core` — deterministic tar + HTTP Range transport shared by server and agent.
28
+ - `abstract_securefiles.agent` — the desktop agent (local control panel on 127.0.0.1 only).
29
+ - `abstract_securefiles.relay` — the server relay (`pip install abstract-securefiles[relay]`, run `securefiles-relay`).
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "abstract_securefiles"
7
+ version = "0.1.0"
8
+ description = "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."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "putkoff", email = "partners@abstractendeavors.com" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Operating System :: POSIX :: Linux",
16
+ "Operating System :: MacOS",
17
+ "Operating System :: Microsoft :: Windows",
18
+ "License :: OSI Approved :: MIT License",
19
+ ]
20
+ dependencies = ["aiohttp>=3.9"]
21
+
22
+ [project.optional-dependencies]
23
+ relay = ["psycopg[binary]>=3.1"]
24
+
25
+ [project.scripts]
26
+ securefiles = "abstract_securefiles.cli:main"
27
+ securefiles-relay = "abstract_securefiles.relay.server:main"
28
+
29
+ [project.urls]
30
+ Homepage = "https://abstractendeavors.com/secure-files"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.setuptools.package-data]
36
+ abstract_securefiles = ["agent/ui/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()
@@ -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()