Development

This commit is contained in:
Matthew Grotke 2026-06-12 23:31:55 -04:00
parent 025adb9f15
commit 5b1f905ed0
7 changed files with 38 additions and 47 deletions

View file

@ -51,7 +51,7 @@ def init_accounts_db():
cookie_unique_token TEXT PRIMARY KEY, cookie_unique_token TEXT PRIMARY KEY,
email TEXT UNIQUE, email TEXT UNIQUE,
hashed_password TEXT, hashed_password TEXT,
tz_offset_seconds INTEGER, timezone TEXT,
verification_code TEXT, verification_code TEXT,
code_sent_ts INTEGER, code_sent_ts INTEGER,
flashes_json TEXT flashes_json TEXT
@ -59,7 +59,7 @@ def init_accounts_db():
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY, session_id TEXT PRIMARY KEY,
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
tz_offset_seconds INTEGER NOT NULL, timezone TEXT NOT NULL DEFAULT '',
preferences_json TEXT NOT NULL, preferences_json TEXT NOT NULL,
flashes_json TEXT, flashes_json TEXT,
session_started_ts INTEGER NOT NULL, session_started_ts INTEGER NOT NULL,
@ -69,8 +69,13 @@ def init_accounts_db():
); );
''') ''')
con.commit() con.commit()
for ddl in (
'ALTER TABLE accounts ADD COLUMN requested_email TEXT',
'ALTER TABLE clients ADD COLUMN timezone TEXT',
'ALTER TABLE sessions ADD COLUMN timezone TEXT NOT NULL DEFAULT \'\'',
):
try: try:
con.execute('ALTER TABLE accounts ADD COLUMN requested_email TEXT') con.execute(ddl)
con.commit() con.commit()
except Exception: except Exception:
pass pass

View file

@ -43,16 +43,6 @@ 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():
@ -89,7 +79,6 @@ def form_create():
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') hashed = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
code = f'{secrets.randbelow(1000000):06d}' code = f'{secrets.randbelow(1000000):06d}'
tz_offset = _tz_to_offset_seconds(tz)
try: try:
_send_verification_email(account['email_address'], code) _send_verification_email(account['email_address'], code)
@ -101,15 +90,15 @@ def form_create():
con = config_utils.open_accounts_db() con = config_utils.open_accounts_db()
con.execute( con.execute(
'''INSERT INTO clients '''INSERT INTO clients
(cookie_unique_token, email, hashed_password, tz_offset_seconds, verification_code, code_sent_ts) (cookie_unique_token, email, hashed_password, timezone, verification_code, code_sent_ts)
VALUES (?,?,?,?,?,?) VALUES (?,?,?,?,?,?)
ON CONFLICT(cookie_unique_token) DO UPDATE SET ON CONFLICT(cookie_unique_token) DO UPDATE SET
email=excluded.email, email=excluded.email,
hashed_password=excluded.hashed_password, hashed_password=excluded.hashed_password,
tz_offset_seconds=excluded.tz_offset_seconds, timezone=excluded.timezone,
verification_code=excluded.verification_code, verification_code=excluded.verification_code,
code_sent_ts=excluded.code_sent_ts''', code_sent_ts=excluded.code_sent_ts''',
(session.sid, account['email_address'].lower(), hashed, tz_offset, code, int(time.time())) (session.sid, account['email_address'].lower(), hashed, tz, code, int(time.time()))
) )
con.commit() con.commit()
con.close() con.close()

View file

@ -67,7 +67,6 @@ def form_login():
session.clear() session.clear()
session['account_id'] = account['account_id'] session['account_id'] = account['account_id']
session['tz_offset_seconds'] = settings.get_host_utc_offset()
session['timezone'] = settings.get_host_timezone() session['timezone'] = settings.get_host_timezone()
session['apply_changes_immediately'] = False session['apply_changes_immediately'] = False
session.permanent = True session.permanent = True

View file

@ -36,7 +36,7 @@ def email_verify():
con = config_utils.open_accounts_db() con = config_utils.open_accounts_db()
con.execute( con.execute(
'''UPDATE clients SET email=NULL, hashed_password=NULL, '''UPDATE clients SET email=NULL, hashed_password=NULL,
tz_offset_seconds=NULL, verification_code=NULL, code_sent_ts=NULL timezone=NULL, verification_code=NULL, code_sent_ts=NULL
WHERE cookie_unique_token=?''', WHERE cookie_unique_token=?''',
(token,) (token,)
) )
@ -71,7 +71,7 @@ def email_verify():
) )
con.execute( con.execute(
'''UPDATE clients SET email=NULL, hashed_password=NULL, '''UPDATE clients SET email=NULL, hashed_password=NULL,
tz_offset_seconds=NULL, verification_code=NULL, code_sent_ts=NULL timezone=NULL, verification_code=NULL, code_sent_ts=NULL
WHERE cookie_unique_token=?''', WHERE cookie_unique_token=?''',
(token,) (token,)
) )
@ -82,7 +82,7 @@ def email_verify():
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
session['account_id'] = account['account_id'] session['account_id'] = account['account_id']
session['tz_offset_seconds'] = int(client['tz_offset_seconds']) session['timezone'] = str(client['timezone'] or '')
session['apply_changes_immediately'] = False session['apply_changes_immediately'] = False
session.permanent = True session.permanent = True

