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. 776
      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. 10
      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. 440
      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
5. Add derived/original filtering
6. Add better single-file actions in browser
## Current update: v0.2.5
- Added inline image serving route for browser previews
- Added device browser view toggle: list or gallery
- Added gallery cards with thumbnails, preview modal, rename, download, and checkbox actions
- Existing bulk delete, download, and zip staging continue to work in both views
## v0.2.5 — Gallery View + Image Preview
### Added
- Gallery view toggle for device file browser
- Image thumbnail rendering (inline file route)
- Click-to-preview full image modal
- Gallery cards with:
- checkbox selection
- rename input
- download button
- preview button
### Improved
- File browsing now supports both:
- list (management)
- gallery (visual)
- Bulk actions work in both views
- Display filename system fully integrated across UI
### Notes
- Originals remain immutable
- Thumbnails currently use original images (no derived images yet)
- Foundation ready for future media processing pipeline
## Current update: v0.2.8
- Added folder-tree browser scoped by current path
- Added clickable breadcrumbs for direct jumps to any parent folder
- Added folders-first navigation while preserving list/gallery modes for files in the current folder
- Browser now reflects preserved backup folder structure instead of flattening all files into one device-wide listing
## v1.1.0-alpha1 — Video System Foundation
- Added video_jobs table (processing queue)
- Added tenant_usage_metrics table (dashboard metrics)
- Added video service scaffolding (jobs, metrics, gpu select, profiles)
- Extended device structure to include:
- video
- video-workshop
- archive
- lts
- Prepared system for background worker architecture
Next step:
- Build video worker processing engine

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
- Backup created before LTS / cold storage archive workflow
- 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):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
base = tenant_root / device_path
for subdir in ["originals", "derived", "exports", "deleted", "tmp"]:
for subdir in ["originals", "video", "video-workshop", "archive", "lts", "derived", "exports", "deleted", "tmp"]:
(base / subdir).mkdir(parents=True, exist_ok=True)
def remove_device_directories(tenant_slug: str, device_path: str):

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

776
app/main/routes.py

