1103 lines
39 KiB
Python
1103 lines
39 KiB
Python
"""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 the remote web portal for browsing and managing the configured car.
|
|
"""
|
|
import functools
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import shutil
|
|
import threading
|
|
import time
|
|
from collections import defaultdict
|
|
|
|
from flask import (
|
|
Flask, request, jsonify, g, render_template, redirect, url_for,
|
|
flash, session, Response, abort, send_file
|
|
)
|
|
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
|
|
from modules import device_config, device_registry
|
|
|
|
# ── Geocode cache ──────────────────────────────────────────────────────────
|
|
# reverse_geocoder is an offline Python library that maps lat/lon →
|
|
# city/province using a precomputed gazetteer (~40MB). Instant, no network,
|
|
# always available — a much better fit for trip scrubbing than Nominatim
|
|
# which is slow, rate-limited, and often unreachable from Docker/Cloudflare.
|
|
import reverse_geocoder as _rg
|
|
|
|
|
|
@functools.lru_cache(maxsize=512)
|
|
def _geocode_cached(lat4, lon4):
|
|
"""Reverse-geocode a coordinate to city/province using the offline
|
|
reverse_geocoder library. Always returns a non-empty label string."""
|
|
lat = lat4 / 10000.0
|
|
lon = lon4 / 10000.0
|
|
try:
|
|
results = _rg.search([(lat, lon)], mode=1, verbose=False)
|
|
if results:
|
|
r = results[0]
|
|
label = ", ".join(p for p in [r.get("name", ""), r.get("admin1", "")] if p)
|
|
if label:
|
|
return label
|
|
except Exception:
|
|
pass
|
|
return f"{lat:.5f}, {lon:.5f}"
|
|
|
|
|
|
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)
|
|
|
|
# Session-cookie hardening. The portal is served over HTTPS via Cloudflare
|
|
# Tunnel, so the cookie is marked Secure by default; set COOKIE_SECURE=0 in
|
|
# .env only if you also log in to the portal over plain HTTP on the LAN.
|
|
app.config.update(
|
|
SESSION_COOKIE_HTTPONLY=True,
|
|
SESSION_COOKIE_SAMESITE="Lax",
|
|
SESSION_COOKIE_SECURE=os.environ.get("COOKIE_SECURE", "1") == "1",
|
|
)
|
|
|
|
# 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")
|
|
MAX_DASHCAM_UPLOAD_MB = int(os.environ.get("MAX_DASHCAM_UPLOAD_MB", "2048"))
|
|
|
|
# Where the Android APK (and its version manifest) live — under the mounted
|
|
# dashcam volume so the user can drop files in without a new mount.
|
|
APP_DIR = os.path.join(STORAGE_DIR, "app")
|
|
APP_APK_PATH = os.path.join(APP_DIR, "yaris-obd.apk")
|
|
APP_VERSION_PATH = os.path.join(APP_DIR, "version.json")
|
|
app.config["MAX_CONTENT_LENGTH"] = MAX_DASHCAM_UPLOAD_MB * 1024 * 1024
|
|
|
|
# Sanity cap on tracking points per request - the Pi client batches in
|
|
# groups of 500, this just guards against a runaway/misbehaving client.
|
|
MAX_TRACKING_BATCH_POINTS = 5000
|
|
|
|
# Dashcam uploads are content-addressed by sha256, and that digest also names
|
|
# the staging directory for chunked uploads. It MUST be a plain 64-char hex
|
|
# string so a crafted digest can't traverse out of the staging directory.
|
|
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
|
|
|
|
def _is_valid_sha256(value):
|
|
return bool(_SHA256_RE.fullmatch(value or ""))
|
|
|
|
# Initialise Flask-Login
|
|
login_manager.init_app(app)
|
|
|
|
|
|
def _portal_device_id():
|
|
"""Return the device id for the user's selected device, falling back to
|
|
the DEVICE_NAME env var default. Selection is stored in the Flask session
|
|
so it persists across pages without a URL parameter on every link."""
|
|
# Check session first (user explicitly picked a device)
|
|
session_id = session.get("selected_device_id")
|
|
if session_id is not None:
|
|
dev = db.get_device_by_id(session_id)
|
|
if dev is not None:
|
|
return dev["id"]
|
|
# Selected device was deleted — clear stale session entry
|
|
session.pop("selected_device_id", None)
|
|
# Fall back to configured default
|
|
name = os.environ.get("DEVICE_NAME", "yaris-2013")
|
|
dev = db.get_device_by_name(name)
|
|
return dev["id"] if dev else None
|
|
|
|
|
|
@app.get("/api/v1/portal/select-device")
|
|
@require_login
|
|
def portal_select_device_state():
|
|
"""Return current selection and available devices for the dropdown."""
|
|
devices = device_registry.list_devices()
|
|
configured_name = os.environ.get("DEVICE_NAME", "yaris-2013")
|
|
for device in devices:
|
|
device["portal_default"] = device["name"] == configured_name
|
|
current_id = session.get("selected_device_id")
|
|
return jsonify(
|
|
devices=devices,
|
|
selected_device_id=current_id,
|
|
configured_device_name=configured_name,
|
|
)
|
|
|
|
|
|
@app.post("/api/v1/portal/select-device")
|
|
@require_login
|
|
def portal_select_device():
|
|
"""Set the active device for the current session."""
|
|
body = request.get_json(silent=True) or {}
|
|
device_id = body.get("device_id")
|
|
if device_id is None:
|
|
session.pop("selected_device_id", None)
|
|
session.pop("selected_device_name", None)
|
|
return jsonify(status="ok", message="Device selection cleared.")
|
|
dev = db.get_device_by_id(int(device_id))
|
|
if dev is None:
|
|
return jsonify(error="Device not found"), 404
|
|
session["selected_device_id"] = dev["id"]
|
|
session["selected_device_name"] = dev["device_name"] or dev["name"]
|
|
return jsonify(status="ok", device_id=dev["id"], device_name=dev["device_name"] or dev["name"])
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
"""Unauthenticated liveness check - used by docker-compose healthcheck
|
|
and to sanity-test connectivity from the Pi before attempting uploads."""
|
|
return jsonify(status="ok"), 200
|
|
|
|
|
|
# ── Android app distribution ──────────────────────────────────────────────
|
|
# The Pi polls /api/v1/app/latest and mirrors the APK to its in-car hotspot so
|
|
# the phone can install/update without leaving the car WiFi. The APK lands here
|
|
# either via the portal upload page below or by dropping it directly into the
|
|
# mounted dashcam volume at app/yaris-obd.apk.
|
|
|
|
def _app_manifest():
|
|
if not os.path.isfile(APP_VERSION_PATH) or not os.path.isfile(APP_APK_PATH):
|
|
return None
|
|
try:
|
|
with open(APP_VERSION_PATH) as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
@app.get("/api/v1/app/latest")
|
|
def app_latest():
|
|
"""Latest APK version + checksum. Public — the Pi polls this unauthenticated."""
|
|
manifest = _app_manifest()
|
|
if manifest is None:
|
|
return jsonify(error="no app published"), 404
|
|
return jsonify(manifest)
|
|
|
|
|
|
@app.get("/app/yaris-obd.apk")
|
|
def app_download_apk():
|
|
if not os.path.isfile(APP_APK_PATH):
|
|
return jsonify(error="no app published"), 404
|
|
return send_file(APP_APK_PATH, as_attachment=True,
|
|
download_name="yaris-obd.apk",
|
|
mimetype="application/vnd.android.package-archive")
|
|
|
|
|
|
@app.get("/app/upload")
|
|
@require_login
|
|
def app_upload_page():
|
|
csrf_token = secrets.token_hex(16)
|
|
session["csrf_token"] = csrf_token
|
|
return render_template("app_upload.html", csrf_token=csrf_token)
|
|
|
|
|
|
@app.post("/app/upload")
|
|
@require_login
|
|
def app_upload_submit():
|
|
token = request.form.get("csrf_token", "")
|
|
if not token or token != session.pop("csrf_token", None):
|
|
return "Invalid request. Please try again.", 400
|
|
if "file" not in request.files:
|
|
return "missing file", 400
|
|
version = (request.form.get("version") or "").strip()
|
|
if not version:
|
|
return "version required", 400
|
|
uploaded = request.files["file"]
|
|
if not uploaded.filename or not uploaded.filename.lower().endswith(".apk"):
|
|
return "expected a .apk file", 400
|
|
|
|
os.makedirs(APP_DIR, exist_ok=True)
|
|
hasher = hashlib.sha256()
|
|
tmp_path = APP_APK_PATH + ".part"
|
|
size_bytes = 0
|
|
with open(tmp_path, "wb") as f:
|
|
while True:
|
|
chunk = uploaded.stream.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
hasher.update(chunk)
|
|
size_bytes += len(chunk)
|
|
f.write(chunk)
|
|
os.replace(tmp_path, APP_APK_PATH)
|
|
with open(APP_VERSION_PATH, "w") as f:
|
|
json.dump({"version": version, "sha256": hasher.hexdigest(), "size": size_bytes}, f)
|
|
return redirect(url_for("app_upload_page"))
|
|
|
|
|
|
@app.post("/api/v1/tracking/batch")
|
|
@require_device_auth
|
|
def upload_tracking_batch():
|
|
"""Bulk-insert a batch of GPS/trip tracking points for the authenticated device.
|
|
|
|
Expected JSON body: {"points": [{"recorded_at": "...", "gps_lat": ..., ...}, ...]}
|
|
recorded_at must be an ISO-8601 timestamp string. Duplicate (device,
|
|
recorded_at) pairs are silently ignored, so the Pi can safely re-send a
|
|
batch after a network failure without creating duplicate rows.
|
|
"""
|
|
body = request.get_json(silent=True) or {}
|
|
points = body.get("points")
|
|
if not isinstance(points, list) or not points:
|
|
return jsonify(error="expected a non-empty 'points' list"), 400
|
|
if len(points) > MAX_TRACKING_BATCH_POINTS:
|
|
return jsonify(
|
|
error=f"batch too large - max {MAX_TRACKING_BATCH_POINTS} points per request"
|
|
), 400
|
|
|
|
for p in points:
|
|
if "recorded_at" not in p:
|
|
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
|
|
|
|
|
|
@app.post("/api/v1/dashcam/upload")
|
|
@require_device_auth
|
|
def upload_dashcam_file():
|
|
"""Upload a single dashcam video file.
|
|
|
|
Multipart form fields:
|
|
file: the video file itself
|
|
relative_path: original sub-folder path on the dashcam SD card (form field)
|
|
sha256: hex digest of the file, computed by the Pi before sending
|
|
|
|
Skips (returns 200, not re-saved) if a file with the same
|
|
(device, relative_path, sha256) was already uploaded - safe to retry.
|
|
"""
|
|
if "file" not in request.files:
|
|
return jsonify(error="missing 'file' in multipart form"), 400
|
|
|
|
relative_path = request.form.get("relative_path", "").strip()
|
|
expected_sha256 = request.form.get("sha256", "").strip().lower()
|
|
if not relative_path or not _is_valid_sha256(expected_sha256):
|
|
return jsonify(error="missing or invalid 'relative_path' or 'sha256' form field"), 400
|
|
|
|
device_id = g.device_id
|
|
if db.dashcam_file_exists(device_id, relative_path, expected_sha256):
|
|
return jsonify(status="already_uploaded"), 200
|
|
|
|
uploaded = request.files["file"]
|
|
device_dir = os.path.join(STORAGE_DIR, g.device_name)
|
|
dest_path = os.path.normpath(os.path.join(device_dir, relative_path))
|
|
# Guard against a malicious/buggy relative_path (e.g. "../../etc") escaping
|
|
# the per-device storage directory.
|
|
if os.path.commonpath([device_dir, dest_path]) != os.path.normpath(device_dir):
|
|
return jsonify(error="invalid relative_path"), 400
|
|
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
|
|
|
hasher = hashlib.sha256()
|
|
tmp_path = dest_path + ".part"
|
|
size_bytes = 0
|
|
with open(tmp_path, "wb") as f:
|
|
while True:
|
|
chunk = uploaded.stream.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
hasher.update(chunk)
|
|
size_bytes += len(chunk)
|
|
f.write(chunk)
|
|
|
|
actual_sha256 = hasher.hexdigest()
|
|
if actual_sha256 != expected_sha256:
|
|
os.remove(tmp_path)
|
|
return jsonify(error="sha256 mismatch, upload corrupted in transit"), 422
|
|
|
|
os.replace(tmp_path, dest_path)
|
|
db.insert_dashcam_file(device_id, relative_path, size_bytes, actual_sha256, dest_path)
|
|
return jsonify(status="ok", size_bytes=size_bytes), 201
|
|
|
|
|
|
# --- Chunked dashcam upload ---------------------------------------------
|
|
# Real dashcam clips run 300+MB, well past Cloudflare Tunnel's ~100MB max
|
|
# request body size (confirmed with a real 250MB file: a direct LAN upload
|
|
# succeeds, the same request through the tunnel comes back 413). The Pi
|
|
# client splits files into small chunks (see CHUNK_SIZE in
|
|
# modules/app_upload.py) and PUTs them one at a time through this endpoint
|
|
# instead, staying comfortably under that limit. Chunks for a given upload
|
|
# are staged on disk under _CHUNK_TMP_DIR and reassembled once complete.
|
|
|
|
_CHUNK_TMP_DIRNAME = ".upload_tmp"
|
|
|
|
|
|
def _chunk_tmp_dir(device_name, sha256):
|
|
return os.path.join(STORAGE_DIR, _CHUNK_TMP_DIRNAME, device_name, sha256)
|
|
|
|
|
|
def _received_chunk_indices(tmp_dir):
|
|
if not os.path.isdir(tmp_dir):
|
|
return set()
|
|
indices = set()
|
|
for name in os.listdir(tmp_dir):
|
|
if name.endswith(".chunk"):
|
|
try:
|
|
indices.add(int(name[: -len(".chunk")]))
|
|
except ValueError:
|
|
continue
|
|
return indices
|
|
|
|
|
|
@app.get("/api/v1/dashcam/upload/status")
|
|
@require_device_auth
|
|
def dashcam_upload_status():
|
|
"""Report upload progress for (relative_path, sha256) so the Pi client
|
|
can resume a chunked upload without re-sending chunks the server
|
|
already has (e.g. after a network drop or app restart mid-transfer)."""
|
|
relative_path = request.args.get("relative_path", "").strip()
|
|
sha256 = request.args.get("sha256", "").strip().lower()
|
|
if not relative_path or not _is_valid_sha256(sha256):
|
|
return jsonify(error="missing or invalid 'relative_path' or 'sha256' query param"), 400
|
|
|
|
if db.dashcam_file_exists(g.device_id, relative_path, sha256):
|
|
return jsonify(status="already_uploaded"), 200
|
|
|
|
tmp_dir = _chunk_tmp_dir(g.device_name, sha256)
|
|
received = sorted(_received_chunk_indices(tmp_dir))
|
|
return jsonify(status="in_progress" if received else "not_started", received_chunk_indices=received), 200
|
|
|
|
|
|
@app.post("/api/v1/dashcam/upload/chunk")
|
|
@require_device_auth
|
|
def dashcam_upload_chunk():
|
|
"""Receive one chunk of a dashcam video file.
|
|
|
|
Multipart form fields:
|
|
file: the chunk's bytes
|
|
relative_path: original sub-folder path on the dashcam SD card
|
|
sha256: hex digest of the *whole* (reassembled) file
|
|
chunk_index: 0-based index of this chunk
|
|
total_chunks: total number of chunks for this file
|
|
|
|
Once every chunk 0..total_chunks-1 has arrived, the chunks are
|
|
concatenated in order, the combined sha256 is verified, and the result
|
|
is recorded exactly like the single-shot /upload endpoint.
|
|
"""
|
|
if "file" not in request.files:
|
|
return jsonify(error="missing 'file' in multipart form"), 400
|
|
|
|
relative_path = request.form.get("relative_path", "").strip()
|
|
expected_sha256 = request.form.get("sha256", "").strip().lower()
|
|
try:
|
|
chunk_index = int(request.form.get("chunk_index", ""))
|
|
total_chunks = int(request.form.get("total_chunks", ""))
|
|
except ValueError:
|
|
return jsonify(error="'chunk_index'/'total_chunks' must be integers"), 400
|
|
|
|
if (not relative_path or not _is_valid_sha256(expected_sha256)
|
|
or total_chunks < 1 or not (0 <= chunk_index < total_chunks)):
|
|
return jsonify(error="missing/invalid form fields"), 400
|
|
|
|
device_id = g.device_id
|
|
if db.dashcam_file_exists(device_id, relative_path, expected_sha256):
|
|
return jsonify(status="already_uploaded"), 200
|
|
|
|
device_dir = os.path.join(STORAGE_DIR, g.device_name)
|
|
dest_path = os.path.normpath(os.path.join(device_dir, relative_path))
|
|
if os.path.commonpath([device_dir, dest_path]) != os.path.normpath(device_dir):
|
|
return jsonify(error="invalid relative_path"), 400
|
|
|
|
tmp_dir = _chunk_tmp_dir(g.device_name, expected_sha256)
|
|
# A stale tmp_dir left over from a previous attempt with a *different*
|
|
# total_chunks (e.g. the client's chunk size changed between app
|
|
# versions) would reassemble incorrectly - start clean if that happens.
|
|
marker_path = os.path.join(tmp_dir, ".total_chunks")
|
|
if os.path.isdir(tmp_dir) and os.path.exists(marker_path):
|
|
with open(marker_path) as marker_file:
|
|
if marker_file.read().strip() != str(total_chunks):
|
|
shutil.rmtree(tmp_dir)
|
|
|
|
os.makedirs(tmp_dir, exist_ok=True)
|
|
with open(marker_path, "w") as marker_file:
|
|
marker_file.write(str(total_chunks))
|
|
|
|
chunk_path = os.path.join(tmp_dir, f"{chunk_index:06d}.chunk")
|
|
uploaded = request.files["file"]
|
|
with open(chunk_path, "wb") as f:
|
|
while True:
|
|
data = uploaded.stream.read(1024 * 1024)
|
|
if not data:
|
|
break
|
|
f.write(data)
|
|
|
|
received = _received_chunk_indices(tmp_dir)
|
|
if len(received) < total_chunks:
|
|
return jsonify(status="chunk_received", received_chunks=len(received), total_chunks=total_chunks), 202
|
|
|
|
# All chunks in - reassemble, verify, and store.
|
|
hasher = hashlib.sha256()
|
|
tmp_path = dest_path + ".part"
|
|
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
|
size_bytes = 0
|
|
with open(tmp_path, "wb") as out:
|
|
for idx in range(total_chunks):
|
|
with open(os.path.join(tmp_dir, f"{idx:06d}.chunk"), "rb") as chunk_file:
|
|
while True:
|
|
data = chunk_file.read(1024 * 1024)
|
|
if not data:
|
|
break
|
|
hasher.update(data)
|
|
size_bytes += len(data)
|
|
out.write(data)
|
|
|
|
actual_sha256 = hasher.hexdigest()
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
if actual_sha256 != expected_sha256:
|
|
os.remove(tmp_path)
|
|
return jsonify(error="sha256 mismatch, upload corrupted in transit"), 422
|
|
|
|
os.replace(tmp_path, dest_path)
|
|
db.insert_dashcam_file(device_id, relative_path, size_bytes, actual_sha256, dest_path)
|
|
return jsonify(status="complete", size_bytes=size_bytes), 201
|
|
|
|
|
|
# ── Login rate limiting ────────────────────────────────────────────────────
|
|
# In-memory failed-attempt limiter. The app runs as a single gunicorn worker
|
|
# (threads=4), so process-local state is sufficient; a multi-worker deployment
|
|
# would need a shared store (Redis/DB) instead. Keys are the client IP (from
|
|
# Cloudflare's CF-Connecting-IP) plus the submitted username, so a distributed
|
|
# brute-force can't bypass the per-IP cap by rotating source addresses.
|
|
_LOGIN_FAIL_WINDOW_S = 900 # 15 minutes
|
|
_LOGIN_FAIL_MAX = 8 # failed attempts per key before blocking
|
|
_login_failures = defaultdict(list)
|
|
_login_failures_lock = threading.Lock()
|
|
|
|
|
|
def _login_failure_key_ip():
|
|
return (
|
|
request.headers.get("CF-Connecting-IP")
|
|
or (request.headers.get("X-Forwarded-For") or "").split(",")[0].strip()
|
|
or request.remote_addr
|
|
or "unknown"
|
|
)
|
|
|
|
|
|
def _login_blocked(key):
|
|
if not key:
|
|
return False
|
|
now = time.time()
|
|
cutoff = now - _LOGIN_FAIL_WINDOW_S
|
|
with _login_failures_lock:
|
|
_login_failures[key] = [t for t in _login_failures[key] if t > cutoff]
|
|
return len(_login_failures[key]) >= _LOGIN_FAIL_MAX
|
|
|
|
|
|
def _record_login_failure(key):
|
|
if not key:
|
|
return
|
|
with _login_failures_lock:
|
|
_login_failures[key].append(time.time())
|
|
|
|
|
|
# ── 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", "")
|
|
client_ip = _login_failure_key_ip()
|
|
|
|
if _login_blocked(client_ip) or _login_blocked(f"user:{username}"):
|
|
csrf_token = secrets.token_hex(16)
|
|
session["csrf_token"] = csrf_token
|
|
flash("Too many failed attempts. Please try again later.", "error")
|
|
return render_template("login.html", csrf_token=csrf_token), 429
|
|
|
|
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"))
|
|
|
|
_record_login_failure(client_ip)
|
|
if username:
|
|
_record_login_failure(f"user:{username}")
|
|
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"))
|
|
|
|
|
|
@app.get("/account/password")
|
|
@require_login
|
|
def change_password_page():
|
|
csrf_token = secrets.token_hex(16)
|
|
session["csrf_token"] = csrf_token
|
|
return render_template("change_password.html", csrf_token=csrf_token)
|
|
|
|
|
|
@app.post("/account/password")
|
|
@require_login
|
|
def change_password():
|
|
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("change_password_page"))
|
|
|
|
current = request.form.get("current_password", "")
|
|
new_pw = request.form.get("new_password", "")
|
|
confirm = request.form.get("confirm_password", "")
|
|
|
|
user_row = db.get_user_by_id(current_user.id)
|
|
if not user_row or not check_password(current, user_row["password_hash"]):
|
|
flash("Current password is incorrect.", "error")
|
|
return redirect(url_for("change_password_page"))
|
|
if len(new_pw) < 8:
|
|
flash("New password must be at least 8 characters.", "error")
|
|
return redirect(url_for("change_password_page"))
|
|
if new_pw != confirm:
|
|
flash("Passwords do not match.", "error")
|
|
return redirect(url_for("change_password_page"))
|
|
|
|
from modules.user_auth import hash_password
|
|
with db.get_cursor(commit=True) as cur:
|
|
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s",
|
|
(hash_password(new_pw), current_user.id))
|
|
flash("Password updated successfully.", "success")
|
|
return redirect(url_for("change_password_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")
|
|
|
|
|
|
@app.get("/settings/device")
|
|
@require_login
|
|
def portal_device_settings_page():
|
|
return render_template(
|
|
"device_settings.html",
|
|
configured_device_name=os.environ.get("DEVICE_NAME", "yaris-2013"),
|
|
)
|
|
|
|
|
|
@app.get("/dtc")
|
|
@require_login
|
|
def portal_dtc_page():
|
|
return render_template("dtc.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
|
|
try:
|
|
limit = min(int(request.args.get("limit", 200)), 1000)
|
|
except (ValueError, TypeError):
|
|
limit = 200
|
|
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
|
|
try:
|
|
limit = min(int(request.args.get("limit", 500)), 2000)
|
|
except (ValueError, TypeError):
|
|
limit = 500
|
|
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
|
|
try:
|
|
safe = os.path.commonpath([STORAGE_DIR, os.path.normpath(storage_path)]) == os.path.normpath(STORAGE_DIR)
|
|
except ValueError:
|
|
safe = False
|
|
if not safe:
|
|
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)
|
|
label = _geocode_cached(lat4, lon4)
|
|
return jsonify(label=label)
|
|
|
|
|
|
@app.post("/api/geocode/batch")
|
|
@require_login
|
|
def geocode_batch():
|
|
"""Batch geocode — returns {key: label} for all unique coords in one request.
|
|
|
|
Uses the offline reverse_geocoder library (instant, no network). Deduplicates
|
|
coords before lookup; lru_cache absorbs any remaining duplicates."""
|
|
body = request.get_json(silent=True) or {}
|
|
coords = body.get("coords")
|
|
if not isinstance(coords, list) or not coords:
|
|
return jsonify(error="expected {coords: [{lat, lon}, ...]}"), 400
|
|
|
|
# Deduplicate
|
|
seen = set()
|
|
items = []
|
|
for item in coords:
|
|
try:
|
|
lat = float(item["lat"])
|
|
lon = float(item["lon"])
|
|
except (KeyError, ValueError, TypeError):
|
|
continue
|
|
lat4 = int(round(lat, 4) * 10000)
|
|
lon4 = int(round(lon, 4) * 10000)
|
|
if (lat4, lon4) not in seen:
|
|
seen.add((lat4, lon4))
|
|
items.append((f"{lat:.3f},{lon:.3f}", lat4, lon4))
|
|
|
|
result = {}
|
|
for key, lat4, lon4 in items:
|
|
result[key] = _geocode_cached(lat4, lon4)
|
|
|
|
return jsonify(result)
|
|
|
|
|
|
# ── Uplink trigger (recompute trips after new batch) ──────────────────────
|
|
|
|
@app.post("/api/v1/portal/recompute-trips")
|
|
@require_login
|
|
def recompute_trips():
|
|
"""Recompute trips from scratch, clearing all deletion markers."""
|
|
device_id = _portal_device_id()
|
|
if device_id is None:
|
|
return jsonify(error="device not configured"), 404
|
|
db.clear_deleted_trip_markers(device_id)
|
|
n = db.compute_trips_for_device(device_id)
|
|
return jsonify(status="ok", trips_computed=n)
|
|
|
|
|
|
@app.delete("/api/v1/portal/trip/<int:trip_id>")
|
|
@require_login
|
|
def portal_delete_trip(trip_id):
|
|
device_id = _portal_device_id()
|
|
if device_id is None:
|
|
return jsonify(error="device not configured"), 404
|
|
deleted = db.delete_trip(device_id, trip_id)
|
|
if not deleted:
|
|
return jsonify(error="trip not found"), 404
|
|
return jsonify(status="ok")
|
|
|
|
|
|
@app.post("/api/v1/portal/trips/delete")
|
|
@require_login
|
|
def portal_delete_trips_batch():
|
|
device_id = _portal_device_id()
|
|
if device_id is None:
|
|
return jsonify(error="device not configured"), 404
|
|
body = request.get_json(silent=True) or {}
|
|
ids = body.get("ids")
|
|
if not isinstance(ids, list) or not ids:
|
|
return jsonify(error="expected {ids: [...]}"), 400
|
|
deleted = db.delete_trips_batch(device_id, ids)
|
|
return jsonify(status="ok", deleted=deleted)
|
|
|
|
|
|
@app.delete("/api/v1/portal/dashcam/<int:file_id>")
|
|
@require_login
|
|
def portal_delete_dashcam(file_id):
|
|
device_id = _portal_device_id()
|
|
if device_id is None:
|
|
return jsonify(error="device not configured"), 404
|
|
storage_path = db.delete_dashcam_file(device_id, file_id)
|
|
if storage_path is None:
|
|
return jsonify(error="file not found"), 404
|
|
# Remove from disk (best-effort — don't fail if file is already gone)
|
|
try:
|
|
if os.path.isfile(storage_path):
|
|
os.remove(storage_path)
|
|
except OSError:
|
|
pass
|
|
return jsonify(status="ok")
|
|
|
|
|
|
@app.get("/api/trips")
|
|
@require_login
|
|
def portal_trips_compat():
|
|
"""Pi history.js compatibility alias — wraps response in {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)
|
|
trips = db.list_trips(device_id, limit=limit)
|
|
return jsonify(trips=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)
|
|
|
|
|
|
# ── Phase 25.5a: Device configuration sync ──────────────────────────────────
|
|
|
|
@app.get("/api/v1/portal/devices")
|
|
@require_login
|
|
def portal_devices():
|
|
devices = device_registry.list_devices()
|
|
configured_name = os.environ.get("DEVICE_NAME", "yaris-2013")
|
|
for device in devices:
|
|
device["portal_default"] = device["name"] == configured_name
|
|
return jsonify(
|
|
devices=devices,
|
|
configured_device_name=configured_name,
|
|
)
|
|
|
|
|
|
@app.post("/api/v1/portal/devices")
|
|
@require_login
|
|
def portal_device_create():
|
|
body = request.get_json(silent=True) or {}
|
|
result, errors = device_registry.create_device(body.get("name"))
|
|
if errors:
|
|
return jsonify(error="Invalid device.", fields=errors), 400
|
|
return jsonify(result), 201
|
|
|
|
|
|
@app.get("/api/v1/portal/devices/<int:device_id>/config")
|
|
@require_login
|
|
def portal_device_config_detail(device_id):
|
|
config = device_config.get_device_config(device_id)
|
|
if not config:
|
|
return jsonify(error="device not found"), 404
|
|
device = db.get_device_by_id(device_id)
|
|
return jsonify(
|
|
device=device,
|
|
config=config,
|
|
)
|
|
|
|
|
|
@app.post("/api/v1/portal/devices/<int:device_id>/config")
|
|
@require_login
|
|
def portal_device_config_update(device_id):
|
|
success, errors = device_config.update_device_config(
|
|
device_id,
|
|
request.get_json(silent=True) or {},
|
|
)
|
|
if not success:
|
|
return jsonify(error="Invalid device config.", fields=errors), 400
|
|
return jsonify(
|
|
device=db.get_device_by_id(device_id),
|
|
config=device_config.get_device_config(device_id),
|
|
)
|
|
|
|
|
|
@app.post("/api/v1/portal/devices/<int:device_id>/rotate-key")
|
|
@require_login
|
|
def portal_device_rotate_key(device_id):
|
|
result, errors = device_registry.rotate_device_key(device_id)
|
|
if errors:
|
|
return jsonify(error=errors.get("error", "Could not rotate key.")), 404
|
|
return jsonify(result)
|
|
|
|
|
|
@app.post("/api/v1/portal/devices/<int:device_id>/request-sync")
|
|
@require_login
|
|
def portal_device_request_sync(device_id):
|
|
result = device_registry.request_sync(device_id)
|
|
if result is None:
|
|
return jsonify(error="device not found"), 404
|
|
return jsonify(result)
|
|
|
|
|
|
@app.delete("/api/v1/portal/devices/<int:device_id>")
|
|
@require_login
|
|
def portal_device_delete(device_id):
|
|
success, errors = device_registry.delete_device(
|
|
device_id,
|
|
protected_device_name=os.environ.get("DEVICE_NAME", "yaris-2013"),
|
|
)
|
|
if not success:
|
|
status = 404 if errors.get("error") == "Device not found." else 409
|
|
return jsonify(error=errors.get("error", "Could not delete device.")), status
|
|
return jsonify(status="deleted")
|
|
|
|
|
|
@app.get("/api/v1/portal/device-config")
|
|
@require_login
|
|
def portal_device_config_get():
|
|
"""Get the configured device identity for the remote portal."""
|
|
device_id = _portal_device_id()
|
|
if device_id is None:
|
|
return jsonify(error="device not configured"), 404
|
|
|
|
config = device_config.get_device_config(device_id)
|
|
if not config:
|
|
return jsonify(error="device not found"), 404
|
|
|
|
config["configured_device_name"] = os.environ.get("DEVICE_NAME", "yaris-2013")
|
|
return jsonify(config)
|
|
|
|
|
|
@app.get("/api/device-config")
|
|
@require_device_auth
|
|
def api_device_config_get():
|
|
"""Get device configuration for the authenticated device.
|
|
|
|
Pi calls this to fetch config from server (connection-driven sync).
|
|
Returns owner_name, car_model, device_name, avatar_path, config_version.
|
|
"""
|
|
config = device_config.get_device_config(g.device_id)
|
|
if not config:
|
|
return jsonify(error="device not found"), 404
|
|
return jsonify(config)
|
|
|
|
|
|
@app.post("/api/device-config/push")
|
|
@require_device_auth
|
|
def api_device_config_push():
|
|
"""Update device configuration from the Pi (device-authenticated).
|
|
|
|
Pi pushes local config changes to server. Accepts a partial update:
|
|
any of owner_name, car_model, device_name, avatar_path, odometer_baseline.
|
|
odometer_baseline is Pi→Server only — the portal cannot edit it.
|
|
"""
|
|
body = request.get_json(silent=True) or {}
|
|
|
|
# Handle odometer_baseline separately — Pi→Server only.
|
|
odometer = body.pop("odometer_baseline", None)
|
|
if odometer is not None:
|
|
try:
|
|
odometer = float(odometer)
|
|
if odometer < 0 or odometer > 999999:
|
|
return jsonify(error="Invalid device config.",
|
|
fields={"odometer_baseline": "Must be between 0 and 999,999 km."}), 400
|
|
except (TypeError, ValueError):
|
|
return jsonify(error="Invalid device config.",
|
|
fields={"odometer_baseline": "Must be a number."}), 400
|
|
with db.get_cursor(commit=True) as cur:
|
|
cur.execute(
|
|
"UPDATE devices SET odometer_baseline = %s, config_version = config_version + 1, config_updated_at = now() WHERE id = %s",
|
|
(odometer, g.device_id),
|
|
)
|
|
|
|
success, errors = device_config.update_device_config(g.device_id, body)
|
|
if not success:
|
|
return jsonify(error="Invalid device config.", fields=errors), 400
|
|
config = device_config.get_device_config(g.device_id)
|
|
return jsonify(config)
|
|
|
|
|
|
@app.post("/api/v1/device-config/ack")
|
|
@require_device_auth
|
|
def api_device_config_ack():
|
|
body = request.get_json(silent=True) or {}
|
|
try:
|
|
config_version = int(body.get("config_version"))
|
|
except (TypeError, ValueError):
|
|
return jsonify(error="config_version is required"), 400
|
|
device_registry.acknowledge_sync(g.device_id, config_version)
|
|
return jsonify(status="ok")
|
|
|
|
|
|
@app.post("/api/device-config")
|
|
@require_login
|
|
def api_device_config_update():
|
|
"""Update device configuration (server portal/admin only).
|
|
|
|
Updates owner_name, car_model, device_name, avatar_path.
|
|
Increments config_version to signal change to Pi.
|
|
"""
|
|
device_id = _portal_device_id()
|
|
if device_id is None:
|
|
return jsonify(error="device not configured"), 404
|
|
|
|
body = request.get_json(silent=True) or {}
|
|
success, errors = device_config.update_device_config(device_id, body)
|
|
|
|
if not success:
|
|
return jsonify(error="Invalid device config.", fields=errors), 400
|
|
|
|
config = device_config.get_device_config(device_id)
|
|
return jsonify(config)
|
|
|
|
|
|
# ── DTC sync (FEAT-05) ────────────────────────────────────────────────────────
|
|
|
|
@app.post("/api/v1/dtc/batch")
|
|
@require_device_auth
|
|
def upload_dtc_batch():
|
|
"""Receive a DTC snapshot from the Pi. Body: {mil_on: bool, codes: [{code, description}, ...]}."""
|
|
body = request.get_json(silent=True) or {}
|
|
mil_on = bool(body.get("mil_on", False))
|
|
codes = body.get("codes")
|
|
if not isinstance(codes, list):
|
|
return jsonify(error="expected 'codes' as a list"), 400
|
|
for c in codes:
|
|
if not isinstance(c.get("code"), str) or not c["code"].strip():
|
|
return jsonify(error="each code must have a non-empty 'code' string"), 400
|
|
result = db.upsert_dtc_snapshot(g.device_id, mil_on, codes)
|
|
return jsonify(result), 200
|
|
|
|
|
|
@app.get("/api/v1/portal/dtc")
|
|
@require_login
|
|
def portal_dtc():
|
|
device_id = _portal_device_id()
|
|
if device_id is None:
|
|
return jsonify(error="device not configured"), 404
|
|
return jsonify(db.list_dtc_events(device_id))
|
|
|
|
|
|
@app.delete("/api/v1/portal/dtc/<int:dtc_id>")
|
|
@require_login
|
|
def portal_dtc_delete(dtc_id):
|
|
device_id = _portal_device_id()
|
|
if device_id is None:
|
|
return jsonify(error="device not configured"), 404
|
|
deleted = db.delete_dtc_event(device_id, dtc_id)
|
|
if not deleted:
|
|
return jsonify(error="DTC event not found"), 404
|
|
return jsonify(status="ok")
|
|
|
|
|
|
def main():
|
|
app.run(host="0.0.0.0", port=8098)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|