V2: Added Webserver for faster formatting
This commit is contained in:
@@ -0,0 +1,817 @@
|
||||
"""Small, dependency-free local web server for the roster formatter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat as stat_module
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
from threading import Event, Lock, Thread
|
||||
from typing import Mapping
|
||||
from urllib.parse import quote, unquote, urlsplit
|
||||
|
||||
from roster_formatter import (
|
||||
BASE_DIR,
|
||||
DEFAULT_LAYOUT,
|
||||
LayoutSettings,
|
||||
RosterFormatError,
|
||||
RosterPeriod,
|
||||
format_roster,
|
||||
)
|
||||
|
||||
|
||||
LOGGER = logging.getLogger("roster.web")
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8787
|
||||
DEFAULT_RETENTION_MONTHS = 3
|
||||
DEFAULT_MAX_UPLOAD_MB = 20
|
||||
RETENTION_CHECK_SECONDS = 6 * 60 * 60
|
||||
DEFAULT_ALLOWED_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "0.0.0.0"})
|
||||
LAYOUT_DEFAULTS_FILENAME = ".roster-layout-defaults.json"
|
||||
MAX_DEFAULTS_REQUEST_BYTES = 16 * 1024
|
||||
|
||||
|
||||
class RequestError(ValueError):
|
||||
def __init__(self, status: HTTPStatus, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UploadedFile:
|
||||
filename: str
|
||||
content_type: str
|
||||
data: bytes
|
||||
|
||||
|
||||
class OutputStore:
|
||||
"""Atomic persistent PNG storage with filename-based calendar retention."""
|
||||
|
||||
def __init__(self, root: Path, retention_months: int = DEFAULT_RETENTION_MONTHS) -> None:
|
||||
if retention_months < 0:
|
||||
raise ValueError("retention_months cannot be negative")
|
||||
self.root = root.resolve()
|
||||
self.retention_months = retention_months
|
||||
self._lock = Lock()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@property
|
||||
def layout_defaults_path(self) -> Path:
|
||||
return self.root / LAYOUT_DEFAULTS_FILENAME
|
||||
|
||||
@staticmethod
|
||||
def _read_regular_file(path: Path, max_bytes: int | None = None) -> bytes | None:
|
||||
try:
|
||||
path_stat = path.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
return None
|
||||
if not stat_module.S_ISREG(path_stat.st_mode):
|
||||
return None
|
||||
if max_bytes is not None and path_stat.st_size > max_bytes:
|
||||
return None
|
||||
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(path, flags)
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
opened_stat = os.fstat(descriptor)
|
||||
if not stat_module.S_ISREG(opened_stat.st_mode):
|
||||
return None
|
||||
if max_bytes is not None and opened_stat.st_size > max_bytes:
|
||||
return None
|
||||
source = os.fdopen(descriptor, "rb")
|
||||
descriptor = -1
|
||||
with source:
|
||||
return source.read()
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
|
||||
def load_layout_defaults(self) -> LayoutSettings:
|
||||
with self._lock:
|
||||
raw = self._read_regular_file(
|
||||
self.layout_defaults_path,
|
||||
max_bytes=MAX_DEFAULTS_REQUEST_BYTES,
|
||||
)
|
||||
if raw is None:
|
||||
return DEFAULT_LAYOUT
|
||||
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
if not isinstance(payload, dict) or payload.get("version") != 1:
|
||||
raise ValueError("unsupported settings format")
|
||||
layout = payload.get("layout")
|
||||
expected = set(DEFAULT_LAYOUT.as_form_values())
|
||||
if not isinstance(layout, dict) or set(layout) != expected:
|
||||
raise ValueError("settings fields do not match")
|
||||
values = {name: str(value) for name, value in layout.items()}
|
||||
return LayoutSettings.from_mapping(values)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, ValueError, TypeError) as exc:
|
||||
LOGGER.warning("Ignoring invalid saved layout defaults: %s", exc)
|
||||
return DEFAULT_LAYOUT
|
||||
|
||||
def save_layout_defaults(self, settings: LayoutSettings) -> None:
|
||||
data = (
|
||||
json.dumps(
|
||||
{"version": 1, "layout": settings.as_json_values()},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
with self._lock:
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with NamedTemporaryFile(
|
||||
mode="wb",
|
||||
prefix=".roster-defaults-",
|
||||
suffix=".json",
|
||||
dir=self.root,
|
||||
delete=False,
|
||||
) as temporary:
|
||||
temporary.write(data)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
temporary_path = Path(temporary.name)
|
||||
os.replace(temporary_path, self.layout_defaults_path)
|
||||
temporary_path = None
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def _cutoff(today: date | datetime, months: int) -> RosterPeriod:
|
||||
current = today.date() if isinstance(today, datetime) else today
|
||||
month_index = current.year * 12 + current.month - 1 - months
|
||||
return RosterPeriod(year=month_index // 12, month=month_index % 12 + 1)
|
||||
|
||||
def _prune_locked(self, today: date | datetime) -> list[str]:
|
||||
if self.retention_months == 0:
|
||||
return []
|
||||
|
||||
cutoff = self._cutoff(today, self.retention_months)
|
||||
cutoff_date = date(cutoff.year, cutoff.month, 1)
|
||||
removed: list[str] = []
|
||||
for path in self.root.iterdir():
|
||||
try:
|
||||
path_stat = path.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
if not stat_module.S_ISREG(path_stat.st_mode):
|
||||
continue
|
||||
period = RosterPeriod.from_output_filename(path.name)
|
||||
modified_date = datetime.fromtimestamp(path_stat.st_mtime).date()
|
||||
if period is None or (period >= cutoff and modified_date >= cutoff_date):
|
||||
continue
|
||||
path.unlink()
|
||||
removed.append(path.name)
|
||||
return sorted(removed)
|
||||
|
||||
def prune(self, today: date | datetime | None = None) -> list[str]:
|
||||
with self._lock:
|
||||
return self._prune_locked(today or date.today())
|
||||
|
||||
def save(
|
||||
self,
|
||||
filename: str,
|
||||
data: bytes,
|
||||
today: date | datetime | None = None,
|
||||
) -> tuple[Path, list[str], bool]:
|
||||
if RosterPeriod.from_output_filename(filename) is None:
|
||||
raise ValueError("Refusing to store an unexpected roster filename")
|
||||
|
||||
with self._lock:
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with NamedTemporaryFile(
|
||||
mode="wb",
|
||||
prefix=".roster-",
|
||||
suffix=".tmp",
|
||||
dir=self.root,
|
||||
delete=False,
|
||||
) as temporary:
|
||||
temporary.write(data)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
temporary_path = Path(temporary.name)
|
||||
|
||||
final_path = self.root / filename
|
||||
os.replace(temporary_path, final_path)
|
||||
temporary_path = None
|
||||
removed = self._prune_locked(today or date.today())
|
||||
return final_path, removed, final_path.exists()
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
def list_items(self) -> list[dict[str, object]]:
|
||||
with self._lock:
|
||||
items: list[tuple[RosterPeriod, dict[str, object]]] = []
|
||||
for path in self.root.iterdir():
|
||||
try:
|
||||
path_stat = path.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
if not stat_module.S_ISREG(path_stat.st_mode):
|
||||
continue
|
||||
period = RosterPeriod.from_output_filename(path.name)
|
||||
if period is None:
|
||||
continue
|
||||
items.append(
|
||||
(
|
||||
period,
|
||||
{
|
||||
"filename": path.name,
|
||||
"label": period.label,
|
||||
"url": f"/downloads/{quote(path.name)}",
|
||||
"size": path_stat.st_size,
|
||||
"modified": datetime.fromtimestamp(
|
||||
path_stat.st_mtime,
|
||||
tz=timezone.utc,
|
||||
).isoformat(),
|
||||
},
|
||||
)
|
||||
)
|
||||
items.sort(key=lambda item: item[0], reverse=True)
|
||||
return [item for _, item in items]
|
||||
|
||||
def verify_writable(self) -> None:
|
||||
"""Exercise both file creation and deletion for the health check."""
|
||||
|
||||
with self._lock:
|
||||
with NamedTemporaryFile(
|
||||
mode="wb",
|
||||
prefix=".roster-health-",
|
||||
suffix=".tmp",
|
||||
dir=self.root,
|
||||
) as probe:
|
||||
probe.write(b"ok")
|
||||
probe.flush()
|
||||
|
||||
def read(self, filename: str) -> bytes | None:
|
||||
if RosterPeriod.from_output_filename(filename) is None:
|
||||
return None
|
||||
with self._lock:
|
||||
return self._read_regular_file(self.root / filename)
|
||||
|
||||
|
||||
class RosterWebApp:
|
||||
def __init__(
|
||||
self,
|
||||
output_dir: Path,
|
||||
retention_months: int = DEFAULT_RETENTION_MONTHS,
|
||||
max_upload_bytes: int = DEFAULT_MAX_UPLOAD_MB * 1024 * 1024,
|
||||
allowed_hosts: set[str] | None = None,
|
||||
) -> None:
|
||||
self.web_dir = BASE_DIR / "web"
|
||||
self.logo_path = BASE_DIR / "assets" / "logo.png"
|
||||
self.store = OutputStore(output_dir, retention_months)
|
||||
self.max_upload_bytes = max_upload_bytes
|
||||
self.max_request_bytes = max_upload_bytes + 1024 * 1024
|
||||
self.allowed_hosts = DEFAULT_ALLOWED_HOSTS | frozenset(
|
||||
host.lower().rstrip(".") for host in (allowed_hosts or set())
|
||||
)
|
||||
# PyMuPDF does not support concurrent rendering in multiple threads.
|
||||
self._format_lock = Lock()
|
||||
|
||||
def index_html(self) -> bytes:
|
||||
template = (self.web_dir / "index.html").read_text(encoding="utf-8")
|
||||
saved_layout = self.store.load_layout_defaults()
|
||||
replacements = {
|
||||
"__DEFAULT_MONTH__": RosterPeriod.suggested().html_value,
|
||||
"__RETENTION_MONTHS__": str(self.store.retention_months),
|
||||
"__MAX_UPLOAD_MB__": str(self.max_upload_bytes // (1024 * 1024)),
|
||||
"__CONTENT_SHIFT__": str(saved_layout.content_shift),
|
||||
"__BOTTOM_MARGIN__": str(saved_layout.bottom_margin),
|
||||
"__LOGO_X__": str(saved_layout.logo_x),
|
||||
"__LOGO_Y__": str(saved_layout.logo_y),
|
||||
"__LOGO_SCALE__": str(saved_layout.logo_scale),
|
||||
"__DATE_X__": str(saved_layout.date_x),
|
||||
"__DATE_Y__": str(saved_layout.date_y),
|
||||
"__DATE_FONT_SIZE__": str(saved_layout.date_font_size),
|
||||
"__FACTORY_CONTENT_SHIFT__": str(DEFAULT_LAYOUT.content_shift),
|
||||
"__FACTORY_BOTTOM_MARGIN__": str(DEFAULT_LAYOUT.bottom_margin),
|
||||
"__FACTORY_LOGO_X__": str(DEFAULT_LAYOUT.logo_x),
|
||||
"__FACTORY_LOGO_Y__": str(DEFAULT_LAYOUT.logo_y),
|
||||
"__FACTORY_LOGO_SCALE__": str(DEFAULT_LAYOUT.logo_scale),
|
||||
"__FACTORY_DATE_X__": str(DEFAULT_LAYOUT.date_x),
|
||||
"__FACTORY_DATE_Y__": str(DEFAULT_LAYOUT.date_y),
|
||||
"__FACTORY_DATE_FONT_SIZE__": str(DEFAULT_LAYOUT.date_font_size),
|
||||
}
|
||||
rendered = template
|
||||
for placeholder, value in replacements.items():
|
||||
rendered = rendered.replace(placeholder, value)
|
||||
return rendered.encode("utf-8")
|
||||
|
||||
def format_upload(
|
||||
self,
|
||||
upload: UploadedFile,
|
||||
values: Mapping[str, str],
|
||||
) -> tuple[bytes, str, bool, list[str]]:
|
||||
if not upload.filename:
|
||||
raise RosterFormatError("Choose a roster PDF to upload.")
|
||||
if Path(upload.filename).suffix.lower() != ".pdf":
|
||||
raise RosterFormatError("Choose a file whose name ends in .pdf.")
|
||||
if not upload.data:
|
||||
raise RosterFormatError("The uploaded PDF is empty.")
|
||||
if len(upload.data) > self.max_upload_bytes:
|
||||
maximum = self.max_upload_bytes // (1024 * 1024)
|
||||
raise RequestError(
|
||||
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||
f"The PDF must be {maximum} MB or smaller.",
|
||||
)
|
||||
|
||||
period = RosterPeriod.parse(values.get("month"))
|
||||
settings = LayoutSettings.from_mapping(
|
||||
values,
|
||||
defaults=self.store.load_layout_defaults(),
|
||||
)
|
||||
|
||||
with self._format_lock:
|
||||
with TemporaryDirectory(prefix="roster-web-") as temp_dir:
|
||||
work_dir = Path(temp_dir)
|
||||
input_path = work_dir / "upload.pdf"
|
||||
output_path = work_dir / period.output_filename
|
||||
input_path.write_bytes(upload.data)
|
||||
format_roster(input_path, output_path, period, settings)
|
||||
png = output_path.read_bytes()
|
||||
|
||||
_, removed, saved = self.store.save(period.output_filename, png)
|
||||
return png, period.output_filename, saved, removed
|
||||
|
||||
def save_layout_defaults(self, values: Mapping[str, str]) -> LayoutSettings:
|
||||
expected = set(DEFAULT_LAYOUT.as_form_values())
|
||||
if set(values) != expected:
|
||||
raise RosterFormatError("Submit every layout setting before saving defaults.")
|
||||
if any(not values[name].strip() for name in expected):
|
||||
raise RosterFormatError("Layout defaults cannot contain blank values.")
|
||||
settings = LayoutSettings.from_mapping(values)
|
||||
self.store.save_layout_defaults(settings)
|
||||
return settings
|
||||
|
||||
|
||||
def parse_multipart(content_type: str, body: bytes) -> tuple[dict[str, str], UploadedFile]:
|
||||
if "\r" in content_type or "\n" in content_type:
|
||||
raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid upload content type.")
|
||||
|
||||
try:
|
||||
header = (
|
||||
f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n"
|
||||
).encode("ascii", errors="strict")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid upload content type.") from exc
|
||||
message = BytesParser(policy=policy.default).parsebytes(header + body)
|
||||
if not message.is_multipart():
|
||||
raise RequestError(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"The upload must use multipart form data.",
|
||||
)
|
||||
|
||||
values: dict[str, str] = {}
|
||||
uploads: list[UploadedFile] = []
|
||||
for part in message.iter_parts():
|
||||
if part.get_content_disposition() != "form-data":
|
||||
continue
|
||||
name = part.get_param("name", header="content-disposition")
|
||||
if not name:
|
||||
continue
|
||||
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload is None:
|
||||
payload = b""
|
||||
filename = part.get_filename()
|
||||
if filename is not None:
|
||||
if name == "roster":
|
||||
uploads.append(
|
||||
UploadedFile(
|
||||
filename=filename,
|
||||
content_type=part.get_content_type(),
|
||||
data=payload,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if len(payload) > 4096:
|
||||
raise RequestError(HTTPStatus.BAD_REQUEST, "A form value is too long.")
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
try:
|
||||
values[name] = payload.decode(charset)
|
||||
except (LookupError, UnicodeDecodeError) as exc:
|
||||
raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid form text encoding.") from exc
|
||||
|
||||
if len(uploads) != 1:
|
||||
raise RequestError(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Upload exactly one roster PDF.",
|
||||
)
|
||||
return values, uploads[0]
|
||||
|
||||
|
||||
class RosterHTTPServer(ThreadingHTTPServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, address: tuple[str, int], app: RosterWebApp) -> None:
|
||||
self.web_app = app
|
||||
super().__init__(address, RosterRequestHandler)
|
||||
|
||||
|
||||
class RosterRequestHandler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "RosterServer/1.0"
|
||||
sys_version = ""
|
||||
|
||||
@property
|
||||
def app(self) -> RosterWebApp:
|
||||
return self.server.web_app # type: ignore[attr-defined]
|
||||
|
||||
def setup(self) -> None:
|
||||
super().setup()
|
||||
self.connection.settimeout(30)
|
||||
|
||||
def end_headers(self) -> None:
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self'; "
|
||||
"img-src 'self' blob:; connect-src 'self'; base-uri 'none'; "
|
||||
"form-action 'self'; frame-ancestors 'none'",
|
||||
)
|
||||
super().end_headers()
|
||||
|
||||
def _send_bytes(
|
||||
self,
|
||||
status: HTTPStatus,
|
||||
data: bytes,
|
||||
content_type: str,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
if self.close_connection:
|
||||
self.send_header("Connection", "close")
|
||||
if headers:
|
||||
for name, value in headers.items():
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(data)
|
||||
|
||||
def _send_json(self, status: HTTPStatus, payload: Mapping[str, object]) -> None:
|
||||
data = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
self._send_bytes(
|
||||
status,
|
||||
data,
|
||||
"application/json; charset=utf-8",
|
||||
{"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
def _same_origin(self) -> bool:
|
||||
origin = self.headers.get("Origin")
|
||||
if not origin:
|
||||
return True
|
||||
try:
|
||||
parsed = urlsplit(origin)
|
||||
except ValueError:
|
||||
return False
|
||||
return (
|
||||
parsed.scheme in {"http", "https"}
|
||||
and parsed.netloc.lower() == self.headers.get("Host", "").lower()
|
||||
)
|
||||
|
||||
def _host_allowed(self) -> bool:
|
||||
raw_host = self.headers.get("Host", "")
|
||||
if not raw_host or any(character in raw_host for character in ("@", "/", "\\", " ", "\t")):
|
||||
return False
|
||||
try:
|
||||
parsed = urlsplit(f"//{raw_host}")
|
||||
host = parsed.hostname
|
||||
parsed.port # Force validation of a supplied port.
|
||||
except ValueError:
|
||||
return False
|
||||
return bool(host and host.lower().rstrip(".") in self.app.allowed_hosts)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = urlsplit(self.path).path
|
||||
try:
|
||||
if not self._host_allowed():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "Host is not allowed."})
|
||||
return
|
||||
if path == "/":
|
||||
self._send_bytes(
|
||||
HTTPStatus.OK,
|
||||
self.app.index_html(),
|
||||
"text/html; charset=utf-8",
|
||||
{"Cache-Control": "no-store"},
|
||||
)
|
||||
return
|
||||
if path == "/static/styles.css":
|
||||
self._send_bytes(
|
||||
HTTPStatus.OK,
|
||||
(self.app.web_dir / "styles.css").read_bytes(),
|
||||
"text/css; charset=utf-8",
|
||||
{"Cache-Control": "no-store"},
|
||||
)
|
||||
return
|
||||
if path == "/static/app.js":
|
||||
self._send_bytes(
|
||||
HTTPStatus.OK,
|
||||
(self.app.web_dir / "app.js").read_bytes(),
|
||||
"text/javascript; charset=utf-8",
|
||||
{"Cache-Control": "no-store"},
|
||||
)
|
||||
return
|
||||
if path == "/static/theme.js":
|
||||
self._send_bytes(
|
||||
HTTPStatus.OK,
|
||||
(self.app.web_dir / "theme.js").read_bytes(),
|
||||
"text/javascript; charset=utf-8",
|
||||
{"Cache-Control": "no-store"},
|
||||
)
|
||||
return
|
||||
if path == "/assets/logo.png":
|
||||
self._send_bytes(
|
||||
HTTPStatus.OK,
|
||||
self.app.logo_path.read_bytes(),
|
||||
"image/png",
|
||||
{"Cache-Control": "no-store"},
|
||||
)
|
||||
return
|
||||
if path == "/health":
|
||||
try:
|
||||
self.app.store.verify_writable()
|
||||
except OSError:
|
||||
self._send_json(
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
{"status": "error", "error": "Output directory is not writable."},
|
||||
)
|
||||
return
|
||||
self._send_json(HTTPStatus.OK, {"status": "ok"})
|
||||
return
|
||||
if path == "/api/history":
|
||||
self._send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"items": self.app.store.list_items(),
|
||||
"retention_months": self.app.store.retention_months,
|
||||
},
|
||||
)
|
||||
return
|
||||
if path.startswith("/downloads/"):
|
||||
filename = unquote(path.removeprefix("/downloads/"))
|
||||
data = self.app.store.read(filename)
|
||||
if data is None:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "Roster not found."})
|
||||
return
|
||||
self._send_bytes(
|
||||
HTTPStatus.OK,
|
||||
data,
|
||||
"image/png",
|
||||
{
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "Not found."})
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
return
|
||||
except Exception:
|
||||
LOGGER.exception("GET %s failed", path)
|
||||
self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Server error."})
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
# Never reuse a connection after a POST. Some validation happens before
|
||||
# the request body is read, so closing prevents leftover bytes from being
|
||||
# interpreted as a second, Origin-less request.
|
||||
self.close_connection = True
|
||||
path = urlsplit(self.path).path
|
||||
try:
|
||||
if not self._host_allowed():
|
||||
raise RequestError(HTTPStatus.FORBIDDEN, "Host is not allowed.")
|
||||
if path not in {"/format", "/api/layout-defaults"}:
|
||||
raise RequestError(HTTPStatus.NOT_FOUND, "Not found.")
|
||||
if not self._same_origin():
|
||||
raise RequestError(HTTPStatus.FORBIDDEN, "Cross-origin requests are not allowed.")
|
||||
if self.headers.get("Transfer-Encoding") is not None:
|
||||
raise RequestError(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Transfer-encoded requests are not supported.",
|
||||
)
|
||||
|
||||
raw_length = self.headers.get("Content-Length")
|
||||
if raw_length is None:
|
||||
raise RequestError(HTTPStatus.LENGTH_REQUIRED, "Request size is required.")
|
||||
try:
|
||||
content_length = int(raw_length)
|
||||
except ValueError as exc:
|
||||
raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid upload size.") from exc
|
||||
if content_length <= 0:
|
||||
raise RequestError(HTTPStatus.BAD_REQUEST, "The request is empty.")
|
||||
|
||||
content_type = self.headers.get("Content-Type", "")
|
||||
if path == "/api/layout-defaults":
|
||||
if content_length > MAX_DEFAULTS_REQUEST_BYTES:
|
||||
raise RequestError(
|
||||
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||
"The saved defaults request is too large.",
|
||||
)
|
||||
if content_type.split(";", 1)[0].strip().lower() != "application/json":
|
||||
raise RequestError(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Saved defaults must use JSON.",
|
||||
)
|
||||
body = self.rfile.read(content_length)
|
||||
if len(body) != content_length:
|
||||
raise RequestError(HTTPStatus.BAD_REQUEST, "The request was incomplete.")
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RequestError(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Saved defaults must contain valid JSON.",
|
||||
) from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RequestError(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Saved defaults must be a JSON object.",
|
||||
)
|
||||
values = {str(name): str(value) for name, value in payload.items()}
|
||||
settings = self.app.save_layout_defaults(values)
|
||||
self._send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"status": "saved",
|
||||
"defaults": settings.as_form_values(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if content_length > self.app.max_request_bytes:
|
||||
raise RequestError(
|
||||
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||
"The upload is too large.",
|
||||
)
|
||||
if not content_type.lower().startswith("multipart/form-data"):
|
||||
raise RequestError(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"The upload must use multipart form data.",
|
||||
)
|
||||
body = self.rfile.read(content_length)
|
||||
if len(body) != content_length:
|
||||
raise RequestError(HTTPStatus.BAD_REQUEST, "The upload was incomplete.")
|
||||
|
||||
values, upload = parse_multipart(content_type, body)
|
||||
png, filename, saved, removed = self.app.format_upload(upload, values)
|
||||
if removed:
|
||||
LOGGER.info("Retention removed: %s", ", ".join(removed))
|
||||
self._send_bytes(
|
||||
HTTPStatus.OK,
|
||||
png,
|
||||
"image/png",
|
||||
{
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Cache-Control": "no-store",
|
||||
"X-Roster-Saved": "true" if saved else "false",
|
||||
},
|
||||
)
|
||||
except RequestError as exc:
|
||||
self._send_json(exc.status, {"error": exc.message})
|
||||
except RosterFormatError as exc:
|
||||
self._send_json(HTTPStatus.UNPROCESSABLE_ENTITY, {"error": str(exc)})
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
return
|
||||
except Exception:
|
||||
LOGGER.exception("POST %s failed", path)
|
||||
message = (
|
||||
"The layout defaults could not be saved. Try again."
|
||||
if path == "/api/layout-defaults"
|
||||
else "The roster could not be formatted. Try again."
|
||||
)
|
||||
self._send_json(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
{"error": message},
|
||||
)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
LOGGER.info("%s - %s", self.address_string(), format % args)
|
||||
|
||||
|
||||
def _environment_int(name: str, fallback: int) -> int:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return fallback
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(f"{name} must be a whole number") from exc
|
||||
|
||||
|
||||
def _retention_maintenance(store: OutputStore, stop: Event) -> None:
|
||||
while not stop.wait(RETENTION_CHECK_SECONDS):
|
||||
try:
|
||||
removed = store.prune()
|
||||
if removed:
|
||||
LOGGER.info("Scheduled retention removed: %s", ", ".join(removed))
|
||||
except OSError:
|
||||
LOGGER.exception("Scheduled retention cleanup failed")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Run the local roster web app")
|
||||
parser.add_argument("--host", default=os.environ.get("ROSTER_HOST", DEFAULT_HOST))
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=_environment_int("ROSTER_PORT", DEFAULT_PORT),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
type=Path,
|
||||
default=Path(os.environ.get("ROSTER_OUTPUT_DIR", BASE_DIR / "output")),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--retention-months",
|
||||
type=int,
|
||||
default=_environment_int("ROSTER_RETENTION_MONTHS", DEFAULT_RETENTION_MONTHS),
|
||||
help="delete roster PNGs more than this many calendar months old; 0 disables cleanup",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-upload-mb",
|
||||
type=int,
|
||||
default=_environment_int("ROSTER_MAX_UPLOAD_MB", DEFAULT_MAX_UPLOAD_MB),
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
if not 1 <= args.port <= 65535:
|
||||
raise SystemExit("port must be between 1 and 65535")
|
||||
if args.retention_months < 0:
|
||||
raise SystemExit("retention months cannot be negative")
|
||||
if not 1 <= args.max_upload_mb <= 100:
|
||||
raise SystemExit("max upload size must be between 1 and 100 MB")
|
||||
|
||||
allowed_hosts = {
|
||||
host.strip().lower().rstrip(".")
|
||||
for host in os.environ.get("ROSTER_ALLOWED_HOSTS", "").split(",")
|
||||
if host.strip()
|
||||
}
|
||||
if args.host not in {"0.0.0.0", "::"}:
|
||||
allowed_hosts.add(args.host.lower().rstrip("."))
|
||||
|
||||
app = RosterWebApp(
|
||||
output_dir=args.output_dir,
|
||||
retention_months=args.retention_months,
|
||||
max_upload_bytes=args.max_upload_mb * 1024 * 1024,
|
||||
allowed_hosts=allowed_hosts,
|
||||
)
|
||||
removed = app.store.prune()
|
||||
if removed:
|
||||
LOGGER.info("Startup retention removed: %s", ", ".join(removed))
|
||||
|
||||
server = RosterHTTPServer((args.host, args.port), app)
|
||||
maintenance_stop = Event()
|
||||
maintenance_thread: Thread | None = None
|
||||
if args.retention_months > 0:
|
||||
maintenance_thread = Thread(
|
||||
target=_retention_maintenance,
|
||||
args=(app.store, maintenance_stop),
|
||||
name="roster-retention",
|
||||
daemon=True,
|
||||
)
|
||||
maintenance_thread.start()
|
||||
LOGGER.info("Roster formatter available at http://%s:%s", args.host, args.port)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
LOGGER.info("Stopping roster formatter")
|
||||
finally:
|
||||
maintenance_stop.set()
|
||||
if maintenance_thread is not None:
|
||||
maintenance_thread.join(timeout=2)
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user