Add the web interface

Plain HTML, CSS and JavaScript modules, with no build step. Hash routes
and relative URLs make it work under any path. The job list is polled:
every 2 seconds while a job runs, and less often otherwise.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 09d6b76a17
commit 2f4271314b
30 changed files with 4442 additions and 0 deletions
+30
View File
@@ -1,5 +1,7 @@
"""The Flask app: login, the JSON API and the static web interface."""
import base64
import hashlib
import io
import logging
import os
@@ -8,6 +10,7 @@ import secrets
import uuid
from collections.abc import Callable, Iterable
from datetime import timedelta
from pathlib import Path
from typing import Any
from flask import Flask, Response, g, jsonify, request, send_file, session
@@ -397,6 +400,33 @@ def library_import() -> Any:
return orchestrator.queue_import(books, bool(data.get("check", True)))
# Web interface. It uses relative URLs and hash routes, so it works under any path.
def _content_security_policy(html: str) -> str:
"""Allow only Bindery's own files, and the inline scripts of the page by their hashes.
The trailing-slash redirect in index.html must be inline: before the
redirect, a relative script URL misses the path prefix.
"""
hashes = [
"'sha256-" + base64.b64encode(hashlib.sha256(script.encode()).digest()).decode() + "'"
for script in re.findall(r"<script>(.*?)</script>", html, re.DOTALL)
]
return (
f"default-src 'self'; script-src 'self' {' '.join(hashes)}; object-src 'none'; "
"base-uri 'none'; form-action 'self'; frame-ancestors 'self'"
)
@app.get("/")
def index() -> Any:
response = app.send_static_file("index.html")
html = Path(app.static_folder or "", "index.html").read_text(encoding="utf-8")
response.headers["Content-Security-Policy"] = _content_security_policy(html)
return response
# Gunicorn runs one worker, and the dev server runs without the reloader, so this runs once.
if config.START_THREADS:
orchestrator.start_threads()