Development

This commit is contained in:
Matthew Grotke 2026-06-10 14:23:47 -04:00
parent f5722f3c7b
commit d60bf15ce4
15 changed files with 367 additions and 285 deletions

View file

@ -1,13 +1,14 @@
import copy, json, subprocess, hashlib, os, uuid import copy, json, subprocess, hashlib, os, uuid
import os as _os import os as _os
import sqlite3 as _sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from flask import session from flask import session
APP_DIR = _os.path.dirname(_os.path.abspath(__file__)) APP_DIR = _os.path.dirname(_os.path.abspath(__file__))
CONFIGS_DIR = '/routlin_location' CONFIGS_DIR = '/routlin_location'
DATA_DIR = '/data'
WWW_DIR = '/www' WWW_DIR = '/www'
ACCOUNTS_FILE = f'{CONFIGS_DIR}/.dashboard-accounts' ACCOUNTS_DB = f'{DATA_DIR}/.dashboard-accounts'
SESSIONS_DB = f'{CONFIGS_DIR}/.dashboard-sessions'
CONFIG_FILE = f'{CONFIGS_DIR}/config.json' CONFIG_FILE = f'{CONFIGS_DIR}/config.json'
DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue' DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue'
DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done' DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done'
@ -28,6 +29,93 @@ DASHB_INTERVAL_SECS = 30
QUEUE_MAX_LINES = 50 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_cache = None
_config_mtime = None _config_mtime = None
@ -354,7 +442,6 @@ def queued_msg(cmd=None, description='', action_label='Configuration saved'):
# Snapshot system =================================================== # Snapshot system ===================================================
import re as _re import re as _re
import sqlite3 as _sqlite3
def _db(): def _db():
@ -845,17 +932,7 @@ def config_datasource(name):
return rows return rows
if name == 'accounts': if name == 'accounts':
try: return list_accounts()
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
if name == 'vpn_peers': if name == 'vpn_peers':
rows = [] rows = []

View file

@ -65,7 +65,7 @@ def load_ddns():
return config_utils.load_config().get('ddns', {}) return config_utils.load_config().get('ddns', {})
def load_accounts(): def load_accounts():
return load_json(config_utils.ACCOUNTS_FILE) return {'accounts': config_utils.list_accounts()}
def run(cmd): def run(cmd):
try: try:

View file

@ -33,7 +33,8 @@ from session_interface import SqliteSessionInterface
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24)) 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 ================================================= # Static www/ serving =================================================
@ -151,33 +152,26 @@ app.register_blueprint(api_apply_health_bp)
def _seed_initial_account(): def _seed_initial_account():
import uuid as _uuid, time as _t
email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower() email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower()
if not email: if not email:
try: if not config_utils.list_accounts():
with open(config_utils.ACCOUNTS_FILE) as f:
data = json.load(f)
except Exception:
data = {'accounts': []}
if not data.get('accounts'):
print('[main] WARNING: No accounts exist and INITIAL_MANAGER_EMAIL is not set. ' 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) 'Set it in docker-compose.yml to seed the initial manager account.', file=sys.stderr)
return return
try: if config_utils.list_accounts():
with open(config_utils.ACCOUNTS_FILE) as f:
data = json.load(f)
except Exception:
data = {'accounts': []}
if data.get('accounts'):
return return
data['accounts'] = [{ try:
'email_address': email, con = config_utils.open_accounts_db()
'access_level': 'manager', con.execute(
'hashed_password': '', 'INSERT INTO accounts(account_id,email,access_level,created_ts,created_by) VALUES(?,?,?,?,?)',
'timezone': '', (str(_uuid.uuid4()), email, 3, int(_t.time()), 'system')
}] )
with open(config_utils.ACCOUNTS_FILE, 'w') as f: con.commit()
json.dump(data, f, indent=2) con.close()
print(f'[main] Seeded initial manager account: {email}', file=sys.stderr) 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() _seed_initial_account()

View file

