diff --git a/app/auth/routes.py.bak.uidguard.20260413-004811 b/app/auth/routes.py.bak.uidguard.20260413-004811 deleted file mode 100644 index 10514e0..0000000 --- a/app/auth/routes.py.bak.uidguard.20260413-004811 +++ /dev/null @@ -1,59 +0,0 @@ -from flask import Blueprint, current_app, redirect, render_template, request, session, url_for - -from app.db import get_db -from .utils import ensure_user_tenant_and_devices, is_valid_signature, is_valid_timestamp - -bp = Blueprint("auth", __name__, url_prefix="/auth") - -@bp.route("/login-required") -def login_required_notice(): - return render_template("auth/login_required.html") - -@bp.route("/handoff") -def handoff(): - portal_user_id = request.args.get("uid", "").strip() - email = request.args.get("email", "").strip().lower() - ts = request.args.get("ts", "").strip() - sig = request.args.get("sig", "").strip() - - if not portal_user_id or not email or not ts or not sig: - return render_template("auth/handoff_error.html", message="Missing handoff parameters."), 400 - - if not is_valid_timestamp(ts): - return render_template("auth/handoff_error.html", message="Handoff timestamp is invalid or expired."), 403 - - if not is_valid_signature(email=email, ts=ts, portal_user_id=portal_user_id, sig=sig): - return render_template("auth/handoff_error.html", message="Invalid handoff signature."), 403 - - identity = ensure_user_tenant_and_devices(email=email, portal_user_id=int(portal_user_id)) - - session.clear() - session["otb_user_id"] = identity["user_id"] - session["otb_tenant_id"] = identity["tenant_id"] - session["otb_tenant_slug"] = identity["tenant_slug"] - session["otb_email"] = identity["email"] - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'handoff_login_success', %s, %s, %s) - """, - ( - identity["tenant_id"], - identity["user_id"], - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Portal handoff accepted for {email}", - ), - ) - db.commit() - - return redirect(url_for("main.dashboard")) - -@bp.route("/logout") -def logout(): - session.clear() - return redirect(url_for("auth.login_required_notice")) diff --git a/app/auth/utils.py.bak.20260413-015405 b/app/auth/utils.py.bak.20260413-015405 deleted file mode 100644 index 29957f7..0000000 --- a/app/auth/utils.py.bak.20260413-015405 +++ /dev/null @@ -1,135 +0,0 @@ -import hashlib -import hmac -import re -import time -from pathlib import Path - -from flask import current_app - -from app.db import get_db - -SLUG_RE = re.compile(r"[^a-z0-9]+") - -def make_signature(email: str, ts: str, portal_user_id: str, secret: str) -> str: - payload = f"{portal_user_id}|{email}|{ts}".encode("utf-8") - return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() - -def is_valid_signature(email: str, ts: str, portal_user_id: str, sig: str) -> bool: - secret = current_app.config["OTB_PORTAL_SHARED_SECRET"] - expected = make_signature(email=email, ts=ts, portal_user_id=portal_user_id, secret=secret) - return hmac.compare_digest(expected, sig) - -def is_valid_timestamp(ts: str) -> bool: - try: - ts_int = int(ts) - except ValueError: - return False - skew = current_app.config["OTB_PORTAL_ALLOWED_SKEW_SECONDS"] - return abs(int(time.time()) - ts_int) <= skew - -def slugify_email(email: str) -> str: - local = email.split("@", 1)[0].lower().strip() - slug = SLUG_RE.sub("-", local).strip("-") - return slug or "tenant" - -def ensure_user_tenant_and_devices(email: str, portal_user_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, portal_user_id, email, display_name - FROM users - WHERE email = %s - """, - (email,), - ) - user = cur.fetchone() - - if user is None: - cur.execute( - """ - INSERT INTO users (portal_user_id, email, display_name, is_active) - VALUES (%s, %s, %s, 1) - """, - (portal_user_id, email, email), - ) - user_id = cur.lastrowid - else: - user_id = user["id"] - cur.execute( - """ - UPDATE users - SET portal_user_id = %s, last_login_at = NOW() - WHERE id = %s - """, - (portal_user_id, user_id), - ) - - cur.execute( - """ - SELECT id, slug, storage_root - FROM tenants - WHERE owner_user_id = %s - """, - (user_id,), - ) - tenant = cur.fetchone() - - if tenant is None: - base_slug = slugify_email(email) - slug = base_slug - suffix = 1 - - while True: - cur.execute("SELECT id FROM tenants WHERE slug = %s", (slug,)) - existing = cur.fetchone() - if existing is None: - break - suffix += 1 - slug = f"{base_slug}-{suffix}" - - storage_root = f"{current_app.config['STORAGE_ROOT']}/tenants/{slug}" - cur.execute( - """ - INSERT INTO tenants (owner_user_id, slug, storage_root, service_status, retention_mode) - VALUES (%s, %s, %s, 'active', 'standard') - """, - (user_id, slug, storage_root), - ) - tenant_id = cur.lastrowid - else: - tenant_id = tenant["id"] - slug = tenant["slug"] - storage_root = tenant["storage_root"] - - for device_name in current_app.config["DEFAULT_DEVICE_NAMES"]: - relative_path = f"devices/{device_name}" - cur.execute( - """ - INSERT IGNORE INTO devices (tenant_id, device_name, device_type, relative_path, is_active) - VALUES (%s, %s, %s, %s, 1) - """, - (tenant_id, device_name, device_name, relative_path), - ) - - db.commit() - - create_tenant_directories(slug, current_app.config["DEFAULT_DEVICE_NAMES"]) - - return { - "user_id": user_id, - "tenant_id": tenant_id, - "tenant_slug": slug, - "storage_root": storage_root, - "email": email, - } - -def create_tenant_directories(tenant_slug: str, device_names: list[str]): - tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug - (tenant_root / "logs").mkdir(parents=True, exist_ok=True) - (tenant_root / "support").mkdir(parents=True, exist_ok=True) - - for device_name in device_names: - for subdir in ["originals", "derived", "exports", "deleted", "tmp"]: - (tenant_root / "devices" / device_name / subdir).mkdir(parents=True, exist_ok=True) diff --git a/app/auth/utils.py.bak.20260413-021018 b/app/auth/utils.py.bak.20260413-021018 deleted file mode 100644 index de359f1..0000000 --- a/app/auth/utils.py.bak.20260413-021018 +++ /dev/null @@ -1,130 +0,0 @@ -import hashlib -import hmac -import re -import time -from pathlib import Path - -from flask import current_app - -from app.db import get_db - -SLUG_RE = re.compile(r"[^a-z0-9]+") - -def make_signature(email: str, ts: str, portal_user_id: str, secret: str) -> str: - payload = f"{portal_user_id}|{email}|{ts}".encode("utf-8") - return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() - -def is_valid_signature(email: str, ts: str, portal_user_id: str, sig: str) -> bool: - secret = current_app.config["OTB_PORTAL_SHARED_SECRET"] - expected = make_signature(email=email, ts=ts, portal_user_id=portal_user_id, secret=secret) - return hmac.compare_digest(expected, sig) - -def is_valid_timestamp(ts: str) -> bool: - try: - ts_int = int(ts) - except ValueError: - return False - skew = current_app.config["OTB_PORTAL_ALLOWED_SKEW_SECONDS"] - return abs(int(time.time()) - ts_int) <= skew - -def slugify_email(email: str) -> str: - local = email.split("@", 1)[0].lower().strip() - slug = SLUG_RE.sub("-", local).strip("-") - return slug or "tenant" - -def slugify_device_name(name: str) -> str: - return SLUG_RE.sub("-", name.lower().strip()).strip("-") or "device" - -def ensure_user_tenant_and_devices(email: str, portal_user_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, portal_user_id, email, display_name - FROM users - WHERE email = %s - """, - (email,), - ) - user = cur.fetchone() - - if user is None: - cur.execute( - """ - INSERT INTO users (portal_user_id, email, display_name, is_active) - VALUES (%s, %s, %s, 1) - """, - (portal_user_id, email, email), - ) - user_id = cur.lastrowid - else: - user_id = user["id"] - cur.execute( - """ - UPDATE users - SET portal_user_id = %s, last_login_at = NOW() - WHERE id = %s - """, - (portal_user_id, user_id), - ) - - cur.execute( - """ - SELECT id, slug, storage_root - FROM tenants - WHERE owner_user_id = %s - """, - (user_id,), - ) - tenant = cur.fetchone() - - if tenant is None: - base_slug = slugify_email(email) - slug = base_slug - suffix = 1 - - while True: - cur.execute("SELECT id FROM tenants WHERE slug = %s", (slug,)) - existing = cur.fetchone() - if existing is None: - break - suffix += 1 - slug = f"{base_slug}-{suffix}" - - storage_root = f"{current_app.config['STORAGE_ROOT']}/tenants/{slug}" - cur.execute( - """ - INSERT INTO tenants (owner_user_id, slug, storage_root, service_status, retention_mode) - VALUES (%s, %s, %s, 'active', 'standard') - """, - (user_id, slug, storage_root), - ) - tenant_id = cur.lastrowid - create_tenant_root(slug) - else: - tenant_id = tenant["id"] - slug = tenant["slug"] - storage_root = tenant["storage_root"] - - db.commit() - - return { - "user_id": user_id, - "tenant_id": tenant_id, - "tenant_slug": slug, - "storage_root": storage_root, - "email": email, - } - -def create_tenant_root(tenant_slug: str): - tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug - (tenant_root / "logs").mkdir(parents=True, exist_ok=True) - (tenant_root / "support").mkdir(parents=True, exist_ok=True) - (tenant_root / "devices").mkdir(parents=True, exist_ok=True) - -def create_device_directories(tenant_slug: str, device_path: str): - tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug - base = tenant_root / device_path - for subdir in ["originals", "derived", "exports", "deleted", "tmp"]: - (base / subdir).mkdir(parents=True, exist_ok=True) diff --git a/app/auth/utils.py.bak.20260413-024827 b/app/auth/utils.py.bak.20260413-024827 deleted file mode 100644 index cc6c6b5..0000000 --- a/app/auth/utils.py.bak.20260413-024827 +++ /dev/null @@ -1,137 +0,0 @@ -import hashlib -import hmac -import re -import shutil -import time -from pathlib import Path - -from flask import current_app - -from app.db import get_db - -SLUG_RE = re.compile(r"[^a-z0-9]+") - -def make_signature(email: str, ts: str, portal_user_id: str, secret: str) -> str: - payload = f"{portal_user_id}|{email}|{ts}".encode("utf-8") - return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() - -def is_valid_signature(email: str, ts: str, portal_user_id: str, sig: str) -> bool: - secret = current_app.config["OTB_PORTAL_SHARED_SECRET"] - expected = make_signature(email=email, ts=ts, portal_user_id=portal_user_id, secret=secret) - return hmac.compare_digest(expected, sig) - -def is_valid_timestamp(ts: str) -> bool: - try: - ts_int = int(ts) - except ValueError: - return False - skew = current_app.config["OTB_PORTAL_ALLOWED_SKEW_SECONDS"] - return abs(int(time.time()) - ts_int) <= skew - -def slugify_email(email: str) -> str: - local = email.split("@", 1)[0].lower().strip() - slug = SLUG_RE.sub("-", local).strip("-") - return slug or "tenant" - -def slugify_device_name(name: str) -> str: - return SLUG_RE.sub("-", name.lower().strip()).strip("-") or "device" - -def ensure_user_tenant_and_devices(email: str, portal_user_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, portal_user_id, email, display_name - FROM users - WHERE email = %s - """, - (email,), - ) - user = cur.fetchone() - - if user is None: - cur.execute( - """ - INSERT INTO users (portal_user_id, email, display_name, is_active) - VALUES (%s, %s, %s, 1) - """, - (portal_user_id, email, email), - ) - user_id = cur.lastrowid - else: - user_id = user["id"] - cur.execute( - """ - UPDATE users - SET portal_user_id = %s, last_login_at = NOW() - WHERE id = %s - """, - (portal_user_id, user_id), - ) - - cur.execute( - """ - SELECT id, slug, storage_root - FROM tenants - WHERE owner_user_id = %s - """, - (user_id,), - ) - tenant = cur.fetchone() - - if tenant is None: - base_slug = slugify_email(email) - slug = base_slug - suffix = 1 - - while True: - cur.execute("SELECT id FROM tenants WHERE slug = %s", (slug,)) - existing = cur.fetchone() - if existing is None: - break - suffix += 1 - slug = f"{base_slug}-{suffix}" - - storage_root = f"{current_app.config['STORAGE_ROOT']}/tenants/{slug}" - cur.execute( - """ - INSERT INTO tenants (owner_user_id, slug, storage_root, service_status, retention_mode) - VALUES (%s, %s, %s, 'active', 'standard') - """, - (user_id, slug, storage_root), - ) - tenant_id = cur.lastrowid - create_tenant_root(slug) - else: - tenant_id = tenant["id"] - slug = tenant["slug"] - storage_root = tenant["storage_root"] - - db.commit() - - return { - "user_id": user_id, - "tenant_id": tenant_id, - "tenant_slug": slug, - "storage_root": storage_root, - "email": email, - } - -def create_tenant_root(tenant_slug: str): - tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug - (tenant_root / "logs").mkdir(parents=True, exist_ok=True) - (tenant_root / "support").mkdir(parents=True, exist_ok=True) - (tenant_root / "devices").mkdir(parents=True, exist_ok=True) - -def create_device_directories(tenant_slug: str, device_path: str): - tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug - base = tenant_root / device_path - for subdir in ["originals", "derived", "exports", "deleted", "tmp"]: - (base / subdir).mkdir(parents=True, exist_ok=True) - -def remove_device_directories(tenant_slug: str, device_path: str): - tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug - base = tenant_root / device_path - if base.exists() and base.is_dir(): - shutil.rmtree(base) diff --git a/app/auth/utils.py.bak.20260413-051439 b/app/auth/utils.py.bak.20260413-051439 deleted file mode 100644 index 9e8d265..0000000 --- a/app/auth/utils.py.bak.20260413-051439 +++ /dev/null @@ -1,144 +0,0 @@ -import hashlib -import hmac -import re -import shutil -import time -from pathlib import Path - -from flask import current_app - -from app.db import get_db - -SLUG_RE = re.compile(r"[^a-z0-9]+") - -def make_signature(email: str, ts: str, portal_user_id: str, secret: str) -> str: - payload = f"{portal_user_id}|{email}|{ts}".encode("utf-8") - return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() - -def is_valid_signature(email: str, ts: str, portal_user_id: str, sig: str) -> bool: - secret = current_app.config["OTB_PORTAL_SHARED_SECRET"] - expected = make_signature(email=email, ts=ts, portal_user_id=portal_user_id, secret=secret) - return hmac.compare_digest(expected, sig) - -def is_valid_timestamp(ts: str) -> bool: - try: - ts_int = int(ts) - except ValueError: - return False - skew = current_app.config["OTB_PORTAL_ALLOWED_SKEW_SECONDS"] - return abs(int(time.time()) - ts_int) <= skew - -def slugify_email(email: str) -> str: - local = email.split("@", 1)[0].lower().strip() - slug = SLUG_RE.sub("-", local).strip("-") - return slug or "tenant" - -def slugify_device_name(name: str) -> str: - return SLUG_RE.sub("-", name.lower().strip()).strip("-") or "device" - -def ensure_user_tenant_and_devices(email: str, portal_user_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, portal_user_id, email, display_name - FROM users - WHERE email = %s - """, - (email,), - ) - user = cur.fetchone() - - if user is None: - cur.execute( - """ - INSERT INTO users (portal_user_id, email, display_name, is_active) - VALUES (%s, %s, %s, 1) - """, - (portal_user_id, email, email), - ) - user_id = cur.lastrowid - else: - user_id = user["id"] - cur.execute( - """ - UPDATE users - SET portal_user_id = %s, last_login_at = NOW() - WHERE id = %s - """, - (portal_user_id, user_id), - ) - - cur.execute( - """ - SELECT id, slug, storage_root - FROM tenants - WHERE owner_user_id = %s - """, - (user_id,), - ) - tenant = cur.fetchone() - - if tenant is None: - base_slug = slugify_email(email) - slug = base_slug - suffix = 1 - - while True: - cur.execute("SELECT id FROM tenants WHERE slug = %s", (slug,)) - existing = cur.fetchone() - if existing is None: - break - suffix += 1 - slug = f"{base_slug}-{suffix}" - - storage_root = f"{current_app.config['STORAGE_ROOT']}/tenants/{slug}" - cur.execute( - """ - INSERT INTO tenants (owner_user_id, slug, storage_root, service_status, retention_mode) - VALUES (%s, %s, %s, 'active', 'standard') - """, - (user_id, slug, storage_root), - ) - tenant_id = cur.lastrowid - create_tenant_root(slug) - else: - tenant_id = tenant["id"] - slug = tenant["slug"] - storage_root = tenant["storage_root"] - - db.commit() - - return { - "user_id": user_id, - "tenant_id": tenant_id, - "tenant_slug": slug, - "storage_root": storage_root, - "email": email, - } - -def create_tenant_root(tenant_slug: str): - tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug - (tenant_root / "logs").mkdir(parents=True, exist_ok=True) - (tenant_root / "support").mkdir(parents=True, exist_ok=True) - (tenant_root / "devices").mkdir(parents=True, exist_ok=True) - -def create_device_directories(tenant_slug: str, device_path: str): - tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug - base = tenant_root / device_path - for subdir in ["originals", "derived", "exports", "deleted", "tmp"]: - (base / subdir).mkdir(parents=True, exist_ok=True) - -def remove_device_directories(tenant_slug: str, device_path: str): - tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug - base = tenant_root / device_path - if base.exists() and base.is_dir(): - shutil.rmtree(base) - -def compute_sha256(path: Path) -> str: - h = hashlib.sha256() - with path.open("rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): - h.update(chunk) - return h.hexdigest() diff --git a/app/main/routes.py b/app/main/routes.py index 9764599..a5f8011 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -1180,7 +1180,13 @@ def rename_file(file_id: int): else: flash(f"File renamed to '{display_filename}'.", "success") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) + return redirect(url_for( + "main.browse_device_files", + device_id=file_row["device_id"], + path=request.form.get("path", ""), + view=request.form.get("view", "list"), + page=request.form.get("page", "1"), + )) @bp.route("/devices//files", methods=["GET"]) @portal_session_required @@ -1192,14 +1198,30 @@ def browse_device_files(device_id: int): flash("Device not found.", "warning") return redirect(url_for("main.dashboard")) - view_mode = request.args.get("view", "list").strip().lower() + current_path = _normalize_browser_path(request.args.get("path", "")) + + requested_view = request.args.get("view") + if requested_view: + view_mode = requested_view.strip().lower() + elif current_path == "images" or current_path.startswith("images/"): + view_mode = "gallery" + else: + view_mode = "list" + if view_mode not in ("list", "gallery"): view_mode = "list" - current_path = _normalize_browser_path(request.args.get("path", "")) root_directory = f"{device['relative_path']}/originals" current_directory = f"{root_directory}/{current_path}" if current_path else root_directory + try: + page = max(1, int(request.args.get("page", "1"))) + except Exception: + page = 1 + + per_page = 100 + offset = (page - 1) * per_page + with db.cursor() as cur: cur.execute( """ @@ -1223,11 +1245,26 @@ def browse_device_files(device_id: int): AND is_deleted = 0 AND directory_path = %s ORDER BY uploaded_at DESC, id DESC + LIMIT %s OFFSET %s """, - (session["otb_tenant_id"], device_id, current_directory), + (session["otb_tenant_id"], device_id, current_directory, per_page, offset), ) files = cur.fetchall() + cur.execute( + """ + SELECT COUNT(*) AS total_files + FROM files + WHERE tenant_id = %s + AND device_id = %s + AND is_deleted = 0 + AND directory_path = %s + """, + (session["otb_tenant_id"], device_id, current_directory), + ) + count_row = cur.fetchone() or {"total_files": 0} + total_files = count_row["total_files"] if isinstance(count_row, dict) else count_row[0] + cur.execute( """ SELECT DISTINCT directory_path @@ -1314,6 +1351,11 @@ def browse_device_files(device_id: int): device=device, files=files, file_count=len(files), + total_files=total_files, + page=page, + per_page=per_page, + has_prev=page > 1, + has_next=(offset + per_page) < total_files, view_mode=view_mode, current_path=current_path, parent_path=parent_path, @@ -1645,7 +1687,18 @@ def lts_view(): from app.services.video_jobs import create_video_job, list_jobs_for_tenant +@bp.route("/image-workshop/") +@portal_session_required +def image_workshop(device_id): + return render_template( + "cloud/image_workshop.html", + device_id=device_id, + user_email=session.get("otb_email"), + tenant_slug=session.get("otb_tenant_slug"), + ) + @bp.route("/workshop/") +@portal_session_required def workshop(device_id): from app.db import get_db @@ -2107,3 +2160,166 @@ def video_queue_summary(): "active_users": active_users } + + +@bp.route("/api/image/process", methods=["POST"]) +@portal_session_required +def image_process(): + from PIL import Image, ImageOps + from pathlib import Path + from datetime import datetime + + db = get_db() + data = request.get_json(silent=True) or {} + items = data.get("items") or [] + state = data.get("state") or {} + + if not isinstance(items, list) or not items: + return jsonify({"ok": False, "error": "no_images_selected"}), 400 + + if len(items) > 25: + return jsonify({"ok": False, "error": "Image Workshop is limited to 25 images per batch."}), 400 + + tenant_id = session.get("otb_tenant_id") + tenant_slug = session.get("otb_tenant_slug") or session.get("tenant") or "def" + + processed = [] + + with db.cursor() as cur: + cur.execute("SELECT storage_root FROM tenants WHERE id = %s LIMIT 1", (tenant_id,)) + tenant_row = cur.fetchone() + + if not tenant_row: + return jsonify({"ok": False, "error": "tenant_not_found"}), 404 + + storage_root = Path(tenant_row["storage_root"]) + + for item in items: + try: + file_id = int(item.get("id")) + except Exception: + continue + + with db.cursor() as cur: + cur.execute( + """ + SELECT id, tenant_id, device_id, relative_path, directory_path, + original_filename, display_filename, mime_type + FROM files + WHERE id = %s + AND tenant_id = %s + AND is_deleted = 0 + AND mime_type LIKE 'image%%' + LIMIT 1 + """, + (file_id, tenant_id), + ) + row = cur.fetchone() + + if not row: + continue + + cfg = state.get(str(file_id)) or state.get(file_id) or {} + rotation = int(cfg.get("rotation") or 0) + filter_name = cfg.get("filter") + fmt = (cfg.get("format") or "").lower() + + src = storage_root / row["relative_path"] + if not src.exists() or not src.is_file(): + continue + + out_dir_rel = row["directory_path"] + out_dir = storage_root / out_dir_rel + out_dir.mkdir(parents=True, exist_ok=True) + + src_name = Path(row["original_filename"]) + requested_name = (cfg.get("name") or "").strip() + if requested_name: + base = "".join(c for c in requested_name if c.isalnum() or c in ("-", "_", " ")).strip().replace(" ", "_") + else: + base = src_name.stem + "_edited" + requested_ext = fmt if fmt in ("jpg", "jpeg", "png", "webp") else src_name.suffix.lower().lstrip(".") + if requested_ext == "jpeg": + requested_ext = "jpg" + if requested_ext not in ("jpg", "png", "webp"): + requested_ext = "jpg" + + stamp = datetime.utcnow().strftime("%Y%m%dT%H%M%S%fZ") + out_name = f"{base}.{requested_ext}" + if (out_dir / out_name).exists(): + out_name = f"{base}_{stamp}.{requested_ext}" + out_path = out_dir / out_name + out_rel = f"{out_dir_rel}/{out_name}" + + with Image.open(src) as img: + img = ImageOps.exif_transpose(img) + + if rotation in (90, 180, 270): + img = img.rotate(-rotation, expand=True) + + if filter_name == "bw": + img = ImageOps.grayscale(img).convert("RGB") + elif filter_name == "sepia": + gray = ImageOps.grayscale(img) + img = ImageOps.colorize(gray, "#2b1b0f", "#f2d3a3").convert("RGB") + else: + if img.mode not in ("RGB", "RGBA"): + img = img.convert("RGB") + + save_kwargs = {} + if requested_ext in ("jpg", "webp"): + if img.mode == "RGBA": + img = img.convert("RGB") + save_kwargs["quality"] = 88 + + if requested_ext == "jpg": + img.save(out_path, "JPEG", **save_kwargs) + mime = "image/jpeg" + elif requested_ext == "png": + img.save(out_path, "PNG") + mime = "image/png" + else: + img.save(out_path, "WEBP", **save_kwargs) + mime = "image/webp" + + size_bytes = out_path.stat().st_size + sha256 = compute_sha256(out_path) + + with db.cursor() as cur: + cur.execute( + """ + INSERT INTO files ( + tenant_id, device_id, parent_file_id, file_kind, relative_path, directory_path, + original_filename, display_filename, basename, extension, mime_type, size_bytes, sha256, + capture_date, uploaded_at, is_immutable, is_deleted, deleted_at + ) VALUES (%s, %s, %s, 'image_processed', %s, %s, + %s, %s, %s, %s, %s, %s, %s, + NULL, UTC_TIMESTAMP(), 1, 0, NULL) + """, + ( + row["tenant_id"], + row["device_id"], + row["id"], + out_rel, + out_dir_rel, + out_name, + out_name, + Path(out_name).stem, + requested_ext, + mime, + size_bytes, + sha256, + ), + ) + new_id = cur.lastrowid + + db.commit() + + processed.append({ + "id": new_id, + "filename": out_name, + "relative_path": out_rel, + "size_bytes": size_bytes, + }) + + return jsonify({"ok": True, "processed": processed}) diff --git a/app/main/routes.py.bak.20260413-015405 b/app/main/routes.py.bak.20260413-015405 deleted file mode 100644 index 38569a0..0000000 --- a/app/main/routes.py.bak.20260413-015405 +++ /dev/null @@ -1,45 +0,0 @@ -from functools import wraps - -from flask import Blueprint, redirect, render_template, session, url_for - -from app.db import get_db - -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 - -@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, - ) diff --git a/app/main/routes.py.bak.20260413-021018 b/app/main/routes.py.bak.20260413-021018 deleted file mode 100644 index 09a881b..0000000 --- a/app/main/routes.py.bak.20260413-021018 +++ /dev/null @@ -1,135 +0,0 @@ -from functools import wraps - -from flask import Blueprint, flash, redirect, render_template, request, session, url_for - -from app.db import get_db -from app.auth.utils import create_device_directories, slugify_device_name - -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 - -@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"], - request.headers.get("X-Forwarded-For", request.remote_addr), - 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")) diff --git a/app/main/routes.py.bak.20260413-024827 b/app/main/routes.py.bak.20260413-024827 deleted file mode 100644 index 9be4fcb..0000000 --- a/app/main/routes.py.bak.20260413-024827 +++ /dev/null @@ -1,199 +0,0 @@ -from functools import wraps - -from flask import Blueprint, flash, redirect, render_template, request, session, url_for - -from app.db import get_db -from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name - -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 - -@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"], - request.headers.get("X-Forwarded-For", request.remote_addr), - 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"], - request.headers.get("X-Forwarded-For", request.remote_addr), - 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")) diff --git a/app/main/routes.py.bak.20260413-032130 b/app/main/routes.py.bak.20260413-032130 deleted file mode 100644 index 143c0fe..0000000 --- a/app/main/routes.py.bak.20260413-032130 +++ /dev/null @@ -1,331 +0,0 @@ -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")) diff --git a/app/main/routes.py.bak.20260413-051439 b/app/main/routes.py.bak.20260413-051439 deleted file mode 100644 index 67b4a28..0000000 --- a/app/main/routes.py.bak.20260413-051439 +++ /dev/null @@ -1,385 +0,0 @@ -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")) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_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")) - - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id), - ) - files = cur.fetchall() - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - ) diff --git a/app/main/routes.py.bak.20260413-054006 b/app/main/routes.py.bak.20260413-054006 deleted file mode 100644 index a3d21ad..0000000 --- a/app/main/routes.py.bak.20260413-054006 +++ /dev/null @@ -1,788 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - 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")) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=file_row["original_filename"]) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(file_row["original_filename"]) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{file_row['original_filename']}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - zf.write(p, arcname=p.name) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id), - ) - files = cur.fetchall() - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - ) diff --git a/app/main/routes.py.bak.android.20260414-002002 b/app/main/routes.py.bak.android.20260414-002002 deleted file mode 100644 index d33a5da..0000000 --- a/app/main/routes.py.bak.android.20260414-002002 +++ /dev/null @@ -1,1212 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/main/routes.py.bak.androidactivate.20260414-023849 b/app/main/routes.py.bak.androidactivate.20260414-023849 deleted file mode 100644 index ae68649..0000000 --- a/app/main/routes.py.bak.androidactivate.20260414-023849 +++ /dev/null @@ -1,1278 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/main/routes.py.bak.androidlock.20260414-014204 b/app/main/routes.py.bak.androidlock.20260414-014204 deleted file mode 100644 index ad4d69f..0000000 --- a/app/main/routes.py.bak.androidlock.20260414-014204 +++ /dev/null @@ -1,1268 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/main/routes.py.bak.androidrepair.20260415-065050 b/app/main/routes.py.bak.androidrepair.20260415-065050 deleted file mode 100644 index 937a526..0000000 --- a/app/main/routes.py.bak.androidrepair.20260415-065050 +++ /dev/null @@ -1,1529 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re -import hashlib - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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("/api/android/activate", methods=["POST"]) -def android_activate(): - -@bp.route("/api/android/upload", methods=["POST"]) -def android_upload(): - db = get_db() - - device_uuid = (request.form.get("device_uuid") or "").strip() - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.tenant_id, - t.device_id, - t.status, - d.device_name, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.device_uuid = %s - LIMIT 1 - """, - (device_uuid,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "device_not_found"}), 404 - - if row["status"] != "activated": - return jsonify({"ok": False, "error": "device_not_activated"}), 403 - - files = request.files.getlist("files") - files = [f for f in files if f and f.filename] - - if not files: - return jsonify({"ok": False, "error": "no_files"}), 400 - - upload_base = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / str(row["tenant_id"]) / row["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) - - if "." in original_filename: - basename, extension = original_filename.rsplit(".", 1) - else: - basename, extension = original_filename, "" - - relative_path = f"{row['relative_path']}/originals/{stored_name}" - directory_path = f"{row['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 - ) - """, - ( - row["tenant_id"], - row["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, NULL, 'device', 'file_uploaded', %s, %s, %s, %s) - """, - ( - row["tenant_id"], - file_id, - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Android upload '{original_filename}' to {relative_path}", - ), - ) - - uploaded_count += 1 - - db.commit() - - return jsonify({ - "ok": True, - "uploaded": uploaded_count, - "device_name": row["device_name"] - }), 200 - - - db = get_db() - - payload = request.get_json(silent=True) or {} - raw_token = (payload.get("token") or "").strip() - device_uuid = (payload.get("device_uuid") or "").strip() - phone_label = (payload.get("phone_label") or "").strip() - - if not raw_token: - return jsonify({"ok": False, "error": "missing_token"}), 400 - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.id, - t.tenant_id, - t.device_id, - t.device_label, - t.status, - t.expires_at, - t.activated_at, - t.device_uuid, - d.device_name, - d.device_type, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.token_hash = %s - LIMIT 1 - """, - (token_hash,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "invalid_token"}), 404 - - if row["device_type"] != "android": - return jsonify({"ok": False, "error": "invalid_device_type"}), 400 - - if row["status"] == "revoked": - return jsonify({"ok": False, "error": "token_revoked"}), 403 - - if row["status"] == "activated": - if row.get("device_uuid") == device_uuid: - return jsonify({ - "ok": True, - "status": "already_activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": row["device_label"], - "relative_path": row["relative_path"], - }), 200 - return jsonify({"ok": False, "error": "token_already_used"}), 409 - - cur.execute("SELECT UTC_TIMESTAMP() AS now_utc") - now_row = cur.fetchone() - now_utc = now_row["now_utc"] - - if row["expires_at"] is not None and now_utc > row["expires_at"]: - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'expired' - WHERE id = %s - """, - (row["id"],), - ) - db.commit() - return jsonify({"ok": False, "error": "token_expired"}), 410 - - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'activated', - activated_at = UTC_TIMESTAMP(), - device_uuid = %s, - device_label = %s - WHERE id = %s - """, - ( - device_uuid, - phone_label or row["device_label"], - row["id"], - ), - ) - - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, file_id, job_id, ip_address, user_agent, event_detail - ) VALUES (%s, NULL, 'system', 'android_device_activated', NULL, NULL, %s, %s, %s) - """, - ( - row["tenant_id"], - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Activated android device '{row['device_name']}' with UUID '{device_uuid}'", - ), - ) - - db.commit() - - return jsonify({ - "ok": True, - "status": "activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": phone_label or row["device_label"], - "relative_path": row["relative_path"], - }), 200 - - -@bp.route("/devices/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - COUNT(*) AS total_file_count, - SUM(CASE WHEN is_deleted = 0 THEN 1 ELSE 0 END) AS active_file_count, - SUM(CASE WHEN is_deleted = 1 THEN 1 ELSE 0 END) AS deleted_file_count - FROM files - WHERE tenant_id = %s AND device_id = %s - """, - (session["otb_tenant_id"], device_id), - ) - counts = cur.fetchone() - - total_file_count = int(counts["total_file_count"] or 0) - active_file_count = int(counts["active_file_count"] or 0) - deleted_file_count = int(counts["deleted_file_count"] or 0) - - if total_file_count > 0: - if deleted_file_count > 0: - flash( - f"This device is not empty. It still has {active_file_count} active file(s) and {deleted_file_count} file(s) in Deleted Files. Please clear those before deleting the device.", - "warning", - ) - else: - flash( - f"This device is not empty. It still has {active_file_count} active file(s). Please remove them before deleting the device.", - "warning", - ) - return redirect(url_for("main.dashboard")) - - cur.execute( - """ - DELETE FROM android_device_tokens - WHERE tenant_id = %s AND device_id = %s - """, - (session["otb_tenant_id"], device_id), - ) - - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/main/routes.py.bak.androidupload.20260415-063731 b/app/main/routes.py.bak.androidupload.20260415-063731 deleted file mode 100644 index 025b5d9..0000000 --- a/app/main/routes.py.bak.androidupload.20260415-063731 +++ /dev/null @@ -1,1410 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re -import hashlib - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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("/api/android/activate", methods=["POST"]) -def android_activate(): - db = get_db() - - payload = request.get_json(silent=True) or {} - raw_token = (payload.get("token") or "").strip() - device_uuid = (payload.get("device_uuid") or "").strip() - phone_label = (payload.get("phone_label") or "").strip() - - if not raw_token: - return jsonify({"ok": False, "error": "missing_token"}), 400 - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.id, - t.tenant_id, - t.device_id, - t.device_label, - t.status, - t.expires_at, - t.activated_at, - t.device_uuid, - d.device_name, - d.device_type, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.token_hash = %s - LIMIT 1 - """, - (token_hash,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "invalid_token"}), 404 - - if row["device_type"] != "android": - return jsonify({"ok": False, "error": "invalid_device_type"}), 400 - - if row["status"] == "revoked": - return jsonify({"ok": False, "error": "token_revoked"}), 403 - - if row["status"] == "activated": - if row.get("device_uuid") == device_uuid: - return jsonify({ - "ok": True, - "status": "already_activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": row["device_label"], - "relative_path": row["relative_path"], - }), 200 - return jsonify({"ok": False, "error": "token_already_used"}), 409 - - cur.execute("SELECT UTC_TIMESTAMP() AS now_utc") - now_row = cur.fetchone() - now_utc = now_row["now_utc"] - - if row["expires_at"] is not None and now_utc > row["expires_at"]: - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'expired' - WHERE id = %s - """, - (row["id"],), - ) - db.commit() - return jsonify({"ok": False, "error": "token_expired"}), 410 - - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'activated', - activated_at = UTC_TIMESTAMP(), - device_uuid = %s, - device_label = %s - WHERE id = %s - """, - ( - device_uuid, - phone_label or row["device_label"], - row["id"], - ), - ) - - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, file_id, job_id, ip_address, user_agent, event_detail - ) VALUES (%s, NULL, 'system', 'android_device_activated', NULL, NULL, %s, %s, %s) - """, - ( - row["tenant_id"], - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Activated android device '{row['device_name']}' with UUID '{device_uuid}'", - ), - ) - - db.commit() - - return jsonify({ - "ok": True, - "status": "activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": phone_label or row["device_label"], - "relative_path": row["relative_path"], - }), 200 - - -@bp.route("/devices/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - COUNT(*) AS total_file_count, - SUM(CASE WHEN is_deleted = 0 THEN 1 ELSE 0 END) AS active_file_count, - SUM(CASE WHEN is_deleted = 1 THEN 1 ELSE 0 END) AS deleted_file_count - FROM files - WHERE tenant_id = %s AND device_id = %s - """, - (session["otb_tenant_id"], device_id), - ) - counts = cur.fetchone() - - total_file_count = int(counts["total_file_count"] or 0) - active_file_count = int(counts["active_file_count"] or 0) - deleted_file_count = int(counts["deleted_file_count"] or 0) - - if total_file_count > 0: - if deleted_file_count > 0: - flash( - f"This device is not empty. It still has {active_file_count} active file(s) and {deleted_file_count} file(s) in Deleted Files. Please clear those before deleting the device.", - "warning", - ) - else: - flash( - f"This device is not empty. It still has {active_file_count} active file(s). Please remove them before deleting the device.", - "warning", - ) - return redirect(url_for("main.dashboard")) - - cur.execute( - """ - DELETE FROM android_device_tokens - WHERE tenant_id = %s AND device_id = %s - """, - (session["otb_tenant_id"], device_id), - ) - - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/main/routes.py.bak.androidupload.20260415-214446 b/app/main/routes.py.bak.androidupload.20260415-214446 deleted file mode 100644 index 1dabdbe..0000000 --- a/app/main/routes.py.bak.androidupload.20260415-214446 +++ /dev/null @@ -1,1529 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re -import hashlib - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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("/api/android/activate", methods=["POST"]) -def android_activate(): - db = get_db() - - payload = request.get_json(silent=True) or {} - raw_token = (payload.get("token") or "").strip() - device_uuid = (payload.get("device_uuid") or "").strip() - phone_label = (payload.get("phone_label") or "").strip() - - if not raw_token: - return jsonify({"ok": False, "error": "missing_token"}), 400 - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.id, - t.tenant_id, - t.device_id, - t.device_label, - t.status, - t.expires_at, - t.activated_at, - t.device_uuid, - d.device_name, - d.device_type, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.token_hash = %s - LIMIT 1 - """, - (token_hash,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "invalid_token"}), 404 - - if row["device_type"] != "android": - return jsonify({"ok": False, "error": "invalid_device_type"}), 400 - - if row["status"] == "revoked": - return jsonify({"ok": False, "error": "token_revoked"}), 403 - - if row["status"] == "activated": - if row.get("device_uuid") == device_uuid: - return jsonify({ - "ok": True, - "status": "already_activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": row["device_label"], - "relative_path": row["relative_path"], - }), 200 - return jsonify({"ok": False, "error": "token_already_used"}), 409 - - cur.execute("SELECT UTC_TIMESTAMP() AS now_utc") - now_row = cur.fetchone() - now_utc = now_row["now_utc"] - - if row["expires_at"] is not None and now_utc > row["expires_at"]: - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'expired' - WHERE id = %s - """, - (row["id"],), - ) - db.commit() - return jsonify({"ok": False, "error": "token_expired"}), 410 - - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'activated', - activated_at = UTC_TIMESTAMP(), - device_uuid = %s, - device_label = %s - WHERE id = %s - """, - ( - device_uuid, - phone_label or row["device_label"], - row["id"], - ), - ) - - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, file_id, job_id, ip_address, user_agent, event_detail - ) VALUES (%s, NULL, 'system', 'android_device_activated', NULL, NULL, %s, %s, %s) - """, - ( - row["tenant_id"], - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Activated android device '{row['device_name']}' with UUID '{device_uuid}'", - ), - ) - - db.commit() - - return jsonify({ - "ok": True, - "status": "activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": phone_label or row["device_label"], - "relative_path": row["relative_path"], - }), 200 - - -@bp.route("/api/android/upload", methods=["POST"]) -def android_upload(): - db = get_db() - - device_uuid = (request.form.get("device_uuid") or "").strip() - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.tenant_id, - t.device_id, - t.status, - d.device_name, - d.relative_path, - tn.slug AS tenant_slug - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - JOIN tenants tn ON tn.id = t.tenant_id - WHERE t.device_uuid = %s - LIMIT 1 - """, - (device_uuid,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "device_not_found"}), 404 - - if row["status"] != "activated": - return jsonify({"ok": False, "error": "device_not_activated"}), 403 - - files = request.files.getlist("files") - files = [f for f in files if f and f.filename] - - if not files: - return jsonify({"ok": False, "error": "no_files"}), 400 - - upload_base = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / row["tenant_slug"] / row["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) - - if "." in original_filename: - basename, extension = original_filename.rsplit(".", 1) - else: - basename, extension = original_filename, "" - - relative_path = f"{row['relative_path']}/originals/{stored_name}" - directory_path = f"{row['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 - ) - """, - ( - row["tenant_id"], - row["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, NULL, 'device', 'file_uploaded', %s, %s, %s, %s) - """, - ( - row["tenant_id"], - file_id, - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Android upload '{original_filename}' to {relative_path}", - ), - ) - - uploaded_count += 1 - - db.commit() - - return jsonify({ - "ok": True, - "uploaded": uploaded_count, - "device_name": row["device_name"] - }), 200 - - -@bp.route("/devices/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - COUNT(*) AS total_file_count, - SUM(CASE WHEN is_deleted = 0 THEN 1 ELSE 0 END) AS active_file_count, - SUM(CASE WHEN is_deleted = 1 THEN 1 ELSE 0 END) AS deleted_file_count - FROM files - WHERE tenant_id = %s AND device_id = %s - """, - (session["otb_tenant_id"], device_id), - ) - counts = cur.fetchone() - - total_file_count = int(counts["total_file_count"] or 0) - active_file_count = int(counts["active_file_count"] or 0) - deleted_file_count = int(counts["deleted_file_count"] or 0) - - if total_file_count > 0: - if deleted_file_count > 0: - flash( - f"This device is not empty. It still has {active_file_count} active file(s) and {deleted_file_count} file(s) in Deleted Files. Please clear those before deleting the device.", - "warning", - ) - else: - flash( - f"This device is not empty. It still has {active_file_count} active file(s). Please remove them before deleting the device.", - "warning", - ) - return redirect(url_for("main.dashboard")) - - cur.execute( - """ - DELETE FROM android_device_tokens - WHERE tenant_id = %s AND device_id = %s - """, - (session["otb_tenant_id"], device_id), - ) - - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/main/routes.py.bak.androidupload_manual.20260415-231918 b/app/main/routes.py.bak.androidupload_manual.20260415-231918 deleted file mode 100644 index a5c445c..0000000 --- a/app/main/routes.py.bak.androidupload_manual.20260415-231918 +++ /dev/null @@ -1,1582 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re -import hashlib - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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("/api/android/upload", methods=["POST"]) -def android_upload(): - db = get_db() - - device_uuid = (request.form.get("device_uuid") or "").strip() - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.tenant_id, - t.device_id, - t.status, - d.device_name, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.device_uuid = %s - LIMIT 1 - """, - (device_uuid,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "device_not_found"}), 404 - - if row["status"] != "activated": - return jsonify({"ok": False, "error": "device_not_activated"}), 403 - - files = request.files.getlist("files") - files = [f for f in files if f and f.filename] - - if not files: - return jsonify({"ok": False, "error": "no_files"}), 400 - - upload_base = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / str(row["tenant_id"]) / row["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) - - if "." in original_filename: - basename, extension = original_filename.rsplit(".", 1) - else: - basename, extension = original_filename, "" - - relative_path = f"{row['relative_path']}/originals/{stored_name}" - directory_path = f"{row['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 - ) - """, - ( - row["tenant_id"], - row["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, NULL, 'device', 'file_uploaded', %s, %s, %s, %s) - """, - ( - row["tenant_id"], - file_id, - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Android upload '{original_filename}' to {relative_path}", - ), - ) - - uploaded_count += 1 - - db.commit() - - return jsonify({ - "ok": True, - "uploaded": uploaded_count, - "device_name": row["device_name"] - }), 200 - - - db = get_db() - - payload = request.get_json(silent=True) or {} - raw_token = (payload.get("token") or "").strip() - device_uuid = (payload.get("device_uuid") or "").strip() - phone_label = (payload.get("phone_label") or "").strip() - - if not raw_token: - return jsonify({"ok": False, "error": "missing_token"}), 400 - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.id, - t.tenant_id, - t.device_id, - t.device_label, - t.status, - t.expires_at, - t.activated_at, - t.device_uuid, - d.device_name, - d.device_type, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.token_hash = %s - LIMIT 1 - """, - (token_hash,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "invalid_token"}), 404 - - if row["device_type"] != "android": - return jsonify({"ok": False, "error": "invalid_device_type"}), 400 - - if row["status"] == "revoked": - return jsonify({"ok": False, "error": "token_revoked"}), 403 - - if row["status"] == "activated": - if row.get("device_uuid") == device_uuid: - return jsonify({ - "ok": True, - "status": "already_activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": row["device_label"], - "relative_path": row["relative_path"], - }), 200 - return jsonify({"ok": False, "error": "token_already_used"}), 409 - - cur.execute("SELECT UTC_TIMESTAMP() AS now_utc") - now_row = cur.fetchone() - now_utc = now_row["now_utc"] - - if row["expires_at"] is not None and now_utc > row["expires_at"]: - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'expired' - WHERE id = %s - """, - (row["id"],), - ) - db.commit() - return jsonify({"ok": False, "error": "token_expired"}), 410 - - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'activated', - activated_at = UTC_TIMESTAMP(), - device_uuid = %s, - device_label = %s - WHERE id = %s - """, - ( - device_uuid, - phone_label or row["device_label"], - row["id"], - ), - ) - - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, file_id, job_id, ip_address, user_agent, event_detail - ) VALUES (%s, NULL, 'system', 'android_device_activated', NULL, NULL, %s, %s, %s) - """, - ( - row["tenant_id"], - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Activated android device '{row['device_name']}' with UUID '{device_uuid}'", - ), - ) - - db.commit() - - return jsonify({ - "ok": True, - "status": "activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": phone_label or row["device_label"], - "relative_path": row["relative_path"], - }), 200 - - -@bp.route("/devices/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - COUNT(*) AS total_file_count, - SUM(CASE WHEN is_deleted = 0 THEN 1 ELSE 0 END) AS active_file_count, - SUM(CASE WHEN is_deleted = 1 THEN 1 ELSE 0 END) AS deleted_file_count - FROM files - WHERE tenant_id = %s AND device_id = %s - """, - (session["otb_tenant_id"], device_id), - ) - counts = cur.fetchone() - - total_file_count = int(counts["total_file_count"] or 0) - active_file_count = int(counts["active_file_count"] or 0) - deleted_file_count = int(counts["deleted_file_count"] or 0) - - if total_file_count > 0: - if deleted_file_count > 0: - flash( - f"This device is not empty. It still has {active_file_count} active file(s) and {deleted_file_count} file(s) in Deleted Files. Please clear those before deleting the device.", - "warning", - ) - else: - flash( - f"This device is not empty. It still has {active_file_count} active file(s). Please remove them before deleting the device.", - "warning", - ) - return redirect(url_for("main.dashboard")) - - cur.execute( - """ - DELETE FROM android_device_tokens - WHERE tenant_id = %s AND device_id = %s - """, - (session["otb_tenant_id"], device_id), - ) - - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) -@bp.route("/api/android/activate", methods=["POST"]) -def android_activate(): - db = get_db() - - payload = request.get_json(silent=True) or {} - raw_token = (payload.get("token") or "").strip() - device_uuid = (payload.get("device_uuid") or "").strip() - phone_label = (payload.get("phone_label") or "").strip() - - if not raw_token: - return jsonify({"ok": False, "error": "missing_token"}), 400 - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - with db.cursor() as cur: - cur.execute( - """ - SELECT t.id, t.tenant_id, t.device_id, t.status, - t.expires_at, d.device_name, d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.token_hash = %s - LIMIT 1 - """, - (token_hash,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "invalid_token"}), 404 - - if row["status"] == "activated": - return jsonify({"ok": True, "status": "already_activated"}), 200 - - cur.execute( - """ - UPDATE android_device_tokens - SET status='activated', - activated_at=UTC_TIMESTAMP(), - device_uuid=%s - WHERE id=%s - """, - (device_uuid, row["id"]), - ) - - db.commit() - - return jsonify({ - "ok": True, - "device_id": row["device_id"], - "device_name": row["device_name"], - "relative_path": row["relative_path"] - }) diff --git a/app/main/routes.py.bak.androiduploadlock.20260414-015331 b/app/main/routes.py.bak.androiduploadlock.20260414-015331 deleted file mode 100644 index c0fdbc6..0000000 --- a/app/main/routes.py.bak.androiduploadlock.20260414-015331 +++ /dev/null @@ -1,1273 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/main/routes.py.bak.deletefix.20260414-031044 b/app/main/routes.py.bak.deletefix.20260414-031044 deleted file mode 100644 index d8bd96e..0000000 --- a/app/main/routes.py.bak.deletefix.20260414-031044 +++ /dev/null @@ -1,1400 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re -import hashlib - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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("/api/android/activate", methods=["POST"]) -def android_activate(): - db = get_db() - - payload = request.get_json(silent=True) or {} - raw_token = (payload.get("token") or "").strip() - device_uuid = (payload.get("device_uuid") or "").strip() - phone_label = (payload.get("phone_label") or "").strip() - - if not raw_token: - return jsonify({"ok": False, "error": "missing_token"}), 400 - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.id, - t.tenant_id, - t.device_id, - t.device_label, - t.status, - t.expires_at, - t.activated_at, - t.device_uuid, - d.device_name, - d.device_type, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.token_hash = %s - LIMIT 1 - """, - (token_hash,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "invalid_token"}), 404 - - if row["device_type"] != "android": - return jsonify({"ok": False, "error": "invalid_device_type"}), 400 - - if row["status"] == "revoked": - return jsonify({"ok": False, "error": "token_revoked"}), 403 - - if row["status"] == "activated": - if row.get("device_uuid") == device_uuid: - return jsonify({ - "ok": True, - "status": "already_activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": row["device_label"], - "relative_path": row["relative_path"], - }), 200 - return jsonify({"ok": False, "error": "token_already_used"}), 409 - - cur.execute("SELECT UTC_TIMESTAMP() AS now_utc") - now_row = cur.fetchone() - now_utc = now_row["now_utc"] - - if row["expires_at"] is not None and now_utc > row["expires_at"]: - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'expired' - WHERE id = %s - """, - (row["id"],), - ) - db.commit() - return jsonify({"ok": False, "error": "token_expired"}), 410 - - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'activated', - activated_at = UTC_TIMESTAMP(), - device_uuid = %s, - device_label = %s - WHERE id = %s - """, - ( - device_uuid, - phone_label or row["device_label"], - row["id"], - ), - ) - - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, file_id, job_id, ip_address, user_agent, event_detail - ) VALUES (%s, NULL, 'system', 'android_device_activated', NULL, NULL, %s, %s, %s) - """, - ( - row["tenant_id"], - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Activated android device '{row['device_name']}' with UUID '{device_uuid}'", - ), - ) - - db.commit() - - return jsonify({ - "ok": True, - "status": "activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": phone_label or row["device_label"], - "relative_path": row["relative_path"], - }), 200 - - -@bp.route("/devices/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/main/routes.py.bak.fix_android_route.20260414-004358 b/app/main/routes.py.bak.fix_android_route.20260414-004358 deleted file mode 100644 index ffadc8b..0000000 --- a/app/main/routes.py.bak.fix_android_route.20260414-004358 +++ /dev/null @@ -1,1269 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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 - -@bp.route("/devices/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/main/routes.py.bak.folder.20260413-190117 b/app/main/routes.py.bak.folder.20260413-190117 deleted file mode 100644 index ab08dce..0000000 --- a/app/main/routes.py.bak.folder.20260413-190117 +++ /dev/null @@ -1,1112 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id), - ) - files = cur.fetchall() - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - ) diff --git a/app/main/routes.py.bak.gallery.20260413-071415 b/app/main/routes.py.bak.gallery.20260413-071415 deleted file mode 100644 index 7c0452e..0000000 --- a/app/main/routes.py.bak.gallery.20260413-071415 +++ /dev/null @@ -1,998 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -import re - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - 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")) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id), - ) - files = cur.fetchall() - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - ) diff --git a/app/main/routes.py.bak.gracefuldelete.20260414-025910 b/app/main/routes.py.bak.gracefuldelete.20260414-025910 deleted file mode 100644 index d8bd96e..0000000 --- a/app/main/routes.py.bak.gracefuldelete.20260414-025910 +++ /dev/null @@ -1,1400 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re -import hashlib - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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("/api/android/activate", methods=["POST"]) -def android_activate(): - db = get_db() - - payload = request.get_json(silent=True) or {} - raw_token = (payload.get("token") or "").strip() - device_uuid = (payload.get("device_uuid") or "").strip() - phone_label = (payload.get("phone_label") or "").strip() - - if not raw_token: - return jsonify({"ok": False, "error": "missing_token"}), 400 - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.id, - t.tenant_id, - t.device_id, - t.device_label, - t.status, - t.expires_at, - t.activated_at, - t.device_uuid, - d.device_name, - d.device_type, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.token_hash = %s - LIMIT 1 - """, - (token_hash,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "invalid_token"}), 404 - - if row["device_type"] != "android": - return jsonify({"ok": False, "error": "invalid_device_type"}), 400 - - if row["status"] == "revoked": - return jsonify({"ok": False, "error": "token_revoked"}), 403 - - if row["status"] == "activated": - if row.get("device_uuid") == device_uuid: - return jsonify({ - "ok": True, - "status": "already_activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": row["device_label"], - "relative_path": row["relative_path"], - }), 200 - return jsonify({"ok": False, "error": "token_already_used"}), 409 - - cur.execute("SELECT UTC_TIMESTAMP() AS now_utc") - now_row = cur.fetchone() - now_utc = now_row["now_utc"] - - if row["expires_at"] is not None and now_utc > row["expires_at"]: - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'expired' - WHERE id = %s - """, - (row["id"],), - ) - db.commit() - return jsonify({"ok": False, "error": "token_expired"}), 410 - - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'activated', - activated_at = UTC_TIMESTAMP(), - device_uuid = %s, - device_label = %s - WHERE id = %s - """, - ( - device_uuid, - phone_label or row["device_label"], - row["id"], - ), - ) - - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, file_id, job_id, ip_address, user_agent, event_detail - ) VALUES (%s, NULL, 'system', 'android_device_activated', NULL, NULL, %s, %s, %s) - """, - ( - row["tenant_id"], - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Activated android device '{row['device_name']}' with UUID '{device_uuid}'", - ), - ) - - db.commit() - - return jsonify({ - "ok": True, - "status": "activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": phone_label or row["device_label"], - "relative_path": row["relative_path"], - }), 200 - - -@bp.route("/devices/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/main/routes.py.bak.gracefuldelete3.20260414-032125 b/app/main/routes.py.bak.gracefuldelete3.20260414-032125 deleted file mode 100644 index edbbec0..0000000 --- a/app/main/routes.py.bak.gracefuldelete3.20260414-032125 +++ /dev/null @@ -1,1386 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re -import hashlib - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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("/api/android/activate", methods=["POST"]) -def android_activate(): - db = get_db() - - payload = request.get_json(silent=True) or {} - raw_token = (payload.get("token") or "").strip() - device_uuid = (payload.get("device_uuid") or "").strip() - phone_label = (payload.get("phone_label") or "").strip() - - if not raw_token: - return jsonify({"ok": False, "error": "missing_token"}), 400 - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.id, - t.tenant_id, - t.device_id, - t.device_label, - t.status, - t.expires_at, - t.activated_at, - t.device_uuid, - d.device_name, - d.device_type, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.token_hash = %s - LIMIT 1 - """, - (token_hash,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "invalid_token"}), 404 - - if row["device_type"] != "android": - return jsonify({"ok": False, "error": "invalid_device_type"}), 400 - - if row["status"] == "revoked": - return jsonify({"ok": False, "error": "token_revoked"}), 403 - - if row["status"] == "activated": - if row.get("device_uuid") == device_uuid: - return jsonify({ - "ok": True, - "status": "already_activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": row["device_label"], - "relative_path": row["relative_path"], - }), 200 - return jsonify({"ok": False, "error": "token_already_used"}), 409 - - cur.execute("SELECT UTC_TIMESTAMP() AS now_utc") - now_row = cur.fetchone() - now_utc = now_row["now_utc"] - - if row["expires_at"] is not None and now_utc > row["expires_at"]: - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'expired' - WHERE id = %s - """, - (row["id"],), - ) - db.commit() - return jsonify({"ok": False, "error": "token_expired"}), 410 - - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'activated', - activated_at = UTC_TIMESTAMP(), - device_uuid = %s, - device_label = %s - WHERE id = %s - """, - ( - device_uuid, - phone_label or row["device_label"], - row["id"], - ), - ) - - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, file_id, job_id, ip_address, user_agent, event_detail - ) VALUES (%s, NULL, 'system', 'android_device_activated', NULL, NULL, %s, %s, %s) - """, - ( - row["tenant_id"], - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Activated android device '{row['device_name']}' with UUID '{device_uuid}'", - ), - ) - - db.commit() - - return jsonify({ - "ok": True, - "status": "activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": phone_label or row["device_label"], - "relative_path": row["relative_path"], - }), 200 - - -@bp.route("/devices/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/main/routes.py.bak.gracefuldelete4.20260414-032525 b/app/main/routes.py.bak.gracefuldelete4.20260414-032525 deleted file mode 100644 index edbbec0..0000000 --- a/app/main/routes.py.bak.gracefuldelete4.20260414-032525 +++ /dev/null @@ -1,1386 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re -import hashlib - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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("/api/android/activate", methods=["POST"]) -def android_activate(): - db = get_db() - - payload = request.get_json(silent=True) or {} - raw_token = (payload.get("token") or "").strip() - device_uuid = (payload.get("device_uuid") or "").strip() - phone_label = (payload.get("phone_label") or "").strip() - - if not raw_token: - return jsonify({"ok": False, "error": "missing_token"}), 400 - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.id, - t.tenant_id, - t.device_id, - t.device_label, - t.status, - t.expires_at, - t.activated_at, - t.device_uuid, - d.device_name, - d.device_type, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.token_hash = %s - LIMIT 1 - """, - (token_hash,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "invalid_token"}), 404 - - if row["device_type"] != "android": - return jsonify({"ok": False, "error": "invalid_device_type"}), 400 - - if row["status"] == "revoked": - return jsonify({"ok": False, "error": "token_revoked"}), 403 - - if row["status"] == "activated": - if row.get("device_uuid") == device_uuid: - return jsonify({ - "ok": True, - "status": "already_activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": row["device_label"], - "relative_path": row["relative_path"], - }), 200 - return jsonify({"ok": False, "error": "token_already_used"}), 409 - - cur.execute("SELECT UTC_TIMESTAMP() AS now_utc") - now_row = cur.fetchone() - now_utc = now_row["now_utc"] - - if row["expires_at"] is not None and now_utc > row["expires_at"]: - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'expired' - WHERE id = %s - """, - (row["id"],), - ) - db.commit() - return jsonify({"ok": False, "error": "token_expired"}), 410 - - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'activated', - activated_at = UTC_TIMESTAMP(), - device_uuid = %s, - device_label = %s - WHERE id = %s - """, - ( - device_uuid, - phone_label or row["device_label"], - row["id"], - ), - ) - - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, file_id, job_id, ip_address, user_agent, event_detail - ) VALUES (%s, NULL, 'system', 'android_device_activated', NULL, NULL, %s, %s, %s) - """, - ( - row["tenant_id"], - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Activated android device '{row['device_name']}' with UUID '{device_uuid}'", - ), - ) - - db.commit() - - return jsonify({ - "ok": True, - "status": "activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": phone_label or row["device_label"], - "relative_path": row["relative_path"], - }), 200 - - -@bp.route("/devices/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/main/routes.py.bak.gracefuldelete4.20260415-065022 b/app/main/routes.py.bak.gracefuldelete4.20260415-065022 deleted file mode 100644 index 937a526..0000000 --- a/app/main/routes.py.bak.gracefuldelete4.20260415-065022 +++ /dev/null @@ -1,1529 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re -import hashlib - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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("/api/android/activate", methods=["POST"]) -def android_activate(): - -@bp.route("/api/android/upload", methods=["POST"]) -def android_upload(): - db = get_db() - - device_uuid = (request.form.get("device_uuid") or "").strip() - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.tenant_id, - t.device_id, - t.status, - d.device_name, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.device_uuid = %s - LIMIT 1 - """, - (device_uuid,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "device_not_found"}), 404 - - if row["status"] != "activated": - return jsonify({"ok": False, "error": "device_not_activated"}), 403 - - files = request.files.getlist("files") - files = [f for f in files if f and f.filename] - - if not files: - return jsonify({"ok": False, "error": "no_files"}), 400 - - upload_base = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / str(row["tenant_id"]) / row["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) - - if "." in original_filename: - basename, extension = original_filename.rsplit(".", 1) - else: - basename, extension = original_filename, "" - - relative_path = f"{row['relative_path']}/originals/{stored_name}" - directory_path = f"{row['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 - ) - """, - ( - row["tenant_id"], - row["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, NULL, 'device', 'file_uploaded', %s, %s, %s, %s) - """, - ( - row["tenant_id"], - file_id, - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Android upload '{original_filename}' to {relative_path}", - ), - ) - - uploaded_count += 1 - - db.commit() - - return jsonify({ - "ok": True, - "uploaded": uploaded_count, - "device_name": row["device_name"] - }), 200 - - - db = get_db() - - payload = request.get_json(silent=True) or {} - raw_token = (payload.get("token") or "").strip() - device_uuid = (payload.get("device_uuid") or "").strip() - phone_label = (payload.get("phone_label") or "").strip() - - if not raw_token: - return jsonify({"ok": False, "error": "missing_token"}), 400 - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.id, - t.tenant_id, - t.device_id, - t.device_label, - t.status, - t.expires_at, - t.activated_at, - t.device_uuid, - d.device_name, - d.device_type, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.token_hash = %s - LIMIT 1 - """, - (token_hash,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "invalid_token"}), 404 - - if row["device_type"] != "android": - return jsonify({"ok": False, "error": "invalid_device_type"}), 400 - - if row["status"] == "revoked": - return jsonify({"ok": False, "error": "token_revoked"}), 403 - - if row["status"] == "activated": - if row.get("device_uuid") == device_uuid: - return jsonify({ - "ok": True, - "status": "already_activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": row["device_label"], - "relative_path": row["relative_path"], - }), 200 - return jsonify({"ok": False, "error": "token_already_used"}), 409 - - cur.execute("SELECT UTC_TIMESTAMP() AS now_utc") - now_row = cur.fetchone() - now_utc = now_row["now_utc"] - - if row["expires_at"] is not None and now_utc > row["expires_at"]: - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'expired' - WHERE id = %s - """, - (row["id"],), - ) - db.commit() - return jsonify({"ok": False, "error": "token_expired"}), 410 - - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'activated', - activated_at = UTC_TIMESTAMP(), - device_uuid = %s, - device_label = %s - WHERE id = %s - """, - ( - device_uuid, - phone_label or row["device_label"], - row["id"], - ), - ) - - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, file_id, job_id, ip_address, user_agent, event_detail - ) VALUES (%s, NULL, 'system', 'android_device_activated', NULL, NULL, %s, %s, %s) - """, - ( - row["tenant_id"], - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Activated android device '{row['device_name']}' with UUID '{device_uuid}'", - ), - ) - - db.commit() - - return jsonify({ - "ok": True, - "status": "activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": phone_label or row["device_label"], - "relative_path": row["relative_path"], - }), 200 - - -@bp.route("/devices/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - COUNT(*) AS total_file_count, - SUM(CASE WHEN is_deleted = 0 THEN 1 ELSE 0 END) AS active_file_count, - SUM(CASE WHEN is_deleted = 1 THEN 1 ELSE 0 END) AS deleted_file_count - FROM files - WHERE tenant_id = %s AND device_id = %s - """, - (session["otb_tenant_id"], device_id), - ) - counts = cur.fetchone() - - total_file_count = int(counts["total_file_count"] or 0) - active_file_count = int(counts["active_file_count"] or 0) - deleted_file_count = int(counts["deleted_file_count"] or 0) - - if total_file_count > 0: - if deleted_file_count > 0: - flash( - f"This device is not empty. It still has {active_file_count} active file(s) and {deleted_file_count} file(s) in Deleted Files. Please clear those before deleting the device.", - "warning", - ) - else: - flash( - f"This device is not empty. It still has {active_file_count} active file(s). Please remove them before deleting the device.", - "warning", - ) - return redirect(url_for("main.dashboard")) - - cur.execute( - """ - DELETE FROM android_device_tokens - WHERE tenant_id = %s AND device_id = %s - """, - (session["otb_tenant_id"], device_id), - ) - - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/main/routes.py.bak.rename.20260413-064038 b/app/main/routes.py.bak.rename.20260413-064038 deleted file mode 100644 index 2072d5f..0000000 --- a/app/main/routes.py.bak.rename.20260413-064038 +++ /dev/null @@ -1,894 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - 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")) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=file_row["original_filename"]) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(file_row["original_filename"]) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{file_row['original_filename']}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - zf.write(p, arcname=p.name) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id), - ) - files = cur.fetchall() - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - ) diff --git a/app/main/routes.py.bak.rename.20260413-064842 b/app/main/routes.py.bak.rename.20260413-064842 deleted file mode 100644 index 2072d5f..0000000 --- a/app/main/routes.py.bak.rename.20260413-064842 +++ /dev/null @@ -1,894 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - 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")) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=file_row["original_filename"]) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(file_row["original_filename"]) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{file_row['original_filename']}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - zf.write(p, arcname=p.name) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id), - ) - files = cur.fetchall() - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - ) diff --git a/app/main/routes.py.bak.rename.20260413-065605 b/app/main/routes.py.bak.rename.20260413-065605 deleted file mode 100644 index 2072d5f..0000000 --- a/app/main/routes.py.bak.rename.20260413-065605 +++ /dev/null @@ -1,894 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - 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")) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=file_row["original_filename"]) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(file_row["original_filename"]) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{file_row['original_filename']}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - zf.write(p, arcname=p.name) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id), - ) - files = cur.fetchall() - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - ) diff --git a/app/main/routes.py.bak.thumb.20260413-073152 b/app/main/routes.py.bak.thumb.20260413-073152 deleted file mode 100644 index b230f4d..0000000 --- a/app/main/routes.py.bak.thumb.20260413-073152 +++ /dev/null @@ -1,1068 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -import re - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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")) - - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id), - ) - files = cur.fetchall() - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - ) diff --git a/app/main/routes.py.bak.tree.20260413-200734 b/app/main/routes.py.bak.tree.20260413-200734 deleted file mode 100644 index 8f28b2d..0000000 --- a/app/main/routes.py.bak.tree.20260413-200734 +++ /dev/null @@ -1,1134 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id), - ) - files = cur.fetchall() - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - ) diff --git a/app/main/routes.py.bak.viewmode.1776064865 b/app/main/routes.py.bak.viewmode.1776064865 deleted file mode 100644 index 114bb9c..0000000 --- a/app/main/routes.py.bak.viewmode.1776064865 +++ /dev/null @@ -1,1038 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -import re - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - with db.cursor() as cur: - 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() - device = _get_device_for_tenant(db, device_id) - - 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")) - - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id), - ) - files = cur.fetchall() - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - ) diff --git a/app/main/routes.py.broken.1776293208 b/app/main/routes.py.broken.1776293208 deleted file mode 100644 index 937a526..0000000 --- a/app/main/routes.py.broken.1776293208 +++ /dev/null @@ -1,1529 +0,0 @@ -from functools import wraps -from pathlib import Path -from datetime import datetime, timezone -import shutil -import zipfile -from PIL import Image -import re -import hashlib - -from werkzeug.utils import secure_filename -from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify - -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}" - - -def _recovered_filename(original_name: str) -> tuple[str, str, str]: - base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - if "." in base_name: - basename, extension = base_name.rsplit(".", 1) - recovered_name = f"{basename}-recovered.{extension}" - return recovered_name, f"{basename}-recovered", extension - recovered_name = f"{base_name}-recovered" - return recovered_name, f"{base_name}-recovered", "" - -def _display_filename(file_row) -> str: - if not file_row: - return "download.bin" - return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() - - -def _sanitize_display_basename(raw_name: str) -> str: - cleaned = (raw_name or "").replace("\x00", "").strip() - cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned) - cleaned = cleaned.strip().strip(".") - if "." in cleaned: - cleaned = cleaned.rsplit(".", 1)[0].strip() - return cleaned[:200] - - - -def _generate_thumbnail(original_path: Path, thumb_path: Path): - thumb_path.parent.mkdir(parents=True, exist_ok=True) - try: - with Image.open(original_path) as img: - img.thumbnail((400, 400)) - img.save(thumb_path) - except Exception: - pass - -def _normalize_browser_path(raw_path: str) -> str: - raw = (raw_path or "").replace("\\", "/").strip().strip("/") - if not raw: - return "" - parts = [] - for part in raw.split("/"): - part = part.strip() - if not part or part in (".", ".."): - continue - parts.append(part) - return "/".join(parts) - - -def _safe_path_from_relative(relative_path: str) -> Path: - return _tenant_root() / relative_path - -def _get_device_for_tenant(db, device_id: int): - 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"]), - ) - return cur.fetchone() - -def _purge_expired_deleted_files(db): - expired = [] - with db.cursor() as cur: - cur.execute( - """ - SELECT id, relative_path, original_filename - FROM files - WHERE tenant_id = %s - AND is_deleted = 1 - AND deleted_at IS NOT NULL - AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) - """, - (session["otb_tenant_id"],), - ) - expired = cur.fetchall() - - for row in expired: - file_path = _safe_path_from_relative(row["relative_path"]) - if file_path.exists(): - try: - file_path.unlink() - except FileNotFoundError: - pass - - 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, 'system', 'deleted_file_purged', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - row["id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Purged deleted file '{row['original_filename']}' after retention window", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) - - db.commit() - -@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("/api/android/activate", methods=["POST"]) -def android_activate(): - -@bp.route("/api/android/upload", methods=["POST"]) -def android_upload(): - db = get_db() - - device_uuid = (request.form.get("device_uuid") or "").strip() - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.tenant_id, - t.device_id, - t.status, - d.device_name, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.device_uuid = %s - LIMIT 1 - """, - (device_uuid,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "device_not_found"}), 404 - - if row["status"] != "activated": - return jsonify({"ok": False, "error": "device_not_activated"}), 403 - - files = request.files.getlist("files") - files = [f for f in files if f and f.filename] - - if not files: - return jsonify({"ok": False, "error": "no_files"}), 400 - - upload_base = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / str(row["tenant_id"]) / row["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) - - if "." in original_filename: - basename, extension = original_filename.rsplit(".", 1) - else: - basename, extension = original_filename, "" - - relative_path = f"{row['relative_path']}/originals/{stored_name}" - directory_path = f"{row['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 - ) - """, - ( - row["tenant_id"], - row["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, NULL, 'device', 'file_uploaded', %s, %s, %s, %s) - """, - ( - row["tenant_id"], - file_id, - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Android upload '{original_filename}' to {relative_path}", - ), - ) - - uploaded_count += 1 - - db.commit() - - return jsonify({ - "ok": True, - "uploaded": uploaded_count, - "device_name": row["device_name"] - }), 200 - - - db = get_db() - - payload = request.get_json(silent=True) or {} - raw_token = (payload.get("token") or "").strip() - device_uuid = (payload.get("device_uuid") or "").strip() - phone_label = (payload.get("phone_label") or "").strip() - - if not raw_token: - return jsonify({"ok": False, "error": "missing_token"}), 400 - - if not device_uuid: - return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 - - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - t.id, - t.tenant_id, - t.device_id, - t.device_label, - t.status, - t.expires_at, - t.activated_at, - t.device_uuid, - d.device_name, - d.device_type, - d.relative_path - FROM android_device_tokens t - JOIN devices d ON d.id = t.device_id - WHERE t.token_hash = %s - LIMIT 1 - """, - (token_hash,), - ) - row = cur.fetchone() - - if not row: - return jsonify({"ok": False, "error": "invalid_token"}), 404 - - if row["device_type"] != "android": - return jsonify({"ok": False, "error": "invalid_device_type"}), 400 - - if row["status"] == "revoked": - return jsonify({"ok": False, "error": "token_revoked"}), 403 - - if row["status"] == "activated": - if row.get("device_uuid") == device_uuid: - return jsonify({ - "ok": True, - "status": "already_activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": row["device_label"], - "relative_path": row["relative_path"], - }), 200 - return jsonify({"ok": False, "error": "token_already_used"}), 409 - - cur.execute("SELECT UTC_TIMESTAMP() AS now_utc") - now_row = cur.fetchone() - now_utc = now_row["now_utc"] - - if row["expires_at"] is not None and now_utc > row["expires_at"]: - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'expired' - WHERE id = %s - """, - (row["id"],), - ) - db.commit() - return jsonify({"ok": False, "error": "token_expired"}), 410 - - cur.execute( - """ - UPDATE android_device_tokens - SET status = 'activated', - activated_at = UTC_TIMESTAMP(), - device_uuid = %s, - device_label = %s - WHERE id = %s - """, - ( - device_uuid, - phone_label or row["device_label"], - row["id"], - ), - ) - - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, file_id, job_id, ip_address, user_agent, event_detail - ) VALUES (%s, NULL, 'system', 'android_device_activated', NULL, NULL, %s, %s, %s) - """, - ( - row["tenant_id"], - request.headers.get("X-Forwarded-For", request.remote_addr), - request.headers.get("User-Agent", ""), - f"Activated android device '{row['device_name']}' with UUID '{device_uuid}'", - ), - ) - - db.commit() - - return jsonify({ - "ok": True, - "status": "activated", - "device_id": row["device_id"], - "device_name": row["device_name"], - "device_label": phone_label or row["device_label"], - "relative_path": row["relative_path"], - }), 200 - - -@bp.route("/devices/android/new", methods=["GET", "POST"]) -@portal_session_required -def create_android_device(): - db = get_db() - - if request.method == "GET": - return render_template( - "cloud/android_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() - - if not device_name: - flash("Device name is required.", "warning") - return redirect(url_for("main.create_android_device")) - - slug = slugify_device_name(device_name) - relative_path = f"devices/{slug}" - - import hashlib, secrets, datetime - - raw_token = secrets.token_hex(16) - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - - expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48) - - with db.cursor() as cur: - # create device bucket - cur.execute( - """ - INSERT INTO devices (tenant_id, device_name, device_type, relative_path) - VALUES (%s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_name, "android", relative_path), - ) - device_id = cur.lastrowid - - # create token - cur.execute( - """ - INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at) - VALUES (%s, %s, %s, %s, %s) - """, - (session["otb_tenant_id"], device_id, token_hash, device_name, expires_at), - ) - - db.commit() - - flash(f"Activation token (valid 48h): {raw_token}", "success") - flash("⚠️ This token must be used within 48 hours or it will expire.", "warning") - - return redirect(url_for("main.dashboard")) - - -@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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - COUNT(*) AS total_file_count, - SUM(CASE WHEN is_deleted = 0 THEN 1 ELSE 0 END) AS active_file_count, - SUM(CASE WHEN is_deleted = 1 THEN 1 ELSE 0 END) AS deleted_file_count - FROM files - WHERE tenant_id = %s AND device_id = %s - """, - (session["otb_tenant_id"], device_id), - ) - counts = cur.fetchone() - - total_file_count = int(counts["total_file_count"] or 0) - active_file_count = int(counts["active_file_count"] or 0) - deleted_file_count = int(counts["deleted_file_count"] or 0) - - if total_file_count > 0: - if deleted_file_count > 0: - flash( - f"This device is not empty. It still has {active_file_count} active file(s) and {deleted_file_count} file(s) in Deleted Files. Please clear those before deleting the device.", - "warning", - ) - else: - flash( - f"This device is not empty. It still has {active_file_count} active file(s). Please remove them before deleting the device.", - "warning", - ) - return redirect(url_for("main.dashboard")) - - cur.execute( - """ - DELETE FROM android_device_tokens - WHERE tenant_id = %s AND device_id = %s - """, - (session["otb_tenant_id"], device_id), - ) - - 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() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - if device.get("device_type") == "android": - flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - 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" - - # preserve folder structure from browser - relative_upload_path = original_filename.replace("\\", "/") - path_parts = relative_upload_path.split("/") - - if len(path_parts) > 1: - subdirs = "/".join(path_parts[:-1]) - filename_only = path_parts[-1] - else: - subdirs = "" - filename_only = path_parts[0] - - stored_name = _stored_name(filename_only) - - target_dir = upload_base - if subdirs: - target_dir = upload_base / subdirs - - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / 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, "" - - if subdirs: - relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}" - directory_path = f"{device['relative_path']}/originals/{subdirs}" - else: - 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")) - - - - -@bp.route("/files//thumb", methods=["GET"]) -@portal_session_required -def thumbnail_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - return "", 404 - - original_path = _safe_path_from_relative(file_row["relative_path"]) - thumb_rel = file_row["relative_path"].replace("originals/", "derived/thumbs/") - thumb_path = _safe_path_from_relative(thumb_rel) - - if not thumb_path.exists(): - _generate_thumbnail(original_path, thumb_path) - - if thumb_path.exists(): - return send_file(thumb_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - return send_file(original_path, mimetype=file_row.get("mime_type"), as_attachment=False) - - -@bp.route("/files//inline", methods=["GET"]) -@portal_session_required -def inline_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, mime_type - FROM files - WHERE id = %s AND tenant_id = %s AND is_deleted = 0 - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file( - file_path, - mimetype=file_row.get("mime_type") or None, - as_attachment=False, - download_name=_display_filename(file_row), - conditional=True, - ) - -@bp.route("/files//download", methods=["GET"]) -@portal_session_required -def download_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if not file_path.exists(): - flash("File is missing from storage.", "warning") - return redirect(url_for("main.dashboard")) - - return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row)) - -@bp.route("/devices//files/download-selected", methods=["POST"]) -@portal_session_required -def download_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - if len(selected_ids) != 1: - flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - return redirect(url_for("main.download_file", file_id=selected_ids[0])) - -@bp.route("/devices//files/delete-selected", methods=["POST"]) -@portal_session_required -def delete_selected_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - deleted_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, relative_path, directory_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - target_rel = f"{device['relative_path']}/deleted/{_stored_name(file_row['original_filename'])}" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - if source_path.exists(): - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET relative_path = %s, - directory_path = %s, - is_deleted = 1, - deleted_at = UTC_TIMESTAMP() - WHERE id = %s AND tenant_id = %s - """, - ( - target_rel, - f"{device['relative_path']}/deleted", - file_id, - session["otb_tenant_id"], - ), - ) - - 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_soft_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Soft-deleted '{file_row['original_filename']}' into deleted area", - ), - ) - - deleted_count += 1 - - db.commit() - - flash(f"Deleted {deleted_count} file(s). Deleted files are retained for up to 24 hours unless hard-deleted.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/devices//files/send-to-zip", methods=["POST"]) -@portal_session_required -def send_selected_to_zip_workspace(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - return redirect(url_for("main.dashboard")) - - selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] - - if not selected_ids: - flash("Select at least one file first.", "warning") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - - staging_dir = _tenant_root() / "zip_staging" - staging_dir.mkdir(parents=True, exist_ok=True) - - copied_count = 0 - - with db.cursor() as cur: - for file_id in selected_ids: - cur.execute( - """ - SELECT id, original_filename, display_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s AND device_id = %s - """, - (file_id, session["otb_tenant_id"], device_id), - ) - file_row = cur.fetchone() - - if not file_row or file_row["is_deleted"]: - continue - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - continue - - target_name = _stored_name(_display_filename(file_row)) - target_path = staging_dir / target_name - shutil.copy2(source_path, target_path) - - 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_staged_for_zip', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Copied '{_display_filename(file_row)}' into zip workspace", - ), - ) - - copied_count += 1 - - db.commit() - - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") - return redirect(url_for("main.browse_device_files", device_id=device_id)) - -@bp.route("/workspace/zip", methods=["GET"]) -@portal_session_required -def zip_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()): - if p.is_file(): - staged_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - export_files = [] - for p in sorted(exports_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): - if p.is_file(): - export_files.append({ - "name": p.name, - "size_bytes": p.stat().st_size, - "path": str(p.relative_to(tenant_root)), - }) - - return render_template( - "cloud/zip_workspace.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - staged_files=staged_files, - export_files=export_files, - ) - -@bp.route("/workspace/zip/create", methods=["POST"]) -@portal_session_required -def create_zip_from_workspace(): - tenant_root = _tenant_root() - staging_dir = tenant_root / "zip_staging" - exports_dir = tenant_root / "exports" - staging_dir.mkdir(parents=True, exist_ok=True) - exports_dir.mkdir(parents=True, exist_ok=True) - - staged = [p for p in staging_dir.iterdir() if p.is_file()] - if not staged: - flash("Zip Workspace is empty.", "warning") - return redirect(url_for("main.zip_workspace")) - - zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip" - zip_path = exports_dir / zip_name - - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in staged: - arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name - zf.write(p, arcname=arcname) - - for p in staged: - p.unlink(missing_ok=True) - - db = get_db() - with db.cursor() as cur: - cur.execute( - """ - INSERT INTO audit_logs ( - tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail - ) VALUES (%s, %s, 'user', 'zip_created', %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - _client_ip(), - request.headers.get("User-Agent", ""), - f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success", - ), - ) - db.commit() - - flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success") - return redirect(url_for("main.zip_workspace")) - -@bp.route("/workspace/exports//download", methods=["GET"]) -@portal_session_required -def download_export(filename: str): - exports_dir = _tenant_root() / "exports" - file_path = exports_dir / filename - - if not file_path.exists() or not file_path.is_file(): - flash("Export file not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - return send_file(file_path, as_attachment=True, download_name=file_path.name) - -@bp.route("/deleted", methods=["GET"]) -@portal_session_required -def deleted_files(): - db = get_db() - _purge_expired_deleted_files(db) - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.original_filename, - f.relative_path, - f.size_bytes, - f.deleted_at, - d.device_name, - d.device_type - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.tenant_id = %s - AND f.is_deleted = 1 - ORDER BY f.deleted_at DESC, f.id DESC - """, - (session["otb_tenant_id"],), - ) - files = cur.fetchall() - - return render_template( - "cloud/deleted_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - files=files, - ) - - - -@bp.route("/deleted//recover", methods=["POST"]) -@portal_session_required -def recover_deleted_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT - f.id, - f.device_id, - f.original_filename, - f.relative_path, - f.is_deleted, - d.device_name, - d.device_type, - d.relative_path AS device_relative_path - FROM files f - LEFT JOIN devices d ON f.device_id = d.id - WHERE f.id = %s - AND f.tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - if not file_row["device_relative_path"]: - flash("Cannot recover this file because its device record is missing.", "warning") - return redirect(url_for("main.deleted_files")) - - source_path = _safe_path_from_relative(file_row["relative_path"]) - if not source_path.exists(): - flash("Deleted file is missing from storage.", "warning") - return redirect(url_for("main.deleted_files")) - - recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) - stored_name = _stored_name(recovered_name) - - target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" - target_dir = f"{file_row['device_relative_path']}/originals" - target_path = _safe_path_from_relative(target_rel) - target_path.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source_path), str(target_path)) - - cur.execute( - """ - UPDATE files - SET original_filename = %s, - display_filename = NULL, - basename = %s, - extension = %s, - relative_path = %s, - directory_path = %s, - is_deleted = 0, - deleted_at = NULL - WHERE id = %s AND tenant_id = %s - """, - ( - recovered_name, - recovered_basename, - recovered_extension, - target_rel, - target_dir, - file_id, - session["otb_tenant_id"], - ), - ) - - 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_recovered', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Recovered deleted file as '{recovered_name}' back into originals", - ), - ) - - db.commit() - - flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") - return redirect(url_for("main.deleted_files")) - - -@bp.route("/deleted//hard-delete", methods=["POST"]) -@portal_session_required -def hard_delete_file(file_id: int): - db = get_db() - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, original_filename, relative_path, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row or not file_row["is_deleted"]: - flash("Deleted file not found.", "warning") - return redirect(url_for("main.deleted_files")) - - file_path = _safe_path_from_relative(file_row["relative_path"]) - if file_path.exists(): - file_path.unlink(missing_ok=True) - - 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_hard_deleted', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Hard-deleted '{file_row['original_filename']}' immediately from deleted area", - ), - ) - - cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (file_id, session["otb_tenant_id"])) - - db.commit() - - flash("File permanently deleted.", "success") - return redirect(url_for("main.deleted_files")) - - - -@bp.route("/files//rename", methods=["POST"]) -@portal_session_required -def rename_file(file_id: int): - db = get_db() - requested_basename = _sanitize_display_basename(request.form.get("display_basename") or "") - - with db.cursor() as cur: - cur.execute( - """ - SELECT id, device_id, original_filename, display_filename, extension, is_deleted - FROM files - WHERE id = %s AND tenant_id = %s - """, - (file_id, session["otb_tenant_id"]), - ) - file_row = cur.fetchone() - - if not file_row: - flash("File not found.", "warning") - return redirect(url_for("main.dashboard")) - - if file_row["is_deleted"]: - flash("You can only rename active files from the device file browser.", "warning") - return redirect(url_for("main.deleted_files")) - - if not requested_basename: - flash("Please enter a new file name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - new_visible_name = ( - f"{requested_basename}.{file_row['extension']}" - if file_row["extension"] - else requested_basename - ) - - if len(new_visible_name) > 255: - flash("That file name is too long.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - current_visible_name = _display_filename(file_row) - if new_visible_name == current_visible_name: - flash("That file already has that name.", "warning") - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - - display_filename = None if new_visible_name == file_row["original_filename"] else new_visible_name - - cur.execute( - """ - UPDATE files - SET display_filename = %s - WHERE id = %s AND tenant_id = %s - """, - (display_filename, file_id, session["otb_tenant_id"]), - ) - - final_visible_name = display_filename or file_row["original_filename"] - - 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_display_renamed', %s, %s, %s, %s) - """, - ( - session["otb_tenant_id"], - session["otb_user_id"], - file_id, - _client_ip(), - request.headers.get("User-Agent", ""), - f"Updated display filename from '{current_visible_name}' to '{final_visible_name}'", - ), - ) - - db.commit() - - if display_filename is None: - flash("Custom name cleared. The original file name is now shown again.", "success") - else: - flash(f"File renamed to '{display_filename}'.", "success") - - return redirect(url_for("main.browse_device_files", device_id=file_row["device_id"])) - -@bp.route("/devices//files", methods=["GET"]) -@portal_session_required -def browse_device_files(device_id: int): - db = get_db() - device = _get_device_for_tenant(db, device_id) - - if not device: - flash("Device not found.", "warning") - return redirect(url_for("main.dashboard")) - - view_mode = request.args.get("view", "list").strip().lower() - if view_mode not in ("list", "gallery"): - view_mode = "list" - - current_path = _normalize_browser_path(request.args.get("path", "")) - root_directory = f"{device['relative_path']}/originals" - current_directory = f"{root_directory}/{current_path}" if current_path else root_directory - - with db.cursor() as cur: - cur.execute( - """ - SELECT - id, - file_kind, - relative_path, - directory_path, - original_filename, - display_filename, - basename, - extension, - mime_type, - size_bytes, - sha256, - uploaded_at, - is_immutable - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path = %s - ORDER BY uploaded_at DESC, id DESC - """, - (session["otb_tenant_id"], device_id, current_directory), - ) - files = cur.fetchall() - - cur.execute( - """ - SELECT DISTINCT directory_path - FROM files - WHERE tenant_id = %s - AND device_id = %s - AND is_deleted = 0 - AND directory_path LIKE %s - ORDER BY directory_path - """, - (session["otb_tenant_id"], device_id, f"{current_directory}/%"), - ) - all_dirs = [row["directory_path"] for row in cur.fetchall()] - - folder_names = [] - prefix = current_directory + "/" - for d in all_dirs: - remainder = d[len(prefix):] if d.startswith(prefix) else "" - if not remainder: - continue - first_segment = remainder.split("/", 1)[0] - if first_segment and first_segment not in folder_names: - folder_names.append(first_segment) - - folders = [] - for name in sorted(folder_names, key=lambda x: x.lower()): - folder_path = f"{current_path}/{name}" if current_path else name - folders.append( - { - "name": name, - "path": folder_path, - } - ) - - breadcrumbs = [ - { - "label": device["device_name"], - "path": "", - } - ] - - if current_path: - accum = [] - for segment in current_path.split("/"): - accum.append(segment) - breadcrumbs.append( - { - "label": segment, - "path": "/".join(accum), - } - ) - - parent_path = "" - if current_path: - parts = current_path.split("/") - parent_path = "/".join(parts[:-1]) - - return render_template( - "cloud/device_files.html", - user_email=session.get("otb_email"), - tenant_slug=session.get("otb_tenant_slug"), - device=device, - files=files, - file_count=len(files), - view_mode=view_mode, - current_path=current_path, - parent_path=parent_path, - folders=folders, - breadcrumbs=breadcrumbs, - ) diff --git a/app/models/schema.sql.bak.android.20260414-002002 b/app/models/schema.sql.bak.android.20260414-002002 deleted file mode 100644 index b52e9e3..0000000 --- a/app/models/schema.sql.bak.android.20260414-002002 +++ /dev/null @@ -1,104 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - id INT AUTO_INCREMENT PRIMARY KEY, - portal_user_id INT NULL, - email VARCHAR(255) NOT NULL UNIQUE, - display_name VARCHAR(255) NULL, - is_active TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_login_at DATETIME NULL -); - -CREATE TABLE IF NOT EXISTS tenants ( - id INT AUTO_INCREMENT PRIMARY KEY, - owner_user_id INT NOT NULL, - slug VARCHAR(100) NOT NULL UNIQUE, - storage_root VARCHAR(500) NOT NULL, - service_status VARCHAR(50) NOT NULL DEFAULT 'active', - retention_mode VARCHAR(50) NOT NULL DEFAULT 'standard', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_tenants_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) -); - -CREATE TABLE IF NOT EXISTS devices ( - id INT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - device_name VARCHAR(100) NOT NULL, - device_type VARCHAR(50) NOT NULL, - relative_path VARCHAR(255) NOT NULL, - is_active TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT uq_devices_tenant_name UNIQUE (tenant_id, device_name), - CONSTRAINT fk_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) -); - -CREATE TABLE IF NOT EXISTS files ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - device_id INT NOT NULL, - parent_file_id BIGINT NULL, - file_kind VARCHAR(20) NOT NULL, - relative_path VARCHAR(1000) NOT NULL, - directory_path VARCHAR(1000) NOT NULL, - original_filename VARCHAR(255) NOT NULL, - display_filename VARCHAR(255) NULL, - basename VARCHAR(255) NOT NULL, - extension VARCHAR(50) NOT NULL, - mime_type VARCHAR(255) NULL, - size_bytes BIGINT NOT NULL DEFAULT 0, - sha256 CHAR(64) NULL, - capture_date DATETIME NULL, - uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - is_immutable TINYINT(1) NOT NULL DEFAULT 1, - is_deleted TINYINT(1) NOT NULL DEFAULT 0, - deleted_at DATETIME NULL, - CONSTRAINT fk_files_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), - CONSTRAINT fk_files_device FOREIGN KEY (device_id) REFERENCES devices(id), - CONSTRAINT fk_files_parent FOREIGN KEY (parent_file_id) REFERENCES files(id) -); - -CREATE TABLE IF NOT EXISTS jobs ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - file_id BIGINT NOT NULL, - job_type VARCHAR(100) NOT NULL, - options_json LONGTEXT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'queued', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - started_at DATETIME NULL, - completed_at DATETIME NULL, - output_file_id BIGINT NULL, - log_text LONGTEXT NULL, - CONSTRAINT fk_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), - CONSTRAINT fk_jobs_file FOREIGN KEY (file_id) REFERENCES files(id) -); - -CREATE TABLE IF NOT EXISTS audit_logs ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NULL, - user_id INT NULL, - actor_type VARCHAR(20) NOT NULL, - event_type VARCHAR(100) NOT NULL, - file_id BIGINT NULL, - job_id BIGINT NULL, - ip_address VARCHAR(64) NULL, - user_agent VARCHAR(500) NULL, - event_detail LONGTEXT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_audit_tenant_created (tenant_id, created_at), - INDEX idx_audit_event_type (event_type) -); - -CREATE TABLE IF NOT EXISTS admin_access_tokens ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - issued_by_user_id INT NOT NULL, - used_by_admin_id INT NULL, - token_hash CHAR(64) NOT NULL, - purpose VARCHAR(255) NOT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'issued', - expires_at DATETIME NOT NULL, - used_at DATETIME NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_admin_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), - CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(id) -); diff --git a/app/models/schema.sql.bak.rename.20260413-064038 b/app/models/schema.sql.bak.rename.20260413-064038 deleted file mode 100644 index 9cf0f38..0000000 --- a/app/models/schema.sql.bak.rename.20260413-064038 +++ /dev/null @@ -1,103 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - id INT AUTO_INCREMENT PRIMARY KEY, - portal_user_id INT NULL, - email VARCHAR(255) NOT NULL UNIQUE, - display_name VARCHAR(255) NULL, - is_active TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_login_at DATETIME NULL -); - -CREATE TABLE IF NOT EXISTS tenants ( - id INT AUTO_INCREMENT PRIMARY KEY, - owner_user_id INT NOT NULL, - slug VARCHAR(100) NOT NULL UNIQUE, - storage_root VARCHAR(500) NOT NULL, - service_status VARCHAR(50) NOT NULL DEFAULT 'active', - retention_mode VARCHAR(50) NOT NULL DEFAULT 'standard', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_tenants_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) -); - -CREATE TABLE IF NOT EXISTS devices ( - id INT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - device_name VARCHAR(100) NOT NULL, - device_type VARCHAR(50) NOT NULL, - relative_path VARCHAR(255) NOT NULL, - is_active TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT uq_devices_tenant_name UNIQUE (tenant_id, device_name), - CONSTRAINT fk_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) -); - -CREATE TABLE IF NOT EXISTS files ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - device_id INT NOT NULL, - parent_file_id BIGINT NULL, - file_kind VARCHAR(20) NOT NULL, - relative_path VARCHAR(1000) NOT NULL, - directory_path VARCHAR(1000) NOT NULL, - original_filename VARCHAR(255) NOT NULL, - basename VARCHAR(255) NOT NULL, - extension VARCHAR(50) NOT NULL, - mime_type VARCHAR(255) NULL, - size_bytes BIGINT NOT NULL DEFAULT 0, - sha256 CHAR(64) NULL, - capture_date DATETIME NULL, - uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - is_immutable TINYINT(1) NOT NULL DEFAULT 1, - is_deleted TINYINT(1) NOT NULL DEFAULT 0, - deleted_at DATETIME NULL, - CONSTRAINT fk_files_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), - CONSTRAINT fk_files_device FOREIGN KEY (device_id) REFERENCES devices(id), - CONSTRAINT fk_files_parent FOREIGN KEY (parent_file_id) REFERENCES files(id) -); - -CREATE TABLE IF NOT EXISTS jobs ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - file_id BIGINT NOT NULL, - job_type VARCHAR(100) NOT NULL, - options_json LONGTEXT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'queued', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - started_at DATETIME NULL, - completed_at DATETIME NULL, - output_file_id BIGINT NULL, - log_text LONGTEXT NULL, - CONSTRAINT fk_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), - CONSTRAINT fk_jobs_file FOREIGN KEY (file_id) REFERENCES files(id) -); - -CREATE TABLE IF NOT EXISTS audit_logs ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NULL, - user_id INT NULL, - actor_type VARCHAR(20) NOT NULL, - event_type VARCHAR(100) NOT NULL, - file_id BIGINT NULL, - job_id BIGINT NULL, - ip_address VARCHAR(64) NULL, - user_agent VARCHAR(500) NULL, - event_detail LONGTEXT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_audit_tenant_created (tenant_id, created_at), - INDEX idx_audit_event_type (event_type) -); - -CREATE TABLE IF NOT EXISTS admin_access_tokens ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - issued_by_user_id INT NOT NULL, - used_by_admin_id INT NULL, - token_hash CHAR(64) NOT NULL, - purpose VARCHAR(255) NOT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'issued', - expires_at DATETIME NOT NULL, - used_at DATETIME NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_admin_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), - CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(id) -); diff --git a/app/models/schema.sql.bak.rename.20260413-064842 b/app/models/schema.sql.bak.rename.20260413-064842 deleted file mode 100644 index 9cf0f38..0000000 --- a/app/models/schema.sql.bak.rename.20260413-064842 +++ /dev/null @@ -1,103 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - id INT AUTO_INCREMENT PRIMARY KEY, - portal_user_id INT NULL, - email VARCHAR(255) NOT NULL UNIQUE, - display_name VARCHAR(255) NULL, - is_active TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_login_at DATETIME NULL -); - -CREATE TABLE IF NOT EXISTS tenants ( - id INT AUTO_INCREMENT PRIMARY KEY, - owner_user_id INT NOT NULL, - slug VARCHAR(100) NOT NULL UNIQUE, - storage_root VARCHAR(500) NOT NULL, - service_status VARCHAR(50) NOT NULL DEFAULT 'active', - retention_mode VARCHAR(50) NOT NULL DEFAULT 'standard', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_tenants_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) -); - -CREATE TABLE IF NOT EXISTS devices ( - id INT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - device_name VARCHAR(100) NOT NULL, - device_type VARCHAR(50) NOT NULL, - relative_path VARCHAR(255) NOT NULL, - is_active TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT uq_devices_tenant_name UNIQUE (tenant_id, device_name), - CONSTRAINT fk_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) -); - -CREATE TABLE IF NOT EXISTS files ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - device_id INT NOT NULL, - parent_file_id BIGINT NULL, - file_kind VARCHAR(20) NOT NULL, - relative_path VARCHAR(1000) NOT NULL, - directory_path VARCHAR(1000) NOT NULL, - original_filename VARCHAR(255) NOT NULL, - basename VARCHAR(255) NOT NULL, - extension VARCHAR(50) NOT NULL, - mime_type VARCHAR(255) NULL, - size_bytes BIGINT NOT NULL DEFAULT 0, - sha256 CHAR(64) NULL, - capture_date DATETIME NULL, - uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - is_immutable TINYINT(1) NOT NULL DEFAULT 1, - is_deleted TINYINT(1) NOT NULL DEFAULT 0, - deleted_at DATETIME NULL, - CONSTRAINT fk_files_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), - CONSTRAINT fk_files_device FOREIGN KEY (device_id) REFERENCES devices(id), - CONSTRAINT fk_files_parent FOREIGN KEY (parent_file_id) REFERENCES files(id) -); - -CREATE TABLE IF NOT EXISTS jobs ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - file_id BIGINT NOT NULL, - job_type VARCHAR(100) NOT NULL, - options_json LONGTEXT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'queued', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - started_at DATETIME NULL, - completed_at DATETIME NULL, - output_file_id BIGINT NULL, - log_text LONGTEXT NULL, - CONSTRAINT fk_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), - CONSTRAINT fk_jobs_file FOREIGN KEY (file_id) REFERENCES files(id) -); - -CREATE TABLE IF NOT EXISTS audit_logs ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NULL, - user_id INT NULL, - actor_type VARCHAR(20) NOT NULL, - event_type VARCHAR(100) NOT NULL, - file_id BIGINT NULL, - job_id BIGINT NULL, - ip_address VARCHAR(64) NULL, - user_agent VARCHAR(500) NULL, - event_detail LONGTEXT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_audit_tenant_created (tenant_id, created_at), - INDEX idx_audit_event_type (event_type) -); - -CREATE TABLE IF NOT EXISTS admin_access_tokens ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - issued_by_user_id INT NOT NULL, - used_by_admin_id INT NULL, - token_hash CHAR(64) NOT NULL, - purpose VARCHAR(255) NOT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'issued', - expires_at DATETIME NOT NULL, - used_at DATETIME NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_admin_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), - CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(id) -); diff --git a/app/models/schema.sql.bak.rename.20260413-065605 b/app/models/schema.sql.bak.rename.20260413-065605 deleted file mode 100644 index 9cf0f38..0000000 --- a/app/models/schema.sql.bak.rename.20260413-065605 +++ /dev/null @@ -1,103 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - id INT AUTO_INCREMENT PRIMARY KEY, - portal_user_id INT NULL, - email VARCHAR(255) NOT NULL UNIQUE, - display_name VARCHAR(255) NULL, - is_active TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_login_at DATETIME NULL -); - -CREATE TABLE IF NOT EXISTS tenants ( - id INT AUTO_INCREMENT PRIMARY KEY, - owner_user_id INT NOT NULL, - slug VARCHAR(100) NOT NULL UNIQUE, - storage_root VARCHAR(500) NOT NULL, - service_status VARCHAR(50) NOT NULL DEFAULT 'active', - retention_mode VARCHAR(50) NOT NULL DEFAULT 'standard', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_tenants_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) -); - -CREATE TABLE IF NOT EXISTS devices ( - id INT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - device_name VARCHAR(100) NOT NULL, - device_type VARCHAR(50) NOT NULL, - relative_path VARCHAR(255) NOT NULL, - is_active TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT uq_devices_tenant_name UNIQUE (tenant_id, device_name), - CONSTRAINT fk_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) -); - -CREATE TABLE IF NOT EXISTS files ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - device_id INT NOT NULL, - parent_file_id BIGINT NULL, - file_kind VARCHAR(20) NOT NULL, - relative_path VARCHAR(1000) NOT NULL, - directory_path VARCHAR(1000) NOT NULL, - original_filename VARCHAR(255) NOT NULL, - basename VARCHAR(255) NOT NULL, - extension VARCHAR(50) NOT NULL, - mime_type VARCHAR(255) NULL, - size_bytes BIGINT NOT NULL DEFAULT 0, - sha256 CHAR(64) NULL, - capture_date DATETIME NULL, - uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - is_immutable TINYINT(1) NOT NULL DEFAULT 1, - is_deleted TINYINT(1) NOT NULL DEFAULT 0, - deleted_at DATETIME NULL, - CONSTRAINT fk_files_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), - CONSTRAINT fk_files_device FOREIGN KEY (device_id) REFERENCES devices(id), - CONSTRAINT fk_files_parent FOREIGN KEY (parent_file_id) REFERENCES files(id) -); - -CREATE TABLE IF NOT EXISTS jobs ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - file_id BIGINT NOT NULL, - job_type VARCHAR(100) NOT NULL, - options_json LONGTEXT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'queued', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - started_at DATETIME NULL, - completed_at DATETIME NULL, - output_file_id BIGINT NULL, - log_text LONGTEXT NULL, - CONSTRAINT fk_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), - CONSTRAINT fk_jobs_file FOREIGN KEY (file_id) REFERENCES files(id) -); - -CREATE TABLE IF NOT EXISTS audit_logs ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NULL, - user_id INT NULL, - actor_type VARCHAR(20) NOT NULL, - event_type VARCHAR(100) NOT NULL, - file_id BIGINT NULL, - job_id BIGINT NULL, - ip_address VARCHAR(64) NULL, - user_agent VARCHAR(500) NULL, - event_detail LONGTEXT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_audit_tenant_created (tenant_id, created_at), - INDEX idx_audit_event_type (event_type) -); - -CREATE TABLE IF NOT EXISTS admin_access_tokens ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - issued_by_user_id INT NOT NULL, - used_by_admin_id INT NULL, - token_hash CHAR(64) NOT NULL, - purpose VARCHAR(255) NOT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'issued', - expires_at DATETIME NOT NULL, - used_at DATETIME NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_admin_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), - CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(id) -); diff --git a/app/services/video_jobs.py b/app/services/video_jobs.py index 81b5084..5787b32 100644 --- a/app/services/video_jobs.py +++ b/app/services/video_jobs.py @@ -98,7 +98,7 @@ def resolve_source_from_file_id(db, tenant_id, device_id, source_file_id): f"Tried tables: files, device_files, uploaded_files" ) -def create_video_job(tenant, device_id, source_file_id, profile="default", rotation_override=None): +def create_video_job(tenant, device_id, source_file_id, profile="default", rotation_override=None, batch_id=None): db = get_db() tenant_row = get_tenant_row(db, tenant) @@ -123,12 +123,13 @@ def create_video_job(tenant, device_id, source_file_id, profile="default", rotat source_file_id, source_relative_path, source_original_filename, + batch_id, requested_profile, requested_rotation_degrees, requested_gpu_preference, status, progress_percent - ) VALUES (%s, %s, %s, %s, %s, %s, %s, 'auto', 'queued', 0) + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'auto', 'queued', 0) """, ( tenant_id, @@ -136,6 +137,7 @@ def create_video_job(tenant, device_id, source_file_id, profile="default", rotat int(source_file_id), file_meta["source_relative_path"], file_meta["source_original_filename"], + batch_id, profile, rotation_override, ) @@ -169,10 +171,12 @@ def list_jobs_for_tenant(tenant): """ SELECT id, + tenant_id, device_id, source_file_id, source_relative_path, source_original_filename, + batch_id, requested_profile, requested_rotation_degrees, status, @@ -200,9 +204,11 @@ def list_jobs_for_tenant(tenant): out.append({ "id": r["id"], + "tenant_id": r["tenant_id"], "device_id": r["device_id"], "source_file_id": r["source_file_id"], "filename": r["source_original_filename"], + "batch_id": r["batch_id"], "profile": r["requested_profile"], "rotation_override": r["requested_rotation_degrees"], "status": r["status"], diff --git a/app/services/video_worker.py b/app/services/video_worker.py index 4273b92..36d66d9 100644 --- a/app/services/video_worker.py +++ b/app/services/video_worker.py @@ -243,6 +243,8 @@ def process_job(db, job, processor): "-ac", "2", "-ar", "48000", "-movflags", "+faststart", + "-metadata:s:v:0", "rotate=0", + "-metadata:s:v:0", "rotate=0", output ] else: @@ -261,6 +263,8 @@ def process_job(db, job, processor): "-ac", "2", "-ar", "48000", "-movflags", "+faststart", + "-metadata:s:v:0", "rotate=0", + "-metadata:s:v:0", "rotate=0", output ] @@ -341,50 +345,189 @@ def process_job(db, job, processor): db.commit() bump_metrics(db, job["tenant_id"], complete=False, failed=True, gpu_seconds=0) -def claim_next_job(db, processor): +def _claim_from_query(cur, processor, where_sql="", args=()): """ - Intel: prefer default/compress, then anything. - AMD: prefer hq, then anything. + Scheduling policy: + - Intel prefers default/compress and can fall back to any queued job. + - AMD prefers HQ. + - AMD may help with default/compress only when there is light-job backlog + or Intel is already processing a job. This avoids AMD stealing single + default/compress jobs when Intel is free. """ - preferred_profile = "hq" if processor == "amd" else None + job = None - with db.cursor() as cur: - cur.execute("START TRANSACTION") + if processor == "amd": + # 1) AMD always prefers HQ first. + cur.execute( + f""" + SELECT * + FROM video_jobs + WHERE status='queued' {where_sql} AND requested_profile = %s + ORDER BY id ASC + LIMIT 1 + FOR UPDATE + """, + tuple(args) + ("hq",), + ) + job = cur.fetchone() - job = None + if job: + return job - if preferred_profile: + # 2) Only let AMD help with light jobs if there is backlog + # or Intel is already busy. + cur.execute( + f""" + SELECT COUNT(*) AS c + FROM video_jobs + WHERE status='queued' {where_sql} + AND requested_profile IN ('default','compress') + """, + tuple(args), + ) + row = cur.fetchone() + light_backlog = row["c"] if isinstance(row, dict) else row[0] + + cur.execute( + """ + SELECT COUNT(*) AS c + FROM video_jobs + WHERE status='processing' + AND assigned_processor = 'intel' + """ + ) + row = cur.fetchone() + intel_busy = (row["c"] if isinstance(row, dict) else row[0]) > 0 + + if light_backlog >= 2 or intel_busy: cur.execute( - """ + f""" SELECT * FROM video_jobs - WHERE status='queued' AND requested_profile = %s + WHERE status='queued' {where_sql} + AND requested_profile IN ('default','compress') ORDER BY id ASC LIMIT 1 FOR UPDATE """, - (preferred_profile,), + tuple(args), ) job = cur.fetchone() - if not job: - if processor == "intel": + return job + + # Intel path: default/compress first. + cur.execute( + f""" + SELECT * + FROM video_jobs + WHERE status='queued' {where_sql} AND requested_profile IN ('default','compress') + ORDER BY id ASC + LIMIT 1 + FOR UPDATE + """, + tuple(args), + ) + job = cur.fetchone() + + # Intel may fall back to HQ/anything if no light work exists. + if not job: + cur.execute( + f""" + SELECT * + FROM video_jobs + WHERE status='queued' {where_sql} + ORDER BY id ASC + LIMIT 1 + FOR UPDATE + """, + tuple(args), + ) + job = cur.fetchone() + + return job + +def claim_next_job(db, processor, current_batch_id=None, current_tenant_id=None): + with db.cursor() as cur: + cur.execute("START TRANSACTION") + + # 1) Keep this worker on its current batch until the batch is finished. + if current_batch_id: + job = _claim_from_query(cur, processor, "AND batch_id = %s", (current_batch_id,)) + if job: cur.execute( """ - SELECT * - FROM video_jobs - WHERE status='queued' AND requested_profile IN ('default','compress') - ORDER BY id ASC - LIMIT 1 - FOR UPDATE - """ + UPDATE video_jobs + SET status='processing', + assigned_processor=%s, + started_at=COALESCE(started_at, UTC_TIMESTAMP()), + progress_percent=5 + WHERE id=%s + """, + (processor, job["id"]), ) - job = cur.fetchone() + db.commit() + job["assigned_processor"] = processor + return job, current_batch_id, job["tenant_id"] + + # 2) Count active tenants. If only one tenant is active, let them use all GPUs. + cur.execute(""" + SELECT COUNT(DISTINCT tenant_id) AS c + FROM video_jobs + WHERE status IN ('queued','processing') + """) + row = cur.fetchone() + active_tenants = row["c"] if isinstance(row, dict) else row[0] + + if active_tenants <= 1: + job = _claim_from_query(cur, processor) + if not job: + db.rollback() + return None, None, None - if not job: cur.execute( """ - SELECT * + UPDATE video_jobs + SET status='processing', + assigned_processor=%s, + started_at=COALESCE(started_at, UTC_TIMESTAMP()), + progress_percent=5 + WHERE id=%s + """, + (processor, job["id"]), + ) + db.commit() + job["assigned_processor"] = processor + return job, job.get("batch_id"), job["tenant_id"] + + # 3) Multiple active tenants: + # only allow a tenant without an already-processing job to get a GPU slot. + cur.execute(""" + SELECT DISTINCT tenant_id + FROM video_jobs + WHERE status='processing' + """) + busy_rows = cur.fetchall() + busy_tenants = {r["tenant_id"] if isinstance(r, dict) else r[0] for r in busy_rows} + + if busy_tenants: + placeholders = ",".join(["%s"] * len(busy_tenants)) + cur.execute( + f""" + SELECT tenant_id + FROM video_jobs + WHERE status='queued' + AND tenant_id NOT IN ({placeholders}) + ORDER BY id ASC + LIMIT 1 + FOR UPDATE + """, + tuple(busy_tenants), + ) + else: + cur.execute( + """ + SELECT tenant_id FROM video_jobs WHERE status='queued' ORDER BY id ASC @@ -392,11 +535,18 @@ def claim_next_job(db, processor): FOR UPDATE """ ) - job = cur.fetchone() + tenant_row = cur.fetchone() + if not tenant_row: + db.rollback() + return None, None, None + + tenant_id = tenant_row["tenant_id"] if isinstance(tenant_row, dict) else tenant_row[0] + + job = _claim_from_query(cur, processor, "AND tenant_id = %s", (tenant_id,)) if not job: db.rollback() - return None + return None, None, None cur.execute( """ @@ -411,9 +561,12 @@ def claim_next_job(db, processor): ) db.commit() job["assigned_processor"] = processor - return job + return job, job.get("batch_id"), job["tenant_id"] def worker_loop(app, processor): + current_batch_id = None + current_tenant_id = None + with app.app_context(): print(f"{processor} worker started", flush=True) @@ -425,12 +578,21 @@ def worker_loop(app, processor): except Exception: pass - job = claim_next_job(db, processor) + job, new_batch_id, new_tenant_id = claim_next_job( + db, + processor, + current_batch_id=current_batch_id, + current_tenant_id=current_tenant_id, + ) if job: - print(f"{processor} worker picked job id={job['id']} source={job['source_relative_path']}", flush=True) + current_batch_id = new_batch_id + current_tenant_id = new_tenant_id + print(f"{processor} worker picked job id={job['id']} batch={job.get('batch_id')} tenant={job['tenant_id']} source={job['source_relative_path']}", flush=True) process_job(db, job, processor) else: + current_batch_id = None + current_tenant_id = None time.sleep(2) except Exception as e: diff --git a/app/templates/auth/handoff_error.html.bak.20260412-235158 b/app/templates/auth/handoff_error.html.bak.20260412-235158 deleted file mode 100644 index 4951fed..0000000 --- a/app/templates/auth/handoff_error.html.bak.20260412-235158 +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Handoff Error{% endblock %} - -{% block content %} -
-

Portal handoff failed

-

{{ message }}

-
-{% endblock %} diff --git a/app/templates/auth/login_required.html.bak.20260412-235158 b/app/templates/auth/login_required.html.bak.20260412-235158 deleted file mode 100644 index 2b23c6f..0000000 --- a/app/templates/auth/login_required.html.bak.20260412-235158 +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Portal Login Required{% endblock %} - -{% block content %} -
-

Portal login required

-

- OTB Cloud does not allow direct unauthenticated access. - This app is intended to be reached through the OTB Billing portal handoff. -

-
- Current status: direct local scaffold only. Real portal handoff wiring is next. -
-
-{% endblock %} diff --git a/app/templates/cloud/dashboard.html.bak.20260412-235158 b/app/templates/cloud/dashboard.html.bak.20260412-235158 deleted file mode 100644 index ce18930..0000000 --- a/app/templates/cloud/dashboard.html.bak.20260412-235158 +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}OTB Cloud Dashboard{% endblock %} - -{% block content %} -
-

OTB Cloud Dashboard

-

Authenticated user: {{ user_email }}

-

Tenant slug: {{ tenant_slug }}

-
- -
-
-

Devices

- {% if devices %} -
    - {% for device in devices %} -
  • - {{ device.device_name }} - ({{ device.device_type }})
    - {{ device.relative_path }} -
  • - {% endfor %} -
- {% else %} -

No devices have been created yet.

- {% endif %} -
- -
-

Current scope

-

- v0.1.1 provides portal-handoff scaffolding, tenant bootstrap, device records, and an authenticated dashboard. -

-
-
-{% endblock %} diff --git a/app/templates/cloud/dashboard.html.bak.20260413-015405 b/app/templates/cloud/dashboard.html.bak.20260413-015405 deleted file mode 100644 index a36e9b3..0000000 --- a/app/templates/cloud/dashboard.html.bak.20260413-015405 +++ /dev/null @@ -1,77 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}OTB Cloud Dashboard{% endblock %} - -{% block portal_content %} -
-
-

OTB Cloud Dashboard

-

{{ user_email }}

-

- Secure backup and storage dashboard for your account. -

-
- -
- - -
-
Logged in as: - {{ user_email }}
-
Tenant slug: - {{ tenant_slug }}
-
-
-
- -
-
-
-
-

Devices

-

Registered source locations for uploaded data.

-
-
- Active -
-
- -
- {% if devices %} -
    - {% for device in devices %} -
  • - {{ device.device_name }} - ({{ device.device_type }})
    - {{ device.relative_path }} -
  • - {% endfor %} -
- {% else %} -

No devices have been created yet.

- {% endif %} -
-
- -
-
-
-

Current scope

-

OTB Cloud is now operating inside the branded OTB portal shell.

-
-
- In Progress -
-
- -
-

- Next steps are the searchable file library, bulk upload endpoints, zip export, and media processing jobs. -

-
-
-
-{% endblock %} diff --git a/app/templates/cloud/dashboard.html.bak.20260413-021018 b/app/templates/cloud/dashboard.html.bak.20260413-021018 deleted file mode 100644 index db6fb03..0000000 --- a/app/templates/cloud/dashboard.html.bak.20260413-021018 +++ /dev/null @@ -1,124 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}OTB Cloud Dashboard{% endblock %} - -{% block portal_content %} -
-
-

