Add SMTP email: password reset + per-frame battery-threshold alerts
Build and push server image / build-and-push (push) Successful in 40s
Build and push server image / build-and-push (push) Successful in 40s
Admin-configured SMTP (server/port/username/password/from address/ STARTTLS, a singleton server_settings row set from /admin -- not env vars, since it's operator infrastructure a household admin sets up once through the UI) powers two features, both requiring the relevant user to have an email set in their own Settings: - "Forgot password?" on /login emails a one-hour single-use reset link (password_reset_tokens table). The endpoint always returns the same generic "check your email" response regardless of whether the address matched an account, so it can't be used to enumerate registered users. - A frame's Configuration tab can set a battery-alert threshold (Frame.battery_alert_threshold_pct, -1 = disabled); POST /frame/battery emails the owner the first time a report drops to or below it, then stays quiet for the rest of that discharge cycle (battery_alert_sent, reset alongside battery_history whenever the existing recharge-jump detection fires) -- not once per wake. New app/mail.py wraps stdlib smtplib (no new dependency); send_email() never raises, so a broken mail server can't 500 a battery report or a password-reset request. Schema migration v2 adds users.email and the two frame columns via ALTER TABLE (safe against the live, already- populated database) plus the two new tables via the existing create_all-based migration runner. Verified against a real (already-migrated, real user/frame data) database: the v1->v2 migration, admin SMTP config + test-email button, full forgot/reset-password roundtrip (including single-use token invalidation and the no-enumeration response), and the battery alert firing exactly once per crossing against a hand-rolled fake SMTP server -- all via curl end-to-end, plus the standing legacy-device curl suite to confirm the device protocol is untouched.
This commit is contained in:
+37
-1
@@ -27,7 +27,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db
|
||||
from .migration import new_device_token, new_manage_token
|
||||
from .models import Frame, PendingClaim, User, UserFrame, UserSession
|
||||
from .models import Frame, PasswordResetToken, PendingClaim, ServerSettings, User, UserFrame, UserSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,6 +35,7 @@ MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
|
||||
SESSION_COOKIE = "session"
|
||||
SESSION_LIFETIME_S = 30 * 86400
|
||||
SESSION_REFRESH_BELOW_S = 15 * 86400 # rolling expiry: extend when under this much left
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_S = 3600
|
||||
|
||||
# stdlib scrypt instead of a passlib/argon2 dependency: zero new deps,
|
||||
# and the parameters are baked into each stored hash so they can be
|
||||
@@ -128,6 +129,41 @@ def users_exist(db: Session) -> bool:
|
||||
return db.scalars(select(User).limit(1)).first() is not None
|
||||
|
||||
|
||||
def get_server_settings(db: Session) -> ServerSettings:
|
||||
"""The SMTP config singleton -- migration.py guarantees row id=1
|
||||
exists (created at startup if missing), so this is never None."""
|
||||
settings = db.get(ServerSettings, 1)
|
||||
assert settings is not None
|
||||
return settings
|
||||
|
||||
|
||||
def create_password_reset_token(db: Session, user: User) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
# Opportunistic prune, same pattern as sessions/pending claims.
|
||||
for stale in db.scalars(select(PasswordResetToken).where(PasswordResetToken.expires_at < now)):
|
||||
db.delete(stale)
|
||||
db.add(PasswordResetToken(
|
||||
token=token, user_id=user.id, created_at=now,
|
||||
expires_at=now + PASSWORD_RESET_TOKEN_LIFETIME_S,
|
||||
))
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def consume_password_reset_token(db: Session, token: str) -> User | None:
|
||||
"""Looks up the token and, if valid, deletes it (single-use) and
|
||||
returns the user it was issued for. None for an unknown/expired
|
||||
token -- callers show a generic error either way."""
|
||||
row = db.get(PasswordResetToken, token)
|
||||
if row is None or row.expires_at < time.time():
|
||||
return None
|
||||
user = db.get(User, row.user_id)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _csrf_ok(request: Request, session: UserSession) -> bool:
|
||||
supplied = request.headers.get("X-CSRF-Token") or ""
|
||||
return hmac.compare_digest(supplied, session.csrf_token)
|
||||
|
||||
Reference in New Issue
Block a user