From f5722f3c7bc653e3db8ee35b3fcb4319ed562cdc Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Wed, 10 Jun 2026 13:16:28 -0400 Subject: [PATCH] Development --- .../routlin-dash/app/action_accountlogout.py | 4 - docker/routlin-dash/app/config_utils.py | 66 ---------- docker/routlin-dash/app/main.py | 8 +- .../app/pages/accountlogin/action.py | 6 - .../app/pages/accountmanage/action.py | 25 +++- .../app/pages/accountmanage/view.py | 40 ++++-- docker/routlin-dash/app/session_interface.py | 118 ++++++++++++++++++ docker/routlin-dash/docker-compose.yml | 1 - 8 files changed, 178 insertions(+), 90 deletions(-) create mode 100644 docker/routlin-dash/app/session_interface.py diff --git a/docker/routlin-dash/app/action_accountlogout.py b/docker/routlin-dash/app/action_accountlogout.py index 2349017..829f296 100644 --- a/docker/routlin-dash/app/action_accountlogout.py +++ b/docker/routlin-dash/app/action_accountlogout.py @@ -7,9 +7,5 @@ bp = Blueprint('accountlogout', __name__) @bp.route('/action/accountlogout/logout', methods=['POST']) @auth.require_level('viewer') def logout(): - sid = session.get('session_id', '') - if sid: - import config_utils as _cu - _cu.record_session_logout(sid) session.clear() return redirect('/overview') diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 3bddca6..2784bd4 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -610,72 +610,6 @@ def revert_group_chain(group_uuid): return errors, succeeded, failed -# Sessions DB ======================================================= - -def _open_sessions_db(): - import sqlite3 as _sq, time as _t - con = _sq.connect(SESSIONS_DB, timeout=5) - con.execute('PRAGMA journal_mode=WAL') - con.executescript(''' - CREATE TABLE IF NOT EXISTS sessions ( - session_id TEXT PRIMARY KEY, - email TEXT NOT NULL, - access_level TEXT NOT NULL DEFAULT '', - logged_in_at INTEGER NOT NULL, - last_seen INTEGER NOT NULL - ); - ''') - con.commit() - return con - -def record_session_login(session_id, email, access_level): - import time as _t - try: - con = _open_sessions_db() - now = int(_t.time()) - con.execute( - 'INSERT OR REPLACE INTO sessions(session_id, email, access_level, logged_in_at, last_seen) VALUES(?,?,?,?,?)', - (session_id, email, access_level, now, now) - ) - con.commit() - con.close() - except Exception: - pass - -def record_session_logout(session_id): - try: - con = _open_sessions_db() - con.execute('DELETE FROM sessions WHERE session_id=?', (session_id,)) - con.commit() - con.close() - except Exception: - pass - -def record_session_activity(session_id): - import time as _t - try: - con = _open_sessions_db() - con.execute('UPDATE sessions SET last_seen=? WHERE session_id=?', (int(_t.time()), session_id)) - con.commit() - con.close() - except Exception: - pass - -def get_active_sessions(): - import time as _t - cutoff = int(_t.time()) - 31 * 86400 - try: - con = _open_sessions_db() - rows = con.execute( - 'SELECT email, access_level, logged_in_at, last_seen FROM sessions WHERE last_seen > ? ORDER BY last_seen DESC', - (cutoff,) - ).fetchall() - con.close() - return rows - except Exception: - return [] - - # Misc ============================================================== def run_apply(): diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index ef7b1de..9d44208 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -29,8 +29,11 @@ from pages.captiveportal.action import bp as captiveportal_bp from action_accountlogout import bp as accountlogout_bp from api_apply_health import bp as api_apply_health_bp +from session_interface import SqliteSessionInterface + 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) # Static www/ serving ================================================= @@ -77,9 +80,6 @@ def serve_view(page_name): view_req = view_def.get('client_requirement') level = factory.client_level() - sid = session.get('session_id', '') - if sid and level > 0: - config_utils.record_session_activity(sid) if not factory.passes(view_req, level): return redirect('/overview' if level > 0 else '/accountlogin') diff --git a/docker/routlin-dash/app/pages/accountlogin/action.py b/docker/routlin-dash/app/pages/accountlogin/action.py index 5dec1bd..313378a 100644 --- a/docker/routlin-dash/app/pages/accountlogin/action.py +++ b/docker/routlin-dash/app/pages/accountlogin/action.py @@ -56,10 +56,4 @@ def form_login(): session['apply_changes_immediately'] = False session.permanent = True - import uuid as _uuid - sid = str(_uuid.uuid4()) - session['session_id'] = sid - import config_utils as _cu - _cu.record_session_login(sid, account['email_address'], account.get('access_level', 'viewer')) - return redirect('/overview') diff --git a/docker/routlin-dash/app/pages/accountmanage/action.py b/docker/routlin-dash/app/pages/accountmanage/action.py index 0f44907..1b48bca 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, re +import json, os, re, sqlite3 from datetime import datetime, timezone import auth import config_utils @@ -100,6 +100,24 @@ def accounts_edit(): 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.commit() + con.close() + flash('Session invalidated.', 'success') + except Exception: + flash('Failed to invalidate session.', 'error') + return redirect(f'/{_PAGE}') + + @bp.route('/action/accountmanage/accounts_delete', methods=['POST']) @auth.require_level('manager') def accounts_delete(): @@ -118,7 +136,10 @@ def accounts_delete(): target = accounts[row_index] - if target.get('email_address', '').lower() == session.get('email_address', '').lower(): + 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}') diff --git a/docker/routlin-dash/app/pages/accountmanage/view.py b/docker/routlin-dash/app/pages/accountmanage/view.py index dfb2d57..a48bc67 100644 --- a/docker/routlin-dash/app/pages/accountmanage/view.py +++ b/docker/routlin-dash/app/pages/accountmanage/view.py @@ -1,4 +1,5 @@ import json +import sqlite3 import time from datetime import datetime, timezone import config_utils @@ -14,28 +15,53 @@ def _fmt_ts(ts): def _active_sessions_table(): - rows = config_utils.get_active_sessions() - no_data = '