OTB Cloud Dashboard

-

{{ user_email }}

-

- Secure backup and storage dashboard for your account. -

-
- -
- - -
-
Logged in as: - {{ user_email }}
-
Tenant slug: - {{ tenant_slug }}
-
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if devices %} -
-
-
-
-

Devices

-

Registered source locations for uploaded data.

-
-
- Active -
-
- -
-
    - {% for device in devices %} -
  • - {{ device.device_name }} - ({{ device.device_type }})
    - {{ device.relative_path }} -
  • - {% endfor %} -
-
-
- -
-
-
-

Current scope

-

OTB Cloud is now operating inside the branded OTB portal shell.

-
-
- In Progress -
-
- -
-

- Next steps are the searchable file library, bulk upload endpoints, zip export, and media processing jobs. -

-
-
-
-{% else %} -
-
-
-
-

No devices connected yet

-

Create your first device source before uploading files.

-
-
- Ready -
-
- - -
- -
-
-
-

Current scope

-

OTB Cloud is ready for user-created devices.

-
-
- In Progress -
-
- -
-

- After adding a device, the next phase is browser upload, file library browsing, and export tools. -

-
-
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/dashboard.html.bak.20260413-024827 b/app/templates/cloud/dashboard.html.bak.20260413-024827 deleted file mode 100644 index 37bdca9..0000000 --- a/app/templates/cloud/dashboard.html.bak.20260413-024827 +++ /dev/null @@ -1,127 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}OTB Cloud Dashboard{% endblock %} - -{% block portal_content %} -
-
-

