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 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 = []

View file

@ -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:

View file

@ -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()

View file

@ -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')

View file

@ -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')

View file

@ -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}')

View file

@ -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",

View file

@ -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 = (
'<span class="badge badge-enabled">Online</span>'
if online else
'<span class="badge badge-disabled">Offline</span>'
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'<span class="tooltip-wrap" data-tooltip="{tip}">'
f'<span class="badge {badge_cls}">{badge_lbl}</span>'
f'</span>'
)
btn = (
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(access_level)}</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(last_seen)}</td>'
f'<td class="table-cell">{_fmt_ts(started_ts, now)}</td>'
f'<td class="table-cell">{btn}</td>'
f'</tr>'
)
@ -60,7 +76,6 @@ def _active_sessions_table():
'<th class="table-header">Access Level</th>'
'<th class="table-header">Status</th>'
'<th class="table-header">Logged In</th>'
'<th class="table-header">Last Seen</th>'
'<th class="table-header"></th>'
'</tr></thead><tbody>' + trs + '</tbody></table>'
)

View file

@ -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}')

View file

@ -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}')

View file

@ -29,6 +29,7 @@
"name": "email",
"input_type": "text",
"value": "%PREF_EMAIL%",
"disabled": true,
"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)
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

View file

@ -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()

View file

@ -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