diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index c74078c..05d5c2f 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -48,3 +48,56 @@ Portal-authenticated secure backup and storage platform for customer files, incl 4. Add media processing jobs 5. Add derived/original filtering 6. Add better single-file actions in browser + + +## Current update: v0.2.5 +- Added inline image serving route for browser previews +- Added device browser view toggle: list or gallery +- Added gallery cards with thumbnails, preview modal, rename, download, and checkbox actions +- Existing bulk delete, download, and zip staging continue to work in both views + +## v0.2.5 — Gallery View + Image Preview + +### Added +- Gallery view toggle for device file browser +- Image thumbnail rendering (inline file route) +- Click-to-preview full image modal +- Gallery cards with: + - checkbox selection + - rename input + - download button + - preview button + +### Improved +- File browsing now supports both: + - list (management) + - gallery (visual) +- Bulk actions work in both views +- Display filename system fully integrated across UI + +### Notes +- Originals remain immutable +- Thumbnails currently use original images (no derived images yet) +- Foundation ready for future media processing pipeline + + + +## Current update: v0.2.8 +- Added folder-tree browser scoped by current path +- Added clickable breadcrumbs for direct jumps to any parent folder +- Added folders-first navigation while preserving list/gallery modes for files in the current folder +- Browser now reflects preserved backup folder structure instead of flattening all files into one device-wide listing + +## 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 diff --git a/README.md b/README.md index 2e27f24..a508cd1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +## v1.1.0-alpha1 — Video System Foundation +- Introduced video job queue system +- Introduced tenant usage metrics +- Added video processing scaffolding +- Prepared for GPU worker processing + ## v0.2.6 — Pre-LTS Save Point - Backup created before LTS / cold storage archive workflow - Android photo dump continuation now working with skip-existing behavior diff --git a/VERSION b/VERSION index 576b777..1710bc8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.2.3 +v1.1.0-alpha1 diff --git a/app/auth/routes.py.bak.uidguard.20260413-004811 b/app/auth/routes.py.bak.uidguard.20260413-004811 new file mode 100644 index 0000000..10514e0 --- /dev/null +++ b/app/auth/routes.py.bak.uidguard.20260413-004811 @@ -0,0 +1,59 @@ +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 b/app/auth/utils.py index 04c3cc0..448aa3f 100644 --- a/app/auth/utils.py +++ b/app/auth/utils.py @@ -129,7 +129,7 @@ def create_tenant_root(tenant_slug: str): 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"]: + for subdir in ["originals", "video", "video-workshop", "archive", "lts", "derived", "exports", "deleted", "tmp"]: (base / subdir).mkdir(parents=True, exist_ok=True) def remove_device_directories(tenant_slug: str, device_path: str): diff --git a/app/auth/utils.py.bak.20260413-015405 b/app/auth/utils.py.bak.20260413-015405 new file mode 100644 index 0000000..29957f7 --- /dev/null +++ b/app/auth/utils.py.bak.20260413-015405 @@ -0,0 +1,135 @@ +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 new file mode 100644 index 0000000..de359f1 --- /dev/null +++ b/app/auth/utils.py.bak.20260413-021018 @@ -0,0 +1,130 @@ +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 new file mode 100644 index 0000000..cc6c6b5 --- /dev/null +++ b/app/auth/utils.py.bak.20260413-024827 @@ -0,0 +1,137 @@ +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 new file mode 100644 index 0000000..9e8d265 --- /dev/null +++ b/app/auth/utils.py.bak.20260413-051439 @@ -0,0 +1,144 @@ +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 2072d5f..2d45bb8 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -3,9 +3,13 @@ from pathlib import Path from datetime import datetime, timezone import shutil import zipfile +import tarfile +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 +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 @@ -33,7 +37,6 @@ def _stored_name(original_name: str) -> str: 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: @@ -43,6 +46,41 @@ def _recovered_filename(original_name: str) -> tuple[str, str, str]: 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 @@ -132,6 +170,61 @@ def dashboard(): 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(): @@ -234,18 +327,42 @@ def delete_device(device_id: int): with db.cursor() as cur: cur.execute( """ - SELECT COUNT(*) AS file_count + 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 AND is_deleted = 0 + WHERE tenant_id = %s AND device_id = %s """, (session["otb_tenant_id"], device_id), ) - file_count = cur.fetchone()["file_count"] + 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 file_count and int(file_count) > 0: - flash("This device cannot be removed because files are still linked to it.", "warning") + 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 @@ -286,6 +403,16 @@ def upload_files(device_id: int): 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", @@ -314,8 +441,26 @@ def upload_files(device_id: int): 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 + + # 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) @@ -328,8 +473,12 @@ def upload_files(device_id: int): else: basename, extension = base_name, "" - relative_path = f"{device['relative_path']}/originals/{stored_name}" - directory_path = f"{device['relative_path']}/originals" + 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( """ @@ -382,6 +531,70 @@ def upload_files(device_id: int): 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): @@ -390,7 +603,7 @@ def download_file(file_id: int): with db.cursor() as cur: cur.execute( """ - SELECT id, original_filename, relative_path + SELECT id, original_filename, display_filename, relative_path FROM files WHERE id = %s AND tenant_id = %s """, @@ -407,7 +620,7 @@ def download_file(file_id: int): 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"]) + 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 @@ -417,6 +630,11 @@ def download_selected_files(device_id: int): 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()] @@ -426,7 +644,7 @@ def download_selected_files(device_id: int): 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") + flash("Download Selected currently supports one file at a time. Use Archive 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])) @@ -439,6 +657,11 @@ def delete_selected_files(device_id: int): 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()] @@ -520,6 +743,11 @@ def send_selected_to_zip_workspace(device_id: int): 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()] @@ -537,7 +765,7 @@ def send_selected_to_zip_workspace(device_id: int): for file_id in selected_ids: cur.execute( """ - SELECT id, original_filename, relative_path, is_deleted + SELECT id, original_filename, display_filename, relative_path, is_deleted FROM files WHERE id = %s AND tenant_id = %s AND device_id = %s """, @@ -552,7 +780,7 @@ def send_selected_to_zip_workspace(device_id: int): if not source_path.exists(): continue - target_name = _stored_name(file_row["original_filename"]) + target_name = _stored_name(_display_filename(file_row)) target_path = staging_dir / target_name shutil.copy2(source_path, target_path) @@ -568,7 +796,7 @@ def send_selected_to_zip_workspace(device_id: int): file_id, _client_ip(), request.headers.get("User-Agent", ""), - f"Copied '{file_row['original_filename']}' into zip workspace", + f"Copied '{_display_filename(file_row)}' into zip workspace", ), ) @@ -576,7 +804,7 @@ def send_selected_to_zip_workspace(device_id: int): db.commit() - flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success") + flash(f"Sent {copied_count} file(s) to Archive Workspace.", "success") return redirect(url_for("main.browse_device_files", device_id=device_id)) @bp.route("/workspace/zip", methods=["GET"]) @@ -625,15 +853,41 @@ def create_zip_from_workspace(): staged = [p for p in staging_dir.iterdir() if p.is_file()] if not staged: - flash("Zip Workspace is empty.", "warning") + flash("Archive 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) + archive_name = (request.form.get("archive_name") or "").strip() + archive_format = (request.form.get("format") or "zip").strip().lower() + + if not archive_name: + archive_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}" + + archive_name = re.sub(r"[^A-Za-z0-9._-]+", "_", archive_name).strip("._-") + if not archive_name: + archive_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}" + + if archive_format == "tar": + archive_filename = f"{archive_name}.tar" + archive_path = exports_dir / archive_filename + with tarfile.open(archive_path, "w") as tf: + for p in staged: + arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name + tf.add(p, arcname=arcname) + elif archive_format == "targz": + archive_filename = f"{archive_name}.tar.gz" + archive_path = exports_dir / archive_filename + with tarfile.open(archive_path, "w:gz") as tf: + for p in staged: + arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name + tf.add(p, arcname=arcname) + else: + archive_format = "zip" + archive_filename = f"{archive_name}.zip" + archive_path = exports_dir / archive_filename + with zipfile.ZipFile(archive_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) @@ -651,12 +905,12 @@ def create_zip_from_workspace(): 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", + f"Created {archive_format} export '{archive_filename}' 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") + flash(f"Archive created successfully. You can find it in the Exports section below as '{archive_filename}'.", "success") return redirect(url_for("main.zip_workspace")) @bp.route("/workspace/exports//download", methods=["GET"]) @@ -705,8 +959,6 @@ def deleted_files(): files=files, ) - - @bp.route("/deleted//recover", methods=["POST"]) @portal_session_required def recover_deleted_file(file_id: int): @@ -760,6 +1012,7 @@ def recover_deleted_file(file_id: int): """ UPDATE files SET original_filename = %s, + display_filename = NULL, basename = %s, extension = %s, relative_path = %s, @@ -800,7 +1053,6 @@ def recover_deleted_file(file_id: int): 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): @@ -848,6 +1100,88 @@ def hard_delete_file(file_id: int): 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): @@ -858,6 +1192,14 @@ 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() + 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( """ @@ -867,6 +1209,7 @@ def browse_device_files(device_id: int): relative_path, directory_path, original_filename, + display_filename, basename, extension, mime_type, @@ -878,12 +1221,70 @@ def browse_device_files(device_id: int): 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), + (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"), @@ -891,4 +1292,317 @@ def browse_device_files(device_id: int): 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"] + }) + +@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("/api/android/file-exists", methods=["GET"]) +def android_file_exists(): + db = get_db() + + device_uuid = (request.args.get("device_uuid") or "").strip() + original_filename = (request.args.get("original_filename") or "").strip() + size_bytes_raw = (request.args.get("size_bytes") or "").strip() + + if not device_uuid: + return jsonify({"ok": False, "error": "missing_device_uuid"}), 400 + + if not original_filename: + return jsonify({"ok": False, "error": "missing_original_filename"}), 400 + + try: + size_bytes = int(size_bytes_raw) + except Exception: + return jsonify({"ok": False, "error": "invalid_size_bytes"}), 400 + + with db.cursor() as cur: + cur.execute( + """ + SELECT + t.tenant_id, + t.device_id, + t.status + FROM android_device_tokens t + WHERE t.device_uuid = %s + LIMIT 1 + """, + (device_uuid,), + ) + token_row = cur.fetchone() + + if not token_row: + return jsonify({"ok": False, "error": "device_not_found"}), 404 + + if token_row["status"] != "activated": + return jsonify({"ok": False, "error": "device_not_activated"}), 403 + + cur.execute( + """ + SELECT id + FROM files + WHERE tenant_id = %s + AND device_id = %s + AND is_deleted = 0 + AND original_filename = %s + AND size_bytes = %s + LIMIT 1 + """, + ( + token_row["tenant_id"], + token_row["device_id"], + original_filename, + size_bytes, + ), + ) + file_row = cur.fetchone() + + return jsonify({ + "ok": True, + "exists": bool(file_row), + }), 200 + + +@bp.route("/workspace/exports//move-to-lts", methods=["POST"]) +@portal_session_required +def move_export_to_lts(filename: str): + tenant_root = _tenant_root() + exports_dir = tenant_root / "exports" + lts_dir = tenant_root / "lts" + + lts_dir.mkdir(parents=True, exist_ok=True) + + src = exports_dir / filename + dst = lts_dir / filename + + if not src.exists(): + flash("Archive not found.", "warning") + return redirect(url_for("main.zip_workspace")) + + src.rename(dst) + + flash(f"Moved '{filename}' to LTS storage.", "success") + return redirect(url_for("main.zip_workspace")) + + +@bp.route("/workspace/exports//download-remove", methods=["GET"]) +@portal_session_required +def download_and_remove_export(filename: str): + tenant_root = _tenant_root() + exports_dir = tenant_root / "exports" + file_path = exports_dir / filename + + if not file_path.exists(): + flash("Archive not found.", "warning") + return redirect(url_for("main.zip_workspace")) + + response = send_file(file_path, as_attachment=True, download_name=file_path.name) + + @response.call_on_close + def cleanup(): + try: + file_path.unlink(missing_ok=True) + except Exception: + pass + + return response + + +@bp.route("/workspace/lts", methods=["GET"]) +@portal_session_required +def lts_view(): + tenant_root = _tenant_root() + lts_dir = tenant_root / "lts" + lts_dir.mkdir(parents=True, exist_ok=True) + + lts_files = [] + for p in lts_dir.iterdir(): + if p.is_file(): + lts_files.append({ + "name": p.name, + "size_bytes": p.stat().st_size, + "path": str(p), + }) + + return render_template( + "cloud/lts.html", + user_email=session.get("otb_email"), + tenant_slug=session.get("otb_tenant_slug"), + lts_files=lts_files, ) diff --git a/app/main/routes.py.bak.20260413-015405 b/app/main/routes.py.bak.20260413-015405 new file mode 100644 index 0000000..38569a0 --- /dev/null +++ b/app/main/routes.py.bak.20260413-015405 @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..09a881b --- /dev/null +++ b/app/main/routes.py.bak.20260413-021018 @@ -0,0 +1,135 @@ +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 new file mode 100644 index 0000000..9be4fcb --- /dev/null +++ b/app/main/routes.py.bak.20260413-024827 @@ -0,0 +1,199 @@ +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 new file mode 100644 index 0000000..143c0fe --- /dev/null +++ b/app/main/routes.py.bak.20260413-032130 @@ -0,0 +1,331 @@ +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 new file mode 100644 index 0000000..67b4a28 --- /dev/null +++ b/app/main/routes.py.bak.20260413-051439 @@ -0,0 +1,385 @@ +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 new file mode 100644 index 0000000..a3d21ad --- /dev/null +++ b/app/main/routes.py.bak.20260413-054006 @@ -0,0 +1,788 @@ +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 new file mode 100644 index 0000000..d33a5da --- /dev/null +++ b/app/main/routes.py.bak.android.20260414-002002 @@ -0,0 +1,1212 @@ +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 new file mode 100644 index 0000000..ae68649 --- /dev/null +++ b/app/main/routes.py.bak.androidactivate.20260414-023849 @@ -0,0 +1,1278 @@ +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 new file mode 100644 index 0000000..ad4d69f --- /dev/null +++ b/app/main/routes.py.bak.androidlock.20260414-014204 @@ -0,0 +1,1268 @@ +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 new file mode 100644 index 0000000..937a526 --- /dev/null +++ b/app/main/routes.py.bak.androidrepair.20260415-065050 @@ -0,0 +1,1529 @@ +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 new file mode 100644 index 0000000..025b5d9 --- /dev/null +++ b/app/main/routes.py.bak.androidupload.20260415-063731 @@ -0,0 +1,1410 @@ +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 new file mode 100644 index 0000000..1dabdbe --- /dev/null +++ b/app/main/routes.py.bak.androidupload.20260415-214446 @@ -0,0 +1,1529 @@ +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 new file mode 100644 index 0000000..a5c445c --- /dev/null +++ b/app/main/routes.py.bak.androidupload_manual.20260415-231918 @@ -0,0 +1,1582 @@ +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 new file mode 100644 index 0000000..c0fdbc6 --- /dev/null +++ b/app/main/routes.py.bak.androiduploadlock.20260414-015331 @@ -0,0 +1,1273 @@ +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 new file mode 100644 index 0000000..d8bd96e --- /dev/null +++ b/app/main/routes.py.bak.deletefix.20260414-031044 @@ -0,0 +1,1400 @@ +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 new file mode 100644 index 0000000..ffadc8b --- /dev/null +++ b/app/main/routes.py.bak.fix_android_route.20260414-004358 @@ -0,0 +1,1269 @@ +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 new file mode 100644 index 0000000..ab08dce --- /dev/null +++ b/app/main/routes.py.bak.folder.20260413-190117 @@ -0,0 +1,1112 @@ +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 new file mode 100644 index 0000000..7c0452e --- /dev/null +++ b/app/main/routes.py.bak.gallery.20260413-071415 @@ -0,0 +1,998 @@ +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 new file mode 100644 index 0000000..d8bd96e --- /dev/null +++ b/app/main/routes.py.bak.gracefuldelete.20260414-025910 @@ -0,0 +1,1400 @@ +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 new file mode 100644 index 0000000..edbbec0 --- /dev/null +++ b/app/main/routes.py.bak.gracefuldelete3.20260414-032125 @@ -0,0 +1,1386 @@ +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 new file mode 100644 index 0000000..edbbec0 --- /dev/null +++ b/app/main/routes.py.bak.gracefuldelete4.20260414-032525 @@ -0,0 +1,1386 @@ +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 new file mode 100644 index 0000000..937a526 --- /dev/null +++ b/app/main/routes.py.bak.gracefuldelete4.20260415-065022 @@ -0,0 +1,1529 @@ +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 new file mode 100644 index 0000000..2072d5f --- /dev/null +++ b/app/main/routes.py.bak.rename.20260413-064038 @@ -0,0 +1,894 @@ +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 new file mode 100644 index 0000000..2072d5f --- /dev/null +++ b/app/main/routes.py.bak.rename.20260413-064842 @@ -0,0 +1,894 @@ +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 new file mode 100644 index 0000000..2072d5f --- /dev/null +++ b/app/main/routes.py.bak.rename.20260413-065605 @@ -0,0 +1,894 @@ +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 new file mode 100644 index 0000000..b230f4d --- /dev/null +++ b/app/main/routes.py.bak.thumb.20260413-073152 @@ -0,0 +1,1068 @@ +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 new file mode 100644 index 0000000..8f28b2d --- /dev/null +++ b/app/main/routes.py.bak.tree.20260413-200734 @@ -0,0 +1,1134 @@ +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 new file mode 100644 index 0000000..114bb9c --- /dev/null +++ b/app/main/routes.py.bak.viewmode.1776064865 @@ -0,0 +1,1038 @@ +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 new file mode 100644 index 0000000..937a526 --- /dev/null +++ b/app/main/routes.py.broken.1776293208 @@ -0,0 +1,1529 @@ +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 b/app/models/schema.sql index 9cf0f38..f7f7fb3 100644 --- a/app/models/schema.sql +++ b/app/models/schema.sql @@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS files ( 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, @@ -101,3 +102,67 @@ CREATE TABLE IF NOT EXISTS admin_access_tokens ( 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) ); + +CREATE TABLE IF NOT EXISTS android_device_tokens ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id INT NOT NULL, + device_id INT NOT NULL, + token_hash CHAR(64) NOT NULL, + device_label VARCHAR(100) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'issued', + expires_at DATETIME NOT NULL, + activated_at DATETIME NULL, + device_uuid VARCHAR(100) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_android_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), + CONSTRAINT fk_android_token_device FOREIGN KEY (device_id) REFERENCES devices(id) +); + +-- =============================== +-- 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) +); + diff --git a/app/models/schema.sql.bak.android.20260414-002002 b/app/models/schema.sql.bak.android.20260414-002002 new file mode 100644 index 0000000..b52e9e3 --- /dev/null +++ b/app/models/schema.sql.bak.android.20260414-002002 @@ -0,0 +1,104 @@ +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 new file mode 100644 index 0000000..9cf0f38 --- /dev/null +++ b/app/models/schema.sql.bak.rename.20260413-064038 @@ -0,0 +1,103 @@ +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 new file mode 100644 index 0000000..9cf0f38 --- /dev/null +++ b/app/models/schema.sql.bak.rename.20260413-064842 @@ -0,0 +1,103 @@ +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 new file mode 100644 index 0000000..9cf0f38 --- /dev/null +++ b/app/models/schema.sql.bak.rename.20260413-065605 @@ -0,0 +1,103 @@ +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/gpu_select.py b/app/services/gpu_select.py new file mode 100644 index 0000000..176ced0 --- /dev/null +++ b/app/services/gpu_select.py @@ -0,0 +1,3 @@ +def select_processor(): + # v1.1.0 logic placeholder + return "intel" diff --git a/app/services/video_jobs.py b/app/services/video_jobs.py new file mode 100644 index 0000000..c2bfabf --- /dev/null +++ b/app/services/video_jobs.py @@ -0,0 +1,9 @@ +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" + } diff --git a/app/services/video_metrics.py b/app/services/video_metrics.py new file mode 100644 index 0000000..7e3765d --- /dev/null +++ b/app/services/video_metrics.py @@ -0,0 +1,3 @@ +def recalc_metrics(db, tenant_id): + # placeholder for v1.1.0 + return {"ok": True} diff --git a/app/services/video_paths.py b/app/services/video_paths.py new file mode 100644 index 0000000..f4a33af --- /dev/null +++ b/app/services/video_paths.py @@ -0,0 +1,8 @@ +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" + } diff --git a/app/services/video_profiles.py b/app/services/video_profiles.py new file mode 100644 index 0000000..632fe34 --- /dev/null +++ b/app/services/video_profiles.py @@ -0,0 +1,6 @@ +PROFILES = { + "portrait_web": "portrait web encode", + "landscape_web": "landscape web encode", + "high_quality_cpu": "cpu encode", + "archive_only": "no processing" +} diff --git a/app/services/video_worker.py b/app/services/video_worker.py new file mode 100644 index 0000000..d94f22d --- /dev/null +++ b/app/services/video_worker.py @@ -0,0 +1,6 @@ +import time + +def run_worker(): + print("video worker starting (stub)") + while True: + time.sleep(10) diff --git a/app/templates/auth/handoff_error.html.bak.20260412-235158 b/app/templates/auth/handoff_error.html.bak.20260412-235158 new file mode 100644 index 0000000..4951fed --- /dev/null +++ b/app/templates/auth/handoff_error.html.bak.20260412-235158 @@ -0,0 +1,10 @@ +{% 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 new file mode 100644 index 0000000..2b23c6f --- /dev/null +++ b/app/templates/auth/login_required.html.bak.20260412-235158 @@ -0,0 +1,16 @@ +{% 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/android_device_new.html b/app/templates/cloud/android_device_new.html new file mode 100644 index 0000000..e4e63cb --- /dev/null +++ b/app/templates/cloud/android_device_new.html @@ -0,0 +1,19 @@ +{% extends "portal_base.html" %} + +{% block title %}Add Android Device{% endblock %} + +{% block portal_content %} +
+
+