OTB Cloud Dashboard

-

{{ user_email }}

-

- Secure backup and storage dashboard for your account. -

-
- -
- - -
-
Logged in as: - {{ user_email }}
-
Tenant slug: - {{ tenant_slug }}
-
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if devices %} -
-
-
-
-

Devices

-

Registered source locations for uploaded data.

-
-
- Active -
-
- -
-
    - {% for device in devices %} -
  • - {{ device.device_name }} - ({{ device.device_type }})
    - {{ device.relative_path }}
    -
    - -
    -
  • - {% endfor %} -
-
-
- -
-
-
-

Current scope

-

OTB Cloud is now operating inside the branded OTB portal shell.

-
-
- In Progress -
-
- -
-

- Next steps are the searchable file library, bulk upload endpoints, zip export, and media processing jobs. -

-
-
-
-{% else %} -
-
-
-
-

No devices connected yet

-

Create your first device source before uploading files.

-
-
- Ready -
-
- - -
- -
-
-
-

Current scope

-

OTB Cloud is ready for user-created devices.

-
-
- In Progress -
-
- -
-

- After adding a device, the next phase is browser upload, file library browsing, and export tools. -

-
-
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/dashboard.html.bak.20260413-032130 b/app/templates/cloud/dashboard.html.bak.20260413-032130 deleted file mode 100644 index a2bf9f5..0000000 --- a/app/templates/cloud/dashboard.html.bak.20260413-032130 +++ /dev/null @@ -1,130 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}OTB Cloud Dashboard{% endblock %} - -{% block portal_content %} -
-
-

