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>
This commit is contained in:
@@ -9,6 +9,7 @@ import json
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -26,12 +27,17 @@ def cleanup_loop(upload_dir: Path, max_age: int):
|
|||||||
time.sleep(3600)
|
time.sleep(3600)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
try:
|
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:
|
if f.is_file() and now - f.stat().st_mtime > max_age:
|
||||||
f.unlink()
|
f.unlink()
|
||||||
print(f" - expired: {f.name}")
|
print(f" - expired: {f.name}")
|
||||||
except Exception:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ── SSL ──
|
# ── SSL ──
|
||||||
@@ -42,7 +48,7 @@ def get_local_ip():
|
|||||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
s.connect(("8.8.8.8", 80))
|
s.connect(("8.8.8.8", 80))
|
||||||
return s.getsockname()[0]
|
return s.getsockname()[0]
|
||||||
except Exception:
|
except OSError:
|
||||||
return "127.0.0.1"
|
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"
|
key = cert_dir / "key.pem"
|
||||||
if cert.is_file() and key.is_file():
|
if cert.is_file() and key.is_file():
|
||||||
return cert, key
|
return cert, key
|
||||||
|
cert.unlink(missing_ok=True)
|
||||||
|
key.unlink(missing_ok=True)
|
||||||
|
|
||||||
ips = {"127.0.0.1", get_local_ip()}
|
ips = {"127.0.0.1", get_local_ip()}
|
||||||
if extra_ips:
|
if extra_ips:
|
||||||
@@ -79,7 +87,7 @@ def _file_type(path: Path) -> str:
|
|||||||
if ct.startswith('text/'):
|
if ct.startswith('text/'):
|
||||||
return 'text'
|
return 'text'
|
||||||
try:
|
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'
|
return 'text' if b'\x00' not in f.read(8192) else 'other'
|
||||||
except OSError:
|
except OSError:
|
||||||
return 'other'
|
return 'other'
|
||||||
@@ -123,13 +131,14 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
def _json(self, data):
|
def _json(self, data):
|
||||||
self._respond(json.dumps(data).encode(), "application/json")
|
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_response(200)
|
||||||
self.send_header("Content-Type", ct)
|
self.send_header("Content-Type", ct)
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
self.send_header("Connection", "close")
|
self.send_header("Connection", "close")
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(data if isinstance(data, bytes) else data.encode())
|
self.wfile.write(data)
|
||||||
|
|
||||||
# ── Routes ──
|
# ── Routes ──
|
||||||
|
|
||||||
@@ -147,15 +156,15 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
def _dispatch(self):
|
def _dispatch(self):
|
||||||
for prefix, method in self.ROUTES.get(self.command, []):
|
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)):
|
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)
|
self.send_error(404)
|
||||||
|
|
||||||
do_GET = do_POST = do_DELETE = _dispatch
|
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")
|
self._respond(self.server.html, "text/html; charset=utf-8")
|
||||||
|
|
||||||
def _route_files(self):
|
def _route_files(self, _prefix):
|
||||||
entries = []
|
entries = []
|
||||||
for p in self.server.upload_dir.iterdir():
|
for p in self.server.upload_dir.iterdir():
|
||||||
if p.is_file():
|
if p.is_file():
|
||||||
@@ -163,37 +172,47 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
entries.sort(key=lambda e: e[1].st_mtime, reverse=True)
|
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])
|
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:
|
def _safe_path(self, prefix: str) -> Path:
|
||||||
name = urllib.parse.unquote(self.path[prefix_len:])
|
name = urllib.parse.unquote(self.path[len(prefix):])
|
||||||
return self.server.upload_dir / Path(name).name
|
return self.server.upload_dir / Path(name).name
|
||||||
|
|
||||||
def _route_download(self):
|
def _route_download(self, prefix):
|
||||||
safe = self._safe_path(4)
|
safe = self._safe_path(prefix)
|
||||||
if not safe.is_file():
|
try:
|
||||||
|
st = safe.stat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
if not stat.S_ISREG(st.st_mode):
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
return
|
return
|
||||||
ct = mimetypes.guess_type(safe.name)[0] or "application/octet-stream"
|
ct = mimetypes.guess_type(safe.name)[0] or "application/octet-stream"
|
||||||
|
fname = safe.name.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", ct)
|
self.send_header("Content-Type", ct)
|
||||||
self.send_header("Content-Disposition", f'attachment; filename="{safe.name}"')
|
self.send_header("Content-Disposition", f'attachment; filename="{fname}"')
|
||||||
self.send_header("Content-Length", str(safe.stat().st_size))
|
self.send_header("Content-Length", str(st.st_size))
|
||||||
|
self.send_header("Connection", "close")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
with open(safe, "rb") as f:
|
with safe.open("rb") as f:
|
||||||
while chunk := f.read(65536):
|
while chunk := f.read(65536):
|
||||||
self.wfile.write(chunk)
|
self.wfile.write(chunk)
|
||||||
|
|
||||||
def _route_upload(self):
|
def _route_upload(self, _prefix):
|
||||||
ct = self.headers.get("Content-Type", "")
|
ct = self.headers.get("Content-Type", "")
|
||||||
if "multipart/form-data" not in ct:
|
if "multipart/form-data" not in ct:
|
||||||
self.send_error(400)
|
self.send_error(400)
|
||||||
return
|
return
|
||||||
length = int(self.headers.get("Content-Length", 0))
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
if not length:
|
||||||
|
self.send_error(400)
|
||||||
|
return
|
||||||
body = self.rfile.read(length)
|
body = self.rfile.read(length)
|
||||||
count = self._save_parts(ct, body)
|
count = self._save_parts(ct, body)
|
||||||
self._json({"ok": True, "count": count})
|
self._json({"ok": True, "count": count})
|
||||||
|
|
||||||
def _route_delete(self):
|
def _route_delete(self, prefix):
|
||||||
safe = self._safe_path(5)
|
safe = self._safe_path(prefix)
|
||||||
if safe.is_file():
|
if safe.is_file():
|
||||||
safe.unlink()
|
safe.unlink()
|
||||||
self._json({"ok": True})
|
self._json({"ok": True})
|
||||||
@@ -254,10 +273,10 @@ def load_config() -> dict:
|
|||||||
|
|
||||||
p = argparse.ArgumentParser(description="Share — local file sharing server")
|
p = argparse.ArgumentParser(description="Share — local file sharing server")
|
||||||
p.add_argument("-p", "--port", type=int)
|
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("--ttl", type=int)
|
||||||
p.add_argument("--san", nargs="*")
|
p.add_argument("--san", nargs="*")
|
||||||
p.add_argument("--lang", type=str)
|
p.add_argument("--lang")
|
||||||
p.add_argument("--refresh", type=int)
|
p.add_argument("--refresh", type=int)
|
||||||
args = p.parse_args()
|
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)
|
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" Local: https://localhost:{port}")
|
||||||
print(f" Network: https://{ip}:{port}")
|
print(f" Network: https://{ip}:{port}")
|
||||||
print(f" Files: {upload_dir}\n")
|
print(f" Files: {upload_dir}\n")
|
||||||
|
|||||||
+23
-14
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
@@ -34,7 +34,7 @@ h1 { font-size: 1.4rem; margin-bottom: 16px; color: #fff }
|
|||||||
width: 100%; max-width: 600px;
|
width: 100%; max-width: 600px;
|
||||||
border: 2px dashed var(--text-empty); border-radius: 12px;
|
border: 2px dashed var(--text-empty); border-radius: 12px;
|
||||||
padding: 48px 24px; text-align: center;
|
padding: 48px 24px; text-align: center;
|
||||||
cursor: pointer; transition: all .2s;
|
cursor: pointer; transition: border-color .2s, background .2s;
|
||||||
margin-bottom: 20px; position: relative;
|
margin-bottom: 20px; position: relative;
|
||||||
}
|
}
|
||||||
#drop.over { border-color: var(--accent); background: rgba(79,195,247,.08) }
|
#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 _cfg = typeof CFG !== 'undefined' ? CFG : {};
|
||||||
const defaultLang = _cfg.lang || 'ru';
|
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];
|
const PER_PAGE_OPTS = _cfg.perPage || [10, 25, 50, 100];
|
||||||
|
|
||||||
/* ── i18n ── */
|
/* ── i18n ── */
|
||||||
@@ -327,7 +327,7 @@ function copyFile(name, type) {
|
|||||||
const url = '/dl/' + encodeURIComponent(name);
|
const url = '/dl/' + encodeURIComponent(name);
|
||||||
|
|
||||||
if (type === 'text') {
|
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));
|
.catch(() => toast(t().copyFail));
|
||||||
|
|
||||||
} else if (type === 'image') {
|
} else if (type === 'image') {
|
||||||
@@ -336,7 +336,7 @@ function copyFile(name, type) {
|
|||||||
copyText(link, t().linkCopied);
|
copyText(link, t().linkCopied);
|
||||||
return;
|
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 })])
|
navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||||
.then(() => toast(t().imgCopied))
|
.then(() => toast(t().imgCopied))
|
||||||
.catch(() => copyText(link, t().linkCopied));
|
.catch(() => copyText(link, t().linkCopied));
|
||||||
@@ -348,8 +348,10 @@ function copyFile(name, type) {
|
|||||||
|
|
||||||
/* ── Upload ── */
|
/* ── Upload ── */
|
||||||
|
|
||||||
|
let uploading = false;
|
||||||
function upload(fileList) {
|
function upload(fileList) {
|
||||||
if (!fileList.length) return;
|
if (!fileList.length || uploading) return;
|
||||||
|
uploading = true;
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
for (const f of fileList) fd.append('files', f);
|
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 + '%)';
|
ptext.textContent = fmtSize(e.loaded) + ' / ' + fmtSize(e.total) + ' (' + pct + '%)';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const done = () => { uploading = false; drop.classList.remove('uploading') };
|
||||||
|
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
drop.classList.remove('uploading');
|
done();
|
||||||
pfill.style.width = '100%';
|
pfill.style.width = '100%';
|
||||||
setTimeout(() => { $('progress').style.display = 'none'; pfill.style.width = '0' }, 1200);
|
setTimeout(() => { $('progress').style.display = 'none'; pfill.style.width = '0' }, 1200);
|
||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
@@ -375,14 +379,14 @@ function upload(fileList) {
|
|||||||
} else { toast(t().uploadFail) }
|
} else { toast(t().uploadFail) }
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = () => { drop.classList.remove('uploading'); toast(t().uploadError) };
|
xhr.onerror = () => { done(); toast(t().uploadError) };
|
||||||
xhr.open('POST', '/upload');
|
xhr.open('POST', '/upload');
|
||||||
xhr.send(fd);
|
xhr.send(fd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Pagination ── */
|
/* ── 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 curPage = 1;
|
||||||
let allFiles = [];
|
let allFiles = [];
|
||||||
|
|
||||||
@@ -462,7 +466,7 @@ function loadFiles() {
|
|||||||
fetch('/files').then(r => r.json()).then(files => {
|
fetch('/files').then(r => r.json()).then(files => {
|
||||||
allFiles = files;
|
allFiles = files;
|
||||||
renderFiles();
|
renderFiles();
|
||||||
});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Event delegation ── */
|
/* ── Event delegation ── */
|
||||||
@@ -475,20 +479,21 @@ flist.addEventListener('click', e => {
|
|||||||
if (action === 'copy') copyFile(name, el.dataset.type);
|
if (action === 'copy') copyFile(name, el.dataset.type);
|
||||||
else if (action === 'del') {
|
else if (action === 'del') {
|
||||||
fetch('/del/' + encodeURIComponent(name), { method: 'DELETE' })
|
fetch('/del/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||||
.then(() => { toast(t().deleted); loadFiles() });
|
.then(() => { toast(t().deleted); loadFiles() })
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('pager').addEventListener('click', e => {
|
$('pager').addEventListener('click', e => {
|
||||||
const el = e.target.closest('[data-action="page"]');
|
const el = e.target.closest('[data-action="page"]');
|
||||||
if (!el || el.disabled) return;
|
if (!el || el.disabled) return;
|
||||||
setPage(parseInt(el.dataset.page));
|
setPage(parseInt(el.dataset.page, 10));
|
||||||
});
|
});
|
||||||
|
|
||||||
$('per-page').addEventListener('click', e => {
|
$('per-page').addEventListener('click', e => {
|
||||||
const el = e.target.closest('[data-action="pp"]');
|
const el = e.target.closest('[data-action="pp"]');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
setPerPage(parseInt(el.dataset.pp));
|
setPerPage(parseInt(el.dataset.pp, 10));
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Events ── */
|
/* ── Events ── */
|
||||||
@@ -526,7 +531,11 @@ document.addEventListener('paste', e => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setLang(lang);
|
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); }
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user