Browse Source

Add MariaDB wiring and portal handoff scaffold v0.1.1

master
Don Kingdon 3 weeks ago
parent
commit
c475dd0951
  1. 6
      .env.example
  2. 35
      PROJECT_STATE.md
  3. 9
      README.md
  4. 2
      VERSION
  5. 20
      app/__init__.py
  6. 59
      app/auth/routes.py
  7. 135
      app/auth/utils.py
  8. 18
      app/core/config.py
  9. 25
      app/db.py
  10. 42
      app/main/routes.py
  11. 10
      app/templates/auth/handoff_error.html
  12. 16
      app/templates/auth/login_required.html
  13. 37
      app/templates/cloud/dashboard.html
  14. 112
      app/templates/portal_base.html
  15. 1
      requirements.txt
  16. 2
      run.py
  17. 21
      scripts/bootstrap_db.sh
  18. 23
      scripts/make_test_handoff.py
  19. 13
      scripts/setup_venv.sh

6
.env.example

@ -1,9 +1,15 @@
FLASK_APP=run.py FLASK_APP=run.py
FLASK_ENV=development FLASK_ENV=development
SECRET_KEY=change-me SECRET_KEY=change-me
APP_PORT=5090
MARIADB_HOST=127.0.0.1 MARIADB_HOST=127.0.0.1
MARIADB_PORT=3306 MARIADB_PORT=3306
MARIADB_DB=otb_cloud MARIADB_DB=otb_cloud
MARIADB_USER=otb_cloud MARIADB_USER=otb_cloud
MARIADB_PASSWORD=change-me MARIADB_PASSWORD=change-me
STORAGE_ROOT=/tank/backups/otb-cloud STORAGE_ROOT=/tank/backups/otb-cloud
OTB_PORTAL_SHARED_SECRET=change-me
OTB_PORTAL_ALLOWED_SKEW_SECONDS=300

35
PROJECT_STATE.md

@ -4,7 +4,7 @@
OTB Cloud OTB Cloud
## Current version ## Current version
v0.1.0 v0.1.1
## Build date ## Build date
2026-04-12 2026-04-12
@ -48,24 +48,29 @@ Each device should have:
- deleted/ - deleted/
- tmp/ - tmp/
## Initial app modules planned ## Current implemented scaffold
- auth - Flask app factory
- main - Main blueprint
- files - Auth blueprint
- jobs - MariaDB connection helper
- admin - Signed handoff placeholder route
- audit - Auth-protected dashboard
- services - Local temporary portal base template
- models - SQL schema file
- DB bootstrap script
- Storage bootstrap scripts
## Immediate next tasks ## Immediate next tasks
1. Add Flask app factory and blueprint registration 1. Create MariaDB database and otb_cloud DB user
2. Add MariaDB config and SQL bootstrap schema 2. Run schema bootstrap script
3. Add shared portal template integration 3. Install Python requirements into venv
4. Add storage bootstrap script for vault3 4. Start local Flask test run on 127.0.0.1:5090
5. Add service card integration plan for OTB Billing portal 5. Add real shared `portal_base.html` integration from OTB portal
6. Build file library and upload endpoints
7. Add OTB Billing service-card integration
## Notes ## Notes
Original uploaded files should remain preserved and effectively read-only. Original uploaded files should remain preserved and effectively read-only.
Any user-facing edits or processing outputs should create derivative files. 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.
The current auth handoff is a placeholder scaffold using a shared secret and HMAC signature.

9
README.md

@ -1,5 +1,14 @@
# OTB Cloud # OTB Cloud
## v0.1.1 - 2026-04-12
- Added app config module and MariaDB connection helper
- Added signed portal handoff placeholder routes
- Added authenticated dashboard route
- Added default tenant bootstrap logic
- Added local temporary `portal_base.html` so app renders now
- Added MariaDB bootstrap script
- Updated project docs for next implementation stage
## v0.1.0 - 2026-04-12 ## v0.1.0 - 2026-04-12
- Initial scaffold created on vault3 at /opt/otb_cloud - Initial scaffold created on vault3 at /opt/otb_cloud
- MariaDB-backed architecture selected - MariaDB-backed architecture selected