OTB Cloud Dashboard

-

{{ user_email }}

-

- Secure backup and storage dashboard for your account. -

-
- -
- - -
-
Logged in as: - {{ user_email }}
-
Tenant slug: - {{ tenant_slug }}
-
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if devices %} -
-
-
-
-

Devices

-

Registered source locations for uploaded data.

-
-
- Active -
-
- -
-
    - {% for device in devices %} -
  • - {{ device.device_name }} - ({{ device.device_type }})
    - {{ device.relative_path }}
    -
    - Upload Files -
    - -
    -
    -
  • - {% endfor %} -
-
-
- -
-
-
-

Current scope

-

OTB Cloud now supports browser uploads to device originals.

-
-
- Live -
-
- -
-

- Next steps are uploaded file listing, searchable library pages, zip export, and media processing jobs. -

-
-
-
-{% else %} -
-
-
-
-

No devices connected yet

-

Create your first device source before uploading files.

-
-
- Ready -
-
- - -
- -
-
-
-

Current scope

-

OTB Cloud is ready for user-created devices and browser uploads.

-
-
- Live -
-
- -
-

- After adding a device, you can upload one or more files into that device’s originals storage. -

-
-
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/dashboard.html.bak.20260413-051439 b/app/templates/cloud/dashboard.html.bak.20260413-051439 deleted file mode 100644 index 8319bcc..0000000 --- a/app/templates/cloud/dashboard.html.bak.20260413-051439 +++ /dev/null @@ -1,131 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}OTB Cloud Dashboard{% endblock %} - -{% block portal_content %} -
-
-

