Files
Roster/server.py
T

818 lines
31 KiB
Python

"""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())