Add Android Backup Device

+

Create a phone backup target and generate activation token.

+
+
+ +
+
+ + +
+
+{% endblock %} diff --git a/app/templates/cloud/dashboard.html b/app/templates/cloud/dashboard.html index 0b14f0a..d809a8c 100644 --- a/app/templates/cloud/dashboard.html +++ b/app/templates/cloud/dashboard.html @@ -15,7 +15,8 @@
Add Device - Zip Workspace + Add Android Device + Archive Workspace Deleted Files Back to Services Logout @@ -63,7 +64,11 @@ ({{ device.device_type }})
{{ device.relative_path }}
- Upload Files + {% if device.device_type != 'android' %} + Upload Files + {% else %} + APK Upload Only + {% endif %} Browse Files
@@ -108,6 +113,7 @@ diff --git a/app/templates/cloud/dashboard.html.bak.20260412-235158 b/app/templates/cloud/dashboard.html.bak.20260412-235158 new file mode 100644 index 0000000..ce18930 --- /dev/null +++ b/app/templates/cloud/dashboard.html.bak.20260412-235158 @@ -0,0 +1,37 @@ +{% 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 new file mode 100644 index 0000000..a36e9b3 --- /dev/null +++ b/app/templates/cloud/dashboard.html.bak.20260413-015405 @@ -0,0 +1,77 @@ +{% 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 new file mode 100644 index 0000000..db6fb03 --- /dev/null +++ b/app/templates/cloud/dashboard.html.bak.20260413-021018 @@ -0,0 +1,124 @@ +{% 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 new file mode 100644 index 0000000..37bdca9 --- /dev/null +++ b/app/templates/cloud/dashboard.html.bak.20260413-024827 @@ -0,0 +1,127 @@ +{% 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 new file mode 100644 index 0000000..a2bf9f5 --- /dev/null +++ b/app/templates/cloud/dashboard.html.bak.20260413-032130 @@ -0,0 +1,130 @@ +{% 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 new file mode 100644 index 0000000..8319bcc --- /dev/null +++ b/app/templates/cloud/dashboard.html.bak.20260413-051439 @@ -0,0 +1,131 @@ +{% 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 new file mode 100644 index 0000000..0b14f0a --- /dev/null +++ b/app/templates/cloud/dashboard.html.bak.android.20260414-002002 @@ -0,0 +1,133 @@ +{% 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 new file mode 100644 index 0000000..4217600 --- /dev/null +++ b/app/templates/cloud/dashboard.html.bak.androidlock.20260414-014204 @@ -0,0 +1,135 @@ +{% 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.bak.20260413-054006 b/app/templates/cloud/deleted_files.html.bak.20260413-054006 new file mode 100644 index 0000000..c82500e --- /dev/null +++ b/app/templates/cloud/deleted_files.html.bak.20260413-054006 @@ -0,0 +1,102 @@ +{% 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 ff59c40..9b31f3a 100644 --- a/app/templates/cloud/device_files.html +++ b/app/templates/cloud/device_files.html @@ -3,6 +3,227 @@ {% block title %}Device Files - OTB Cloud{% endblock %} {% block portal_content %} + +

Device Files

@@ -10,16 +231,32 @@

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

+ +
+ {% for crumb in breadcrumbs %} + {{ crumb.label }} + {% endfor %} + {% if current_path %} + Up One Level + {% endif %} +
+ + +
-
File count: {{ file_count }}
+
Current folder file count: {{ file_count }}
+
Subfolders here: {{ folders|length }}
Device path: {{ device.relative_path }}
@@ -37,27 +274,116 @@ {% endif %} {% endwith %} +
+
+
+
+

Folders

+

Navigate the preserved backup structure like a normal file browser.

+
+
+ Tree View +
+
+ + {% if folders %} +
+ {% for folder in folders %} + +
📁 {{ folder.name }}
+
Open folder
+
+ {% endfor %} +
+ {% else %} +
No subfolders in this location.
+ {% endif %} +
+
+ {% if files %}
-

Files

-

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

+

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

+

+ {% if view_mode == 'gallery' %} + Gallery view is scoped to the current folder only. + {% else %} + Bulk actions and rename apply to files in the current folder only. + {% endif %} +

- DB-backed + Scoped
-
+
- +
+
+ + {% if view_mode == 'gallery' %} + + {% else %} @@ -73,13 +399,41 @@ {% 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'] %}
- + - {{ file.original_filename }}
- SHA256: {{ file.sha256 }} +
+
+ {{ 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 }} @@ -100,26 +454,78 @@ {% endfor %}
- + {% endif %}
+ + + + {% else %}
-

No files yet

-

This device does not have any uploaded files recorded yet.

+

No files in this folder

+

This location does not currently contain any active files.

Empty
- -
{% endif %} diff --git a/app/templates/cloud/device_files.html.bak.20260413-051439 b/app/templates/cloud/device_files.html.bak.20260413-051439 new file mode 100644 index 0000000..29714c1 --- /dev/null +++ b/app/templates/cloud/device_files.html.bak.20260413-051439 @@ -0,0 +1,101 @@ +{% 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 new file mode 100644 index 0000000..963899a --- /dev/null +++ b/app/templates/cloud/device_files.html.bak.gallery.20260413-071415 @@ -0,0 +1,146 @@ +{% 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 new file mode 100644 index 0000000..000ec51 --- /dev/null +++ b/app/templates/cloud/device_files.html.bak.previewfix.1776065858 @@ -0,0 +1,454 @@ +{% 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 new file mode 100644 index 0000000..ff59c40 --- /dev/null +++ b/app/templates/cloud/device_files.html.bak.rename.20260413-064038 @@ -0,0 +1,126 @@ +{% 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 new file mode 100644 index 0000000..ff59c40 --- /dev/null +++ b/app/templates/cloud/device_files.html.bak.rename.20260413-064842 @@ -0,0 +1,126 @@ +{% 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 new file mode 100644 index 0000000..ff59c40 --- /dev/null +++ b/app/templates/cloud/device_files.html.bak.rename.20260413-065605 @@ -0,0 +1,126 @@ +{% 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 new file mode 100644 index 0000000..16f1cfb --- /dev/null +++ b/app/templates/cloud/device_files.html.bak.thumb.20260413-073152 @@ -0,0 +1,454 @@ +{% 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 new file mode 100644 index 0000000..a909662 --- /dev/null +++ b/app/templates/cloud/device_files.html.bak.tree.20260413-200734 @@ -0,0 +1,454 @@ +{% 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 new file mode 100644 index 0000000..f9a721e --- /dev/null +++ b/app/templates/cloud/device_new.html.bak.20260413-022612 @@ -0,0 +1,89 @@ +{% 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/lts.html b/app/templates/cloud/lts.html new file mode 100644 index 0000000..7309bfd --- /dev/null +++ b/app/templates/cloud/lts.html @@ -0,0 +1,63 @@ +{% extends "portal_base.html" %} + +{% block title %}LTS Storage - OTB Cloud{% endblock %} + +{% block portal_content %} +
+
+

LTS Storage

+

{{ user_email }}

+

+ Long-term stored archives. +

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

LTS Archives

+

Archive files retained in long-term storage.

+
+
+ {{ lts_files|length }} stored +
+
+ +
+ {% if lts_files %} +
    + {% for item in lts_files %} +
  • + {{ item.name }}
    + {{ "{:,}".format(item.size_bytes) }} bytes +
  • + {% endfor %} +
+ {% else %} +

No files in LTS storage yet.

+ {% endif %} +
+
+
+{% endblock %} diff --git a/app/templates/cloud/upload.html b/app/templates/cloud/upload.html index ee0f2a2..0154546 100644 --- a/app/templates/cloud/upload.html +++ b/app/templates/cloud/upload.html @@ -46,6 +46,7 @@
+
+
+ +
+ +
@@ -66,9 +79,51 @@
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.20260413-210211 b/app/templates/cloud/upload.html.bak.20260413-210211 new file mode 100644 index 0000000..a7df538 --- /dev/null +++ b/app/templates/cloud/upload.html.bak.20260413-210211 @@ -0,0 +1,96 @@ +{% 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 new file mode 100644 index 0000000..ee0f2a2 --- /dev/null +++ b/app/templates/cloud/upload.html.bak.folder.20260413-190117 @@ -0,0 +1,74 @@ +{% 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/zip_workspace.html b/app/templates/cloud/zip_workspace.html index 0c6906b..4d6fdd7 100644 --- a/app/templates/cloud/zip_workspace.html +++ b/app/templates/cloud/zip_workspace.html @@ -1,20 +1,21 @@ {% extends "portal_base.html" %} -{% block title %}Zip Workspace - OTB Cloud{% endblock %} +{% block title %}Archive Workspace - OTB Cloud{% endblock %} {% block portal_content %}
-

Zip Workspace

+

Archive Workspace

{{ user_email }}

- Stage selected files here, then create a zip archive in your exports area. + Stage selected files here, then create an archive in your exports area.

@@ -45,9 +46,32 @@
{% if staged_files %} -
- + + + + + + +
+ + + + + +
+ +
+
    {% for item in staged_files %}
  • @@ -66,7 +90,7 @@

    Exports

    -

    Completed zip files are stored here for download.

    +

    Completed archive files are stored here for download or transfer to LTS.

    {{ export_files|length }} exports @@ -77,17 +101,39 @@ {% if export_files %} {% else %} -

    No export zip files yet.

    +

    No export archive files yet.

    {% endif %}
    + +
    +
    +
    +

    LTS Storage

    +

    Long-term stored archives.

    +
    +
    + +
    + View LTS +
    +
    {% endblock %} diff --git a/app/templates/portal_base.html.bak.20260412-235158 b/app/templates/portal_base.html.bak.20260412-235158 new file mode 100644 index 0000000..34e7c01 --- /dev/null +++ b/app/templates/portal_base.html.bak.20260412-235158 @@ -0,0 +1,112 @@ + + + + + + {% block title %}OTB Cloud{% endblock %} + + + +
    + +
    + +
    + {% block content %}{% endblock %} +
    + + + + diff --git a/backups/ui-import-20260413-011506/dashboard.html b/backups/ui-import-20260413-011506/dashboard.html new file mode 100644 index 0000000..5cc42f1 --- /dev/null +++ b/backups/ui-import-20260413-011506/dashboard.html @@ -0,0 +1,38 @@ +{% 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

    +

    + OTB Cloud is now running as a portal-linked secure storage service. + Next steps are real OTB Billing handoff integration, file library pages, and upload endpoints. +

    +
    +
    +{% endblock %} diff --git a/backups/ui-import-20260413-011506/handoff_error.html b/backups/ui-import-20260413-011506/handoff_error.html new file mode 100644 index 0000000..fbba73d --- /dev/null +++ b/backups/ui-import-20260413-011506/handoff_error.html @@ -0,0 +1,13 @@ +{% extends "portal_base.html" %} + +{% block title %}Portal Handoff Error{% endblock %} + +{% block content %} +
    +

    Portal handoff failed

    +

    {{ message }}

    +
    + Please return to the OTB Billing portal and try launching OTB Cloud again. +
    +
    +{% endblock %} diff --git a/backups/ui-import-20260413-011506/login_required.html b/backups/ui-import-20260413-011506/login_required.html new file mode 100644 index 0000000..a1cc79e --- /dev/null +++ b/backups/ui-import-20260413-011506/login_required.html @@ -0,0 +1,15 @@ +{% extends "portal_base.html" %} + +{% block title %}Portal Login Required{% endblock %} + +{% block content %} +
    +

    Portal login required

    +

    + OTB Cloud is available only through a signed handoff from the OTB Billing portal. +

    +
    + Please return to the OTB Billing portal and open OTB Cloud from your Services page. +
    +
    +{% endblock %} diff --git a/backups/ui-import-20260413-011506/portal_base.html b/backups/ui-import-20260413-011506/portal_base.html new file mode 100644 index 0000000..9214f13 --- /dev/null +++ b/backups/ui-import-20260413-011506/portal_base.html @@ -0,0 +1,114 @@ + + + + + + {% block title %}OTB Cloud{% endblock %} + + + +
    + +
    + +
    + {% block content %}{% endblock %} +
    + + + + diff --git a/patch.sh b/patch.sh new file mode 100755 index 0000000..5613205 --- /dev/null +++ b/patch.sh @@ -0,0 +1,170 @@ +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/run.py b/run.py index 562e815..3035702 100644 --- a/run.py +++ b/run.py @@ -3,4 +3,4 @@ from app import create_app app = create_app() if __name__ == "__main__": - app.run(host="127.0.0.1", port=app.config["APP_PORT"], debug=True) + app.run(host="0.0.0.0", port=app.config["APP_PORT"], debug=True) diff --git a/scripts/bootstrap_db.sh b/scripts/bootstrap_db.sh old mode 100755 new mode 100644 diff --git a/scripts/bootstrap_storage.sh b/scripts/bootstrap_storage.sh old mode 100755 new mode 100644 diff --git a/scripts/create_tenant_layout.sh b/scripts/create_tenant_layout.sh old mode 100755 new mode 100644 diff --git a/scripts/make_test_handoff.py.bak.20260412-235158 b/scripts/make_test_handoff.py.bak.20260412-235158 new file mode 100644 index 0000000..93b3118 --- /dev/null +++ b/scripts/make_test_handoff.py.bak.20260412-235158 @@ -0,0 +1,27 @@ +#!/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}") diff --git a/scripts/setup_venv.sh b/scripts/setup_venv.sh old mode 100755 new mode 100644 diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..0a23b5a --- /dev/null +++ b/wsgi.py @@ -0,0 +1,3 @@ +from app import create_app + +app = create_app()