OTB Cloud Dashboard

-

{{ user_email }}

-

- Secure backup and storage dashboard for your account. -

-
- -
- - -
-
Logged in as: - {{ user_email }}
-
Tenant slug: - {{ tenant_slug }}
-
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if devices %} -
-
-
-
-

Devices

-

Registered source locations for uploaded data.

-
-
- Active -
-
- -
-
    - {% for device in devices %} -
  • - {{ device.device_name }} - ({{ device.device_type }})
    - {{ device.relative_path }}
    -
    - Upload Files - Browse Files -
    - -
    -
    -
  • - {% endfor %} -
-
-
- -
-
-
-

Current scope

-

OTB Cloud now supports browser uploads and device file browsing.

-
-
- Live -
-
- -
-

- Next steps are single-file download, searchable library pages, zip export, and media processing jobs. -

-
-
-
-{% else %} -
-
-
-
-

No devices connected yet

-

Create your first device source before uploading files.

-
-
- Ready -
-
- - -
- -
-
-
-

Current scope

-

OTB Cloud is ready for user-created devices and browser uploads.

-
-
- Live -
-
- -
-

- After adding a device, you can upload one or more files into that device’s originals storage and browse them here. -

-
-
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/dashboard.html.bak.android.20260414-002002 b/app/templates/cloud/dashboard.html.bak.android.20260414-002002 deleted file mode 100644 index 0b14f0a..0000000 --- a/app/templates/cloud/dashboard.html.bak.android.20260414-002002 +++ /dev/null @@ -1,133 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}OTB Cloud Dashboard{% endblock %} - -{% block portal_content %} -
-
-