@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from flask import Blueprint, request, session, redirect, flash from flask import Blueprint, request, session, redirect, flash
import json, os, bcrypt, secrets, smtplib import os, bcrypt, secrets, smtplib
from datetime import datetime, timezone, timedelta import time
from email.message import EmailMessage from email.message import EmailMessage
import auth import auth
import config_utils import config_utils
@ -11,15 +11,7 @@ _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__) bp = Blueprint(_PAGE, __name__)
CODE_TTL_MIN = 15 CODE_TTL_SECS = 15 * 60
def _load_accounts():
try:
with open(config_utils.ACCOUNTS_FILE) as f:
return json.load(f)
except Exception:
return {'accounts': []}
def _send_verification_email(to_address, code): def _send_verification_email(to_address, code):
@ -38,7 +30,7 @@ def _send_verification_email(to_address, code):
msg['To'] = to_address msg['To'] = to_address
msg.set_content( msg.set_content(
f'Your verification code is: {code}\n\n' 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.' 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) 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']) @bp.route('/action/accountcreate/form_create', methods=['POST'])
@auth.require_level('nothing') @auth.require_level('nothing')
def form_create(): def form_create():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing': if session.get('access_level', 'nothing') != 'nothing':
return redirect('/overview') return redirect('/overview')
@ -75,8 +76,7 @@ def form_create():
flash('Password must be at least 8 characters.', 'error') flash('Password must be at least 8 characters.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
accounts = _load_accounts().get('accounts', []) account = config_utils.get_account_by_email(email)
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
if account is None: if account is None:
flash('Email address not recognised. Contact your manager.', 'error') flash('Email address not recognised. Contact your manager.', 'error')
@ -87,9 +87,10 @@ def form_create():
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode('utf-8'), salt) hashed = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
code = f'{secrets.randbelow(1000000):06d}' code = f'{secrets.randbelow(1000000):06d}'
expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat() expires_ts = int(time.time()) + CODE_TTL_SECS
tz_offset = _tz_to_offset_seconds(tz)
try: try:
_send_verification_email(account['email_address'], code) _send_verification_email(account['email_address'], code)
@ -97,12 +98,20 @@ def form_create():
flash(f'Could not send verification email: {exc}', 'error') flash(f'Could not send verification email: {exc}', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
session['pending_create_account'] = { try:
'email': account['email_address'], con = config_utils.open_accounts_db()
'hashed_password': hashed.decode('utf-8'), con.execute(
'timezone': tz, '''INSERT OR REPLACE INTO pending_verifications
'code': code, (email, hashed_password, tz_offset_seconds, code, expires_ts)
'expires': expires, 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') return redirect('/accountverifyemail')

View file

@ -1,28 +1,19 @@
from pathlib import Path from pathlib import Path
from flask import Blueprint, request, session, redirect, flash from flask import Blueprint, request, session, redirect, flash
import json, bcrypt import bcrypt
import auth import auth
import config_utils import config_utils
import sanitize import sanitize
import settings
_PAGE = Path(__file__).parent.name _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __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']) @bp.route('/action/accountlogin/form_login', methods=['POST'])
@auth.require_level('nothing') @auth.require_level('nothing')
def form_login(): def form_login():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing': if session.get('access_level', 'nothing') != 'nothing':
return redirect('/overview') return redirect('/overview')
@ -33,8 +24,7 @@ def form_login():
flash('Email address and password are required.', 'error') flash('Email address and password are required.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
accounts = _load_accounts().get('accounts', []) account = config_utils.get_account_by_email(email)
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
if account is None: if account is None:
flash('Email address not recognised.', 'error') flash('Email address not recognised.', 'error')
@ -44,15 +34,13 @@ def form_login():
flash('Account setup is not complete. Please use Create Account to set your password first.', 'error') flash('Account setup is not complete. Please use Create Account to set your password first.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
stored_hash = account['hashed_password'].encode('utf-8') if not bcrypt.checkpw(password.encode('utf-8'), account['hashed_password'].encode('utf-8')):
if not bcrypt.checkpw(password.encode('utf-8'), stored_hash):
flash('Invalid email address or password.', 'error') flash('Invalid email address or password.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
session.clear() session.clear()
session['email_address'] = account['email_address'] session['account_id'] = account['account_id']
session['access_level'] = account.get('access_level', 'viewer') session['tz_offset_seconds'] = settings.get_host_utc_offset()
session['timezone'] = account.get('timezone', '')
session['apply_changes_immediately'] = False session['apply_changes_immediately'] = False
session.permanent = True session.permanent = True

View file

@ -1,6 +1,6 @@
from pathlib import Path from pathlib import Path
from flask import Blueprint, request, session, redirect, flash from flask import Blueprint, request, session, redirect, flash
import json, os, re, sqlite3 import os, re, sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
import auth import auth
import config_utils import config_utils
@ -10,24 +10,31 @@ _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __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: try:
with open(config_utils.ACCOUNTS_FILE) as f: con = config_utils.open_accounts_db()
return json.load(f) con.execute('DELETE FROM sessions WHERE session_id=?', (sid,))
con.commit()
con.close()
flash('Session invalidated.', 'success')
except Exception: except Exception:
return {'accounts': []} flash('Failed to invalidate session.', 'error')
return redirect(f'/{_PAGE}')
def _save_accounts(data):
with open(config_utils.ACCOUNTS_FILE, 'w') as f:
json.dump(data, f, indent=2)
@bp.route('/action/accountmanage/accounts_add', methods=['POST']) @bp.route('/action/accountmanage/accounts_add', methods=['POST'])
@auth.require_level('manager') @auth.require_level('manager')
def accounts_add(): def accounts_add():
import uuid as _uuid, time as _t
email = sanitize.email(request.form.get('email_address', '')) email = sanitize.email(request.form.get('email_address', ''))
access_level = request.form.get('access_level', '').strip() access_level = request.form.get('access_level', '').strip()
@ -43,26 +50,24 @@ def accounts_add():
flash('Invalid access level.', 'error') flash('Invalid access level.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
data = _load_accounts() if config_utils.get_account_by_email(email):
accounts = data.get('accounts', [])
if any(a.get('email_address', '').lower() == email for a in accounts):
flash('An account with that email address already exists.', 'error') flash('An account with that email address already exists.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') try:
accounts.append({ con = config_utils.open_accounts_db()
'email_address': email, con.execute(
'access_level': access_level, 'INSERT INTO accounts(account_id,email,access_level,created_ts,created_by) VALUES(?,?,?,?,?)',
'account_created_utc': now, (str(_uuid.uuid4()), email, VALID_LEVELS[access_level], int(_t.time()),
'account_created_by': session.get('email_address', ''), session.get('email_address', ''))
'hashed_password': '', )
'timezone': '', con.commit()
}) con.close()
data['accounts'] = accounts except Exception as exc:
_save_accounts(data) 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}') return redirect(f'/{_PAGE}')
@ -80,9 +85,7 @@ def accounts_edit():
flash('Invalid access level.', 'error') flash('Invalid access level.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
data = _load_accounts() accounts = config_utils.list_accounts()
accounts = data.get('accounts', [])
if row_index < 0 or row_index >= len(accounts): if row_index < 0 or row_index >= len(accounts):
flash('Account not found.', 'error') flash('Account not found.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
@ -92,29 +95,19 @@ def accounts_edit():
flash('You cannot change your own access level.', 'error') flash('You cannot change your own access level.', 'error')
return redirect(f'/{_PAGE}') 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: try:
con = sqlite3.connect(config_utils.SESSIONS_DB, timeout=5) con = config_utils.open_accounts_db()
con.execute('DELETE FROM sessions WHERE session_id=?', (sid,)) con.execute(
'UPDATE accounts SET access_level=? WHERE account_id=?',
(VALID_LEVELS[access_level], target['account_id'])
)
con.commit() con.commit()
con.close() con.close()
flash('Session invalidated.', 'success') except Exception as exc:
except Exception: flash(f'Could not update account: {exc}', 'error')
flash('Failed to invalidate session.', 'error') return redirect(f'/{_PAGE}')
flash('Account updated.', 'success')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
@ -127,26 +120,29 @@ def accounts_delete():
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
data = _load_accounts() accounts = config_utils.list_accounts()
accounts = data.get('accounts', [])
if row_index < 0 or row_index >= len(accounts): if row_index < 0 or row_index >= len(accounts):
flash('Account not found.', 'error') flash('Account not found.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
target = accounts[row_index] target = accounts[row_index]
target_email = target.get('email_address', '').lower() target_email = target.get('email_address', '').lower()
current_email = session.get('email_address', '').lower() current_email = session.get('email_address', '').lower()
initial_email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower() initial_email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower()
if target_email == current_email and target_email != initial_email: if target_email == current_email and target_email != initial_email:
flash('You cannot remove your own account.', 'error') flash('You cannot remove your own account.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
removed_email = target.get('email_address', '') try:
accounts.pop(row_index) con = config_utils.open_accounts_db()
data['accounts'] = accounts con.execute('DELETE FROM sessions WHERE account_id=?', (target['account_id'],))
_save_accounts(data) con.execute('DELETE FROM accounts WHERE account_id=?', (target['account_id'],))
con.commit()
flash(f'Account for {removed_email} has been removed.', 'success') con.close()
except Exception as exc:
flash(f'Could not delete account: {exc}', 'error')
return redirect(f'/{_PAGE}')
flash(f'Account for {target["email_address"]} has been removed.', 'success')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')

View file

@ -32,7 +32,7 @@
{"label": "Email Address", "field": "email_address"}, {"label": "Email Address", "field": "email_address"},
{"label": "Access Level", "field": "access_level"}, {"label": "Access Level", "field": "access_level"},
{"label": "Added By", "field": "account_created_by"}, {"label": "Added By", "field": "account_created_by"},
{"label": "Added", "field": "account_created_utc"}, {"label": "Added", "field": "account_created_ts"},
{ {
"label": "Status", "label": "Status",
"field": "account_status", "field": "account_status",

View file

@ -1,25 +1,33 @@
import json import json
import sqlite3 import sqlite3
import time import time
from datetime import datetime, timezone from datetime import datetime
import config_utils import config_utils
import factory import factory
def _fmt_ts(ts): def _fmt_ts(ts, now):
try: try:
dt = datetime.fromtimestamp(int(ts), tz=timezone.utc) dt = datetime.fromtimestamp(int(ts))
return dt.strftime('%Y-%m-%d %H:%M UTC') ago = config_utils.relative_time(int(ts), now)
return f'{dt.strftime("%Y-%m-%d %H:%M")} ({ago} ago)'
except Exception: except Exception:
return '-' return '-'
_LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'}
def _active_sessions_table(): def _active_sessions_table():
try: 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( rows = con.execute(
'SELECT session_id, email, access_level, created_at, last_seen' '''SELECT s.session_id, a.email, a.access_level,
' FROM sessions ORDER BY last_seen DESC' 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() ).fetchall()
con.close() con.close()
except Exception: except Exception:
@ -30,12 +38,21 @@ def _active_sessions_table():
now = int(time.time()) now = int(time.time())
trs = '' trs = ''
for sid, email, access_level, created_at, last_seen in rows: 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 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 = ( badge = (
'<span class="badge badge-enabled">Online</span>' f'<span class="tooltip-wrap" data-tooltip="{tip}">'
if online else f'<span class="badge {badge_cls}">{badge_lbl}</span>'
'<span class="badge badge-disabled">Offline</span>' f'</span>'
) )
btn = ( btn = (
f'<form method="post" action="/action/accountmanage/session_invalidate"' f'<form method="post" action="/action/accountmanage/session_invalidate"'
@ -49,8 +66,7 @@ def _active_sessions_table():
f'<td class="table-cell">{factory.e(email)}</td>' f'<td class="table-cell">{factory.e(email)}</td>'
f'<td class="table-cell">{factory.e(access_level)}</td>' f'<td class="table-cell">{factory.e(access_level)}</td>'
f'<td class="table-cell">{badge}</td>' f'<td class="table-cell">{badge}</td>'
f'<td class="table-cell">{_fmt_ts(created_at)}</td>' f'<td class="table-cell">{_fmt_ts(started_ts, now)}</td>'
f'<td class="table-cell">{_fmt_ts(last_seen)}</td>'
f'<td class="table-cell">{btn}</td>' f'<td class="table-cell">{btn}</td>'
f'</tr>' f'</tr>'
) )
@ -60,7 +76,6 @@ def _active_sessions_table():
'<th class="table-header">Access Level</th>' '<th class="table-header">Access Level</th>'
'<th class="table-header">Status</th>' '<th class="table-header">Status</th>'
'<th class="table-header">Logged In</th>' '<th class="table-header">Logged In</th>'
'<th class="table-header">Last Seen</th>'
'<th class="table-header"></th>' '<th class="table-header"></th>'
'</tr></thead><tbody>' + trs + '</tbody></table>' '</tr></thead><tbody>' + trs + '</tbody></table>'
) )

