From 9858738e8281abb5d8abcf26ca3daf576b6f964d Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:48:38 -0400 Subject: [PATCH] Fix web blueprint: security, stubs, dashboard context, upload validation - Pass pets list to dashboard template (Critical 3) - Sanitize upload filenames with werkzeug secure_filename (Important 6) - Validate image extension against allowlist (Important 7) - 404 check for pet existence in upload and update endpoints (Important 8) - Save uploads to training/{pet_name}/ not training/{pet_id}/ (Important 11) - Wire /train to PetTrainer with background thread (Important 12) - Wire /api/training-status to stored trainer status (Important 13) - Implement /api/highlights from sightings + wildlife queries (Important 14) - Cap limit param at 500 on sightings and wildlife endpoints (Important 15) Co-Authored-By: Claude Sonnet 4.6 --- vigilar/web/blueprints/pets.py | 124 +++++++++++++++++++++++++++++---- 1 file changed, 110 insertions(+), 14 deletions(-) diff --git a/vigilar/web/blueprints/pets.py b/vigilar/web/blueprints/pets.py index 2d533cb..6daf0ac 100644 --- a/vigilar/web/blueprints/pets.py +++ b/vigilar/web/blueprints/pets.py @@ -1,12 +1,16 @@ """Pets blueprint — pet registration, sightings, labeling, training.""" +import os import time from pathlib import Path from flask import Blueprint, current_app, jsonify, render_template, request +from werkzeug.utils import secure_filename pets_bp = Blueprint("pets", __name__, url_prefix="/pets") +ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} + def _engine(): return current_app.config.get("DB_ENGINE") @@ -14,7 +18,12 @@ def _engine(): @pets_bp.route("/") def dashboard(): - return render_template("pets/dashboard.html") + engine = _engine() + all_pets = [] + if engine: + from vigilar.storage.queries import get_all_pets + all_pets = get_all_pets(engine) + return render_template("pets/dashboard.html", pets=all_pets) @pets_bp.route("/register", methods=["POST"]) @@ -69,7 +78,7 @@ def pet_sightings(): pet_id = request.args.get("pet_id") camera_id = request.args.get("camera_id") since_ts = request.args.get("since", type=float) - limit = request.args.get("limit", 100, type=int) + limit = min(request.args.get("limit", 100, type=int), 500) from vigilar.storage.queries import get_pet_sightings sightings = get_pet_sightings(engine, pet_id=pet_id, camera_id=camera_id, @@ -86,7 +95,7 @@ def wildlife_sightings(): threat_level = request.args.get("threat_level") camera_id = request.args.get("camera_id") since_ts = request.args.get("since", type=float) - limit = request.args.get("limit", 100, type=int) + limit = min(request.args.get("limit", 100, type=int), 500) from vigilar.storage.queries import get_wildlife_sightings sightings = get_wildlife_sightings(engine, threat_level=threat_level, camera_id=camera_id, @@ -133,20 +142,35 @@ def upload_photo(pet_id: str): if engine is None: return jsonify({"error": "database not available"}), 503 + from vigilar.storage.queries import get_pet, insert_training_image + pet = get_pet(engine, pet_id) + if not pet: + return jsonify({"error": "Pet not found"}), 404 + files = request.files.getlist("photos") if not files: return jsonify({"error": "no files provided"}), 400 cfg = current_app.config.get("VIGILAR_CONFIG") - upload_dir = Path(cfg.system.data_dir if cfg else "/tmp/vigilar") / "training" / pet_id - upload_dir.mkdir(parents=True, exist_ok=True) + pets_cfg = current_app.config.get("PETS_CONFIG") + if pets_cfg: + training_dir = pets_cfg.training_dir + else: + training_dir = os.path.join(cfg.system.data_dir if cfg else "/tmp/vigilar", "training") + + pet_dir = Path(training_dir) / pet["name"].lower() + pet_dir.mkdir(parents=True, exist_ok=True) - from vigilar.storage.queries import insert_training_image saved = [] for f in files: if not f.filename: continue - dest = upload_dir / f"{int(time.time() * 1000)}_{f.filename}" + safe_name = secure_filename(f.filename) + if not safe_name: + continue + if Path(safe_name).suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: + continue + dest = pet_dir / f"{int(time.time() * 1000)}_{safe_name}" f.save(str(dest)) insert_training_image(engine, pet_id=pet_id, image_path=str(dest), source="upload") saved.append(str(dest)) @@ -160,6 +184,11 @@ def update_pet(pet_id: str): if engine is None: return jsonify({"error": "database not available"}), 503 + from vigilar.storage.queries import get_pet + pet = get_pet(engine, pet_id) + if not pet: + return jsonify({"error": "Pet not found"}), 404 + data = request.get_json(silent=True) or {} from sqlalchemy import update as sa_update @@ -188,18 +217,85 @@ def delete_pet(pet_id: str): @pets_bp.route("/train", methods=["POST"]) -def trigger_training(): - # Publish training trigger via MQTT or queue; for now return accepted - return jsonify({"ok": True, "status": "queued"}) +def train_model(): + engine = _engine() + pets_cfg = current_app.config.get("PETS_CONFIG") + if not pets_cfg: + return jsonify({"error": "Pet detection not configured"}), 400 + + from vigilar.detection.trainer import PetTrainer + trainer = PetTrainer( + training_dir=pets_cfg.training_dir, + model_output_path=pets_cfg.pet_id_model_path, + ) + + ready, msg = trainer.check_readiness(min_images=pets_cfg.min_training_images) + if not ready: + return jsonify({"error": msg}), 400 + + # Store trainer on app for status polling + current_app.extensions["pet_trainer"] = trainer + + import threading + thread = threading.Thread(target=trainer.train, daemon=True) + thread.start() + + return jsonify({"ok": True, "status": "training_started", "message": msg}) @pets_bp.route("/api/training-status") def training_status(): - # TODO: read actual training progress from shared state / DB - return jsonify({"status": "idle", "progress": 0, "message": ""}) + trainer = current_app.extensions.get("pet_trainer") + if not trainer: + return jsonify({"status": "idle"}) + s = trainer.status + return jsonify({ + "status": "training" if s.is_training else "complete" if s.progress >= 1.0 else "idle", + "progress": round(s.progress, 2), + "epoch": s.epoch, + "total_epochs": s.total_epochs, + "accuracy": round(s.accuracy, 4), + "error": s.error, + }) @pets_bp.route("/api/highlights") def highlights(): - # TODO: query today's highlight clips from recordings - return jsonify({"highlights": []}) + engine = _engine() + if not engine: + return jsonify({"highlights": []}) + + import time as time_mod + since = time_mod.time() - 86400 + + from vigilar.storage.queries import get_pet_sightings, get_wildlife_sightings + + result = [] + + # High-confidence pet sightings + sightings = get_pet_sightings(engine, since_ts=since, limit=50) + for s in sightings: + if s.get("confidence", 0) > 0.8 and s.get("pet_id"): + result.append({ + "type": "pet_sighting", + "pet_id": s.get("pet_id"), + "camera_id": s["camera_id"], + "timestamp": s["ts"], + "confidence": s.get("confidence"), + }) + + # Wildlife events + wildlife = get_wildlife_sightings(engine, since_ts=since, limit=20) + for w in wildlife: + result.append({ + "type": "wildlife", + "species": w["species"], + "threat_level": w["threat_level"], + "camera_id": w["camera_id"], + "timestamp": w["ts"], + "confidence": w.get("confidence"), + }) + + # Sort by timestamp descending, limit to 20 + result.sort(key=lambda x: x["timestamp"], reverse=True) + return jsonify({"highlights": result[:20]})