OTB Cloud Dashboard

-

{{ user_email }}

-

- Secure backup and storage dashboard for your account. -

-
- -
- - -
-
Logged in as: - {{ user_email }}
-
Tenant slug: - {{ tenant_slug }}
-
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if devices %} -
-
-
-
-

Devices

-

Registered source locations for uploaded data.

-
-
- Active -
-
- -
-
    - {% for device in devices %} -
  • - {{ device.device_name }} - ({{ device.device_type }})
    - {{ device.relative_path }}
    -
    - Upload Files - Browse Files -
    - -
    -
    -
  • - {% endfor %} -
-
-
- -
-
-
-

Current scope

-

OTB Cloud now supports browser uploads, browsing, zip staging, and soft delete.

-
-
- Live -
-
- -
-

- Next steps are basename-only rename, searchable library pages, folder upload, and media processing jobs. -

-
-
-
-{% else %} -
-
-
-
-

No devices connected yet

-

Create your first device source before uploading files.

-
-
- Ready -
-
- - -
- -
-
-
-

Current scope

-

OTB Cloud is ready for user-created devices and browser uploads.

-
-
- Live -
-
- -
-

- After adding a device, you can upload files, browse them, soft-delete them, and stage them for zip export. -

-
-
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/dashboard.html.bak.androidlock.20260414-014204 b/app/templates/cloud/dashboard.html.bak.androidlock.20260414-014204 deleted file mode 100644 index 4217600..0000000 --- a/app/templates/cloud/dashboard.html.bak.androidlock.20260414-014204 +++ /dev/null @@ -1,135 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}OTB Cloud Dashboard{% endblock %} - -{% block portal_content %} -
-
-