View file

@ -1,83 +1,85 @@
from pathlib import Path from pathlib import Path
from flask import Blueprint, request, session, redirect, flash from flask import Blueprint, request, session, redirect, flash
import json, os, secrets import time, secrets
from datetime import datetime, timezone, timedelta
import auth import auth
import config_utils import config_utils
import settings
_PAGE = Path(__file__).parent.name _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __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']) @bp.route('/action/accountverifyemail/email_verify', methods=['POST'])
@auth.require_level('nothing') @auth.require_level('nothing')
def email_verify(): def email_verify():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing': if session.get('access_level', 'nothing') != 'nothing':
return redirect('/overview') return redirect('/overview')
pending = session.get('pending_create_account') pending_email = session.get('pending_verify_email', '').lower()
if not pending_email:
if not pending:
flash('No pending account creation found. Please start over.', 'error') flash('No pending account creation found. Please start over.', 'error')
return redirect('/accountcreate') return redirect('/accountcreate')
expires = datetime.fromisoformat(pending['expires']) try:
if datetime.now(tz=timezone.utc) > expires: con = config_utils.open_accounts_db()
session.pop('pending_create_account', None) 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') flash('Verification code has expired. Please start over.', 'error')
return redirect('/accountcreate') return redirect('/accountcreate')
submitted = request.form.get('code', '').strip() submitted = request.form.get('code', '').strip()
if submitted != pending['code']: if submitted != row['code']:
flash('Incorrect verification code.', 'error') flash('Incorrect verification code.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
data = _load_accounts() account = config_utils.get_account_by_email(pending_email)
accounts = data.get('accounts', [])
account = next(
(a for a in accounts if a.get('email_address', '').lower() == pending['email'].lower()),
None
)
if account is None: 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') flash('Account no longer exists. Contact your manager.', 'error')
return redirect('/accountcreate') return redirect('/accountcreate')
if account.get('hashed_password'): 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') flash('This account is already set up. Please log in.', 'error')
return redirect('/accountlogin') return redirect('/accountlogin')
now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') now = int(time.time())
account['hashed_password'] = pending['hashed_password'] try:
account['timezone'] = pending['timezone'] con = config_utils.open_accounts_db()
if not account.get('account_created_utc'): con.execute(
account['account_created_utc'] = now 'UPDATE accounts SET hashed_password=?, created_ts=?, created_by=? WHERE account_id=?',
if not account.get('account_created_by'): (row['hashed_password'], now, 'self', account['account_id'])
account['account_created_by'] = 'self' )
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_verify_email', None)
session.pop('pending_create_account', None) session['account_id'] = account['account_id']
session['tz_offset_seconds'] = int(row['tz_offset_seconds'])
session['email_address'] = account['email_address'] session['apply_changes_immediately'] = False
session['access_level'] = account.get('access_level', 'viewer')
session['timezone'] = pending['timezone']
session.permanent = True session.permanent = True
return redirect('/overview') return redirect('/overview')
@ -86,30 +88,35 @@ def email_verify():
@bp.route('/action/accountverifyemail/email_resend') @bp.route('/action/accountverifyemail/email_resend')
@auth.require_level('nothing') @auth.require_level('nothing')
def email_resend(): def email_resend():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing': if session.get('access_level', 'nothing') != 'nothing':
return redirect('/overview') 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') pending_email = session.get('pending_verify_email', '').lower()
if not pending_email:
if not pending:
flash('No pending account creation found. Please start over.', 'error') flash('No pending account creation found. Please start over.', 'error')
return redirect('/accountcreate') return redirect('/accountcreate')
code = f'{secrets.randbelow(1000000):06d}' code = f'{secrets.randbelow(1000000):06d}'
expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat() expires_ts = int(time.time()) + CODE_TTL_SECS
try: try:
_send_verification_email(pending['email'], code) _send_verification_email(pending_email, code)
except Exception as exc: except Exception as exc:
flash(f'Could not resend verification email: {exc}', 'error') flash(f'Could not resend verification email: {exc}', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
pending['code'] = code try:
pending['expires'] = expires con = config_utils.open_accounts_db()
session['pending_create_account'] = pending 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') flash('A new verification code has been sent.', 'success')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')

View file

@ -1,6 +1,6 @@
from pathlib import Path from pathlib import Path
from flask import Blueprint, request, session, redirect, flash from flask import Blueprint, request, session, redirect, flash
import json, bcrypt import bcrypt
import auth import auth
import config_utils import config_utils
import sanitize import sanitize
@ -10,17 +10,14 @@ _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__) bp = Blueprint(_PAGE, __name__)
def _tz_to_offset_seconds(tz_str):
def _load_accounts():
try: try:
with open(config_utils.ACCOUNTS_FILE) as f: from zoneinfo import ZoneInfo
return json.load(f) from datetime import datetime
return int(datetime.now(ZoneInfo(tz_str)).utcoffset().total_seconds())
except Exception: except Exception:
return {'accounts': []} import settings as _s
return _s.get_host_utc_offset()
def _save_accounts(data):
with open(config_utils.ACCOUNTS_FILE, 'w') as f:
json.dump(data, f, indent=2)
@bp.route('/action/preferences/accountdetails_save', methods=['POST']) @bp.route('/action/preferences/accountdetails_save', methods=['POST'])
@ -32,19 +29,8 @@ def accountdetails_save():
flash('Timezone is required.', 'error') flash('Timezone is required.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
email = session.get('email_address', '').lower() tz_offset = _tz_to_offset_seconds(tz)
data = _load_accounts() session['tz_offset_seconds'] = tz_offset
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
flash('Preferences saved.', 'success') flash('Preferences saved.', 'success')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
@ -69,26 +55,28 @@ def changepassword_save():
flash('New password must be at least 8 characters.', 'error') flash('New password must be at least 8 characters.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
email = session.get('email_address', '').lower() account = config_utils.get_account_by_id(session.get('account_id', ''))
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: if account is None:
flash('Account not found. Please log in again.', 'error') flash('Account not found. Please log in again.', 'error')
return redirect('/accountlogin') return redirect('/accountlogin')
stored_hash = account.get('hashed_password', '').encode('utf-8') if not bcrypt.checkpw(current_password.encode('utf-8'), account['hashed_password'].encode('utf-8')):
if not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash):
flash('Current password is incorrect.', 'error') flash('Current password is incorrect.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
salt = bcrypt.gensalt() hashed = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
hashed = bcrypt.hashpw(new_password.encode('utf-8'), salt)
account['hashed_password'] = hashed.decode('utf-8') try:
account['salt'] = salt.decode('utf-8') con = config_utils.open_accounts_db()
_save_accounts(data) 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') flash('Password changed successfully.', 'success')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')

View file

@ -29,6 +29,7 @@
"name": "email", "name": "email",
"input_type": "text", "input_type": "text",
"value": "%PREF_EMAIL%", "value": "%PREF_EMAIL%",
"disabled": true,
"hint": "Contact your manager to change your email address." "hint": "Contact your manager to change your email address."
}, },
{ {

View file

@ -8,6 +8,6 @@ def collect_tokens(cfg):
tokens = config_utils.collect_layout_tokens(cfg) tokens = config_utils.collect_layout_tokens(cfg)
blank = [{'value': '', 'label': '-- Select timezone --'}] blank = [{'value': '', 'label': '-- Select timezone --'}]
tokens['PREF_EMAIL'] = session.get('email_address', '') 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]) tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES])
return tokens return tokens

