diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 9539661..e7da184 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,7 +4,7 @@ OTB Cloud ## Current version -v0.1.4 +v0.2.0 ## Build date 2026-04-12 @@ -49,11 +49,12 @@ Portal-authenticated secure backup and storage platform for customer files, incl - OTB Billing signed handoff working - Add Device flow - Remove Device flow for empty devices +- Browser upload flow to device originals ## Immediate next tasks 1. Build first file library page -2. Add upload endpoint and upload form -3. Add upload audit logging +2. Add uploaded file listing per device +3. Add upload audit log UI or admin reference 4. Add zip export flow 5. Add searchable file listing 6. Add media processing jobs @@ -64,3 +65,4 @@ Any user-facing edits or processing outputs should create derivative files. Admin access should require owner-issued one-time support authorization. New tenants no longer receive default devices automatically; devices are now user-created. Devices can only be removed when no files are associated with them. +Browser uploads now write original files into device-specific originals directories and create DB records. diff --git a/README.md b/README.md index 4a92c56..4930ce0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # OTB Cloud +## v0.2.0 - 2026-04-12 +- Added first browser upload flow for user-created devices +- Added Upload Files action per device +- Added multi-file upload form +- Files now save into device `originals/` storage +- Uploads are recorded in MariaDB with SHA-256, size, and original filename +- Added upload audit logging + ## v0.1.4 - 2026-04-12 - Added Remove Device flow - Device removal is POST-only diff --git a/VERSION b/VERSION index 8c43fb4..1474d00 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.1.4 +v0.2.0 diff --git a/app/auth/utils.py b/app/auth/utils.py index cc6c6b5..9e8d265 100644 --- a/app/auth/utils.py +++ b/app/auth/utils.py @@ -135,3 +135,10 @@ def remove_device_directories(tenant_slug: str, device_path: str): base = tenant_root / device_path if base.exists() and base.is_dir(): shutil.rmtree(base) + +def compute_sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() diff --git a/app/main/routes.py b/app/main/routes.py index 9be4fcb..143c0fe 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -1,9 +1,12 @@ 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 +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 +from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256 bp = Blueprint("main", __name__) @@ -15,6 +18,19 @@ def portal_session_required(view_func): 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: @@ -121,7 +137,7 @@ def add_device(): ( session["otb_tenant_id"], session["otb_user_id"], - request.headers.get("X-Forwarded-For", request.remote_addr), + _client_ip(), request.headers.get("User-Agent", ""), f"Created device '{device_name}' ({device_type}) at {relative_path}", ), @@ -185,7 +201,7 @@ def delete_device(device_id: int): ( session["otb_tenant_id"], session["otb_user_id"], - request.headers.get("X-Forwarded-For", request.remote_addr), + _client_ip(), request.headers.get("User-Agent", ""), f"Deleted device '{device['device_name']}' ({device['device_type']}) at {device['relative_path']}", ), @@ -197,3 +213,119 @@ def delete_device(device_id: int): flash("Device removed successfully.", "success") return redirect(url_for("main.dashboard")) + +@bp.route("/devices//upload", methods=["GET", "POST"]) +@portal_session_required +def upload_files(device_id: int): + db = get_db() + + with db.cursor() as cur: + cur.execute( + """ + SELECT id, device_name, device_type, relative_path + FROM devices + WHERE id = %s AND tenant_id = %s + """, + (device_id, session["otb_tenant_id"]), + ) + device = cur.fetchone() + + if not device: + flash("Device not found.", "warning") + return redirect(url_for("main.dashboard")) + + if request.method == "GET": + return render_template( + "cloud/upload.html", + user_email=session.get("otb_email"), + tenant_slug=session.get("otb_tenant_slug"), + device=device, + ) + + files = request.files.getlist("files") + files = [f for f in files if f and f.filename] + + if not files: + flash("Please choose at least one file to upload.", "warning") + return render_template( + "cloud/upload.html", + user_email=session.get("otb_email"), + tenant_slug=session.get("otb_tenant_slug"), + device=device, + ) + + upload_base = _tenant_root() / device["relative_path"] / "originals" + upload_base.mkdir(parents=True, exist_ok=True) + + uploaded_count = 0 + + with db.cursor() as cur: + for incoming in files: + original_filename = incoming.filename or "upload.bin" + stored_name = _stored_name(original_filename) + target_path = upload_base / stored_name + + incoming.save(target_path) + + size_bytes = target_path.stat().st_size + sha256 = compute_sha256(target_path) + + base_name = original_filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] + if "." in base_name: + basename, extension = base_name.rsplit(".", 1) + else: + basename, extension = base_name, "" + + relative_path = f"{device['relative_path']}/originals/{stored_name}" + directory_path = f"{device['relative_path']}/originals" + + cur.execute( + """ + INSERT INTO files ( + tenant_id, device_id, parent_file_id, file_kind, relative_path, directory_path, + original_filename, basename, extension, mime_type, size_bytes, sha256, + capture_date, uploaded_at, is_immutable, is_deleted, deleted_at + ) VALUES ( + %s, %s, NULL, 'original', %s, %s, + %s, %s, %s, %s, %s, %s, + NULL, UTC_TIMESTAMP(), 1, 0, NULL + ) + """, + ( + session["otb_tenant_id"], + device["id"], + relative_path, + directory_path, + original_filename, + basename, + extension, + incoming.mimetype or None, + size_bytes, + sha256, + ), + ) + + file_id = cur.lastrowid + + cur.execute( + """ + INSERT INTO audit_logs ( + tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail + ) VALUES (%s, %s, 'user', 'file_uploaded', %s, %s, %s, %s) + """, + ( + session["otb_tenant_id"], + session["otb_user_id"], + file_id, + _client_ip(), + request.headers.get("User-Agent", ""), + f"Uploaded '{original_filename}' to {relative_path}", + ), + ) + + uploaded_count += 1 + + db.commit() + + flash(f"Uploaded {uploaded_count} file(s) to {device['device_name']}.", "success") + return redirect(url_for("main.dashboard")) diff --git a/app/templates/cloud/dashboard.html b/app/templates/cloud/dashboard.html index 37bdca9..a2bf9f5 100644 --- a/app/templates/cloud/dashboard.html +++ b/app/templates/cloud/dashboard.html @@ -60,9 +60,12 @@ {{ device.device_name }} ({{ device.device_type }})
{{ device.relative_path }}
-
- -
+
+ Upload Files +
+ +
+
{% endfor %} @@ -73,16 +76,16 @@

Current scope

-

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

+

OTB Cloud now supports browser uploads to device originals.

- In Progress + Live

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

@@ -109,16 +112,16 @@

Current scope

-

OTB Cloud is ready for user-created devices.

+

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

- In Progress + Live

- After adding a device, the next phase is browser upload, file library browsing, and export tools. + After adding a device, you can upload one or more files into that device’s originals storage.

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

Upload Files

+

{{ user_email }}

+

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

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

Upload to {{ device.device_name }}

+

Selected files will be stored as immutable originals.

+
+
+ {{ device.device_type|capitalize }} +
+
+ +
+
+
+
+ + +
+ +
+ + Cancel +
+ +
+ Files uploaded here are stored in the device originals folder and recorded in the database. +
+
+
+
+
+
+{% endblock %}