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
+
+
+
+
+'''
+
+ 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)}
`).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', () => {