links.nikolai-linschmann.de
Download
server.py
Edit and save this text file in the browser. Autosaves every 30 seconds after changes.
Save
Back
#!/usr/bin/env python3 """links.nikolai-linschmann.de — Simple file sharing server. Run: python3 server.py - Upload via POST /upload (multipart/form-data) - Download via GET /d/<id> - List via GET / (HTML page) - Delete JSON via POST /delete """ import http.server import json import os import hashlib import mimetypes import html import urllib.parse import uuid import shutil import time import socket from pathlib import Path from io import BytesIO DATA_DIR = Path(os.environ.get("LINKS_DATA_DIR", "/var/lib/links/data")) LINKS_FILE = Path(os.environ.get("LINKS_LINKS_FILE", "/var/lib/links/links.json")) BIND_HOST = os.environ.get("LINKS_HOST", "127.0.0.1") BIND_PORT = int(os.environ.get("LINKS_PORT", "8765")) MAX_UPLOAD = 200 * 1024 * 1024 # 200 MB def load_links(): if LINKS_FILE.exists(): with open(LINKS_FILE) as f: return json.load(f) return [] def save_links(links): LINKS_FILE.parent.mkdir(parents=True, exist_ok=True) with open(LINKS_FILE, "w") as f: json.dump(links, f, indent=2) def generate_id(): return uuid.uuid4().hex[:12] def parse_multipart(body, content_type): """Parse multipart/form-data without cgi module.""" boundary = None for part in content_type.split(";"): part = part.strip() if part.startswith("boundary="): boundary = part[9:].strip('"').strip("'") break if not boundary: return {} boundary = boundary.encode("utf-8") delimiter = b"--" + boundary delimiter_end = b"--" + boundary + b"--" parts = {} # Split by boundary chunks = body.split(delimiter) for chunk in chunks: # Skip empty, opening, closing if not chunk or chunk.strip() == b"--" or chunk == b"--\r\n": continue # Split headers from body at first \r\n\r\n try: header_end = chunk.index(b"\r\n\r\n") header_bytes = chunk[:header_end] content_bytes = chunk[header_end + 4:] except ValueError: continue # Remove trailing \r\n if content_bytes.endswith(b"\r\n"): content_bytes = content_bytes[:-2] # Parse headers headers = {} for line in header_bytes.decode("utf-8", errors="replace").split("\r\n"): if ":" in line: k, v = line.split(":", 1) headers[k.strip().lower()] = v.strip() disposition = headers.get("content-disposition", "") name = None filename = None for dp in disposition.split(";"): dp = dp.strip() if dp.startswith("name="): name = dp[5:].strip('"').strip("'") elif dp.startswith("filename="): filename = dp[9:].strip('"').strip("'") if name: parts[name] = { "value": content_bytes, "filename": filename, "headers": headers, } return parts def format_size(n): for unit in ("B", "KB", "MB", "GB"): if n < 1024: return f"{n:.0f} {unit}" if n == int(n) else f"{n:.1f} {unit}" n /= 1024 return f"{n:.1f} TB" PAGE_HEAD = """<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>links.nikolai-linschmann.de</title> <style> *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; min-height: 100vh; } header { background: #161b22; border-bottom: 1px solid #30363d; padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; } header h1 { font-size: 1.25rem; font-weight: 600; } header h1 a { color: #58a6ff; text-decoration: none; } header h1 a:hover { text-decoration: underline; } main { max-width: 800px; margin: 0 auto; padding: 24px; } .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 16px; } .card h2 { font-size: 1.1rem; margin-bottom: 12px; color: #f0f6fc; } .form-group { margin-bottom: 12px; } .form-group label { display: block; font-size: 0.875rem; margin-bottom: 4px; color: #8b949e; } input[type="file"] { width: 100%; padding: 8px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.875rem; } .btn { display: inline-block; padding: 8px 20px; border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; text-decoration: none; } .btn-primary { background: #238636; color: #fff; } .btn-primary:hover { background: #2ea043; } .btn-primary:disabled { opacity: .5; cursor: not-allowed; } .btn-danger { background: #da3633; color: #fff; } .btn-danger:hover { background: #f85149; } .msg { padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; font-size: 0.875rem; } .msg-success { background: #1a3a1c; border: 1px solid #238636; color: #7ee787; } .msg-error { background: #3d1414; border: 1px solid #da3633; color: #ffa198; } table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #21262d; font-size: 0.875rem; } th { color: #8b949e; font-weight: 600; } td a { color: #58a6ff; text-decoration: none; } td a:hover { text-decoration: underline; } .filename { word-break: break-all; max-width: 240px; } .size { color: #8b949e; white-space: nowrap; } .time { color: #8b949e; white-space: nowrap; font-size: 0.75rem; } .delete-btn { background: none; border: 1px solid #30363d; color: #f85149; cursor: pointer; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; } .delete-btn:hover { background: #da3633; color: #fff; } .empty { text-align: center; padding: 40px 20px; color: #8b949e; } .progress-wrap { display: none; margin-top: 12px; } .progress-bar { width: 100%; height: 8px; background: #21262d; border-radius: 4px; overflow: hidden; } .progress-fill { height: 100%; width: 0%; background: #238636; border-radius: 4px; transition: width .3s; } .progress-text { font-size: 0.75rem; color: #8b949e; margin-top: 4px; text-align: center; } .copy-btn { background: none; border: 1px solid #30363d; color: #c9d1d9; cursor: pointer; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; } .copy-btn:hover { background: #30363d; } footer { text-align: center; padding: 24px; color: #484f58; font-size: 0.75rem; } </style> </head> <body> """ PAGE_FOOT = """ <script> function copyLink(btn, url) { navigator.clipboard.writeText(url).then(() => { const orig = btn.textContent; btn.textContent = '✓ Copied!'; setTimeout(() => btn.textContent = orig, 1500); }); } function deleteFile(id, btn) { if (!confirm('Delete this file?')) return; btn.textContent = '...'; fetch('/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id}) }) .then(r => r.json()).then(d => { if (d.ok) { btn.closest('tr').remove(); } else { alert(d.error); btn.textContent = 'Delete'; } }).catch(() => { alert('Error'); btn.textContent = 'Delete'; }); } document.getElementById('upload-form')?.addEventListener('submit', function(e) { const btn = this.querySelector('button[type="submit"]'); const pw = document.getElementById('progress-wrap'); const pf = document.getElementById('progress-fill'); const pt = document.getElementById('progress-text'); btn.disabled = true; btn.textContent = 'Uploading...'; pw.style.display = 'block'; const xhr = new XMLHttpRequest(); xhr.upload.onprogress = function(ev) { if (ev.lengthComputable) { const pct = Math.round(ev.loaded / ev.total * 100); pf.style.width = pct + '%'; pt.textContent = pct + '%'; } }; xhr.onload = function() { pf.style.width = '100%'; pt.textContent = 'Done!'; if (xhr.status === 200) { location.reload(); } else { try { const d = JSON.parse(xhr.responseText); alert(d.error || 'Upload failed'); } catch { alert('Upload failed'); } btn.disabled = false; btn.textContent = 'Upload'; pw.style.display = 'none'; } }; xhr.onerror = function() { alert('Upload failed - network error'); btn.disabled = false; btn.textContent = 'Upload'; pw.style.display = 'none'; }; xhr.open('POST', '/upload', true); xhr.send(new FormData(this)); e.preventDefault(); }); </script> </body> </html>""" def render_list(): links = load_links() rows = "" if not links: rows = '<tr><td colspan="4" class="empty">No files uploaded yet.</td></tr>' else: for item in reversed(links): fid = item["id"] fn = html.escape(item["filename"]) sz = format_size(item["size"]) ts = item.get("time", "") link = f"https://links.nikolai-linschmann.de/d/{fid}" # Format time nicely try: t = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) except: t = ts rows += ( f'<tr>' f'<td class="filename"><a href="/d/{fid}" download>{fn}</a></td>' f'<td class="size">{sz}</td>' f'<td class="time">{t}</td>' f'<td>' f'<button class="copy-btn" onclick="copyLink(this,\'{link}\')">Copy link</button> ' f'<button class="delete-btn" onclick="deleteFile(\'{fid}\',this)">Delete</button>' f'</td>' f'</tr>' ) body = f""" <header><h1><a href="/">links.nikolai-linschmann.de</a></h1></header> <main> <div class="card"> <h2>Upload File</h2> <form id="upload-form" method="post" action="/upload" enctype="multipart/form-data"> <div class="form-group"><input type="file" name="file" required></div> <button type="submit" class="btn btn-primary">Upload</button> </form> <div id="progress-wrap" class="progress-wrap"> <div class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div> <div id="progress-text" class="progress-text">0%</div> </div> </div> <div class="card"> <h2>Files</h2> <table><thead><tr><th>Filename</th><th>Size</th><th>Uploaded</th><th>Actions</th></tr></thead><tbody>{rows}</tbody></table> </div> </main> <footer>links.nikolai-linschmann.de</footer> """ return PAGE_HEAD + body + PAGE_FOOT class FileSharingHandler(http.server.BaseHTTPRequestHandler): def log_message(self, fmt, *args): print(f"[links] {self.client_address[0]} - {fmt % args}") def _send_json(self, data, status=200): body = json.dumps(data).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _send_html(self, html_text, status=200): body = html_text.encode("utf-8") self.send_response(status) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _send_error(self, msg, status=400): self._send_json({"ok": False, "error": msg}, status) def do_GET(self): parsed = urllib.parse.urlparse(self.path) path = parsed.path if path == "/" or path == "/index.html": self._send_html(render_list()) elif path.startswith("/d/"): fid = path[3:] links = load_links() item = None for l in links: if l["id"] == fid: item = l break if not item: self._send_error("File not found", 404) return filepath = DATA_DIR / item["filename"] if not filepath.exists(): self._send_error("File not found on disk", 404) return mime, _ = mimetypes.guess_type(item["filename"]) if mime is None: mime = "application/octet-stream" size = filepath.stat().st_size self.send_response(200) self.send_header("Content-Type", mime) self.send_header("Content-Disposition", f'attachment; filename="{item["filename"]}"') self.send_header("Content-Length", str(size)) self.end_headers() with open(filepath, "rb") as f: shutil.copyfileobj(f, self.wfile) else: self._send_error("Not found", 404) def do_POST(self): parsed = urllib.parse.urlparse(self.path) path = parsed.path if path == "/upload": ctype = self.headers.get("Content-Type", "") c_len = int(self.headers.get("Content-Length", "0")) if c_len > MAX_UPLOAD: self._send_error(f"File too large (max {format_size(MAX_UPLOAD)})", 413) return body = self.rfile.read(c_len) parts = parse_multipart(body, ctype) if "file" not in parts: self._send_error("No file uploaded") return file_part = parts["file"] content = file_part["value"] filename = file_part.get("filename") or "unnamed" # Sanitize filename filename = os.path.basename(filename) if not filename: filename = "unnamed" # Generate unique ID and store fid = generate_id() DATA_DIR.mkdir(parents=True, exist_ok=True) filepath = DATA_DIR / (fid + "_" + filename) with open(filepath, "wb") as f: f.write(content) links = load_links() links.append({ "id": fid, "filename": filename, "size": len(content), "time": time.time(), "stored_as": filepath.name, }) save_links(links) self._send_json({ "ok": True, "id": fid, "url": f"https://links.nikolai-linschmann.de/d/{fid}", "filename": filename, }) elif path == "/delete": c_len = int(self.headers.get("Content-Length", "0")) body = self.rfile.read(c_len) try: data = json.loads(body) except json.JSONDecodeError: self._send_error("Invalid JSON") return fid = data.get("id") if not fid: self._send_error("No id provided") return links = load_links() new_links = [l for l in links if l["id"] != fid] removed = [l for l in links if l["id"] == fid] if not removed: self._send_error("File not found", 404) return # Delete from disk for item in removed: filepath = DATA_DIR / item.get("stored_as", item["filename"]) if filepath.exists(): filepath.unlink() save_links(new_links) self._send_json({"ok": True}) else: self._send_error("Not found", 404) def main(): DATA_DIR.mkdir(parents=True, exist_ok=True) LINKS_FILE.parent.mkdir(parents=True, exist_ok=True) server = http.server.HTTPServer((BIND_HOST, BIND_PORT), FileSharingHandler) print(f"[links] Starting on {BIND_HOST}:{BIND_PORT}") print(f"[links] Data dir: {DATA_DIR}") print(f"[links] Links file: {LINKS_FILE}") try: server.serve_forever() except KeyboardInterrupt: print("\n[links] Shutting down") server.server_close() if __name__ == "__main__": main()