2
VERSION

@ -1 +1 @@
v0.1.0 v0.1.1

20
app/__init__.py

@ -1,23 +1,21 @@
import os
from flask import Flask
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import Flask
from .core.config import Config
from .db import init_app as init_db
def create_app(): def create_app():
load_dotenv() load_dotenv()
app = Flask(__name__, instance_relative_config=True) app = Flask(__name__, instance_relative_config=True)
app.config.from_object(Config)
app.config.from_mapping( init_db(app)
SECRET_KEY=os.getenv("SECRET_KEY", "change-me"),
MARIADB_HOST=os.getenv("MARIADB_HOST", "127.0.0.1"),
MARIADB_PORT=int(os.getenv("MARIADB_PORT", "3306")),
MARIADB_DB=os.getenv("MARIADB_DB", "otb_cloud"),
MARIADB_USER=os.getenv("MARIADB_USER", "otb_cloud"),
MARIADB_PASSWORD=os.getenv("MARIADB_PASSWORD", "change-me"),
STORAGE_ROOT=os.getenv("STORAGE_ROOT", "/tank/backups/otb-cloud"),
)
from .main.routes import bp as main_bp from .main.routes import bp as main_bp
from .auth.routes import bp as auth_bp
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
return app return app

59
app/auth/routes.py