No active sessions.

' + try: + con = sqlite3.connect(config_utils.SESSIONS_DB, timeout=5) + rows = con.execute( + 'SELECT session_id, email, access_level, created_at, last_seen' + ' FROM sessions ORDER BY last_seen DESC' + ).fetchall() + con.close() + except Exception: + rows = [] + if not rows: - return no_data + return '

No active sessions.

' + now = int(time.time()) trs = '' - for email, access_level, logged_in_at, last_seen in rows: - ago = config_utils.relative_time(int(last_seen), now) + for sid, email, access_level, created_at, last_seen in rows: + online = (now - int(last_seen)) < 300 + badge = ( + 'Online' + if online else + 'Offline' + ) + btn = ( + f'
' + f'' + f'' + f'
' + ) trs += ( f'' f'{factory.e(email)}' f'{factory.e(access_level)}' - f'{_fmt_ts(logged_in_at)}' - f'{factory.e(ago)} ago' + f'{badge}' + f'{_fmt_ts(created_at)}' + f'{_fmt_ts(last_seen)}' + f'{btn}' f'' ) return ( '' '' '' + '' '' '' + '' '' + trs + '
EmailAccess LevelStatusLogged InLast Seen
' ) diff --git a/docker/routlin-dash/app/session_interface.py b/docker/routlin-dash/app/session_interface.py new file mode 100644 index 0000000..030d519 --- /dev/null +++ b/docker/routlin-dash/app/session_interface.py @@ -0,0 +1,118 @@ +import json +import sqlite3 +import time +import uuid +from flask.sessions import SessionInterface, SessionMixin +from werkzeug.datastructures import CallbackDict + + +class SqliteSession(CallbackDict, SessionMixin): + def __init__(self, initial=None, sid=None, new=False): + def on_update(self): + self.modified = True + CallbackDict.__init__(self, initial or {}, on_update) + self.sid = sid + self.new = new + self.modified = False + + +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') + 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) + if sid: + try: + con = self._connect() + row = con.execute( + 'SELECT data_json FROM sessions WHERE session_id=?', (sid,) + ).fetchone() + con.close() + if row: + return SqliteSession(json.loads(row[0]), sid=sid, new=False) + except Exception: + pass + return SqliteSession(sid=str(uuid.uuid4()), new=True) + + 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 + response.delete_cookie(name, domain=domain, path=path) + return + + now = int(time.time()) + email = session.get('email_address', '') + level = session.get('access_level', '') + + 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) + ) + 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) + ) + else: + con.execute( + 'UPDATE sessions SET last_seen=? WHERE session_id=?', + (now, session.sid) + ) + con.commit() + con.close() + except Exception: + pass + + response.set_cookie( + name, session.sid, + expires=self.get_expiration_time(app, session), + httponly=self.get_cookie_httponly(app), + domain=domain, + path=path, + secure=self.get_cookie_secure(app), + samesite=self.get_cookie_samesite(app), + ) diff --git a/docker/routlin-dash/docker-compose.yml b/docker/routlin-dash/docker-compose.yml index 84caf93..3e3ce96 100644 --- a/docker/routlin-dash/docker-compose.yml +++ b/docker/routlin-dash/docker-compose.yml @@ -25,6 +25,5 @@ services: - SMTP_USER=grotek.industries@gmail.com - SMTP_PASSWORD=lfhrygyuwvlaczaw - SMTP_FROM=grotek.industries@gmail.com - - LICENSE=asdf - DEV_MODE=true restart: unless-stopped