Browse Source

Replace default devices with user-created Add Device flow

master
Don Kingdon 3 weeks ago
parent
commit
20a0098b91
  1. 37
      PROJECT_STATE.md
  2. 47
      README.md
  3. 2
      VERSION
  4. 27
      app/auth/utils.py
  5. 92
      app/main/routes.py
  6. 75
      app/templates/cloud/dashboard.html
  7. 89
      app/templates/cloud/device_new.html

37
PROJECT_STATE.md

@ -4,7 +4,7 @@
OTB Cloud
## Current version
v0.1.2
v0.1.3
## Build date
2026-04-12
@ -25,7 +25,7 @@ Portal-authenticated secure backup and storage platform for customer files, incl
- MariaDB backend
- Vault3 storage root at `/tank/backups/otb-cloud`
- Tenant-isolated storage
- Device-defined source directories
- User-created devices
- Immutable originals
- Derived-file processing workflow
- Search by filename and date
@ -33,21 +33,6 @@ Portal-authenticated secure backup and storage platform for customer files, incl
- Audit logging
- Owner-approved admin support access using one-time token
## Device organization model
Per-tenant storage will be organized by named devices, for example:
- laptop
- phone
- tablet
- workpc
- homepc
Each device should have:
- originals/
- derived/
- exports/
- deleted/
- tmp/
## Current implemented scaffold
- Flask app factory
- Main blueprint
@ -55,23 +40,25 @@ Each device should have:
- MariaDB connection helper
- Signed handoff endpoint
- Auth-protected dashboard
- Temporary portal base template
- Branded OTB portal shell styling
- SQL schema file
- DB bootstrap script
- Storage bootstrap scripts
- Gunicorn systemd service on vault3
- Mintme reverse proxy in place
- OTB Billing signed handoff working
- Add Device flow
## Immediate next tasks
1. Patch OTB Billing to add OTB Cloud services card
2. Add signed handoff redirect route in OTB Billing
3. Replace temporary portal base with shared portal template structure
4. Build file library and upload endpoints
5. Add upload audit logging
6. Add first real storage browsing page
1. Build first file library page
2. Add upload endpoint and upload form
3. Add upload audit logging
4. Add zip export flow
5. Add searchable file listing
6. Add media processing jobs
## Notes
Original uploaded files should remain preserved and effectively read-only.
Any user-facing edits or processing outputs should create derivative files.
Admin access should require owner-issued one-time support authorization.
This version cleans up the temporary UI while keeping the same signed handoff endpoint for OTB Billing integration.
New tenants no longer receive default devices automatically; devices are now user-created.

47
README.md

@ -1,5 +1,11 @@
# OTB Cloud
## v0.1.3 - 2026-04-12
- Removed automatic default device creation for new tenants
- Added real Add Device flow
- Updated dashboard to show empty state when no devices exist
- Device directories are now created only when a user adds a device
## v0.1.2 - 2026-04-12
- Cleaned up unauthenticated OTB Cloud pages
- Hid Dashboard/Logout navigation when not authenticated
@ -32,49 +38,10 @@ OTB Cloud is a private portal-authenticated backup and storage platform for Outs
Primary goals:
- Secure backup and storage for documents, images, videos, and uploaded files
- Per-customer tenant isolation
- Device-based organization (laptop, phone, tablet, workpc, homepc, etc.)
- Device-based organization
- Immutable original uploads
- Derived file workflow for processing and edits
- Searchable file library
- Bulk upload and bulk export support
- Audit logging
- Owner-approved admin support access using one-time token workflow
## Planned host and path
- Host: vault3
- App path: `/opt/otb_cloud`
- Domain: `otb-cloud.outsidethebox.top`
- Storage root: `/tank/backups/otb-cloud`
## Planned backend stack
- Flask
- MariaDB
- Jinja templates with shared portal base
- Background job processing for media conversions
- FFmpeg for video/audio processing
- Nginx reverse proxy
## Security goals
- Portal-authenticated access only
- No unauthenticated file access
- Tenant-isolated storage and database access
- Audit logging for login attempts and file actions
- Encrypted storage at rest
- HTTPS/TLS in transit
- Immutable originals by default
## Device model
Each tenant may define logical upload devices such as:
- laptop
- phone
- tablet
- workpc
- homepc
Uploads are organized by source device to preserve context.
## Documentation policy
This repository uses:
- `README.md` for version/change log summary
- `PROJECT_STATE.md` for current status and working notes
- `VERSION` for current version

2
VERSION

@ -1 +1 @@
v0.1.2
v0.1.3

27
app/auth/utils.py

@ -32,6 +32,9 @@ def slugify_email(email: str) -> str:
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()
@ -98,25 +101,14 @@ def ensure_user_tenant_and_devices(email: str, portal_user_id: int):
(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"]
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,
@ -125,11 +117,14 @@ def ensure_user_tenant_and_devices(email: str, portal_user_id: int):
"email": email,
}
def create_tenant_directories(tenant_slug: str, device_names: list[str]):
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)
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)
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)

92
app/main/routes.py

@ -1,8 +1,9 @@
from functools import wraps
from flask import Blueprint, redirect, render_template, session, url_for
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__)
@ -43,3 +44,92 @@ def dashboard():
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"))

75
app/templates/cloud/dashboard.html

@ -14,7 +14,8 @@
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn primary" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<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>
@ -27,6 +28,19 @@
</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">
@ -40,19 +54,15 @@
</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 %}
<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>
@ -74,4 +84,41 @@
</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 %}

89
app/templates/cloud/device_new.html

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