@ -0,0 +1,59 @@
from flask import Blueprint, current_app, redirect, render_template, request, session, url_for
from app.db import get_db
from .utils import ensure_user_tenant_and_devices, is_valid_signature, is_valid_timestamp
bp = Blueprint("auth", __name__, url_prefix="/auth")
@bp.route("/login-required")
def login_required_notice():
return render_template("auth/login_required.html")
@bp.route("/handoff")
def handoff():
portal_user_id = request.args.get("uid", "").strip()
email = request.args.get("email", "").strip().lower()
ts = request.args.get("ts", "").strip()
sig = request.args.get("sig", "").strip()
if not portal_user_id or not email or not ts or not sig:
return render_template("auth/handoff_error.html", message="Missing handoff parameters."), 400
if not is_valid_timestamp(ts):
return render_template("auth/handoff_error.html", message="Handoff timestamp is invalid or expired."), 403
if not is_valid_signature(email=email, ts=ts, portal_user_id=portal_user_id, sig=sig):
return render_template("auth/handoff_error.html", message="Invalid handoff signature."), 403
identity = ensure_user_tenant_and_devices(email=email, portal_user_id=int(portal_user_id))
session.clear()
session["otb_user_id"] = identity["user_id"]
session["otb_tenant_id"] = identity["tenant_id"]
session["otb_tenant_slug"] = identity["tenant_slug"]
session["otb_email"] = identity["email"]
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
INSERT INTO audit_logs (
tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail
) VALUES (%s, %s, 'user', 'handoff_login_success', %s, %s, %s)
""",
(
identity["tenant_id"],
identity["user_id"],
request.headers.get("X-Forwarded-For", request.remote_addr),
request.headers.get("User-Agent", ""),
f"Portal handoff accepted for {email}",
),
)
db.commit()
return redirect(url_for("main.dashboard"))
@bp.route("/logout")
def logout():
session.clear()
return redirect(url_for("auth.login_required_notice"))

135
app/auth/utils.py

@ -0,0 +1,135 @@
import hashlib
import hmac
import re
import time
from pathlib import Path
from flask import current_app
from app.db import get_db
SLUG_RE = re.compile(r"[^a-z0-9]+")
def make_signature(email: str, ts: str, portal_user_id: str, secret: str) -> str:
payload = f"{portal_user_id}|{email}|{ts}".encode("utf-8")
return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
def is_valid_signature(email: str, ts: str, portal_user_id: str, sig: str) -> bool:
secret = current_app.config["OTB_PORTAL_SHARED_SECRET"]
expected = make_signature(email=email, ts=ts, portal_user_id=portal_user_id, secret=secret)
return hmac.compare_digest(expected, sig)
def is_valid_timestamp(ts: str) -> bool:
try:
ts_int = int(ts)
except ValueError:
return False
skew = current_app.config["OTB_PORTAL_ALLOWED_SKEW_SECONDS"]
return abs(int(time.time()) - ts_int) <= skew
def slugify_email(email: str) -> str:
local = email.split("@", 1)[0].lower().strip()
slug = SLUG_RE.sub("-", local).strip("-")
return slug or "tenant"
def ensure_user_tenant_and_devices(email: str, portal_user_id: int):
db = get_db()
with db.cursor() as cur:
cur.execute(
"""
SELECT id, portal_user_id, email, display_name
FROM users
WHERE email = %s
""",
(email,),
)
user = cur.fetchone()
if user is None:
cur.execute(
"""
INSERT INTO users (portal_user_id, email, display_name, is_active)
VALUES (%s, %s, %s, 1)
""",
(portal_user_id, email, email),
)
user_id = cur.lastrowid
else:
user_id = user["id"]
cur.execute(
"""
UPDATE users
SET portal_user_id = %s, last_login_at = NOW()
WHERE id = %s
""",
(portal_user_id, user_id),
)
cur.execute(
"""
SELECT id, slug, storage_root
FROM tenants
WHERE owner_user_id = %s
""",
(user_id,),
)
tenant = cur.fetchone()
if tenant is None:
base_slug = slugify_email(email)
slug = base_slug
suffix = 1
while True:
cur.execute("SELECT id FROM tenants WHERE slug = %s", (slug,))
existing = cur.fetchone()
if existing is None:
break
suffix += 1
slug = f"{base_slug}-{suffix}"
storage_root = f"{current_app.config['STORAGE_ROOT']}/tenants/{slug}"
cur.execute(
"""
INSERT INTO tenants (owner_user_id, slug, storage_root, service_status, retention_mode)
VALUES (%s, %s, %s, 'active', 'standard')
""",
(user_id, slug, storage_root),
)
tenant_id = cur.lastrowid
else:
tenant_id = tenant["id"]
slug = tenant["slug"]
storage_root = tenant["storage_root"]
for device_name in current_app.config["DEFAULT_DEVICE_NAMES"]:
relative_path = f"devices/{device_name}"
cur.execute(
"""
INSERT IGNORE INTO devices (tenant_id, device_name, device_type, relative_path, is_active)
VALUES (%s, %s, %s, %s, 1)
""",
(tenant_id, device_name, device_name, relative_path),
)
db.commit()
create_tenant_directories(slug, current_app.config["DEFAULT_DEVICE_NAMES"])
return {
"user_id": user_id,
"tenant_id": tenant_id,
"tenant_slug": slug,
"storage_root": storage_root,
"email": email,
}
def create_tenant_directories(tenant_slug: str, device_names: list[str]):
tenant_root = Path(current_app.config["STORAGE_ROOT"]) / "tenants" / tenant_slug
(tenant_root / "logs").mkdir(parents=True, exist_ok=True)
(tenant_root / "support").mkdir(parents=True, exist_ok=True)
for device_name in device_names:
for subdir in ["originals", "derived", "exports", "deleted", "tmp"]:
(tenant_root / "devices" / device_name / subdir).mkdir(parents=True, exist_ok=True)

18
app/core/config.py

@ -0,0 +1,18 @@
import os
class Config:
SECRET_KEY = os.getenv("SECRET_KEY", "change-me")
APP_PORT = int(os.getenv("APP_PORT", "5090"))
MARIADB_HOST = os.getenv("MARIADB_HOST", "127.0.0.1")
MARIADB_PORT = int(os.getenv("MARIADB_PORT", "3306"))
MARIADB_DB = os.getenv("MARIADB_DB", "otb_cloud")
MARIADB_USER = os.getenv("MARIADB_USER", "otb_cloud")
MARIADB_PASSWORD = os.getenv("MARIADB_PASSWORD", "change-me")
STORAGE_ROOT = os.getenv("STORAGE_ROOT", "/tank/backups/otb-cloud")
OTB_PORTAL_SHARED_SECRET = os.getenv("OTB_PORTAL_SHARED_SECRET", "change-me")
OTB_PORTAL_ALLOWED_SKEW_SECONDS = int(os.getenv("OTB_PORTAL_ALLOWED_SKEW_SECONDS", "300"))
DEFAULT_DEVICE_NAMES = ["laptop", "phone", "tablet", "workpc", "homepc"]

25
app/db.py

@ -0,0 +1,25 @@
from flask import current_app, g
import pymysql
from pymysql.cursors import DictCursor
def get_db():
if "db" not in g:
g.db = pymysql.connect(
host=current_app.config["MARIADB_HOST"],
port=current_app.config["MARIADB_PORT"],
user=current_app.config["MARIADB_USER"],
password=current_app.config["MARIADB_PASSWORD"],
database=current_app.config["MARIADB_DB"],
cursorclass=DictCursor,
autocommit=False,
charset="utf8mb4",
)
return g.db
def close_db(_e=None):
db = g.pop("db", None)
if db is not None:
db.close()
def init_app(app):
app.teardown_appcontext(close_db)

42
app/main/routes.py

@ -1,7 +1,45 @@
from flask import Blueprint, render_template from functools import wraps
from flask import Blueprint, redirect, render_template, session, url_for
from app.db import get_db
bp = Blueprint("main", __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("/") @bp.route("/")
def index(): def index():
return render_template("cloud/index.html") 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,
)

10
app/templates/auth/handoff_error.html

@ -0,0 +1,10 @@
{% extends "portal_base.html" %}
{% block title %}Handoff Error{% endblock %}
{% block content %}
<div class="card">
<h1>Portal handoff failed</h1>
<p class="muted">{{ message }}</p>
</div>
{% endblock %}

16
app/templates/auth/login_required.html

@ -0,0 +1,16 @@
{% extends "portal_base.html" %}
{% block title %}Portal Login Required{% endblock %}
{% block content %}
<div class="card">
<h1>Portal login required</h1>
<p class="muted">
OTB Cloud does not allow direct unauthenticated access.
This app is intended to be reached through the OTB Billing portal handoff.
</p>
<div class="warn" style="margin-top:16px;">
Current status: direct local scaffold only. Real portal handoff wiring is next.
</div>
</div>
{% endblock %}

37
app/templates/cloud/dashboard.html

@ -0,0 +1,37 @@
{% extends "portal_base.html" %}
{% block title %}OTB Cloud Dashboard{% endblock %}
{% block content %}
<div class="card" style="margin-bottom:16px;">
<h1 style="margin-top:0;">OTB Cloud Dashboard</h1>
<p class="muted" style="margin-bottom:10px;">Authenticated user: {{ user_email }}</p>
<p class="muted" style="margin:0;">Tenant slug: <span class="badge">{{ tenant_slug }}</span></p>
</div>
<div class="grid">
<div class="card">
<h2 style="margin-top:0;">Devices</h2>
{% if devices %}
<ul style="padding-left:18px; margin-bottom:0;">
{% for device in devices %}
<li style="margin-bottom:8px;">
<strong>{{ device.device_name }}</strong>
<span class="muted">({{ device.device_type }})</span><br>
<span class="muted">{{ device.relative_path }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No devices have been created yet.</p>
{% endif %}
</div>
<div class="card">
<h2 style="margin-top:0;">Current scope</h2>
<p class="muted">
v0.1.1 provides portal-handoff scaffolding, tenant bootstrap, device records, and an authenticated dashboard.
</p>
</div>
</div>
{% endblock %}

112
app/templates/portal_base.html

@ -0,0 +1,112 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}OTB Cloud{% endblock %}</title>
<style>
:root {
--bg: #0b1220;
--panel: #121b2d;
--panel-2: #16233b;
--text: #e9eef8;
--muted: #9fb0cf;
--line: rgba(255,255,255,0.10);
--accent: #4da3ff;
--danger: #ff6b6b;
--ok: #4fd18b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, sans-serif;
background: linear-gradient(180deg, #08101d 0%, #0b1220 100%);
color: var(--text);
}
a { color: var(--accent); text-decoration: none; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 20px; }
.topbar {
border-bottom: 1px solid var(--line);
background: rgba(255,255,255,0.03);
}
.nav {
display: flex;
gap: 18px;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
max-width: 1200px;
margin: 0 auto;
}
.brand {
font-weight: bold;
font-size: 20px;
letter-spacing: 0.3px;
}
.nav-links {
display: flex;
gap: 14px;
flex-wrap: wrap;
align-items: center;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
padding: 20px;
box-shadow: 0 10px 24px rgba(0,0,0,0.18);
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.muted { color: var(--muted); }
.badge {
display: inline-block;
padding: 6px 10px;
border-radius: 999px;
background: rgba(77,163,255,0.12);
border: 1px solid rgba(77,163,255,0.22);
color: var(--text);
font-size: 12px;
}
.footer {
border-top: 1px solid var(--line);
margin-top: 28px;
color: var(--muted);
}
.footer .wrap {
padding-top: 16px;
padding-bottom: 24px;
}
.warn {
border-left: 4px solid var(--danger);
background: rgba(255,107,107,0.08);
padding: 14px 16px;
border-radius: 12px;
}
</style>
</head>
<body>
<div class="topbar">
<div class="nav">
<div class="brand">OTB Cloud</div>
<div class="nav-links">
<a href="/dashboard">Dashboard</a>
<a href="/auth/logout">Logout</a>
</div>
</div>
</div>
<div class="wrap">
{% block content %}{% endblock %}
</div>
<div class="footer">
<div class="wrap">
Temporary local portal base template for OTB Cloud v0.1.1. Replace this with the shared OTB portal base during integration.
</div>
</div>
</body>
</html>

1
requirements.txt

@ -1,3 +1,4 @@
Flask==3.1.0 Flask==3.1.0
PyMySQL==1.1.1 PyMySQL==1.1.1
python-dotenv==1.0.1 python-dotenv==1.0.1
cryptography==44.0.2

2
run.py

@ -3,4 +3,4 @@ from app import create_app
app = create_app() app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="127.0.0.1", port=5090, debug=True) app.run(host="127.0.0.1", port=app.config["APP_PORT"], debug=True)

21
scripts/bootstrap_db.sh

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -e
cd /opt/otb_cloud || exit 1
echo "===== creating MariaDB database and user ====="
echo "You will be prompted for sudo password because this uses sudo mysql."
sudo mysql <<'SQL'
CREATE DATABASE IF NOT EXISTS otb_cloud CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'otb_cloud'@'localhost' IDENTIFIED BY 'change-me';
GRANT ALL PRIVILEGES ON otb_cloud.* TO 'otb_cloud'@'localhost';
FLUSH PRIVILEGES;
SQL
echo "===== importing schema ====="
mysql -uotb_cloud -pchange-me otb_cloud < app/models/schema.sql
echo "===== database bootstrap completed ====="
echo "===== IMPORTANT: change the MariaDB password in both MariaDB and .env before production ====="
echo "===== script completed successfully ====="

23
scripts/make_test_handoff.py

@ -0,0 +1,23 @@
#!/usr/bin/env python3
import hashlib
import hmac
import os
import time
import urllib.parse
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}")

13
scripts/setup_venv.sh

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e
cd /opt/otb_cloud || exit 1
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
echo "===== venv ready ====="
echo "===== activate with: source /opt/otb_cloud/venv/bin/activate ====="
echo "===== script completed successfully ====="
Loading…
Cancel
Save