From d60bf15ce4d13a92c5ee249bc4d786bf7bec2a7f Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Wed, 10 Jun 2026 14:23:47 -0400 Subject: [PATCH] Development --- docker/routlin-dash/app/config_utils.py | 105 +++++++++++++-- docker/routlin-dash/app/factory.py | 2 +- docker/routlin-dash/app/main.py | 38 +++--- .../app/pages/accountcreate/action.py | 61 +++++---- .../app/pages/accountlogin/action.py | 26 +--- .../app/pages/accountmanage/action.py | 114 ++++++++--------- .../app/pages/accountmanage/content.json | 2 +- .../app/pages/accountmanage/view.py | 47 ++++--- .../app/pages/accountverifyemail/action.py | 121 +++++++++--------- .../app/pages/preferences/action.py | 58 ++++----- .../app/pages/preferences/content.json | 1 + .../app/pages/preferences/view.py | 2 +- docker/routlin-dash/app/session_interface.py | 68 +++++----- docker/routlin-dash/app/settings.py | 6 + docker/routlin-dash/docker-compose.yml | 1 + 15 files changed, 367 insertions(+), 285 deletions(-) diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 2784bd4..119954f 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -1,13 +1,14 @@ import copy, json, subprocess, hashlib, os, uuid import os as _os +import sqlite3 as _sqlite3 from datetime import datetime, timezone from flask import session APP_DIR = _os.path.dirname(_os.path.abspath(__file__)) CONFIGS_DIR = '/routlin_location' +DATA_DIR = '/data' WWW_DIR = '/www' -ACCOUNTS_FILE = f'{CONFIGS_DIR}/.dashboard-accounts' -SESSIONS_DB = f'{CONFIGS_DIR}/.dashboard-sessions' +ACCOUNTS_DB = f'{DATA_DIR}/.dashboard-accounts' CONFIG_FILE = f'{CONFIGS_DIR}/config.json' DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue' DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done' @@ -28,6 +29,93 @@ DASHB_INTERVAL_SECS = 30 QUEUE_MAX_LINES = 50 +# Accounts DB ======================================================== + +def open_accounts_db(): + con = _sqlite3.connect(ACCOUNTS_DB, timeout=5) + con.execute('PRAGMA journal_mode=WAL') + con.row_factory = _sqlite3.Row + return con + +def init_accounts_db(): + con = open_accounts_db() + con.executescript(''' + CREATE TABLE IF NOT EXISTS accounts ( + account_id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + access_level INTEGER NOT NULL DEFAULT 1, + hashed_password TEXT NOT NULL DEFAULT '', + created_ts INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + tz_offset_seconds INTEGER NOT NULL DEFAULT 0, + apply_changes_immediately INTEGER NOT NULL DEFAULT 0, + session_started_ts INTEGER NOT NULL, + last_seen_ts INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS pending_verifications ( + email TEXT PRIMARY KEY, + hashed_password TEXT NOT NULL, + tz_offset_seconds INTEGER NOT NULL DEFAULT 0, + code TEXT NOT NULL, + expires_ts INTEGER NOT NULL + ); + ''') + con.commit() + con.close() + +_LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'} +_LEVEL_STR_TO_INT = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3} + +def _account_row_to_dict(row): + if row is None: + return None + import time as _t + from datetime import datetime as _dt + d = dict(row) + d['email_address'] = d.pop('email', d.get('email_address', '')) + d['access_level_int'] = d.get('access_level', 1) + d['access_level'] = _LEVEL_INT_TO_STR.get(d['access_level_int'], 'viewer') + d['account_status'] = 'active' if d.get('hashed_password') else 'pending' + d['account_created_by'] = d.get('created_by', '') + ts = d.get('created_ts', 0) + try: + d['account_created_ts'] = _dt.fromtimestamp(int(ts)).strftime('%Y-%m-%d %H:%M') if ts else '-' + except Exception: + d['account_created_ts'] = '-' + return d + +def get_account_by_email(email): + try: + con = open_accounts_db() + row = con.execute('SELECT * FROM accounts WHERE lower(email)=?', (email.lower(),)).fetchone() + con.close() + return _account_row_to_dict(row) + except Exception: + return None + +def get_account_by_id(account_id): + try: + con = open_accounts_db() + row = con.execute('SELECT * FROM accounts WHERE account_id=?', (account_id,)).fetchone() + con.close() + return _account_row_to_dict(row) + except Exception: + return None + +def list_accounts(): + try: + con = open_accounts_db() + rows = con.execute('SELECT * FROM accounts ORDER BY created_ts').fetchall() + con.close() + return [_account_row_to_dict(r) for r in rows] + except Exception: + return [] + + _config_cache = None _config_mtime = None @@ -354,7 +442,6 @@ def queued_msg(cmd=None, description='', action_label='Configuration saved'): # Snapshot system =================================================== import re as _re -import sqlite3 as _sqlite3 def _db(): @@ -845,17 +932,7 @@ def config_datasource(name): return rows if name == 'accounts': - try: - with open(ACCOUNTS_FILE) as f: - data = json.load(f) - except Exception: - data = {} - rows = [] - for acct in data.get('accounts', []): - row = dict(acct) - row['account_status'] = 'active' if acct.get('hashed_password') else 'pending' - rows.append(row) - return rows + return list_accounts() if name == 'vpn_peers': rows = [] diff --git a/docker/routlin-dash/app/factory.py b/docker/routlin-dash/app/factory.py index 56f52e5..d46f455 100644 --- a/docker/routlin-dash/app/factory.py +++ b/docker/routlin-dash/app/factory.py @@ -65,7 +65,7 @@ def load_ddns(): return config_utils.load_config().get('ddns', {}) def load_accounts(): - return load_json(config_utils.ACCOUNTS_FILE) + return {'accounts': config_utils.list_accounts()} def run(cmd): try: diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 9d44208..773caff 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -33,7 +33,8 @@ from session_interface import SqliteSessionInterface app = Flask(__name__) app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24)) -app.session_interface = SqliteSessionInterface(config_utils.SESSIONS_DB) +app.session_interface = SqliteSessionInterface(config_utils.ACCOUNTS_DB) +config_utils.init_accounts_db() # Static www/ serving ================================================= @@ -151,33 +152,26 @@ app.register_blueprint(api_apply_health_bp) def _seed_initial_account(): + import uuid as _uuid, time as _t email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower() if not email: - try: - with open(config_utils.ACCOUNTS_FILE) as f: - data = json.load(f) - except Exception: - data = {'accounts': []} - if not data.get('accounts'): + if not config_utils.list_accounts(): print('[main] WARNING: No accounts exist and INITIAL_MANAGER_EMAIL is not set. ' 'Set it in docker-compose.yml to seed the initial manager account.', file=sys.stderr) return - try: - with open(config_utils.ACCOUNTS_FILE) as f: - data = json.load(f) - except Exception: - data = {'accounts': []} - if data.get('accounts'): + if config_utils.list_accounts(): return - data['accounts'] = [{ - 'email_address': email, - 'access_level': 'manager', - 'hashed_password': '', - 'timezone': '', - }] - with open(config_utils.ACCOUNTS_FILE, 'w') as f: - json.dump(data, f, indent=2) - print(f'[main] Seeded initial manager account: {email}', file=sys.stderr) + try: + con = config_utils.open_accounts_db() + con.execute( + 'INSERT INTO accounts(account_id,email,access_level,created_ts,created_by) VALUES(?,?,?,?,?)', + (str(_uuid.uuid4()), email, 3, int(_t.time()), 'system') + ) + con.commit() + con.close() + print(f'[main] Seeded initial manager account: {email}', file=sys.stderr) + except Exception as exc: + print(f'[main] WARNING: Could not seed initial account: {exc}', file=sys.stderr) _seed_initial_account() diff --git a/docker/routlin-dash/app/pages/accountcreate/action.py b/docker/routlin-dash/app/pages/accountcreate/action.py index 6b80c4a..0f496ce 100644 --- a/docker/routlin-dash/app/pages/accountcreate/action.py +++ b/docker/routlin-dash/app/pages/accountcreate/action.py @@ -1,7 +1,7 @@ from pathlib import Path from flask import Blueprint, request, session, redirect, flash -import json, os, bcrypt, secrets, smtplib -from datetime import datetime, timezone, timedelta +import os, bcrypt, secrets, smtplib +import time from email.message import EmailMessage import auth import config_utils @@ -11,15 +11,7 @@ _PAGE = Path(__file__).parent.name bp = Blueprint(_PAGE, __name__) -CODE_TTL_MIN = 15 - - -def _load_accounts(): - try: - with open(config_utils.ACCOUNTS_FILE) as f: - return json.load(f) - except Exception: - return {'accounts': []} +CODE_TTL_SECS = 15 * 60 def _send_verification_email(to_address, code): @@ -38,7 +30,7 @@ def _send_verification_email(to_address, code): msg['To'] = to_address msg.set_content( f'Your verification code is: {code}\n\n' - f'This code expires in {CODE_TTL_MIN} minutes.\n\n' + f'This code expires in 15 minutes.\n\n' f'If you did not request this, you can ignore this email.' ) @@ -51,10 +43,19 @@ def _send_verification_email(to_address, code): smtp.send_message(msg) +def _tz_to_offset_seconds(tz_str): + try: + from zoneinfo import ZoneInfo + from datetime import datetime + return int(datetime.now(ZoneInfo(tz_str)).utcoffset().total_seconds()) + except Exception: + import settings as _s + return _s.get_host_utc_offset() + + @bp.route('/action/accountcreate/form_create', methods=['POST']) @auth.require_level('nothing') def form_create(): - # Abort if already logged in if session.get('access_level', 'nothing') != 'nothing': return redirect('/overview') @@ -75,8 +76,7 @@ def form_create(): flash('Password must be at least 8 characters.', 'error') return redirect(f'/{_PAGE}') - accounts = _load_accounts().get('accounts', []) - account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) + account = config_utils.get_account_by_email(email) if account is None: flash('Email address not recognised. Contact your manager.', 'error') @@ -86,10 +86,11 @@ def form_create(): flash('This account is already set up. Please log in instead.', 'error') return redirect(f'/{_PAGE}') - salt = bcrypt.gensalt() - hashed = bcrypt.hashpw(password.encode('utf-8'), salt) - code = f'{secrets.randbelow(1000000):06d}' - expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat() + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') + code = f'{secrets.randbelow(1000000):06d}' + expires_ts = int(time.time()) + CODE_TTL_SECS + tz_offset = _tz_to_offset_seconds(tz) try: _send_verification_email(account['email_address'], code) @@ -97,12 +98,20 @@ def form_create(): flash(f'Could not send verification email: {exc}', 'error') return redirect(f'/{_PAGE}') - session['pending_create_account'] = { - 'email': account['email_address'], - 'hashed_password': hashed.decode('utf-8'), - 'timezone': tz, - 'code': code, - 'expires': expires, - } + try: + con = config_utils.open_accounts_db() + con.execute( + '''INSERT OR REPLACE INTO pending_verifications + (email, hashed_password, tz_offset_seconds, code, expires_ts) + VALUES (?,?,?,?,?)''', + (account['email_address'].lower(), hashed, tz_offset, code, expires_ts) + ) + con.commit() + con.close() + except Exception as exc: + flash(f'Could not store verification: {exc}', 'error') + return redirect(f'/{_PAGE}') + + session['pending_verify_email'] = account['email_address'] return redirect('/accountverifyemail') diff --git a/docker/routlin-dash/app/pages/accountlogin/action.py b/docker/routlin-dash/app/pages/accountlogin/action.py index 313378a..ac4ddeb 100644 --- a/docker/routlin-dash/app/pages/accountlogin/action.py +++ b/docker/routlin-dash/app/pages/accountlogin/action.py @@ -1,28 +1,19 @@ from pathlib import Path from flask import Blueprint, request, session, redirect, flash -import json, bcrypt +import bcrypt import auth import config_utils import sanitize +import settings _PAGE = Path(__file__).parent.name bp = Blueprint(_PAGE, __name__) - -def _load_accounts(): - try: - with open(config_utils.ACCOUNTS_FILE) as f: - return json.load(f) - except Exception: - return {'accounts': []} - - @bp.route('/action/accountlogin/form_login', methods=['POST']) @auth.require_level('nothing') def form_login(): - # Abort if already logged in if session.get('access_level', 'nothing') != 'nothing': return redirect('/overview') @@ -33,8 +24,7 @@ def form_login(): flash('Email address and password are required.', 'error') return redirect(f'/{_PAGE}') - accounts = _load_accounts().get('accounts', []) - account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) + account = config_utils.get_account_by_email(email) if account is None: flash('Email address not recognised.', 'error') @@ -44,16 +34,14 @@ def form_login(): flash('Account setup is not complete. Please use Create Account to set your password first.', 'error') return redirect(f'/{_PAGE}') - stored_hash = account['hashed_password'].encode('utf-8') - if not bcrypt.checkpw(password.encode('utf-8'), stored_hash): + if not bcrypt.checkpw(password.encode('utf-8'), account['hashed_password'].encode('utf-8')): flash('Invalid email address or password.', 'error') return redirect(f'/{_PAGE}') session.clear() - session['email_address'] = account['email_address'] - session['access_level'] = account.get('access_level', 'viewer') - session['timezone'] = account.get('timezone', '') + session['account_id'] = account['account_id'] + session['tz_offset_seconds'] = settings.get_host_utc_offset() session['apply_changes_immediately'] = False - session.permanent = True + session.permanent = True return redirect('/overview') diff --git a/docker/routlin-dash/app/pages/accountmanage/action.py b/docker/routlin-dash/app/pages/accountmanage/action.py index 1b48bca..47dbd3c 100644 --- a/docker/routlin-dash/app/pages/accountmanage/action.py +++ b/docker/routlin-dash/app/pages/accountmanage/action.py @@ -1,6 +1,6 @@ from pathlib import Path from flask import Blueprint, request, session, redirect, flash -import json, os, re, sqlite3 +import os, re, sqlite3 from datetime import datetime, timezone import auth import config_utils @@ -10,24 +10,31 @@ _PAGE = Path(__file__).parent.name bp = Blueprint(_PAGE, __name__) -VALID_LEVELS = {'viewer', 'administrator', 'manager'} +VALID_LEVELS = {'viewer': 1, 'administrator': 2, 'manager': 3} -def _load_accounts(): +@bp.route('/action/accountmanage/session_invalidate', methods=['POST']) +@auth.require_level('manager') +def session_invalidate(): + sid = request.form.get('session_id', '').strip() + if not sid: + flash('Invalid request.', 'error') + return redirect(f'/{_PAGE}') try: - with open(config_utils.ACCOUNTS_FILE) as f: - return json.load(f) + con = config_utils.open_accounts_db() + con.execute('DELETE FROM sessions WHERE session_id=?', (sid,)) + con.commit() + con.close() + flash('Session invalidated.', 'success') except Exception: - return {'accounts': []} - -def _save_accounts(data): - with open(config_utils.ACCOUNTS_FILE, 'w') as f: - json.dump(data, f, indent=2) + flash('Failed to invalidate session.', 'error') + return redirect(f'/{_PAGE}') @bp.route('/action/accountmanage/accounts_add', methods=['POST']) @auth.require_level('manager') def accounts_add(): + import uuid as _uuid, time as _t email = sanitize.email(request.form.get('email_address', '')) access_level = request.form.get('access_level', '').strip() @@ -43,26 +50,24 @@ def accounts_add(): flash('Invalid access level.', 'error') return redirect(f'/{_PAGE}') - data = _load_accounts() - accounts = data.get('accounts', []) - - if any(a.get('email_address', '').lower() == email for a in accounts): + if config_utils.get_account_by_email(email): flash('An account with that email address already exists.', 'error') return redirect(f'/{_PAGE}') - now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') - accounts.append({ - 'email_address': email, - 'access_level': access_level, - 'account_created_utc': now, - 'account_created_by': session.get('email_address', ''), - 'hashed_password': '', - 'timezone': '', - }) - data['accounts'] = accounts - _save_accounts(data) + try: + con = config_utils.open_accounts_db() + con.execute( + 'INSERT INTO accounts(account_id,email,access_level,created_ts,created_by) VALUES(?,?,?,?,?)', + (str(_uuid.uuid4()), email, VALID_LEVELS[access_level], int(_t.time()), + session.get('email_address', '')) + ) + con.commit() + con.close() + except Exception as exc: + flash(f'Could not add account: {exc}', 'error') + return redirect(f'/{_PAGE}') - flash(f'Authorization added for {email}. User must complete account setup via the Create Account page.', 'success') + flash(f'Authorization added for {email}.', 'success') return redirect(f'/{_PAGE}') @@ -80,9 +85,7 @@ def accounts_edit(): flash('Invalid access level.', 'error') return redirect(f'/{_PAGE}') - data = _load_accounts() - accounts = data.get('accounts', []) - + accounts = config_utils.list_accounts() if row_index < 0 or row_index >= len(accounts): flash('Account not found.', 'error') return redirect(f'/{_PAGE}') @@ -92,29 +95,19 @@ def accounts_edit(): flash('You cannot change your own access level.', 'error') return redirect(f'/{_PAGE}') - accounts[row_index]['access_level'] = access_level - data['accounts'] = accounts - _save_accounts(data) - - flash('Account updated.', 'success') - return redirect(f'/{_PAGE}') - - -@bp.route('/action/accountmanage/session_invalidate', methods=['POST']) -@auth.require_level('manager') -def session_invalidate(): - sid = request.form.get('session_id', '').strip() - if not sid: - flash('Invalid request.', 'error') - return redirect(f'/{_PAGE}') try: - con = sqlite3.connect(config_utils.SESSIONS_DB, timeout=5) - con.execute('DELETE FROM sessions WHERE session_id=?', (sid,)) + con = config_utils.open_accounts_db() + con.execute( + 'UPDATE accounts SET access_level=? WHERE account_id=?', + (VALID_LEVELS[access_level], target['account_id']) + ) con.commit() con.close() - flash('Session invalidated.', 'success') - except Exception: - flash('Failed to invalidate session.', 'error') + except Exception as exc: + flash(f'Could not update account: {exc}', 'error') + return redirect(f'/{_PAGE}') + + flash('Account updated.', 'success') return redirect(f'/{_PAGE}') @@ -127,26 +120,29 @@ def accounts_delete(): flash('Invalid request.', 'error') return redirect(f'/{_PAGE}') - data = _load_accounts() - accounts = data.get('accounts', []) - + accounts = config_utils.list_accounts() if row_index < 0 or row_index >= len(accounts): flash('Account not found.', 'error') return redirect(f'/{_PAGE}') - target = accounts[row_index] - + target = accounts[row_index] target_email = target.get('email_address', '').lower() current_email = session.get('email_address', '').lower() initial_email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower() + if target_email == current_email and target_email != initial_email: flash('You cannot remove your own account.', 'error') return redirect(f'/{_PAGE}') - removed_email = target.get('email_address', '') - accounts.pop(row_index) - data['accounts'] = accounts - _save_accounts(data) + try: + con = config_utils.open_accounts_db() + con.execute('DELETE FROM sessions WHERE account_id=?', (target['account_id'],)) + con.execute('DELETE FROM accounts WHERE account_id=?', (target['account_id'],)) + con.commit() + con.close() + except Exception as exc: + flash(f'Could not delete account: {exc}', 'error') + return redirect(f'/{_PAGE}') - flash(f'Account for {removed_email} has been removed.', 'success') + flash(f'Account for {target["email_address"]} has been removed.', 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/accountmanage/content.json b/docker/routlin-dash/app/pages/accountmanage/content.json index 861fb85..7f9b252 100644 --- a/docker/routlin-dash/app/pages/accountmanage/content.json +++ b/docker/routlin-dash/app/pages/accountmanage/content.json @@ -32,7 +32,7 @@ {"label": "Email Address", "field": "email_address"}, {"label": "Access Level", "field": "access_level"}, {"label": "Added By", "field": "account_created_by"}, - {"label": "Added", "field": "account_created_utc"}, + {"label": "Added", "field": "account_created_ts"}, { "label": "Status", "field": "account_status", diff --git a/docker/routlin-dash/app/pages/accountmanage/view.py b/docker/routlin-dash/app/pages/accountmanage/view.py index a48bc67..7b07460 100644 --- a/docker/routlin-dash/app/pages/accountmanage/view.py +++ b/docker/routlin-dash/app/pages/accountmanage/view.py @@ -1,25 +1,33 @@ import json import sqlite3 import time -from datetime import datetime, timezone +from datetime import datetime import config_utils import factory -def _fmt_ts(ts): +def _fmt_ts(ts, now): try: - dt = datetime.fromtimestamp(int(ts), tz=timezone.utc) - return dt.strftime('%Y-%m-%d %H:%M UTC') + dt = datetime.fromtimestamp(int(ts)) + ago = config_utils.relative_time(int(ts), now) + return f'{dt.strftime("%Y-%m-%d %H:%M")} ({ago} ago)' except Exception: return '-' +_LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'} + + def _active_sessions_table(): try: - con = sqlite3.connect(config_utils.SESSIONS_DB, timeout=5) + con = sqlite3.connect(config_utils.ACCOUNTS_DB, timeout=5) + con.row_factory = sqlite3.Row rows = con.execute( - 'SELECT session_id, email, access_level, created_at, last_seen' - ' FROM sessions ORDER BY last_seen DESC' + '''SELECT s.session_id, a.email, a.access_level, + s.session_started_ts, s.last_seen_ts + FROM sessions s + JOIN accounts a ON a.account_id = s.account_id + ORDER BY s.last_seen_ts DESC''' ).fetchall() con.close() except Exception: @@ -30,12 +38,21 @@ def _active_sessions_table(): now = int(time.time()) trs = '' - for sid, email, access_level, created_at, last_seen in rows: - online = (now - int(last_seen)) < 300 - badge = ( - 'Online' - if online else - 'Offline' + for row in rows: + sid = row['session_id'] + email = row['email'] + access_level = _LEVEL_INT_TO_STR.get(row['access_level'], 'viewer') + started_ts = row['session_started_ts'] + last_seen = row['last_seen_ts'] + online = (now - int(last_seen)) < 300 + ago = config_utils.relative_time(int(last_seen), now) + tip = factory.e(f'Last seen {ago} ago') + badge_cls = 'badge-enabled' if online else 'badge-disabled' + badge_lbl = 'Online' if online else 'Offline' + badge = ( + f'' + f'{badge_lbl}' + f'' ) btn = ( f'
{factory.e(email)}' f'{factory.e(access_level)}' f'{badge}' - f'{_fmt_ts(created_at)}' - f'{_fmt_ts(last_seen)}' + f'{_fmt_ts(started_ts, now)}' f'{btn}' f'' ) @@ -60,7 +76,6 @@ def _active_sessions_table(): 'Access Level' 'Status' 'Logged In' - 'Last Seen' '' '' + trs + '' ) diff --git a/docker/routlin-dash/app/pages/accountverifyemail/action.py b/docker/routlin-dash/app/pages/accountverifyemail/action.py index a83cb7a..43eb5f9 100644 --- a/docker/routlin-dash/app/pages/accountverifyemail/action.py +++ b/docker/routlin-dash/app/pages/accountverifyemail/action.py @@ -1,84 +1,86 @@ from pathlib import Path from flask import Blueprint, request, session, redirect, flash -import json, os, secrets -from datetime import datetime, timezone, timedelta +import time, secrets import auth import config_utils +import settings _PAGE = Path(__file__).parent.name bp = Blueprint(_PAGE, __name__) - -def _load_accounts(): - try: - with open(config_utils.ACCOUNTS_FILE) as f: - return json.load(f) - except Exception: - return {'accounts': []} - -def _save_accounts(data): - with open(config_utils.ACCOUNTS_FILE, 'w') as f: - json.dump(data, f, indent=2) - - @bp.route('/action/accountverifyemail/email_verify', methods=['POST']) @auth.require_level('nothing') def email_verify(): - # Abort if already logged in if session.get('access_level', 'nothing') != 'nothing': return redirect('/overview') - pending = session.get('pending_create_account') - - if not pending: + pending_email = session.get('pending_verify_email', '').lower() + if not pending_email: flash('No pending account creation found. Please start over.', 'error') return redirect('/accountcreate') - expires = datetime.fromisoformat(pending['expires']) - if datetime.now(tz=timezone.utc) > expires: - session.pop('pending_create_account', None) + try: + con = config_utils.open_accounts_db() + row = con.execute( + 'SELECT * FROM pending_verifications WHERE email=?', (pending_email,) + ).fetchone() + con.close() + except Exception: + row = None + + if not row: + flash('No pending account creation found. Please start over.', 'error') + return redirect('/accountcreate') + + if int(time.time()) > row['expires_ts']: + try: + con = config_utils.open_accounts_db() + con.execute('DELETE FROM pending_verifications WHERE email=?', (pending_email,)) + con.commit() + con.close() + except Exception: + pass + session.pop('pending_verify_email', None) flash('Verification code has expired. Please start over.', 'error') return redirect('/accountcreate') submitted = request.form.get('code', '').strip() - if submitted != pending['code']: + if submitted != row['code']: flash('Incorrect verification code.', 'error') return redirect(f'/{_PAGE}') - data = _load_accounts() - accounts = data.get('accounts', []) - account = next( - (a for a in accounts if a.get('email_address', '').lower() == pending['email'].lower()), - None - ) - + account = config_utils.get_account_by_email(pending_email) if account is None: - session.pop('pending_create_account', None) + session.pop('pending_verify_email', None) flash('Account no longer exists. Contact your manager.', 'error') return redirect('/accountcreate') if account.get('hashed_password'): - session.pop('pending_create_account', None) + session.pop('pending_verify_email', None) flash('This account is already set up. Please log in.', 'error') return redirect('/accountlogin') - now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') - account['hashed_password'] = pending['hashed_password'] - account['timezone'] = pending['timezone'] - if not account.get('account_created_utc'): - account['account_created_utc'] = now - if not account.get('account_created_by'): - account['account_created_by'] = 'self' + now = int(time.time()) + try: + con = config_utils.open_accounts_db() + con.execute( + 'UPDATE accounts SET hashed_password=?, created_ts=?, created_by=? WHERE account_id=?', + (row['hashed_password'], now, 'self', account['account_id']) + ) + con.execute('DELETE FROM pending_verifications WHERE email=?', (pending_email,)) + con.commit() + con.close() + except Exception as exc: + flash(f'Could not complete account setup: {exc}', 'error') + return redirect(f'/{_PAGE}') - _save_accounts(data) - session.pop('pending_create_account', None) - - session['email_address'] = account['email_address'] - session['access_level'] = account.get('access_level', 'viewer') - session['timezone'] = pending['timezone'] - session.permanent = True + session.pop('pending_verify_email', None) + session['account_id'] = account['account_id'] + session['tz_offset_seconds'] = int(row['tz_offset_seconds']) + session['apply_changes_immediately'] = False + session.permanent = True return redirect('/overview') @@ -86,30 +88,35 @@ def email_verify(): @bp.route('/action/accountverifyemail/email_resend') @auth.require_level('nothing') def email_resend(): - # Abort if already logged in if session.get('access_level', 'nothing') != 'nothing': return redirect('/overview') - from pages.accountcreate.action import _send_verification_email, CODE_TTL_MIN + from pages.accountcreate.action import _send_verification_email, CODE_TTL_SECS - pending = session.get('pending_create_account') - - if not pending: + pending_email = session.get('pending_verify_email', '').lower() + if not pending_email: flash('No pending account creation found. Please start over.', 'error') return redirect('/accountcreate') - code = f'{secrets.randbelow(1000000):06d}' - expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat() + code = f'{secrets.randbelow(1000000):06d}' + expires_ts = int(time.time()) + CODE_TTL_SECS try: - _send_verification_email(pending['email'], code) + _send_verification_email(pending_email, code) except Exception as exc: flash(f'Could not resend verification email: {exc}', 'error') return redirect(f'/{_PAGE}') - pending['code'] = code - pending['expires'] = expires - session['pending_create_account'] = pending + try: + con = config_utils.open_accounts_db() + con.execute( + 'UPDATE pending_verifications SET code=?, expires_ts=? WHERE email=?', + (code, expires_ts, pending_email) + ) + con.commit() + con.close() + except Exception: + pass flash('A new verification code has been sent.', 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/preferences/action.py b/docker/routlin-dash/app/pages/preferences/action.py index 2ed63a0..c218254 100644 --- a/docker/routlin-dash/app/pages/preferences/action.py +++ b/docker/routlin-dash/app/pages/preferences/action.py @@ -1,6 +1,6 @@ from pathlib import Path from flask import Blueprint, request, session, redirect, flash -import json, bcrypt +import bcrypt import auth import config_utils import sanitize @@ -10,17 +10,14 @@ _PAGE = Path(__file__).parent.name bp = Blueprint(_PAGE, __name__) - -def _load_accounts(): +def _tz_to_offset_seconds(tz_str): try: - with open(config_utils.ACCOUNTS_FILE) as f: - return json.load(f) + from zoneinfo import ZoneInfo + from datetime import datetime + return int(datetime.now(ZoneInfo(tz_str)).utcoffset().total_seconds()) except Exception: - return {'accounts': []} - -def _save_accounts(data): - with open(config_utils.ACCOUNTS_FILE, 'w') as f: - json.dump(data, f, indent=2) + import settings as _s + return _s.get_host_utc_offset() @bp.route('/action/preferences/accountdetails_save', methods=['POST']) @@ -32,19 +29,8 @@ def accountdetails_save(): flash('Timezone is required.', 'error') return redirect(f'/{_PAGE}') - email = session.get('email_address', '').lower() - data = _load_accounts() - accounts = data.get('accounts', []) - account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) - - if account is None: - flash('Account not found. Please log in again.', 'error') - return redirect('/accountlogin') - - account['timezone'] = tz - _save_accounts(data) - - session['timezone'] = tz + tz_offset = _tz_to_offset_seconds(tz) + session['tz_offset_seconds'] = tz_offset flash('Preferences saved.', 'success') return redirect(f'/{_PAGE}') @@ -69,26 +55,28 @@ def changepassword_save(): flash('New password must be at least 8 characters.', 'error') return redirect(f'/{_PAGE}') - email = session.get('email_address', '').lower() - data = _load_accounts() - accounts = data.get('accounts', []) - account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) - + account = config_utils.get_account_by_id(session.get('account_id', '')) if account is None: flash('Account not found. Please log in again.', 'error') return redirect('/accountlogin') - stored_hash = account.get('hashed_password', '').encode('utf-8') - if not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash): + if not bcrypt.checkpw(current_password.encode('utf-8'), account['hashed_password'].encode('utf-8')): flash('Current password is incorrect.', 'error') return redirect(f'/{_PAGE}') - salt = bcrypt.gensalt() - hashed = bcrypt.hashpw(new_password.encode('utf-8'), salt) + hashed = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - account['hashed_password'] = hashed.decode('utf-8') - account['salt'] = salt.decode('utf-8') - _save_accounts(data) + try: + con = config_utils.open_accounts_db() + con.execute( + 'UPDATE accounts SET hashed_password=? WHERE account_id=?', + (hashed, account['account_id']) + ) + con.commit() + con.close() + except Exception as exc: + flash(f'Could not update password: {exc}', 'error') + return redirect(f'/{_PAGE}') flash('Password changed successfully.', 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/preferences/content.json b/docker/routlin-dash/app/pages/preferences/content.json index 8f082b5..53e376b 100644 --- a/docker/routlin-dash/app/pages/preferences/content.json +++ b/docker/routlin-dash/app/pages/preferences/content.json @@ -29,6 +29,7 @@ "name": "email", "input_type": "text", "value": "%PREF_EMAIL%", + "disabled": true, "hint": "Contact your manager to change your email address." }, { diff --git a/docker/routlin-dash/app/pages/preferences/view.py b/docker/routlin-dash/app/pages/preferences/view.py index 94fe6d5..2efcc50 100644 --- a/docker/routlin-dash/app/pages/preferences/view.py +++ b/docker/routlin-dash/app/pages/preferences/view.py @@ -8,6 +8,6 @@ def collect_tokens(cfg): tokens = config_utils.collect_layout_tokens(cfg) blank = [{'value': '', 'label': '-- Select timezone --'}] tokens['PREF_EMAIL'] = session.get('email_address', '') - tokens['PREF_TIMEZONE'] = session.get('timezone', '') + tokens['PREF_TIMEZONE'] = '' tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]) return tokens diff --git a/docker/routlin-dash/app/session_interface.py b/docker/routlin-dash/app/session_interface.py index 030d519..1ac25ea 100644 --- a/docker/routlin-dash/app/session_interface.py +++ b/docker/routlin-dash/app/session_interface.py @@ -1,10 +1,11 @@ -import json import sqlite3 import time import uuid from flask.sessions import SessionInterface, SessionMixin from werkzeug.datastructures import CallbackDict +_LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'} + class SqliteSession(CallbackDict, SessionMixin): def __init__(self, initial=None, sid=None, new=False): @@ -19,31 +20,13 @@ class SqliteSession(CallbackDict, SessionMixin): class SqliteSessionInterface(SessionInterface): def __init__(self, db_path): self.db_path = db_path - self._init_db() def _connect(self): con = sqlite3.connect(self.db_path, timeout=5) con.execute('PRAGMA journal_mode=WAL') + con.row_factory = sqlite3.Row return con - def _init_db(self): - try: - con = self._connect() - con.execute(''' - CREATE TABLE IF NOT EXISTS sessions ( - session_id TEXT PRIMARY KEY, - email TEXT NOT NULL DEFAULT '', - access_level TEXT NOT NULL DEFAULT '', - data_json TEXT NOT NULL DEFAULT '{}', - created_at INTEGER NOT NULL, - last_seen INTEGER NOT NULL - ) - ''') - con.commit() - con.close() - except Exception: - pass - def open_session(self, app, request): name = app.config.get('SESSION_COOKIE_NAME', 'session') sid = request.cookies.get(name) @@ -51,11 +34,24 @@ class SqliteSessionInterface(SessionInterface): try: con = self._connect() row = con.execute( - 'SELECT data_json FROM sessions WHERE session_id=?', (sid,) + '''SELECT s.session_id, s.account_id, s.tz_offset_seconds, + s.apply_changes_immediately, + a.email, a.access_level + FROM sessions s + JOIN accounts a ON a.account_id = s.account_id + WHERE s.session_id=?''', + (sid,) ).fetchone() con.close() if row: - return SqliteSession(json.loads(row[0]), sid=sid, new=False) + data = { + 'account_id': str(row['account_id']), + 'email_address': str(row['email']), + 'access_level': _LEVEL_INT_TO_STR.get(row['access_level'], 'viewer'), + 'tz_offset_seconds': int(row['tz_offset_seconds']), + 'apply_changes_immediately': bool(row['apply_changes_immediately']), + } + return SqliteSession(data, sid=sid, new=False) except Exception: pass return SqliteSession(sid=str(uuid.uuid4()), new=True) @@ -77,29 +73,33 @@ class SqliteSessionInterface(SessionInterface): response.delete_cookie(name, domain=domain, path=path) return - now = int(time.time()) - email = session.get('email_address', '') - level = session.get('access_level', '') + account_id = session.get('account_id') + if not account_id: + return + + now = int(time.time()) + tz_offset = int(session.get('tz_offset_seconds', 0)) + apply_changes = 1 if session.get('apply_changes_immediately') else 0 try: con = self._connect() if session.new: - if not session.modified: - con.close() - return con.execute( - 'INSERT INTO sessions(session_id,email,access_level,data_json,created_at,last_seen)' - ' VALUES(?,?,?,?,?,?)', - (session.sid, email, level, json.dumps(dict(session)), now, now) + '''INSERT INTO sessions + (session_id, account_id, tz_offset_seconds, apply_changes_immediately, + session_started_ts, last_seen_ts) + VALUES (?,?,?,?,?,?)''', + (session.sid, account_id, tz_offset, apply_changes, now, now) ) elif session.modified: con.execute( - 'UPDATE sessions SET email=?,access_level=?,data_json=?,last_seen=? WHERE session_id=?', - (email, level, json.dumps(dict(session)), now, session.sid) + '''UPDATE sessions SET tz_offset_seconds=?, apply_changes_immediately=?, + last_seen_ts=? WHERE session_id=?''', + (tz_offset, apply_changes, now, session.sid) ) else: con.execute( - 'UPDATE sessions SET last_seen=? WHERE session_id=?', + 'UPDATE sessions SET last_seen_ts=? WHERE session_id=?', (now, session.sid) ) con.commit() diff --git a/docker/routlin-dash/app/settings.py b/docker/routlin-dash/app/settings.py index 1464d1d..802412d 100644 --- a/docker/routlin-dash/app/settings.py +++ b/docker/routlin-dash/app/settings.py @@ -9,6 +9,12 @@ def is_pro(): return bool(os.environ.get('LICENSE', '').strip()) +def get_host_utc_offset(): + # Returns signed integer seconds east of UTC (e.g. -21600 for UTC-6, +19800 for UTC+5:30). + import time + return time.localtime().tm_gmtoff + + def get_credentials_key(): """Return a Fernet-compatible key derived from the CREDENTIALS_KEY environment variable, or None if not set. SHA-256 hashes the raw string to produce 32 bytes, which are then diff --git a/docker/routlin-dash/docker-compose.yml b/docker/routlin-dash/docker-compose.yml index 3e3ce96..0155ea0 100644 --- a/docker/routlin-dash/docker-compose.yml +++ b/docker/routlin-dash/docker-compose.yml @@ -8,6 +8,7 @@ services: - "25327:25327" volumes: - ./www:/www + - ./data:/data - $HOME/routlin:/routlin_location - /sys/class/net:/sys/class/net:ro - /sys/devices:/sys/devices:ro