commit eba5ece1475ee828ed27ee662ee54763b50a6818 Author: Эльнар Date: Tue Mar 10 20:23:55 2026 +0400 Initial commit Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72f2ced --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.certs/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..cdd3c85 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Share + +Local network file sharing via web page. Drag & drop, Ctrl+V paste, download. + +No dependencies — Python 3 stdlib only. + +## Quick start + +``` +python3 share.py +``` + +Open `http://localhost:8888` in a browser. The network address is printed at startup. + +## Options + +``` +python3 share.py -p 3001 -d ~/Downloads/shared --ttl 7 +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `-p`, `--port` | `8888` | Port | +| `-d`, `--dir` | `~/Downloads/shared` | Upload directory | +| `--ttl` | `7` | File lifetime in days (0 = keep forever) | + +## systemd (Arch) + +Service file: `~/.config/systemd/user/share.service` + +``` +systemctl --user enable --now share # start + autostart +systemctl --user restart share # restart +systemctl --user stop share # stop +journalctl --user -u share -f # logs +``` + +## Features + +- Drag & drop files onto the page +- Ctrl+V to paste images / files from clipboard +- Click the drop zone to open file picker +- Upload progress bar +- File list with download and delete +- Auto-cleanup of old files (configurable via `--ttl`) diff --git a/share.conf b/share.conf new file mode 100644 index 0000000..48f9969 --- /dev/null +++ b/share.conf @@ -0,0 +1,8 @@ +[share] +port = 3001 +dir = ~/Downloads/shared +ttl = 7 +san = 192.168.0.1 +lang = ru +refresh = 5 +per_page = 10,25,50,100 diff --git a/share.py b/share.py new file mode 100755 index 0000000..0f974a7 --- /dev/null +++ b/share.py @@ -0,0 +1,308 @@ +#!/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"".encode() + 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) + 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() diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..5618c8a --- /dev/null +++ b/static/index.html @@ -0,0 +1,542 @@ + + + + + +Share + + + + + + + + + + + + + + + + + + + + + +
+

+
+
+ + +
+
+
+
+ +
+
+

+
+
+
+ +
+ +
+ + + + +