Browse Source

v1.1.0-alpha1 schema + service scaffolding

master
Don Kingdon 3 weeks ago
parent
commit
087c50f26b
  1. 53
      PROJECT_STATE.md
  2. 6
      README.md
  3. 2
      VERSION
  4. 59
      app/auth/routes.py.bak.uidguard.20260413-004811
  5. 2
      app/auth/utils.py
  6. 135
      app/auth/utils.py.bak.20260413-015405
  7. 130
      app/auth/utils.py.bak.20260413-021018
  8. 137
      app/auth/utils.py.bak.20260413-024827
  9. 144
      app/auth/utils.py.bak.20260413-051439
  10. 768
      app/main/routes.py
  11. 45
      app/main/routes.py.bak.20260413-015405
  12. 135
      app/main/routes.py.bak.20260413-021018
  13. 199
      app/main/routes.py.bak.20260413-024827
  14. 331
      app/main/routes.py.bak.20260413-032130
  15. 385
      app/main/routes.py.bak.20260413-051439
  16. 788
      app/main/routes.py.bak.20260413-054006
  17. 1212
      app/main/routes.py.bak.android.20260414-002002
  18. 1278
      app/main/routes.py.bak.androidactivate.20260414-023849
  19. 1268
      app/main/routes.py.bak.androidlock.20260414-014204
  20. 1529
      app/main/routes.py.bak.androidrepair.20260415-065050
  21. 1410
      app/main/routes.py.bak.androidupload.20260415-063731
  22. 1529
      app/main/routes.py.bak.androidupload.20260415-214446
  23. 1582
      app/main/routes.py.bak.androidupload_manual.20260415-231918
  24. 1273
      app/main/routes.py.bak.androiduploadlock.20260414-015331
  25. 1400
      app/main/routes.py.bak.deletefix.20260414-031044
  26. 1269
      app/main/routes.py.bak.fix_android_route.20260414-004358
  27. 1112
      app/main/routes.py.bak.folder.20260413-190117
  28. 998
      app/main/routes.py.bak.gallery.20260413-071415
  29. 1400
      app/main/routes.py.bak.gracefuldelete.20260414-025910
  30. 1386
      app/main/routes.py.bak.gracefuldelete3.20260414-032125
  31. 1386
      app/main/routes.py.bak.gracefuldelete4.20260414-032525
  32. 1529
      app/main/routes.py.bak.gracefuldelete4.20260415-065022
  33. 894
      app/main/routes.py.bak.rename.20260413-064038
  34. 894
      app/main/routes.py.bak.rename.20260413-064842
  35. 894
      app/main/routes.py.bak.rename.20260413-065605
  36. 1068
      app/main/routes.py.bak.thumb.20260413-073152
  37. 1134
      app/main/routes.py.bak.tree.20260413-200734
  38. 1038
      app/main/routes.py.bak.viewmode.1776064865
  39. 1529
      app/main/routes.py.broken.1776293208
  40. 65
      app/models/schema.sql
  41. 104
      app/models/schema.sql.bak.android.20260414-002002
  42. 103
      app/models/schema.sql.bak.rename.20260413-064038
  43. 103
      app/models/schema.sql.bak.rename.20260413-064842
  44. 103
      app/models/schema.sql.bak.rename.20260413-065605
  45. 3
      app/services/gpu_select.py
  46. 9
      app/services/video_jobs.py
  47. 3
      app/services/video_metrics.py
  48. 8
      app/services/video_paths.py
  49. 6
      app/services/video_profiles.py
  50. 6
      app/services/video_worker.py
  51. 10
      app/templates/auth/handoff_error.html.bak.20260412-235158
  52. 16
      app/templates/auth/login_required.html.bak.20260412-235158
  53. 19
      app/templates/cloud/android_device_new.html
  54. 8
      app/templates/cloud/dashboard.html
  55. 37
      app/templates/cloud/dashboard.html.bak.20260412-235158
  56. 77
      app/templates/cloud/dashboard.html.bak.20260413-015405
  57. 124
      app/templates/cloud/dashboard.html.bak.20260413-021018
  58. 127
      app/templates/cloud/dashboard.html.bak.20260413-024827
  59. 130
      app/templates/cloud/dashboard.html.bak.20260413-032130
  60. 131
      app/templates/cloud/dashboard.html.bak.20260413-051439
  61. 133
      app/templates/cloud/dashboard.html.bak.android.20260414-002002
  62. 135
      app/templates/cloud/dashboard.html.bak.androidlock.20260414-014204
  63. 102
      app/templates/cloud/deleted_files.html.bak.20260413-054006
  64. 438
      app/templates/cloud/device_files.html
  65. 101
      app/templates/cloud/device_files.html.bak.20260413-051439
  66. 146
      app/templates/cloud/device_files.html.bak.gallery.20260413-071415
  67. 454
      app/templates/cloud/device_files.html.bak.previewfix.1776065858
  68. 126
      app/templates/cloud/device_files.html.bak.rename.20260413-064038
  69. 126
      app/templates/cloud/device_files.html.bak.rename.20260413-064842
  70. 126
      app/templates/cloud/device_files.html.bak.rename.20260413-065605
  71. 454
      app/templates/cloud/device_files.html.bak.thumb.20260413-073152
  72. 454
      app/templates/cloud/device_files.html.bak.tree.20260413-200734
  73. 89
      app/templates/cloud/device_new.html.bak.20260413-022612
  74. 63
      app/templates/cloud/lts.html
  75. 57
      app/templates/cloud/upload.html
  76. 96
      app/templates/cloud/upload.html.bak.20260413-210211
  77. 74
      app/templates/cloud/upload.html.bak.folder.20260413-190117
  78. 64
      app/templates/cloud/zip_workspace.html
  79. 112
      app/templates/portal_base.html.bak.20260412-235158
  80. 38
      backups/ui-import-20260413-011506/dashboard.html
  81. 13
      backups/ui-import-20260413-011506/handoff_error.html
  82. 15
      backups/ui-import-20260413-011506/login_required.html
  83. 114
      backups/ui-import-20260413-011506/portal_base.html
  84. 170
      patch.sh
  85. 2
      run.py
  86. 0
      scripts/bootstrap_db.sh
  87. 0
      scripts/bootstrap_storage.sh
  88. 0
      scripts/create_tenant_layout.sh
  89. 27
      scripts/make_test_handoff.py.bak.20260412-235158
  90. 0
      scripts/setup_venv.sh
  91. 3
      wsgi.py

53
PROJECT_STATE.md

@ -48,3 +48,56 @@ Portal-authenticated secure backup and storage platform for customer files, incl
4. Add media processing jobs 4. Add media processing jobs
5. Add derived/original filtering 5. Add derived/original filtering
6. Add better single-file actions in browser 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

6
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 ## v0.2.6 — Pre-LTS Save Point
- Backup created before LTS / cold storage archive workflow - Backup created before LTS / cold storage archive workflow
- Android photo dump continuation now working with skip-existing behavior - Android photo dump continuation now working with skip-existing behavior

2
VERSION

@ -1 +1 @@
v0.2.3 v1.1.0-alpha1

59
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"))

2
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): def create_device_directories(tenant_slug: str, device_path: str):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
base = tenant_root / device_path 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) (base / subdir).mkdir(parents=True, exist_ok=True)
def remove_device_directories(tenant_slug: str, device_path: str): def remove_device_directories(tenant_slug: str, device_path: str):

135
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)

130
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)

137
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)

144
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()

768
app/main/routes.py