OTB Cloud Dashboard

-

{{ user_email }}

-

- Secure backup and storage dashboard for your account. -

-
- -
- - -
-
Logged in as: - {{ user_email }}
-
Tenant slug: - {{ tenant_slug }}
-
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if devices %} -
-
-
-
-

Devices

-

Registered source locations for uploaded data.

-
-
- Active -
-
- -
-
    - {% for device in devices %} -
  • - {{ device.device_name }} - ({{ device.device_type }})
    - {{ device.relative_path }}
    -
    - Upload Files - Browse Files -
    - -
    -
    -
  • - {% endfor %} -
-
-
- -
-
-
-

Current scope

-

OTB Cloud now supports browser uploads, browsing, zip staging, and soft delete.

-
-
- Live -
-
- -
-

- Next steps are basename-only rename, searchable library pages, folder upload, and media processing jobs. -

-
-
-
-{% else %} -
-
-
-
-

No devices connected yet

-

Create your first device source before uploading files.

-
-
- Ready -
-
- - -
- -
-
-
-

Current scope

-

OTB Cloud is ready for user-created devices and browser uploads.

-
-
- Live -
-
- -
-

- After adding a device, you can upload files, browse them, soft-delete them, and stage them for zip export. -

-
-
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/deleted_files.html b/app/templates/cloud/deleted_files.html index 30fa813..f503c28 100644 --- a/app/templates/cloud/deleted_files.html +++ b/app/templates/cloud/deleted_files.html @@ -74,7 +74,10 @@
-
+ + + +
@@ -105,3 +108,11 @@ {% endif %} {% endblock %} + + diff --git a/app/templates/cloud/deleted_files.html.bak.20260413-054006 b/app/templates/cloud/deleted_files.html.bak.20260413-054006 deleted file mode 100644 index c82500e..0000000 --- a/app/templates/cloud/deleted_files.html.bak.20260413-054006 +++ /dev/null @@ -1,102 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Deleted Files - OTB Cloud{% endblock %} - -{% block portal_content %} -
-
-

Deleted Files

-

{{ user_email }}

-

- Deleted files are retained for up to 24 hours unless you hard-delete them immediately. -

-
- - -
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if files %} -
-
-
-
-

Deleted Files

-

Files here are pending retention expiry or hard delete.

-
-
- {{ files|length }} items -
-
- -
- - - - - - - - - - - - {% for file in files %} - - - - - - - - {% endfor %} - -
NameDeviceSizeDeleted AtAction
- {{ file.original_filename }}
- {{ file.relative_path }} -
- {{ file.device_name or 'Unknown' }}
- {{ file.device_type or '' }} -
- {{ "{:,}".format(file.size_bytes or 0) }} bytes - - {{ file.deleted_at }} - - - - -
-
-
-
-{% else %} -
-
-
-
-

No deleted files

-

There are currently no files in the deleted area.

-
-
- Clear -
-
-
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/device_files.html b/app/templates/cloud/device_files.html index e5770ae..2082195 100644 --- a/app/templates/cloud/device_files.html +++ b/app/templates/cloud/device_files.html @@ -253,7 +253,12 @@
@@ -296,7 +301,7 @@ {% if folders %}
{% for folder in folders %} - +
📁 {{ folder.name }}
Open folder
@@ -308,6 +313,24 @@ + +{% if total_files is defined and total_files > per_page %} +
+
+ Showing {{ file_count }} of {{ total_files }} files + Page {{ page }} + + {% if has_prev %} + Previous + {% endif %} + + {% if has_next %} + Next + {% endif %} +
+
+{% endif %} + {% if files %}
@@ -330,10 +353,11 @@
- + + + -
@@ -375,9 +399,17 @@
+ + + + + + + + value="{{ rename_value }}" maxlength="200" placeholder="Enter custom file name" @@ -563,7 +603,25 @@ window.sendToWorkshop = function () { const deviceId = parts[2]; localStorage.setItem("videoSelection", JSON.stringify(selected)); - window.location.href = "/workshop/" + deviceId; + + const pathParams = new URLSearchParams(window.location.search); + const currentPath = (pathParams.get("path") || "").toLowerCase(); + + const allVideos = selected.every(f => (f.mime_type || f.mime || "").startsWith("video/")); + const allImages = selected.every(f => (f.mime_type || f.mime || "").startsWith("image/")); + + if (allVideos || currentPath === "video" || currentPath.startsWith("video/")) { + window.location.href = "/workshop/" + deviceId; + } else if (allImages || currentPath === "images" || currentPath.startsWith("images/")) { + if (selected.length > 25) { + alert("Image Workshop is limited to 25 images per batch."); + return; + } + localStorage.setItem("imageSelection", JSON.stringify(selected)); + window.location.href = "/image-workshop/" + deviceId; + } else { + alert("Cannot mix images and videos in the same workshop job."); + } }; @@ -572,3 +630,10 @@ window.sendToWorkshop = function () { + + + diff --git a/app/templates/cloud/device_files.html.bak.20260413-051439 b/app/templates/cloud/device_files.html.bak.20260413-051439 deleted file mode 100644 index 29714c1..0000000 --- a/app/templates/cloud/device_files.html.bak.20260413-051439 +++ /dev/null @@ -1,101 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Device Files - OTB Cloud{% endblock %} - -{% block portal_content %} -
-
-

Device Files

-

{{ user_email }}

-

- Browsing files for {{ device.device_name }} ({{ device.device_type }}). -

-
- -
- -
-
File count: {{ file_count }}
-
Device path: {{ device.relative_path }}
-
-
-
- -{% if files %} -
-
-
-
-

Files

-

Files recorded in the database for this device.

-
-
- DB-backed -
-
- -
- - - - - - - - - - - - {% for file in files %} - - - - - - - - {% endfor %} - -
NameKindSizeUploadedPath
- {{ file.original_filename }}
- - SHA256: {{ file.sha256 }} - -
- {{ file.file_kind }} - {% if file.is_immutable %} -
immutable - {% endif %} -
- {{ "{:,}".format(file.size_bytes or 0) }} bytes - - {{ file.uploaded_at }} - - {{ file.relative_path }} -
-
-
-
-{% else %} -
-
-
-
-

No files yet

-

This device does not have any uploaded files recorded yet.

-
-
- Empty -
-
- - -
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/device_files.html.bak.gallery.20260413-071415 b/app/templates/cloud/device_files.html.bak.gallery.20260413-071415 deleted file mode 100644 index 963899a..0000000 --- a/app/templates/cloud/device_files.html.bak.gallery.20260413-071415 +++ /dev/null @@ -1,146 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Device Files - OTB Cloud{% endblock %} - -{% block portal_content %} -
-
-

Device Files

-

{{ user_email }}

-

- Browsing files for {{ device.device_name }} ({{ device.device_type }}). -

-
- -
- -
-
File count: {{ file_count }}
-
Device path: {{ device.relative_path }}
-
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if files %} -
-
-
-
-

Files

-

Select files to delete, download, or send to Zip Workspace. Rename changes only the customer-facing name, not the immutable stored file.

-
-
- DB-backed -
-
- -
- -
- - - -
- - - - - - - - - - - - - - - {% for file in files %} - {% set visible_name = file.display_filename or file.original_filename %} - {% set rename_value = file.display_filename.rsplit('.', 1)[0] if file.display_filename and '.' in file.display_filename else file.basename %} - - - - - - - - - {% endfor %} - -
- - NameKindSizeUploadedPath
- - - {{ visible_name }}
- {% if file.display_filename %} - Original: {{ file.original_filename }}
- {% endif %} - SHA256: {{ file.sha256 }} - -
- - {% if file.extension %} - .{{ file.extension }} - {% endif %} - -
-
- {{ file.file_kind }} - {% if file.is_immutable %} -
immutable - {% endif %} -
- {{ "{:,}".format(file.size_bytes or 0) }} bytes - - {{ file.uploaded_at }} - - {{ file.relative_path }} -
-
-
-
-{% else %} -
-
-
-
-

No files yet

-

This device does not have any uploaded files recorded yet.

-
-
- Empty -
-
- - -
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/device_files.html.bak.previewfix.1776065858 b/app/templates/cloud/device_files.html.bak.previewfix.1776065858 deleted file mode 100644 index 000ec51..0000000 --- a/app/templates/cloud/device_files.html.bak.previewfix.1776065858 +++ /dev/null @@ -1,454 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Device Files - OTB Cloud{% endblock %} - -{% block portal_content %} - - -
-
-

Device Files

-

{{ user_email }}

-

- Browsing files for {{ device.device_name }} ({{ device.device_type }}). -

-
- -
- - - - -
-
File count: {{ file_count }}
-
Device path: {{ device.relative_path }}
-
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if files %} -
-
-
-
-

{% if view_mode == 'gallery' %}Image Gallery{% else %}Files{% endif %}

-

- {% if view_mode == 'gallery' %} - Browse uploaded images visually. Click a thumbnail to preview the full-size image. - {% else %} - Select files to delete, download, or send to Zip Workspace. Rename changes only the customer-facing name, not the immutable stored file. - {% endif %} -

-
-
- DB-backed -
-
- -
-
-
- - - -
-
- - {% if view_mode == 'gallery' %} - - {% else %} - - - - - - - - - - - - - {% for file in files %} - {% set visible_name = file.display_filename or file.original_filename %} - {% set rename_value = file.display_filename.rsplit('.', 1)[0] if file.display_filename and '.' in file.display_filename else file.basename %} - {% set ext = (file.extension or '')|lower %} - {% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %} - - - - - - - - - {% endfor %} - -
- - NameKindSizeUploadedPath
- - -
-
- {{ visible_name }}
- {% if file.display_filename %} - Original: {{ file.original_filename }}
- {% endif %} - {% if is_image %} - - {% endif %} - SHA256: {{ file.sha256 }} -
- -
- - {% if file.extension %} - .{{ file.extension }} - {% endif %} - -
-
-
- {{ file.file_kind }} - {% if file.is_immutable %} -
immutable - {% endif %} -
- {{ "{:,}".format(file.size_bytes or 0) }} bytes - - {{ file.uploaded_at }} - - {{ file.relative_path }} -
- {% endif %} -
-
-
- - - - -{% else %} -
-
-
-
-

No files yet

-

This device does not have any uploaded files recorded yet.

-
-
- Empty -
-
- - -
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/device_files.html.bak.rename.20260413-064038 b/app/templates/cloud/device_files.html.bak.rename.20260413-064038 deleted file mode 100644 index ff59c40..0000000 --- a/app/templates/cloud/device_files.html.bak.rename.20260413-064038 +++ /dev/null @@ -1,126 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Device Files - OTB Cloud{% endblock %} - -{% block portal_content %} -
-
-

Device Files

-

{{ user_email }}

-

- Browsing files for {{ device.device_name }} ({{ device.device_type }}). -

-
- -
- -
-
File count: {{ file_count }}
-
Device path: {{ device.relative_path }}
-
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if files %} -
-
-
-
-

Files

-

Select files to delete, download, or send to Zip Workspace.

-
-
- DB-backed -
-
- -
-
-
- - - -
- - - - - - - - - - - - - - {% for file in files %} - - - - - - - - - {% endfor %} - -
- - NameKindSizeUploadedPath
- - - {{ file.original_filename }}
- SHA256: {{ file.sha256 }} -
- {{ file.file_kind }} - {% if file.is_immutable %} -
immutable - {% endif %} -
- {{ "{:,}".format(file.size_bytes or 0) }} bytes - - {{ file.uploaded_at }} - - {{ file.relative_path }} -
-
-
-
-
-{% else %} -
-
-
-
-

No files yet

-

This device does not have any uploaded files recorded yet.

-
-
- Empty -
-
- - -
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/device_files.html.bak.rename.20260413-064842 b/app/templates/cloud/device_files.html.bak.rename.20260413-064842 deleted file mode 100644 index ff59c40..0000000 --- a/app/templates/cloud/device_files.html.bak.rename.20260413-064842 +++ /dev/null @@ -1,126 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Device Files - OTB Cloud{% endblock %} - -{% block portal_content %} -
-
-

Device Files

-

{{ user_email }}

-

- Browsing files for {{ device.device_name }} ({{ device.device_type }}). -

-
- -
- -
-
File count: {{ file_count }}
-
Device path: {{ device.relative_path }}
-
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if files %} -
-
-
-
-

Files

-

