71 changed files with 853 additions and 35556 deletions
@ -1,59 +0,0 @@
|
||||
from flask import Blueprint, current_app, redirect, render_template, request, session, url_for |
||||
|
||||
from app.db import get_db |
||||
from .utils import ensure_user_tenant_and_devices, is_valid_signature, is_valid_timestamp |
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/auth") |
||||
|
||||
@bp.route("/login-required") |
||||
def login_required_notice(): |
||||
return render_template("auth/login_required.html") |
||||
|
||||
@bp.route("/handoff") |
||||
def handoff(): |
||||
portal_user_id = request.args.get("uid", "").strip() |
||||
email = request.args.get("email", "").strip().lower() |
||||
ts = request.args.get("ts", "").strip() |
||||
sig = request.args.get("sig", "").strip() |
||||
|
||||
if not portal_user_id or not email or not ts or not sig: |
||||
return render_template("auth/handoff_error.html", message="Missing handoff parameters."), 400 |
||||
|
||||
if not is_valid_timestamp(ts): |
||||
return render_template("auth/handoff_error.html", message="Handoff timestamp is invalid or expired."), 403 |
||||
|
||||
if not is_valid_signature(email=email, ts=ts, portal_user_id=portal_user_id, sig=sig): |
||||
return render_template("auth/handoff_error.html", message="Invalid handoff signature."), 403 |
||||
|
||||
identity = ensure_user_tenant_and_devices(email=email, portal_user_id=int(portal_user_id)) |
||||
|
||||
session.clear() |
||||
session["otb_user_id"] = identity["user_id"] |
||||
session["otb_tenant_id"] = identity["tenant_id"] |
||||
session["otb_tenant_slug"] = identity["tenant_slug"] |
||||
session["otb_email"] = identity["email"] |
||||
|
||||
db = get_db() |
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'user', 'handoff_login_success', %s, %s, %s) |
||||
""", |
||||
( |
||||
identity["tenant_id"], |
||||
identity["user_id"], |
||||
request.headers.get("X-Forwarded-For", request.remote_addr), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Portal handoff accepted for {email}", |
||||
), |
||||
) |
||||
db.commit() |
||||
|
||||
return redirect(url_for("main.dashboard")) |
||||
|
||||
@bp.route("/logout") |
||||
def logout(): |
||||
session.clear() |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
@ -1,135 +0,0 @@
|
||||
import hashlib |
||||
import hmac |
||||
import re |
||||
import time |
||||
from pathlib import Path |
||||
|
||||
from flask import current_app |
||||
|
||||
from app.db import get_db |
||||
|
||||
SLUG_RE = re.compile(r"[^a-z0-9]+") |
||||
|
||||
def make_signature(email: str, ts: str, portal_user_id: str, secret: str) -> str: |
||||
payload = f"{portal_user_id}|{email}|{ts}".encode("utf-8") |
||||
return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() |
||||
|
||||
def is_valid_signature(email: str, ts: str, portal_user_id: str, sig: str) -> bool: |
||||
secret = current_app.config["OTB_PORTAL_SHARED_SECRET"] |
||||
expected = make_signature(email=email, ts=ts, portal_user_id=portal_user_id, secret=secret) |
||||
return hmac.compare_digest(expected, sig) |
||||
|
||||
def is_valid_timestamp(ts: str) -> bool: |
||||
try: |
||||
ts_int = int(ts) |
||||
except ValueError: |
||||
return False |
||||
skew = current_app.config["OTB_PORTAL_ALLOWED_SKEW_SECONDS"] |
||||
return abs(int(time.time()) - ts_int) <= skew |
||||
|
||||
def slugify_email(email: str) -> str: |
||||
local = email.split("@", 1)[0].lower().strip() |
||||
slug = SLUG_RE.sub("-", local).strip("-") |
||||
return slug or "tenant" |
||||
|
||||
def ensure_user_tenant_and_devices(email: str, portal_user_id: int): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, portal_user_id, email, display_name |
||||
FROM users |
||||
WHERE email = %s |
||||
""", |
||||
(email,), |
||||
) |
||||
user = cur.fetchone() |
||||
|
||||
if user is None: |
||||
cur.execute( |
||||
""" |
||||
INSERT INTO users (portal_user_id, email, display_name, is_active) |
||||
VALUES (%s, %s, %s, 1) |
||||
""", |
||||
(portal_user_id, email, email), |
||||
) |
||||
user_id = cur.lastrowid |
||||
else: |
||||
user_id = user["id"] |
||||
cur.execute( |
||||
""" |
||||
UPDATE users |
||||
SET portal_user_id = %s, last_login_at = NOW() |
||||
WHERE id = %s |
||||
""", |
||||
(portal_user_id, user_id), |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
SELECT id, slug, storage_root |
||||
FROM tenants |
||||
WHERE owner_user_id = %s |
||||
""", |
||||
(user_id,), |
||||
) |
||||
tenant = cur.fetchone() |
||||
|
||||
if tenant is None: |
||||
base_slug = slugify_email(email) |
||||
slug = base_slug |
||||
suffix = 1 |
||||
|
||||
while True: |
||||
cur.execute("SELECT id FROM tenants WHERE slug = %s", (slug,)) |
||||
existing = cur.fetchone() |
||||
if existing is None: |
||||
break |
||||
suffix += 1 |
||||
slug = f"{base_slug}-{suffix}" |
||||
|
||||
storage_root = f"{current_app.config['STORAGE_ROOT']}/tenants/{slug}" |
||||
cur.execute( |
||||
""" |
||||
INSERT INTO tenants (owner_user_id, slug, storage_root, service_status, retention_mode) |
||||
VALUES (%s, %s, %s, 'active', 'standard') |
||||
""", |
||||
(user_id, slug, storage_root), |
||||
) |
||||
tenant_id = cur.lastrowid |
||||
else: |
||||
tenant_id = tenant["id"] |
||||
slug = tenant["slug"] |
||||
storage_root = tenant["storage_root"] |
||||
|
||||
for device_name in current_app.config["DEFAULT_DEVICE_NAMES"]: |
||||
relative_path = f"devices/{device_name}" |
||||
cur.execute( |
||||
""" |
||||
INSERT IGNORE INTO devices (tenant_id, device_name, device_type, relative_path, is_active) |
||||
VALUES (%s, %s, %s, %s, 1) |
||||
""", |
||||
(tenant_id, device_name, device_name, relative_path), |
||||
) |
||||
|
||||
db.commit() |
||||
|
||||
create_tenant_directories(slug, current_app.config["DEFAULT_DEVICE_NAMES"]) |
||||
|
||||
return { |
||||
"user_id": user_id, |
||||
"tenant_id": tenant_id, |
||||
"tenant_slug": slug, |
||||
"storage_root": storage_root, |
||||
"email": email, |
||||
} |
||||
|
||||
def create_tenant_directories(tenant_slug: str, device_names: list[str]): |
||||
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug |
||||
(tenant_root / "logs").mkdir(parents=True, exist_ok=True) |
||||
(tenant_root / "support").mkdir(parents=True, exist_ok=True) |
||||
|
||||
for device_name in device_names: |
||||
for subdir in ["originals", "derived", "exports", "deleted", "tmp"]: |
||||
(tenant_root / "devices" / device_name / subdir).mkdir(parents=True, exist_ok=True) |
||||
@ -1,130 +0,0 @@
|
||||
import hashlib |
||||
import hmac |
||||
import re |
||||
import time |
||||
from pathlib import Path |
||||
|
||||
from flask import current_app |
||||
|
||||
from app.db import get_db |
||||
|
||||
SLUG_RE = re.compile(r"[^a-z0-9]+") |
||||
|
||||
def make_signature(email: str, ts: str, portal_user_id: str, secret: str) -> str: |
||||
payload = f"{portal_user_id}|{email}|{ts}".encode("utf-8") |
||||
return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() |
||||
|
||||
def is_valid_signature(email: str, ts: str, portal_user_id: str, sig: str) -> bool: |
||||
secret = current_app.config["OTB_PORTAL_SHARED_SECRET"] |
||||
expected = make_signature(email=email, ts=ts, portal_user_id=portal_user_id, secret=secret) |
||||
return hmac.compare_digest(expected, sig) |
||||
|
||||
def is_valid_timestamp(ts: str) -> bool: |
||||
try: |
||||
ts_int = int(ts) |
||||
except ValueError: |
||||
return False |
||||
skew = current_app.config["OTB_PORTAL_ALLOWED_SKEW_SECONDS"] |
||||
return abs(int(time.time()) - ts_int) <= skew |
||||
|
||||
def slugify_email(email: str) -> str: |
||||
local = email.split("@", 1)[0].lower().strip() |
||||
slug = SLUG_RE.sub("-", local).strip("-") |
||||
return slug or "tenant" |
||||
|
||||
def slugify_device_name(name: str) -> str: |
||||
return SLUG_RE.sub("-", name.lower().strip()).strip("-") or "device" |
||||
|
||||
def ensure_user_tenant_and_devices(email: str, portal_user_id: int): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, portal_user_id, email, display_name |
||||
FROM users |
||||
WHERE email = %s |
||||
""", |
||||
(email,), |
||||
) |
||||
user = cur.fetchone() |
||||
|
||||
if user is None: |
||||
cur.execute( |
||||
""" |
||||
INSERT INTO users (portal_user_id, email, display_name, is_active) |
||||
VALUES (%s, %s, %s, 1) |
||||
""", |
||||
(portal_user_id, email, email), |
||||
) |
||||
user_id = cur.lastrowid |
||||
else: |
||||
user_id = user["id"] |
||||
cur.execute( |
||||
""" |
||||
UPDATE users |
||||
SET portal_user_id = %s, last_login_at = NOW() |
||||
WHERE id = %s |
||||
""", |
||||
(portal_user_id, user_id), |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
SELECT id, slug, storage_root |
||||
FROM tenants |
||||
WHERE owner_user_id = %s |
||||
""", |
||||
(user_id,), |
||||
) |
||||
tenant = cur.fetchone() |
||||
|
||||
if tenant is None: |
||||
base_slug = slugify_email(email) |
||||
slug = base_slug |
||||
suffix = 1 |
||||
|
||||
while True: |
||||
cur.execute("SELECT id FROM tenants WHERE slug = %s", (slug,)) |
||||
existing = cur.fetchone() |
||||
if existing is None: |
||||
break |
||||
suffix += 1 |
||||
slug = f"{base_slug}-{suffix}" |
||||
|
||||
storage_root = f"{current_app.config['STORAGE_ROOT']}/tenants/{slug}" |
||||
cur.execute( |
||||
""" |
||||
INSERT INTO tenants (owner_user_id, slug, storage_root, service_status, retention_mode) |
||||
VALUES (%s, %s, %s, 'active', 'standard') |
||||
""", |
||||
(user_id, slug, storage_root), |
||||
) |
||||
tenant_id = cur.lastrowid |
||||
create_tenant_root(slug) |
||||
else: |
||||
tenant_id = tenant["id"] |
||||
slug = tenant["slug"] |
||||
storage_root = tenant["storage_root"] |
||||
|
||||
db.commit() |
||||
|
||||
return { |
||||
"user_id": user_id, |
||||
"tenant_id": tenant_id, |
||||
"tenant_slug": slug, |
||||
"storage_root": storage_root, |
||||
"email": email, |
||||
} |
||||
|
||||
def create_tenant_root(tenant_slug: str): |
||||
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug |
||||
(tenant_root / "logs").mkdir(parents=True, exist_ok=True) |
||||
(tenant_root / "support").mkdir(parents=True, exist_ok=True) |
||||
(tenant_root / "devices").mkdir(parents=True, exist_ok=True) |
||||
|
||||
def create_device_directories(tenant_slug: str, device_path: str): |
||||
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug |
||||
base = tenant_root / device_path |
||||
for subdir in ["originals", "derived", "exports", "deleted", "tmp"]: |
||||
(base / subdir).mkdir(parents=True, exist_ok=True) |
||||
@ -1,137 +0,0 @@
|
||||
import hashlib |
||||
import hmac |
||||
import re |
||||
import shutil |
||||
import time |
||||
from pathlib import Path |
||||
|
||||
from flask import current_app |
||||
|
||||
from app.db import get_db |
||||
|
||||
SLUG_RE = re.compile(r"[^a-z0-9]+") |
||||
|
||||
def make_signature(email: str, ts: str, portal_user_id: str, secret: str) -> str: |
||||
payload = f"{portal_user_id}|{email}|{ts}".encode("utf-8") |
||||
return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() |
||||
|
||||
def is_valid_signature(email: str, ts: str, portal_user_id: str, sig: str) -> bool: |
||||
secret = current_app.config["OTB_PORTAL_SHARED_SECRET"] |
||||
expected = make_signature(email=email, ts=ts, portal_user_id=portal_user_id, secret=secret) |
||||
return hmac.compare_digest(expected, sig) |
||||
|
||||
def is_valid_timestamp(ts: str) -> bool: |
||||
try: |
||||
ts_int = int(ts) |
||||
except ValueError: |
||||
return False |
||||
skew = current_app.config["OTB_PORTAL_ALLOWED_SKEW_SECONDS"] |
||||
return abs(int(time.time()) - ts_int) <= skew |
||||
|
||||
def slugify_email(email: str) -> str: |
||||
local = email.split("@", 1)[0].lower().strip() |
||||
slug = SLUG_RE.sub("-", local).strip("-") |
||||
return slug or "tenant" |
||||
|
||||
def slugify_device_name(name: str) -> str: |
||||
return SLUG_RE.sub("-", name.lower().strip()).strip("-") or "device" |
||||
|
||||
def ensure_user_tenant_and_devices(email: str, portal_user_id: int): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, portal_user_id, email, display_name |
||||
FROM users |
||||
WHERE email = %s |
||||
""", |
||||
(email,), |
||||
) |
||||
user = cur.fetchone() |
||||
|
||||
if user is None: |
||||
cur.execute( |
||||
""" |
||||
INSERT INTO users (portal_user_id, email, display_name, is_active) |
||||
VALUES (%s, %s, %s, 1) |
||||
""", |
||||
(portal_user_id, email, email), |
||||
) |
||||
user_id = cur.lastrowid |
||||
else: |
||||
user_id = user["id"] |
||||
cur.execute( |
||||
""" |
||||
UPDATE users |
||||
SET portal_user_id = %s, last_login_at = NOW() |
||||
WHERE id = %s |
||||
""", |
||||
(portal_user_id, user_id), |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
SELECT id, slug, storage_root |
||||
FROM tenants |
||||
WHERE owner_user_id = %s |
||||
""", |
||||
(user_id,), |
||||
) |
||||
tenant = cur.fetchone() |
||||
|
||||
if tenant is None: |
||||
base_slug = slugify_email(email) |
||||
slug = base_slug |
||||
suffix = 1 |
||||
|
||||
while True: |
||||
cur.execute("SELECT id FROM tenants WHERE slug = %s", (slug,)) |
||||
existing = cur.fetchone() |
||||
if existing is None: |
||||
break |
||||
suffix += 1 |
||||
slug = f"{base_slug}-{suffix}" |
||||
|
||||
storage_root = f"{current_app.config['STORAGE_ROOT']}/tenants/{slug}" |
||||
cur.execute( |
||||
""" |
||||
INSERT INTO tenants (owner_user_id, slug, storage_root, service_status, retention_mode) |
||||
VALUES (%s, %s, %s, 'active', 'standard') |
||||
""", |
||||
(user_id, slug, storage_root), |
||||
) |
||||
tenant_id = cur.lastrowid |
||||
create_tenant_root(slug) |
||||
else: |
||||
tenant_id = tenant["id"] |
||||
slug = tenant["slug"] |
||||
storage_root = tenant["storage_root"] |
||||
|
||||
db.commit() |
||||
|
||||
return { |
||||
"user_id": user_id, |
||||
"tenant_id": tenant_id, |
||||
"tenant_slug": slug, |
||||
"storage_root": storage_root, |
||||
"email": email, |
||||
} |
||||
|
||||
def create_tenant_root(tenant_slug: str): |
||||
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug |
||||
(tenant_root / "logs").mkdir(parents=True, exist_ok=True) |
||||
(tenant_root / "support").mkdir(parents=True, exist_ok=True) |
||||
(tenant_root / "devices").mkdir(parents=True, exist_ok=True) |
||||
|
||||
def create_device_directories(tenant_slug: str, device_path: str): |
||||
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug |
||||
base = tenant_root / device_path |
||||
for subdir in ["originals", "derived", "exports", "deleted", "tmp"]: |
||||
(base / subdir).mkdir(parents=True, exist_ok=True) |
||||
|
||||
def remove_device_directories(tenant_slug: str, device_path: str): |
||||
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug |
||||
base = tenant_root / device_path |
||||
if base.exists() and base.is_dir(): |
||||
shutil.rmtree(base) |
||||
@ -1,144 +0,0 @@
|
||||
import hashlib |
||||
import hmac |
||||
import re |
||||
import shutil |
||||
import time |
||||
from pathlib import Path |
||||
|
||||
from flask import current_app |
||||
|
||||
from app.db import get_db |
||||
|
||||
SLUG_RE = re.compile(r"[^a-z0-9]+") |
||||
|
||||
def make_signature(email: str, ts: str, portal_user_id: str, secret: str) -> str: |
||||
payload = f"{portal_user_id}|{email}|{ts}".encode("utf-8") |
||||
return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() |
||||
|
||||
def is_valid_signature(email: str, ts: str, portal_user_id: str, sig: str) -> bool: |
||||
secret = current_app.config["OTB_PORTAL_SHARED_SECRET"] |
||||
expected = make_signature(email=email, ts=ts, portal_user_id=portal_user_id, secret=secret) |
||||
return hmac.compare_digest(expected, sig) |
||||
|
||||
def is_valid_timestamp(ts: str) -> bool: |
||||
try: |
||||
ts_int = int(ts) |
||||
except ValueError: |
||||
return False |
||||
skew = current_app.config["OTB_PORTAL_ALLOWED_SKEW_SECONDS"] |
||||
return abs(int(time.time()) - ts_int) <= skew |
||||
|
||||
def slugify_email(email: str) -> str: |
||||
local = email.split("@", 1)[0].lower().strip() |
||||
slug = SLUG_RE.sub("-", local).strip("-") |
||||
return slug or "tenant" |
||||
|
||||
def slugify_device_name(name: str) -> str: |
||||
return SLUG_RE.sub("-", name.lower().strip()).strip("-") or "device" |
||||
|
||||
def ensure_user_tenant_and_devices(email: str, portal_user_id: int): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, portal_user_id, email, display_name |
||||
FROM users |
||||
WHERE email = %s |
||||
""", |
||||
(email,), |
||||
) |
||||
user = cur.fetchone() |
||||
|
||||
if user is None: |
||||
cur.execute( |
||||
""" |
||||
INSERT INTO users (portal_user_id, email, display_name, is_active) |
||||
VALUES (%s, %s, %s, 1) |
||||
""", |
||||
(portal_user_id, email, email), |
||||
) |
||||
user_id = cur.lastrowid |
||||
else: |
||||
user_id = user["id"] |
||||
cur.execute( |
||||
""" |
||||
UPDATE users |
||||
SET portal_user_id = %s, last_login_at = NOW() |
||||
WHERE id = %s |
||||
""", |
||||
(portal_user_id, user_id), |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
SELECT id, slug, storage_root |
||||
FROM tenants |
||||
WHERE owner_user_id = %s |
||||
""", |
||||
(user_id,), |
||||
) |
||||
tenant = cur.fetchone() |
||||
|
||||
if tenant is None: |
||||
base_slug = slugify_email(email) |
||||
slug = base_slug |
||||
suffix = 1 |
||||
|
||||
while True: |
||||
cur.execute("SELECT id FROM tenants WHERE slug = %s", (slug,)) |
||||
existing = cur.fetchone() |
||||
if existing is None: |
||||
break |
||||
suffix += 1 |
||||
slug = f"{base_slug}-{suffix}" |
||||
|
||||
storage_root = f"{current_app.config['STORAGE_ROOT']}/tenants/{slug}" |
||||
cur.execute( |
||||
""" |
||||
INSERT INTO tenants (owner_user_id, slug, storage_root, service_status, retention_mode) |
||||
VALUES (%s, %s, %s, 'active', 'standard') |
||||
""", |
||||
(user_id, slug, storage_root), |
||||
) |
||||
tenant_id = cur.lastrowid |
||||
create_tenant_root(slug) |
||||
else: |
||||
tenant_id = tenant["id"] |
||||
slug = tenant["slug"] |
||||
storage_root = tenant["storage_root"] |
||||
|
||||
db.commit() |
||||
|
||||
return { |
||||
"user_id": user_id, |
||||
"tenant_id": tenant_id, |
||||
"tenant_slug": slug, |
||||
"storage_root": storage_root, |
||||
"email": email, |
||||
} |
||||
|
||||
def create_tenant_root(tenant_slug: str): |
||||
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug |
||||
(tenant_root / "logs").mkdir(parents=True, exist_ok=True) |
||||
(tenant_root / "support").mkdir(parents=True, exist_ok=True) |
||||
(tenant_root / "devices").mkdir(parents=True, exist_ok=True) |
||||
|
||||
def create_device_directories(tenant_slug: str, device_path: str): |
||||
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug |
||||
base = tenant_root / device_path |
||||
for subdir in ["originals", "derived", "exports", "deleted", "tmp"]: |
||||
(base / subdir).mkdir(parents=True, exist_ok=True) |
||||
|
||||
def remove_device_directories(tenant_slug: str, device_path: str): |
||||
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug |
||||
base = tenant_root / device_path |
||||
if base.exists() and base.is_dir(): |
||||
shutil.rmtree(base) |
||||
|
||||
def compute_sha256(path: Path) -> str: |
||||
h = hashlib.sha256() |
||||
with path.open("rb") as f: |
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""): |
||||
h.update(chunk) |
||||
return h.hexdigest() |
||||
@ -1,45 +0,0 @@
|
||||
from functools import wraps |
||||
|
||||
from flask import Blueprint, redirect, render_template, session, url_for |
||||
|
||||
from app.db import get_db |
||||
|
||||
bp = Blueprint("main", __name__) |
||||
|
||||
def portal_session_required(view_func): |
||||
@wraps(view_func) |
||||
def wrapped(*args, **kwargs): |
||||
if "otb_user_id" not in session or "otb_tenant_id" not in session: |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
return view_func(*args, **kwargs) |
||||
return wrapped |
||||
|
||||
@bp.route("/") |
||||
def index(): |
||||
if "otb_user_id" in session: |
||||
return redirect(url_for("main.dashboard")) |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
|
||||
@bp.route("/dashboard") |
||||
@portal_session_required |
||||
def dashboard(): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path, is_active, created_at |
||||
FROM devices |
||||
WHERE tenant_id = %s |
||||
ORDER BY id |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
devices = cur.fetchall() |
||||
|
||||
return render_template( |
||||
"cloud/dashboard.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
devices=devices, |
||||
) |
||||
@ -1,135 +0,0 @@
|
||||
from functools import wraps |
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for |
||||
|
||||
from app.db import get_db |
||||
from app.auth.utils import create_device_directories, slugify_device_name |
||||
|
||||
bp = Blueprint("main", __name__) |
||||
|
||||
def portal_session_required(view_func): |
||||
@wraps(view_func) |
||||
def wrapped(*args, **kwargs): |
||||
if "otb_user_id" not in session or "otb_tenant_id" not in session: |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
return view_func(*args, **kwargs) |
||||
return wrapped |
||||
|
||||
@bp.route("/") |
||||
def index(): |
||||
if "otb_user_id" in session: |
||||
return redirect(url_for("main.dashboard")) |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
|
||||
@bp.route("/dashboard") |
||||
@portal_session_required |
||||
def dashboard(): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path, is_active, created_at |
||||
FROM devices |
||||
WHERE tenant_id = %s |
||||
ORDER BY id |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
devices = cur.fetchall() |
||||
|
||||
return render_template( |
||||
"cloud/dashboard.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
devices=devices, |
||||
) |
||||
|
||||
@bp.route("/devices/new", methods=["GET", "POST"]) |
||||
@portal_session_required |
||||
def add_device(): |
||||
if request.method == "GET": |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
) |
||||
|
||||
device_name = (request.form.get("device_name") or "").strip() |
||||
device_type = (request.form.get("device_type") or "").strip() |
||||
|
||||
if not device_name: |
||||
flash("Device name is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
if not device_type: |
||||
flash("Device type is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
slug = slugify_device_name(device_name) |
||||
relative_path = f"devices/{slug}" |
||||
|
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id |
||||
FROM devices |
||||
WHERE tenant_id = %s AND device_name = %s |
||||
""", |
||||
(session["otb_tenant_id"], device_name), |
||||
) |
||||
existing = cur.fetchone() |
||||
|
||||
if existing: |
||||
flash("A device with that name already exists.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active) |
||||
VALUES (%s, %s, %s, %s, 1) |
||||
""", |
||||
(session["otb_tenant_id"], device_name, device_type, relative_path), |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
request.headers.get("X-Forwarded-For", request.remote_addr), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Created device '{device_name}' ({device_type}) at {relative_path}", |
||||
), |
||||
) |
||||
|
||||
db.commit() |
||||
|
||||
create_device_directories(session["otb_tenant_slug"], relative_path) |
||||
|
||||
flash("Device added successfully.", "success") |
||||
return redirect(url_for("main.dashboard")) |
||||
@ -1,199 +0,0 @@
|
||||
from functools import wraps |
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for |
||||
|
||||
from app.db import get_db |
||||
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name |
||||
|
||||
bp = Blueprint("main", __name__) |
||||
|
||||
def portal_session_required(view_func): |
||||
@wraps(view_func) |
||||
def wrapped(*args, **kwargs): |
||||
if "otb_user_id" not in session or "otb_tenant_id" not in session: |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
return view_func(*args, **kwargs) |
||||
return wrapped |
||||
|
||||
@bp.route("/") |
||||
def index(): |
||||
if "otb_user_id" in session: |
||||
return redirect(url_for("main.dashboard")) |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
|
||||
@bp.route("/dashboard") |
||||
@portal_session_required |
||||
def dashboard(): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path, is_active, created_at |
||||
FROM devices |
||||
WHERE tenant_id = %s |
||||
ORDER BY id |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
devices = cur.fetchall() |
||||
|
||||
return render_template( |
||||
"cloud/dashboard.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
devices=devices, |
||||
) |
||||
|
||||
@bp.route("/devices/new", methods=["GET", "POST"]) |
||||
@portal_session_required |
||||
def add_device(): |
||||
if request.method == "GET": |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
) |
||||
|
||||
device_name = (request.form.get("device_name") or "").strip() |
||||
device_type = (request.form.get("device_type") or "").strip() |
||||
|
||||
if not device_name: |
||||
flash("Device name is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
if not device_type: |
||||
flash("Device type is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
slug = slugify_device_name(device_name) |
||||
relative_path = f"devices/{slug}" |
||||
|
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id |
||||
FROM devices |
||||
WHERE tenant_id = %s AND device_name = %s |
||||
""", |
||||
(session["otb_tenant_id"], device_name), |
||||
) |
||||
existing = cur.fetchone() |
||||
|
||||
if existing: |
||||
flash("A device with that name already exists.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active) |
||||
VALUES (%s, %s, %s, %s, 1) |
||||
""", |
||||
(session["otb_tenant_id"], device_name, device_type, relative_path), |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
request.headers.get("X-Forwarded-For", request.remote_addr), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Created device '{device_name}' ({device_type}) at {relative_path}", |
||||
), |
||||
) |
||||
|
||||
db.commit() |
||||
|
||||
create_device_directories(session["otb_tenant_slug"], relative_path) |
||||
|
||||
flash("Device added successfully.", "success") |
||||
return redirect(url_for("main.dashboard")) |
||||
|
||||
@bp.route("/devices/delete/<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")) |
||||
@ -1,331 +0,0 @@
|
||||
from functools import wraps |
||||
from pathlib import Path |
||||
from datetime import datetime, timezone |
||||
from werkzeug.utils import secure_filename |
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app |
||||
|
||||
from app.db import get_db |
||||
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256 |
||||
|
||||
bp = Blueprint("main", __name__) |
||||
|
||||
def portal_session_required(view_func): |
||||
@wraps(view_func) |
||||
def wrapped(*args, **kwargs): |
||||
if "otb_user_id" not in session or "otb_tenant_id" not in session: |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
return view_func(*args, **kwargs) |
||||
return wrapped |
||||
|
||||
def _client_ip(): |
||||
return request.headers.get("X-Forwarded-For", request.remote_addr) |
||||
|
||||
def _tenant_root() -> Path: |
||||
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"] |
||||
|
||||
def _stored_name(original_name: str) -> str: |
||||
safe = secure_filename(original_name or "upload.bin") |
||||
if not safe: |
||||
safe = "upload.bin" |
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") |
||||
return f"{ts}__{safe}" |
||||
|
||||
@bp.route("/") |
||||
def index(): |
||||
if "otb_user_id" in session: |
||||
return redirect(url_for("main.dashboard")) |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
|
||||
@bp.route("/dashboard") |
||||
@portal_session_required |
||||
def dashboard(): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path, is_active, created_at |
||||
FROM devices |
||||
WHERE tenant_id = %s |
||||
ORDER BY id |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
devices = cur.fetchall() |
||||
|
||||
return render_template( |
||||
"cloud/dashboard.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
devices=devices, |
||||
) |
||||
|
||||
@bp.route("/devices/new", methods=["GET", "POST"]) |
||||
@portal_session_required |
||||
def add_device(): |
||||
if request.method == "GET": |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
) |
||||
|
||||
device_name = (request.form.get("device_name") or "").strip() |
||||
device_type = (request.form.get("device_type") or "").strip() |
||||
|
||||
if not device_name: |
||||
flash("Device name is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
if not device_type: |
||||
flash("Device type is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
slug = slugify_device_name(device_name) |
||||
relative_path = f"devices/{slug}" |
||||
|
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id |
||||
FROM devices |
||||
WHERE tenant_id = %s AND device_name = %s |
||||
""", |
||||
(session["otb_tenant_id"], device_name), |
||||
) |
||||
existing = cur.fetchone() |
||||
|
||||
if existing: |
||||
flash("A device with that name already exists.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active) |
||||
VALUES (%s, %s, %s, %s, 1) |
||||
""", |
||||
(session["otb_tenant_id"], device_name, device_type, relative_path), |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
_client_ip(), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Created device '{device_name}' ({device_type}) at {relative_path}", |
||||
), |
||||
) |
||||
|
||||
db.commit() |
||||
|
||||
create_device_directories(session["otb_tenant_slug"], relative_path) |
||||
|
||||
flash("Device added successfully.", "success") |
||||
return redirect(url_for("main.dashboard")) |
||||
|
||||
@bp.route("/devices/delete/<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")) |
||||
@ -1,385 +0,0 @@
|
||||
from functools import wraps |
||||
from pathlib import Path |
||||
from datetime import datetime, timezone |
||||
from werkzeug.utils import secure_filename |
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app |
||||
|
||||
from app.db import get_db |
||||
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256 |
||||
|
||||
bp = Blueprint("main", __name__) |
||||
|
||||
def portal_session_required(view_func): |
||||
@wraps(view_func) |
||||
def wrapped(*args, **kwargs): |
||||
if "otb_user_id" not in session or "otb_tenant_id" not in session: |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
return view_func(*args, **kwargs) |
||||
return wrapped |
||||
|
||||
def _client_ip(): |
||||
return request.headers.get("X-Forwarded-For", request.remote_addr) |
||||
|
||||
def _tenant_root() -> Path: |
||||
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"] |
||||
|
||||
def _stored_name(original_name: str) -> str: |
||||
safe = secure_filename(original_name or "upload.bin") |
||||
if not safe: |
||||
safe = "upload.bin" |
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") |
||||
return f"{ts}__{safe}" |
||||
|
||||
@bp.route("/") |
||||
def index(): |
||||
if "otb_user_id" in session: |
||||
return redirect(url_for("main.dashboard")) |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
|
||||
@bp.route("/dashboard") |
||||
@portal_session_required |
||||
def dashboard(): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path, is_active, created_at |
||||
FROM devices |
||||
WHERE tenant_id = %s |
||||
ORDER BY id |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
devices = cur.fetchall() |
||||
|
||||
return render_template( |
||||
"cloud/dashboard.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
devices=devices, |
||||
) |
||||
|
||||
@bp.route("/devices/new", methods=["GET", "POST"]) |
||||
@portal_session_required |
||||
def add_device(): |
||||
if request.method == "GET": |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
) |
||||
|
||||
device_name = (request.form.get("device_name") or "").strip() |
||||
device_type = (request.form.get("device_type") or "").strip() |
||||
|
||||
if not device_name: |
||||
flash("Device name is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
if not device_type: |
||||
flash("Device type is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
slug = slugify_device_name(device_name) |
||||
relative_path = f"devices/{slug}" |
||||
|
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id |
||||
FROM devices |
||||
WHERE tenant_id = %s AND device_name = %s |
||||
""", |
||||
(session["otb_tenant_id"], device_name), |
||||
) |
||||
existing = cur.fetchone() |
||||
|
||||
if existing: |
||||
flash("A device with that name already exists.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active) |
||||
VALUES (%s, %s, %s, %s, 1) |
||||
""", |
||||
(session["otb_tenant_id"], device_name, device_type, relative_path), |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
_client_ip(), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Created device '{device_name}' ({device_type}) at {relative_path}", |
||||
), |
||||
) |
||||
|
||||
db.commit() |
||||
|
||||
create_device_directories(session["otb_tenant_slug"], relative_path) |
||||
|
||||
flash("Device added successfully.", "success") |
||||
return redirect(url_for("main.dashboard")) |
||||
|
||||
@bp.route("/devices/delete/<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), |
||||
) |
||||
@ -1,788 +0,0 @@
|
||||
from functools import wraps |
||||
from pathlib import Path |
||||
from datetime import datetime, timezone |
||||
import shutil |
||||
import zipfile |
||||
|
||||
from werkzeug.utils import secure_filename |
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file |
||||
|
||||
from app.db import get_db |
||||
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256 |
||||
|
||||
bp = Blueprint("main", __name__) |
||||
|
||||
def portal_session_required(view_func): |
||||
@wraps(view_func) |
||||
def wrapped(*args, **kwargs): |
||||
if "otb_user_id" not in session or "otb_tenant_id" not in session: |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
return view_func(*args, **kwargs) |
||||
return wrapped |
||||
|
||||
def _client_ip(): |
||||
return request.headers.get("X-Forwarded-For", request.remote_addr) |
||||
|
||||
def _tenant_root() -> Path: |
||||
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"] |
||||
|
||||
def _stored_name(original_name: str) -> str: |
||||
safe = secure_filename(original_name or "upload.bin") |
||||
if not safe: |
||||
safe = "upload.bin" |
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") |
||||
return f"{ts}__{safe}" |
||||
|
||||
def _safe_path_from_relative(relative_path: str) -> Path: |
||||
return _tenant_root() / relative_path |
||||
|
||||
def _get_device_for_tenant(db, device_id: int): |
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path |
||||
FROM devices |
||||
WHERE id = %s AND tenant_id = %s |
||||
""", |
||||
(device_id, session["otb_tenant_id"]), |
||||
) |
||||
return cur.fetchone() |
||||
|
||||
def _purge_expired_deleted_files(db): |
||||
expired = [] |
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, relative_path, original_filename |
||||
FROM files |
||||
WHERE tenant_id = %s |
||||
AND is_deleted = 1 |
||||
AND deleted_at IS NOT NULL |
||||
AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
expired = cur.fetchall() |
||||
|
||||
for row in expired: |
||||
file_path = _safe_path_from_relative(row["relative_path"]) |
||||
if file_path.exists(): |
||||
try: |
||||
file_path.unlink() |
||||
except FileNotFoundError: |
||||
pass |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'system', 'deleted_file_purged', %s, %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
row["id"], |
||||
_client_ip(), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Purged deleted file '{row['original_filename']}' after retention window", |
||||
), |
||||
) |
||||
|
||||
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) |
||||
|
||||
db.commit() |
||||
|
||||
@bp.route("/") |
||||
def index(): |
||||
if "otb_user_id" in session: |
||||
return redirect(url_for("main.dashboard")) |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
|
||||
@bp.route("/dashboard") |
||||
@portal_session_required |
||||
def dashboard(): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path, is_active, created_at |
||||
FROM devices |
||||
WHERE tenant_id = %s |
||||
ORDER BY id |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
devices = cur.fetchall() |
||||
|
||||
return render_template( |
||||
"cloud/dashboard.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
devices=devices, |
||||
) |
||||
|
||||
@bp.route("/devices/new", methods=["GET", "POST"]) |
||||
@portal_session_required |
||||
def add_device(): |
||||
if request.method == "GET": |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
) |
||||
|
||||
device_name = (request.form.get("device_name") or "").strip() |
||||
device_type = (request.form.get("device_type") or "").strip() |
||||
|
||||
if not device_name: |
||||
flash("Device name is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
if not device_type: |
||||
flash("Device type is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
slug = slugify_device_name(device_name) |
||||
relative_path = f"devices/{slug}" |
||||
|
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id |
||||
FROM devices |
||||
WHERE tenant_id = %s AND device_name = %s |
||||
""", |
||||
(session["otb_tenant_id"], device_name), |
||||
) |
||||
existing = cur.fetchone() |
||||
|
||||
if existing: |
||||
flash("A device with that name already exists.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active) |
||||
VALUES (%s, %s, %s, %s, 1) |
||||
""", |
||||
(session["otb_tenant_id"], device_name, device_type, relative_path), |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
_client_ip(), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Created device '{device_name}' ({device_type}) at {relative_path}", |
||||
), |
||||
) |
||||
|
||||
db.commit() |
||||
|
||||
create_device_directories(session["otb_tenant_slug"], relative_path) |
||||
|
||||
flash("Device added successfully.", "success") |
||||
return redirect(url_for("main.dashboard")) |
||||
|
||||
@bp.route("/devices/delete/<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), |
||||
) |
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,998 +0,0 @@
|
||||
from functools import wraps |
||||
from pathlib import Path |
||||
from datetime import datetime, timezone |
||||
import shutil |
||||
import zipfile |
||||
import re |
||||
|
||||
from werkzeug.utils import secure_filename |
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file |
||||
|
||||
from app.db import get_db |
||||
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256 |
||||
|
||||
bp = Blueprint("main", __name__) |
||||
|
||||
def portal_session_required(view_func): |
||||
@wraps(view_func) |
||||
def wrapped(*args, **kwargs): |
||||
if "otb_user_id" not in session or "otb_tenant_id" not in session: |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
return view_func(*args, **kwargs) |
||||
return wrapped |
||||
|
||||
def _client_ip(): |
||||
return request.headers.get("X-Forwarded-For", request.remote_addr) |
||||
|
||||
def _tenant_root() -> Path: |
||||
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"] |
||||
|
||||
def _stored_name(original_name: str) -> str: |
||||
safe = secure_filename(original_name or "upload.bin") |
||||
if not safe: |
||||
safe = "upload.bin" |
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") |
||||
return f"{ts}__{safe}" |
||||
|
||||
|
||||
def _recovered_filename(original_name: str) -> tuple[str, str, str]: |
||||
base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] |
||||
if "." in base_name: |
||||
basename, extension = base_name.rsplit(".", 1) |
||||
recovered_name = f"{basename}-recovered.{extension}" |
||||
return recovered_name, f"{basename}-recovered", extension |
||||
recovered_name = f"{base_name}-recovered" |
||||
return recovered_name, f"{base_name}-recovered", "" |
||||
|
||||
def _display_filename(file_row) -> str: |
||||
if not file_row: |
||||
return "download.bin" |
||||
return (file_row.get("display_filename") or file_row.get("original_filename") or "download.bin").strip() |
||||
|
||||
|
||||
def _sanitize_display_basename(raw_name: str) -> str: |
||||
cleaned = (raw_name or "").replace("\x00", "").strip() |
||||
cleaned = re.sub(r'[\\/:*?"<>|]+', "", cleaned) |
||||
cleaned = re.sub(r"\s+", " ", cleaned) |
||||
cleaned = cleaned.strip().strip(".") |
||||
if "." in cleaned: |
||||
cleaned = cleaned.rsplit(".", 1)[0].strip() |
||||
return cleaned[:200] |
||||
|
||||
|
||||
def _safe_path_from_relative(relative_path: str) -> Path: |
||||
return _tenant_root() / relative_path |
||||
|
||||
def _get_device_for_tenant(db, device_id: int): |
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path |
||||
FROM devices |
||||
WHERE id = %s AND tenant_id = %s |
||||
""", |
||||
(device_id, session["otb_tenant_id"]), |
||||
) |
||||
return cur.fetchone() |
||||
|
||||
def _purge_expired_deleted_files(db): |
||||
expired = [] |
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, relative_path, original_filename |
||||
FROM files |
||||
WHERE tenant_id = %s |
||||
AND is_deleted = 1 |
||||
AND deleted_at IS NOT NULL |
||||
AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
expired = cur.fetchall() |
||||
|
||||
for row in expired: |
||||
file_path = _safe_path_from_relative(row["relative_path"]) |
||||
if file_path.exists(): |
||||
try: |
||||
file_path.unlink() |
||||
except FileNotFoundError: |
||||
pass |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'system', 'deleted_file_purged', %s, %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
row["id"], |
||||
_client_ip(), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Purged deleted file '{row['original_filename']}' after retention window", |
||||
), |
||||
) |
||||
|
||||
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) |
||||
|
||||
db.commit() |
||||
|
||||
@bp.route("/") |
||||
def index(): |
||||
if "otb_user_id" in session: |
||||
return redirect(url_for("main.dashboard")) |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
|
||||
@bp.route("/dashboard") |
||||
@portal_session_required |
||||
def dashboard(): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path, is_active, created_at |
||||
FROM devices |
||||
WHERE tenant_id = %s |
||||
ORDER BY id |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
devices = cur.fetchall() |
||||
|
||||
return render_template( |
||||
"cloud/dashboard.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
devices=devices, |
||||
) |
||||
|
||||
@bp.route("/devices/new", methods=["GET", "POST"]) |
||||
@portal_session_required |
||||
def add_device(): |
||||
if request.method == "GET": |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
) |
||||
|
||||
device_name = (request.form.get("device_name") or "").strip() |
||||
device_type = (request.form.get("device_type") or "").strip() |
||||
|
||||
if not device_name: |
||||
flash("Device name is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
if not device_type: |
||||
flash("Device type is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
slug = slugify_device_name(device_name) |
||||
relative_path = f"devices/{slug}" |
||||
|
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id |
||||
FROM devices |
||||
WHERE tenant_id = %s AND device_name = %s |
||||
""", |
||||
(session["otb_tenant_id"], device_name), |
||||
) |
||||
existing = cur.fetchone() |
||||
|
||||
if existing: |
||||
flash("A device with that name already exists.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active) |
||||
VALUES (%s, %s, %s, %s, 1) |
||||
""", |
||||
(session["otb_tenant_id"], device_name, device_type, relative_path), |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
_client_ip(), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Created device '{device_name}' ({device_type}) at {relative_path}", |
||||
), |
||||
) |
||||
|
||||
db.commit() |
||||
|
||||
create_device_directories(session["otb_tenant_slug"], relative_path) |
||||
|
||||
flash("Device added successfully.", "success") |
||||
return redirect(url_for("main.dashboard")) |
||||
|
||||
@bp.route("/devices/delete/<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), |
||||
) |
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,894 +0,0 @@
|
||||
from functools import wraps |
||||
from pathlib import Path |
||||
from datetime import datetime, timezone |
||||
import shutil |
||||
import zipfile |
||||
|
||||
from werkzeug.utils import secure_filename |
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file |
||||
|
||||
from app.db import get_db |
||||
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256 |
||||
|
||||
bp = Blueprint("main", __name__) |
||||
|
||||
def portal_session_required(view_func): |
||||
@wraps(view_func) |
||||
def wrapped(*args, **kwargs): |
||||
if "otb_user_id" not in session or "otb_tenant_id" not in session: |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
return view_func(*args, **kwargs) |
||||
return wrapped |
||||
|
||||
def _client_ip(): |
||||
return request.headers.get("X-Forwarded-For", request.remote_addr) |
||||
|
||||
def _tenant_root() -> Path: |
||||
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"] |
||||
|
||||
def _stored_name(original_name: str) -> str: |
||||
safe = secure_filename(original_name or "upload.bin") |
||||
if not safe: |
||||
safe = "upload.bin" |
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") |
||||
return f"{ts}__{safe}" |
||||
|
||||
|
||||
def _recovered_filename(original_name: str) -> tuple[str, str, str]: |
||||
base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] |
||||
if "." in base_name: |
||||
basename, extension = base_name.rsplit(".", 1) |
||||
recovered_name = f"{basename}-recovered.{extension}" |
||||
return recovered_name, f"{basename}-recovered", extension |
||||
recovered_name = f"{base_name}-recovered" |
||||
return recovered_name, f"{base_name}-recovered", "" |
||||
|
||||
def _safe_path_from_relative(relative_path: str) -> Path: |
||||
return _tenant_root() / relative_path |
||||
|
||||
def _get_device_for_tenant(db, device_id: int): |
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path |
||||
FROM devices |
||||
WHERE id = %s AND tenant_id = %s |
||||
""", |
||||
(device_id, session["otb_tenant_id"]), |
||||
) |
||||
return cur.fetchone() |
||||
|
||||
def _purge_expired_deleted_files(db): |
||||
expired = [] |
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, relative_path, original_filename |
||||
FROM files |
||||
WHERE tenant_id = %s |
||||
AND is_deleted = 1 |
||||
AND deleted_at IS NOT NULL |
||||
AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
expired = cur.fetchall() |
||||
|
||||
for row in expired: |
||||
file_path = _safe_path_from_relative(row["relative_path"]) |
||||
if file_path.exists(): |
||||
try: |
||||
file_path.unlink() |
||||
except FileNotFoundError: |
||||
pass |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'system', 'deleted_file_purged', %s, %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
row["id"], |
||||
_client_ip(), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Purged deleted file '{row['original_filename']}' after retention window", |
||||
), |
||||
) |
||||
|
||||
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) |
||||
|
||||
db.commit() |
||||
|
||||
@bp.route("/") |
||||
def index(): |
||||
if "otb_user_id" in session: |
||||
return redirect(url_for("main.dashboard")) |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
|
||||
@bp.route("/dashboard") |
||||
@portal_session_required |
||||
def dashboard(): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path, is_active, created_at |
||||
FROM devices |
||||
WHERE tenant_id = %s |
||||
ORDER BY id |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
devices = cur.fetchall() |
||||
|
||||
return render_template( |
||||
"cloud/dashboard.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
devices=devices, |
||||
) |
||||
|
||||
@bp.route("/devices/new", methods=["GET", "POST"]) |
||||
@portal_session_required |
||||
def add_device(): |
||||
if request.method == "GET": |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
) |
||||
|
||||
device_name = (request.form.get("device_name") or "").strip() |
||||
device_type = (request.form.get("device_type") or "").strip() |
||||
|
||||
if not device_name: |
||||
flash("Device name is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
if not device_type: |
||||
flash("Device type is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
slug = slugify_device_name(device_name) |
||||
relative_path = f"devices/{slug}" |
||||
|
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id |
||||
FROM devices |
||||
WHERE tenant_id = %s AND device_name = %s |
||||
""", |
||||
(session["otb_tenant_id"], device_name), |
||||
) |
||||
existing = cur.fetchone() |
||||
|
||||
if existing: |
||||
flash("A device with that name already exists.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active) |
||||
VALUES (%s, %s, %s, %s, 1) |
||||
""", |
||||
(session["otb_tenant_id"], device_name, device_type, relative_path), |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
_client_ip(), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Created device '{device_name}' ({device_type}) at {relative_path}", |
||||
), |
||||
) |
||||
|
||||
db.commit() |
||||
|
||||
create_device_directories(session["otb_tenant_slug"], relative_path) |
||||
|
||||
flash("Device added successfully.", "success") |
||||
return redirect(url_for("main.dashboard")) |
||||
|
||||
@bp.route("/devices/delete/<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), |
||||
) |
||||
@ -1,894 +0,0 @@
|
||||
from functools import wraps |
||||
from pathlib import Path |
||||
from datetime import datetime, timezone |
||||
import shutil |
||||
import zipfile |
||||
|
||||
from werkzeug.utils import secure_filename |
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file |
||||
|
||||
from app.db import get_db |
||||
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256 |
||||
|
||||
bp = Blueprint("main", __name__) |
||||
|
||||
def portal_session_required(view_func): |
||||
@wraps(view_func) |
||||
def wrapped(*args, **kwargs): |
||||
if "otb_user_id" not in session or "otb_tenant_id" not in session: |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
return view_func(*args, **kwargs) |
||||
return wrapped |
||||
|
||||
def _client_ip(): |
||||
return request.headers.get("X-Forwarded-For", request.remote_addr) |
||||
|
||||
def _tenant_root() -> Path: |
||||
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"] |
||||
|
||||
def _stored_name(original_name: str) -> str: |
||||
safe = secure_filename(original_name or "upload.bin") |
||||
if not safe: |
||||
safe = "upload.bin" |
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") |
||||
return f"{ts}__{safe}" |
||||
|
||||
|
||||
def _recovered_filename(original_name: str) -> tuple[str, str, str]: |
||||
base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] |
||||
if "." in base_name: |
||||
basename, extension = base_name.rsplit(".", 1) |
||||
recovered_name = f"{basename}-recovered.{extension}" |
||||
return recovered_name, f"{basename}-recovered", extension |
||||
recovered_name = f"{base_name}-recovered" |
||||
return recovered_name, f"{base_name}-recovered", "" |
||||
|
||||
def _safe_path_from_relative(relative_path: str) -> Path: |
||||
return _tenant_root() / relative_path |
||||
|
||||
def _get_device_for_tenant(db, device_id: int): |
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path |
||||
FROM devices |
||||
WHERE id = %s AND tenant_id = %s |
||||
""", |
||||
(device_id, session["otb_tenant_id"]), |
||||
) |
||||
return cur.fetchone() |
||||
|
||||
def _purge_expired_deleted_files(db): |
||||
expired = [] |
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, relative_path, original_filename |
||||
FROM files |
||||
WHERE tenant_id = %s |
||||
AND is_deleted = 1 |
||||
AND deleted_at IS NOT NULL |
||||
AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
expired = cur.fetchall() |
||||
|
||||
for row in expired: |
||||
file_path = _safe_path_from_relative(row["relative_path"]) |
||||
if file_path.exists(): |
||||
try: |
||||
file_path.unlink() |
||||
except FileNotFoundError: |
||||
pass |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'system', 'deleted_file_purged', %s, %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
row["id"], |
||||
_client_ip(), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Purged deleted file '{row['original_filename']}' after retention window", |
||||
), |
||||
) |
||||
|
||||
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) |
||||
|
||||
db.commit() |
||||
|
||||
@bp.route("/") |
||||
def index(): |
||||
if "otb_user_id" in session: |
||||
return redirect(url_for("main.dashboard")) |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
|
||||
@bp.route("/dashboard") |
||||
@portal_session_required |
||||
def dashboard(): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path, is_active, created_at |
||||
FROM devices |
||||
WHERE tenant_id = %s |
||||
ORDER BY id |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
devices = cur.fetchall() |
||||
|
||||
return render_template( |
||||
"cloud/dashboard.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
devices=devices, |
||||
) |
||||
|
||||
@bp.route("/devices/new", methods=["GET", "POST"]) |
||||
@portal_session_required |
||||
def add_device(): |
||||
if request.method == "GET": |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
) |
||||
|
||||
device_name = (request.form.get("device_name") or "").strip() |
||||
device_type = (request.form.get("device_type") or "").strip() |
||||
|
||||
if not device_name: |
||||
flash("Device name is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
if not device_type: |
||||
flash("Device type is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
slug = slugify_device_name(device_name) |
||||
relative_path = f"devices/{slug}" |
||||
|
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id |
||||
FROM devices |
||||
WHERE tenant_id = %s AND device_name = %s |
||||
""", |
||||
(session["otb_tenant_id"], device_name), |
||||
) |
||||
existing = cur.fetchone() |
||||
|
||||
if existing: |
||||
flash("A device with that name already exists.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active) |
||||
VALUES (%s, %s, %s, %s, 1) |
||||
""", |
||||
(session["otb_tenant_id"], device_name, device_type, relative_path), |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
_client_ip(), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Created device '{device_name}' ({device_type}) at {relative_path}", |
||||
), |
||||
) |
||||
|
||||
db.commit() |
||||
|
||||
create_device_directories(session["otb_tenant_slug"], relative_path) |
||||
|
||||
flash("Device added successfully.", "success") |
||||
return redirect(url_for("main.dashboard")) |
||||
|
||||
@bp.route("/devices/delete/<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), |
||||
) |
||||
@ -1,894 +0,0 @@
|
||||
from functools import wraps |
||||
from pathlib import Path |
||||
from datetime import datetime, timezone |
||||
import shutil |
||||
import zipfile |
||||
|
||||
from werkzeug.utils import secure_filename |
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file |
||||
|
||||
from app.db import get_db |
||||
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256 |
||||
|
||||
bp = Blueprint("main", __name__) |
||||
|
||||
def portal_session_required(view_func): |
||||
@wraps(view_func) |
||||
def wrapped(*args, **kwargs): |
||||
if "otb_user_id" not in session or "otb_tenant_id" not in session: |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
return view_func(*args, **kwargs) |
||||
return wrapped |
||||
|
||||
def _client_ip(): |
||||
return request.headers.get("X-Forwarded-For", request.remote_addr) |
||||
|
||||
def _tenant_root() -> Path: |
||||
return Path(current_app.config["STORAGE_ROOT"]) / "tenants" / session["otb_tenant_slug"] |
||||
|
||||
def _stored_name(original_name: str) -> str: |
||||
safe = secure_filename(original_name or "upload.bin") |
||||
if not safe: |
||||
safe = "upload.bin" |
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") |
||||
return f"{ts}__{safe}" |
||||
|
||||
|
||||
def _recovered_filename(original_name: str) -> tuple[str, str, str]: |
||||
base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] |
||||
if "." in base_name: |
||||
basename, extension = base_name.rsplit(".", 1) |
||||
recovered_name = f"{basename}-recovered.{extension}" |
||||
return recovered_name, f"{basename}-recovered", extension |
||||
recovered_name = f"{base_name}-recovered" |
||||
return recovered_name, f"{base_name}-recovered", "" |
||||
|
||||
def _safe_path_from_relative(relative_path: str) -> Path: |
||||
return _tenant_root() / relative_path |
||||
|
||||
def _get_device_for_tenant(db, device_id: int): |
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path |
||||
FROM devices |
||||
WHERE id = %s AND tenant_id = %s |
||||
""", |
||||
(device_id, session["otb_tenant_id"]), |
||||
) |
||||
return cur.fetchone() |
||||
|
||||
def _purge_expired_deleted_files(db): |
||||
expired = [] |
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, relative_path, original_filename |
||||
FROM files |
||||
WHERE tenant_id = %s |
||||
AND is_deleted = 1 |
||||
AND deleted_at IS NOT NULL |
||||
AND deleted_at <= (UTC_TIMESTAMP() - INTERVAL 24 HOUR) |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
expired = cur.fetchall() |
||||
|
||||
for row in expired: |
||||
file_path = _safe_path_from_relative(row["relative_path"]) |
||||
if file_path.exists(): |
||||
try: |
||||
file_path.unlink() |
||||
except FileNotFoundError: |
||||
pass |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'system', 'deleted_file_purged', %s, %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
row["id"], |
||||
_client_ip(), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Purged deleted file '{row['original_filename']}' after retention window", |
||||
), |
||||
) |
||||
|
||||
cur.execute("DELETE FROM files WHERE id = %s AND tenant_id = %s", (row["id"], session["otb_tenant_id"])) |
||||
|
||||
db.commit() |
||||
|
||||
@bp.route("/") |
||||
def index(): |
||||
if "otb_user_id" in session: |
||||
return redirect(url_for("main.dashboard")) |
||||
return redirect(url_for("auth.login_required_notice")) |
||||
|
||||
@bp.route("/dashboard") |
||||
@portal_session_required |
||||
def dashboard(): |
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id, device_name, device_type, relative_path, is_active, created_at |
||||
FROM devices |
||||
WHERE tenant_id = %s |
||||
ORDER BY id |
||||
""", |
||||
(session["otb_tenant_id"],), |
||||
) |
||||
devices = cur.fetchall() |
||||
|
||||
return render_template( |
||||
"cloud/dashboard.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
devices=devices, |
||||
) |
||||
|
||||
@bp.route("/devices/new", methods=["GET", "POST"]) |
||||
@portal_session_required |
||||
def add_device(): |
||||
if request.method == "GET": |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
) |
||||
|
||||
device_name = (request.form.get("device_name") or "").strip() |
||||
device_type = (request.form.get("device_type") or "").strip() |
||||
|
||||
if not device_name: |
||||
flash("Device name is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
if not device_type: |
||||
flash("Device type is required.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
slug = slugify_device_name(device_name) |
||||
relative_path = f"devices/{slug}" |
||||
|
||||
db = get_db() |
||||
|
||||
with db.cursor() as cur: |
||||
cur.execute( |
||||
""" |
||||
SELECT id |
||||
FROM devices |
||||
WHERE tenant_id = %s AND device_name = %s |
||||
""", |
||||
(session["otb_tenant_id"], device_name), |
||||
) |
||||
existing = cur.fetchone() |
||||
|
||||
if existing: |
||||
flash("A device with that name already exists.", "warning") |
||||
return render_template( |
||||
"cloud/device_new.html", |
||||
user_email=session.get("otb_email"), |
||||
tenant_slug=session.get("otb_tenant_slug"), |
||||
device_name=device_name, |
||||
device_type=device_type, |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO devices (tenant_id, device_name, device_type, relative_path, is_active) |
||||
VALUES (%s, %s, %s, %s, 1) |
||||
""", |
||||
(session["otb_tenant_id"], device_name, device_type, relative_path), |
||||
) |
||||
|
||||
cur.execute( |
||||
""" |
||||
INSERT INTO audit_logs ( |
||||
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail |
||||
) VALUES (%s, %s, 'user', 'device_created', %s, %s, %s) |
||||
""", |
||||
( |
||||
session["otb_tenant_id"], |
||||
session["otb_user_id"], |
||||
_client_ip(), |
||||
request.headers.get("User-Agent", ""), |
||||
f"Created device '{device_name}' ({device_type}) at {relative_path}", |
||||
), |
||||
) |
||||
|
||||
db.commit() |
||||
|
||||
create_device_directories(session["otb_tenant_slug"], relative_path) |
||||
|
||||
flash("Device added successfully.", "success") |
||||
return redirect(url_for("main.dashboard")) |
||||
|
||||
@bp.route("/devices/delete/<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), |
||||
) |
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,104 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS users ( |
||||
id INT AUTO_INCREMENT PRIMARY KEY, |
||||
portal_user_id INT NULL, |
||||
email VARCHAR(255) NOT NULL UNIQUE, |
||||
display_name VARCHAR(255) NULL, |
||||
is_active TINYINT(1) NOT NULL DEFAULT 1, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
last_login_at DATETIME NULL |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS tenants ( |
||||
id INT AUTO_INCREMENT PRIMARY KEY, |
||||
owner_user_id INT NOT NULL, |
||||
slug VARCHAR(100) NOT NULL UNIQUE, |
||||
storage_root VARCHAR(500) NOT NULL, |
||||
service_status VARCHAR(50) NOT NULL DEFAULT 'active', |
||||
retention_mode VARCHAR(50) NOT NULL DEFAULT 'standard', |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
CONSTRAINT fk_tenants_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS devices ( |
||||
id INT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
device_name VARCHAR(100) NOT NULL, |
||||
device_type VARCHAR(50) NOT NULL, |
||||
relative_path VARCHAR(255) NOT NULL, |
||||
is_active TINYINT(1) NOT NULL DEFAULT 1, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
CONSTRAINT uq_devices_tenant_name UNIQUE (tenant_id, device_name), |
||||
CONSTRAINT fk_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS files ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
device_id INT NOT NULL, |
||||
parent_file_id BIGINT NULL, |
||||
file_kind VARCHAR(20) NOT NULL, |
||||
relative_path VARCHAR(1000) NOT NULL, |
||||
directory_path VARCHAR(1000) NOT NULL, |
||||
original_filename VARCHAR(255) NOT NULL, |
||||
display_filename VARCHAR(255) NULL, |
||||
basename VARCHAR(255) NOT NULL, |
||||
extension VARCHAR(50) NOT NULL, |
||||
mime_type VARCHAR(255) NULL, |
||||
size_bytes BIGINT NOT NULL DEFAULT 0, |
||||
sha256 CHAR(64) NULL, |
||||
capture_date DATETIME NULL, |
||||
uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
is_immutable TINYINT(1) NOT NULL DEFAULT 1, |
||||
is_deleted TINYINT(1) NOT NULL DEFAULT 0, |
||||
deleted_at DATETIME NULL, |
||||
CONSTRAINT fk_files_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), |
||||
CONSTRAINT fk_files_device FOREIGN KEY (device_id) REFERENCES devices(id), |
||||
CONSTRAINT fk_files_parent FOREIGN KEY (parent_file_id) REFERENCES files(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
file_id BIGINT NOT NULL, |
||||
job_type VARCHAR(100) NOT NULL, |
||||
options_json LONGTEXT NULL, |
||||
status VARCHAR(50) NOT NULL DEFAULT 'queued', |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
started_at DATETIME NULL, |
||||
completed_at DATETIME NULL, |
||||
output_file_id BIGINT NULL, |
||||
log_text LONGTEXT NULL, |
||||
CONSTRAINT fk_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), |
||||
CONSTRAINT fk_jobs_file FOREIGN KEY (file_id) REFERENCES files(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NULL, |
||||
user_id INT NULL, |
||||
actor_type VARCHAR(20) NOT NULL, |
||||
event_type VARCHAR(100) NOT NULL, |
||||
file_id BIGINT NULL, |
||||
job_id BIGINT NULL, |
||||
ip_address VARCHAR(64) NULL, |
||||
user_agent VARCHAR(500) NULL, |
||||
event_detail LONGTEXT NULL, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
INDEX idx_audit_tenant_created (tenant_id, created_at), |
||||
INDEX idx_audit_event_type (event_type) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_access_tokens ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
issued_by_user_id INT NOT NULL, |
||||
used_by_admin_id INT NULL, |
||||
token_hash CHAR(64) NOT NULL, |
||||
purpose VARCHAR(255) NOT NULL, |
||||
status VARCHAR(50) NOT NULL DEFAULT 'issued', |
||||
expires_at DATETIME NOT NULL, |
||||
used_at DATETIME NULL, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
CONSTRAINT fk_admin_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), |
||||
CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(id) |
||||
); |
||||
@ -1,103 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS users ( |
||||
id INT AUTO_INCREMENT PRIMARY KEY, |
||||
portal_user_id INT NULL, |
||||
email VARCHAR(255) NOT NULL UNIQUE, |
||||
display_name VARCHAR(255) NULL, |
||||
is_active TINYINT(1) NOT NULL DEFAULT 1, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
last_login_at DATETIME NULL |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS tenants ( |
||||
id INT AUTO_INCREMENT PRIMARY KEY, |
||||
owner_user_id INT NOT NULL, |
||||
slug VARCHAR(100) NOT NULL UNIQUE, |
||||
storage_root VARCHAR(500) NOT NULL, |
||||
service_status VARCHAR(50) NOT NULL DEFAULT 'active', |
||||
retention_mode VARCHAR(50) NOT NULL DEFAULT 'standard', |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
CONSTRAINT fk_tenants_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS devices ( |
||||
id INT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
device_name VARCHAR(100) NOT NULL, |
||||
device_type VARCHAR(50) NOT NULL, |
||||
relative_path VARCHAR(255) NOT NULL, |
||||
is_active TINYINT(1) NOT NULL DEFAULT 1, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
CONSTRAINT uq_devices_tenant_name UNIQUE (tenant_id, device_name), |
||||
CONSTRAINT fk_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS files ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
device_id INT NOT NULL, |
||||
parent_file_id BIGINT NULL, |
||||
file_kind VARCHAR(20) NOT NULL, |
||||
relative_path VARCHAR(1000) NOT NULL, |
||||
directory_path VARCHAR(1000) NOT NULL, |
||||
original_filename VARCHAR(255) NOT NULL, |
||||
basename VARCHAR(255) NOT NULL, |
||||
extension VARCHAR(50) NOT NULL, |
||||
mime_type VARCHAR(255) NULL, |
||||
size_bytes BIGINT NOT NULL DEFAULT 0, |
||||
sha256 CHAR(64) NULL, |
||||
capture_date DATETIME NULL, |
||||
uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
is_immutable TINYINT(1) NOT NULL DEFAULT 1, |
||||
is_deleted TINYINT(1) NOT NULL DEFAULT 0, |
||||
deleted_at DATETIME NULL, |
||||
CONSTRAINT fk_files_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), |
||||
CONSTRAINT fk_files_device FOREIGN KEY (device_id) REFERENCES devices(id), |
||||
CONSTRAINT fk_files_parent FOREIGN KEY (parent_file_id) REFERENCES files(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
file_id BIGINT NOT NULL, |
||||
job_type VARCHAR(100) NOT NULL, |
||||
options_json LONGTEXT NULL, |
||||
status VARCHAR(50) NOT NULL DEFAULT 'queued', |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
started_at DATETIME NULL, |
||||
completed_at DATETIME NULL, |
||||
output_file_id BIGINT NULL, |
||||
log_text LONGTEXT NULL, |
||||
CONSTRAINT fk_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), |
||||
CONSTRAINT fk_jobs_file FOREIGN KEY (file_id) REFERENCES files(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NULL, |
||||
user_id INT NULL, |
||||
actor_type VARCHAR(20) NOT NULL, |
||||
event_type VARCHAR(100) NOT NULL, |
||||
file_id BIGINT NULL, |
||||
job_id BIGINT NULL, |
||||
ip_address VARCHAR(64) NULL, |
||||
user_agent VARCHAR(500) NULL, |
||||
event_detail LONGTEXT NULL, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
INDEX idx_audit_tenant_created (tenant_id, created_at), |
||||
INDEX idx_audit_event_type (event_type) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_access_tokens ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
issued_by_user_id INT NOT NULL, |
||||
used_by_admin_id INT NULL, |
||||
token_hash CHAR(64) NOT NULL, |
||||
purpose VARCHAR(255) NOT NULL, |
||||
status VARCHAR(50) NOT NULL DEFAULT 'issued', |
||||
expires_at DATETIME NOT NULL, |
||||
used_at DATETIME NULL, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
CONSTRAINT fk_admin_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), |
||||
CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(id) |
||||
); |
||||
@ -1,103 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS users ( |
||||
id INT AUTO_INCREMENT PRIMARY KEY, |
||||
portal_user_id INT NULL, |
||||
email VARCHAR(255) NOT NULL UNIQUE, |
||||
display_name VARCHAR(255) NULL, |
||||
is_active TINYINT(1) NOT NULL DEFAULT 1, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
last_login_at DATETIME NULL |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS tenants ( |
||||
id INT AUTO_INCREMENT PRIMARY KEY, |
||||
owner_user_id INT NOT NULL, |
||||
slug VARCHAR(100) NOT NULL UNIQUE, |
||||
storage_root VARCHAR(500) NOT NULL, |
||||
service_status VARCHAR(50) NOT NULL DEFAULT 'active', |
||||
retention_mode VARCHAR(50) NOT NULL DEFAULT 'standard', |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
CONSTRAINT fk_tenants_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS devices ( |
||||
id INT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
device_name VARCHAR(100) NOT NULL, |
||||
device_type VARCHAR(50) NOT NULL, |
||||
relative_path VARCHAR(255) NOT NULL, |
||||
is_active TINYINT(1) NOT NULL DEFAULT 1, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
CONSTRAINT uq_devices_tenant_name UNIQUE (tenant_id, device_name), |
||||
CONSTRAINT fk_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS files ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
device_id INT NOT NULL, |
||||
parent_file_id BIGINT NULL, |
||||
file_kind VARCHAR(20) NOT NULL, |
||||
relative_path VARCHAR(1000) NOT NULL, |
||||
directory_path VARCHAR(1000) NOT NULL, |
||||
original_filename VARCHAR(255) NOT NULL, |
||||
basename VARCHAR(255) NOT NULL, |
||||
extension VARCHAR(50) NOT NULL, |
||||
mime_type VARCHAR(255) NULL, |
||||
size_bytes BIGINT NOT NULL DEFAULT 0, |
||||
sha256 CHAR(64) NULL, |
||||
capture_date DATETIME NULL, |
||||
uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
is_immutable TINYINT(1) NOT NULL DEFAULT 1, |
||||
is_deleted TINYINT(1) NOT NULL DEFAULT 0, |
||||
deleted_at DATETIME NULL, |
||||
CONSTRAINT fk_files_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), |
||||
CONSTRAINT fk_files_device FOREIGN KEY (device_id) REFERENCES devices(id), |
||||
CONSTRAINT fk_files_parent FOREIGN KEY (parent_file_id) REFERENCES files(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
file_id BIGINT NOT NULL, |
||||
job_type VARCHAR(100) NOT NULL, |
||||
options_json LONGTEXT NULL, |
||||
status VARCHAR(50) NOT NULL DEFAULT 'queued', |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
started_at DATETIME NULL, |
||||
completed_at DATETIME NULL, |
||||
output_file_id BIGINT NULL, |
||||
log_text LONGTEXT NULL, |
||||
CONSTRAINT fk_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), |
||||
CONSTRAINT fk_jobs_file FOREIGN KEY (file_id) REFERENCES files(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NULL, |
||||
user_id INT NULL, |
||||
actor_type VARCHAR(20) NOT NULL, |
||||
event_type VARCHAR(100) NOT NULL, |
||||
file_id BIGINT NULL, |
||||
job_id BIGINT NULL, |
||||
ip_address VARCHAR(64) NULL, |
||||
user_agent VARCHAR(500) NULL, |
||||
event_detail LONGTEXT NULL, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
INDEX idx_audit_tenant_created (tenant_id, created_at), |
||||
INDEX idx_audit_event_type (event_type) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_access_tokens ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
issued_by_user_id INT NOT NULL, |
||||
used_by_admin_id INT NULL, |
||||
token_hash CHAR(64) NOT NULL, |
||||
purpose VARCHAR(255) NOT NULL, |
||||
status VARCHAR(50) NOT NULL DEFAULT 'issued', |
||||
expires_at DATETIME NOT NULL, |
||||
used_at DATETIME NULL, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
CONSTRAINT fk_admin_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), |
||||
CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(id) |
||||
); |
||||
@ -1,103 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS users ( |
||||
id INT AUTO_INCREMENT PRIMARY KEY, |
||||
portal_user_id INT NULL, |
||||
email VARCHAR(255) NOT NULL UNIQUE, |
||||
display_name VARCHAR(255) NULL, |
||||
is_active TINYINT(1) NOT NULL DEFAULT 1, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
last_login_at DATETIME NULL |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS tenants ( |
||||
id INT AUTO_INCREMENT PRIMARY KEY, |
||||
owner_user_id INT NOT NULL, |
||||
slug VARCHAR(100) NOT NULL UNIQUE, |
||||
storage_root VARCHAR(500) NOT NULL, |
||||
service_status VARCHAR(50) NOT NULL DEFAULT 'active', |
||||
retention_mode VARCHAR(50) NOT NULL DEFAULT 'standard', |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
CONSTRAINT fk_tenants_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS devices ( |
||||
id INT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
device_name VARCHAR(100) NOT NULL, |
||||
device_type VARCHAR(50) NOT NULL, |
||||
relative_path VARCHAR(255) NOT NULL, |
||||
is_active TINYINT(1) NOT NULL DEFAULT 1, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
CONSTRAINT uq_devices_tenant_name UNIQUE (tenant_id, device_name), |
||||
CONSTRAINT fk_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS files ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
device_id INT NOT NULL, |
||||
parent_file_id BIGINT NULL, |
||||
file_kind VARCHAR(20) NOT NULL, |
||||
relative_path VARCHAR(1000) NOT NULL, |
||||
directory_path VARCHAR(1000) NOT NULL, |
||||
original_filename VARCHAR(255) NOT NULL, |
||||
basename VARCHAR(255) NOT NULL, |
||||
extension VARCHAR(50) NOT NULL, |
||||
mime_type VARCHAR(255) NULL, |
||||
size_bytes BIGINT NOT NULL DEFAULT 0, |
||||
sha256 CHAR(64) NULL, |
||||
capture_date DATETIME NULL, |
||||
uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
is_immutable TINYINT(1) NOT NULL DEFAULT 1, |
||||
is_deleted TINYINT(1) NOT NULL DEFAULT 0, |
||||
deleted_at DATETIME NULL, |
||||
CONSTRAINT fk_files_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), |
||||
CONSTRAINT fk_files_device FOREIGN KEY (device_id) REFERENCES devices(id), |
||||
CONSTRAINT fk_files_parent FOREIGN KEY (parent_file_id) REFERENCES files(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
file_id BIGINT NOT NULL, |
||||
job_type VARCHAR(100) NOT NULL, |
||||
options_json LONGTEXT NULL, |
||||
status VARCHAR(50) NOT NULL DEFAULT 'queued', |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
started_at DATETIME NULL, |
||||
completed_at DATETIME NULL, |
||||
output_file_id BIGINT NULL, |
||||
log_text LONGTEXT NULL, |
||||
CONSTRAINT fk_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), |
||||
CONSTRAINT fk_jobs_file FOREIGN KEY (file_id) REFERENCES files(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NULL, |
||||
user_id INT NULL, |
||||
actor_type VARCHAR(20) NOT NULL, |
||||
event_type VARCHAR(100) NOT NULL, |
||||
file_id BIGINT NULL, |
||||
job_id BIGINT NULL, |
||||
ip_address VARCHAR(64) NULL, |
||||
user_agent VARCHAR(500) NULL, |
||||
event_detail LONGTEXT NULL, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
INDEX idx_audit_tenant_created (tenant_id, created_at), |
||||
INDEX idx_audit_event_type (event_type) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_access_tokens ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
issued_by_user_id INT NOT NULL, |
||||
used_by_admin_id INT NULL, |
||||
token_hash CHAR(64) NOT NULL, |
||||
purpose VARCHAR(255) NOT NULL, |
||||
status VARCHAR(50) NOT NULL DEFAULT 'issued', |
||||
expires_at DATETIME NOT NULL, |
||||
used_at DATETIME NULL, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
CONSTRAINT fk_admin_token_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), |
||||
CONSTRAINT fk_admin_token_owner FOREIGN KEY (issued_by_user_id) REFERENCES users(id) |
||||
); |
||||
@ -1,10 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,16 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,37 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,77 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,124 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,127 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,130 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,131 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,133 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,135 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,102 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,101 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,146 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,454 +0,0 @@
|
||||
{% 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 }} Original: {{ file.original_filename }} Type: {{ file.mime_type or file.file_kind }} Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes 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 %} |
||||
@ -1,126 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,126 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,126 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,454 +0,0 @@
|
||||
{% 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 }} Original: {{ file.original_filename }} Type: {{ file.mime_type or file.file_kind }} Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes 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 %} |
||||
@ -1,454 +0,0 @@
|
||||
{% 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 }} Original: {{ file.original_filename }} Type: {{ file.mime_type or file.file_kind }} Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes 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 %} |
||||
@ -1,89 +0,0 @@
|
||||
{% 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 %} |
||||
@ -0,0 +1,324 @@
|
||||
{% extends "portal_base.html" %} |
||||
{% block portal_content %} |
||||
|
||||
<style> |
||||
.image-workshop-wrap { max-width: 1200px; margin: 0 auto; } |
||||
.iw-toolbar { display:flex; gap:10px; flex-wrap:wrap; margin: 12px 0 18px; } |
||||
.iw-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:14px; } |
||||
.iw-tile { background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.12); border-radius:14px; padding:10px; } |
||||
.iw-tile img { width:100%; height:130px; object-fit:cover; border-radius:10px; cursor:pointer; background:#111; } |
||||
.iw-name { margin-top:8px; font-weight:700; word-break:break-word; font-size:.9rem; } |
||||
.iw-mini { display:flex; gap:6px; flex-wrap:wrap; margin-top:8px; } |
||||
.iw-mini button, .iw-toolbar button, .iw-modal-actions button { cursor:pointer; } |
||||
|
||||
.iw-modal { |
||||
display:none; position:fixed; z-index:9999; inset:0; |
||||
background:rgba(0,0,0,.82); padding:30px; overflow:auto; |
||||
} |
||||
.iw-modal-inner { |
||||
max-width:1100px; margin:0 auto; background:#0f172a; |
||||
border:1px solid rgba(255,255,255,.14); border-radius:18px; padding:18px; |
||||
} |
||||
.iw-preview-wrap { display:flex; justify-content:center; background:#050816; border-radius:14px; padding:16px; min-height:360px; } |
||||
#modalImage { max-width:100%; max-height:70vh; object-fit:contain; transition:filter .15s, transform .15s; } |
||||
.iw-modal-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:14px; align-items:center; } |
||||
.iw-modal-actions input { min-width:260px; padding:8px; } |
||||
.iw-note { opacity:.8; margin-top:8px; } |
||||
|
||||
.iw-modal-actions select { |
||||
background:#0f172a; |
||||
color:#e6eefc; |
||||
border:1px solid rgba(255,255,255,0.2); |
||||
border-radius:8px; |
||||
padding:6px 10px; |
||||
} |
||||
|
||||
|
||||
.iw-modal-actions select, |
||||
.iw-modal-actions select option, |
||||
#formatSelect, |
||||
#formatSelect option, |
||||
#sizeSelect, |
||||
#sizeSelect option { |
||||
background-color: #0f172a !important; |
||||
color: #e6eefc !important; |
||||
border: 1px solid rgba(255,255,255,0.28) !important; |
||||
} |
||||
|
||||
.iw-modal-actions select, |
||||
#formatSelect, |
||||
#sizeSelect { |
||||
border-radius: 8px !important; |
||||
padding: 8px 10px !important; |
||||
min-width: 160px !important; |
||||
} |
||||
|
||||
</style> |
||||
|
||||
<div class="image-workshop-wrap"> |
||||
<div class="portal-page-header"> |
||||
<div> |
||||
<h1 class="portal-page-title">Image Workshop</h1> |
||||
<p class="portal-page-subtitle">Double-click an image to edit it. Max 25 images per batch.</p> |
||||
</div> |
||||
<div class="portal-toolbar"> |
||||
<a class="portal-btn" href="/devices/{{ device_id }}/files?path=images">Back to Images</a> |
||||
<a class="portal-btn" href="/dashboard">Dashboard</a> |
||||
<a class="portal-btn" href="/health">Health</a> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="service-card"> |
||||
<div class="service-card-body"> |
||||
<div class="iw-toolbar"> |
||||
<button type="button" onclick="bulkRotate(0)">Original Rotation</button> |
||||
<button type="button" onclick="bulkRotate(90)">Rotate 90</button> |
||||
<button type="button" onclick="bulkRotate(180)">Rotate 180</button> |
||||
<button type="button" onclick="bulkRotate(270)">Rotate 270</button> |
||||
<button type="button" onclick="processAll()">Save All Changed</button> |
||||
</div> |
||||
<div id="grid" class="iw-grid"></div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div id="editorModal" class="iw-modal"> |
||||
<div class="iw-modal-inner"> |
||||
<h2 id="modalTitle">Edit Image</h2> |
||||
<div class="iw-preview-wrap"> |
||||
<img id="modalImage" src=""> |
||||
</div> |
||||
|
||||
<div class="iw-modal-actions"> |
||||
<button type="button" onclick="modalRotate(0)">Original</button> |
||||
<button type="button" onclick="modalRotate(90)">Rotate 90</button> |
||||
<button type="button" onclick="modalRotate(180)">Rotate 180</button> |
||||
<button type="button" onclick="modalRotate(270)">Rotate 270</button> |
||||
<button type="button" onclick="modalFilter(null)">Normal</button> |
||||
<button type="button" onclick="modalFilter('bw')">B/W</button> |
||||
<button type="button" onclick="modalFilter('sepia')">Sepia</button> |
||||
</div> |
||||
|
||||
<div class="iw-modal-actions"> |
||||
<label>New image name:</label> |
||||
<input id="saveName" type="text" placeholder="required name, no extension"> |
||||
</div> |
||||
|
||||
<div class="iw-modal-actions"> |
||||
<label>Format:</label> |
||||
<select id="formatSelect"> |
||||
<option value="">Keep Format</option> |
||||
<option value="jpg">JPG</option> |
||||
<option value="webp">WebP</option> |
||||
<option value="png">PNG</option> |
||||
</select> |
||||
|
||||
<label>Size:</label> |
||||
<select id="sizeSelect"> |
||||
<option value="">Original Size</option> |
||||
<option value="2000">2000px</option> |
||||
<option value="1600" selected>1600px Web</option> |
||||
<option value="1200">1200px Small Web</option> |
||||
<option value="800">800px Preview</option> |
||||
</select> |
||||
</div> |
||||
|
||||
<div class="iw-modal-actions"> |
||||
<button type="button" onclick="saveCurrentImage()">Save Image</button> |
||||
<button type="button" onclick="revertCurrent()">Revert to Original</button> |
||||
<button type="button" onclick="closeModal()">Close</button> |
||||
</div> |
||||
|
||||
<p class="iw-note">Saving creates a new image in the images folder. Original files are not overwritten.</p> |
||||
</div> |
||||
</div> |
||||
|
||||
<script> |
||||
let items = []; |
||||
let state = {}; |
||||
let currentId = null; |
||||
|
||||
function safeName(v){ return (v || "image").toString(); } |
||||
|
||||
function loadSelection() { |
||||
try { items = JSON.parse(localStorage.getItem("imageSelection") || "[]"); } |
||||
catch { items = []; } |
||||
|
||||
if (items.length > 25) { |
||||
alert("Image Workshop is limited to 25 images per batch."); |
||||
items = items.slice(0, 25); |
||||
} |
||||
|
||||
items.forEach(i => { |
||||
if (!state[i.id]) state[i.id] = { rotation: 0, filter: null, format: null, name: "" }; |
||||
}); |
||||
|
||||
render(); |
||||
} |
||||
|
||||
function render() { |
||||
const grid = document.getElementById("grid"); |
||||
if (!items.length) { |
||||
grid.innerHTML = '<div class="job-empty">No images staged.</div>'; |
||||
return; |
||||
} |
||||
|
||||
grid.innerHTML = ""; |
||||
|
||||
items.forEach(item => { |
||||
const s = state[item.id] || { rotation:0, filter:null }; |
||||
const filename = safeName(item.display_filename || item.original_filename || item.filename || item.name); |
||||
const filterCss = s.filter === "bw" ? "grayscale(1)" : (s.filter === "sepia" ? "sepia(1)" : "none"); |
||||
|
||||
const div = document.createElement("div"); |
||||
div.className = "iw-tile"; |
||||
div.innerHTML = ` |
||||
<img ondblclick="openModal('${item.id}')" src="/files/${item.id}/thumb" style="transform:rotate(${s.rotation}deg);filter:${filterCss};"> |
||||
<div class="iw-name">${filename}</div> |
||||
<div class="iw-mini"> |
||||
<button type="button" onclick="rotateOne('${item.id}',0)">0</button> |
||||
<button type="button" onclick="rotateOne('${item.id}',90)">90</button> |
||||
<button type="button" onclick="rotateOne('${item.id}',180)">180</button> |
||||
<button type="button" onclick="rotateOne('${item.id}',270)">270</button> |
||||
</div> |
||||
`; |
||||
grid.appendChild(div); |
||||
}); |
||||
} |
||||
|
||||
function rotateOne(id, deg) { |
||||
state[id].rotation = deg; |
||||
render(); |
||||
} |
||||
|
||||
function bulkRotate(deg) { |
||||
Object.keys(state).forEach(id => state[id].rotation = deg); |
||||
render(); |
||||
} |
||||
|
||||
function getItem(id) { |
||||
return items.find(x => String(x.id) === String(id)); |
||||
} |
||||
|
||||
function openModal(id) { |
||||
currentId = id; |
||||
const item = getItem(id); |
||||
const s = state[id]; |
||||
document.getElementById("modalTitle").textContent = safeName(item.filename || item.display_filename || item.original_filename || item.name); |
||||
document.getElementById("modalImage").src = "/files/" + id + "/inline"; |
||||
document.getElementById("saveName").value = s.name || ""; |
||||
const fmt = document.getElementById("formatSelect"); |
||||
const size = document.getElementById("sizeSelect"); |
||||
if (fmt) fmt.value = s.format || ""; |
||||
if (size) size.value = s.max_size || "1600"; |
||||
applyModalPreview(); |
||||
document.getElementById("editorModal").style.display = "block"; |
||||
} |
||||
|
||||
function closeModal() { |
||||
document.getElementById("editorModal").style.display = "none"; |
||||
currentId = null; |
||||
} |
||||
|
||||
function applyModalPreview() { |
||||
if (!currentId) return; |
||||
const s = state[currentId]; |
||||
const img = document.getElementById("modalImage"); |
||||
img.style.transform = `rotate(${s.rotation || 0}deg)`; |
||||
img.style.filter = s.filter === "bw" ? "grayscale(1)" : (s.filter === "sepia" ? "sepia(1)" : "none"); |
||||
} |
||||
|
||||
function modalRotate(deg) { |
||||
state[currentId].rotation = deg; |
||||
applyModalPreview(); |
||||
render(); |
||||
} |
||||
|
||||
function modalFilter(f) { |
||||
state[currentId].filter = f; |
||||
applyModalPreview(); |
||||
render(); |
||||
} |
||||
|
||||
function revertCurrent() { |
||||
state[currentId] = { rotation: 0, filter: null, format: null, name: "" }; |
||||
document.getElementById("saveName").value = ""; |
||||
applyModalPreview(); |
||||
render(); |
||||
} |
||||
|
||||
async function saveCurrentImage() { |
||||
if (!currentId) return; |
||||
const name = document.getElementById("saveName").value.trim(); |
||||
if (!name) { |
||||
alert("Please enter a name for the new image."); |
||||
return; |
||||
} |
||||
|
||||
state[currentId].name = name; |
||||
state[currentId].format = document.getElementById("formatSelect").value || null; |
||||
state[currentId].max_size = document.getElementById("sizeSelect").value || null; |
||||
|
||||
const item = getItem(currentId); |
||||
const payload = { items: [item], state: { [currentId]: state[currentId] } }; |
||||
|
||||
const r = await fetch("/api/image/process", { |
||||
method: "POST", |
||||
headers: {"Content-Type": "application/json"}, |
||||
body: JSON.stringify(payload) |
||||
}); |
||||
|
||||
const d = await r.json(); |
||||
if (!d.ok) { |
||||
alert(d.error || "Processing failed"); |
||||
return; |
||||
} |
||||
|
||||
alert("Saved image."); |
||||
closeModal(); |
||||
} |
||||
|
||||
async function processAll() { |
||||
const changed = items.filter(item => { |
||||
const s = state[item.id]; |
||||
return s && (s.rotation || s.filter || s.format || s.name); |
||||
}); |
||||
|
||||
if (!changed.length) { |
||||
alert("No changed images to save."); |
||||
return; |
||||
} |
||||
|
||||
for (const item of changed) { |
||||
if (!state[item.id].name) { |
||||
alert("Each changed image needs a new name before saving."); |
||||
openModal(item.id); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
const r = await fetch("/api/image/process", { |
||||
method: "POST", |
||||
headers: {"Content-Type": "application/json"}, |
||||
body: JSON.stringify({ items: changed, state: state }) |
||||
}); |
||||
|
||||
const d = await r.json(); |
||||
if (!d.ok) { |
||||
alert(d.error || "Processing failed"); |
||||
return; |
||||
} |
||||
|
||||
alert("Saved " + d.processed.length + " image(s)."); |
||||
} |
||||
|
||||
document.getElementById("editorModal").addEventListener("click", function(e){ |
||||
if (e.target === this) closeModal(); |
||||
}); |
||||
|
||||
loadSelection(); |
||||
</script> |
||||
|
||||
{% endblock %} |
||||
|
||||
|
||||
@ -1,96 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,74 +0,0 @@
|
||||
{% 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 %} |
||||
@ -1,112 +0,0 @@
|
||||
<!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> |
||||
@ -1,170 +0,0 @@
|
||||
cd /opt/otb_cloud || exit 1 |
||||
|
||||
echo "===== backups =====" |
||||
STAMP=$(date +%Y%m%d-%H%M%S) |
||||
|
||||
cp app/models/schema.sql /home/def/backuphere/schema.sql.$STAMP.bak |
||||
cp app/auth/utils.py /home/def/backuphere/utils.py.$STAMP.bak |
||||
cp VERSION /home/def/backuphere/VERSION.$STAMP.bak 2>/dev/null || true |
||||
cp PROJECT_STATE.md /home/def/backuphere/PROJECT_STATE.md.$STAMP.bak 2>/dev/null || true |
||||
cp README.md /home/def/backuphere/README.md.$STAMP.bak 2>/dev/null || true |
||||
|
||||
echo "===== extend schema =====" |
||||
cat >> app/models/schema.sql <<'EOF' |
||||
|
||||
-- =============================== |
||||
-- v1.1.0-alpha1 VIDEO JOB SYSTEM |
||||
-- =============================== |
||||
|
||||
CREATE TABLE IF NOT EXISTS video_jobs ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
device_id INT NOT NULL, |
||||
source_file_id BIGINT NULL, |
||||
source_relative_path VARCHAR(1000) NOT NULL, |
||||
source_original_filename VARCHAR(255) NOT NULL, |
||||
requested_profile VARCHAR(50) NOT NULL, |
||||
requested_gpu_preference VARCHAR(20) NOT NULL DEFAULT 'auto', |
||||
assigned_processor VARCHAR(20) NULL, |
||||
status VARCHAR(50) NOT NULL DEFAULT 'queued', |
||||
progress_percent INT NOT NULL DEFAULT 0, |
||||
output_relative_path VARCHAR(1000) NULL, |
||||
output_file_id BIGINT NULL, |
||||
log_excerpt LONGTEXT NULL, |
||||
error_message LONGTEXT NULL, |
||||
gpu_seconds INT NOT NULL DEFAULT 0, |
||||
started_at DATETIME NULL, |
||||
completed_at DATETIME NULL, |
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
created_by_user_id INT NULL, |
||||
CONSTRAINT fk_video_jobs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), |
||||
CONSTRAINT fk_video_jobs_device FOREIGN KEY (device_id) REFERENCES devices(id) |
||||
); |
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant_usage_metrics ( |
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
tenant_id INT NOT NULL, |
||||
storage_bytes_originals BIGINT NOT NULL DEFAULT 0, |
||||
storage_bytes_video BIGINT NOT NULL DEFAULT 0, |
||||
storage_bytes_archive BIGINT NOT NULL DEFAULT 0, |
||||
storage_bytes_lts BIGINT NOT NULL DEFAULT 0, |
||||
storage_bytes_total BIGINT NOT NULL DEFAULT 0, |
||||
gpu_seconds_intel BIGINT NOT NULL DEFAULT 0, |
||||
gpu_seconds_amd BIGINT NOT NULL DEFAULT 0, |
||||
gpu_seconds_cpu BIGINT NOT NULL DEFAULT 0, |
||||
completed_jobs INT NOT NULL DEFAULT 0, |
||||
failed_jobs INT NOT NULL DEFAULT 0, |
||||
accrued_storage_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00, |
||||
accrued_gpu_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00, |
||||
calculated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
CONSTRAINT fk_metrics_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) |
||||
); |
||||
|
||||
EOF |
||||
|
||||
echo "===== update device directory structure =====" |
||||
python3 <<'PY' |
||||
from pathlib import Path |
||||
|
||||
p = Path("app/auth/utils.py") |
||||
txt = p.read_text() |
||||
|
||||
old = """for subdir in ["originals", "derived", "exports", "deleted", "tmp"]:""" |
||||
|
||||
new = """for subdir in ["originals", "video", "video-workshop", "archive", "lts", "derived", "exports", "deleted", "tmp"]:""" |
||||
|
||||
if old not in txt: |
||||
raise SystemExit("PATCH FAIL: device dir block not found") |
||||
|
||||
txt = txt.replace(old, new) |
||||
p.write_text(txt) |
||||
|
||||
print("device directory structure updated") |
||||
PY |
||||
|
||||
echo "===== create service scaffolding =====" |
||||
mkdir -p app/services |
||||
|
||||
cat > app/services/video_jobs.py <<'EOF' |
||||
def create_job(db, tenant_id, device_id, source_path, filename, profile): |
||||
return { |
||||
"tenant_id": tenant_id, |
||||
"device_id": device_id, |
||||
"source_path": source_path, |
||||
"filename": filename, |
||||
"profile": profile, |
||||
"status": "queued" |
||||
} |
||||
EOF |
||||
|
||||
cat > app/services/video_metrics.py <<'EOF' |
||||
def recalc_metrics(db, tenant_id): |
||||
# placeholder for v1.1.0 |
||||
return {"ok": True} |
||||
EOF |
||||
|
||||
cat > app/services/gpu_select.py <<'EOF' |
||||
def select_processor(): |
||||
# v1.1.0 logic placeholder |
||||
return "intel" |
||||
EOF |
||||
|
||||
cat > app/services/video_profiles.py <<'EOF' |
||||
PROFILES = { |
||||
"portrait_web": "portrait web encode", |
||||
"landscape_web": "landscape web encode", |
||||
"high_quality_cpu": "cpu encode", |
||||
"archive_only": "no processing" |
||||
} |
||||
EOF |
||||
|
||||
cat > app/services/video_paths.py <<'EOF' |
||||
def device_paths(base): |
||||
return { |
||||
"originals": f"{base}/originals", |
||||
"video": f"{base}/video", |
||||
"video_workshop": f"{base}/video-workshop", |
||||
"archive": f"{base}/archive", |
||||
"lts": f"{base}/lts" |
||||
} |
||||
EOF |
||||
|
||||
echo "===== create worker scaffold =====" |
||||
cat > app/services/video_worker.py <<'EOF' |
||||
import time |
||||
|
||||
def run_worker(): |
||||
print("video worker starting (stub)") |
||||
while True: |
||||
time.sleep(10) |
||||
EOF |
||||
|
||||
echo "===== bump version =====" |
||||
echo "v1.1.0-alpha1" > VERSION |
||||
|
||||
echo "===== update PROJECT_STATE.md =====" |
||||
cat >> PROJECT_STATE.md <<'EOF' |
||||
|
||||
## v1.1.0-alpha1 — Video System Foundation |
||||
- Added video_jobs table (processing queue) |
||||
- Added tenant_usage_metrics table (dashboard metrics) |
||||
- Added video service scaffolding (jobs, metrics, gpu select, profiles) |
||||
- Extended device structure to include: |
||||
- video |
||||
- video-workshop |
||||
- archive |
||||
- lts |
||||
- Prepared system for background worker architecture |
||||
|
||||
Next step: |
||||
- Build video worker processing engine |
||||
EOF |
||||
|
||||
echo "===== update README.md =====" |
||||
sed -i '1i\ |
||||
## v1.1.0-alpha1 — Video System Foundation\n- Introduced video job queue system\n- Introduced tenant usage metrics\n- Added video processing scaffolding\n- Prepared for GPU worker processing\n' README.md |
||||
|
||||
echo "===== verify =====" |
||||
python3 -m py_compile app/auth/utils.py |
||||
|
||||
echo "===== done =====" |
||||
@ -1,22 +0,0 @@
|
||||
cd /opt/otb_cloud || exit 1 |
||||
|
||||
echo "===== version =====" |
||||
cat VERSION |
||||
|
||||
echo |
||||
echo "===== new tables present in schema =====" |
||||
grep -n "CREATE TABLE IF NOT EXISTS video_jobs" -A25 app/models/schema.sql |
||||
echo |
||||
grep -n "CREATE TABLE IF NOT EXISTS tenant_usage_metrics" -A20 app/models/schema.sql |
||||
|
||||
echo |
||||
echo "===== new services =====" |
||||
find app/services -maxdepth 1 -type f | sort |
||||
|
||||
echo |
||||
echo "===== updated device dirs helper =====" |
||||
grep -n 'for subdir in \[' -A2 app/auth/utils.py |
||||
|
||||
echo |
||||
echo "===== git status =====" |
||||
git status --short |
||||
@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env python3 |
||||
import hashlib |
||||
import hmac |
||||
import os |
||||
import time |
||||
import urllib.parse |
||||
|
||||
from dotenv import load_dotenv |
||||
|
||||
load_dotenv() |
||||
|
||||
secret = os.getenv("OTB_PORTAL_SHARED_SECRET", "change-me") |
||||
uid = os.getenv("OTB_TEST_UID", "1001") |
||||
email = os.getenv("OTB_TEST_EMAIL", "client@example.com") |
||||
ts = str(int(time.time())) |
||||
|
||||
payload = f"{uid}|{email}|{ts}".encode("utf-8") |
||||
sig = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() |
||||
|
||||
params = urllib.parse.urlencode({ |
||||
"uid": uid, |
||||
"email": email, |
||||
"ts": ts, |
||||
"sig": sig, |
||||
}) |
||||
|
||||
print(f"http://127.0.0.1:5090/auth/handoff?{params}") |
||||
Loading…
Reference in new issue