19 changed files with 556 additions and 30 deletions
@ -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 |
||||
|
||||
@ -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 |
||||
|
||||
@ -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")) |
||||
@ -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) |
||||
@ -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"] |
||||
@ -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) |
||||
@ -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, |
||||
) |
||||
|
||||
@ -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 %} |
||||
@ -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 %} |
||||
@ -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 %} |
||||
@ -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,3 +1,4 @@
|
||||
Flask==3.1.0 |
||||
PyMySQL==1.1.1 |
||||
python-dotenv==1.0.1 |
||||
cryptography==44.0.2 |
||||
|
||||
@ -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 =====" |
||||
@ -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}") |
||||
@ -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…
Reference in new issue