Browse Source

Add first browser upload flow to device originals

master
Don Kingdon 3 weeks ago
parent
commit
ffc65ecebc
  1. 8
      PROJECT_STATE.md
  2. 8
      README.md
  3. 2
      VERSION
  4. 7
      app/auth/utils.py
  5. 140
      app/main/routes.py
  6. 21
      app/templates/cloud/dashboard.html
  7. 74
      app/templates/cloud/upload.html

8
PROJECT_STATE.md

@ -4,7 +4,7 @@
OTB Cloud OTB Cloud
## Current version ## Current version
v0.1.4 v0.2.0
## Build date ## Build date
2026-04-12 2026-04-12
@ -49,11 +49,12 @@ Portal-authenticated secure backup and storage platform for customer files, incl
- OTB Billing signed handoff working - OTB Billing signed handoff working
- Add Device flow - Add Device flow
- Remove Device flow for empty devices - Remove Device flow for empty devices
- Browser upload flow to device originals
## Immediate next tasks ## Immediate next tasks
1. Build first file library page 1. Build first file library page
2. Add upload endpoint and upload form 2. Add uploaded file listing per device
3. Add upload audit logging 3. Add upload audit log UI or admin reference
4. Add zip export flow 4. Add zip export flow
5. Add searchable file listing 5. Add searchable file listing
6. Add media processing jobs 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. Admin access should require owner-issued one-time support authorization.
New tenants no longer receive default devices automatically; devices are now user-created. 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. 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.

8
README.md

@ -1,5 +1,13 @@
# OTB Cloud # 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 ## v0.1.4 - 2026-04-12
- Added Remove Device flow - Added Remove Device flow
- Device removal is POST-only - Device removal is POST-only

2
VERSION

@ -1 +1 @@
v0.1.4 v0.2.0

7
app/auth/utils.py

@ -135,3 +135,10 @@ def remove_device_directories(tenant_slug: str, device_path: str):
base = tenant_root / device_path base = tenant_root / device_path
if base.exists() and base.is_dir(): if base.exists() and base.is_dir():
shutil.rmtree(base) 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()

140
app/main/routes.py

@ -1,9 +1,12 @@
from functools import wraps 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.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__) bp = Blueprint("main", __name__)
@ -15,6 +18,19 @@ def portal_session_required(view_func):
return view_func(*args, **kwargs) return view_func(*args, **kwargs)
return wrapped 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("/") @bp.route("/")
def index(): def index():
if "otb_user_id" in session: if "otb_user_id" in session:
@ -121,7 +137,7 @@ def add_device():
( (
session["otb_tenant_id"], session["otb_tenant_id"],
session["otb_user_id"], session["otb_user_id"],
request.headers.get("X-Forwarded-For", request.remote_addr), _client_ip(),
request.headers.get("User-Agent", ""), request.headers.get("User-Agent", ""),
f"Created device '{device_name}' ({device_type}) at {relative_path}", 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_tenant_id"],
session["otb_user_id"], session["otb_user_id"],
request.headers.get("X-Forwarded-For", request.remote_addr), _client_ip(),
request.headers.get("User-Agent", ""), request.headers.get("User-Agent", ""),
f"Deleted device '{device['device_name']}' ({device['device_type']}) at {device['relative_path']}", 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") flash("Device removed successfully.", "success")
return redirect(url_for("main.dashboard")) 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"))

21
app/templates/cloud/dashboard.html

@ -60,9 +60,12 @@
<strong>{{ device.device_name }}</strong> <strong>{{ device.device_name }}</strong>
<span style="opacity:0.85;">({{ device.device_type }})</span><br> <span style="opacity:0.85;">({{ device.device_type }})</span><br>
<span style="opacity:0.75;">{{ device.relative_path }}</span><br> <span style="opacity:0.75;">{{ device.relative_path }}</span><br>
<form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}" style="margin-top:10px;"> <div style="display:flex;gap:10px;flex-wrap:wrap;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> <a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a>
</form> <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> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -73,16 +76,16 @@
<div class="service-card-header"> <div class="service-card-header">
<div> <div>
<h2>Current scope</h2> <h2>Current scope</h2>
<p>OTB Cloud is now operating inside the branded OTB portal shell.</p> <p>OTB Cloud now supports browser uploads to device originals.</p>
</div> </div>
<div> <div>
<span class="service-badge service-badge-beta">In Progress</span> <span class="service-badge service-badge-beta">Live</span>
</div> </div>
</div> </div>
<div class="service-card-actions"> <div class="service-card-actions">
<p style="margin:0;"> <p style="margin:0;">
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.
</p> </p>
</div> </div>
</article> </article>
@ -109,16 +112,16 @@
<div class="service-card-header"> <div class="service-card-header">
<div> <div>
<h2>Current scope</h2> <h2>Current scope</h2>
<p>OTB Cloud is ready for user-created devices.</p> <p>OTB Cloud is ready for user-created devices and browser uploads.</p>
</div> </div>
<div> <div>
<span class="service-badge service-badge-beta">In Progress</span> <span class="service-badge service-badge-beta">Live</span>
</div> </div>
</div> </div>
<div class="service-card-actions"> <div class="service-card-actions">
<p style="margin:0;"> <p style="margin:0;">
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.
</p> </p>
</div> </div>
</article> </article>

74
app/templates/cloud/upload.html

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