@ -3,9 +3,13 @@ from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
import shutil import shutil
import zipfile import zipfile
import tarfile
from PIL import Image
import re
import hashlib
from werkzeug.utils import secure_filename 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.db import get_db
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256 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") ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
return f"{ts}__{safe}" return f"{ts}__{safe}"
def _recovered_filename(original_name: str) -> tuple[str, str, str]: def _recovered_filename(original_name: str) -> tuple[str, str, str]:
base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." in base_name: if "." in base_name:
@ -43,6 +46,41 @@ def _recovered_filename(original_name: str) -> tuple[str, str, str]:
recovered_name = f"{base_name}-recovered" recovered_name = f"{base_name}-recovered"
return 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: def _safe_path_from_relative(relative_path: str) -> Path:
return _tenant_root() / relative_path return _tenant_root() / relative_path
@ -132,6 +170,61 @@ def dashboard():
devices=devices, 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"]) @bp.route("/devices/new", methods=["GET", "POST"])
@portal_session_required @portal_session_required
def add_device(): def add_device():
@ -234,18 +327,42 @@ def delete_device(device_id: int):
with db.cursor() as cur: with db.cursor() as cur:
cur.execute( 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 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), (session["otb_tenant_id"], device_id),
) )
file_count = cur.fetchone()["file_count"] counts = cur.fetchone()
if file_count and int(file_count) > 0: total_file_count = int(counts["total_file_count"] or 0)
flash("This device cannot be removed because files are still linked to it.", "warning") 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")) 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( cur.execute(
""" """
DELETE FROM devices DELETE FROM devices
@ -286,6 +403,16 @@ def upload_files(device_id: int):
flash("Device not found.", "warning") flash("Device not found.", "warning")
return redirect(url_for("main.dashboard")) 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": if request.method == "GET":
return render_template( return render_template(
"cloud/upload.html", "cloud/upload.html",
@ -314,8 +441,26 @@ def upload_files(device_id: int):
with db.cursor() as cur: with db.cursor() as cur:
for incoming in files: for incoming in files:
original_filename = incoming.filename or "upload.bin" 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) incoming.save(target_path)
@ -328,6 +473,10 @@ def upload_files(device_id: int):
else: else:
basename, extension = base_name, "" 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}" relative_path = f"{device['relative_path']}/originals/{stored_name}"
directory_path = f"{device['relative_path']}/originals" directory_path = f"{device['relative_path']}/originals"
@ -382,6 +531,70 @@ def upload_files(device_id: int):
flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success") flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success")
return redirect(url_for("main.dashboard")) return redirect(url_for("main.dashboard"))
@bp.route("/files/<int:file_id>/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/<int:file_id>/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/<int:file_id>/download", methods=["GET"]) @bp.route("/files/<int:file_id>/download", methods=["GET"])
@portal_session_required @portal_session_required
def download_file(file_id: int): def download_file(file_id: int):
@ -390,7 +603,7 @@ def download_file(file_id: int):
with db.cursor() as cur: with db.cursor() as cur:
cur.execute( cur.execute(
""" """
SELECT id, original_filename, relative_path SELECT id, original_filename, display_filename, relative_path
FROM files FROM files
WHERE id = %s AND tenant_id = %s WHERE id = %s AND tenant_id = %s
""", """,
@ -407,7 +620,7 @@ def download_file(file_id: int):
flash("File is missing from storage.", "warning") flash("File is missing from storage.", "warning")
return redirect(url_for("main.dashboard")) 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/<int:device_id>/files/download-selected", methods=["POST"]) @bp.route("/devices/<int:device_id>/files/download-selected", methods=["POST"])
@portal_session_required @portal_session_required
@ -417,6 +630,11 @@ def download_selected_files(device_id: int):
if not device: if not device:
flash("Device not found.", "warning") 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")) return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] 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)) return redirect(url_for("main.browse_device_files", device_id=device_id))
if len(selected_ids) != 1: 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.browse_device_files", device_id=device_id))
return redirect(url_for("main.download_file", file_id=selected_ids[0])) 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: if not device:
flash("Device not found.", "warning") 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")) return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] 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: if not device:
flash("Device not found.", "warning") 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")) return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()] 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: for file_id in selected_ids:
cur.execute( cur.execute(
""" """
SELECT id, original_filename, relative_path, is_deleted SELECT id, original_filename, display_filename, relative_path, is_deleted
FROM files FROM files
WHERE id = %s AND tenant_id = %s AND device_id = %s 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(): if not source_path.exists():
continue continue
target_name = _stored_name(file_row["original_filename"]) target_name = _stored_name(_display_filename(file_row))
target_path = staging_dir / target_name target_path = staging_dir / target_name
shutil.copy2(source_path, target_path) shutil.copy2(source_path, target_path)
@ -568,7 +796,7 @@ def send_selected_to_zip_workspace(device_id: int):
file_id, file_id,
_client_ip(), _client_ip(),
request.headers.get("User-Agent", ""), 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() 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)) return redirect(url_for("main.browse_device_files", device_id=device_id))
@bp.route("/workspace/zip", methods=["GET"]) @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()] staged = [p for p in staging_dir.iterdir() if p.is_file()]
if not staged: if not staged:
flash("Zip Workspace is empty.", "warning") flash("Archive Workspace is empty.", "warning")
return redirect(url_for("main.zip_workspace")) 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" archive_name = (request.form.get("archive_name") or "").strip()
zip_path = exports_dir / zip_name 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')}"
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: 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: for p in staged:
zf.write(p, arcname=p.name) arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name
zf.write(p, arcname=arcname)
for p in staged: for p in staged:
p.unlink(missing_ok=True) p.unlink(missing_ok=True)
@ -651,12 +905,12 @@ def create_zip_from_workspace():
session["otb_user_id"], session["otb_user_id"],
_client_ip(), _client_ip(),
request.headers.get("User-Agent", ""), 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() 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")) return redirect(url_for("main.zip_workspace"))
@bp.route("/workspace/exports/<path:filename>/download", methods=["GET"]) @bp.route("/workspace/exports/<path:filename>/download", methods=["GET"])
@ -705,8 +959,6 @@ def deleted_files():
files=files, files=files,
) )
@bp.route("/deleted/<int:file_id>/recover", methods=["POST"]) @bp.route("/deleted/<int:file_id>/recover", methods=["POST"])
@portal_session_required @portal_session_required
def recover_deleted_file(file_id: int): def recover_deleted_file(file_id: int):
@ -760,6 +1012,7 @@ def recover_deleted_file(file_id: int):
""" """
UPDATE files UPDATE files
SET original_filename = %s, SET original_filename = %s,
display_filename = NULL,
basename = %s, basename = %s,
extension = %s, extension = %s,
relative_path = %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") 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")) return redirect(url_for("main.deleted_files"))
@bp.route("/deleted/<int:file_id>/hard-delete", methods=["POST"]) @bp.route("/deleted/<int:file_id>/hard-delete", methods=["POST"])
@portal_session_required @portal_session_required
def hard_delete_file(file_id: int): def hard_delete_file(file_id: int):
@ -848,6 +1100,88 @@ def hard_delete_file(file_id: int):
flash("File permanently deleted.", "success") flash("File permanently deleted.", "success")
return redirect(url_for("main.deleted_files")) return redirect(url_for("main.deleted_files"))
@bp.route("/files/<int:file_id>/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/<int:device_id>/files", methods=["GET"]) @bp.route("/devices/<int:device_id>/files", methods=["GET"])
@portal_session_required @portal_session_required
def browse_device_files(device_id: int): def browse_device_files(device_id: int):
@ -858,6 +1192,14 @@ def browse_device_files(device_id: int):
flash("Device not found.", "warning") flash("Device not found.", "warning")
return redirect(url_for("main.dashboard")) 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: with db.cursor() as cur:
cur.execute( cur.execute(
""" """
@ -867,6 +1209,7 @@ def browse_device_files(device_id: int):
relative_path, relative_path,
directory_path, directory_path,
original_filename, original_filename,
display_filename,
basename, basename,
extension, extension,
mime_type, mime_type,
@ -878,12 +1221,70 @@ def browse_device_files(device_id: int):
WHERE tenant_id = %s WHERE tenant_id = %s
AND device_id = %s AND device_id = %s
AND is_deleted = 0 AND is_deleted = 0
AND directory_path = %s
ORDER BY uploaded_at DESC, id DESC ORDER BY uploaded_at DESC, id DESC
""", """,
(session["otb_tenant_id"], device_id), (session["otb_tenant_id"], device_id, current_directory),
) )
files = cur.fetchall() 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( return render_template(
"cloud/device_files.html", "cloud/device_files.html",
user_email=session.get("otb_email"), user_email=session.get("otb_email"),
@ -891,4 +1292,317 @@ def browse_device_files(device_id: int):
device=device, device=device,
files=files, files=files,
file_count=len(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/<path:filename>/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/<path:filename>/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,
) )

45
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,
)

135
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"))

199
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/<int:device_id>", 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"))

331
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/<int:device_id>", 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/<int:device_id>/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"))

385
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/<int:device_id>", 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/<int:device_id>/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/<int:device_id>/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),
)

788
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/<int:device_id>", 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/<int:device_id>/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/<int:file_id>/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/<int:device_id>/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/<int:device_id>/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/<int:device_id>/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/<path:filename>/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/<int:file_id>/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/<int:device_id>/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),
)

1212
app/main/routes.py.bak.android.20260414-002002

File diff suppressed because it is too large Load Diff

1278
app/main/routes.py.bak.androidactivate.20260414-023849

File diff suppressed because it is too large Load Diff

1268
app/main/routes.py.bak.androidlock.20260414-014204

File diff suppressed because it is too large Load Diff

1529
app/main/routes.py.bak.androidrepair.20260415-065050

File diff suppressed because it is too large Load Diff

1410
app/main/routes.py.bak.androidupload.20260415-063731

File diff suppressed because it is too large Load Diff

1529
app/main/routes.py.bak.androidupload.20260415-214446

File diff suppressed because it is too large Load Diff

1582
app/main/routes.py.bak.androidupload_manual.20260415-231918

File diff suppressed because it is too large Load Diff

1273
app/main/routes.py.bak.androiduploadlock.20260414-015331

File diff suppressed because it is too large Load Diff

1400
app/main/routes.py.bak.deletefix.20260414-031044

File diff suppressed because it is too large Load Diff

1269
app/main/routes.py.bak.fix_android_route.20260414-004358

File diff suppressed because it is too large Load Diff

1112
app/main/routes.py.bak.folder.20260413-190117

File diff suppressed because it is too large Load Diff

998
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/<int:device_id>", 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/<int:device_id>/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/<int:file_id>/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/<int:device_id>/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/<int:device_id>/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/<int:device_id>/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/<path:filename>/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/<int:file_id>/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/<int:file_id>/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/<int:file_id>/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/<int:device_id>/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),
)

1400
app/main/routes.py.bak.gracefuldelete.20260414-025910

File diff suppressed because it is too large Load Diff

1386
app/main/routes.py.bak.gracefuldelete3.20260414-032125

File diff suppressed because it is too large Load Diff

1386
app/main/routes.py.bak.gracefuldelete4.20260414-032525

File diff suppressed because it is too large Load Diff

1529
app/main/routes.py.bak.gracefuldelete4.20260415-065022

File diff suppressed because it is too large Load Diff

894
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/<int:device_id>", 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/<int:device_id>/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/<int:file_id>/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/<int:device_id>/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/<int:device_id>/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/<int:device_id>/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/<path:filename>/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/<int:file_id>/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/<int:file_id>/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/<int:device_id>/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),
)

894
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/<int:device_id>", 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/<int:device_id>/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/<int:file_id>/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/<int:device_id>/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/<int:device_id>/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/<int:device_id>/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/<path:filename>/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/<int:file_id>/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/<int:file_id>/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/<int:device_id>/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),
)

894
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/<int:device_id>", 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/<int:device_id>/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/<int:file_id>/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/<int:device_id>/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/<int:device_id>/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/<int:device_id>/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/<path:filename>/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/<int:file_id>/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/<int:file_id>/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/<int:device_id>/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),
)

1068
app/main/routes.py.bak.thumb.20260413-073152

File diff suppressed because it is too large Load Diff

1134
app/main/routes.py.bak.tree.20260413-200734

File diff suppressed because it is too large Load Diff

1038
app/main/routes.py.bak.viewmode.1776064865

File diff suppressed because it is too large Load Diff

1529
app/main/routes.py.broken.1776293208

File diff suppressed because it is too large Load Diff

65
app/models/schema.sql