View file

@ -10,16 +10,6 @@ _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__) bp = Blueprint(_PAGE, __name__)
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/preferences/accountdetails_save', methods=['POST']) @bp.route('/action/preferences/accountdetails_save', methods=['POST'])
@auth.require_level('viewer') @auth.require_level('viewer')
def accountdetails_save(): def accountdetails_save():
@ -29,9 +19,7 @@ def accountdetails_save():
flash('Timezone is required.', 'error') flash('Timezone is required.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
tz_offset = _tz_to_offset_seconds(tz)
session['timezone'] = tz session['timezone'] = tz
session['tz_offset_seconds'] = tz_offset
flash('Preferences saved.', 'success') flash('Preferences saved.', 'success')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')

View file

@ -4,6 +4,7 @@ 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
import settings as _settings
_LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'} _LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'}
@ -36,7 +37,7 @@ class SqliteSessionInterface(SessionInterface):
try: try:
con = self._connect() con = self._connect()
row = con.execute( row = con.execute(
'''SELECT s.session_id, s.account_id, s.tz_offset_seconds, '''SELECT s.session_id, s.account_id, s.timezone,
s.preferences_json, s.flashes_json, s.preferences_json, s.flashes_json,
a.email, a.access_level a.email, a.access_level
FROM sessions s FROM sessions s
@ -47,12 +48,12 @@ class SqliteSessionInterface(SessionInterface):
if row: if row:
prefs = json.loads(row['preferences_json'] or '{}') prefs = json.loads(row['preferences_json'] or '{}')
flashes = json.loads(row['flashes_json'] or '[]') flashes = json.loads(row['flashes_json'] or '[]')
tz = str(row['timezone'] or '') or _settings.get_host_timezone()
data = { data = {
'account_id': str(row['account_id']), 'account_id': str(row['account_id']),
'email_address': str(row['email']), 'email_address': str(row['email']),
'access_level': _LEVEL_INT_TO_STR.get(row['access_level'], 'viewer'), 'access_level': _LEVEL_INT_TO_STR.get(row['access_level'], 'viewer'),
'tz_offset_seconds': int(row['tz_offset_seconds']), 'timezone': tz,
'timezone': str(prefs.get('timezone', '')),
'apply_changes_immediately': bool(prefs.get('apply_changes_immediately', False)), 'apply_changes_immediately': bool(prefs.get('apply_changes_immediately', False)),
'_flashes': flashes, '_flashes': flashes,
'_permanent': True, '_permanent': True,
@ -94,23 +95,22 @@ class SqliteSessionInterface(SessionInterface):
con = self._connect() con = self._connect()
if account_id: if account_id:
prefs = json.dumps({ prefs = json.dumps({
'timezone': session.get('timezone', ''),
'apply_changes_immediately': bool(session.get('apply_changes_immediately', False)), 'apply_changes_immediately': bool(session.get('apply_changes_immediately', False)),
}) })
tz_offset = int(session.get('tz_offset_seconds', 0)) tz = session.get('timezone', '')
con.execute('INSERT OR IGNORE INTO clients (cookie_unique_token) VALUES (?)', (session.sid,)) con.execute('INSERT OR IGNORE INTO clients (cookie_unique_token) VALUES (?)', (session.sid,))
con.execute( con.execute(
'''INSERT INTO sessions '''INSERT INTO sessions
(session_id, account_id, tz_offset_seconds, preferences_json, (session_id, account_id, timezone, preferences_json,
flashes_json, session_started_ts, last_seen_ts) flashes_json, session_started_ts, last_seen_ts)
VALUES (?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?)
ON CONFLICT(session_id) DO UPDATE SET ON CONFLICT(session_id) DO UPDATE SET
account_id=excluded.account_id, account_id=excluded.account_id,
tz_offset_seconds=excluded.tz_offset_seconds, timezone=excluded.timezone,
preferences_json=excluded.preferences_json, preferences_json=excluded.preferences_json,
flashes_json=excluded.flashes_json, flashes_json=excluded.flashes_json,
last_seen_ts=excluded.last_seen_ts''', last_seen_ts=excluded.last_seen_ts''',
(session.sid, account_id, tz_offset, prefs, flashes_json, now, now) (session.sid, account_id, tz, prefs, flashes_json, now, now)
) )
else: else:
con.execute( con.execute(

View file

@ -31,6 +31,16 @@ def get_host_utc_offset():
return time.localtime().tm_gmtoff return time.localtime().tm_gmtoff
def get_client_utc_offset(timezone_str):
"""Return signed integer UTC offset in seconds for the given IANA timezone string."""
try:
from zoneinfo import ZoneInfo
from datetime import datetime
return int(datetime.now(ZoneInfo(timezone_str)).utcoffset().total_seconds())
except Exception:
return get_host_utc_offset()
def get_host_timezone(): def get_host_timezone():
"""Return the host timezone name (e.g. 'America/New_York'), or '' if unknown.""" """Return the host timezone name (e.g. 'America/New_York'), or '' if unknown."""
tz = os.environ.get('TZ', '').strip() tz = os.environ.get('TZ', '').strip()