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.
- abstract_securefiles/__init__.py +3 -0
- abstract_securefiles/__main__.py +3 -0
- abstract_securefiles/agent/__init__.py +0 -0
- abstract_securefiles/agent/autostart.py +86 -0
- abstract_securefiles/agent/config.py +99 -0
- abstract_securefiles/agent/relay_client.py +139 -0
- abstract_securefiles/agent/server.py +156 -0
- abstract_securefiles/agent/ui/index.html +125 -0
- abstract_securefiles/cli.py +147 -0
- abstract_securefiles/core.py +184 -0
- abstract_securefiles/relay/__init__.py +0 -0
- abstract_securefiles/relay/server.py +239 -0
- abstract_securefiles-0.1.0.dist-info/METADATA +47 -0
- abstract_securefiles-0.1.0.dist-info/RECORD +17 -0
- abstract_securefiles-0.1.0.dist-info/WHEEL +5 -0
- abstract_securefiles-0.1.0.dist-info/entry_points.txt +3 -0
- abstract_securefiles-0.1.0.dist-info/top_level.txt +1 -0
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 @@
|
|
|
1
|
+
abstract_securefiles
|