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.
- abstract_securefiles-0.1.0/PKG-INFO +47 -0
- abstract_securefiles-0.1.0/README.md +29 -0
- abstract_securefiles-0.1.0/pyproject.toml +36 -0
- abstract_securefiles-0.1.0/setup.cfg +4 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles/__init__.py +3 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles/__main__.py +3 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles/agent/__init__.py +0 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles/agent/autostart.py +86 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles/agent/config.py +99 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles/agent/relay_client.py +139 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles/agent/server.py +156 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles/agent/ui/index.html +125 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles/cli.py +147 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles/core.py +184 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles/relay/__init__.py +0 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles/relay/server.py +239 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles.egg-info/PKG-INFO +47 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles.egg-info/SOURCES.txt +20 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles.egg-info/dependency_links.txt +1 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles.egg-info/entry_points.txt +3 -0
- abstract_securefiles-0.1.0/src/abstract_securefiles.egg-info/requires.txt +4 -0
- 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/*"]
|
|
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()
|