diff --git a/app.py b/app.py index 0ffa776..7c3499d 100644 --- a/app.py +++ b/app.py @@ -1,254 +1,180 @@ -from flask import Flask, request, jsonify, send_from_directory -from dotenv import load_dotenv +import os, hmac, hashlib, json, logging +from flask import Flask, request, jsonify +from datetime import datetime +import pytz, sqlite3 from apscheduler.schedulers.background import BackgroundScheduler -import sqlite3 -import datetime as dt -import requests -import urllib3 -import os +import requests, urllib3 -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -load_dotenv() +urllib3.disable_warnings() -app = Flask(__name__) +logging.basicConfig(level=logging.INFO) +log = logging.getLogger(__name__) -DB_PATH = os.environ.get("DB_PATH", "/data/dashboard.db") -UNIFI_HOST = os.environ.get("UNIFI_HOST", "") -UNIFI_API_TOKEN = os.environ.get("UNIFI_API_TOKEN", "") +app = Flask(__name__, static_folder="static", static_url_path="") +UNIFI_HOST = os.environ.get("UNIFI_HOST", "10.0.0.1") +UNIFI_PORT = int(os.environ.get("UNIFI_PORT", "12445")) +UNIFI_TOKEN = os.environ.get("UNIFI_API_TOKEN", "") +WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "") +DB_PATH = os.environ.get("DB_PATH", "/data/dashboard.db") +TZ = os.environ.get("TZ", "America/Chicago") -# ── Database ────────────────────────────────────────────────────────────────── +UNIFI_BASE = f"https://{UNIFI_HOST}:{UNIFI_PORT}/api/v1/developer" def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn - def init_db(): - conn = get_db() - conn.executescript(""" - CREATE TABLE IF NOT EXISTS unlocks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts TEXT NOT NULL, - event TEXT NOT NULL DEFAULT 'access.door.unlock', - door_name TEXT, - device_name TEXT, - actor_id TEXT, - actor_name TEXT, - resolved_name TEXT, - auth_type TEXT, - result TEXT - ); - - CREATE TABLE IF NOT EXISTS access_users ( - id TEXT PRIMARY KEY, - name TEXT, - email TEXT, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - """) - for col in ("resolved_name", "device_name"): - try: - conn.execute(f"ALTER TABLE unlocks ADD COLUMN {col} TEXT") - except Exception: - pass - conn.commit() - conn.close() - - -# ── UniFi user cache ────────────────────────────────────────────────────────── + with get_db() as db: + db.execute(""" + CREATE TABLE IF NOT EXISTS badge_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id TEXT NOT NULL, + ts TEXT NOT NULL, + date TEXT NOT NULL + ) + """) + db.execute(""" + CREATE TABLE IF NOT EXISTS user_cache ( + actor_id TEXT PRIMARY KEY, + full_name TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + db.commit() def sync_unifi_users(): - if not UNIFI_HOST or not UNIFI_API_TOKEN: - print("[UniFi Sync] UNIFI_HOST or UNIFI_API_TOKEN not set — skipping.") - return - url = f"https://{UNIFI_HOST}:45/api1/users" - headers = {"Authorization": f"Bearer {UNIFI_API_TOKEN}"} try: - resp = requests.get(url, headers=headers, verify=False, timeout=10) - resp.raise_for_status() - users = resp.json().get("data", []) - conn = get_db() - for user in users: - conn.execute( - """INSERT INTO access_users (id, name, email, updated_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(id) DO UPDATE SET - name=excluded.name, - email=excluded.email, - updated_at=excluded.updated_at""", - (user.get("id"), user.get("name"), user.get("email", "")), - ) - conn.commit() - conn.close() - print(f"[UniFi Sync] Cached {len(users)} users.") + r = requests.get( + f"{UNIFI_BASE}/users", + headers={"Authorization": f"Bearer {UNIFI_TOKEN}"}, + verify=False, timeout=10 + ) + if r.status_code != 200: + log.warning("User sync failed: %s %s", r.status_code, r.text[:200]) + return + users = r.json().get("data", []) + with get_db() as db: + for u in users: + db.execute(""" + INSERT INTO user_cache (actor_id, full_name, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(actor_id) DO UPDATE SET + full_name = excluded.full_name, + updated_at = excluded.updated_at + """, (u["id"], u.get("full_name", "").strip() or + f"{u.get('first_name','')} {u.get('last_name','')}".strip(), + datetime.utcnow().isoformat())) + db.commit() + log.info("Synced %d users from UniFi Access", len(users)) except Exception as e: - print(f"[UniFi Sync] Failed: {e}") + log.error("sync_unifi_users error: %s", e) +def verify_signature(payload_bytes, sig_header): + """Return True if HMAC-SHA256 signature matches, or if no secret configured.""" + if not WEBHOOK_SECRET: + return True + expected = hmac.new(WEBHOOK_SECRET.encode(), payload_bytes, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, sig_header or "") -def resolve_user_name(user_id: str) -> str: - if not user_id: - return "Unknown" - conn = get_db() - row = conn.execute( - "SELECT name FROM access_users WHERE id = ?", (user_id,) - ).fetchone() - conn.close() - return row["name"] if row else f"Unknown ({user_id[:8]}…)" +@app.route("/") +def index(): + return app.send_static_file("index.html") +@app.route("/api/unifi-access", methods=["POST"]) +def receive_webhook(): + raw = request.get_data() -# ── Webhook receiver ────────────────────────────────────────────────────────── + # Optional signature verification + sig = request.headers.get("X-Signature-SHA256", "") + if not verify_signature(raw, sig): + log.warning("Webhook signature mismatch") + return jsonify({"error": "invalid signature"}), 401 -@app.post("/api/unifi-access") -def unifi_access_webhook(): - payload = request.get_json(force=True, silent=True) or {} - ts = dt.datetime.utcnow().isoformat(timespec="seconds") + "Z" + try: + payload = json.loads(raw) + except Exception: + return jsonify({"error": "bad json"}), 400 - if payload.get("event") == "access.door.unlock": - data = payload.get("data", {}) - actor = data.get("actor", {}) - location = data.get("location", {}) - device = data.get("device", {}) - obj = data.get("object", {}) - actor_id = actor.get("id", "") - actor_name = actor.get("name") or resolve_user_name(actor_id) - door_name = location.get("name") - device_name = device.get("name") - auth_type = obj.get("authentication_type") - result = obj.get("result", "Access Granted") + log.info("Webhook received: %s", json.dumps(payload)[:300]) - elif "events" in payload: - event = payload["events"][0] - actor_id = event.get("user", "") - actor_name = event.get("user_name") or resolve_user_name(actor_id) - door_name = event.get("location_name") or event.get("location", "Unknown Door") - device_name = None - auth_type = event.get("unlock_method_text", "Unknown") - result = "Access Granted" + # Support both UniFi Access developer API and legacy Alarm Manager formats + event = payload.get("event") or payload.get("event_object_id", "") + actor = (payload.get("actor") or {}).get("id") or payload.get("actor_id", "") + ts_raw = payload.get("timestamp") or payload.get("created_at") or datetime.utcnow().isoformat() - else: - return "", 204 + if "door.unlock" not in str(event) and "access.door.unlock" not in str(event): + return jsonify({"status": "ignored"}), 200 - conn = get_db() - conn.execute( - """INSERT INTO unlocks - (ts, event, door_name, device_name, actor_id, - actor_name, resolved_name, auth_type, result) - VALUES (?, 'access.door.unlock', ?, ?, ?, ?, ?, ?, ?)""", - (ts, door_name, device_name, actor_id, actor_name, - actor_name, auth_type, result), - ) - conn.commit() - conn.close() - return "", 204 + if not actor: + return jsonify({"error": "no actor"}), 400 + tz = pytz.timezone(TZ) + ts = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00")) + ts_l = ts.astimezone(tz) + date = ts_l.strftime("%Y-%m-%d") + ts_s = ts_l.strftime("%H:%M:%S") -# ── Dashboard data API ──────────────────────────────────────────────────────── + with get_db() as db: + db.execute( + "INSERT INTO badge_events (actor_id, ts, date) VALUES (?, ?, ?)", + (actor, ts_s, date) + ) + db.commit() -@app.get("/api/first-badge-status") + log.info("Badge-in recorded: actor=%s date=%s ts=%s", actor, date, ts_s) + return jsonify({"status": "ok"}), 200 + +@app.route("/api/first-badge-status") def first_badge_status(): - date = request.args.get("date") or dt.date.today().isoformat() + date = request.args.get("date", datetime.now().strftime("%Y-%m-%d")) cutoff = request.args.get("cutoff", "09:00") - start = f"{date}T00:00:00Z" - end = f"{date}T23:59:59Z" - conn = get_db() - # First badge per person - first_rows = conn.execute( - """SELECT COALESCE(resolved_name, actor_name, actor_id) AS display_name, - actor_id, - MIN(ts) AS first_ts - FROM unlocks - WHERE event = 'access.door.unlock' - AND result = 'Access Granted' - AND ts BETWEEN ? AND ? - AND (actor_id IS NOT NULL OR actor_name IS NOT NULL) - GROUP BY actor_id - ORDER BY first_ts""", - (start, end), - ).fetchall() - - # Latest badge per person (may equal first if they only badged once) - latest_rows = conn.execute( - """SELECT actor_id, MAX(ts) AS latest_ts - FROM unlocks - WHERE event = 'access.door.unlock' - AND result = 'Access Granted' - AND ts BETWEEN ? AND ? - AND actor_id IS NOT NULL - GROUP BY actor_id""", - (start, end), - ).fetchall() - conn.close() - - latest_map = {r["actor_id"]: r["latest_ts"] for r in latest_rows} + with get_db() as db: + rows = db.execute(""" + SELECT + b.actor_id, + MIN(b.ts) AS first_ts, + MAX(b.ts) AS latest_ts, + COALESCE(u.full_name, 'Unknown (' || SUBSTR(b.actor_id,1,8) || '...)') AS name + FROM badge_events b + LEFT JOIN user_cache u ON u.actor_id = b.actor_id + WHERE b.date = ? + GROUP BY b.actor_id + ORDER BY first_ts ASC + """, (date,)).fetchall() result = [] - for r in first_rows: - t_first = dt.datetime.fromisoformat(r["first_ts"].replace("Z", "+00:00")) - first_time = t_first.strftime("%H:%M") - - latest_ts = latest_map.get(r["actor_id"], r["first_ts"]) - t_latest = dt.datetime.fromisoformat(latest_ts.replace("Z", "+00:00")) - latest_time = t_latest.strftime("%H:%M") - + for r in rows: + first = r["first_ts"] + latest = r["latest_ts"] + status = "ON TIME" if first <= cutoff + ":59" else "LATE" result.append({ - "actor_name": r["display_name"], - "actor_id": r["actor_id"], - "first_badge": r["first_ts"], - "badge_time": first_time, - "latest_badge": latest_ts, - "latest_time": latest_time, - "on_time": first_time <= cutoff, + "actor_id": r["actor_id"], + "name": r["name"], + "first_ts": first, + "latest_ts": latest if latest != first else None, + "status": status }) return jsonify(result) - -# ── Sync users ──────────────────────────────────────────────────────────────── - -@app.get("/api/sync-users") +@app.route("/api/sync-users") def manual_sync(): sync_unifi_users() - return jsonify({"status": "ok"}) + return jsonify({"status": "synced"}) - -# ── Reset day (testing only) ────────────────────────────────────────────────── - -@app.delete("/api/reset-day") +@app.route("/api/reset-day", methods=["DELETE"]) def reset_day(): - """Delete all unlock records for a given date (defaults to today). - Intended for development/testing only. - """ - date = request.args.get("date") or dt.date.today().isoformat() - start = f"{date}T00:00:00Z" - end = f"{date}T23:59:59Z" - conn = get_db() - res = conn.execute( - "DELETE FROM unlocks WHERE ts BETWEEN ? AND ?", (start, end) - ) - conn.commit() - deleted = res.rowcount - conn.close() - return jsonify({"status": "ok", "deleted": deleted, "date": date}) - - -# ── Static files ────────────────────────────────────────────────────────────── - -@app.get("/") -def index(): - return send_from_directory("static", "index.html") - -@app.get("/static/") -def send_static(path): - return send_from_directory("static", path) - - -# ── Startup ─────────────────────────────────────────────────────────────────── + date = request.args.get("date", datetime.now().strftime("%Y-%m-%d")) + with get_db() as db: + cur = db.execute("DELETE FROM badge_events WHERE date = ?", (date,)) + db.commit() + return jsonify({"status": "ok", "deleted": cur.rowcount, "date": date}) # Initialise DB and kick off background scheduler at import time -# (works whether started via `flask run` or `python app.py`) with app.app_context(): init_db() sync_unifi_users() diff --git a/docker-compose.yml b/docker-compose.yml index 1e0a0b8..97340af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ -version: "3.9" +version: "3.8" services: - unifi-access-dashboard: + dashboard: build: . container_name: unifi-access-dashboard restart: unless-stopped @@ -11,4 +11,9 @@ services: env_file: - .env environment: - - TZ=America/Chicago + - UNIFI_HOST=${UNIFI_HOST} + - UNIFI_PORT=${UNIFI_PORT:-12445} + - UNIFI_API_TOKEN=${UNIFI_API_TOKEN} + - WEBHOOK_SECRET=${WEBHOOK_SECRET} + - TZ=${TZ:-America/Chicago} + - DB_PATH=${DB_PATH:-/data/dashboard.db}