@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS files (
relative_path VARCHAR(1000) NOT NULL, relative_path VARCHAR(1000) NOT NULL,
directory_path VARCHAR(1000) NOT NULL, directory_path VARCHAR(1000) NOT NULL,
original_filename VARCHAR(255) NOT NULL, original_filename VARCHAR(255) NOT NULL,
display_filename VARCHAR(255) NULL,
basename VARCHAR(255) NOT NULL, basename VARCHAR(255) NOT NULL,
extension VARCHAR(50) NOT NULL, extension VARCHAR(50) NOT NULL,
mime_type VARCHAR(255) 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_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(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)
);

104
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)
);

103
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)
);

103
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)
);

103
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)
);

3
app/services/gpu_select.py

@ -0,0 +1,3 @@
def select_processor():
# v1.1.0 logic placeholder
return "intel"

9
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"
}

3
app/services/video_metrics.py

@ -0,0 +1,3 @@
def recalc_metrics(db, tenant_id):
# placeholder for v1.1.0
return {"ok": True}

8
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"
}

6
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"
}

6
app/services/video_worker.py

@ -0,0 +1,6 @@
import time
def run_worker():
print("video worker starting (stub)")
while True:
time.sleep(10)

10
app/templates/auth/handoff_error.html.bak.20260412-235158

@ -0,0 +1,10 @@
{% extends "portal_base.html" %}
{% block title %}Handoff Error{% endblock %}
{% block content %}
<div class="card">
<h1>Portal handoff failed</h1>
<p class="muted">{{ message }}</p>
</div>
{% endblock %}

16
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 %}
<div class="card">
<h1>Portal login required</h1>
<p class="muted">
OTB Cloud does not allow direct unauthenticated access.
This app is intended to be reached through the OTB Billing portal handoff.
</p>
<div class="warn" style="margin-top:16px;">
Current status: direct local scaffold only. Real portal handoff wiring is next.
</div>
</div>
{% endblock %}

19
app/templates/cloud/android_device_new.html

@ -0,0 +1,19 @@
{% extends "portal_base.html" %}
{% block title %}Add Android Device{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Add Android Backup Device</h1>
<p class="portal-page-subtitle">Create a phone backup target and generate activation token.</p>
</div>
</div>
<form method="post">
<div style="max-width:500px;display:flex;flex-direction:column;gap:14px;">
<input type="text" name="device_name" placeholder="Phone Name (e.g. Mom Samsung A14)" required>
<button class="portal-btn primary" type="submit">Create Device + Token</button>
</div>
</form>
{% endblock %}

8
app/templates/cloud/dashboard.html

@ -15,7 +15,8 @@
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;"> <div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;"> <div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a> <a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a> <a class="portal-btn primary" href="{{ url_for('main.create_android_device') }}">Add Android Device</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Archive Workspace</a>
<a class="portal-btn" href="{{ url_for('main.deleted_files') }}">Deleted Files</a> <a class="portal-btn" href="{{ url_for('main.deleted_files') }}">Deleted Files</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a> <a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a> <a class="portal-btn" href="/auth/logout">Logout</a>
@ -63,7 +64,11 @@
<span style="opacity:0.85;">({{ device.device_type }})</span><br> <span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span><br> <span style="opacity:0.75;">{{ device.relative_path }}</span><br>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px;"> <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px;">
{% if device.device_type != 'android' %}
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a> <a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
{% else %}
<span class="portal-btn" style="opacity:0.6;cursor:not-allowed;">APK Upload Only</span>
{% endif %}
<a class="portal-btn" href="{{ url_for('main.browse_device_files', device_id=device.id) }}">Browse Files</a> <a class="portal-btn" href="{{ url_for('main.browse_device_files', device_id=device.id) }}">Browse Files</a>
<form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}"> <form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}">
<button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button> <button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button>
@ -108,6 +113,7 @@
<div class="service-card-actions"> <div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a> <a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn primary" href="{{ url_for('main.create_android_device') }}">Add Android Device</a>
</div> </div>
</article> </article>

37
app/templates/cloud/dashboard.html.bak.20260412-235158

@ -0,0 +1,37 @@
{% extends "portal_base.html" %}
{% block title %}OTB Cloud Dashboard{% endblock %}
{% block content %}
<div class="card" style="margin-bottom:16px;">
<h1 style="margin-top:0;">OTB Cloud Dashboard</h1>
<p class="muted" style="margin-bottom:10px;">Authenticated user: {{ user_email }}</p>
<p class="muted" style="margin:0;">Tenant slug: <span class="badge">{{ tenant_slug }}</span></p>
</div>
<div class="grid">
<div class="card">
<h2 style="margin-top:0;">Devices</h2>
{% if devices %}
<ul style="padding-left:18px; margin-bottom:0;">
{% for device in devices %}
<li style="margin-bottom:8px;">
<strong>{{ device.device_name }}</strong>
<span class="muted">({{ device.device_type }})</span><br>
<span class="muted">{{ device.relative_path }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No devices have been created yet.</p>
{% endif %}
</div>
<div class="card">
<h2 style="margin-top:0;">Current scope</h2>
<p class="muted">
v0.1.1 provides portal-handoff scaffolding, tenant bootstrap, device records, and an authenticated dashboard.
</p>
</div>
</div>
{% endblock %}

77
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
{% if devices %}
<ul style="padding-left:18px; margin:0;">
{% for device in devices %}
<li style="margin-bottom:10px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p>No devices have been created yet.</p>
{% endif %}
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is now operating inside the branded OTB portal shell.</p>
</div>
<div>
<span class="service-badge service-badge-beta">In Progress</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are the searchable file library, bulk upload endpoints, zip export, and media processing jobs.
</p>
</div>
</article>
</section>
{% endblock %}

124
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if devices %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
<ul style="padding-left:18px; margin:0;">
{% for device in devices %}
<li style="margin-bottom:10px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span>
</li>
{% endfor %}
</ul>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is now operating inside the branded OTB portal shell.</p>
</div>
<div>
<span class="service-badge service-badge-beta">In Progress</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are the searchable file library, bulk upload endpoints, zip export, and media processing jobs.
</p>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No devices connected yet</h2>
<p>Create your first device source before uploading files.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Ready</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is ready for user-created devices.</p>
</div>
<div>
<span class="service-badge service-badge-beta">In Progress</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
After adding a device, the next phase is browser upload, file library browsing, and export tools.
</p>
</div>
</article>
</section>
{% endif %}
{% endblock %}

127
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if devices %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
<ul style="padding-left:18px; margin:0; list-style:disc;">
{% for device in devices %}
<li style="margin-bottom:18px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span><br>
<form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}" style="margin-top:10px;">
<button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button>
</form>
</li>
{% endfor %}
</ul>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is now operating inside the branded OTB portal shell.</p>
</div>
<div>
<span class="service-badge service-badge-beta">In Progress</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are the searchable file library, bulk upload endpoints, zip export, and media processing jobs.
</p>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No devices connected yet</h2>
<p>Create your first device source before uploading files.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Ready</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is ready for user-created devices.</p>
</div>
<div>
<span class="service-badge service-badge-beta">In Progress</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
After adding a device, the next phase is browser upload, file library browsing, and export tools.
</p>
</div>
</article>
</section>
{% endif %}
{% endblock %}

130
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if devices %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
<ul style="padding-left:18px; margin:0; list-style:disc;">
{% for device in devices %}
<li style="margin-bottom:18px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span><br>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}">
<button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button>
</form>
</div>
</li>
{% endfor %}
</ul>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud now supports browser uploads to device originals.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are uploaded file listing, searchable library pages, zip export, and media processing jobs.
</p>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No devices connected yet</h2>
<p>Create your first device source before uploading files.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Ready</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is ready for user-created devices and browser uploads.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
After adding a device, you can upload one or more files into that device’s originals storage.
</p>
</div>
</article>
</section>
{% endif %}
{% endblock %}

131
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if devices %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
<ul style="padding-left:18px; margin:0; list-style:disc;">
{% for device in devices %}
<li style="margin-bottom:18px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span><br>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.browse_device_files', device_id=device.id) }}">Browse Files</a>
<form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}">
<button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button>
</form>
</div>
</li>
{% endfor %}
</ul>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud now supports browser uploads and device file browsing.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are single-file download, searchable library pages, zip export, and media processing jobs.
</p>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No devices connected yet</h2>
<p>Create your first device source before uploading files.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Ready</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is ready for user-created devices and browser uploads.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
After adding a device, you can upload one or more files into that device’s originals storage and browse them here.
</p>
</div>
</article>
</section>
{% endif %}
{% endblock %}

133
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.deleted_files') }}">Deleted Files</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if devices %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
<ul style="padding-left:18px; margin:0; list-style:disc;">
{% for device in devices %}
<li style="margin-bottom:18px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span><br>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.browse_device_files', device_id=device.id) }}">Browse Files</a>
<form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}">
<button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button>
</form>
</div>
</li>
{% endfor %}
</ul>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud now supports browser uploads, browsing, zip staging, and soft delete.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are basename-only rename, searchable library pages, folder upload, and media processing jobs.
</p>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No devices connected yet</h2>
<p>Create your first device source before uploading files.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Ready</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is ready for user-created devices and browser uploads.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
After adding a device, you can upload files, browse them, soft-delete them, and stage them for zip export.
</p>
</div>
</article>
</section>
{% endif %}
{% endblock %}

