cf5cbb33c0
Reader-retention as ritual, not capture (Codex's framing). Opt-in calm morning email of today's brief; the on-site twin is the finite end-of-feed nudge. * Schema: users.digest_enabled + digest_unsub_token; digest_sends (dedupe + visibility). auth.get_user now returns the digest fields. * goodnews/digest.py: build (dated calm subject, items w/ summary + "why it's here" + UB/source links + one-click unsubscribe, "you're caught up" sign-off) and send_due_digests (morning-window gated, >=4-item floor or skip quietly, deduped, reuses SMTP). No streaks/urgency/"you missed". * API: /auth/me exposes digest_enabled; POST /api/account/digest toggle; GET /api/digest/unsubscribe (token, no login, calm confirmation page). * CLI: cycle gains a morning-gated digest step (--no-digest) + a send-digests command (--force). * Frontend: digest toggle on the Account profile; the Highlights end-cap now says "you're caught up — see you tomorrow" with a one-tap "Get tomorrow's brief by email" (signed-in → enable; anon → sign in). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
5.8 KiB
Python
161 lines
5.8 KiB
Python
"""Account auth core: users, identities, magic-link tokens, sessions.
|
|
|
|
Stdlib-only helpers over a sqlite3 connection. The caller (the API) supplies the
|
|
connection and commits. Self-hosted, minimal-PII. Secrets are random and stored
|
|
only as SHA-256 hashes — the raw token is shown exactly once (in the magic link
|
|
or returned to the client as a session token).
|
|
|
|
Identity model: a user can have several `identities` (email magic-link, google,
|
|
later apple). They resolve to ONE user by verified email, so signing in with
|
|
Google and with a magic link to the same address lands on the same account.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import secrets
|
|
import sqlite3
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
LOGIN_TOKEN_TTL = timedelta(minutes=15)
|
|
SESSION_TTL = timedelta(days=60)
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _iso(dt: datetime) -> str:
|
|
return dt.replace(microsecond=0).isoformat()
|
|
|
|
|
|
def _parse(value: str) -> datetime:
|
|
dt = datetime.fromisoformat(value)
|
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
def _hash(token: str) -> str:
|
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def normalize_email(email: str) -> str:
|
|
return (email or "").strip().lower()
|
|
|
|
|
|
# ---- users & identities ------------------------------------------------------
|
|
|
|
def get_user(conn: sqlite3.Connection, user_id: int) -> sqlite3.Row | None:
|
|
return conn.execute(
|
|
"SELECT id, email, display_name, avatar_url, is_admin, digest_enabled, digest_unsub_token, created_at "
|
|
"FROM users WHERE id = ?",
|
|
(user_id,),
|
|
).fetchone()
|
|
|
|
|
|
def find_or_create_user(
|
|
conn: sqlite3.Connection,
|
|
email: str,
|
|
provider: str,
|
|
provider_subject: str,
|
|
display_name: str | None = None,
|
|
avatar_url: str | None = None,
|
|
) -> int:
|
|
"""Resolve (or create) the user for a verified sign-in, linking the identity.
|
|
|
|
Order: an existing identity wins; else an existing user with the same verified
|
|
email is linked to the new identity; else a fresh user is created.
|
|
"""
|
|
email = normalize_email(email)
|
|
existing = conn.execute(
|
|
"SELECT user_id FROM identities WHERE provider = ? AND provider_subject = ?",
|
|
(provider, provider_subject),
|
|
).fetchone()
|
|
if existing:
|
|
user_id = existing["user_id"]
|
|
else:
|
|
user = conn.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone()
|
|
if user:
|
|
user_id = user["id"]
|
|
else:
|
|
user_id = conn.execute(
|
|
"INSERT INTO users (email, display_name, avatar_url) VALUES (?, ?, ?)",
|
|
(email, display_name, avatar_url),
|
|
).lastrowid
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO identities (user_id, provider, provider_subject) VALUES (?, ?, ?)",
|
|
(user_id, provider, provider_subject),
|
|
)
|
|
|
|
# Always refresh provider-supplied profile bits (even for a returning identity):
|
|
# fill the name if missing, and keep the avatar current when the provider sends one.
|
|
if display_name or avatar_url:
|
|
conn.execute(
|
|
"UPDATE users SET display_name = COALESCE(display_name, ?), "
|
|
"avatar_url = COALESCE(?, avatar_url), updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
(display_name, avatar_url, user_id),
|
|
)
|
|
return user_id
|
|
|
|
|
|
# ---- magic-link tokens -------------------------------------------------------
|
|
|
|
def create_login_token(conn: sqlite3.Connection, email: str) -> str:
|
|
"""Create a single-use sign-in token; return the raw token (goes in the link)."""
|
|
raw = secrets.token_urlsafe(32)
|
|
conn.execute(
|
|
"INSERT INTO login_tokens (email, token_hash, expires_at) VALUES (?, ?, ?)",
|
|
(normalize_email(email), _hash(raw), _iso(_now() + LOGIN_TOKEN_TTL)),
|
|
)
|
|
return raw
|
|
|
|
|
|
def consume_login_token(conn: sqlite3.Connection, raw: str) -> str | None:
|
|
"""Return the email for a valid, unused, unexpired token, marking it used.
|
|
|
|
Returns None (and changes nothing) if the token is unknown, already used, or
|
|
expired. Single-use even under concurrent taps (the consumed_at guard).
|
|
"""
|
|
if not raw:
|
|
return None
|
|
row = conn.execute(
|
|
"SELECT id, email, expires_at, consumed_at FROM login_tokens WHERE token_hash = ?",
|
|
(_hash(raw),),
|
|
).fetchone()
|
|
if not row or row["consumed_at"] is not None or _parse(row["expires_at"]) < _now():
|
|
return None
|
|
consumed = conn.execute(
|
|
"UPDATE login_tokens SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL",
|
|
(_iso(_now()), row["id"]),
|
|
).rowcount
|
|
return row["email"] if consumed else None
|
|
|
|
|
|
# ---- sessions ----------------------------------------------------------------
|
|
|
|
def create_session(conn: sqlite3.Connection, user_id: int, user_agent: str | None = None) -> str:
|
|
"""Create a session; return the raw token (cookie value / bearer token)."""
|
|
raw = secrets.token_urlsafe(32)
|
|
conn.execute(
|
|
"INSERT INTO sessions (user_id, token_hash, expires_at, user_agent) VALUES (?, ?, ?, ?)",
|
|
(user_id, _hash(raw), _iso(_now() + SESSION_TTL), (user_agent or "")[:300]),
|
|
)
|
|
return raw
|
|
|
|
|
|
def resolve_session(conn: sqlite3.Connection, raw: str | None) -> sqlite3.Row | None:
|
|
"""Return the user for a valid session token (and refresh last_seen), else None."""
|
|
if not raw:
|
|
return None
|
|
row = conn.execute(
|
|
"SELECT id, user_id, expires_at FROM sessions WHERE token_hash = ?", (_hash(raw),)
|
|
).fetchone()
|
|
if not row or _parse(row["expires_at"]) < _now():
|
|
return None
|
|
conn.execute("UPDATE sessions SET last_seen_at = ? WHERE id = ?", (_iso(_now()), row["id"]))
|
|
return get_user(conn, row["user_id"])
|
|
|
|
|
|
def revoke_session(conn: sqlite3.Connection, raw: str | None) -> None:
|
|
if raw:
|
|
conn.execute("DELETE FROM sessions WHERE token_hash = ?", (_hash(raw),))
|