diff --git a/README.md b/README.md index cdd3c85..697c095 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,25 @@ python3 share.py -p 3001 -d ~/Downloads/shared --ttl 7 | `-p`, `--port` | `8888` | Port | | `-d`, `--dir` | `~/Downloads/shared` | Upload directory | | `--ttl` | `7` | File lifetime in days (0 = keep forever) | +| `--token` | | Auth token (empty = open access) | +| `--cert` | | Path to SSL certificate | +| `--key` | | Path to SSL key | + +## Authentication + +Set `token` in `share.config` or pass `--token`. All endpoints require a valid token. + +The token can be stored as plain text or as a SHA-256 hash: + +``` +# generate hash +python3 share.py hash mytoken + +# store in share.config +token = 9c56cc51b374c3ba189210d5b6d0d04... +``` + +If the value is a 64-character hex string, it is treated as a hash. Otherwise it is hashed automatically at startup. ## systemd (Arch) diff --git a/config.py b/config.py index 6a636f8..84e23f9 100644 --- a/config.py +++ b/config.py @@ -11,7 +11,8 @@ _cp.read_dict({ "server": { "port": "3001", "dir": str(Path.home() / "Downloads" / "shared"), "ttl": "7", "san": "", "lang": "ru", "refresh": "5", - "per_page": "10,25,50,100", + "per_page": "10,25,50,100", "token": "", + "cert": "", "key": "", }, "internal": { "secs_per_day": "86400", "cleanup_interval": "3600", @@ -42,6 +43,9 @@ def load_config() -> dict: p.add_argument("--san", nargs="*") p.add_argument("--lang") p.add_argument("--refresh", type=int) + p.add_argument("--token") + p.add_argument("--cert", help="Path to SSL certificate file") + p.add_argument("--key", help="Path to SSL key file") args = p.parse_args() for key, val in vars(args).items(): diff --git a/server.py b/server.py index 23a0fdc..cd9fd07 100644 --- a/server.py +++ b/server.py @@ -1,6 +1,8 @@ """HTTP server and request handler.""" import email.parser +import hashlib +import hmac import http.server import json import mimetypes @@ -27,11 +29,17 @@ def file_type(path: Path) -> str: class ShareServer(http.server.ThreadingHTTPServer): - def __init__(self, addr, handler, *, upload_dir: Path, html: bytes, ssl_ctx: ssl.SSLContext | None = None): + def __init__(self, addr, handler, *, upload_dir: Path, html: bytes, ssl_ctx: ssl.SSLContext | None = None, token: str = "", token_is_hash: bool = False): super().__init__(addr, handler) self.upload_dir = upload_dir self.html = html self.ssl_ctx = ssl_ctx + if not token: + self.token_hash = "" + elif token_is_hash: + self.token_hash = token + else: + self.token_hash = hashlib.sha256(token.encode()).hexdigest() def get_request(self): client, addr = self.socket.accept() @@ -70,6 +78,73 @@ class Handler(http.server.BaseHTTPRequestHandler): self.end_headers() self.wfile.write(data) + # ── Auth ── + + @staticmethod + def _hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + def _match(self, value: str) -> bool: + return hmac.compare_digest(self._hash(value), self.server.token_hash) + + def _check_auth(self) -> bool: + if not self.server.token_hash: + return True + parsed = urllib.parse.urlparse(self.path) + qs = urllib.parse.parse_qs(parsed.query) + if qs.get("token", [None])[0] and self._match(qs["token"][0]): + return True + auth = self.headers.get("Authorization", "") + if auth.startswith("Bearer ") and self._match(auth[7:]): + return True + cookie = self.headers.get("Cookie", "") + for part in cookie.split(";"): + part = part.strip() + if part.startswith("share_token=") and hmac.compare_digest(part[12:], self.server.token_hash): + return True + return False + + _AUTH_PAGE = b''' + + +Share + + +
+

Share

