Development
This commit is contained in:
parent
f5722f3c7b
commit
d60bf15ce4
15 changed files with 367 additions and 285 deletions
|
|
@ -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 = []
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}')
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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}')
|
||||||
|
|
|
||||||
|
|
@ -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}')
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue