from functools import wraps from pathlib import Path from datetime import datetime, timezone from werkzeug.utils import secure_filename from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app from app.db import get_db from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256 bp = Blueprint("main", __name__) def portal_session_required(view_func): @wraps(view_func) def wrapped(*args, **kwargs): if "otb_user_id" not in session or "otb_tenant_id" not in session: return redirect(url_for("auth.login_required_notice")) return view_func(*args, **kwargs) return wrapped def _client_ip(): return request.headers.get("X-Forwarded-For", request.remote_addr) def _tenant_root() -> Path: return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"] def _stored_name(original_name: str) -> str: safe = secure_filename(original_name or "upload.bin") if not safe: safe = "upload.bin" ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") return f"{ts}__{safe}" @bp.route("/") def index(): if "otb_user_id" in session: return redirect(url_for("main.dashboard")) return redirect(url_for("auth.login_required_notice")) @bp.route("/dashboard") @portal_session_required def dashboard(): db = get_db() with db.cursor() as cur: cur.execute( """ SELECT id, device_name, device_type, relative_path, is_active, created_at FROM devices WHERE tenant_id = %s ORDER BY id """, (session["otb_tenant_id"],), ) devices = cur.fetchall() return render_template( "cloud/dashboard.html", user_email=session.get("otb_email"), tenant_slug=session.get("otb_tenant_slug"), devices=devices, ) @bp.route("/devices/new", methods=["GET", "POST"]) @portal_session_required def add_device(): if request.method == "GET": return render_template( "cloud/device_new.html", user_email=session.get("otb_email"), tenant_slug=session.get("otb_tenant_slug"), ) device_name = (request.form.get("device_name") or "").strip() device_type = (request.form.get("device_type") or "").strip() if not device_name: flash("Device name is required.", "warning") return render_template( "cloud/device_new.html", user_email=session.get("otb_email"), tenant_slug=session.get("otb_tenant_slug"), device_name=device_name, device_type=device_type, ) if not device_type: flash("Device type is required.", "warning") return render_template( "cloud/device_new.html", user_email=session.get("otb_email"), tenant_slug=session.get("otb_tenant_slug"), device_name=device_name, device_type=device_type, ) slug = slugify_device_name(device_name) relative_path = f"devices/{slug}" db = get_db() with db.cursor() as cur: cur.execute( """ SELECT id FROM devices WHERE tenant_id = %s AND device_name = %s """, (session["otb_tenant_id"], device_name), ) existing = cur.fetchone() if existing: flash("A device with that name already exists.", "warning") return render_template( "cloud/device_new.html", user_email=session.get("otb_email"), tenant_slug=session.get("otb_tenant_slug"), device_name=device_name, device_type=device_type, ) cur.execute( """ INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active) VALUES (%s, %s, %s, %s, 1) """, (session["otb_tenant_id"], device_name, device_type, relative_path), ) cur.execute( """ INSERT INTO audit_logs ( tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail ) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s) """, ( session["otb_tenant_id"], session["otb_user_id"], _client_ip(), request.headers.get("User-Agent", ""), f"Created device '{device_name}' ({device_type}) at {relative_path}", ), ) db.commit() create_device_directories(session["otb_tenant_slug"], relative_path) flash("Device added successfully.", "success") return redirect(url_for("main.dashboard")) @bp.route("/devices/delete/", methods=["POST"]) @portal_session_required def delete_device(device_id: int): db = get_db() with db.cursor() as cur: cur.execute( """ SELECT id, device_name, device_type, relative_path FROM devices WHERE id = %s AND tenant_id = %s """, (device_id, session["otb_tenant_id"]), ) device = cur.fetchone() if not device: flash("Device not found.", "warning") return redirect(url_for("main.dashboard")) cur.execute( """ SELECT COUNT(*) AS file_count FROM files WHERE tenant_id = %s AND device_id = %s AND is_deleted = 0 """, (session["otb_tenant_id"], device_id), ) file_count = cur.fetchone()["file_count"] if file_count and int(file_count) > 0: flash("This device cannot be removed because files are still linked to it.", "warning") return redirect(url_for("main.dashboard")) cur.execute( """ DELETE FROM devices WHERE id = %s AND tenant_id = %s """, (device_id, session["otb_tenant_id"]), ) cur.execute( """ INSERT INTO audit_logs ( tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail ) VALUES (%s, %s, 'user', 'device_deleted', %s, %s, %s) """, ( session["otb_tenant_id"], session["otb_user_id"], _client_ip(), request.headers.get("User-Agent", ""), f"Deleted device '{device['device_name']}' ({device['device_type']}) at {device['relative_path']}", ), ) db.commit() remove_device_directories(session["otb_tenant_slug"], device["relative_path"]) flash("Device removed successfully.", "success") return redirect(url_for("main.dashboard")) @bp.route("/devices//upload", methods=["GET", "POST"]) @portal_session_required def upload_files(device_id: int): db = get_db() with db.cursor() as cur: cur.execute( """ SELECT id, device_name, device_type, relative_path FROM devices WHERE id = %s AND tenant_id = %s """, (device_id, session["otb_tenant_id"]), ) device = cur.fetchone() if not device: flash("Device not found.", "warning") return redirect(url_for("main.dashboard")) if request.method == "GET": return render_template( "cloud/upload.html", user_email=session.get("otb_email"), tenant_slug=session.get("otb_tenant_slug"), device=device, ) files = request.files.getlist("files") files = [f for f in files if f and f.filename] if not files: flash("Please choose at least one file to upload.", "warning") return render_template( "cloud/upload.html", user_email=session.get("otb_email"), tenant_slug=session.get("otb_tenant_slug"), device=device, ) upload_base = _tenant_root() / device["relative_path"] / "originals" upload_base.mkdir(parents=True, exist_ok=True) uploaded_count = 0 with db.cursor() as cur: for incoming in files: original_filename = incoming.filename or "upload.bin" stored_name = _stored_name(original_filename) target_path = upload_base / stored_name incoming.save(target_path) size_bytes = target_path.stat().st_size sha256 = compute_sha256(target_path) base_name = original_filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] if "." in base_name: basename, extension = base_name.rsplit(".", 1) else: basename, extension = base_name, "" relative_path = f"{device['relative_path']}/originals/{stored_name}" directory_path = f"{device['relative_path']}/originals" cur.execute( """ INSERT INTO files ( tenant_id, device_id, parent_file_id, file_kind, relative_path, directory_path, original_filename, basename, extension, mime_type, size_bytes, sha256, capture_date, uploaded_at, is_immutable, is_deleted, deleted_at ) VALUES ( %s, %s, NULL, 'original', %s, %s, %s, %s, %s, %s, %s, %s, NULL, UTC_TIMESTAMP(), 1, 0, NULL ) """, ( session["otb_tenant_id"], device["id"], relative_path, directory_path, original_filename, basename, extension, incoming.mimetype or None, size_bytes, sha256, ), ) file_id = cur.lastrowid cur.execute( """ INSERT INTO audit_logs ( tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail ) VALUES (%s, %s, 'user', 'file_uploaded', %s, %s, %s, %s) """, ( session["otb_tenant_id"], session["otb_user_id"], file_id, _client_ip(), request.headers.get("User-Agent", ""), f"Uploaded '{original_filename}' to {relative_path}", ), ) uploaded_count += 1 db.commit() flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success") return redirect(url_for("main.dashboard"))