Phase 9: scaffold remote server (Flask + Postgres, docker-compose for unraid)
- server/sql/init.sql: devices/tracking_points/dashcam_files schema - server/modules/db.py: psycopg3 pool + query helpers - server/modules/auth.py: X-Device-Name/X-Api-Key header auth - server/app.py: /health, /api/v1/tracking/batch, /api/v1/dashcam/upload - server/Dockerfile + docker-compose.yml: api (gunicorn) + postgres services - server/scripts/create_device.py: one-time device/API-key registration - server/README.md: unraid deployment + Cloudflare Tunnel exposure docs - PLAN.md: Phase 9 marked in progress with full build notes Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""API-key authentication for device uploads.
|
||||
|
||||
Devices authenticate with two headers:
|
||||
X-Device-Name: <device name row in the devices table, e.g. "yaris-2013">
|
||||
X-Api-Key: <raw API key, only ever compared as a sha256 hash>
|
||||
|
||||
The raw key is never stored - only its sha256 hex digest is kept in the
|
||||
devices.api_key_hash column, generated once via scripts/create_device.py.
|
||||
"""
|
||||
import hashlib
|
||||
from functools import wraps
|
||||
|
||||
from flask import request, jsonify, g
|
||||
|
||||
from modules import db
|
||||
|
||||
|
||||
def _hash_key(raw_key):
|
||||
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def require_device_auth(view):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
device_name = request.headers.get("X-Device-Name", "")
|
||||
api_key = request.headers.get("X-Api-Key", "")
|
||||
|
||||
if not device_name or not api_key:
|
||||
return jsonify(error="missing X-Device-Name or X-Api-Key header"), 401
|
||||
|
||||
device = db.get_device_by_name(device_name)
|
||||
if device is None or device["api_key_hash"] != _hash_key(api_key):
|
||||
return jsonify(error="invalid device name or api key"), 401
|
||||
|
||||
g.device_id = device["id"]
|
||||
g.device_name = device["name"]
|
||||
db.touch_device_last_seen(device["id"])
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Postgres connection pool + small query helpers for the yaris-obd remote server."""
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
_POOL = None
|
||||
|
||||
|
||||
def _dsn():
|
||||
return (
|
||||
f"host={os.environ.get('POSTGRES_HOST', 'db')} "
|
||||
f"port={os.environ.get('POSTGRES_PORT', '5432')} "
|
||||
f"dbname={os.environ.get('POSTGRES_DB', 'yarisobd')} "
|
||||
f"user={os.environ.get('POSTGRES_USER', 'yarisobd')} "
|
||||
f"password={os.environ.get('POSTGRES_PASSWORD', '')}"
|
||||
)
|
||||
|
||||
|
||||
def get_pool():
|
||||
global _POOL
|
||||
if _POOL is None:
|
||||
_POOL = ConnectionPool(conninfo=_dsn(), min_size=1, max_size=5, open=True)
|
||||
return _POOL
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_cursor(commit=False):
|
||||
"""Yield a cursor from the pool, optionally committing on clean exit."""
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
yield cur
|
||||
if commit:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_device_by_name(name):
|
||||
with get_cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, name, api_key_hash FROM devices WHERE name = %s",
|
||||
(name,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {"id": row[0], "name": row[1], "api_key_hash": row[2]}
|
||||
|
||||
|
||||
def touch_device_last_seen(device_id):
|
||||
with get_cursor(commit=True) as cur:
|
||||
cur.execute(
|
||||
"UPDATE devices SET last_seen_at = now() WHERE id = %s",
|
||||
(device_id,),
|
||||
)
|
||||
|
||||
|
||||
def insert_tracking_points(device_id, points):
|
||||
"""Bulk-insert tracking points, skipping any that already exist
|
||||
(device_id, recorded_at) so a client can safely re-send a batch after a
|
||||
partial-failure retry without creating duplicates."""
|
||||
if not points:
|
||||
return 0
|
||||
with get_cursor(commit=True) as cur:
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO tracking_points
|
||||
(device_id, vehicle_name, driver_name, recorded_at, gps_lat, gps_lon, speed, odometer)
|
||||
VALUES (%(device_id)s, %(vehicle_name)s, %(driver_name)s, %(recorded_at)s,
|
||||
%(gps_lat)s, %(gps_lon)s, %(speed)s, %(odometer)s)
|
||||
ON CONFLICT (device_id, recorded_at) DO NOTHING
|
||||
""",
|
||||
[
|
||||
{
|
||||
"device_id": device_id,
|
||||
"vehicle_name": p.get("vehicle_name"),
|
||||
"driver_name": p.get("driver_name"),
|
||||
"recorded_at": p["recorded_at"],
|
||||
"gps_lat": p.get("gps_lat"),
|
||||
"gps_lon": p.get("gps_lon"),
|
||||
"speed": p.get("speed"),
|
||||
"odometer": p.get("odometer"),
|
||||
}
|
||||
for p in points
|
||||
],
|
||||
)
|
||||
return len(points)
|
||||
|
||||
|
||||
def dashcam_file_exists(device_id, relative_path, sha256):
|
||||
with get_cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT 1 FROM dashcam_files WHERE device_id = %s AND relative_path = %s AND sha256 = %s",
|
||||
(device_id, relative_path, sha256),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def insert_dashcam_file(device_id, relative_path, size_bytes, sha256, storage_path):
|
||||
with get_cursor(commit=True) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO dashcam_files (device_id, relative_path, size_bytes, sha256, storage_path)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (device_id, relative_path, sha256) DO NOTHING
|
||||
""",
|
||||
(device_id, relative_path, size_bytes, sha256, storage_path),
|
||||
)
|
||||
Reference in New Issue
Block a user