Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions codex/librarian/scribe/janitor/cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from types import MappingProxyType

from django.contrib.sessions.models import Session
from django.core import signing
from django.db.models.functions.datetime import Now

from codex.librarian.scribe.janitor.failed_imports import JanitorUpdateFailedImports
Expand Down Expand Up @@ -172,7 +173,7 @@ def cleanup_custom_covers(self) -> None:
self.status_controller.finish(status)

def cleanup_sessions(self) -> None:
"""Delete corrupt sessions."""
"""Delete expired and corrupt sessions."""
status = JanitorCleanupSessionsStatus()
try:
self.status_controller.start(status)
Expand All @@ -181,9 +182,22 @@ def cleanup_sessions(self) -> None:
if count:
self.log.info(f"Deleted {count} expired sessions.")
bad_session_keys = set()
store = Session.get_session_store_class()()
salt = store.key_salt # pyright: ignore[reportAttributeAccessIssue], # ty: ignore[unresolved-attribute]
serializer = store.serializer
for encoded_session in Session.objects.all():
session = encoded_session.get_decoded()
if not session:
# Session.get_decoded() swallows decode errors and returns
# an empty dict, which is also the legitimate state for an
# anonymous session with no stored data — so we can't use
# it to detect corruption. Call signing.loads directly so a
# genuine signature/decode failure raises.
try:
signing.loads(
encoded_session.session_data,
salt=salt,
serializer=serializer,
)
except Exception:
bad_session_keys.add(encoded_session.session_key)

if bad_session_keys:
Expand Down
19 changes: 15 additions & 4 deletions codex/views/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from types import MappingProxyType

from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
from django.contrib.sessions.models import Session
from loguru import logger

from codex.choices.browser import DEFAULT_BROWSER_ROUTE
Expand Down Expand Up @@ -56,10 +57,20 @@ class SettingsBaseView(AuthFilterGenericAPIView, ABC):
# ── Session / user helpers ──────────────────────────────────────

def _ensure_session_key(self) -> str | None:
"""Ensure the Django session is saved and return its key."""
if not self.request.session.session_key:
self.request.session.save()
return self.request.session.session_key
"""Ensure a Django session row exists in the DB and return its key."""
# The cookie may carry a session_key for a row that has been removed
# from the DB (e.g. by sessions cleanup or expiry). The cached_db
# backend serves such sessions from cache without rechecking, so
# session.session_key alone is not safe to use as an FK target.
session = self.request.session
if (
session.session_key
and not Session.objects.filter(session_key=session.session_key).exists()
):
session.flush()
if not session.session_key:
session.save()
return session.session_key

def _get_request_user(self):
"""Return the authenticated user or None."""
Expand Down