309 lines
9.2 KiB
Python
Executable File
309 lines
9.2 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 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:
|
|
for f in upload_dir.iterdir():
|
|
if f.is_file() and now - f.stat().st_mtime > max_age:
|
|
f.unlink()
|
|
print(f" - expired: {f.name}")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ── SSL ──
|
|
|
|
|
|
def get_local_ip():
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
s.connect(("8.8.8.8", 80))
|
|
ip = s.getsockname()[0]
|
|
s.close()
|
|
return ip
|
|
except Exception:
|
|
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
|
|
|
|
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
|
|
|
|
|
|
# ── 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, ct):
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", ct)
|
|
self.send_header("Connection", "close")
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.end_headers()
|
|
self.wfile.write(data if isinstance(data, bytes) else data.encode())
|
|
|
|
# ── 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)()
|
|
self.send_error(404)
|
|
|
|
do_GET = do_POST = do_DELETE = _dispatch
|
|
|
|
def _route_index(self):
|
|
self._respond(self.server.html, "text/html; charset=utf-8")
|
|
|
|
def _route_files(self):
|
|
files = []
|
|
for p in sorted(self.server.upload_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
if p.is_file():
|
|
files.append({"name": p.name, "size": p.stat().st_size})
|
|
self._json(files)
|
|
|
|
def _route_download(self):
|
|
name = urllib.parse.unquote(self.path[4:])
|
|
safe = self.server.upload_dir / Path(name).name
|
|
if not safe.is_file():
|
|
self.send_error(404)
|
|
return
|
|
ct = mimetypes.guess_type(safe.name)[0] or "application/octet-stream"
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", ct)
|
|
self.send_header("Content-Disposition", f'attachment; filename="{safe.name}"')
|
|
self.send_header("Content-Length", str(safe.stat().st_size))
|
|
self.end_headers()
|
|
with open(safe, "rb") as f:
|
|
while chunk := f.read(65536):
|
|
self.wfile.write(chunk)
|
|
|
|
def _route_upload(self):
|
|
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))
|
|
body = self.rfile.read(length)
|
|
count = self._save_parts(ct, body)
|
|
self._json({"ok": True, "count": count})
|
|
|
|
def _route_delete(self):
|
|
name = urllib.parse.unquote(self.path[5:])
|
|
safe = self.server.upload_dir / Path(name).name
|
|
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", type=str)
|
|
p.add_argument("--ttl", type=int)
|
|
p.add_argument("--san", nargs="*")
|
|
p.add_argument("--lang", type=str)
|
|
p.add_argument("--refresh", type=int)
|
|
args = p.parse_args()
|
|
|
|
if args.port is not None:
|
|
conf["port"] = str(args.port)
|
|
if args.dir is not None:
|
|
conf["dir"] = args.dir
|
|
if args.ttl is not None:
|
|
conf["ttl"] = str(args.ttl)
|
|
if args.san is not None:
|
|
conf["san"] = " ".join(args.san)
|
|
if args.lang is not None:
|
|
conf["lang"] = args.lang
|
|
if args.refresh is not None:
|
|
conf["refresh"] = str(args.refresh)
|
|
|
|
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(f"\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()
|