135
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Dashboard</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Secure backup and storage dashboard for your account.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn primary" href="{{ url_for('main.create_android_device') }}">Add Android Device</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.deleted_files') }}">Deleted Files</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Logged in as:
<strong>{{ user_email }}</strong></div>
<div>Tenant slug:
<strong>{{ tenant_slug }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if devices %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Devices</h2>
<p>Registered source locations for uploaded data.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Active</span>
</div>
</div>
<div class="service-card-actions">
<ul style="padding-left:18px; margin:0; list-style:disc;">
{% for device in devices %}
<li style="margin-bottom:18px;">
<strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span><br>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.browse_device_files', device_id=device.id) }}">Browse Files</a>
<form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}">
<button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button>
</form>
</div>
</li>
{% endfor %}
</ul>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud now supports browser uploads, browsing, zip staging, and soft delete.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
Next steps are basename-only rename, searchable library pages, folder upload, and media processing jobs.
</p>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No devices connected yet</h2>
<p>Create your first device source before uploading files.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Ready</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn primary" href="{{ url_for('main.create_android_device') }}">Add Android Device</a>
</div>
</article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Current scope</h2>
<p>OTB Cloud is ready for user-created devices and browser uploads.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Live</span>
</div>
</div>
<div class="service-card-actions">
<p style="margin:0;">
After adding a device, you can upload files, browse them, soft-delete them, and stage them for zip export.
</p>
</div>
</article>
</section>
{% endif %}
{% endblock %}

102
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Deleted Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Deleted files are retained for up to 24 hours unless you hard-delete them immediately.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Deleted Files</h2>
<p>Files here are pending retention expiry or hard delete.</p>
</div>
<div>
<span class="service-badge service-badge-beta">{{ files|length }} items</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Device</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Deleted At</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Action</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<strong>{{ file.original_filename }}</strong><br>
<span style="opacity:0.75;font-size:0.9rem;">{{ file.relative_path }}</span>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.device_name or 'Unknown' }}<br>
<span style="opacity:0.75;font-size:0.9rem;">{{ file.device_type or '' }}</span>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.deleted_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<form method="post" action="{{ url_for('main.hard_delete_file', file_id=file.id) }}">
<button class="portal-btn" type="submit" onclick="return confirm('Permanently delete {{ file.original_filename|e }} now? This cannot be undone.');">Hard Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No deleted files</h2>
<p>There are currently no files in the deleted area.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Clear</span>
</div>
</div>
</article>
</section>
{% endif %}
{% endblock %}

438
app/templates/cloud/device_files.html

@ -3,6 +3,227 @@
{% block title %}Device Files - OTB Cloud{% endblock %} {% block title %}Device Files - OTB Cloud{% endblock %}
{% block portal_content %} {% block portal_content %}
<style>
.otb-view-toggle {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-view-toggle a {
text-decoration: none;
}
.otb-breadcrumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 10px;
font-size: 0.95rem;
}
.otb-breadcrumbs a {
text-decoration: none;
}
.otb-folder-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.otb-folder-card {
display: block;
padding: 16px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.10);
background: rgba(255,255,255,0.04);
text-decoration: none;
color: inherit;
}
.otb-folder-card:hover {
background: rgba(255,255,255,0.07);
}
.otb-folder-name {
font-weight: 700;
word-break: break-word;
}
.otb-folder-sub {
opacity: 0.75;
font-size: 0.92rem;
margin-top: 6px;
}
.otb-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-top: 18px;
}
.otb-gallery-card {
border: 1px solid rgba(255,255,255,0.10);
border-radius: 16px;
background: rgba(255,255,255,0.03);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 320px;
}
.otb-gallery-thumb-wrap {
position: relative;
aspect-ratio: 1 / 1;
background: rgba(255,255,255,0.04);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.otb-gallery-thumb {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
display: block;
}
.otb-gallery-check {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
transform: scale(1.2);
}
.otb-gallery-meta {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.otb-gallery-name {
font-weight: 700;
word-break: break-word;
line-height: 1.25;
}
.otb-gallery-sub {
font-size: 0.9rem;
opacity: 0.8;
line-height: 1.35;
}
.otb-gallery-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-gallery-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-gallery-rename input {
min-width: 120px;
flex: 1 1 120px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
.otb-modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 9999;
align-items: center;
justify-content: center;
padding: 20px;
}
.otb-modal-backdrop.active {
display: flex;
}
.otb-modal-card {
max-width: min(96vw, 1400px);
max-height: 94vh;
width: 100%;
background: rgba(11,18,37,0.98);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 18px;
overflow: hidden;
box-shadow: 0 18px 50px rgba(0,0,0,0.45);
}
.otb-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid rgba(255,255,255,0.10);
}
.otb-modal-title {
font-weight: 700;
word-break: break-word;
}
.otb-modal-body {
display: flex;
align-items: center;
justify-content: center;
background: #000;
max-height: calc(94vh - 64px);
overflow: auto;
}
.otb-modal-body img {
max-width: 100%;
max-height: calc(94vh - 100px);
object-fit: contain;
display: block;
}
.otb-list-name-wrap {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 320px;
}
.otb-list-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-list-rename input {
min-width: 220px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
</style>
<div class="portal-page-header"> <div class="portal-page-header">
<div> <div>
<h1 class="portal-page-title">Device Files</h1> <h1 class="portal-page-title">Device Files</h1>
@ -10,16 +231,32 @@
<p class="portal-page-subtitle"> <p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}). Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p> </p>
<div class="otb-breadcrumbs">
{% for crumb in breadcrumbs %}
<a class="portal-btn {% if loop.last %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view=view_mode, path=crumb.path) }}">{{ crumb.label }}</a>
{% endfor %}
{% if current_path %}
<a class="portal-btn" href="{{ url_for('main.browse_device_files', device_id=device.id, view=view_mode, path=parent_path) }}">Up One Level</a>
{% endif %}
</div>
</div> </div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;"> <div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;"> <div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a> <a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a> <a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Archive Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a> <a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div> </div>
<div class="otb-view-toggle">
<a class="portal-btn {% if view_mode == 'list' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='list', path=current_path) }}">List View</a>
<a class="portal-btn {% if view_mode == 'gallery' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='gallery', path=current_path) }}">Gallery View</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;"> <div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div> <div>Current folder file count: <strong>{{ file_count }}</strong></div>
<div>Subfolders here: <strong>{{ folders|length }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div> <div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div> </div>
</div> </div>
@ -37,27 +274,116 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Folders</h2>
<p>Navigate the preserved backup structure like a normal file browser.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Tree View</span>
</div>
</div>
{% if folders %}
<div class="otb-folder-grid">
{% for folder in folders %}
<a class="otb-folder-card" href="{{ url_for('main.browse_device_files', device_id=device.id, view=view_mode, path=folder.path) }}">
<div class="otb-folder-name">📁 {{ folder.name }}</div>
<div class="otb-folder-sub">Open folder</div>
</a>
{% endfor %}
</div>
{% else %}
<div style="opacity:0.8;margin-bottom:16px;">No subfolders in this location.</div>
{% endif %}
</article>
</section>
{% if files %} {% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;"> <section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta"> <article class="service-card status-beta">
<div class="service-card-header"> <div class="service-card-header">
<div> <div>
<h2>Files</h2> <h2>{% if view_mode == 'gallery' %}Current Folder Gallery{% else %}Current Folder Files{% endif %}</h2>
<p>Select files to delete, download, or send to Zip Workspace.</p> <p>
{% 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 %}
</p>
</div> </div>
<div> <div>
<span class="service-badge service-badge-beta">DB-backed</span> <span class="service-badge service-badge-beta">Scoped</span>
</div> </div>
</div> </div>
<div class="service-card-actions" style="overflow-x:auto;"> <div class="service-card-actions" style="overflow-x:auto;">
<form method="post"> <form id="bulk-actions-form" method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;"> <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button> <button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Archive Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button> <button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button> <button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div> </div>
</form>
{% if view_mode == 'gallery' %}
<div class="otb-gallery-grid">
{% 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'] %}
<div class="otb-gallery-card" title="Name: {{ visible_name }}&#10;Original: {{ file.original_filename }}&#10;Type: {{ file.mime_type or file.file_kind }}&#10;Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes&#10;Uploaded: {{ file.uploaded_at }}">
<div class="otb-gallery-thumb-wrap">
<input class="row-check otb-gallery-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
{% if is_image %}
<img
src="{{ url_for('main.thumbnail_file', file_id=file.id) }}"
alt="{{ visible_name }}"
class="otb-gallery-thumb preview-trigger"
data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}"
data-preview-title="{{ visible_name }}"
>
{% else %}
<div style="padding:20px;text-align:center;opacity:0.75;">No preview</div>
{% endif %}
</div>
<div class="otb-gallery-meta">
<div class="otb-gallery-name">{{ visible_name }}</div>
<div class="otb-gallery-sub">
{{ file.mime_type or file.file_kind }}<br>
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</div>
<div class="otb-gallery-actions">
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}">Preview</button>
{% endif %}
<a class="portal-btn" href="{{ url_for('main.download_file', file_id=file.id) }}">Download</a>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-gallery-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<table style="width:100%;border-collapse:collapse;"> <table style="width:100%;border-collapse:collapse;">
<thead> <thead>
<tr> <tr>
@ -73,13 +399,41 @@
</thead> </thead>
<tbody> <tbody>
{% for file in files %} {% 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'] %}
<tr> <tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> <td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}"> <input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
</td> </td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> <td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<strong>{{ file.original_filename }}</strong><br> <div class="otb-list-name-wrap">
<div>
<strong>{{ visible_name }}</strong><br>
{% if file.display_filename %}
<span style="opacity:0.75;font-size:0.9rem;">Original: {{ file.original_filename }}</span><br>
{% endif %}
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}" style="margin-top:8px;">Preview</button>
{% endif %}
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span> <span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-list-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Enter custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</td> </td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> <td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }} {{ file.file_kind }}
@ -100,26 +454,78 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</form> {% endif %}
</div> </div>
</article> </article>
</section> </section>
<div id="otb-image-modal" class="otb-modal-backdrop" aria-hidden="true">
<div class="otb-modal-card">
<div class="otb-modal-header">
<div id="otb-image-modal-title" class="otb-modal-title">Preview</div>
<button type="button" class="portal-btn" id="otb-image-modal-close">Close</button>
</div>
<div class="otb-modal-body">
<img id="otb-image-modal-img" src="" alt="">
</div>
</div>
</div>
<script>
(function () {
const modal = document.getElementById('otb-image-modal');
const modalImg = document.getElementById('otb-image-modal-img');
const modalTitle = document.getElementById('otb-image-modal-title');
const closeBtn = document.getElementById('otb-image-modal-close');
if (!modal || !modalImg || !modalTitle || !closeBtn) return;
function openPreview(url, title) {
modalImg.src = url;
modalImg.alt = title || 'Preview image';
modalTitle.textContent = title || 'Preview';
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
}
function closePreview() {
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
modalImg.src = '';
modalImg.alt = '';
}
document.querySelectorAll('.preview-trigger').forEach(function (el) {
el.addEventListener('click', function () {
const url = this.dataset.previewUrl || this.getAttribute('src');
const title = this.dataset.previewTitle || this.getAttribute('alt') || 'Preview';
if (url) openPreview(url, title);
});
});
closeBtn.addEventListener('click', closePreview);
modal.addEventListener('click', function (e) {
if (e.target === modal) closePreview();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closePreview();
});
})();
</script>
{% else %} {% else %}
<section class="services-grid"> <section class="services-grid">
<article class="service-card status-beta"> <article class="service-card status-beta">
<div class="service-card-header"> <div class="service-card-header">
<div> <div>
<h2>No files yet</h2> <h2>No files in this folder</h2>
<p>This device does not have any uploaded files recorded yet.</p> <p>This location does not currently contain any active files.</p>
</div> </div>
<div> <div>
<span class="service-badge service-badge-beta">Empty</span> <span class="service-badge service-badge-beta">Empty</span>
</div> </div>
</div> </div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article> </article>
</section> </section>
{% endif %} {% endif %}

