diff --git a/share.py b/share.py index 4f2a537..6f71555 100755 --- a/share.py +++ b/share.py @@ -9,6 +9,7 @@ import json import mimetypes import socket import ssl +import stat import subprocess import threading import time @@ -26,12 +27,17 @@ def cleanup_loop(upload_dir: Path, max_age: int): time.sleep(3600) now = time.time() try: - for f in upload_dir.iterdir(): + 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 Exception: - pass + except OSError: + pass # ── SSL ── @@ -42,7 +48,7 @@ def get_local_ip(): with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.connect(("8.8.8.8", 80)) return s.getsockname()[0] - except Exception: + except OSError: return "127.0.0.1" @@ -53,6 +59,8 @@ def ensure_cert(cert_dir: Path, extra_ips: list[str] | None = None) -> tuple[Pat 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: @@ -79,7 +87,7 @@ def _file_type(path: Path) -> str: if ct.startswith('text/'): return 'text' try: - with open(path, 'rb') as f: + with path.open('rb') as f: return 'text' if b'\x00' not in f.read(8192) else 'other' except OSError: return 'other' @@ -123,13 +131,14 @@ class Handler(http.server.BaseHTTPRequestHandler): def _json(self, data): self._respond(json.dumps(data).encode(), "application/json") - def _respond(self, data, ct): + 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 if isinstance(data, bytes) else data.encode()) + self.wfile.write(data) # ── Routes ── @@ -147,15 +156,15 @@ class Handler(http.server.BaseHTTPRequestHandler): 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)() + return getattr(self, method)(prefix) self.send_error(404) do_GET = do_POST = do_DELETE = _dispatch - def _route_index(self): + def _route_index(self, _prefix): self._respond(self.server.html, "text/html; charset=utf-8") - def _route_files(self): + def _route_files(self, _prefix): entries = [] for p in self.server.upload_dir.iterdir(): if p.is_file(): @@ -163,37 +172,47 @@ class Handler(http.server.BaseHTTPRequestHandler): 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_len: int) -> Path: - name = urllib.parse.unquote(self.path[prefix_len:]) + 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): - safe = self._safe_path(4) - if not safe.is_file(): + 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="{safe.name}"') - self.send_header("Content-Length", str(safe.stat().st_size)) + 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 open(safe, "rb") as f: + with safe.open("rb") as f: while chunk := f.read(65536): self.wfile.write(chunk) - def _route_upload(self): + 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): - safe = self._safe_path(5) + def _route_delete(self, prefix): + safe = self._safe_path(prefix) if safe.is_file(): safe.unlink() self._json({"ok": True}) @@ -254,10 +273,10 @@ def load_config() -> dict: 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("-d", "--dir") p.add_argument("--ttl", type=int) p.add_argument("--san", nargs="*") - p.add_argument("--lang", type=str) + p.add_argument("--lang") p.add_argument("--refresh", type=int) args = p.parse_args() @@ -297,7 +316,7 @@ def main(): server = ShareServer(("0.0.0.0", port), Handler, upload_dir=upload_dir, html=html, ssl_ctx=ctx) - print(f"\n Share running") + print("\n Share running") print(f" Local: https://localhost:{port}") print(f" Network: https://{ip}:{port}") print(f" Files: {upload_dir}\n") diff --git a/static/index.html b/static/index.html index 996237b..208b37d 100644 --- a/static/index.html +++ b/static/index.html @@ -1,5 +1,5 @@ - +
@@ -34,7 +34,7 @@ h1 { font-size: 1.4rem; margin-bottom: 16px; color: #fff } width: 100%; max-width: 600px; border: 2px dashed var(--text-empty); border-radius: 12px; padding: 48px 24px; text-align: center; - cursor: pointer; transition: all .2s; + cursor: pointer; transition: border-color .2s, background .2s; margin-bottom: 20px; position: relative; } #drop.over { border-color: var(--accent); background: rgba(79,195,247,.08) } @@ -192,7 +192,7 @@ const drop = $('drop'), fi = $('fileinput'), flist = $('flist'), const _cfg = typeof CFG !== 'undefined' ? CFG : {}; const defaultLang = _cfg.lang || 'ru'; -const REFRESH_SEC = (_cfg.refresh || 5) * 1000; +const REFRESH_MS = (_cfg.refresh || 5) * 1000; const PER_PAGE_OPTS = _cfg.perPage || [10, 25, 50, 100]; /* ── i18n ── */ @@ -327,7 +327,7 @@ function copyFile(name, type) { const url = '/dl/' + encodeURIComponent(name); if (type === 'text') { - fetch(url).then(r => r.text()).then(text => copyText(text)) + fetch(url).then(r => { if (!r.ok) throw 0; return r.text() }).then(text => copyText(text)) .catch(() => toast(t().copyFail)); } else if (type === 'image') { @@ -336,7 +336,7 @@ function copyFile(name, type) { copyText(link, t().linkCopied); return; } - const pngBlob = fetch(url).then(r => r.blob()).then(toPngBlob); + const pngBlob = fetch(url).then(r => { if (!r.ok) throw 0; return r.blob() }).then(toPngBlob); navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]) .then(() => toast(t().imgCopied)) .catch(() => copyText(link, t().linkCopied)); @@ -348,8 +348,10 @@ function copyFile(name, type) { /* ── Upload ── */ +let uploading = false; function upload(fileList) { - if (!fileList.length) return; + if (!fileList.length || uploading) return; + uploading = true; const fd = new FormData(); for (const f of fileList) fd.append('files', f); @@ -364,8 +366,10 @@ function upload(fileList) { ptext.textContent = fmtSize(e.loaded) + ' / ' + fmtSize(e.total) + ' (' + pct + '%)'; }; + const done = () => { uploading = false; drop.classList.remove('uploading') }; + xhr.onload = () => { - drop.classList.remove('uploading'); + done(); pfill.style.width = '100%'; setTimeout(() => { $('progress').style.display = 'none'; pfill.style.width = '0' }, 1200); if (xhr.status === 200) { @@ -375,14 +379,14 @@ function upload(fileList) { } else { toast(t().uploadFail) } }; - xhr.onerror = () => { drop.classList.remove('uploading'); toast(t().uploadError) }; + xhr.onerror = () => { done(); toast(t().uploadError) }; xhr.open('POST', '/upload'); xhr.send(fd); } /* ── Pagination ── */ -let perPage = parseInt(localStorage.getItem('share-perpage')) || PER_PAGE_OPTS[0]; +let perPage = parseInt(localStorage.getItem('share-perpage'), 10) || PER_PAGE_OPTS[0]; let curPage = 1; let allFiles = []; @@ -462,7 +466,7 @@ function loadFiles() { fetch('/files').then(r => r.json()).then(files => { allFiles = files; renderFiles(); - }); + }).catch(() => {}); } /* ── Event delegation ── */ @@ -475,20 +479,21 @@ flist.addEventListener('click', e => { if (action === 'copy') copyFile(name, el.dataset.type); else if (action === 'del') { fetch('/del/' + encodeURIComponent(name), { method: 'DELETE' }) - .then(() => { toast(t().deleted); loadFiles() }); + .then(() => { toast(t().deleted); loadFiles() }) + .catch(() => {}); } }); $('pager').addEventListener('click', e => { const el = e.target.closest('[data-action="page"]'); if (!el || el.disabled) return; - setPage(parseInt(el.dataset.page)); + setPage(parseInt(el.dataset.page, 10)); }); $('per-page').addEventListener('click', e => { const el = e.target.closest('[data-action="pp"]'); if (!el) return; - setPerPage(parseInt(el.dataset.pp)); + setPerPage(parseInt(el.dataset.pp, 10)); }); /* ── Events ── */ @@ -526,7 +531,11 @@ document.addEventListener('paste', e => { }); setLang(lang); -setInterval(loadFiles, REFRESH_SEC); +let pollId = setInterval(loadFiles, REFRESH_MS); +document.addEventListener('visibilitychange', () => { + if (document.hidden) { clearInterval(pollId); } + else { loadFiles(); pollId = setInterval(loadFiles, REFRESH_MS); } +});