The webmail is now a PWA: web app manifest, icons (any + maskable, a status-bar badge), theme-coloured title bar following the chosen theme, and an app-icon badge with the unread Inbox count. The service worker is always installed; offline access is on only when it is registered as /sw.js?offline=1, so turning it on or off is an update that applies at once. Notifications (Settings → Accounts → Notifications, per device) use Web Push. Encryption (RFC 8291) and VAPID signing (RFC 8292) are implemented in app/push/webpush.py — pywebpush needs a newer cryptography than we pin — and checked against the RFC's worked example. The server makes its key pair on first use (private half encrypted with MASTER_KEY). New mail found by the live sync in an Inbox enqueues a job 5 s later that tells the user's devices about messages still unread there, under 2 hours old and not from the user; more than three become one "N new messages". A subscription belongs to the session it was made under: signed out or revoked, nothing more is sent there. Endpoints are limited to the known push services (no SSRF). Nothing shows while the app is in front (except on Apple, which requires it). Tested with real pushes through Google's service in Chromium and Opera. The mobile drawer gets "Install app": Chrome's own install dialog where the browser offers one, else short steps for iPhone/iPad, Opera and Firefox, with "Don't show this again". docs/nas-install.md §5.3 covers installing, notifications, Public URL and a later Android app (TWA). Migration 0022: push_subscriptions, app_settings.vapid_*. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
4.6 KiB
Python
149 lines
4.6 KiB
Python
"""Server-side sessions in Redis (SPEC §4 "Sessions", §9.1 session management).
|
|
|
|
``sess:{token}`` -> JSON ``{user_id, sid, created, ua}`` with a sliding TTL of
|
|
``SESSION_TTL_DAYS`` days. A per-user index ``usess:{user_id}`` (hash sid->token)
|
|
lets us list and revoke a user's sessions without exposing the raw token, which is
|
|
the session credential itself. Tokens are opaque and high-entropy.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import secrets
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
|
|
from app.config import get_settings
|
|
from app.redis_client import aw, get_redis
|
|
|
|
_PREFIX = "sess:"
|
|
_INDEX = "usess:"
|
|
_TOKEN_BYTES = 32
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Session:
|
|
token: str
|
|
user_id: uuid.UUID
|
|
sid: str # public session id (safe to expose; not the token)
|
|
created: datetime
|
|
ua: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SessionInfo:
|
|
sid: str
|
|
created: datetime
|
|
ua: str
|
|
current: bool
|
|
|
|
|
|
def _ttl_seconds() -> int:
|
|
return get_settings().session_ttl_days * 24 * 3600
|
|
|
|
|
|
def _index_key(user_id: uuid.UUID | str) -> str:
|
|
return f"{_INDEX}{user_id}"
|
|
|
|
|
|
async def create_session(user_id: uuid.UUID, user_agent: str) -> str:
|
|
"""Create a session for ``user_id`` and return its opaque token."""
|
|
token = secrets.token_urlsafe(_TOKEN_BYTES)
|
|
sid = secrets.token_hex(8)
|
|
payload = {
|
|
"user_id": str(user_id),
|
|
"sid": sid,
|
|
"created": datetime.now(UTC).isoformat(),
|
|
"ua": user_agent[:256],
|
|
}
|
|
redis = get_redis()
|
|
ttl = _ttl_seconds()
|
|
await redis.set(_PREFIX + token, json.dumps(payload), ex=ttl)
|
|
await aw(redis.hset(_index_key(user_id), sid, token))
|
|
await redis.expire(_index_key(user_id), ttl)
|
|
return token
|
|
|
|
|
|
async def resolve_session(token: str) -> Session | None:
|
|
"""Resolve ``token`` to a :class:`Session`, refreshing its sliding TTL."""
|
|
if not token:
|
|
return None
|
|
redis = get_redis()
|
|
raw = await redis.get(_PREFIX + token)
|
|
if raw is None:
|
|
return None
|
|
ttl = _ttl_seconds()
|
|
data = json.loads(raw)
|
|
await redis.expire(_PREFIX + token, ttl) # sliding
|
|
await redis.expire(_index_key(data["user_id"]), ttl)
|
|
return Session(
|
|
token=token,
|
|
user_id=uuid.UUID(data["user_id"]),
|
|
sid=data.get("sid", ""),
|
|
created=datetime.fromisoformat(data["created"]),
|
|
ua=data.get("ua", ""),
|
|
)
|
|
|
|
|
|
async def destroy_session(token: str) -> None:
|
|
"""Delete a session (logout)."""
|
|
if not token:
|
|
return
|
|
redis = get_redis()
|
|
raw = await redis.get(_PREFIX + token)
|
|
await redis.delete(_PREFIX + token)
|
|
if raw is not None:
|
|
data = json.loads(raw)
|
|
await aw(redis.hdel(_index_key(data["user_id"]), data.get("sid", "")))
|
|
|
|
|
|
async def list_sessions(user_id: uuid.UUID, current_token: str) -> list[SessionInfo]:
|
|
"""List a user's active sessions, pruning any whose token has expired."""
|
|
redis = get_redis()
|
|
mapping: dict[str, str] = await aw(redis.hgetall(_index_key(user_id)))
|
|
out: list[SessionInfo] = []
|
|
for sid, token in mapping.items():
|
|
raw = await redis.get(_PREFIX + token)
|
|
if raw is None:
|
|
await aw(redis.hdel(_index_key(user_id), sid)) # prune stale index entry
|
|
continue
|
|
data = json.loads(raw)
|
|
out.append(
|
|
SessionInfo(
|
|
sid=sid,
|
|
created=datetime.fromisoformat(data["created"]),
|
|
ua=data.get("ua", ""),
|
|
current=(token == current_token),
|
|
)
|
|
)
|
|
out.sort(key=lambda s: s.created, reverse=True)
|
|
return out
|
|
|
|
|
|
async def session_alive(user_id: uuid.UUID | str, sid: str) -> bool:
|
|
"""Whether the session with public id ``sid`` is still signed in."""
|
|
redis = get_redis()
|
|
token = await aw(redis.hget(_index_key(user_id), sid))
|
|
return token is not None and bool(await redis.exists(_PREFIX + token))
|
|
|
|
|
|
async def revoke_session(user_id: uuid.UUID, sid: str) -> bool:
|
|
"""Revoke a specific session by its public sid. Returns False if not found."""
|
|
redis = get_redis()
|
|
token = await aw(redis.hget(_index_key(user_id), sid))
|
|
if token is None:
|
|
return False
|
|
await redis.delete(_PREFIX + token)
|
|
await aw(redis.hdel(_index_key(user_id), sid))
|
|
return True
|
|
|
|
|
|
async def destroy_all_sessions(user_id: uuid.UUID) -> None:
|
|
"""Revoke every session for a user (e.g. after a password reset)."""
|
|
redis = get_redis()
|
|
mapping: dict[str, str] = await aw(redis.hgetall(_index_key(user_id)))
|
|
for token in mapping.values():
|
|
await redis.delete(_PREFIX + token)
|
|
await redis.delete(_index_key(user_id))
|