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:
+17
-2
@@ -79,6 +79,18 @@ algorithm itself -- it just streams the response straight to the panel.
|
|||||||
view current + upcoming, "show next", advance, back -- nothing else.
|
view current + upcoming, "show next", advance, back -- nothing else.
|
||||||
The share QR stays public (it creates a 30-minute Immich share link
|
The share QR stays public (it creates a 30-minute Immich share link
|
||||||
for exactly the photo shown).
|
for exactly the photo shown).
|
||||||
|
- **Email (optional).** An admin sets an SMTP server once (`/admin` --
|
||||||
|
server, port, username/password, from address, STARTTLS on/off; a
|
||||||
|
"send test email to myself" button, delivered to the admin's own
|
||||||
|
email); each user sets their own email in Settings. Once both are in
|
||||||
|
place: **"Forgot password?"** on the login page emails a one-hour
|
||||||
|
reset link (a generic "check your email" response either way, so the
|
||||||
|
endpoint can't be used to enumerate accounts), and a frame's
|
||||||
|
Configuration tab can set a **battery-alert threshold** -- an email to
|
||||||
|
the frame's owner the first time a report drops to or below it, not
|
||||||
|
again until a recharge is detected and it crosses again. No SMTP
|
||||||
|
configured, or no email on the relevant account, and both features
|
||||||
|
silently no-op rather than erroring.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
@@ -117,7 +129,9 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
flat-scalar parser.
|
flat-scalar parser.
|
||||||
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
|
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
|
||||||
history (feeds the runtime estimate) plus a permanent per-frame
|
history (feeds the runtime estimate) plus a permanent per-frame
|
||||||
battery log (the Stats chart). Only sent on battery power.
|
battery log (the Stats chart). Only sent on battery power. Also where
|
||||||
|
the battery-alert threshold (below) is checked and, at most once per
|
||||||
|
discharge cycle, emailed to the owner.
|
||||||
- `GET /frame/firmware` -- streams the frame's staged OTA image.
|
- `GET /frame/firmware` -- streams the frame's staged OTA image.
|
||||||
|
|
||||||
### Web API (`/api/frames/{id}/...` -- session auth; *view* for reads, *control* for writes)
|
### Web API (`/api/frames/{id}/...` -- session auth; *view* for reads, *control* for writes)
|
||||||
@@ -138,7 +152,8 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
on-device manage overlay still renders native, a known limitation),
|
on-device manage overlay still renders native, a known limitation),
|
||||||
`quiet_hours_*` + `timezone` (a pure server-side decision shaping
|
`quiet_hours_*` + `timezone` (a pure server-side decision shaping
|
||||||
what `refresh_interval_s` gets handed to the device),
|
what `refresh_interval_s` gets handed to the device),
|
||||||
`firmware_update_repo_url`, `firmware_auto_update`).
|
`firmware_update_repo_url`, `firmware_auto_update`,
|
||||||
|
`battery_alert_threshold_pct` -- percent, or `-1`/blank to disable).
|
||||||
- `POST .../take-control` -- always succeeds for a linked user.
|
- `POST .../take-control` -- always succeeds for a linked user.
|
||||||
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
|
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
|
||||||
- `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated),
|
- `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated),
|
||||||
|
|||||||
+37
-1
@@ -27,7 +27,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
from .migration import new_device_token, new_manage_token
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
|
|||||||
SESSION_COOKIE = "session"
|
SESSION_COOKIE = "session"
|
||||||
SESSION_LIFETIME_S = 30 * 86400
|
SESSION_LIFETIME_S = 30 * 86400
|
||||||
SESSION_REFRESH_BELOW_S = 15 * 86400 # rolling expiry: extend when under this much left
|
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,
|
# stdlib scrypt instead of a passlib/argon2 dependency: zero new deps,
|
||||||
# and the parameters are baked into each stored hash so they can be
|
# 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
|
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:
|
def _csrf_ok(request: Request, session: UserSession) -> bool:
|
||||||
supplied = request.headers.get("X-CSRF-Token") or ""
|
supplied = request.headers.get("X-CSRF-Token") or ""
|
||||||
return hmac.compare_digest(supplied, session.csrf_token)
|
return hmac.compare_digest(supplied, session.csrf_token)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""SMTP email sending -- password resets and battery-threshold alerts.
|
||||||
|
|
||||||
|
Config lives in the server_settings singleton row (admin-configured via
|
||||||
|
/admin, see routers/pages.py), not env vars -- it's operator
|
||||||
|
infrastructure a household admin sets up once through the UI, same
|
||||||
|
spirit as the rest of this project's "no separate config file" stance
|
||||||
|
post-redesign. Uses stdlib smtplib; no new dependency.
|
||||||
|
|
||||||
|
send_email() never raises -- a broken mail server shouldn't 500 a
|
||||||
|
password-reset request or a battery report; callers get a bool and log
|
||||||
|
a warning on failure."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
from .models import ServerSettings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SMTP_TIMEOUT_S = 10
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(settings: ServerSettings, to_address: str, subject: str, body: str) -> bool:
|
||||||
|
if not settings.smtp_host or not to_address:
|
||||||
|
return False
|
||||||
|
|
||||||
|
msg = MIMEText(body)
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = settings.smtp_from_address or settings.smtp_username or "noreply@localhost"
|
||||||
|
msg["To"] = to_address
|
||||||
|
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=SMTP_TIMEOUT_S) as smtp:
|
||||||
|
if settings.smtp_use_tls:
|
||||||
|
smtp.starttls()
|
||||||
|
if settings.smtp_username:
|
||||||
|
smtp.login(settings.smtp_username, settings.smtp_password)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
return True
|
||||||
|
except (OSError, smtplib.SMTPException) as e:
|
||||||
|
logger.warning("Failed to send email to %s: %s", to_address, e)
|
||||||
|
return False
|
||||||
+24
-1
@@ -19,7 +19,7 @@ from sqlalchemy import select, text
|
|||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
from .db import SessionLocal, engine
|
from .db import SessionLocal, engine
|
||||||
from .models import Base, BatteryLog, Frame
|
from .models import Base, BatteryLog, Frame, ServerSettings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -28,8 +28,20 @@ def _migration_1(conn) -> None:
|
|||||||
Base.metadata.create_all(bind=conn)
|
Base.metadata.create_all(bind=conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_2(conn) -> None:
|
||||||
|
"""Adds email (users) and battery-alert threshold (frames) columns,
|
||||||
|
plus the new server_settings/password_reset_tokens tables. ALTER
|
||||||
|
TABLE ADD COLUMN with a default is safe on SQLite against a live,
|
||||||
|
already-populated database -- existing rows just get the default."""
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''"))
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1"))
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_sent INTEGER NOT NULL DEFAULT 0"))
|
||||||
|
Base.metadata.create_all(bind=conn) # creates the two new tables only; existing ones untouched
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
|
(2, _migration_2),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -50,6 +62,7 @@ def run_migrations() -> None:
|
|||||||
else:
|
else:
|
||||||
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
||||||
_ensure_frame_one()
|
_ensure_frame_one()
|
||||||
|
_ensure_server_settings()
|
||||||
|
|
||||||
|
|
||||||
def new_device_token() -> str:
|
def new_device_token() -> str:
|
||||||
@@ -144,3 +157,13 @@ def _ensure_frame_one() -> None:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("Fresh install: created default frame #%d", frame.id)
|
logger.info("Fresh install: created default frame #%d", frame.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_server_settings() -> None:
|
||||||
|
"""The SMTP config singleton (id=1) -- created with everything blank
|
||||||
|
(email sending disabled) the first time this runs; /admin edits it in
|
||||||
|
place from then on."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
if db.get(ServerSettings, 1) is None:
|
||||||
|
db.add(ServerSettings(id=1))
|
||||||
|
db.commit()
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ class User(Base):
|
|||||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
immich_url: Mapped[str] = mapped_column(String, default="")
|
immich_url: Mapped[str] = mapped_column(String, default="")
|
||||||
immich_api_key: Mapped[str] = mapped_column(String, default="")
|
immich_api_key: Mapped[str] = mapped_column(String, default="")
|
||||||
|
# Password-reset emails and battery-threshold alerts (frames.owner's
|
||||||
|
# email -- see routers/device.py's frame_battery) go here; blank = no
|
||||||
|
# email configured, both features silently no-op for this user.
|
||||||
|
email: Mapped[str] = mapped_column(String, default="")
|
||||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -139,6 +143,13 @@ class Frame(Base):
|
|||||||
device_firmware_version: Mapped[str] = mapped_column(String, default="")
|
device_firmware_version: Mapped[str] = mapped_column(String, default="")
|
||||||
device_board_variant: Mapped[str] = mapped_column(String, default="")
|
device_board_variant: Mapped[str] = mapped_column(String, default="")
|
||||||
|
|
||||||
|
# Battery-low email alert (see routers/device.py's frame_battery).
|
||||||
|
# -1 = disabled. Sent to the owner's email once per discharge cycle
|
||||||
|
# (battery_alert_sent resets alongside battery_history whenever a
|
||||||
|
# recharge is detected, same trigger as stats_recharge_cycles).
|
||||||
|
battery_alert_threshold_pct: Mapped[int] = mapped_column(Integer, default=-1)
|
||||||
|
battery_alert_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
# -- firmware / OTA (per frame; image lives at /data/firmware/<id>.bin) --
|
# -- firmware / OTA (per frame; image lives at /data/firmware/<id>.bin) --
|
||||||
firmware_available_version: Mapped[str] = mapped_column(String, default="")
|
firmware_available_version: Mapped[str] = mapped_column(String, default="")
|
||||||
firmware_update_repo_url: Mapped[str] = mapped_column(String, default="")
|
firmware_update_repo_url: Mapped[str] = mapped_column(String, default="")
|
||||||
@@ -191,6 +202,39 @@ class PendingClaim(Base):
|
|||||||
expires_at: Mapped[float] = mapped_column(Float)
|
expires_at: Mapped[float] = mapped_column(Float)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerSettings(Base):
|
||||||
|
"""Singleton row (id always 1) holding operator-level SMTP config, set
|
||||||
|
from /admin -- not env vars, since this is infrastructure a household
|
||||||
|
admin configures once through the UI rather than at container
|
||||||
|
deploy time. Used for password-reset emails and battery-threshold
|
||||||
|
alerts (see app/mail.py). smtp_host empty = email sending disabled;
|
||||||
|
every send site checks that and no-ops rather than erroring."""
|
||||||
|
|
||||||
|
__tablename__ = "server_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
smtp_host: Mapped[str] = mapped_column(String, default="")
|
||||||
|
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
|
||||||
|
smtp_username: Mapped[str] = mapped_column(String, default="")
|
||||||
|
smtp_password: Mapped[str] = mapped_column(String, default="")
|
||||||
|
smtp_from_address: Mapped[str] = mapped_column(String, default="")
|
||||||
|
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetToken(Base):
|
||||||
|
"""A single-use, time-limited "forgot password" link. token is the
|
||||||
|
URL-safe secret itself (not hashed, like PendingClaim/manage_token --
|
||||||
|
it's a short-lived bearer credential emailed once, not a long-lived
|
||||||
|
session secret)."""
|
||||||
|
|
||||||
|
__tablename__ = "password_reset_tokens"
|
||||||
|
|
||||||
|
token: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||||
|
expires_at: Mapped[float] = mapped_column(Float)
|
||||||
|
|
||||||
|
|
||||||
class BatteryLog(Base):
|
class BatteryLog(Base):
|
||||||
"""Every battery report ever, per frame -- the permanent record behind
|
"""Every battery report ever, per frame -- the permanent record behind
|
||||||
the battery history chart (was a 20k-entry JSON array in config.json)."""
|
the battery history chart (was a 20k-entry JSON array in config.json)."""
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ def api_config_save(
|
|||||||
timezone: str | None = Form(None),
|
timezone: str | None = Form(None),
|
||||||
firmware_update_repo_url: str | None = Form(None),
|
firmware_update_repo_url: str | None = Form(None),
|
||||||
firmware_auto_update: bool | None = Form(None),
|
firmware_auto_update: bool | None = Form(None),
|
||||||
|
battery_alert_threshold_pct: int | None = Form(None),
|
||||||
frame: Frame = Depends(require_frame_control),
|
frame: Frame = Depends(require_frame_control),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -118,6 +119,11 @@ def api_config_save(
|
|||||||
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
|
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
|
||||||
if firmware_auto_update is not None:
|
if firmware_auto_update is not None:
|
||||||
cfg.firmware_auto_update = firmware_auto_update
|
cfg.firmware_auto_update = firmware_auto_update
|
||||||
|
if battery_alert_threshold_pct is not None:
|
||||||
|
cfg.battery_alert_threshold_pct = max(-1, min(100, battery_alert_threshold_pct))
|
||||||
|
# A changed threshold should be able to fire again immediately,
|
||||||
|
# not stay suppressed by a flag set under the old value.
|
||||||
|
cfg.battery_alert_sent = False
|
||||||
cfg.stats_config_saves += 1
|
cfg.stats_config_saves += 1
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import photo_queue, quiet_hours
|
from .. import mail, photo_queue, quiet_hours
|
||||||
from ..auth import require_device
|
from ..auth import get_server_settings, require_device
|
||||||
from ..db import frame_locked, get_db
|
from ..db import frame_locked, get_db
|
||||||
from ..face_labels import compute_face_labels
|
from ..face_labels import compute_face_labels
|
||||||
from ..firmware import firmware_path
|
from ..firmware import firmware_path
|
||||||
@@ -194,14 +194,19 @@ def frame_battery(
|
|||||||
if not 0 <= body.percent <= 100:
|
if not 0 <= body.percent <= 100:
|
||||||
raise HTTPException(400, "percent must be 0-100")
|
raise HTTPException(400, "percent must be 0-100")
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
should_alert = False
|
||||||
|
alert_email = ""
|
||||||
|
alert_frame_name = ""
|
||||||
with frame_locked(db, frame.id) as locked:
|
with frame_locked(db, frame.id) as locked:
|
||||||
locked.stats_battery_reports += 1
|
locked.stats_battery_reports += 1
|
||||||
if locked.battery_history and body.percent >= locked.battery_history[-1][1] + RECHARGE_JUMP_PCT:
|
if locked.battery_history and body.percent >= locked.battery_history[-1][1] + RECHARGE_JUMP_PCT:
|
||||||
# Percent jumped up meaningfully -- the battery was recharged
|
# Percent jumped up meaningfully -- the battery was recharged
|
||||||
# (or swapped). Start a fresh discharge cycle so runtime and
|
# (or swapped). Start a fresh discharge cycle so runtime and
|
||||||
# discharge-rate estimates never span a charge.
|
# discharge-rate estimates never span a charge -- and let a
|
||||||
|
# low-battery alert fire again next time it actually gets low.
|
||||||
locked.battery_history = []
|
locked.battery_history = []
|
||||||
locked.stats_recharge_cycles += 1
|
locked.stats_recharge_cycles += 1
|
||||||
|
locked.battery_alert_sent = False
|
||||||
locked.battery_history.append([now, body.percent])
|
locked.battery_history.append([now, body.percent])
|
||||||
locked.battery_history = locked.battery_history[-BATTERY_HISTORY_MAX:]
|
locked.battery_history = locked.battery_history[-BATTERY_HISTORY_MAX:]
|
||||||
locked.battery_percent = body.percent
|
locked.battery_percent = body.percent
|
||||||
@@ -216,6 +221,35 @@ def frame_battery(
|
|||||||
BatteryLog.ts
|
BatteryLog.ts
|
||||||
).limit(count + 1 - BATTERY_LOG_MAX)
|
).limit(count + 1 - BATTERY_LOG_MAX)
|
||||||
db.execute(delete(BatteryLog).where(BatteryLog.id.in_(cutoff_ids)))
|
db.execute(delete(BatteryLog).where(BatteryLog.id.in_(cutoff_ids)))
|
||||||
|
|
||||||
|
# Once per discharge cycle (see the recharge reset above), not
|
||||||
|
# once per report -- a frame idling at 4% would otherwise get an
|
||||||
|
# email every wake.
|
||||||
|
if (
|
||||||
|
locked.battery_alert_threshold_pct >= 0
|
||||||
|
and body.percent <= locked.battery_alert_threshold_pct
|
||||||
|
and not locked.battery_alert_sent
|
||||||
|
and locked.owner is not None
|
||||||
|
and locked.owner.email
|
||||||
|
):
|
||||||
|
should_alert = True
|
||||||
|
alert_email = locked.owner.email
|
||||||
|
alert_frame_name = locked.name or f"Frame {locked.id}"
|
||||||
|
|
||||||
|
if should_alert:
|
||||||
|
# Network I/O outside the lock, same convention as everywhere
|
||||||
|
# else in this file -- then a short re-lock to record that it
|
||||||
|
# went out, only on actual success (an SMTP hiccup should let
|
||||||
|
# the next report's still-below-threshold reading try again
|
||||||
|
# rather than silently giving up for the rest of the cycle).
|
||||||
|
settings = get_server_settings(db)
|
||||||
|
sent = mail.send_email(
|
||||||
|
settings, alert_email, f"{alert_frame_name}: battery low",
|
||||||
|
f"{alert_frame_name}'s battery is at {body.percent}%.",
|
||||||
|
)
|
||||||
|
if sent:
|
||||||
|
with frame_locked(db, frame.id) as locked:
|
||||||
|
locked.battery_alert_sent = True
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+119
-1
@@ -18,19 +18,23 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import mail
|
||||||
from ..auth import (
|
from ..auth import (
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
SESSION_LIFETIME_S,
|
SESSION_LIFETIME_S,
|
||||||
|
consume_password_reset_token,
|
||||||
|
create_password_reset_token,
|
||||||
create_session,
|
create_session,
|
||||||
current_session,
|
current_session,
|
||||||
current_user,
|
current_user,
|
||||||
destroy_session,
|
destroy_session,
|
||||||
|
get_server_settings,
|
||||||
hash_password,
|
hash_password,
|
||||||
users_exist,
|
users_exist,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..models import Frame, PendingClaim, User, UserFrame
|
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -181,6 +185,72 @@ def logout(request: Request, csrf_token: str = Form(""), db: Session = Depends(g
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/forgot-password", response_class=HTMLResponse)
|
||||||
|
def forgot_password_page(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"forgot_password.html", {"request": request, "sent": False, "error": None}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/forgot-password", response_class=HTMLResponse)
|
||||||
|
def forgot_password_submit(
|
||||||
|
request: Request, email: str = Form(...), db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Always shows the same "check your email" result regardless of
|
||||||
|
whether the address matches an account -- otherwise this endpoint
|
||||||
|
would let anyone enumerate registered emails. Silently no-ops (same
|
||||||
|
response) if SMTP isn't configured or the user has no email set."""
|
||||||
|
email = email.strip().lower()
|
||||||
|
user = db.scalars(select(User).where(User.email != "").where(User.email == email)).first()
|
||||||
|
if user is not None:
|
||||||
|
token = create_password_reset_token(db, user)
|
||||||
|
reset_url = str(request.base_url).rstrip("/") + f"/reset-password/{token}"
|
||||||
|
settings = get_server_settings(db)
|
||||||
|
mail.send_email(
|
||||||
|
settings, user.email, "Reset your ESPresso Frame password",
|
||||||
|
f"Someone (hopefully you) asked to reset the password for '{user.username}'.\n\n"
|
||||||
|
f"Reset it here (valid for 1 hour): {reset_url}\n\n"
|
||||||
|
"If you didn't request this, ignore this email.",
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"forgot_password.html", {"request": request, "sent": True, "error": None}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reset-password/{token}", response_class=HTMLResponse)
|
||||||
|
def reset_password_page(token: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
row = db.get(PasswordResetToken, token)
|
||||||
|
valid = row is not None and row.expires_at > time.time()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"reset_password.html", {"request": request, "token": token, "valid": valid, "error": None}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password/{token}", response_class=HTMLResponse)
|
||||||
|
def reset_password_submit(
|
||||||
|
token: str, request: Request, password: str = Form(...), db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
if len(password) < PASSWORD_MIN_LEN:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"reset_password.html",
|
||||||
|
{"request": request, "token": token, "valid": True,
|
||||||
|
"error": f"Password must be at least {PASSWORD_MIN_LEN} characters."},
|
||||||
|
)
|
||||||
|
user = consume_password_reset_token(db, token)
|
||||||
|
if user is None:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"reset_password.html",
|
||||||
|
{"request": request, "token": token, "valid": False, "error": None},
|
||||||
|
)
|
||||||
|
user.password_hash = hash_password(password)
|
||||||
|
db.commit()
|
||||||
|
logger.info("Password reset via email link for user '%s'", user.username)
|
||||||
|
cookie_value, _ = create_session(db, user)
|
||||||
|
response = RedirectResponse("/", status_code=303)
|
||||||
|
_set_session_cookie(response, cookie_value)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _normalize_device_id(device_id: str) -> str:
|
def _normalize_device_id(device_id: str) -> str:
|
||||||
device_id = device_id.strip().lower()
|
device_id = device_id.strip().lower()
|
||||||
if len(device_id) != 12 or any(c not in "0123456789abcdef" for c in device_id):
|
if len(device_id) != 12 or any(c not in "0123456789abcdef" for c in device_id):
|
||||||
@@ -349,6 +419,7 @@ def settings_submit(
|
|||||||
request: Request,
|
request: Request,
|
||||||
csrf_token: str = Form(""),
|
csrf_token: str = Form(""),
|
||||||
display_name: str = Form(""),
|
display_name: str = Form(""),
|
||||||
|
email: str = Form(""),
|
||||||
immich_url: str = Form(""),
|
immich_url: str = Form(""),
|
||||||
immich_api_key: str = Form(""),
|
immich_api_key: str = Form(""),
|
||||||
current_password: str = Form(""),
|
current_password: str = Form(""),
|
||||||
@@ -362,6 +433,7 @@ def settings_submit(
|
|||||||
|
|
||||||
error = None
|
error = None
|
||||||
user.display_name = display_name.strip() or user.username
|
user.display_name = display_name.strip() or user.username
|
||||||
|
user.email = email.strip().lower()
|
||||||
user.immich_url = immich_url.strip()
|
user.immich_url = immich_url.strip()
|
||||||
# Blank API key field = keep the existing one (it's never echoed back
|
# Blank API key field = keep the existing one (it's never echoed back
|
||||||
# into the form -- a secret that round-trips through HTML is a secret
|
# into the form -- a secret that round-trips through HTML is a secret
|
||||||
@@ -406,6 +478,7 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
|
|||||||
"users": users,
|
"users": users,
|
||||||
"frames": frames,
|
"frames": frames,
|
||||||
"links_by_frame": links_by_frame,
|
"links_by_frame": links_by_frame,
|
||||||
|
"smtp": get_server_settings(db),
|
||||||
"notice": notice,
|
"notice": notice,
|
||||||
"error": error,
|
"error": error,
|
||||||
})
|
})
|
||||||
@@ -535,6 +608,51 @@ def admin_end_legacy(
|
|||||||
return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.")
|
return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/smtp", response_class=HTMLResponse)
|
||||||
|
def admin_smtp_save(
|
||||||
|
request: Request,
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
smtp_host: str = Form(""),
|
||||||
|
smtp_port: int = Form(587),
|
||||||
|
smtp_username: str = Form(""),
|
||||||
|
smtp_password: str = Form(""),
|
||||||
|
smtp_from_address: str = Form(""),
|
||||||
|
smtp_use_tls: bool = Form(False),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Saves the SMTP config used for password-reset emails and battery-
|
||||||
|
threshold alerts. Blank password = keep the existing one, same
|
||||||
|
round-trip-avoidance as the Immich API key field in /settings."""
|
||||||
|
admin = _require_admin_page(request, db)
|
||||||
|
_check_form_csrf(request, db, csrf_token)
|
||||||
|
settings = get_server_settings(db)
|
||||||
|
settings.smtp_host = smtp_host.strip()
|
||||||
|
settings.smtp_port = max(1, min(65535, smtp_port))
|
||||||
|
settings.smtp_username = smtp_username.strip()
|
||||||
|
if smtp_password.strip():
|
||||||
|
settings.smtp_password = smtp_password.strip()
|
||||||
|
settings.smtp_from_address = smtp_from_address.strip()
|
||||||
|
settings.smtp_use_tls = smtp_use_tls
|
||||||
|
db.commit()
|
||||||
|
return _render_admin(request, db, admin, notice="SMTP settings saved.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/smtp/test", response_class=HTMLResponse)
|
||||||
|
def admin_smtp_test(request: Request, csrf_token: str = Form(""), db: Session = Depends(get_db)):
|
||||||
|
admin = _require_admin_page(request, db)
|
||||||
|
_check_form_csrf(request, db, csrf_token)
|
||||||
|
if not admin.email:
|
||||||
|
return _render_admin(request, db, admin, error="Set an email on your own account (Settings) to test SMTP.")
|
||||||
|
settings = get_server_settings(db)
|
||||||
|
ok = mail.send_email(
|
||||||
|
settings, admin.email, "ESPresso Frame test email",
|
||||||
|
"If you're reading this, SMTP is configured correctly.",
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
return _render_admin(request, db, admin, notice=f"Test email sent to {admin.email}.")
|
||||||
|
return _render_admin(request, db, admin, error="Failed to send -- check the SMTP settings and server logs.")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/frames/{frame_id}/delete", response_class=HTMLResponse)
|
@router.post("/admin/frames/{frame_id}/delete", response_class=HTMLResponse)
|
||||||
def admin_delete_frame(
|
def admin_delete_frame(
|
||||||
frame_id: int,
|
frame_id: int,
|
||||||
|
|||||||
@@ -66,6 +66,26 @@ async function loadControl() {
|
|||||||
|
|
||||||
document.getElementById('take-control').addEventListener('click', takeControl);
|
document.getElementById('take-control').addEventListener('click', takeControl);
|
||||||
|
|
||||||
|
// ---- Battery alerts card ----
|
||||||
|
|
||||||
|
document.getElementById('battery-alert-save').addEventListener('click', async () => {
|
||||||
|
const raw = document.getElementById('battery_alert_threshold_pct').value.trim();
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
battery_alert_threshold_pct: raw === '' ? '-1' : raw,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Saved.');
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Firmware card ----
|
// ---- Firmware card ----
|
||||||
|
|
||||||
document.getElementById('firmware-upload').addEventListener('click', async () => {
|
document.getElementById('firmware-upload').addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -41,6 +41,39 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<h2 class="card-title" style="margin-top: 24px;">Email (SMTP)</h2>
|
||||||
|
<p class="sub">Used for "forgot password" links and battery-low
|
||||||
|
alerts (set per frame in its Configuration tab). Each user needs
|
||||||
|
an email set in their own Settings for either to reach them.</p>
|
||||||
|
<form method="post" action="/admin/smtp">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<label>SMTP server
|
||||||
|
<input type="text" name="smtp_host" placeholder="smtp.example.com" value="{{ smtp.smtp_host }}">
|
||||||
|
</label>
|
||||||
|
<label>Port
|
||||||
|
<input type="number" name="smtp_port" min="1" max="65535" value="{{ smtp.smtp_port }}">
|
||||||
|
</label>
|
||||||
|
<label>Username
|
||||||
|
<input type="text" name="smtp_username" autocomplete="off" value="{{ smtp.smtp_username }}">
|
||||||
|
</label>
|
||||||
|
<label>Password
|
||||||
|
<input type="password" name="smtp_password" autocomplete="off"
|
||||||
|
placeholder="{% if smtp.smtp_password %}(unchanged -- enter a new one to replace){% else %}smtp password{% endif %}">
|
||||||
|
</label>
|
||||||
|
<label>From address
|
||||||
|
<input type="text" name="smtp_from_address" placeholder="[email protected]" value="{{ smtp.smtp_from_address }}">
|
||||||
|
</label>
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" id="smtp_use_tls" name="smtp_use_tls" value="true" {% if smtp.smtp_use_tls %}checked{% endif %}>
|
||||||
|
<label for="smtp_use_tls">Use STARTTLS</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Save SMTP settings</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/smtp/test" style="margin-top: 8px;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="secondary">Send test email to myself</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<h2 class="card-title" style="margin-top: 24px;">Enroll a user</h2>
|
<h2 class="card-title" style="margin-top: 24px;">Enroll a user</h2>
|
||||||
<form method="post" action="/admin/users">
|
<form method="post" action="/admin/users">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block page_class %}page-narrow{% endblock %}
|
||||||
|
|
||||||
|
{% block subtitle %}
|
||||||
|
<p class="sub">Reset your password</p>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card-title">Forgot password</h2>
|
||||||
|
{% if sent %}
|
||||||
|
<div class="status ok">If that email is on an account, a reset link is on its way.</div>
|
||||||
|
<p class="sub" style="margin-top: 14px;">Nothing arriving? The server's
|
||||||
|
SMTP settings may not be configured yet -- ask your admin.</p>
|
||||||
|
{% else %}
|
||||||
|
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||||
|
<p class="sub">Enter the email on your account and we'll send a reset link.</p>
|
||||||
|
<form method="post" action="/forgot-password">
|
||||||
|
<label>Email
|
||||||
|
<input type="email" name="email" required autofocus autocomplete="email">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Send reset link</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<p class="sub" style="margin-top: 14px;"><a href="/login">Back to log in</a></p>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -99,6 +99,19 @@
|
|||||||
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
|
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
|
||||||
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
|
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card-title">Battery alerts</h2>
|
||||||
|
<label>Email me when battery drops below (%)
|
||||||
|
<input type="number" id="battery_alert_threshold_pct" min="0" max="100"
|
||||||
|
value="{% if frame.battery_alert_threshold_pct >= 0 %}{{ frame.battery_alert_threshold_pct }}{% endif %}"
|
||||||
|
placeholder="disabled">
|
||||||
|
</label>
|
||||||
|
<p class="sub" style="margin-top: 8px;">Sent once per discharge cycle
|
||||||
|
to the frame owner's email (set in Settings) -- clear the field to
|
||||||
|
disable. Needs SMTP configured by an admin.</p>
|
||||||
|
<button type="button" class="secondary" id="battery-alert-save">Save</button>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,6 @@
|
|||||||
</label>
|
</label>
|
||||||
<button type="submit">Log in</button>
|
<button type="submit">Log in</button>
|
||||||
</form>
|
</form>
|
||||||
|
<p class="sub" style="margin-top: 14px;"><a href="/forgot-password">Forgot your password?</a></p>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block page_class %}page-narrow{% endblock %}
|
||||||
|
|
||||||
|
{% block subtitle %}
|
||||||
|
<p class="sub">Reset your password</p>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card-title">Set a new password</h2>
|
||||||
|
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||||
|
{% if valid %}
|
||||||
|
<form method="post" action="/reset-password/{{ token }}">
|
||||||
|
<label>New password
|
||||||
|
<input type="password" name="password" minlength="8" required autofocus autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Set password</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="sub">This reset link is invalid or has expired -- links are
|
||||||
|
only good for an hour.</p>
|
||||||
|
<p class="sub" style="margin-top: 14px;"><a href="/forgot-password">Request a new one</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -14,6 +14,12 @@
|
|||||||
<label>Display name
|
<label>Display name
|
||||||
<input type="text" name="display_name" maxlength="64" value="{{ user.display_name }}">
|
<input type="text" name="display_name" maxlength="64" value="{{ user.display_name }}">
|
||||||
</label>
|
</label>
|
||||||
|
<label>Email
|
||||||
|
<input type="email" name="email" value="{{ user.email }}" placeholder="[email protected]">
|
||||||
|
</label>
|
||||||
|
<p class="sub" style="margin-top: 8px;">Used for password-reset links
|
||||||
|
and, for frames you own, battery-low alerts (set a threshold in a
|
||||||
|
frame's Configuration tab).</p>
|
||||||
<label>Immich URL
|
<label>Immich URL
|
||||||
<input type="text" name="immich_url" placeholder="http://your-immich-host:2283"
|
<input type="text" name="immich_url" placeholder="http://your-immich-host:2283"
|
||||||
value="{{ user.immich_url }}">
|
value="{{ user.immich_url }}">
|
||||||
|
|||||||
Reference in New Issue
Block a user