Split share.py into modules by responsibility

config.py  — share.config loading, constants, load_config(), client_cfg()
cert.py    — get_local_ip(), ensure_cert()
cleanup.py — cleanup_loop()
server.py  — ShareServer, Handler, file_type()
share.py   — main() entry point only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 20:56:58 +04:00
co-authored by Claude Opus 4.6
parent 9cc4693f39
commit 6a540eaf81
5 changed files with 314 additions and 298 deletions
+42
View File
@@ -0,0 +1,42 @@
"""Self-signed certificate generation."""
import socket
import subprocess
from pathlib import Path
from config import CERT_DAYS, CERT_KEY_BITS
def get_local_ip() -> str:
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", f"rsa:{CERT_KEY_BITS}",
"-keyout", str(key), "-out", str(cert),
"-days", str(CERT_DAYS), "-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
+26
View File
@@ -0,0 +1,26 @@
"""Periodic cleanup of expired files."""
import time
from pathlib import Path
from config import CLEANUP_INTERVAL
def cleanup_loop(upload_dir: Path, max_age: int):
if max_age <= 0:
return
while True:
time.sleep(CLEANUP_INTERVAL)
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
+62
View File
@@ -0,0 +1,62 @@
"""Configuration: share.config loading, constants, CLI args."""
import argparse
import configparser
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
_cp = configparser.ConfigParser()
_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",
},
"internal": {
"secs_per_day": "86400", "cleanup_interval": "3600",
"ssl_handshake_timeout": "5", "sniff_size": "8192",
"chunk_size": "65536", "cert_days": "3650", "cert_key_bits": "2048",
"toast_ms": "2500", "progress_hide_ms": "1200",
},
})
_cp.read(BASE_DIR / "share.config")
SECS_PER_DAY = _cp.getint("internal", "secs_per_day")
CLEANUP_INTERVAL = _cp.getint("internal", "cleanup_interval")
SSL_HANDSHAKE_TIMEOUT = _cp.getint("internal", "ssl_handshake_timeout")
SNIFF_SIZE = _cp.getint("internal", "sniff_size")
CHUNK_SIZE = _cp.getint("internal", "chunk_size")
CERT_DAYS = _cp.getint("internal", "cert_days")
CERT_KEY_BITS = _cp.getint("internal", "cert_key_bits")
def load_config() -> dict:
"""Load config: share.config defaults <- CLI args."""
conf = dict(_cp["server"])
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 client_cfg(conf: dict) -> dict:
"""Build config dict to inject into HTML as CFG."""
return {
"lang": conf["lang"],
"refresh": int(conf["refresh"]),
"perPage": [int(x) for x in conf["per_page"].split(",")],
"toastMs": _cp.getint("internal", "toast_ms"),
"progressHideMs": _cp.getint("internal", "progress_hide_ms"),
}
+179
View File
@@ -0,0 +1,179 @@
"""HTTP server and request handler."""
import email.parser
import http.server
import json
import mimetypes
import ssl
import stat
import urllib.parse
from datetime import datetime
from pathlib import Path
from config import CHUNK_SIZE, SNIFF_SIZE, SSL_HANDSHAKE_TIMEOUT
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(SNIFF_SIZE) else 'other'
except OSError:
return 'other'
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(SSL_HANDSHAKE_TIMEOUT)
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(CHUNK_SIZE):
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
+5 -298
View File
@@ -1,301 +1,15 @@
#!/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(CLEANUP_INTERVAL)
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", f"rsa:{CERT_KEY_BITS}",
"-keyout", str(key), "-out", str(cert),
"-days", str(CERT_DAYS), "-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
# ── Config ──
_cp = configparser.ConfigParser()
_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",
},
"internal": {
"secs_per_day": "86400", "cleanup_interval": "3600",
"ssl_handshake_timeout": "5", "sniff_size": "8192",
"chunk_size": "65536", "cert_days": "3650", "cert_key_bits": "2048",
"toast_ms": "2500", "progress_hide_ms": "1200",
},
})
_cp.read(BASE_DIR / "share.config")
SECS_PER_DAY = _cp.getint("internal", "secs_per_day")
CLEANUP_INTERVAL = _cp.getint("internal", "cleanup_interval")
SSL_HANDSHAKE_TIMEOUT = _cp.getint("internal", "ssl_handshake_timeout")
SNIFF_SIZE = _cp.getint("internal", "sniff_size")
CHUNK_SIZE = _cp.getint("internal", "chunk_size")
CERT_DAYS = _cp.getint("internal", "cert_days")
CERT_KEY_BITS = _cp.getint("internal", "cert_key_bits")
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(SNIFF_SIZE) 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(SSL_HANDSHAKE_TIMEOUT)
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(CHUNK_SIZE):
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 ──
def load_config() -> dict:
"""Load config: share.config defaults ← CLI args."""
conf = dict(_cp["server"])
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
from cert import ensure_cert, get_local_ip
from cleanup import cleanup_loop
from config import BASE_DIR, SECS_PER_DAY, client_cfg, load_config
from server import Handler, ShareServer
def main():
@@ -311,14 +25,7 @@ def main():
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(",")],
"toastMs": _cp.getint("internal", "toast_ms"),
"progressHideMs": _cp.getint("internal", "progress_hide_ms"),
})
cfg_tag = f"<script>const CFG={cfg_json}</script>".encode()
cfg_tag = f"<script>const CFG={json.dumps(client_cfg(conf))}</script>".encode()
html = (BASE_DIR / "static" / "index.html").read_bytes()
html = html.replace(b"<!--CFG-->", cfg_tag)