diff --git a/.env.example b/.env.example index fcdc621..4bbb364 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,15 @@ FLASK_APP=run.py FLASK_ENV=development SECRET_KEY=change-me +APP_PORT=5090 + MARIADB_HOST=127.0.0.1 MARIADB_PORT=3306 MARIADB_DB=otb_cloud MARIADB_USER=otb_cloud MARIADB_PASSWORD=change-me + STORAGE_ROOT=/tank/backups/otb-cloud + +OTB_PORTAL_SHARED_SECRET=change-me +OTB_PORTAL_ALLOWED_SKEW_SECONDS=300 diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 6312c7b..1a89815 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,7 +4,7 @@ OTB Cloud ## Current version -v0.1.0 +v0.1.1 ## Build date 2026-04-12 @@ -48,24 +48,29 @@ Each device should have: - deleted/ - tmp/ -## Initial app modules planned -- auth -- main -- files -- jobs -- admin -- audit -- services -- models +## Current implemented scaffold +- Flask app factory +- Main blueprint +- Auth blueprint +- MariaDB connection helper +- Signed handoff placeholder route +- Auth-protected dashboard +- Local temporary portal base template +- SQL schema file +- DB bootstrap script +- Storage bootstrap scripts ## Immediate next tasks -1. Add Flask app factory and blueprint registration -2. Add MariaDB config and SQL bootstrap schema -3. Add shared portal template integration -4. Add storage bootstrap script for vault3 -5. Add service card integration plan for OTB Billing portal +1. Create MariaDB database and otb_cloud DB user +2. Run schema bootstrap script +3. Install Python requirements into venv +4. Start local Flask test run on 127.0.0.1:5090 +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 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. +The current auth handoff is a placeholder scaffold using a shared secret and HMAC signature. diff --git a/README.md b/README.md index a99155b..9e58464 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # 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 - Initial scaffold created on vault3 at /opt/otb_cloud - MariaDB-backed architecture selected diff --git a/VERSION b/VERSION index b82608c..8308b63 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.1.0 +v0.1.1 diff --git a/app/__init__.py b/app/__init__.py index fe2d8a1..6ecbbcb 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,23 +1,21 @@ -import os -from flask import Flask 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(): load_dotenv() app = Flask(__name__, instance_relative_config=True) + app.config.from_object(Config) - app.config.from_mapping( - 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"), - ) + init_db(app) from .main.routes import bp as main_bp + from .auth.routes import bp as auth_bp + app.register_blueprint(main_bp) + app.register_blueprint(auth_bp) return app diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..10514e0 --- /dev/null +++ b/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")) diff --git a/app/auth/utils.py b/app/auth/utils.py new file mode 100644 index 0000000..29957f7 --- /dev/null +++ b/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) diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..54e90e7 --- /dev/null +++ b/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"] diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..6e5dc78 --- /dev/null +++ b/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) diff --git a/app/main/routes.py b/app/main/routes.py index ae99655..38569a0 100644 --- a/app/main/routes.py +++ b/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__) +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(): - 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, + ) diff --git a/app/templates/auth/handoff_error.html b/app/templates/auth/handoff_error.html new file mode 100644 index 0000000..4951fed --- /dev/null +++ b/app/templates/auth/handoff_error.html @@ -0,0 +1,10 @@ +{% extends "portal_base.html" %} + +{% block title %}Handoff Error{% endblock %} + +{% block content %} +
+

Portal handoff failed

+

{{ message }}

+
+{% endblock %} diff --git a/app/templates/auth/login_required.html b/app/templates/auth/login_required.html new file mode 100644 index 0000000..2b23c6f --- /dev/null +++ b/app/templates/auth/login_required.html @@ -0,0 +1,16 @@ +{% extends "portal_base.html" %} + +{% block title %}Portal Login Required{% endblock %} + +{% block content %} +
+

Portal login required

+

+ OTB Cloud does not allow direct unauthenticated access. + This app is intended to be reached through the OTB Billing portal handoff. +

+
+ Current status: direct local scaffold only. Real portal handoff wiring is next. +
+
+{% endblock %} diff --git a/app/templates/cloud/dashboard.html b/app/templates/cloud/dashboard.html new file mode 100644 index 0000000..ce18930 --- /dev/null +++ b/app/templates/cloud/dashboard.html @@ -0,0 +1,37 @@ +{% extends "portal_base.html" %} + +{% block title %}OTB Cloud Dashboard{% endblock %} + +{% block content %} +
+

OTB Cloud Dashboard

+

Authenticated user: {{ user_email }}

+

Tenant slug: {{ tenant_slug }}

+
+ +
+
+

Devices

+ {% if devices %} + + {% else %} +

No devices have been created yet.

+ {% endif %} +
+ +
+

Current scope

+

+ v0.1.1 provides portal-handoff scaffolding, tenant bootstrap, device records, and an authenticated dashboard. +

+
+
+{% endblock %} diff --git a/app/templates/portal_base.html b/app/templates/portal_base.html new file mode 100644 index 0000000..34e7c01 --- /dev/null +++ b/app/templates/portal_base.html @@ -0,0 +1,112 @@ + + + + + + {% block title %}OTB Cloud{% endblock %} + + + +
+ +
+ +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/requirements.txt b/requirements.txt index 5b819a4..85bb3f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Flask==3.1.0 PyMySQL==1.1.1 python-dotenv==1.0.1 +cryptography==44.0.2 diff --git a/run.py b/run.py index 953b7aa..562e815 100644 --- a/run.py +++ b/run.py @@ -3,4 +3,4 @@ from app import create_app app = create_app() 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) diff --git a/scripts/bootstrap_db.sh b/scripts/bootstrap_db.sh new file mode 100755 index 0000000..ea8d866 --- /dev/null +++ b/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 =====" diff --git a/scripts/make_test_handoff.py b/scripts/make_test_handoff.py new file mode 100755 index 0000000..5cd8108 --- /dev/null +++ b/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}") diff --git a/scripts/setup_venv.sh b/scripts/setup_venv.sh new file mode 100755 index 0000000..9f80238 --- /dev/null +++ b/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 ====="