Files
share/share.py
T
eberkheevandClaude Opus 4.6 082a80a6c5 Harden server and fix client-side edge cases
Python: per-file error handling in cleanup, single stat() in download,
S_ISREG check, Content-Disposition escaping, Content-Length on all
responses, route prefix passed to handlers (no magic numbers), validate
empty upload, ensure_cert handles partial cert pair, narrower exception
catches.

JS: visibility-aware polling, concurrent upload guard, fetch r.ok checks,
missing .catch() on delete/loadFiles, REFRESH_SEC→REFRESH_MS rename,
explicit parseInt radix, transition: all→specific properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:47:47 +04:00

332 lines
9.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Share — local file sharing server (HTTPS)."""
import argparse
import configparser
import email.parser
import http.server
import json
import mimetypes
import socket
import ssl
import stat
import subprocess
import threading
import time
import urllib.parse
from datetime import datetime
from pathlib import Path
# ── Cleanup ──
def cleanup_loop(upload_dir: Path, max_age: int):
if max_age <= 0:
return
while True:
time.sleep(3600)
now = time.time()
try:
entries = list(upload_dir.iterdir())
except OSError as e:
print(f" cleanup error: {e}")
continue
for f in entries:
try:
if f.is_file() and now - f.stat().st_mtime > max_age:
f.unlink()
print(f" - expired: {f.name}")
except OSError:
pass
# ── SSL ──
def get_local_ip():
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return "127.0.0.1"
def ensure_cert(cert_dir: Path, extra_ips: list[str] | None = None) -> tuple[Path, Path]:
"""Generate self-signed cert if missing. Returns (certfile, keyfile)."""
cert_dir.mkdir(parents=True, exist_ok=True)
cert = cert_dir / "cert.pem"
key = cert_dir / "key.pem"
if cert.is_file() and key.is_file():
return cert, key
cert.unlink(missing_ok=True)
key.unlink(missing_ok=True)
ips = {"127.0.0.1", get_local_ip()}
if extra_ips:
ips.update(extra_ips)
san = ",".join([f"IP:{ip}" for ip in sorted(ips)] + ["DNS:localhost"])
subprocess.run([
"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", str(key), "-out", str(cert),
"-days", "3650", "-nodes",
"-subj", "/CN=Share",
"-addext", f"subjectAltName={san}",
], check=True, capture_output=True)
print(f" Generated self-signed cert in {cert_dir}")
return cert, key
BASE_DIR = Path(__file__).resolve().parent
def _file_type(path: Path) -> str:
ct = mimetypes.guess_type(path.name)[0] or ''
if ct.startswith('image/'):
return 'image'
if ct.startswith('text/'):
return 'text'
try:
with path.open('rb') as f:
return 'text' if b'\x00' not in f.read(8192) else 'other'
except OSError:
return 'other'
# ── HTTP Handler ──
class ShareServer(http.server.ThreadingHTTPServer):
def __init__(self, addr, handler, *, upload_dir: Path, html: bytes, ssl_ctx: ssl.SSLContext | None = None):
super().__init__(addr, handler)
self.upload_dir = upload_dir
self.html = html
self.ssl_ctx = ssl_ctx
def get_request(self):
client, addr = self.socket.accept()
if self.ssl_ctx:
client.settimeout(5)
try:
client = self.ssl_ctx.wrap_socket(client, server_side=True)
except (ssl.SSLError, OSError):
client.close()
raise
client.settimeout(None)
return client, addr
class Handler(http.server.BaseHTTPRequestHandler):
server: ShareServer
def finish(self):
try:
super().finish()
except (ssl.SSLError, BrokenPipeError, ConnectionResetError, OSError):
pass
def log_message(self, fmt, *args):
print(f" [{datetime.now():%H:%M:%S}] {args[0]}")
def _json(self, data):
self._respond(json.dumps(data).encode(), "application/json")
def _respond(self, data: bytes, ct: str):
self.send_response(200)
self.send_header("Content-Type", ct)
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
# ── Routes ──
ROUTES = {
"GET": [
("/", "_route_index"),
("/index.html","_route_index"),
("/files", "_route_files"),
("/dl/", "_route_download"),
],
"POST": [("/upload", "_route_upload")],
"DELETE": [("/del/", "_route_delete")],
}
def _dispatch(self):
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)):
return getattr(self, method)(prefix)
self.send_error(404)
do_GET = do_POST = do_DELETE = _dispatch
def _route_index(self, _prefix):
self._respond(self.server.html, "text/html; charset=utf-8")
def _route_files(self, _prefix):
entries = []
for p in self.server.upload_dir.iterdir():
if p.is_file():
entries.append((p, p.stat()))
entries.sort(key=lambda e: e[1].st_mtime, reverse=True)
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):])
return self.server.upload_dir / Path(name).name
def _route_download(self, prefix):
safe = self._safe_path(prefix)
try:
st = safe.stat()
except FileNotFoundError:
self.send_error(404)
return
if not stat.S_ISREG(st.st_mode):
self.send_error(404)
return
ct = mimetypes.guess_type(safe.name)[0] or "application/octet-stream"
fname = safe.name.replace("\\", "\\\\").replace('"', '\\"')
self.send_response(200)
self.send_header("Content-Type", ct)
self.send_header("Content-Disposition", f'attachment; filename="{fname}"')
self.send_header("Content-Length", str(st.st_size))
self.send_header("Connection", "close")
self.end_headers()
with safe.open("rb") as f:
while chunk := f.read(65536):
self.wfile.write(chunk)
def _route_upload(self, _prefix):
ct = self.headers.get("Content-Type", "")
if "multipart/form-data" not in ct:
self.send_error(400)
return
length = int(self.headers.get("Content-Length", 0))
if not length:
self.send_error(400)
return
body = self.rfile.read(length)
count = self._save_parts(ct, body)
self._json({"ok": True, "count": count})
def _route_delete(self, prefix):
safe = self._safe_path(prefix)
if safe.is_file():
safe.unlink()
self._json({"ok": True})
# ── Helpers ──
def _save_parts(self, content_type: str, body: bytes) -> int:
header = f"Content-Type: {content_type}\r\n\r\n".encode()
msg = email.parser.BytesParser().parsebytes(header + body)
count = 0
for part in msg.walk():
fname = part.get_filename()
if not fname:
continue
fname = Path(fname).name or f"paste_{datetime.now():%H%M%S}"
data = part.get_payload(decode=True)
if not data:
continue
dest = self._unique_path(fname)
dest.write_bytes(data)
count += 1
print(f" + {dest.name} ({len(data)} bytes)")
return count
def _unique_path(self, filename: str) -> Path:
dest = self.server.upload_dir / filename
if not dest.exists():
return dest
stem, suffix = dest.stem, dest.suffix
i = 1
while dest.exists():
dest = self.server.upload_dir / f"{stem}_{i}{suffix}"
i += 1
return dest
# ── Main ──
DEFAULTS = {
"port": "3001",
"dir": str(Path.home() / "Downloads" / "shared"),
"ttl": "7",
"san": "",
"lang": "ru",
"refresh": "5",
"per_page": "10,25,50,100",
}
def load_config() -> dict:
"""Load config: defaults ← config file ← CLI args."""
cfg_path = BASE_DIR / "share.conf"
cp = configparser.ConfigParser()
cp.read_dict({"share": DEFAULTS})
cp.read(cfg_path)
conf = dict(cp["share"])
p = argparse.ArgumentParser(description="Share — local file sharing server")
p.add_argument("-p", "--port", type=int)
p.add_argument("-d", "--dir")
p.add_argument("--ttl", type=int)
p.add_argument("--san", nargs="*")
p.add_argument("--lang")
p.add_argument("--refresh", type=int)
args = p.parse_args()
for key, val in vars(args).items():
if val is not None:
conf[key] = " ".join(val) if isinstance(val, list) else str(val)
return conf
def main():
conf = load_config()
port = int(conf["port"])
upload_dir = Path(conf["dir"]).expanduser().resolve()
max_age = int(conf["ttl"]) * 86400
san_list = conf["san"].split() if conf["san"] else None
upload_dir.mkdir(parents=True, exist_ok=True)
threading.Thread(target=cleanup_loop, args=(upload_dir, max_age), daemon=True).start()
ip = get_local_ip()
cfg_json = json.dumps({
"lang": conf["lang"],
"refresh": int(conf["refresh"]),
"perPage": [int(x) for x in conf["per_page"].split(",")],
})
cfg_tag = f"<script>const CFG={cfg_json}</script>".encode()
html = (BASE_DIR / "static" / "index.html").read_bytes()
html = html.replace(b"<!--CFG-->", cfg_tag)
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)
print("\n Share running")
print(f" Local: https://localhost:{port}")
print(f" Network: https://{ip}:{port}")
print(f" Files: {upload_dir}\n")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n Stopped.")
server.server_close()
if __name__ == "__main__":
main()