Select files to delete, download, or send to Zip Workspace.

-
-
- DB-backed -
-
- -
-
-
- - - -
- - - - - - - - - - - - - - {% for file in files %} - - - - - - - - - {% endfor %} - -
- - NameKindSizeUploadedPath
- - - {{ file.original_filename }}
- SHA256: {{ file.sha256 }} -
- {{ file.file_kind }} - {% if file.is_immutable %} -
immutable - {% endif %} -
- {{ "{:,}".format(file.size_bytes or 0) }} bytes - - {{ file.uploaded_at }} - - {{ file.relative_path }} -
-
-
-
-
-{% else %} -
-
-
-
-

No files yet

-

This device does not have any uploaded files recorded yet.

-
-
- Empty -
-
- - -
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/device_files.html.bak.rename.20260413-065605 b/app/templates/cloud/device_files.html.bak.rename.20260413-065605 deleted file mode 100644 index ff59c40..0000000 --- a/app/templates/cloud/device_files.html.bak.rename.20260413-065605 +++ /dev/null @@ -1,126 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Device Files - OTB Cloud{% endblock %} - -{% block portal_content %} -
-
-

Device Files

-

{{ user_email }}

-

- Browsing files for {{ device.device_name }} ({{ device.device_type }}). -

-
- -
- -
-
File count: {{ file_count }}
-
Device path: {{ device.relative_path }}
-
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if files %} -
-
-
-
-

Files

-

Select files to delete, download, or send to Zip Workspace.

-
-
- DB-backed -
-
- -
-
-
- - - -
- - - - - - - - - - - - - - {% for file in files %} - - - - - - - - - {% endfor %} - -
- - NameKindSizeUploadedPath
- - - {{ file.original_filename }}
- SHA256: {{ file.sha256 }} -
- {{ file.file_kind }} - {% if file.is_immutable %} -
immutable - {% endif %} -
- {{ "{:,}".format(file.size_bytes or 0) }} bytes - - {{ file.uploaded_at }} - - {{ file.relative_path }} -
-
-
-
-
-{% else %} -
-
-
-
-

No files yet

-

This device does not have any uploaded files recorded yet.

-
-
- Empty -
-
- - -
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/device_files.html.bak.thumb.20260413-073152 b/app/templates/cloud/device_files.html.bak.thumb.20260413-073152 deleted file mode 100644 index 16f1cfb..0000000 --- a/app/templates/cloud/device_files.html.bak.thumb.20260413-073152 +++ /dev/null @@ -1,454 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Device Files - OTB Cloud{% endblock %} - -{% block portal_content %} - - -
-
-

Device Files

-

{{ user_email }}

-

- Browsing files for {{ device.device_name }} ({{ device.device_type }}). -

-
- -
- - - - -
-
File count: {{ file_count }}
-
Device path: {{ device.relative_path }}
-
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if files %} -
-
-
-
-

{% if view_mode == 'gallery' %}Image Gallery{% else %}Files{% endif %}

-

- {% if view_mode == 'gallery' %} - Browse uploaded images visually. Click a thumbnail to preview the full-size image. - {% else %} - Select files to delete, download, or send to Zip Workspace. Rename changes only the customer-facing name, not the immutable stored file. - {% endif %} -

-
-
- DB-backed -
-
- -
-
-
- - - -
-
- - {% if view_mode == 'gallery' %} - - {% else %} - - - - - - - - - - - - - {% for file in files %} - {% set visible_name = file.display_filename or file.original_filename %} - {% set rename_value = file.display_filename.rsplit('.', 1)[0] if file.display_filename and '.' in file.display_filename else file.basename %} - {% set ext = (file.extension or '')|lower %} - {% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %} - - - - - - - - - {% endfor %} - -
- - NameKindSizeUploadedPath
- - -
-
- {{ visible_name }}
- {% if file.display_filename %} - Original: {{ file.original_filename }}
- {% endif %} - {% if is_image %} - - {% endif %} - SHA256: {{ file.sha256 }} -
- -
- - {% if file.extension %} - .{{ file.extension }} - {% endif %} - -
-
-
- {{ file.file_kind }} - {% if file.is_immutable %} -
immutable - {% endif %} -
- {{ "{:,}".format(file.size_bytes or 0) }} bytes - - {{ file.uploaded_at }} - - {{ file.relative_path }} -
- {% endif %} -
-
-
- - - - -{% else %} -
-
-
-
-

No files yet

-

This device does not have any uploaded files recorded yet.

-
-
- Empty -
-
- - -
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/device_files.html.bak.tree.20260413-200734 b/app/templates/cloud/device_files.html.bak.tree.20260413-200734 deleted file mode 100644 index a909662..0000000 --- a/app/templates/cloud/device_files.html.bak.tree.20260413-200734 +++ /dev/null @@ -1,454 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Device Files - OTB Cloud{% endblock %} - -{% block portal_content %} - - -
-
-

Device Files

-

{{ user_email }}

-

- Browsing files for {{ device.device_name }} ({{ device.device_type }}). -

-
- -
- - - - -
-
File count: {{ file_count }}
-
Device path: {{ device.relative_path }}
-
-
-
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -{% if files %} -
-
-
-
-

{% if view_mode == 'gallery' %}Image Gallery{% else %}Files{% endif %}

-

- {% if view_mode == 'gallery' %} - Browse uploaded images visually. Click a thumbnail to preview the full-size image. - {% else %} - Select files to delete, download, or send to Zip Workspace. Rename changes only the customer-facing name, not the immutable stored file. - {% endif %} -

-
-
- DB-backed -
-
- -
-
-
- - - -
-
- - {% if view_mode == 'gallery' %} - - {% else %} - - - - - - - - - - - - - {% for file in files %} - {% set visible_name = file.display_filename or file.original_filename %} - {% set rename_value = file.display_filename.rsplit('.', 1)[0] if file.display_filename and '.' in file.display_filename else file.basename %} - {% set ext = (file.extension or '')|lower %} - {% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %} - - - - - - - - - {% endfor %} - -
- - NameKindSizeUploadedPath
- - -
-
- {{ visible_name }}
- {% if file.display_filename %} - Original: {{ file.original_filename }}
- {% endif %} - {% if is_image %} - - {% endif %} - SHA256: {{ file.sha256 }} -
- -
- - {% if file.extension %} - .{{ file.extension }} - {% endif %} - -
-
-
- {{ file.file_kind }} - {% if file.is_immutable %} -
immutable - {% endif %} -
- {{ "{:,}".format(file.size_bytes or 0) }} bytes - - {{ file.uploaded_at }} - - {{ file.relative_path }} -
- {% endif %} -
-
-
- - - - -{% else %} -
-
-
-
-

No files yet

-

This device does not have any uploaded files recorded yet.

-
-
- Empty -
-
- - -
-
-{% endif %} -{% endblock %} diff --git a/app/templates/cloud/device_new.html.bak.20260413-022612 b/app/templates/cloud/device_new.html.bak.20260413-022612 deleted file mode 100644 index f9a721e..0000000 --- a/app/templates/cloud/device_new.html.bak.20260413-022612 +++ /dev/null @@ -1,89 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Add Device - OTB Cloud{% endblock %} - -{% block portal_content %} -
-
-

Add Device

-

{{ user_email }}

-

- Create a named source for uploads like a laptop, phone, tablet, or workstation. -

-
- - -
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -
-
-
-
-

Device Details

-

This creates a device source and its storage folders.

-
-
- Setup -
-
- -
-
-
-
- - -
- -
- - -
- -
- - Cancel -
-
-
-
-
-
-{% endblock %} diff --git a/app/templates/cloud/image_workshop.html b/app/templates/cloud/image_workshop.html new file mode 100644 index 0000000..fdc6f8a --- /dev/null +++ b/app/templates/cloud/image_workshop.html @@ -0,0 +1,324 @@ +{% extends "portal_base.html" %} +{% block portal_content %} + + + +
+
+
+

Image Workshop

+

Double-click an image to edit it. Max 25 images per batch.

+
+ +
+ +
+
+
+ + + + + +
+
+
+
+
+ +
+
+

Edit Image

+
+ +
+ +
+ + + + + + + +
+ +
+ + +
+ +
+ + + + + +
+ +
+ + + +
+ +

Saving creates a new image in the images folder. Original files are not overwritten.

+
+
+ + + +{% endblock %} + + diff --git a/app/templates/cloud/upload.html.bak.20260413-210211 b/app/templates/cloud/upload.html.bak.20260413-210211 deleted file mode 100644 index a7df538..0000000 --- a/app/templates/cloud/upload.html.bak.20260413-210211 +++ /dev/null @@ -1,96 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Upload Files - OTB Cloud{% endblock %} - -{% block portal_content %} -
-
-

Upload Files

-

{{ user_email }}

-

- Upload files into the {{ device.device_name }} device originals area. -

-
- - -
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -
-
-
-
-

Upload to {{ device.device_name }}

-

Selected files will be stored as immutable originals.

-
-
- {{ device.device_type|capitalize }} -
-
- -
-
-
-
- - -
- -
- - Cancel -
- -
- Files uploaded here are stored in the device originals folder and recorded in the database. -
-
- -
-
- - - -
-
-
-
-{% endblock %} diff --git a/app/templates/cloud/upload.html.bak.folder.20260413-190117 b/app/templates/cloud/upload.html.bak.folder.20260413-190117 deleted file mode 100644 index ee0f2a2..0000000 --- a/app/templates/cloud/upload.html.bak.folder.20260413-190117 +++ /dev/null @@ -1,74 +0,0 @@ -{% extends "portal_base.html" %} - -{% block title %}Upload Files - OTB Cloud{% endblock %} - -{% block portal_content %} -
-
-

Upload Files

-

{{ user_email }}

-

- Upload files into the {{ device.device_name }} device originals area. -

-
- - -
- -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ category|capitalize }}: {{ message }} -
- {% endfor %} -
- {% endif %} -{% endwith %} - -
-
-
-
-

Upload to {{ device.device_name }}

-

Selected files will be stored as immutable originals.

-
-
- {{ device.device_type|capitalize }} -
-
- -
-
-
-
- - -
- -
- - Cancel -
- -
- Files uploaded here are stored in the device originals folder and recorded in the database. -
-
-
-
-
-
-{% endblock %} diff --git a/app/templates/cloud/workshop.html b/app/templates/cloud/workshop.html index 7af5c62..5a12c1b 100644 --- a/app/templates/cloud/workshop.html +++ b/app/templates/cloud/workshop.html @@ -78,7 +78,8 @@

Queue Video Jobs

-

Selected files from the device browser are staged here. Only checked staged files will be processed.

+

Selected files from the device browser are staged here. Only checked staged files will be processed. Limit: 5 original files per batch.

+
Queue: loading... | Active users: loading...
alpha3-l @@ -164,6 +165,10 @@ function getSel(){ } function setSel(items){ + if(Array.isArray(items) && items.length > 5){ + alert("Workshop staging is limited to 5 files at a time."); + items = items.slice(0, 5); + } localStorage.setItem("videoSelection", JSON.stringify(items)); } @@ -336,9 +341,26 @@ function renderJobs(jobs){ }).join("")+'
'; } + +function updateQueueSummary(){ + fetch("/api/video/queue-summary") + .then(r=>r.json()) + .then(d=>{ + const el = document.getElementById("queue-summary"); + if(!el) return; + el.innerHTML = ` +
+ Queue: ${d.queue_count} jobs   |   Active users: ${d.active_users} +
`; + }) + .catch(()=>{}); +} + + function processWorkshop(){ let files=getCheckedStaged(); if(!files.length){alert("No staged files checked");return;} + if(files.length > 5){alert("Only 5 files may be processed in one batch.");return;} const profiles = getSelectedProfiles(); if(!profiles.length){ @@ -390,6 +412,9 @@ document.getElementById("manualRotationToggle").addEventListener("change", funct renderSel(); loadJobs(); -setInterval(loadJobs,5000); +setInterval(()=>{ + loadJobs(); + updateQueueSummary(); +},5000); {% endblock %} diff --git a/app/templates/portal_base.html.bak.20260412-235158 b/app/templates/portal_base.html.bak.20260412-235158 deleted file mode 100644 index 34e7c01..0000000 --- a/app/templates/portal_base.html.bak.20260412-235158 +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - {% block title %}OTB Cloud{% endblock %} - - - -
- -
- -
- {% block content %}{% endblock %} -
- - - - diff --git a/patch.sh b/patch.sh deleted file mode 100755 index 5613205..0000000 --- a/patch.sh +++ /dev/null @@ -1,170 +0,0 @@ -cd /opt/otb_cloud || exit 1 - -echo "===== backups =====" -STAMP=$(date +%Y%m%d-%H%M%S) - -cp app/models/schema.sql /home/def/backuphere/schema.sql.$STAMP.bak -cp app/auth/utils.py /home/def/backuphere/utils.py.$STAMP.bak -cp VERSION /home/def/backuphere/VERSION.$STAMP.bak 2>/dev/null || true -cp PROJECT_STATE.md /home/def/backuphere/PROJECT_STATE.md.$STAMP.bak 2>/dev/null || true -cp README.md /home/def/backuphere/README.md.$STAMP.bak 2>/dev/null || true - -echo "===== extend schema =====" -cat >> app/models/schema.sql <<'EOF' - --- =============================== --- v1.1.0-alpha1 VIDEO JOB SYSTEM --- =============================== - -CREATE TABLE IF NOT EXISTS video_jobs ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - device_id INT NOT NULL, - source_file_id BIGINT NULL, - source_relative_path VARCHAR(1000) NOT NULL, - source_original_filename VARCHAR(255) NOT NULL, - requested_profile VARCHAR(50) NOT NULL, - requested_gpu_preference VARCHAR(20) NOT NULL DEFAULT 'auto', - assigned_processor VARCHAR(20) NULL, - status VARCHAR(50) NOT NULL DEFAULT 'queued', - progress_percent INT NOT NULL DEFAULT 0, - output_relative_path VARCHAR(1000) NULL, - output_file_id BIGINT NULL, - log_excerpt LONGTEXT NULL, - error_message LONGTEXT NULL, - gpu_seconds INT NOT NULL DEFAULT 0, - started_at DATETIME NULL, - completed_at DATETIME NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by_user_id INT NULL, - CONSTRAINT fk_video_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), - CONSTRAINT fk_video_jobs_device FOREIGN KEY (device_id) REFERENCES devices(id) -); - -CREATE TABLE IF NOT EXISTS tenant_usage_metrics ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - tenant_id INT NOT NULL, - storage_bytes_originals BIGINT NOT NULL DEFAULT 0, - storage_bytes_video BIGINT NOT NULL DEFAULT 0, - storage_bytes_archive BIGINT NOT NULL DEFAULT 0, - storage_bytes_lts BIGINT NOT NULL DEFAULT 0, - storage_bytes_total BIGINT NOT NULL DEFAULT 0, - gpu_seconds_intel BIGINT NOT NULL DEFAULT 0, - gpu_seconds_amd BIGINT NOT NULL DEFAULT 0, - gpu_seconds_cpu BIGINT NOT NULL DEFAULT 0, - completed_jobs INT NOT NULL DEFAULT 0, - failed_jobs INT NOT NULL DEFAULT 0, - accrued_storage_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00, - accrued_gpu_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00, - calculated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_metrics_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) -); - -EOF - -echo "===== update device directory structure =====" -python3 <<'PY' -from pathlib import Path - -p = Path("app/auth/utils.py") -txt = p.read_text() - -old = """for subdir in ["originals", "derived", "exports", "deleted", "tmp"]:""" - -new = """for subdir in ["originals", "video", "video-workshop", "archive", "lts", "derived", "exports", "deleted", "tmp"]:""" - -if old not in txt: - raise SystemExit("PATCH FAIL: device dir block not found") - -txt = txt.replace(old, new) -p.write_text(txt) - -print("device directory structure updated") -PY - -echo "===== create service scaffolding =====" -mkdir -p app/services - -cat > app/services/video_jobs.py <<'EOF' -def create_job(db, tenant_id, device_id, source_path, filename, profile): - return { - "tenant_id": tenant_id, - "device_id": device_id, - "source_path": source_path, - "filename": filename, - "profile": profile, - "status": "queued" - } -EOF - -cat > app/services/video_metrics.py <<'EOF' -def recalc_metrics(db, tenant_id): - # placeholder for v1.1.0 - return {"ok": True} -EOF - -cat > app/services/gpu_select.py <<'EOF' -def select_processor(): - # v1.1.0 logic placeholder - return "intel" -EOF - -cat > app/services/video_profiles.py <<'EOF' -PROFILES = { - "portrait_web": "portrait web encode", - "landscape_web": "landscape web encode", - "high_quality_cpu": "cpu encode", - "archive_only": "no processing" -} -EOF - -cat > app/services/video_paths.py <<'EOF' -def device_paths(base): - return { - "originals": f"{base}/originals", - "video": f"{base}/video", - "video_workshop": f"{base}/video-workshop", - "archive": f"{base}/archive", - "lts": f"{base}/lts" - } -EOF - -echo "===== create worker scaffold =====" -cat > app/services/video_worker.py <<'EOF' -import time - -def run_worker(): - print("video worker starting (stub)") - while True: - time.sleep(10) -EOF - -echo "===== bump version =====" -echo "v1.1.0-alpha1" > VERSION - -echo "===== update PROJECT_STATE.md =====" -cat >> PROJECT_STATE.md <<'EOF' - -## v1.1.0-alpha1 — Video System Foundation -- Added video_jobs table (processing queue) -- Added tenant_usage_metrics table (dashboard metrics) -- Added video service scaffolding (jobs, metrics, gpu select, profiles) -- Extended device structure to include: - - video - - video-workshop - - archive - - lts -- Prepared system for background worker architecture - -Next step: -- Build video worker processing engine -EOF - -echo "===== update README.md =====" -sed -i '1i\ -## v1.1.0-alpha1 — Video System Foundation\n- Introduced video job queue system\n- Introduced tenant usage metrics\n- Added video processing scaffolding\n- Prepared for GPU worker processing\n' README.md - -echo "===== verify =====" -python3 -m py_compile app/auth/utils.py - -echo "===== done =====" diff --git a/patch1.sh b/patch1.sh deleted file mode 100755 index fadc975..0000000 --- a/patch1.sh +++ /dev/null @@ -1,22 +0,0 @@ -cd /opt/otb_cloud || exit 1 - -echo "===== version =====" -cat VERSION - -echo -echo "===== new tables present in schema =====" -grep -n "CREATE TABLE IF NOT EXISTS video_jobs" -A25 app/models/schema.sql -echo -grep -n "CREATE TABLE IF NOT EXISTS tenant_usage_metrics" -A20 app/models/schema.sql - -echo -echo "===== new services =====" -find app/services -maxdepth 1 -type f | sort - -echo -echo "===== updated device dirs helper =====" -grep -n 'for subdir in \[' -A2 app/auth/utils.py - -echo -echo "===== git status =====" -git status --short diff --git a/scripts/make_test_handoff.py.bak.20260412-235158 b/scripts/make_test_handoff.py.bak.20260412-235158 deleted file mode 100644 index 93b3118..0000000 --- a/scripts/make_test_handoff.py.bak.20260412-235158 +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -import hashlib -import hmac -import os -import time -import urllib.parse - -from dotenv import load_dotenv - -load_dotenv() - -secret = os.getenv("OTB_PORTAL_SHARED_SECRET", "change-me") -uid = os.getenv("OTB_TEST_UID", "1001") -email = os.getenv("OTB_TEST_EMAIL", "client@example.com") -ts = str(int(time.time())) - -payload = f"{uid}|{email}|{ts}".encode("utf-8") -sig = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() - -params = urllib.parse.urlencode({ - "uid": uid, - "email": email, - "ts": ts, - "sig": sig, -}) - -print(f"http://127.0.0.1:5090/auth/handoff?{params}")