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:
+281
-1
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user