View file

@ -1,10 +1,11 @@
import json
import sqlite3 import sqlite3
import time import time
import uuid import uuid
from flask.sessions import SessionInterface, SessionMixin from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict from werkzeug.datastructures import CallbackDict
_LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'}
class SqliteSession(CallbackDict, SessionMixin): class SqliteSession(CallbackDict, SessionMixin):
def __init__(self, initial=None, sid=None, new=False): def __init__(self, initial=None, sid=None, new=False):
@ -19,31 +20,13 @@ class SqliteSession(CallbackDict, SessionMixin):
class SqliteSessionInterface(SessionInterface): class SqliteSessionInterface(SessionInterface):
def __init__(self, db_path): def __init__(self, db_path):
self.db_path = db_path self.db_path = db_path
self._init_db()
def _connect(self): def _connect(self):
con = sqlite3.connect(self.db_path, timeout=5) con = sqlite3.connect(self.db_path, timeout=5)
con.execute('PRAGMA journal_mode=WAL') con.execute('PRAGMA journal_mode=WAL')
con.row_factory = sqlite3.Row
return con 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): def open_session(self, app, request):
name = app.config.get('SESSION_COOKIE_NAME', 'session') name = app.config.get('SESSION_COOKIE_NAME', 'session')
sid = request.cookies.get(name) sid = request.cookies.get(name)
@ -51,11 +34,24 @@ class SqliteSessionInterface(SessionInterface):
try: try:
con = self._connect() con = self._connect()
row = con.execute( 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() ).fetchone()
con.close() con.close()
if row: 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: except Exception:
pass pass
return SqliteSession(sid=str(uuid.uuid4()), new=True) return SqliteSession(sid=str(uuid.uuid4()), new=True)
@ -77,29 +73,33 @@ class SqliteSessionInterface(SessionInterface):
response.delete_cookie(name, domain=domain, path=path) response.delete_cookie(name, domain=domain, path=path)
return return
account_id = session.get('account_id')
if not account_id:
return
now = int(time.time()) now = int(time.time())
email = session.get('email_address', '') tz_offset = int(session.get('tz_offset_seconds', 0))
level = session.get('access_level', '') apply_changes = 1 if session.get('apply_changes_immediately') else 0
try: try:
con = self._connect() con = self._connect()
if session.new: if session.new:
if not session.modified:
con.close()
return
con.execute( con.execute(
'INSERT INTO sessions(session_id,email,access_level,data_json,created_at,last_seen)' '''INSERT INTO sessions
' VALUES(?,?,?,?,?,?)', (session_id, account_id, tz_offset_seconds, apply_changes_immediately,
(session.sid, email, level, json.dumps(dict(session)), now, now) session_started_ts, last_seen_ts)
VALUES (?,?,?,?,?,?)''',
(session.sid, account_id, tz_offset, apply_changes, now, now)
) )
elif session.modified: elif session.modified:
con.execute( con.execute(
'UPDATE sessions SET email=?,access_level=?,data_json=?,last_seen=? WHERE session_id=?', '''UPDATE sessions SET tz_offset_seconds=?, apply_changes_immediately=?,
(email, level, json.dumps(dict(session)), now, session.sid) last_seen_ts=? WHERE session_id=?''',
(tz_offset, apply_changes, now, session.sid)
) )
else: else:
con.execute( con.execute(
'UPDATE sessions SET last_seen=? WHERE session_id=?', 'UPDATE sessions SET last_seen_ts=? WHERE session_id=?',
(now, session.sid) (now, session.sid)
) )
con.commit() con.commit()

View file

@ -9,6 +9,12 @@ def is_pro():
return bool(os.environ.get('LICENSE', '').strip()) 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(): def get_credentials_key():
"""Return a Fernet-compatible key derived from the CREDENTIALS_KEY environment variable, """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 or None if not set. SHA-256 hashes the raw string to produce 32 bytes, which are then

View file

@ -8,6 +8,7 @@ services:
- "25327:25327" - "25327:25327"
volumes: volumes:
- ./www:/www - ./www:/www
- ./data:/data
- $HOME/routlin:/routlin_location - $HOME/routlin:/routlin_location
- /sys/class/net:/sys/class/net:ro - /sys/class/net:/sys/class/net:ro
- /sys/devices:/sys/devices:ro - /sys/devices:/sys/devices:ro