101
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Files</h2>
<p>Files recorded in the database for this device.</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<strong>{{ file.original_filename }}</strong><br>
<span style="opacity:0.75;font-size:0.9rem;">
SHA256: {{ file.sha256 }}
</span>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

146
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Files</h2>
<p>Select files to delete, download, or send to Zip Workspace. Rename changes only the customer-facing name, not the immutable stored file.</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form id="bulk-actions-form" method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
</form>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% 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 %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;min-width:320px;">
<strong>{{ visible_name }}</strong><br>
{% if file.display_filename %}
<span style="opacity:0.75;font-size:0.9rem;">Original: {{ file.original_filename }}</span><br>
{% endif %}
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Enter custom file name"
style="min-width:220px;padding:8px 10px;border-radius:10px;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.06);color:#fff;"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

454
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 %}
<style>
.otb-view-toggle {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-view-toggle a {
text-decoration: none;
}
.otb-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-top: 18px;
}
.otb-gallery-card {
border: 1px solid rgba(255,255,255,0.10);
border-radius: 16px;
background: rgba(255,255,255,0.03);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 320px;
}
.otb-gallery-thumb-wrap {
position: relative;
aspect-ratio: 1 / 1;
background: rgba(255,255,255,0.04);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.otb-gallery-thumb {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
display: block;
}
.otb-gallery-check {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
transform: scale(1.2);
}
.otb-gallery-meta {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.otb-gallery-name {
font-weight: 700;
word-break: break-word;
line-height: 1.25;
}
.otb-gallery-sub {
font-size: 0.9rem;
opacity: 0.8;
line-height: 1.35;
}
.otb-gallery-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-gallery-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-gallery-rename input {
min-width: 120px;
flex: 1 1 120px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
.otb-modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 9999;
align-items: center;
justify-content: center;
padding: 20px;
}
.otb-modal-backdrop.active {
display: flex;
}
.otb-modal-card {
max-width: min(96vw, 1400px);
max-height: 94vh;
width: 100%;
background: rgba(11,18,37,0.98);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 18px;
overflow: hidden;
box-shadow: 0 18px 50px rgba(0,0,0,0.45);
}
.otb-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid rgba(255,255,255,0.10);
}
.otb-modal-title {
font-weight: 700;
word-break: break-word;
}
.otb-modal-body {
display: flex;
align-items: center;
justify-content: center;
background: #000;
max-height: calc(94vh - 64px);
overflow: auto;
}
.otb-modal-body img {
max-width: 100%;
max-height: calc(94vh - 100px);
object-fit: contain;
display: block;
}
.otb-list-name-wrap {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 320px;
}
.otb-list-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-list-rename input {
min-width: 220px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
</style>
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div class="otb-view-toggle">
<a class="portal-btn {% if view_mode == 'list' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='list') }}">List View</a>
<a class="portal-btn {% if view_mode == 'gallery' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='gallery') }}">Gallery View</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>{% if view_mode == 'gallery' %}Image Gallery{% else %}Files{% endif %}</h2>
<p>
{% 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 %}
</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form id="bulk-actions-form" method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
</form>
{% if view_mode == 'gallery' %}
<div class="otb-gallery-grid">
{% 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'] %}
<div class="otb-gallery-card" title="Name: {{ visible_name }}&#10;Original: {{ file.original_filename }}&#10;Type: {{ file.mime_type or file.file_kind }}&#10;Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes&#10;Uploaded: {{ file.uploaded_at }}">
<div class="otb-gallery-thumb-wrap">
<input class="row-check otb-gallery-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
{% if is_image %}
<img
src="{{ url_for('main.thumbnail_file', file_id=file.id) }}"
alt="{{ visible_name }}"
class="otb-gallery-thumb preview-trigger"
data-preview-url="{{ url_for('main.thumbnail_file', file_id=file.id) }}"
data-preview-title="{{ visible_name }}"
>
{% else %}
<div style="padding:20px;text-align:center;opacity:0.75;">No preview</div>
{% endif %}
</div>
<div class="otb-gallery-meta">
<div class="otb-gallery-name">{{ visible_name }}</div>
<div class="otb-gallery-sub">
{{ file.mime_type or file.file_kind }}<br>
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</div>
<div class="otb-gallery-actions">
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.thumbnail_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}">Preview</button>
{% endif %}
<a class="portal-btn" href="{{ url_for('main.download_file', file_id=file.id) }}">Download</a>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-gallery-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% 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'] %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<div class="otb-list-name-wrap">
<div>
<strong>{{ visible_name }}</strong><br>
{% if file.display_filename %}
<span style="opacity:0.75;font-size:0.9rem;">Original: {{ file.original_filename }}</span><br>
{% endif %}
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.thumbnail_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}" style="margin-top:8px;">Preview</button>
{% endif %}
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-list-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Enter custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</article>
</section>
<div id="otb-image-modal" class="otb-modal-backdrop" aria-hidden="true">
<div class="otb-modal-card">
<div class="otb-modal-header">
<div id="otb-image-modal-title" class="otb-modal-title">Preview</div>
<button type="button" class="portal-btn" id="otb-image-modal-close">Close</button>
</div>
<div class="otb-modal-body">
<img id="otb-image-modal-img" src="" alt="">
</div>
</div>
</div>
<script>
(function () {
const modal = document.getElementById('otb-image-modal');
const modalImg = document.getElementById('otb-image-modal-img');
const modalTitle = document.getElementById('otb-image-modal-title');
const closeBtn = document.getElementById('otb-image-modal-close');
if (!modal || !modalImg || !modalTitle || !closeBtn) return;
function openPreview(url, title) {
modalImg.src = url;
modalImg.alt = title || 'Preview image';
modalTitle.textContent = title || 'Preview';
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
}
function closePreview() {
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
modalImg.src = '';
modalImg.alt = '';
}
document.querySelectorAll('.preview-trigger').forEach(function (el) {
el.addEventListener('click', function () {
const url = this.dataset.previewUrl || this.getAttribute('src');
const title = this.dataset.previewTitle || this.getAttribute('alt') || 'Preview';
if (url) openPreview(url, title);
});
});
closeBtn.addEventListener('click', closePreview);
modal.addEventListener('click', function (e) {
if (e.target === modal) closePreview();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closePreview();
});
})();
</script>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

126
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Files</h2>
<p>Select files to delete, download, or send to Zip Workspace.</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<strong>{{ file.original_filename }}</strong><br>
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

126
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Files</h2>
<p>Select files to delete, download, or send to Zip Workspace.</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<strong>{{ file.original_filename }}</strong><br>
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

126
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Files</h2>
<p>Select files to delete, download, or send to Zip Workspace.</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<strong>{{ file.original_filename }}</strong><br>
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
</article>
</section>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

454
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 %}
<style>
.otb-view-toggle {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-view-toggle a {
text-decoration: none;
}
.otb-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-top: 18px;
}
.otb-gallery-card {
border: 1px solid rgba(255,255,255,0.10);
border-radius: 16px;
background: rgba(255,255,255,0.03);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 320px;
}
.otb-gallery-thumb-wrap {
position: relative;
aspect-ratio: 1 / 1;
background: rgba(255,255,255,0.04);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.otb-gallery-thumb {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
display: block;
}
.otb-gallery-check {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
transform: scale(1.2);
}
.otb-gallery-meta {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.otb-gallery-name {
font-weight: 700;
word-break: break-word;
line-height: 1.25;
}
.otb-gallery-sub {
font-size: 0.9rem;
opacity: 0.8;
line-height: 1.35;
}
.otb-gallery-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-gallery-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-gallery-rename input {
min-width: 120px;
flex: 1 1 120px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
.otb-modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 9999;
align-items: center;
justify-content: center;
padding: 20px;
}
.otb-modal-backdrop.active {
display: flex;
}
.otb-modal-card {
max-width: min(96vw, 1400px);
max-height: 94vh;
width: 100%;
background: rgba(11,18,37,0.98);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 18px;
overflow: hidden;
box-shadow: 0 18px 50px rgba(0,0,0,0.45);
}
.otb-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid rgba(255,255,255,0.10);
}
.otb-modal-title {
font-weight: 700;
word-break: break-word;
}
.otb-modal-body {
display: flex;
align-items: center;
justify-content: center;
background: #000;
max-height: calc(94vh - 64px);
overflow: auto;
}
.otb-modal-body img {
max-width: 100%;
max-height: calc(94vh - 100px);
object-fit: contain;
display: block;
}
.otb-list-name-wrap {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 320px;
}
.otb-list-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-list-rename input {
min-width: 220px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
</style>
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div class="otb-view-toggle">
<a class="portal-btn {% if view_mode == 'list' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='list') }}">List View</a>
<a class="portal-btn {% if view_mode == 'gallery' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='gallery') }}">Gallery View</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>{% if view_mode == 'gallery' %}Image Gallery{% else %}Files{% endif %}</h2>
<p>
{% 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 %}
</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form id="bulk-actions-form" method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
</form>
{% if view_mode == 'gallery' %}
<div class="otb-gallery-grid">
{% 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'] %}
<div class="otb-gallery-card" title="Name: {{ visible_name }}&#10;Original: {{ file.original_filename }}&#10;Type: {{ file.mime_type or file.file_kind }}&#10;Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes&#10;Uploaded: {{ file.uploaded_at }}">
<div class="otb-gallery-thumb-wrap">
<input class="row-check otb-gallery-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
{% if is_image %}
<img
src="{{ url_for('main.inline_file', file_id=file.id) }}"
alt="{{ visible_name }}"
class="otb-gallery-thumb preview-trigger"
data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}"
data-preview-title="{{ visible_name }}"
>
{% else %}
<div style="padding:20px;text-align:center;opacity:0.75;">No preview</div>
{% endif %}
</div>
<div class="otb-gallery-meta">
<div class="otb-gallery-name">{{ visible_name }}</div>
<div class="otb-gallery-sub">
{{ file.mime_type or file.file_kind }}<br>
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</div>
<div class="otb-gallery-actions">
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}">Preview</button>
{% endif %}
<a class="portal-btn" href="{{ url_for('main.download_file', file_id=file.id) }}">Download</a>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-gallery-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% 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'] %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<div class="otb-list-name-wrap">
<div>
<strong>{{ visible_name }}</strong><br>
{% if file.display_filename %}
<span style="opacity:0.75;font-size:0.9rem;">Original: {{ file.original_filename }}</span><br>
{% endif %}
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}" style="margin-top:8px;">Preview</button>
{% endif %}
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-list-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Enter custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</article>
</section>
<div id="otb-image-modal" class="otb-modal-backdrop" aria-hidden="true">
<div class="otb-modal-card">
<div class="otb-modal-header">
<div id="otb-image-modal-title" class="otb-modal-title">Preview</div>
<button type="button" class="portal-btn" id="otb-image-modal-close">Close</button>
</div>
<div class="otb-modal-body">
<img id="otb-image-modal-img" src="" alt="">
</div>
</div>
</div>
<script>
(function () {
const modal = document.getElementById('otb-image-modal');
const modalImg = document.getElementById('otb-image-modal-img');
const modalTitle = document.getElementById('otb-image-modal-title');
const closeBtn = document.getElementById('otb-image-modal-close');
if (!modal || !modalImg || !modalTitle || !closeBtn) return;
function openPreview(url, title) {
modalImg.src = url;
modalImg.alt = title || 'Preview image';
modalTitle.textContent = title || 'Preview';
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
}
function closePreview() {
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
modalImg.src = '';
modalImg.alt = '';
}
document.querySelectorAll('.preview-trigger').forEach(function (el) {
el.addEventListener('click', function () {
const url = this.dataset.previewUrl || this.getAttribute('src');
const title = this.dataset.previewTitle || this.getAttribute('alt') || 'Preview';
if (url) openPreview(url, title);
});
});
closeBtn.addEventListener('click', closePreview);
modal.addEventListener('click', function (e) {
if (e.target === modal) closePreview();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closePreview();
});
})();
</script>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

