diff --git a/l4d2web/l4d2web/auth.py b/l4d2web/l4d2web/auth.py index 3592770..1efbd54 100644 --- a/l4d2web/l4d2web/auth.py +++ b/l4d2web/l4d2web/auth.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import UTC, datetime from functools import wraps from typing import Callable, TypeVar from urllib.parse import quote, unquote @@ -53,11 +53,12 @@ def load_current_user() -> None: except ValueError: g.user = None return - # user.password_changed_at comes back naive from SQLite; strip tz from the - # marker so an aware-marker session (just stamped from an in-memory user) - # compares cleanly with a freshly-loaded user row. - if marker_dt.tzinfo is not None: - marker_dt = marker_dt.replace(tzinfo=None) + # Legacy sessions minted before the UtcDateTime migration carry naive + # ISO markers; stamp UTC so the comparison against the now-aware DB + # value works. Cheap permanent defense; no reliable signal for "all + # naive cookies have expired." + if marker_dt.tzinfo is None: + marker_dt = marker_dt.replace(tzinfo=UTC) if marker_dt < user.password_changed_at: g.user = None return diff --git a/l4d2web/l4d2web/models.py b/l4d2web/l4d2web/models.py index f43fd32..a7b6294 100644 --- a/l4d2web/l4d2web/models.py +++ b/l4d2web/l4d2web/models.py @@ -9,6 +9,7 @@ from sqlalchemy import ( Integer, String, Text, + TypeDecorator, UniqueConstraint, text, ) @@ -23,6 +24,34 @@ def now_utc() -> datetime: return datetime.now(UTC) +class UtcDateTime(TypeDecorator): + """Always store and surface UTC-aware datetimes. + + SQLite has no native tz-aware type and pysqlite strips tzinfo on + round-trip. We convert inputs to UTC, persist them as naive UTC under + the hood, and re-stamp tzinfo=UTC on read. The result: every datetime + on a model attribute is aware UTC, always. + """ + + impl = DateTime + cache_ok = True + + def process_bind_param(self, value, dialect): + if value is None: + return None + if value.tzinfo is None: + raise TypeError( + "naive datetime passed to UtcDateTime column; " + "all writes must be UTC-aware" + ) + return value.astimezone(UTC).replace(tzinfo=None) + + def process_result_value(self, value, dialect): + if value is None: + return None + return value.replace(tzinfo=UTC) + + class User(Base): __tablename__ = "users" @@ -33,9 +62,9 @@ class User(Base): active: Mapped[bool] = mapped_column( Boolean, default=True, nullable=False, server_default=text("1"), ) - created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) - password_changed_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) + updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) + password_changed_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) class Overlay(Base): @@ -65,8 +94,8 @@ class Overlay(Base): user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) script: Mapped[str] = mapped_column(Text, default="", nullable=False) last_build_status: Mapped[str] = mapped_column(String(16), default="", nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) + updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) class WorkshopItem(Base): @@ -80,10 +109,10 @@ class WorkshopItem(Base): file_size: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False) time_updated: Mapped[int] = mapped_column(Integer, default=0, nullable=False) preview_url: Mapped[str] = mapped_column(Text, default="", nullable=False) - last_downloaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_downloaded_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True) last_error: Mapped[str] = mapped_column(Text, default="", nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) + updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) class OverlayWorkshopItem(Base): @@ -110,8 +139,8 @@ class Blueprint(Base): name: Mapped[str] = mapped_column(String(128), nullable=False) arguments: Mapped[str] = mapped_column(Text, default="[]", nullable=False) config: Mapped[str] = mapped_column(Text, default="[]", nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) + updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) class BlueprintOverlay(Base): @@ -124,8 +153,8 @@ class BlueprintOverlay(Base): expose_server_cfg: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default=text("0") ) - created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) + updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) class Server(Base): @@ -141,7 +170,7 @@ class Server(Base): port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False) desired_state: Mapped[str] = mapped_column(String(16), default="stopped", nullable=False) actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False) - actual_state_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + actual_state_updated_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True) last_error: Mapped[str] = mapped_column(Text, default="", nullable=False) rcon_password: Mapped[str] = mapped_column( String(64), nullable=False, default="", server_default="" @@ -149,8 +178,8 @@ class Server(Base): hostname: Mapped[str] = mapped_column( String(128), nullable=False, default="", server_default="" ) - created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) + updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) class Job(Base): @@ -163,10 +192,10 @@ class Job(Base): operation: Mapped[str] = mapped_column(String(32), nullable=False) state: Mapped[str] = mapped_column(String(16), default="queued", nullable=False) exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True) - started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) + started_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) + updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) class JobLog(Base): @@ -177,7 +206,7 @@ class JobLog(Base): seq: Mapped[int] = mapped_column(Integer, nullable=False) stream: Mapped[str] = mapped_column(String(8), nullable=False) line: Mapped[str] = mapped_column(Text, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False) class ServerLiveState(Base): @@ -191,8 +220,8 @@ class ServerLiveState(Base): server_id: Mapped[int] = mapped_column( ForeignKey("servers.id", ondelete="CASCADE"), nullable=False ) - started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) - last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + started_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False) + last_seen_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False) players: Mapped[int] = mapped_column(Integer, nullable=False) max_players: Mapped[int] = mapped_column(Integer, nullable=False) bots: Mapped[int] = mapped_column(Integer, nullable=False) @@ -213,8 +242,8 @@ class ServerPlayerSession(Base): ForeignKey("servers.id", ondelete="CASCADE"), nullable=False ) steam_id_64: Mapped[str] = mapped_column(String(20), nullable=False) - joined_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) - left_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + joined_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False) + left_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True) name_at_join: Mapped[str] = mapped_column(String(64), nullable=False) min_ping: Mapped[int] = mapped_column(Integer, nullable=False) max_ping: Mapped[int] = mapped_column(Integer, nullable=False) @@ -226,7 +255,7 @@ class SteamUserProfile(Base): steam_id_64: Mapped[str] = mapped_column(String(20), primary_key=True) persona_name: Mapped[str] = mapped_column(String(64), nullable=False) avatar_url: Mapped[str] = mapped_column(Text, nullable=False) - fetched_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + fetched_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False) class CommandHistory(Base): @@ -248,5 +277,5 @@ class CommandHistory(Base): Boolean, nullable=False, default=False, server_default=text("0") ) created_at: Mapped[datetime] = mapped_column( - DateTime, default=now_utc, nullable=False + UtcDateTime, default=now_utc, nullable=False ) diff --git a/l4d2web/l4d2web/routes/page_routes.py b/l4d2web/l4d2web/routes/page_routes.py index 2b9aad3..8437835 100644 --- a/l4d2web/l4d2web/routes/page_routes.py +++ b/l4d2web/l4d2web/routes/page_routes.py @@ -171,7 +171,7 @@ def servers_page() -> str: ).all() stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30) - cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=stale_seconds) + cutoff = datetime.now(UTC) - timedelta(seconds=stale_seconds) server_ids = [s.id for s, _bp in rows] latest_rows: dict[int, ServerLiveState] = {} diff --git a/l4d2web/l4d2web/routes/profile_routes.py b/l4d2web/l4d2web/routes/profile_routes.py index 4875308..3dacfb2 100644 --- a/l4d2web/l4d2web/routes/profile_routes.py +++ b/l4d2web/l4d2web/routes/profile_routes.py @@ -83,9 +83,7 @@ def profile_password_change() -> Response: if user is None or not verify_password(current_password, user.password_digest): return _redirect_with_error("wrong_current") user.password_digest = hash_password(new_password) - # Strip tz so the marker matches what a subsequent DB read returns - # (SQLite DateTime columns don't preserve tzinfo). - user.password_changed_at = now_utc().replace(tzinfo=None) + user.password_changed_at = now_utc() new_marker = user.password_changed_at.isoformat() session["pw_changed_at"] = new_marker diff --git a/l4d2web/l4d2web/routes/server_routes.py b/l4d2web/l4d2web/routes/server_routes.py index f0152b5..132f266 100644 --- a/l4d2web/l4d2web/routes/server_routes.py +++ b/l4d2web/l4d2web/routes/server_routes.py @@ -207,7 +207,7 @@ def live_state_fragment(server_id: int) -> Response: return Response(status=404) stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30) - cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=stale_seconds) + cutoff = datetime.now(UTC) - timedelta(seconds=stale_seconds) latest = db.scalar( select(ServerLiveState) @@ -231,7 +231,7 @@ def live_state_fragment(server_id: int) -> Response: current_ids = [r[0].steam_id_64 for r in current_rows] - recent_cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta( + recent_cutoff = datetime.now(UTC) - timedelta( days=current_app.config.get("LIVE_STATE_HISTORY_DAYS", 30) ) diff --git a/l4d2web/l4d2web/services/live_state_poller.py b/l4d2web/l4d2web/services/live_state_poller.py index 87fda4b..5057c47 100644 --- a/l4d2web/l4d2web/services/live_state_poller.py +++ b/l4d2web/l4d2web/services/live_state_poller.py @@ -34,7 +34,7 @@ logger = logging.getLogger(__name__) def _now() -> datetime: - return datetime.now(UTC).replace(tzinfo=None) + return datetime.now(UTC) def poll_once() -> None: diff --git a/l4d2web/tests/test_models.py b/l4d2web/tests/test_models.py index 9825bd6..caf6a56 100644 --- a/l4d2web/tests/test_models.py +++ b/l4d2web/tests/test_models.py @@ -1,3 +1,6 @@ +from datetime import UTC, datetime + +import pytest from sqlalchemy import select from l4d2web.db import init_db, session_scope @@ -8,6 +11,7 @@ from l4d2web.models import ( ServerPlayerSession, SteamUserProfile, User, + UtcDateTime, now_utc as now_utc_aware, ) @@ -108,3 +112,15 @@ def test_steam_user_profile_table_columns(tmp_path, monkeypatch) -> None: fetched_at=now_utc_aware(), ) db.add(row); db.flush() + + +def test_utc_datetime_rejects_naive_bind() -> None: + with pytest.raises(TypeError, match="naive"): + UtcDateTime().process_bind_param(datetime(2026, 5, 16, 12, 0), None) + + +def test_utc_datetime_stamps_utc_on_read() -> None: + naive = datetime(2026, 5, 16, 12, 0) + result = UtcDateTime().process_result_value(naive, None) + assert result == datetime(2026, 5, 16, 12, 0, tzinfo=UTC) + assert result.tzinfo == UTC