@ -3,9 +3,13 @@ from pathlib import Path
from datetime import datetime, timezone
import shutil
import zipfile
import tarfile
from PIL import Image
import re
import hashlib
from werkzeug.utils import secure_filename
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify
from app.db import get_db
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256
@ -33,7 +37,6 @@ def _stored_name(original_name: str) -> str:
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
return f"{ts}__{safe}"
def _recovered_filename(original_name: str) -> tuple[str, str, str]:
base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." in base_name:
@ -43,6 +46,41 @@ def _recovered_filename(original_name: str) -> tuple[str, str, str]:
recovered_name = f"{base_name}-recovered"
return recovered_name, f"{base_name}-recovered", ""
def _display_filename(file_row) -> str:
if not file_row:
return "download.bin"
return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip()
def _sanitize_display_basename(raw_name: str) -> str:
cleaned = (raw_name or "").replace("\x00", "").strip()
cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned)
cleaned = cleaned.strip().strip(".")
if "." in cleaned:
cleaned = cleaned.rsplit(".", 1)[0].strip()
return cleaned[:200]
def _generate_thumbnail(original_path: Path, thumb_path: Path):
thumb_path.parent.mkdir(parents=True, exist_ok=True)
try:
with Image.open(original_path) as img:
img.thumbnail((400, 400))
img.save(thumb_path)
except Exception:
pass
def _normalize_browser_path(raw_path: str) -> str:
raw = (raw_path or "").replace("\\", "/").strip().strip("/")
if not raw:
return ""
parts = []
for part in raw.split("/"):
part = part.strip()
if not part or part in (".", ".."):
continue
parts.append(part)
return "/".join(parts)
def _safe_path_from_relative(relative_path: str) -> Path:
return _tenant_root() / relative_path
@ -132,6 +170,61 @@ def dashboard():
devices=devices,
)
@bp.route("/devices/android/new", methods=["GET", "POST"])
@portal_session_required
def create_android_device():
db = get_db()
if request.method == "GET":
return render_template(
"cloud/android_device_new.html",
user_email=session.get("otb_email"),
tenant_slug=session.get("otb_tenant_slug"),
)
device_name = (request.form.get("device_name") or "").strip()
if not device_name:
flash("Device name is required.", "warning")
return redirect(url_for("main.create_android_device"))
slug = slugify_device_name(device_name)
relative_path = f"devices/{slug}"
import hashlib, secrets, datetime
raw_token = secrets.token_hex(16)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=48)
with db.cursor() as cur:
# create device bucket
cur.execute(
"""
INSERT INTO devices (tenant_id, device_name, device_type, relative_path)
VALUES (%s, %s, %s, %s)
""",
(session["otb_tenant_id"], device_name, "android", relative_path),
)
device_id = cur.lastrowid
# create token
cur.execute(
"""
INSERT INTO android_device_tokens (tenant_id, device_id, token_hash, device_label, expires_at)
VALUES (%s, %s, %s, %s, %s)
""",
(session["otb_tenant_id"], device_id, token_hash, device_name, expires_at),
)
db.commit()
flash(f"Activation token (valid 48h): {raw_token}", "success")
flash(" This token must be used within 48 hours or it will expire.", "warning")
return redirect(url_for("main.dashboard"))
@bp.route("/devices/new", methods=["GET", "POST"])
@portal_session_required
def add_device():
@ -234,18 +327,42 @@ def delete_device(device_id: int):
with db.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) AS file_count
SELECT
COUNT(*) AS total_file_count,
SUM(CASE WHEN is_deleted = 0 THEN 1 ELSE 0 END) AS active_file_count,
SUM(CASE WHEN is_deleted = 1 THEN 1 ELSE 0 END) AS deleted_file_count
FROM files
WHERE tenant_id = %s AND device_id = %s AND is_deleted = 0
WHERE tenant_id = %s AND device_id = %s
""",
(session["otb_tenant_id"], device_id),
)
file_count = cur.fetchone()["file_count"]
counts = cur.fetchone()
total_file_count = int(counts["total_file_count"] or 0)
active_file_count = int(counts["active_file_count"] or 0)
deleted_file_count = int(counts["deleted_file_count"] or 0)
if file_count and int(file_count) > 0:
flash("This device cannot be removed because files are still linked to it.", "warning")
if total_file_count > 0:
if deleted_file_count > 0:
flash(
f"This device is not empty. It still has {active_file_count} active file(s) and {deleted_file_count} file(s) in Deleted Files. Please clear those before deleting the device.",
"warning",
)
else:
flash(
f"This device is not empty. It still has {active_file_count} active file(s). Please remove them before deleting the device.",
"warning",
)
return redirect(url_for("main.dashboard"))
cur.execute(
"""
DELETE FROM android_device_tokens
WHERE tenant_id = %s AND device_id = %s
""",
(session["otb_tenant_id"], device_id),
)
cur.execute(
"""
DELETE FROM devices
@ -286,6 +403,16 @@ def upload_files(device_id: int):
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
if device.get("device_type") == "android":
flash("Android backup devices only accept uploads from the OTB Cloud mobile app.", "warning")
return redirect(url_for("main.dashboard"))
view_mode = request.args.get("view", "list").strip().lower()
if view_mode not in ("list", "gallery"):
view_mode = "list"
return redirect(url_for("main.dashboard"))
if request.method == "GET":
return render_template(
"cloud/upload.html",
@ -314,8 +441,26 @@ def upload_files(device_id: int):
with db.cursor() as cur:
for incoming in files:
original_filename = incoming.filename or "upload.bin"
stored_name = _stored_name(original_filename)
target_path = upload_base / stored_name
# preserve folder structure from browser
relative_upload_path = original_filename.replace("\\", "/")
path_parts = relative_upload_path.split("/")
if len(path_parts) > 1:
subdirs = "/".join(path_parts[:-1])
filename_only = path_parts[-1]
else:
subdirs = ""
filename_only = path_parts[0]
stored_name = _stored_name(filename_only)
target_dir = upload_base
if subdirs:
target_dir = upload_base / subdirs
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / stored_name
incoming.save(target_path)
@ -328,8 +473,12 @@ def upload_files(device_id: int):
else:
basename, extension = base_name, ""
relative_path = f"{device['relative_path']}/originals/{stored_name}"
directory_path = f"{device['relative_path']}/originals"
if subdirs:
relative_path = f"{device['relative_path']}/originals/{subdirs}/{stored_name}"
directory_path = f"{device['relative_path']}/originals/{subdirs}"
else:
relative_path = f"{device['relative_path']}/originals/{stored_name}"
directory_path = f"{device['relative_path']}/originals"
cur.execute(
"""
@ -382,6 +531,70 @@ def upload_files(device_id: int):
flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success")
return redirect(url_for("main.dashboard"))
@bp.route("/files/<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"])
@portal_session_required
def download_file(file_id: int):
@ -390,7 +603,7 @@ def download_file(file_id: int):
with db.cursor() as cur:
cur.execute(
"""
SELECT id, original_filename, relative_path
SELECT id, original_filename, display_filename, relative_path
FROM files
WHERE id = %s AND tenant_id = %s
""",
@ -407,7 +620,7 @@ def download_file(file_id: int):
flash("File is missing from storage.", "warning")
return redirect(url_for("main.dashboard"))
return send_file(file_path, as_attachment=True, download_name=file_row["original_filename"])
return send_file(file_path, as_attachment=True, download_name=_display_filename(file_row))
@bp.route("/devices/<int:device_id>/files/download-selected", methods=["POST"])
@portal_session_required
@ -417,6 +630,11 @@ def download_selected_files(device_id: int):
if not device:
flash("Device not found.", "warning")
view_mode = request.args.get("view", "list").strip().lower()
if view_mode not in ("list", "gallery"):
view_mode = "list"
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
@ -426,7 +644,7 @@ def download_selected_files(device_id: int):
return redirect(url_for("main.browse_device_files", device_id=device_id))
if len(selected_ids) != 1:
flash("Download Selected currently supports one file at a time. Use Zip Workspace for multiple files.", "warning")
flash("Download Selected currently supports one file at a time. Use Archive Workspace for multiple files.", "warning")
return redirect(url_for("main.browse_device_files", device_id=device_id))
return redirect(url_for("main.download_file", file_id=selected_ids[0]))
@ -439,6 +657,11 @@ def delete_selected_files(device_id: int):
if not device:
flash("Device not found.", "warning")
view_mode = request.args.get("view", "list").strip().lower()
if view_mode not in ("list", "gallery"):
view_mode = "list"
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
@ -520,6 +743,11 @@ def send_selected_to_zip_workspace(device_id: int):
if not device:
flash("Device not found.", "warning")
view_mode = request.args.get("view", "list").strip().lower()
if view_mode not in ("list", "gallery"):
view_mode = "list"
return redirect(url_for("main.dashboard"))
selected_ids = [int(x) for x in request.form.getlist("selected_files") if x.strip().isdigit()]
@ -537,7 +765,7 @@ def send_selected_to_zip_workspace(device_id: int):
for file_id in selected_ids:
cur.execute(
"""
SELECT id, original_filename, relative_path, is_deleted
SELECT id, original_filename, display_filename, relative_path, is_deleted
FROM files
WHERE id = %s AND tenant_id = %s AND device_id = %s
""",
@ -552,7 +780,7 @@ def send_selected_to_zip_workspace(device_id: int):
if not source_path.exists():
continue
target_name = _stored_name(file_row["original_filename"])
target_name = _stored_name(_display_filename(file_row))
target_path = staging_dir / target_name
shutil.copy2(source_path, target_path)
@ -568,7 +796,7 @@ def send_selected_to_zip_workspace(device_id: int):
file_id,
_client_ip(),
request.headers.get("User-Agent", ""),
f"Copied '{file_row['original_filename']}' into zip workspace",
f"Copied '{_display_filename(file_row)}' into zip workspace",
),
)
@ -576,7 +804,7 @@ def send_selected_to_zip_workspace(device_id: int):
db.commit()
flash(f"Sent {copied_count} file(s) to Zip Workspace.", "success")
flash(f"Sent {copied_count} file(s) to Archive Workspace.", "success")
return redirect(url_for("main.browse_device_files", device_id=device_id))
@bp.route("/workspace/zip", methods=["GET"])
@ -625,15 +853,41 @@ def create_zip_from_workspace():
staged = [p for p in staging_dir.iterdir() if p.is_file()]
if not staged:
flash("Zip Workspace is empty.", "warning")
flash("Archive Workspace is empty.", "warning")
return redirect(url_for("main.zip_workspace"))
zip_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.zip"
zip_path = exports_dir / zip_name
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in staged:
zf.write(p, arcname=p.name)
archive_name = (request.form.get("archive_name") or "").strip()
archive_format = (request.form.get("format") or "zip").strip().lower()
if not archive_name:
archive_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}"
archive_name = re.sub(r"[^A-Za-z0-9._-]+", "_", archive_name).strip("._-")
if not archive_name:
archive_name = f"otb-cloud-export-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}"
if archive_format == "tar":
archive_filename = f"{archive_name}.tar"
archive_path = exports_dir / archive_filename
with tarfile.open(archive_path, "w") as tf:
for p in staged:
arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name
tf.add(p, arcname=arcname)
elif archive_format == "targz":
archive_filename = f"{archive_name}.tar.gz"
archive_path = exports_dir / archive_filename
with tarfile.open(archive_path, "w:gz") as tf:
for p in staged:
arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name
tf.add(p, arcname=arcname)
else:
archive_format = "zip"
archive_filename = f"{archive_name}.zip"
archive_path = exports_dir / archive_filename
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in staged:
arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name
zf.write(p, arcname=arcname)
for p in staged:
p.unlink(missing_ok=True)
@ -651,12 +905,12 @@ def create_zip_from_workspace():
session["otb_user_id"],
_client_ip(),
request.headers.get("User-Agent", ""),
f"Created zip export '{zip_name}' in exports workspace; staged copies cleared after success",
f"Created {archive_format} export '{archive_filename}' in exports workspace; staged copies cleared after success",
),
)
db.commit()
flash(f"Zip file created successfully. You can find it in the Exports section below as '{zip_name}'.", "success")
flash(f"Archive created successfully. You can find it in the Exports section below as '{archive_filename}'.", "success")
return redirect(url_for("main.zip_workspace"))
@bp.route("/workspace/exports/<path:filename>/download", methods=["GET"])
@ -705,8 +959,6 @@ def deleted_files():
files=files,
)
@bp.route("/deleted/<int:file_id>/recover", methods=["POST"])
@portal_session_required
def recover_deleted_file(file_id: int):
@ -760,6 +1012,7 @@ def recover_deleted_file(file_id: int):
"""
UPDATE files
SET original_filename = %s,
display_filename = NULL,
basename = %s,
extension = %s,
relative_path = %s,
@ -800,7 +1053,6 @@ def recover_deleted_file(file_id: int):
flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success")
return redirect(url_for("main.deleted_files"))
@bp.route("/deleted/<int:file_id>/hard-delete", methods=["POST"])
@portal_session_required
def hard_delete_file(file_id: int):
@ -848,6 +1100,88 @@ def hard_delete_file(file_id: int):
flash("File permanently deleted.", "success")
return redirect(url_for("main.deleted_files"))
@bp.route("/files/<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):
@ -858,6 +1192,14 @@ def browse_device_files(device_id: int):
flash("Device not found.", "warning")
return redirect(url_for("main.dashboard"))
view_mode = request.args.get("view", "list").strip().lower()
if view_mode not in ("list", "gallery"):
view_mode = "list"
current_path = _normalize_browser_path(request.args.get("path", ""))
root_directory = f"{device['relative_path']}/originals"
current_directory = f"{root_directory}/{current_path}" if current_path else root_directory
with db.cursor() as cur:
cur.execute(
"""
@ -867,6 +1209,7 @@ def browse_device_files(device_id: int):
relative_path,
directory_path,
original_filename,
display_filename,
basename,
extension,
mime_type,
@ -878,12 +1221,70 @@ def browse_device_files(device_id: int):
WHERE tenant_id = %s
AND device_id = %s
AND is_deleted = 0
AND directory_path = %s
ORDER BY uploaded_at DESC, id DESC
""",
(session["otb_tenant_id"], device_id),
(session["otb_tenant_id"], device_id, current_directory),
)
files = cur.fetchall()
cur.execute(
"""
SELECT DISTINCT directory_path
FROM files
WHERE tenant_id = %s
AND device_id = %s
AND is_deleted = 0
AND directory_path LIKE %s
ORDER BY directory_path
""",
(session["otb_tenant_id"], device_id, f"{current_directory}/%"),
)
all_dirs = [row["directory_path"] for row in cur.fetchall()]
folder_names = []
prefix = current_directory + "/"
for d in all_dirs:
remainder = d[len(prefix):] if d.startswith(prefix) else ""
if not remainder:
continue
first_segment = remainder.split("/", 1)[0]
if first_segment and first_segment not in folder_names:
folder_names.append(first_segment)
folders = []
for name in sorted(folder_names, key=lambda x: x.lower()):
folder_path = f"{current_path}/{name}" if current_path else name
folders.append(
{
"name": name,
"path": folder_path,
}
)
breadcrumbs = [
{
"label": device["device_name"],
"path": "",
}
]
if current_path:
accum = []
for segment in current_path.split("/"):
accum.append(segment)
breadcrumbs.append(
{
"label": segment,
"path": "/".join(accum),
}
)
parent_path = ""
if current_path:
parts = current_path.split("/")
parent_path = "/".join(parts[:-1])
return render_template(
"cloud/device_files.html",
user_email=session.get("otb_email"),
@ -891,4 +1292,317 @@ def browse_device_files(device_id: int):
device=device,
files=files,
file_count=len(files),
view_mode=view_mode,
current_path=current_path,
parent_path=parent_path,
folders=folders,
breadcrumbs=breadcrumbs,
)
@bp.route("/api/android/activate", methods=["POST"])
def android_activate():
db = get_db()
payload = request.get_json(silent=True) or {}
raw_token = (payload.get("token") or "").strip()
device_uuid = (payload.get("device_uuid") or "").strip()
phone_label = (payload.get("phone_label") or "").strip()
if not raw_token:
return jsonify({"ok": False, "error": "missing_token"}), 400
if not device_uuid:
return jsonify({"ok": False, "error": "missing_device_uuid"}), 400
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
with db.cursor() as cur:
cur.execute(
"""
SELECT t.id, t.tenant_id, t.device_id, t.status,
t.expires_at, d.device_name, d.relative_path
FROM android_device_tokens t
JOIN devices d ON d.id = t.device_id
WHERE t.token_hash = %s
LIMIT 1
""",
(token_hash,),
)
row = cur.fetchone()
if not row:
return jsonify({"ok": False, "error": "invalid_token"}), 404
if row["status"] == "activated":
return jsonify({"ok": True, "status": "already_activated"}), 200
cur.execute(
"""
UPDATE android_device_tokens
SET status='activated',
activated_at=UTC_TIMESTAMP(),
device_uuid=%s
WHERE id=%s
""",
(device_uuid, row["id"]),
)
db.commit()
return jsonify({
"ok": True,
"device_id": row["device_id"],
"device_name": row["device_name"],
"relative_path": row["relative_path"]
})
@bp.route("/api/android/upload", methods=["POST"])
def android_upload():
db = get_db()
device_uuid = (request.form.get("device_uuid") or "").strip()
if not device_uuid:
return jsonify({"ok": False, "error": "missing_device_uuid"}), 400
with db.cursor() as cur:
cur.execute(
"""
SELECT
t.tenant_id,
t.device_id,
t.status,
d.device_name,
d.relative_path,
tn.slug AS tenant_slug
FROM android_device_tokens t
JOIN devices d ON d.id = t.device_id
JOIN tenants tn ON tn.id = t.tenant_id
WHERE t.device_uuid = %s
LIMIT 1
""",
(device_uuid,),
)
row = cur.fetchone()
if not row:
return jsonify({"ok": False, "error": "device_not_found"}), 404
if row["status"] != "activated":
return jsonify({"ok": False, "error": "device_not_activated"}), 403
files = request.files.getlist("files")
files = [f for f in files if f and f.filename]
if not files:
return jsonify({"ok": False, "error": "no_files"}), 400
upload_base = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / row["tenant_slug"] / row["relative_path"] / "originals"
upload_base.mkdir(parents=True, exist_ok=True)
uploaded_count = 0
with db.cursor() as cur:
for incoming in files:
original_filename = incoming.filename or "upload.bin"
stored_name = _stored_name(original_filename)
target_path = upload_base / stored_name
incoming.save(target_path)
size_bytes = target_path.stat().st_size
sha256 = compute_sha256(target_path)
if "." in original_filename:
basename, extension = original_filename.rsplit(".", 1)
else:
basename, extension = original_filename, ""
relative_path = f"{row['relative_path']}/originals/{stored_name}"
directory_path = f"{row['relative_path']}/originals"
cur.execute(
"""
INSERT INTO files (
tenant_id, device_id, parent_file_id, file_kind, relative_path, directory_path,
original_filename, basename, extension, mime_type, size_bytes, sha256,
capture_date, uploaded_at, is_immutable, is_deleted, deleted_at
) VALUES (
%s, %s, NULL, 'original', %s, %s,
%s, %s, %s, %s, %s, %s,
NULL, UTC_TIMESTAMP(), 1, 0, NULL
)
""",
(
row["tenant_id"],
row["device_id"],
relative_path,
directory_path,
original_filename,
basename,
extension,
incoming.mimetype or None,
size_bytes,
sha256,
),
)
file_id = cur.lastrowid
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail
) VALUES (%s, NULL, 'device', 'file_uploaded', %s, %s, %s, %s)
""",
(
row["tenant_id"],
file_id,
request.headers.get("X-Forwarded-For", request.remote_addr),
request.headers.get("User-Agent", ""),
f"Android upload '{original_filename}' to {relative_path}",
),
)
uploaded_count += 1
db.commit()
return jsonify({
"ok": True,
"uploaded": uploaded_count,
"device_name": row["device_name"]
}), 200
@bp.route("/api/android/file-exists", methods=["GET"])
def android_file_exists():
db = get_db()
device_uuid = (request.args.get("device_uuid") or "").strip()
original_filename = (request.args.get("original_filename") or "").strip()
size_bytes_raw = (request.args.get("size_bytes") or "").strip()
if not device_uuid:
return jsonify({"ok": False, "error": "missing_device_uuid"}), 400
if not original_filename:
return jsonify({"ok": False, "error": "missing_original_filename"}), 400
try:
size_bytes = int(size_bytes_raw)
except Exception:
return jsonify({"ok": False, "error": "invalid_size_bytes"}), 400
with db.cursor() as cur:
cur.execute(
"""
SELECT
t.tenant_id,
t.device_id,
t.status
FROM android_device_tokens t
WHERE t.device_uuid = %s
LIMIT 1
""",
(device_uuid,),
)
token_row = cur.fetchone()
if not token_row:
return jsonify({"ok": False, "error": "device_not_found"}), 404
if token_row["status"] != "activated":
return jsonify({"ok": False, "error": "device_not_activated"}), 403
cur.execute(
"""
SELECT id
FROM files
WHERE tenant_id = %s
AND device_id = %s
AND is_deleted = 0
AND original_filename = %s
AND size_bytes = %s
LIMIT 1
""",
(
token_row["tenant_id"],
token_row["device_id"],
original_filename,
size_bytes,
),
)
file_row = cur.fetchone()
return jsonify({
"ok": True,
"exists": bool(file_row),
}), 200
@bp.route("/workspace/exports/<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,
directory_path VARCHAR(1000) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
display_filename VARCHAR(255) NULL,
basename VARCHAR(255) NOT NULL,
extension VARCHAR(50) NOT NULL,
mime_type VARCHAR(255) NULL,
@ -101,3 +102,67 @@ CREATE TABLE IF NOT EXISTS admin_access_tokens (
CONSTRAINT fk_admin_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS android_device_tokens (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
device_id INT NOT NULL,
token_hash CHAR(64) NOT NULL,
device_label VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'issued',
expires_at DATETIME NOT NULL,
activated_at DATETIME NULL,
device_uuid VARCHAR(100) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_android_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_android_token_device FOREIGN KEY (device_id) REFERENCES devices(id)
);
-- ===============================
-- v1.1.0-alpha1 VIDEO JOB SYSTEM
-- ===============================
CREATE TABLE IF NOT EXISTS video_jobs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
device_id INT NOT NULL,
source_file_id BIGINT NULL,
source_relative_path VARCHAR(1000) NOT NULL,
source_original_filename VARCHAR(255) NOT NULL,
requested_profile VARCHAR(50) NOT NULL,
requested_gpu_preference VARCHAR(20) NOT NULL DEFAULT 'auto',
assigned_processor VARCHAR(20) NULL,
status VARCHAR(50) NOT NULL DEFAULT 'queued',
progress_percent INT NOT NULL DEFAULT 0,
output_relative_path VARCHAR(1000) NULL,
output_file_id BIGINT NULL,
log_excerpt LONGTEXT NULL,
error_message LONGTEXT NULL,
gpu_seconds INT NOT NULL DEFAULT 0,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by_user_id INT NULL,
CONSTRAINT fk_video_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_video_jobs_device FOREIGN KEY (device_id) REFERENCES devices(id)
);
CREATE TABLE IF NOT EXISTS tenant_usage_metrics (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
storage_bytes_originals BIGINT NOT NULL DEFAULT 0,
storage_bytes_video BIGINT NOT NULL DEFAULT 0,
storage_bytes_archive BIGINT NOT NULL DEFAULT 0,
storage_bytes_lts BIGINT NOT NULL DEFAULT 0,
storage_bytes_total BIGINT NOT NULL DEFAULT 0,
gpu_seconds_intel BIGINT NOT NULL DEFAULT 0,
gpu_seconds_amd BIGINT NOT NULL DEFAULT 0,
gpu_seconds_cpu BIGINT NOT NULL DEFAULT 0,
completed_jobs INT NOT NULL DEFAULT 0,
failed_jobs INT NOT NULL DEFAULT 0,
accrued_storage_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00,
accrued_gpu_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00,
calculated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_metrics_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);

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 %}

10
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 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 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="https://otb-billing.outsidethebox.top/portal/services">Back to Services</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.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>
{% if device.device_type != 'android' %}
<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>
<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>
@ -108,6 +113,7 @@
<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>

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 %}

440
app/templates/cloud/device_files.html

@ -3,6 +3,227 @@
{% 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-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>
<h1 class="portal-page-title">Device Files</h1>
@ -10,16 +231,32 @@
<p class="portal-page-subtitle">
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}).
</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 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.zip_workspace') }}">Archive 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', 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>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>
</div>
@ -37,27 +274,116 @@
{% endif %}
{% 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 %}
<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>
<h2>{% if view_mode == 'gallery' %}Current Folder Gallery{% else %}Current Folder Files{% endif %}</h2>
<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>
<span class="service-badge service-badge-beta">DB-backed</span>
<span class="service-badge service-badge-beta">Scoped</span>
</div>
</div>
<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;">
<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.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>
@ -73,13 +399,41 @@
</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 }}">
<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;">
<strong>{{ file.original_filename }}</strong><br>
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span>
<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 }}
@ -100,26 +454,78 @@
{% endfor %}
</tbody>
</table>
</form>
{% 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>
<h2>No files in this folder</h2>
<p>This location does not currently contain any active files.</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 %}

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">
<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
@ -53,7 +54,19 @@
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>
<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;"
>
</div>
@ -66,9 +79,51 @@
<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 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>
</form>
</div>
</article>
</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 %}

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" %}
{% block title %}Zip Workspace - OTB Cloud{% endblock %}
{% block title %}Archive Workspace - OTB Cloud{% endblock %}
{% block portal_content %}
<div class="portal-page-header">
<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-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>
</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>
<a class="portal-btn" href="{{ url_for('main.lts_view') }}">View LTS</a>
</div>
</div>
</div>
@ -45,9 +46,32 @@
<div class="service-card-actions">
{% if staged_files %}
<form method="post" action="{{ url_for('main.create_zip_from_workspace') }}" style="margin-bottom:14px;">
<button class="portal-btn primary" type="submit">Zip It</button>
<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;">
<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>
<ul style="padding-left:18px; margin:0;">
{% for item in staged_files %}
<li style="margin-bottom:8px;">
@ -66,7 +90,7 @@
<div class="service-card-header">
<div>
<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>
<span class="service-badge service-badge-beta">{{ export_files|length }} exports</span>
@ -77,17 +101,39 @@
{% if export_files %}
<ul style="padding-left:18px; margin:0;">
{% for item in export_files %}
<li style="margin-bottom:10px;">
<li style="margin-bottom:14px;">
<strong>{{ item.name }}</strong><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>
{% endfor %}
</ul>
{% else %}
<p style="margin:0;">No export zip files yet.</p>
<p style="margin:0;">No export archive files yet.</p>
{% endif %}
</div>
</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>
{% 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()
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