+
+ + +
+
+
+ +''' + + def _send_auth_page(self): + self.send_response(401) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(self._AUTH_PAGE))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(self._AUTH_PAGE) + # ── Routes ── ROUTES = { @@ -78,14 +153,29 @@ class Handler(http.server.BaseHTTPRequestHandler): ("/index.html","_route_index"), ("/files", "_route_files"), ("/dl/", "_route_download"), + ("/logout", "_route_logout"), ], - "POST": [("/upload", "_route_upload")], + "POST": [("/upload", "_route_upload"), ("/auth", "_route_auth")], "DELETE": [("/del/", "_route_delete")], } def _dispatch(self): + parsed = urllib.parse.urlparse(self.path) + path_only = parsed.path + for prefix, method in self.ROUTES.get(self.command, []): - if self.path == prefix or (len(prefix) > 1 and prefix.endswith("/") and self.path.startswith(prefix)): + if path_only == prefix or (len(prefix) > 1 and prefix.endswith("/") and path_only.startswith(prefix)): + if method not in ("_route_auth",) and not self._check_auth(): + if self.command == "GET" and path_only in ("/", "/index.html"): + return self._send_auth_page() + self.send_response(401) + body = json.dumps({"error": "unauthorized"}).encode() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + return return getattr(self, method)(prefix) self.send_error(404) @@ -103,7 +193,8 @@ class Handler(http.server.BaseHTTPRequestHandler): self._json([{"name": p.name, "size": st.st_size, "type": file_type(p)} for p, st in entries]) def _safe_path(self, prefix: str) -> Path: - name = urllib.parse.unquote(self.path[len(prefix):]) + parsed = urllib.parse.urlparse(self.path) + name = urllib.parse.unquote(parsed.path[len(prefix):]) return self.server.upload_dir / Path(name).name def _route_download(self, prefix): @@ -141,6 +232,38 @@ class Handler(http.server.BaseHTTPRequestHandler): count = self._save_parts(ct, body) self._json({"ok": True, "count": count}) + def _route_auth(self, _prefix): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) if length else b"" + try: + data = json.loads(body) + except (json.JSONDecodeError, ValueError): + data = {} + if data.get("token") and self._match(data["token"]): + resp = json.dumps({"ok": True}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(resp))) + self.send_header("Set-Cookie", f"share_token={self._hash(data['token'])}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000") + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(resp) + else: + resp = json.dumps({"ok": False}).encode() + self.send_response(401) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(resp))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(resp) + + def _route_logout(self, _prefix): + self.send_response(302) + self.send_header("Set-Cookie", "share_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0") + self.send_header("Location", "/") + self.send_header("Connection", "close") + self.end_headers() + def _route_delete(self, prefix): safe = self._safe_path(prefix) if safe.is_file(): diff --git a/share.config b/share.config index d680eb4..4cdb378 100644 --- a/share.config +++ b/share.config @@ -5,6 +5,7 @@ ttl = 7 san = lang = ru refresh = 5 +token = 755b7c9ca04986c0dd496353e9d94ce713f2df0657b9b4008cb4d617679e103f per_page = 10,25,50,100 [internal] diff --git a/share.py b/share.py index a784d01..f81fec5 100755 --- a/share.py +++ b/share.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 """Share — local file sharing server (HTTPS).""" +import hashlib import json import ssl +import sys import threading from pathlib import Path @@ -13,6 +15,11 @@ from server import Handler, ShareServer def main(): + if len(sys.argv) > 1 and sys.argv[1] == "hash": + token = sys.argv[2] if len(sys.argv) > 2 else input("Token: ") + print(hashlib.sha256(token.encode()).hexdigest()) + return + conf = load_config() port = int(conf["port"]) @@ -29,12 +36,18 @@ def main(): html = (BASE_DIR / "static" / "index.html").read_bytes() html = html.replace(b"", cfg_tag) - cert_dir = BASE_DIR / ".certs" - cert, key = ensure_cert(cert_dir, extra_ips=san_list) + if conf["cert"] and conf["key"]: + cert, key = Path(conf["cert"]), Path(conf["key"]) + else: + cert_dir = BASE_DIR / ".certs" + cert, key = ensure_cert(cert_dir, extra_ips=san_list) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain(cert, key) - server = ShareServer(("0.0.0.0", port), Handler, upload_dir=upload_dir, html=html, ssl_ctx=ctx) + token = conf["token"] + # If token looks like a SHA-256 hash, pass it as pre-hashed + is_hash = len(token) == 64 and all(c in "0123456789abcdef" for c in token) + server = ShareServer(("0.0.0.0", port), Handler, upload_dir=upload_dir, html=html, ssl_ctx=ctx, token=token, token_is_hash=is_hash) print("\n Share running") print(f" Local: https://localhost:{port}") diff --git a/static/index.html b/static/index.html index b227b21..f9d2d13 100644 --- a/static/index.html +++ b/static/index.html @@ -127,6 +127,14 @@ h1 { font-size: 1.4rem; margin-bottom: 16px; color: #fff } margin-bottom: 16px; position: relative; } #header h1 { margin-bottom: 0 } +#logout-btn { + position: absolute; left: 0; + background: none; border: none; cursor: pointer; + color: var(--text-muted); padding: 4px; + display: inline-flex; align-items: center; + transition: color .2s; +} +#logout-btn:hover { color: var(--danger) } #lang-sw { position: absolute; right: 0; display: flex; background: var(--bg-card-hover); border-radius: 6px; @@ -156,6 +164,7 @@ h1 { font-size: 1.4rem; margin-bottom: 16px; color: #fff } @@ -218,6 +227,7 @@ const I18N = { uploadError: 'Ошибка соединения', deleted: 'Удалено', perPage: 'На странице:', + logout: 'Выход', sz: [' Б', ' КБ', ' МБ', ' ГБ'], }, en: { @@ -238,6 +248,7 @@ const I18N = { uploadError: 'Upload error', deleted: 'Deleted', perPage: 'Per page:', + logout: 'Logout', sz: [' B', ' KB', ' MB', ' GB'], } }; @@ -254,6 +265,7 @@ function setLang(l) { $('drop-text').textContent = t().drop; $('drop-hint').textContent = t().hint; $('files-title').textContent = t().files; + $('logout-btn').title = t().logout; loadFiles(); } @@ -326,7 +338,7 @@ function toPngBlob(blob) { } function copyFile(name, type) { - const url = '/dl/' + encodeURIComponent(name); + const url = 'dl/' + encodeURIComponent(name); if (type === 'text') { fetch(url).then(r => { if (!r.ok) throw 0; return r.text() }).then(text => copyText(text)) @@ -382,7 +394,7 @@ function upload(fileList) { }; xhr.onerror = () => { done(); toast(t().uploadError) }; - xhr.open('POST', '/upload'); + xhr.open('POST', 'upload'); xhr.send(fd); } @@ -456,7 +468,7 @@ function renderFiles() { ${esc(f.name)}
${fmtSize(f.size)} - +
`).join(''); @@ -465,7 +477,7 @@ function renderFiles() { } function loadFiles() { - fetch('/files').then(r => r.json()).then(files => { + fetch('files').then(r => r.json()).then(files => { allFiles = files; renderFiles(); }).catch(() => {}); @@ -480,7 +492,7 @@ flist.addEventListener('click', e => { const name = el.dataset.name; if (action === 'copy') copyFile(name, el.dataset.type); else if (action === 'del') { - fetch('/del/' + encodeURIComponent(name), { method: 'DELETE' }) + fetch('del/' + encodeURIComponent(name), { method: 'DELETE' }) .then(() => { toast(t().deleted); loadFiles() }) .catch(() => {}); } @@ -532,6 +544,8 @@ document.addEventListener('paste', e => { } }); +$('logout-btn').addEventListener('click', () => { window.location = 'logout' }); + setLang(lang); let pollId = setInterval(loadFiles, REFRESH_MS); document.addEventListener('visibilitychange', () => {