Phase 24: remote trip + dashcam portal

- Add Flask-Login browser session auth (separate from device X-Api-Key auth)
  - modules/user_auth.py: User model, require_login decorator, bcrypt helpers
  - /login with CSRF token, /logout
  - scripts/create_user.py to provision admin user

- Add server-side trip computation from tracking_points
  - db.compute_trips_for_device(): gap > 5 min = new trip, haversine distance,
    upserts into trips table, auto-triggered in bg thread after each upload batch
  - db.list_trips(), db.get_trip_route(): same JSON shape as Pi API
  - db.get_overview_stats(): aggregated totals for overview page
  - db.list_dashcam_files(), db.get_dashcam_file(): browse/stream helpers

- Add web portal to server/app.py
  - Overview page (/): stat cards + recent trips + recent clips
  - Trip history (/history): reuses Pi history.js + data-list.js unchanged
    via /api/trips + /api/trip/<id> compatibility aliases
  - Dashcam archive (/dashcam): file list + inline video player
  - /api/v1/portal/dashcam/stream/<id>: Range-request streaming, path traversal guard
  - /api/geocode: same Nominatim→reverse_geocoder offline fallback as Pi

- Add server templates (server/templates/)
  - base.html: shared shell with 'Remote server' badge in sidebar + mobile topbar
  - login.html, index.html, history.html, dashcam.html, components.html

- DB schema: users + trips tables added to init.sql (fresh deploys) +
  migrate_phase24.sql (existing deployments)

