Add secure 6h timeline with CSV merge and timestamped serial events

This commit is contained in:
2026-02-22 02:11:31 +01:00
parent 680ed7044e
commit b5f34868d1
9 changed files with 1315 additions and 23 deletions

View File

@@ -1,16 +1,48 @@
import hmac
import json
import os
import queue
import secrets
import subprocess
import threading
import time
from typing import Any, Dict
from collections import defaultdict, deque
from typing import Any, Deque, Dict, Optional
from urllib.parse import urlparse
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
from flask import Flask, Response, jsonify, render_template, request, session, stream_with_context
from waitress import serve
from werkzeug.exceptions import RequestEntityTooLarge
from app_state import AppState
from network_manager import NetworkManager
from serial_bridge import SerialBroadcaster
from timeline_service import (
TimelineNotFoundError,
TimelineService,
TimelineStorageError,
TimelineValidationError,
)
class SlidingWindowRateLimiter:
def __init__(self, limit: int, window_seconds: int) -> None:
self.limit = int(limit)
self.window_seconds = int(window_seconds)
self._hits: Dict[str, Deque[float]] = defaultdict(deque)
self._lock = threading.Lock()
def allow(self, key: str) -> bool:
now = time.monotonic()
cutoff = now - self.window_seconds
with self._lock:
bucket = self._hits[key]
while bucket and bucket[0] < cutoff:
bucket.popleft()
if len(bucket) >= self.limit:
return False
bucket.append(now)
return True
class WebPortal:
@@ -19,6 +51,9 @@ class WebPortal:
state: AppState,
network_manager: NetworkManager,
broadcaster: SerialBroadcaster,
log_dir: str = "/home/pi",
log_prefix: str = "xxx",
upload_dir: str = "/home/pi/timeline_uploads",
template_folder: str = "../templates",
static_folder: str = "../static",
) -> None:
@@ -28,10 +63,82 @@ class WebPortal:
self._status_refresh_interval_s = 15.0
self._last_status_refresh_mono = 0.0
self._status_lock = threading.Lock()
self.timeline_service = TimelineService(log_dir=log_dir, log_prefix=log_prefix, upload_dir=upload_dir)
self._upload_limiter = SlidingWindowRateLimiter(limit=10, window_seconds=60)
self._download_limiter = SlidingWindowRateLimiter(limit=30, window_seconds=60)
self.app = Flask(__name__, template_folder=template_folder, static_folder=static_folder)
self.app.secret_key = os.environ.get("SERIAL_WEB_SECRET") or secrets.token_hex(32)
self.app.config["SESSION_COOKIE_HTTPONLY"] = True
self.app.config["SESSION_COOKIE_SAMESITE"] = "Strict"
self.app.config["MAX_CONTENT_LENGTH"] = TimelineService.MAX_UPLOAD_SIZE + (1024 * 1024)
self._register_routes()
self._register_error_handlers()
def _register_error_handlers(self) -> None:
@self.app.errorhandler(RequestEntityTooLarge)
def too_large(_exc: RequestEntityTooLarge) -> Response:
if request.path.startswith("/api/timeline/uploads"):
return jsonify({"ok": False, "message": "Upload exceeds size limit"}), 413
return jsonify({"ok": False, "message": "Request too large"}), 413
def _client_ip(self) -> str:
if request.access_route:
return request.access_route[0]
return request.remote_addr or "unknown"
def _ensure_csrf_token(self) -> str:
token = session.get("csrf_token")
if not token:
token = secrets.token_urlsafe(32)
session["csrf_token"] = token
return str(token)
def _is_valid_csrf(self) -> bool:
expected = session.get("csrf_token")
provided = (
request.headers.get("X-CSRF-Token")
or request.form.get("csrf_token")
or request.args.get("csrf_token")
)
if not expected or not provided:
return False
return hmac.compare_digest(str(expected), str(provided))
def _is_same_origin(self) -> bool:
expected_origin = request.host_url.rstrip("/")
for header in ("Origin", "Referer"):
value = request.headers.get(header, "").strip()
if not value:
continue
try:
parsed = urlparse(value)
except ValueError:
return False
if not parsed.scheme or not parsed.netloc:
return False
origin = f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
return origin == expected_origin
return False
def _guard_secure_endpoint(
self,
*,
require_csrf: bool = True,
limiter: Optional[SlidingWindowRateLimiter] = None,
) -> Optional[Response]:
if not self._is_same_origin():
return jsonify({"ok": False, "message": "Cross-origin requests are blocked"}), 403
if require_csrf and not self._is_valid_csrf():
return jsonify({"ok": False, "message": "Invalid CSRF token"}), 403
if limiter and not limiter.allow(self._client_ip()):
return jsonify({"ok": False, "message": "Too many requests"}), 429
return None
def _register_routes(self) -> None:
@self.app.context_processor
def inject_template_vars() -> Dict[str, Any]:
return {"csrf_token": self._ensure_csrf_token()}
@self.app.route("/")
def index() -> str:
return render_template("index.html")
@@ -40,6 +147,14 @@ class WebPortal:
def serial_page() -> str:
return render_template("serial.html")
@self.app.route("/timeline")
def timeline_page() -> str:
return render_template(
"timeline.html",
max_upload_mib=TimelineService.MAX_UPLOAD_SIZE // (1024 * 1024),
max_upload_files=TimelineService.MAX_UPLOAD_FILES,
)
@self.app.route("/api/status", methods=["GET"])
def status() -> Response:
try:
@@ -110,8 +225,17 @@ class WebPortal:
yield "retry: 2000\n\n"
while True:
try:
line = q.get(timeout=15)
data = json.dumps({"line": line})
event = q.get(timeout=15)
if isinstance(event, dict):
payload = {
"line": event.get("line", ""),
"ts_iso": event.get("ts_iso", ""),
"ts_hms": event.get("ts_hms", ""),
"source": event.get("source", "serial"),
}
else:
payload = {"line": str(event), "ts_iso": "", "ts_hms": "", "source": "serial"}
data = json.dumps(payload)
yield f"data: {data}\n\n"
except queue.Empty:
yield ": keepalive\n\n"
@@ -120,5 +244,100 @@ class WebPortal:
return Response(generate(), mimetype="text/event-stream")
@self.app.route("/api/timeline/uploads", methods=["GET"])
def list_timeline_uploads() -> Response:
uploads = self.timeline_service.list_uploads()
return jsonify({"ok": True, "uploads": uploads})
@self.app.route("/api/timeline/uploads", methods=["POST"])
def upload_timeline_csv() -> Response:
guard = self._guard_secure_endpoint(limiter=self._upload_limiter)
if guard is not None:
return guard
csv_file = request.files.get("file")
if csv_file is None:
return jsonify({"ok": False, "message": "Missing file field"}), 400
if not csv_file.filename:
return jsonify({"ok": False, "message": "Filename is required"}), 400
raw = csv_file.stream.read(TimelineService.MAX_UPLOAD_SIZE + 1)
if len(raw) > TimelineService.MAX_UPLOAD_SIZE:
return jsonify({"ok": False, "message": "Upload exceeds size limit"}), 413
try:
meta = self.timeline_service.upload_csv(csv_file.filename, raw)
except TimelineValidationError as exc:
return jsonify({"ok": False, "message": str(exc)}), 400
except TimelineStorageError as exc:
return jsonify({"ok": False, "message": str(exc)}), 503
return jsonify({"ok": True, "upload": meta}), 201
@self.app.route("/api/timeline/uploads/<upload_id>", methods=["DELETE"])
def delete_timeline_csv(upload_id: str) -> Response:
guard = self._guard_secure_endpoint(limiter=self._upload_limiter)
if guard is not None:
return guard
try:
self.timeline_service.delete_upload(upload_id)
except TimelineValidationError as exc:
return jsonify({"ok": False, "message": str(exc)}), 400
except TimelineNotFoundError:
return jsonify({"ok": False, "message": "Upload not found"}), 404
return jsonify({"ok": True})
@self.app.route("/api/timeline", methods=["GET"])
def timeline_data() -> Response:
hours_arg = request.args.get("hours", "6")
upload_id = request.args.get("upload_id", "")
try:
hours = int(hours_arg)
except ValueError:
hours = 6
try:
data = self.timeline_service.get_timeline(hours=hours, upload_id=upload_id)
except TimelineValidationError as exc:
return jsonify({"ok": False, "message": str(exc)}), 400
except TimelineNotFoundError:
return jsonify({"ok": False, "message": "Upload not found"}), 404
except TimelineStorageError as exc:
return jsonify({"ok": False, "message": str(exc)}), 503
return jsonify({"ok": True, **data})
@self.app.route("/api/timeline/download", methods=["GET"])
def download_timeline() -> Response:
guard = self._guard_secure_endpoint(limiter=self._download_limiter)
if guard is not None:
return guard
kind = request.args.get("kind", "serial")
upload_id = request.args.get("upload_id", "")
hours_arg = request.args.get("hours", "6")
try:
hours = int(hours_arg)
except ValueError:
hours = 6
try:
filename, payload = self.timeline_service.build_download(
kind=kind,
hours=hours,
upload_id=upload_id,
)
except TimelineValidationError as exc:
return jsonify({"ok": False, "message": str(exc)}), 400
except TimelineNotFoundError:
return jsonify({"ok": False, "message": "Upload not found"}), 404
except TimelineStorageError as exc:
return jsonify({"ok": False, "message": str(exc)}), 503
headers = {
"Content-Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
}
return Response(payload, headers=headers, mimetype="text/csv")
def run(self, host: str = "0.0.0.0", port: int = 80) -> None:
serve(self.app, host=host, port=port, threads=6)