Screenshots werden hier laufend unter gleichem Dateinamen ausgetauscht. Der Browser hat dann die alte Fassung gezeigt, obwohl die Datei auf der Platte schon die neue war. Das ist ein Werkzeug, kein Auslieferungsserver: ein erneuter Ladevorgang kostet weniger als die Suche nach dem Grund. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
88 lines
3.0 KiB
Python
Executable File
88 lines
3.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Static server for the post generator, plus a save endpoint.
|
|
|
|
The browser download route turned out to be unreliable here: with a data: URL it
|
|
opened a blank tab, with a blob: URL it opened the image in a tab. Both times the
|
|
download attribute was ignored. Since this only ever runs locally, the generator
|
|
now POSTs the rendered bytes to /save and the file is written straight to the
|
|
Downloads folder. No browser download machinery involved.
|
|
"""
|
|
|
|
import http.server
|
|
import os
|
|
import re
|
|
import socketserver
|
|
from pathlib import Path
|
|
|
|
PORT = 4321
|
|
ROOT = Path(__file__).resolve().parent
|
|
TARGET = Path.home() / "Downloads"
|
|
SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,120}$")
|
|
|
|
|
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
def __init__(self, *a, **kw):
|
|
super().__init__(*a, directory=str(ROOT), **kw)
|
|
|
|
def do_POST(self):
|
|
if self.path.split("?")[0] != "/save":
|
|
self.send_error(404)
|
|
return
|
|
|
|
name = self.headers.get("X-Filename", "")
|
|
# The filename comes from the page, so it is never trusted: no paths, no
|
|
# traversal, plain basename only.
|
|
name = os.path.basename(name)
|
|
if not SAFE_NAME.match(name):
|
|
self._json(400, '{"error":"ungueltiger Dateiname"}')
|
|
return
|
|
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
if length <= 0 or length > 80 * 1024 * 1024:
|
|
self._json(400, '{"error":"ungueltige Groesse"}')
|
|
return
|
|
|
|
data = self.rfile.read(length)
|
|
TARGET.mkdir(parents=True, exist_ok=True)
|
|
dest = TARGET / name
|
|
stem, suffix = dest.stem, dest.suffix
|
|
n = 1
|
|
while dest.exists():
|
|
dest = TARGET / f"{stem}-{n}{suffix}"
|
|
n += 1
|
|
dest.write_bytes(data)
|
|
print(f"gespeichert: {dest} ({len(data)/1024:.0f} KB)")
|
|
self._json(200, '{"path":"%s"}' % dest)
|
|
|
|
def _json(self, code, body):
|
|
payload = body.encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def end_headers(self):
|
|
# Werkzeug, kein Auslieferungsserver: Assets werden hier laufend
|
|
# ausgetauscht, und ein gecachter Screenshot unter gleichem Dateinamen
|
|
# kostet mehr Zeit als jeder erneute Ladevorgang.
|
|
self.send_header("Cache-Control", "no-store, must-revalidate")
|
|
self.send_header("Pragma", "no-cache")
|
|
self.send_header("Expires", "0")
|
|
super().end_headers()
|
|
|
|
def log_message(self, fmt, *args):
|
|
if "POST" in (args[0] if args else ""):
|
|
super().log_message(fmt, *args)
|
|
|
|
|
|
class Server(socketserver.ThreadingTCPServer):
|
|
allow_reuse_address = True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(f"Generator: http://127.0.0.1:{PORT}/post1.html")
|
|
print(f"Speichert nach: {TARGET}")
|
|
with Server(("127.0.0.1", PORT), Handler) as httpd:
|
|
httpd.serve_forever()
|