From a886a56982f715b147d45df7ede08a8de8fd4b07 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Wed, 10 Jun 2026 22:57:55 -0400 Subject: [PATCH] Development --- docker/routlin-dash/app/config_utils.py | 41 +++--- .../app/pages/accountcreate/action.py | 21 ++-- .../app/pages/accountverifyemail/action.py | 69 +++++----- docker/routlin-dash/app/session_interface.py | 119 ++++++++++-------- 4 files changed, 142 insertions(+), 108 deletions(-) diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 119954f..0a4e53b 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -42,26 +42,31 @@ def init_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 '' + email TEXT NOT NULL UNIQUE, + access_level INTEGER NOT NULL, + hashed_password TEXT, + created_ts INTEGER NOT NULL, + created_by TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS clients ( + cookie_unique_token TEXT PRIMARY KEY, + email TEXT UNIQUE, + hashed_password TEXT, + tz_offset_seconds INTEGER, + verification_code TEXT, + code_sent_ts INTEGER, + flashes_json TEXT ); 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 + session_id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + tz_offset_seconds INTEGER NOT NULL, + preferences_json TEXT NOT NULL, + flashes_json TEXT, + session_started_ts INTEGER NOT NULL, + last_seen_ts INTEGER NOT NULL, + FOREIGN KEY (session_id) REFERENCES clients(cookie_unique_token), + FOREIGN KEY (account_id) REFERENCES accounts(account_id) ); ''') con.commit() diff --git a/docker/routlin-dash/app/pages/accountcreate/action.py b/docker/routlin-dash/app/pages/accountcreate/action.py index 0f496ce..a61b53f 100644 --- a/docker/routlin-dash/app/pages/accountcreate/action.py +++ b/docker/routlin-dash/app/pages/accountcreate/action.py @@ -88,9 +88,8 @@ def form_create(): 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) + code = f'{secrets.randbelow(1000000):06d}' + tz_offset = _tz_to_offset_seconds(tz) try: _send_verification_email(account['email_address'], code) @@ -101,10 +100,16 @@ def form_create(): 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) + '''INSERT INTO clients + (cookie_unique_token, email, hashed_password, tz_offset_seconds, verification_code, code_sent_ts) + VALUES (?,?,?,?,?,?) + ON CONFLICT(cookie_unique_token) DO UPDATE SET + email=excluded.email, + hashed_password=excluded.hashed_password, + tz_offset_seconds=excluded.tz_offset_seconds, + verification_code=excluded.verification_code, + code_sent_ts=excluded.code_sent_ts''', + (session.sid, account['email_address'].lower(), hashed, tz_offset, code, int(time.time())) ) con.commit() con.close() @@ -112,6 +117,4 @@ def form_create(): 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/accountverifyemail/action.py b/docker/routlin-dash/app/pages/accountverifyemail/action.py index 43eb5f9..e677f63 100644 --- a/docker/routlin-dash/app/pages/accountverifyemail/action.py +++ b/docker/routlin-dash/app/pages/accountverifyemail/action.py @@ -3,7 +3,6 @@ from flask import Blueprint, request, session, redirect, flash import time, secrets import auth import config_utils -import settings _PAGE = Path(__file__).parent.name @@ -16,49 +15,50 @@ def email_verify(): if session.get('access_level', 'nothing') != 'nothing': return redirect('/overview') - 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') + from pages.accountcreate.action import CODE_TTL_SECS + token = session.sid try: - con = config_utils.open_accounts_db() - row = con.execute( - 'SELECT * FROM pending_verifications WHERE email=?', (pending_email,) + con = config_utils.open_accounts_db() + client = con.execute( + 'SELECT * FROM clients WHERE cookie_unique_token=?', (token,) ).fetchone() con.close() except Exception: - row = None + client = None - if not row: + if not client or not client['email']: flash('No pending account creation found. Please start over.', 'error') return redirect('/accountcreate') - if int(time.time()) > row['expires_ts']: + if int(time.time()) > client['code_sent_ts'] + CODE_TTL_SECS: try: con = config_utils.open_accounts_db() - con.execute('DELETE FROM pending_verifications WHERE email=?', (pending_email,)) + con.execute( + '''UPDATE clients SET email=NULL, hashed_password=NULL, + tz_offset_seconds=NULL, verification_code=NULL, code_sent_ts=NULL + WHERE cookie_unique_token=?''', + (token,) + ) 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 != row['code']: + if submitted != client['verification_code']: flash('Incorrect verification code.', 'error') return redirect(f'/{_PAGE}') + pending_email = client['email'] account = config_utils.get_account_by_email(pending_email) if account is 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_verify_email', None) flash('This account is already set up. Please log in.', 'error') return redirect('/accountlogin') @@ -67,18 +67,22 @@ def email_verify(): 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']) + (client['hashed_password'], now, 'self', account['account_id']) + ) + con.execute( + '''UPDATE clients SET email=NULL, hashed_password=NULL, + tz_offset_seconds=NULL, verification_code=NULL, code_sent_ts=NULL + WHERE cookie_unique_token=?''', + (token,) ) - 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}') - session.pop('pending_verify_email', None) session['account_id'] = account['account_id'] - session['tz_offset_seconds'] = int(row['tz_offset_seconds']) + session['tz_offset_seconds'] = int(client['tz_offset_seconds']) session['apply_changes_immediately'] = False session.permanent = True @@ -91,18 +95,27 @@ def email_resend(): if session.get('access_level', 'nothing') != 'nothing': return redirect('/overview') - from pages.accountcreate.action import _send_verification_email, CODE_TTL_SECS + from pages.accountcreate.action import _send_verification_email - pending_email = session.get('pending_verify_email', '').lower() - if not pending_email: + token = session.sid + try: + con = config_utils.open_accounts_db() + client = con.execute( + 'SELECT * FROM clients WHERE cookie_unique_token=?', (token,) + ).fetchone() + con.close() + except Exception: + client = None + + if not client or not client['email']: flash('No pending account creation found. Please start over.', 'error') return redirect('/accountcreate') - code = f'{secrets.randbelow(1000000):06d}' - expires_ts = int(time.time()) + CODE_TTL_SECS + code = f'{secrets.randbelow(1000000):06d}' + code_sent_ts = int(time.time()) try: - _send_verification_email(pending_email, code) + _send_verification_email(client['email'], code) except Exception as exc: flash(f'Could not resend verification email: {exc}', 'error') return redirect(f'/{_PAGE}') @@ -110,8 +123,8 @@ def email_resend(): try: con = config_utils.open_accounts_db() con.execute( - 'UPDATE pending_verifications SET code=?, expires_ts=? WHERE email=?', - (code, expires_ts, pending_email) + 'UPDATE clients SET verification_code=?, code_sent_ts=? WHERE cookie_unique_token=?', + (code, code_sent_ts, token) ) con.commit() con.close() diff --git a/docker/routlin-dash/app/session_interface.py b/docker/routlin-dash/app/session_interface.py index 1ac25ea..12d960b 100644 --- a/docker/routlin-dash/app/session_interface.py +++ b/docker/routlin-dash/app/session_interface.py @@ -1,3 +1,4 @@ +import json import sqlite3 import time import uuid @@ -30,77 +31,89 @@ class SqliteSessionInterface(SessionInterface): def open_session(self, app, request): name = app.config.get('SESSION_COOKIE_NAME', 'session') sid = request.cookies.get(name) - if sid: - try: - con = self._connect() - row = con.execute( - '''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() + if not sid: + return SqliteSession(sid=str(uuid.uuid4()), new=True) + try: + con = self._connect() + row = con.execute( + '''SELECT s.session_id, s.account_id, s.tz_offset_seconds, + s.preferences_json, s.flashes_json, + a.email, a.access_level + FROM sessions s + JOIN accounts a ON a.account_id = s.account_id + WHERE s.session_id=?''', + (sid,) + ).fetchone() + if row: + prefs = json.loads(row['preferences_json'] or '{}') + flashes = json.loads(row['flashes_json'] or '[]') + 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(prefs.get('apply_changes_immediately', False)), + '_flashes': flashes, + } con.close() - if row: - 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) + return SqliteSession(data, sid=sid, new=False) + client = con.execute( + 'SELECT flashes_json FROM clients WHERE cookie_unique_token=?', (sid,) + ).fetchone() + con.close() + flashes = json.loads(client['flashes_json'] or '[]') if client else [] + data = {'_flashes': flashes} if flashes else {} + return SqliteSession(data, sid=sid, new=False) + except Exception: + pass + return SqliteSession(sid=sid, new=False) def save_session(self, app, session, response): name = app.config.get('SESSION_COOKIE_NAME', 'session') domain = self.get_cookie_domain(app) path = self.get_cookie_path(app) - if not session: - if not session.new: - try: - con = self._connect() - con.execute('DELETE FROM sessions WHERE session_id=?', (session.sid,)) - con.commit() - con.close() - except Exception: - pass + if not session and session.modified and not session.new: + try: + con = self._connect() + con.execute('DELETE FROM sessions WHERE session_id=?', (session.sid,)) + con.commit() + con.close() + except Exception: + pass response.delete_cookie(name, domain=domain, path=path) return - 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 + account_id = session.get('account_id') + flashes_json = json.dumps(session.get('_flashes', [])) + now = int(time.time()) try: con = self._connect() - if session.new: + if account_id: + prefs = json.dumps({'apply_changes_immediately': bool(session.get('apply_changes_immediately', False))}) + tz_offset = int(session.get('tz_offset_seconds', 0)) + con.execute('INSERT OR IGNORE INTO clients (cookie_unique_token) VALUES (?)', (session.sid,)) con.execute( '''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 tz_offset_seconds=?, apply_changes_immediately=?, - last_seen_ts=? WHERE session_id=?''', - (tz_offset, apply_changes, now, session.sid) + (session_id, account_id, tz_offset_seconds, preferences_json, + flashes_json, session_started_ts, last_seen_ts) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(session_id) DO UPDATE SET + account_id=excluded.account_id, + tz_offset_seconds=excluded.tz_offset_seconds, + preferences_json=excluded.preferences_json, + flashes_json=excluded.flashes_json, + last_seen_ts=excluded.last_seen_ts''', + (session.sid, account_id, tz_offset, prefs, flashes_json, now, now) ) else: con.execute( - 'UPDATE sessions SET last_seen_ts=? WHERE session_id=?', - (now, session.sid) + '''INSERT INTO clients (cookie_unique_token, flashes_json) + VALUES (?,?) + ON CONFLICT(cookie_unique_token) DO UPDATE SET + flashes_json=excluded.flashes_json''', + (session.sid, flashes_json) ) con.commit() con.close()