454
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 %}
<style>
.otb-view-toggle {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-view-toggle a {
text-decoration: none;
}
.otb-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-top: 18px;
}
.otb-gallery-card {
border: 1px solid rgba(255,255,255,0.10);
border-radius: 16px;
background: rgba(255,255,255,0.03);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 320px;
}
.otb-gallery-thumb-wrap {
position: relative;
aspect-ratio: 1 / 1;
background: rgba(255,255,255,0.04);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.otb-gallery-thumb {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
display: block;
}
.otb-gallery-check {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
transform: scale(1.2);
}
.otb-gallery-meta {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.otb-gallery-name {
font-weight: 700;
word-break: break-word;
line-height: 1.25;
}
.otb-gallery-sub {
font-size: 0.9rem;
opacity: 0.8;
line-height: 1.35;
}
.otb-gallery-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.otb-gallery-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-gallery-rename input {
min-width: 120px;
flex: 1 1 120px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
.otb-modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 9999;
align-items: center;
justify-content: center;
padding: 20px;
}
.otb-modal-backdrop.active {
display: flex;
}
.otb-modal-card {
max-width: min(96vw, 1400px);
max-height: 94vh;
width: 100%;
background: rgba(11,18,37,0.98);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 18px;
overflow: hidden;
box-shadow: 0 18px 50px rgba(0,0,0,0.45);
}
.otb-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid rgba(255,255,255,0.10);
}
.otb-modal-title {
font-weight: 700;
word-break: break-word;
}
.otb-modal-body {
display: flex;
align-items: center;
justify-content: center;
background: #000;
max-height: calc(94vh - 64px);
overflow: auto;
}
.otb-modal-body img {
max-width: 100%;
max-height: calc(94vh - 100px);
object-fit: contain;
display: block;
}
.otb-list-name-wrap {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 320px;
}
.otb-list-rename {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.otb-list-rename input {
min-width: 220px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #fff;
}
</style>
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Device Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
<div class="otb-view-toggle">
<a class="portal-btn {% if view_mode == 'list' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='list') }}">List View</a>
<a class="portal-btn {% if view_mode == 'gallery' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='gallery') }}">Gallery View</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>File count: <strong>{{ file_count }}</strong></div>
<div>Device path: <strong>{{ device.relative_path }}</strong></div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% if files %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>{% if view_mode == 'gallery' %}Image Gallery{% else %}Files{% endif %}</h2>
<p>
{% 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 %}
</p>
</div>
<div>
<span class="service-badge service-badge-beta">DB-backed</span>
</div>
</div>
<div class="service-card-actions" style="overflow-x:auto;">
<form id="bulk-actions-form" method="post">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;">
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button>
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button>
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button>
</div>
</form>
{% if view_mode == 'gallery' %}
<div class="otb-gallery-grid">
{% 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'] %}
<div class="otb-gallery-card" title="Name: {{ visible_name }}&#10;Original: {{ file.original_filename }}&#10;Type: {{ file.mime_type or file.file_kind }}&#10;Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes&#10;Uploaded: {{ file.uploaded_at }}">
<div class="otb-gallery-thumb-wrap">
<input class="row-check otb-gallery-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
{% if is_image %}
<img
src="{{ url_for('main.thumbnail_file', file_id=file.id) }}"
alt="{{ visible_name }}"
class="otb-gallery-thumb preview-trigger"
data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}"
data-preview-title="{{ visible_name }}"
>
{% else %}
<div style="padding:20px;text-align:center;opacity:0.75;">No preview</div>
{% endif %}
</div>
<div class="otb-gallery-meta">
<div class="otb-gallery-name">{{ visible_name }}</div>
<div class="otb-gallery-sub">
{{ file.mime_type or file.file_kind }}<br>
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</div>
<div class="otb-gallery-actions">
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}">Preview</button>
{% endif %}
<a class="portal-btn" href="{{ url_for('main.download_file', file_id=file.id) }}">Download</a>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-gallery-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;">
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);">
</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th>
</tr>
</thead>
<tbody>
{% 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'] %}
<tr>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form">
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<div class="otb-list-name-wrap">
<div>
<strong>{{ visible_name }}</strong><br>
{% if file.display_filename %}
<span style="opacity:0.75;font-size:0.9rem;">Original: {{ file.original_filename }}</span><br>
{% endif %}
{% if is_image %}
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}" style="margin-top:8px;">Preview</button>
{% endif %}
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
</div>
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-list-rename">
<input
type="text"
name="display_basename"
value="{{ rename_value }}"
maxlength="200"
placeholder="Enter custom file name"
>
{% if file.extension %}
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span>
{% endif %}
<button class="portal-btn" type="submit">Rename</button>
</form>
</div>
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.file_kind }}
{% if file.is_immutable %}
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span>
{% endif %}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ "{:,}".format(file.size_bytes or 0) }} bytes
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
{{ file.uploaded_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;">
{{ file.relative_path }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</article>
</section>
<div id="otb-image-modal" class="otb-modal-backdrop" aria-hidden="true">
<div class="otb-modal-card">
<div class="otb-modal-header">
<div id="otb-image-modal-title" class="otb-modal-title">Preview</div>
<button type="button" class="portal-btn" id="otb-image-modal-close">Close</button>
</div>
<div class="otb-modal-body">
<img id="otb-image-modal-img" src="" alt="">
</div>
</div>
</div>
<script>
(function () {
const modal = document.getElementById('otb-image-modal');
const modalImg = document.getElementById('otb-image-modal-img');
const modalTitle = document.getElementById('otb-image-modal-title');
const closeBtn = document.getElementById('otb-image-modal-close');
if (!modal || !modalImg || !modalTitle || !closeBtn) return;
function openPreview(url, title) {
modalImg.src = url;
modalImg.alt = title || 'Preview image';
modalTitle.textContent = title || 'Preview';
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
}
function closePreview() {
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
modalImg.src = '';
modalImg.alt = '';
}
document.querySelectorAll('.preview-trigger').forEach(function (el) {
el.addEventListener('click', function () {
const url = this.dataset.previewUrl || this.getAttribute('src');
const title = this.dataset.previewTitle || this.getAttribute('alt') || 'Preview';
if (url) openPreview(url, title);
});
});
closeBtn.addEventListener('click', closePreview);
modal.addEventListener('click', function (e) {
if (e.target === modal) closePreview();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closePreview();
});
})();
</script>
{% else %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>No files yet</h2>
<p>This device does not have any uploaded files recorded yet.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</div>
</article>
</section>
{% endif %}
{% endblock %}

89
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Add Device</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Create a named source for uploads like a laptop, phone, tablet, or workstation.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
<section class="services-grid">
<article class="service-card status-beta" style="max-width:900px;">
<div class="service-card-header">
<div>
<h2>Device Details</h2>
<p>This creates a device source and its storage folders.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Setup</span>
</div>
</div>
<div class="service-card-actions">
<form method="post" action="{{ url_for('main.add_device') }}">
<div style="display:grid;gap:16px;max-width:680px;">
<div>
<label for="device_name" style="display:block;margin-bottom:6px;font-weight:700;">Device Name</label>
<input
id="device_name"
name="device_name"
type="text"
value="{{ device_name or '' }}"
placeholder="Example: Main Laptop"
style="width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.14);background:rgba(255,255,255,0.04);color:white;"
required
>
</div>
<div>
<label for="device_type" style="display:block;margin-bottom:6px;font-weight:700;">Device Type</label>
<select
id="device_type"
name="device_type"
style="width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.14);background:#0f1a2b;color:white;"
required
>
<option value="">Select type</option>
<option value="laptop" {% if device_type == 'laptop' %}selected{% endif %}>Laptop</option>
<option value="phone" {% if device_type == 'phone' %}selected{% endif %}>Phone</option>
<option value="tablet" {% if device_type == 'tablet' %}selected{% endif %}>Tablet</option>
<option value="desktop" {% if device_type == 'desktop' %}selected{% endif %}>Desktop</option>
<option value="workstation" {% if device_type == 'workstation' %}selected{% endif %}>Workstation</option>
<option value="other" {% if device_type == 'other' %}selected{% endif %}>Other</option>
</select>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button class="portal-btn primary" type="submit">Create Device</button>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Cancel</a>
</div>
</div>
</form>
</div>
</article>
</section>
{% endblock %}

63
app/templates/cloud/lts.html

@ -0,0 +1,63 @@
{% extends "portal_base.html" %}
{% block title %}LTS Storage - OTB Cloud{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">LTS Storage</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Long-term stored archives.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Back to Archive Workspace</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
<section class="services-grid">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>LTS Archives</h2>
<p>Archive files retained in long-term storage.</p>
</div>
<div>
<span class="service-badge service-badge-beta">{{ lts_files|length }} stored</span>
</div>
</div>
<div class="service-card-actions">
{% if lts_files %}
<ul style="padding-left:18px; margin:0;">
{% for item in lts_files %}
<li style="margin-bottom:14px;">
<strong>{{ item.name }}</strong><br>
<span style="opacity:0.75;">{{ "{:,}".format(item.size_bytes) }} bytes</span>
</li>
{% endfor %}
</ul>
{% else %}
<p style="margin:0;">No files in LTS storage yet.</p>
{% endif %}
</div>
</article>
</section>
{% endblock %}

57
app/templates/cloud/upload.html

@ -46,6 +46,7 @@
<div class="service-card-actions"> <div class="service-card-actions">
<form method="post" action="{{ url_for('main.upload_files', device_id=device.id) }}" enctype="multipart/form-data"> <form method="post" action="{{ url_for('main.upload_files', device_id=device.id) }}" enctype="multipart/form-data">
<div style="display:grid;gap:16px;max-width:780px;"> <div style="display:grid;gap:16px;max-width:780px;">
<div> <div>
<label for="files" style="display:block;margin-bottom:6px;font-weight:700;">Choose Files</label> <label for="files" style="display:block;margin-bottom:6px;font-weight:700;">Choose Files</label>
<input <input
@ -53,7 +54,19 @@
name="files" name="files"
type="file" type="file"
multiple multiple
required style="width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.14);background:rgba(255,255,255,0.04);color:white;"
>
</div>
<div>
<label for="folder_files" style="display:block;margin-bottom:6px;font-weight:700;">Choose Folder</label>
<input
id="folder_files"
name="files"
type="file"
webkitdirectory
directory
multiple
style="width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.14);background:rgba(255,255,255,0.04);color:white;" style="width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.14);background:rgba(255,255,255,0.04);color:white;"
> >
</div> </div>
@ -66,9 +79,51 @@
<div style="opacity:0.8;font-size:0.95rem;"> <div style="opacity:0.8;font-size:0.95rem;">
Files uploaded here are stored in the device originals folder and recorded in the database. Files uploaded here are stored in the device originals folder and recorded in the database.
</div> </div>
<div id="upload-info" style="margin-top:10px;font-size:0.95rem;opacity:0.85;"></div>
<div id="upload-warning" style="margin-top:10px;color:#ffcc00;font-size:0.9rem;"></div>
</div> </div>
</form> </form>
</div> </div>
</article> </article>
</section> </section>
<script>
const fileInput = document.getElementById('files');
const folderInput = document.getElementById('folder_files');
const info = document.getElementById('upload-info');
const warn = document.getElementById('upload-warning');
function updateUploadInfo(sourceLabel, inputEl) {
const count = inputEl.files.length;
if (!count) {
info.innerText = "";
warn.innerText = "";
return;
}
info.innerText = `${sourceLabel}: selected ${count} file(s)`;
if (count > 100) {
warn.innerText = "Large upload detected. Use WiFi or wired connection if possible, and keep this browser tab open until the upload completes.";
} else {
warn.innerText = "";
}
}
fileInput.addEventListener('change', function() {
if (this.files.length) {
folderInput.value = "";
}
updateUploadInfo("Files", this);
});
folderInput.addEventListener('change', function() {
if (this.files.length) {
fileInput.value = "";
}
updateUploadInfo("Folder", this);
});
</script>
{% endblock %} {% endblock %}

96
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Upload Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Upload files into the <strong>{{ device.device_name }}</strong> device originals area.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
<section class="services-grid">
<article class="service-card status-beta" style="max-width:900px;">
<div class="service-card-header">
<div>
<h2>Upload to {{ device.device_name }}</h2>
<p>Selected files will be stored as immutable originals.</p>
</div>
<div>
<span class="service-badge service-badge-beta">{{ device.device_type|capitalize }}</span>
</div>
</div>
<div class="service-card-actions">
<form method="post" action="{{ url_for('main.upload_files', device_id=device.id) }}" enctype="multipart/form-data">
<div style="display:grid;gap:16px;max-width:780px;">
<div>
<label for="files" style="display:block;margin-bottom:6px;font-weight:700;">Choose Files</label>
<input
id="files"
name="files"
type="file" webkitdirectory directory
multiple
required
style="width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.14);background:rgba(255,255,255,0.04);color:white;"
>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button class="portal-btn primary" type="submit">Upload Selected Files</button>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Cancel</a>
</div>
<div style="opacity:0.8;font-size:0.95rem;">
Files uploaded here are stored in the device originals folder and recorded in the database.
</div>
</div>
<div id="upload-info" style="margin-top:10px;font-size:0.95rem;opacity:0.85;"></div>
<div id="upload-warning" style="margin-top:10px;color:#ffcc00;font-size:0.9rem;"></div>
<script>
const fileInput = document.getElementById('files');
const info = document.getElementById('upload-info');
const warn = document.getElementById('upload-warning');
fileInput.addEventListener('change', function() {
const count = this.files.length;
info.innerText = `Selected ${count} file(s)`;
if (count > 100) {
warn.innerText = "Large upload detected. Use WiFi or wired connection. Keep this tab open.";
} else {
warn.innerText = "";
}
});
</script>
</form>
</div>
</article>
</section>
{% endblock %}

74
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 %}
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Upload Files</h1>
<p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle">
Upload files into the <strong>{{ device.device_name }}</strong> device originals area.
</p>
</div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section style="margin-bottom:22px;">
{% for category, message in messages %}
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;">
<strong>{{ category|capitalize }}:</strong> {{ message }}
</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
<section class="services-grid">
<article class="service-card status-beta" style="max-width:900px;">
<div class="service-card-header">
<div>
<h2>Upload to {{ device.device_name }}</h2>
<p>Selected files will be stored as immutable originals.</p>
</div>
<div>
<span class="service-badge service-badge-beta">{{ device.device_type|capitalize }}</span>
</div>
</div>
<div class="service-card-actions">
<form method="post" action="{{ url_for('main.upload_files', device_id=device.id) }}" enctype="multipart/form-data">
<div style="display:grid;gap:16px;max-width:780px;">
<div>
<label for="files" style="display:block;margin-bottom:6px;font-weight:700;">Choose Files</label>
<input
id="files"
name="files"
type="file"
multiple
required
style="width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.14);background:rgba(255,255,255,0.04);color:white;"
>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button class="portal-btn primary" type="submit">Upload Selected Files</button>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Cancel</a>
</div>
<div style="opacity:0.8;font-size:0.95rem;">
Files uploaded here are stored in the device originals folder and recorded in the database.
</div>
</div>
</form>
</div>
</article>
</section>
{% endblock %}

64
app/templates/cloud/zip_workspace.html

@ -1,20 +1,21 @@
{% extends "portal_base.html" %} {% extends "portal_base.html" %}
{% block title %}Zip Workspace - OTB Cloud{% endblock %} {% block title %}Archive Workspace - OTB Cloud{% endblock %}
{% block portal_content %} {% block portal_content %}
<div class="portal-page-header"> <div class="portal-page-header">
<div> <div>
<h1 class="portal-page-title">Zip Workspace</h1> <h1 class="portal-page-title">Archive Workspace</h1>
<p class="portal-client-name">{{ user_email }}</p> <p class="portal-client-name">{{ user_email }}</p>
<p class="portal-page-subtitle"> <p class="portal-page-subtitle">
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.
</p> </p>
</div> </div>
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;"> <div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;"> <div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a> <a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
<a class="portal-btn" href="{{ url_for('main.lts_view') }}">View LTS</a>
</div> </div>
</div> </div>
</div> </div>
@ -45,9 +46,32 @@
<div class="service-card-actions"> <div class="service-card-actions">
{% if staged_files %} {% if staged_files %}
<form method="post" action="{{ url_for('main.create_zip_from_workspace') }}" style="margin-bottom:14px;"> <form method="post" action="{{ url_for('main.create_zip_from_workspace') }}" style="margin-bottom:14px;display:flex;flex-direction:column;gap:10px;max-width:420px;">
<button class="portal-btn primary" type="submit">Zip It</button> <label>Archive Name</label>
<input type="text" name="archive_name" placeholder="my-backup" class="portal-input">
<label>Format</label>
<div style="display:flex;flex-direction:column;gap:6px;">
<label>
<input type="radio" name="format" value="zip" checked>
ZIP — best Windows compatibility
</label>
<label>
<input type="radio" name="format" value="tar">
TAR — fastest (no compression)
</label>
<label>
<input type="radio" name="format" value="targz">
TAR.GZ — good compression, faster than ZIP
</label>
</div>
<button class="portal-btn primary" type="submit">Create Archive</button>
</form> </form>
<ul style="padding-left:18px; margin:0;"> <ul style="padding-left:18px; margin:0;">
{% for item in staged_files %} {% for item in staged_files %}
<li style="margin-bottom:8px;"> <li style="margin-bottom:8px;">
@ -66,7 +90,7 @@
<div class="service-card-header"> <div class="service-card-header">
<div> <div>
<h2>Exports</h2> <h2>Exports</h2>
<p>Completed zip files are stored here for download.</p> <p>Completed archive files are stored here for download or transfer to LTS.</p>
</div> </div>
<div> <div>
<span class="service-badge service-badge-beta">{{ export_files|length }} exports</span> <span class="service-badge service-badge-beta">{{ export_files|length }} exports</span>
@ -77,17 +101,39 @@
{% if export_files %} {% if export_files %}
<ul style="padding-left:18px; margin:0;"> <ul style="padding-left:18px; margin:0;">
{% for item in export_files %} {% for item in export_files %}
<li style="margin-bottom:10px;"> <li style="margin-bottom:14px;">
<strong>{{ item.name }}</strong><br> <strong>{{ item.name }}</strong><br>
<span style="opacity:0.75;">{{ "{:,}".format(item.size_bytes) }} bytes • {{ item.path }}</span><br> <span style="opacity:0.75;">{{ "{:,}".format(item.size_bytes) }} bytes • {{ item.path }}</span><br>
<a class="portal-btn" style="margin-top:8px;display:inline-block;" href="{{ url_for('main.download_export', filename=item.name) }}">Download Zip</a>
<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;">
<a class="portal-btn" href="{{ url_for('main.download_export', filename=item.name) }}">Download Archive</a>
<a class="portal-btn" href="{{ url_for('main.download_and_remove_export', filename=item.name) }}">Download + Remove</a>
<form method="post" action="{{ url_for('main.move_export_to_lts', filename=item.name) }}" style="display:inline;">
<button class="portal-btn" type="submit">Move to LTS</button>
</form>
</div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p style="margin:0;">No export zip files yet.</p> <p style="margin:0;">No export archive files yet.</p>
{% endif %} {% endif %}
</div> </div>
</article> </article>
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>LTS Storage</h2>
<p>Long-term stored archives.</p>
</div>
</div>
<div class="service-card-actions">
<a class="portal-btn" href="{{ url_for('main.lts_view') }}">View LTS</a>
</div>
</article>
</section> </section>
{% endblock %} {% endblock %}

112
app/templates/portal_base.html.bak.20260412-235158

@ -0,0 +1,112 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}OTB Cloud{% endblock %}</title>
<style>
:root {
--bg: #0b1220;
--panel: #121b2d;
--panel-2: #16233b;
--text: #e9eef8;
--muted: #9fb0cf;
--line: rgba(255,255,255,0.10);
--accent: #4da3ff;
--danger: #ff6b6b;
--ok: #4fd18b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, sans-serif;
background: linear-gradient(180deg, #08101d 0%, #0b1220 100%);
color: var(--text);
}
a { color: var(--accent); text-decoration: none; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 20px; }
.topbar {
border-bottom: 1px solid var(--line);
background: rgba(255,255,255,0.03);
}
.nav {
display: flex;
gap: 18px;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
max-width: 1200px;
margin: 0 auto;
}
.brand {
font-weight: bold;
font-size: 20px;
letter-spacing: 0.3px;
}
.nav-links {
display: flex;
gap: 14px;
flex-wrap: wrap;
align-items: center;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
padding: 20px;
box-shadow: 0 10px 24px rgba(0,0,0,0.18);
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.muted { color: var(--muted); }
.badge {
display: inline-block;
padding: 6px 10px;
border-radius: 999px;
background: rgba(77,163,255,0.12);
border: 1px solid rgba(77,163,255,0.22);
color: var(--text);
font-size: 12px;
}
.footer {
border-top: 1px solid var(--line);
margin-top: 28px;
color: var(--muted);
}
.footer .wrap {
padding-top: 16px;
padding-bottom: 24px;
}
.warn {
border-left: 4px solid var(--danger);
background: rgba(255,107,107,0.08);
padding: 14px 16px;
border-radius: 12px;
}
</style>
</head>
<body>
<div class="topbar">
<div class="nav">
<div class="brand">OTB Cloud</div>
<div class="nav-links">
<a href="/dashboard">Dashboard</a>
<a href="/auth/logout">Logout</a>
</div>
</div>
</div>
<div class="wrap">
{% block content %}{% endblock %}
</div>
<div class="footer">
<div class="wrap">
Temporary local portal base template for OTB Cloud v0.1.1. Replace this with the shared OTB portal base during integration.
</div>
</div>
</body>
</html>

38
backups/ui-import-20260413-011506/dashboard.html

@ -0,0 +1,38 @@
{% extends "portal_base.html" %}
{% block title %}OTB Cloud Dashboard{% endblock %}
{% block content %}
<div class="card" style="margin-bottom:16px;">
<h1 style="margin-top:0;">OTB Cloud Dashboard</h1>
<p class="muted" style="margin-bottom:10px;">Authenticated user: {{ user_email }}</p>
<p class="muted" style="margin:0;">Tenant slug: <span class="badge">{{ tenant_slug }}</span></p>
</div>
<div class="grid">
<div class="card">
<h2 style="margin-top:0;">Devices</h2>
{% if devices %}
<ul style="padding-left:18px; margin-bottom:0;">
{% for device in devices %}
<li style="margin-bottom:8px;">
<strong>{{ device.device_name }}</strong>
<span class="muted">({{ device.device_type }})</span><br>
<span class="muted">{{ device.relative_path }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No devices have been created yet.</p>
{% endif %}
</div>
<div class="card">
<h2 style="margin-top:0;">Current scope</h2>
<p class="muted">
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.
</p>
</div>
</div>
{% endblock %}

13
backups/ui-import-20260413-011506/handoff_error.html

@ -0,0 +1,13 @@
{% extends "portal_base.html" %}
{% block title %}Portal Handoff Error{% endblock %}
{% block content %}
<div class="card">
<h1>Portal handoff failed</h1>
<p class="muted">{{ message }}</p>
<div class="warn" style="margin-top:16px;">
Please return to the OTB Billing portal and try launching OTB Cloud again.
</div>
</div>
{% endblock %}

15
backups/ui-import-20260413-011506/login_required.html

@ -0,0 +1,15 @@
{% extends "portal_base.html" %}
{% block title %}Portal Login Required{% endblock %}
{% block content %}
<div class="card">
<h1>Portal login required</h1>
<p class="muted">
OTB Cloud is available only through a signed handoff from the OTB Billing portal.
</p>
<div class="warn" style="margin-top:16px;">
Please return to the OTB Billing portal and open OTB Cloud from your Services page.
</div>
</div>
{% endblock %}

114
backups/ui-import-20260413-011506/portal_base.html

@ -0,0 +1,114 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}OTB Cloud{% endblock %}</title>
<style>
:root {
--bg: #0b1220;
--panel: #121b2d;
--panel-2: #16233b;
--text: #e9eef8;
--muted: #9fb0cf;
--line: rgba(255,255,255,0.10);
--accent: #4da3ff;
--danger: #ff6b6b;
--ok: #4fd18b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, sans-serif;
background: linear-gradient(180deg, #08101d 0%, #0b1220 100%);
color: var(--text);
}
a { color: var(--accent); text-decoration: none; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 20px; }
.topbar {
border-bottom: 1px solid var(--line);
background: rgba(255,255,255,0.03);
}
.nav {
display: flex;
gap: 18px;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
max-width: 1200px;
margin: 0 auto;
}
.brand {
font-weight: bold;
font-size: 20px;
letter-spacing: 0.3px;
}
.nav-links {
display: flex;
gap: 14px;
flex-wrap: wrap;
align-items: center;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
padding: 20px;
box-shadow: 0 10px 24px rgba(0,0,0,0.18);
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.muted { color: var(--muted); }
.badge {
display: inline-block;
padding: 6px 10px;
border-radius: 999px;
background: rgba(77,163,255,0.12);
border: 1px solid rgba(77,163,255,0.22);
color: var(--text);
font-size: 12px;
}
.footer {
border-top: 1px solid var(--line);
margin-top: 28px;
color: var(--muted);
}
.footer .wrap {
padding-top: 16px;
padding-bottom: 24px;
}
.warn {
border-left: 4px solid var(--danger);
background: rgba(255,107,107,0.08);
padding: 14px 16px;
border-radius: 12px;
}
</style>
</head>
<body>
<div class="topbar">
<div class="nav">
<div class="brand">OTB Cloud</div>
<div class="nav-links">
{% if session.get("otb_user_id") %}
<a href="/dashboard">Dashboard</a>
<a href="/auth/logout">Logout</a>
{% endif %}
</div>
</div>
</div>
<div class="wrap">
{% block content %}{% endblock %}
</div>
<div class="footer">
<div class="wrap">
OTB Cloud is a portal-linked secure backup and storage service for Outsidethebox.top.
</div>
</div>
</body>
</html>

170
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 ====="

2
run.py

@ -3,4 +3,4 @@ from app import create_app
app = create_app() app = create_app()
if __name__ == "__main__": 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)

0
scripts/bootstrap_db.sh

0
scripts/bootstrap_storage.sh

0
scripts/create_tenant_layout.sh

27
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}")

0
scripts/setup_venv.sh

3
wsgi.py

@ -0,0 +1,3 @@
from app import create_app
app = create_app()
Loading…
Cancel
Save