- Infrastructure: Dockerfile uses repo-root build context to copy shared
  static/ assets; docker-compose updated; SECRET_KEY + DEVICE_NAME in .env;
  requirements: Flask-Login, bcrypt, reverse_geocoder

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 03:58:45 -04:00
co-authored by Copilot
parent 51c6be3a94
commit c4dfcbaf25
18 changed files with 1453 additions and 59 deletions
+281 -1
View File
@@ -1,17 +1,63 @@
"""yaris-obd remote server: receives trip/GPS log batches and dashcam files
uploaded by the Pi, storing them in Postgres + on-disk file storage.
Also serves a read-only web portal for browsing trip history and dashcam archive.
"""
import functools
import hashlib
import os
import secrets
import shutil
from flask import Flask, request, jsonify, g
from flask import (
Flask, request, jsonify, g, render_template, redirect, url_for,
flash, session, Response, abort
)
from flask_login import login_user, logout_user, current_user
from modules import db
from modules.auth import require_device_auth
from modules.user_auth import login_manager, User, check_password, require_login
# ── Geocode cache ──────────────────────────────────────────────────────────
try:
import reverse_geocoder as _rg
_RG_AVAILABLE = True
except ImportError:
_RG_AVAILABLE = False
@functools.lru_cache(maxsize=512)
def _geocode_cached(lat4, lon4):
lat = lat4 / 10000.0
lon = lon4 / 10000.0
try:
import urllib.request, urllib.error, json as _json
url = (
f"https://nominatim.openstreetmap.org/reverse"
f"?lat={lat}&lon={lon}&format=json&addressdetails=1"
)
req = urllib.request.Request(url, headers={"User-Agent": "yaris-obd-server/1.0"})
with urllib.request.urlopen(req, timeout=4) as resp:
data = _json.loads(resp.read())
addr = data.get("address", {})
road = addr.get("road") or addr.get("pedestrian") or addr.get("cycleway", "")
city = addr.get("city") or addr.get("town") or addr.get("village", "")
return f"{road}, {city}".strip(", ") if road or city else None
except Exception:
pass
if _RG_AVAILABLE:
results = _rg.search([(lat, lon)], verbose=False)
if results:
r = results[0]
return f"{r.get('name', '')}, {r.get('admin1', '')}".strip(", ")
return None
app = Flask(__name__)
# Secret key for browser sessions — read from env in production.
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
# Where uploaded dashcam files get written on disk (mounted to the unraid
# array via docker-compose - see DASHCAM_STORAGE_DIR in .env).
STORAGE_DIR = os.environ.get("DASHCAM_STORAGE_DIR", "/data/dashcam")
@@ -22,6 +68,16 @@ app.config["MAX_CONTENT_LENGTH"] = MAX_DASHCAM_UPLOAD_MB * 1024 * 1024
# groups of 500, this just guards against a runaway/misbehaving client.
MAX_TRACKING_BATCH_POINTS = 5000
# Initialise Flask-Login
login_manager.init_app(app)
def _portal_device_id():
"""Return the device id for the configured device name (single Pi setup)."""
name = os.environ.get("DEVICE_NAME", "yaris-2013")
dev = db.get_device_by_name(name)
return dev["id"] if dev else None
@app.get("/health")
def health():
@@ -54,6 +110,14 @@ def upload_tracking_batch():
return jsonify(error="every point requires 'recorded_at'"), 400
inserted = db.insert_tracking_points(g.device_id, points)
# Recompute trips in background thread so portal reflects new data immediately.
if inserted > 0:
import threading
threading.Thread(
target=db.compute_trips_for_device,
args=(g.device_id,),
daemon=True,
).start()
return jsonify(status="ok", received=inserted), 201
@@ -254,6 +318,222 @@ def dashcam_upload_chunk():
return jsonify(status="complete", size_bytes=size_bytes), 201
# ── Browser auth ───────────────────────────────────────────────────────────
@app.get("/login")
def login_page():
if current_user.is_authenticated:
return redirect(url_for("portal_index"))
csrf_token = secrets.token_hex(16)
session["csrf_token"] = csrf_token
return render_template("login.html", csrf_token=csrf_token)
@app.post("/login")
def login_submit():
# CSRF check
token = request.form.get("csrf_token", "")
if not token or token != session.pop("csrf_token", None):
flash("Invalid request. Please try again.", "error")
return redirect(url_for("login_page"))
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
user_row = db.get_user_by_username(username)
if user_row and check_password(password, user_row["password_hash"]):
login_user(User(user_row["id"], user_row["username"]), remember=False)
return redirect(url_for("portal_index"))
flash("Invalid username or password.", "error")
return redirect(url_for("login_page"))
@app.get("/logout")
@require_login
def logout():
logout_user()
return redirect(url_for("login_page"))
# ── Portal UI pages ────────────────────────────────────────────────────────
@app.get("/")
@require_login
def portal_index():
return render_template("index.html")
@app.get("/history")
@require_login
def portal_history():
return render_template("history.html")
@app.get("/dashcam")
@require_login
def portal_dashcam():
return render_template("dashcam.html")
# ── Portal JSON API ────────────────────────────────────────────────────────
@app.get("/api/v1/portal/stats")
@require_login
def portal_stats():
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
return jsonify(db.get_overview_stats(device_id))
@app.get("/api/v1/portal/trips")
@require_login
def portal_trips():
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
limit = min(int(request.args.get("limit", 200)), 1000)
return jsonify(db.list_trips(device_id, limit=limit))
@app.get("/api/v1/portal/trip/<int:trip_id>")
@require_login
def portal_trip_detail(trip_id):
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
data = db.get_trip_route(device_id, trip_id)
if data is None:
return jsonify(error="trip not found"), 404
return jsonify(data)
@app.get("/api/v1/portal/dashcam")
@require_login
def portal_dashcam_list():
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
limit = min(int(request.args.get("limit", 500)), 2000)
return jsonify(db.list_dashcam_files(device_id, limit=limit))
@app.get("/api/v1/portal/dashcam/stream/<int:file_id>")
@require_login
def portal_dashcam_stream(file_id):
"""Stream a dashcam file with Range request support for video seeking."""
device_id = _portal_device_id()
if device_id is None:
abort(404)
file_row = db.get_dashcam_file(device_id, file_id)
if file_row is None:
abort(404)
storage_path = file_row["storage_path"]
# Safety check: must still be under STORAGE_DIR
if not os.path.commonpath([STORAGE_DIR, os.path.normpath(storage_path)]) == os.path.normpath(STORAGE_DIR):
abort(403)
if not os.path.isfile(storage_path):
abort(404)
file_size = os.path.getsize(storage_path)
range_header = request.headers.get("Range")
mime = "video/mp4"
filename = file_row["relative_path"].split("/")[-1]
if not range_header:
# Full file download
def generate_full():
with open(storage_path, "rb") as f:
while True:
chunk = f.read(1024 * 1024)
if not chunk:
break
yield chunk
headers = {
"Content-Disposition": f'inline; filename="{filename}"',
"Content-Length": file_size,
"Accept-Ranges": "bytes",
}
return Response(generate_full(), status=200, headers=headers, mimetype=mime)
# Parse Range header (bytes=start-end)
try:
range_val = range_header.replace("bytes=", "")
start_s, end_s = range_val.split("-")
start = int(start_s)
end = int(end_s) if end_s else file_size - 1
except Exception:
abort(416)
if start >= file_size or end >= file_size or start > end:
abort(416)
length = end - start + 1
def generate_range():
with open(storage_path, "rb") as f:
f.seek(start)
remaining = length
while remaining > 0:
chunk = f.read(min(1024 * 1024, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
headers = {
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Content-Length": length,
"Accept-Ranges": "bytes",
"Content-Disposition": f'inline; filename="{filename}"',
}
return Response(generate_range(), status=206, headers=headers, mimetype=mime)
# ── Geocode (same offline logic as Pi) ────────────────────────────────────
@app.get("/api/geocode")
@require_login
def geocode():
try:
lat = float(request.args["lat"])
lon = float(request.args["lon"])
except (KeyError, ValueError):
return jsonify(error="lat and lon required"), 400
lat4 = int(round(lat, 4) * 10000)
lon4 = int(round(lon, 4) * 10000)
name = _geocode_cached(lat4, lon4)
if name:
return jsonify(name=name)
return jsonify(name=None), 204
# ── Uplink trigger (recompute trips after new batch) ──────────────────────
@app.post("/api/v1/portal/recompute-trips")
@require_login
def recompute_trips():
"""Manually trigger trip recomputation. Called after upload batch for freshness."""
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
n = db.compute_trips_for_device(device_id)
return jsonify(status="ok", trips_computed=n)
@app.get("/api/trips")
@require_login
def portal_trips_compat():
"""Pi history.js compatibility alias."""
return portal_trips()
@app.get("/api/trip/<int:trip_id>")
@require_login
def portal_trip_compat(trip_id):
"""Pi history.js compatibility alias."""
return portal_trip_detail(trip_id)
def main():
app.run(host="0.0.0.0", port=8098)