Build and push server image / build-and-push (push) Successful in 43s
The web UI grows into the multi-frame world: a left sidebar lists the
user's frames (with an online dot driven by the same overdue math as
the Device panel; collapsible off-canvas with a hamburger on mobile),
and each frame gets three tabs -- Photos (album picker, now displaying,
the drag-to-reorder upcoming grid), Configuration (name/order/
orientation/refresh/quiet hours/timezone/smart crop + the firmware
card), and Stats (device telemetry, lifetime counters, battery chart).
Settings and Admin adopt the same shell. / becomes a routing hub:
first frame, empty-state onboarding page, setup/login, or the
manage-QR redirect.
The JSON API moves to /api/frames/{id}/... behind require_frame_view /
require_frame_control: any linked user (admins see all) can view; 404
for frames outside your view so ids aren't confirmed; mutations 409
with the holder's name unless you hold the soft control lock, and
POST take-control always flips it to you. Config saves are now partial
updates -- each tab posts only its own fields (checkboxes always sent
explicitly), so the split forms can't clobber each other.
All CSS moves to static/theme.css and the old 680-line inline script
block splits into static/*.js -- the Pointer Events drag-drop state
machine and the canvas battery chart ported intact, not rewritten. The
CSRF fetch wrapper now reads a <meta> tag. No build step, still vanilla.
Verified end-to-end: page/static/API suites, control-lock handoff in
both directions, partial-save field preservation, non-admin frame
isolation, and the legacy-device curl suite (still byte-identical
responses for the deployed frame).
553 lines
20 KiB
Python
553 lines
20 KiB
Python
"""HTML page routes: first-run setup, login/logout, user settings, and
|
|
the admin panel. The frame pages themselves stay in main.py (Phase A's
|
|
single-frame index) until the Phase D restructure.
|
|
|
|
All POSTs here are plain HTML forms, so CSRF rides a hidden form field
|
|
(checked explicitly) rather than the X-CSRF-Token header the JSON API
|
|
uses."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import logging
|
|
import time
|
|
|
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..auth import (
|
|
SESSION_COOKIE,
|
|
SESSION_LIFETIME_S,
|
|
create_session,
|
|
current_session,
|
|
current_user,
|
|
destroy_session,
|
|
hash_password,
|
|
users_exist,
|
|
verify_password,
|
|
)
|
|
from ..db import get_db
|
|
from ..models import Frame, PendingClaim, User, UserFrame
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
templates = Jinja2Templates(directory="app/templates")
|
|
|
|
USERNAME_MAX_LEN = 64
|
|
PASSWORD_MIN_LEN = 8
|
|
PENDING_CLAIM_TTL_S = 24 * 3600
|
|
|
|
|
|
def _set_session_cookie(response, cookie_value: str) -> None:
|
|
# No Secure flag: the server itself is plain HTTP by design (TLS is a
|
|
# reverse proxy's job, see README) and a LAN deployment without HTTPS
|
|
# must still be able to log in.
|
|
response.set_cookie(
|
|
SESSION_COOKIE,
|
|
cookie_value,
|
|
max_age=SESSION_LIFETIME_S,
|
|
httponly=True,
|
|
samesite="lax",
|
|
)
|
|
|
|
|
|
def _check_form_csrf(request: Request, db: Session, csrf_token: str) -> None:
|
|
session = current_session(request, db)
|
|
if session is None or not hmac.compare_digest(csrf_token, session.csrf_token):
|
|
raise HTTPException(403, "Missing or invalid CSRF token")
|
|
|
|
|
|
def _normalize_username(username: str) -> str:
|
|
return username.strip().lower()
|
|
|
|
|
|
def _validate_credentials(username: str, password: str) -> str:
|
|
username = _normalize_username(username)
|
|
if not username or len(username) > USERNAME_MAX_LEN:
|
|
raise HTTPException(400, "Invalid username")
|
|
if len(password) < PASSWORD_MIN_LEN:
|
|
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LEN} characters")
|
|
return username
|
|
|
|
|
|
@router.get("/setup", response_class=HTMLResponse)
|
|
def setup_page(request: Request, db: Session = Depends(get_db)):
|
|
if users_exist(db):
|
|
return RedirectResponse("/login", status_code=303)
|
|
return templates.TemplateResponse("setup.html", {"request": request, "error": None})
|
|
|
|
|
|
@router.post("/setup")
|
|
def setup_submit(
|
|
request: Request,
|
|
username: str = Form(...),
|
|
display_name: str = Form(""),
|
|
password: str = Form(...),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Creates admin #1 -- only ever available while no users exist, so it
|
|
needs no CSRF/session (there is nothing to ride). Links every
|
|
existing frame (i.e. the migrated frame #1) to the new admin, makes
|
|
them its owner + controller, and inherits the migrated Immich creds
|
|
onto their account (that's how env/config.json creds become per-user
|
|
state)."""
|
|
if users_exist(db):
|
|
raise HTTPException(403, "Setup has already been completed")
|
|
username = _validate_credentials(username, password)
|
|
|
|
admin = User(
|
|
username=username,
|
|
display_name=display_name.strip() or username,
|
|
password_hash=hash_password(password),
|
|
is_admin=True,
|
|
created_at=time.time(),
|
|
)
|
|
db.add(admin)
|
|
db.flush()
|
|
|
|
for frame in db.scalars(select(Frame)):
|
|
db.add(UserFrame(user_id=admin.id, frame_id=frame.id))
|
|
if frame.owner_user_id is None:
|
|
frame.owner_user_id = admin.id
|
|
frame.claimed_at = time.time()
|
|
if frame.controlled_by_user_id is None:
|
|
frame.controlled_by_user_id = admin.id
|
|
if not admin.immich_url and frame.immich_url and frame.immich_api_key:
|
|
admin.immich_url = frame.immich_url
|
|
admin.immich_api_key = frame.immich_api_key
|
|
|
|
db.commit()
|
|
logger.info("First-run setup: created admin '%s' and linked %s", username,
|
|
", ".join(f"frame #{f.id}" for f in db.scalars(select(Frame))) or "no frames")
|
|
|
|
cookie_value, _ = create_session(db, admin)
|
|
response = RedirectResponse("/", status_code=303)
|
|
_set_session_cookie(response, cookie_value)
|
|
return response
|
|
|
|
|
|
def _safe_next(next_url: str) -> str:
|
|
"""Same-site relative paths only -- a login redirect target from a
|
|
query param must never become an open redirect."""
|
|
if next_url.startswith("/") and not next_url.startswith("//"):
|
|
return next_url
|
|
return "/"
|
|
|
|
|
|
@router.get("/login", response_class=HTMLResponse)
|
|
def login_page(request: Request, next: str = "", db: Session = Depends(get_db)):
|
|
if not users_exist(db):
|
|
return RedirectResponse("/setup", status_code=303)
|
|
if current_user(request, db) is not None:
|
|
return RedirectResponse(_safe_next(next), status_code=303)
|
|
return templates.TemplateResponse(
|
|
"login.html", {"request": request, "error": None, "next": next}
|
|
)
|
|
|
|
|
|
@router.post("/login")
|
|
def login_submit(
|
|
request: Request,
|
|
username: str = Form(...),
|
|
password: str = Form(...),
|
|
next: str = Form(""),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
user = db.scalars(
|
|
select(User).where(User.username == _normalize_username(username))
|
|
).first()
|
|
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
|
|
return templates.TemplateResponse(
|
|
"login.html",
|
|
{"request": request, "error": "Wrong username or password.", "next": next},
|
|
status_code=401,
|
|
)
|
|
cookie_value, _ = create_session(db, user)
|
|
response = RedirectResponse(_safe_next(next), status_code=303)
|
|
_set_session_cookie(response, cookie_value)
|
|
return response
|
|
|
|
|
|
@router.post("/logout")
|
|
def logout(request: Request, csrf_token: str = Form(""), db: Session = Depends(get_db)):
|
|
_check_form_csrf(request, db, csrf_token)
|
|
destroy_session(db, request)
|
|
response = RedirectResponse("/login", status_code=303)
|
|
response.delete_cookie(SESSION_COOKIE)
|
|
return response
|
|
|
|
|
|
def _normalize_device_id(device_id: str) -> str:
|
|
device_id = device_id.strip().lower()
|
|
if len(device_id) != 12 or any(c not in "0123456789abcdef" for c in device_id):
|
|
raise HTTPException(400, "Invalid device id")
|
|
return device_id
|
|
|
|
|
|
def _attempt_claim(db: Session, user: User, device_id: str) -> str:
|
|
"""Claims the frame for `user` if it has registered, else records a
|
|
pending claim the frame's first check-in will attach (see
|
|
auth._register_frame). Returns "claimed" or "pending"."""
|
|
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
|
now = time.time()
|
|
if frame is not None:
|
|
if frame.owner_user_id is not None:
|
|
raise HTTPException(409, "That frame is already claimed")
|
|
frame.owner_user_id = user.id
|
|
frame.claimed_at = now
|
|
if frame.controlled_by_user_id is None:
|
|
frame.controlled_by_user_id = user.id
|
|
if db.get(UserFrame, (user.id, frame.id)) is None:
|
|
db.add(UserFrame(user_id=user.id, frame_id=frame.id))
|
|
pending = db.get(PendingClaim, device_id)
|
|
if pending is not None:
|
|
db.delete(pending)
|
|
db.commit()
|
|
logger.info("User '%s' claimed frame #%d (%s)", user.username, frame.id, device_id)
|
|
return "claimed"
|
|
|
|
pending = db.get(PendingClaim, device_id)
|
|
if pending is None:
|
|
pending = PendingClaim(device_id=device_id, user_id=user.id, created_at=now,
|
|
expires_at=now + PENDING_CLAIM_TTL_S)
|
|
db.add(pending)
|
|
else:
|
|
pending.user_id = user.id
|
|
pending.expires_at = now + PENDING_CLAIM_TTL_S
|
|
db.commit()
|
|
logger.info("User '%s' filed a pending claim for device %s", user.username, device_id)
|
|
return "pending"
|
|
|
|
|
|
def _render_claim(request: Request, db: Session, device_id: str, error: str | None = None):
|
|
user = current_user(request, db)
|
|
session = current_session(request, db) if user else None
|
|
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
|
if frame is None:
|
|
status = "unregistered"
|
|
pending = db.get(PendingClaim, device_id)
|
|
pending_yours = bool(pending and user and pending.user_id == user.id
|
|
and pending.expires_at > time.time())
|
|
elif frame.owner_user_id is None:
|
|
status, pending_yours = "unclaimed", False
|
|
elif user is not None and (
|
|
frame.owner_user_id == user.id or db.get(UserFrame, (user.id, frame.id)) is not None
|
|
):
|
|
status, pending_yours = "claimed_yours", False
|
|
else:
|
|
status, pending_yours = "claimed", False
|
|
return templates.TemplateResponse(
|
|
"claim.html",
|
|
{
|
|
"request": request,
|
|
"device_id": device_id,
|
|
"status": status,
|
|
"pending_yours": pending_yours,
|
|
"user": user,
|
|
"csrf_token": session.csrf_token if session else None,
|
|
"error": error,
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/claim", response_class=HTMLResponse)
|
|
def claim_page(request: Request, device_id: str = "", db: Session = Depends(get_db)):
|
|
"""Where the captive portal's post-provisioning redirect lands. Also
|
|
the enrollment gate: a valid device id is what entitles a stranger to
|
|
create an account (signup form on this page); everyone else gets
|
|
enrolled by the admin."""
|
|
device_id = _normalize_device_id(device_id)
|
|
return _render_claim(request, db, device_id)
|
|
|
|
|
|
@router.post("/claim")
|
|
def claim_submit(
|
|
request: Request,
|
|
device_id: str = Form(...),
|
|
csrf_token: str = Form(""),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
device_id = _normalize_device_id(device_id)
|
|
user = current_user(request, db)
|
|
if user is None:
|
|
return RedirectResponse(f"/login?next=/claim%3Fdevice_id%3D{device_id}", status_code=303)
|
|
_check_form_csrf(request, db, csrf_token)
|
|
try:
|
|
_attempt_claim(db, user, device_id)
|
|
except HTTPException as e:
|
|
if e.status_code == 409:
|
|
return _render_claim(request, db, device_id, error=e.detail)
|
|
raise
|
|
return RedirectResponse(f"/claim?device_id={device_id}", status_code=303)
|
|
|
|
|
|
@router.post("/claim/signup")
|
|
def claim_signup(
|
|
request: Request,
|
|
device_id: str = Form(...),
|
|
username: str = Form(...),
|
|
password: str = Form(...),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Account creation, gated on a plausible frame claim: the device id
|
|
must belong to a frame that is unclaimed (or not yet registered --
|
|
the user beat the device here after provisioning). A fabricated id
|
|
can create an orphan account whose pending claim expires in 24h --
|
|
accepted at household scale, and visible in /admin."""
|
|
device_id = _normalize_device_id(device_id)
|
|
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
|
if frame is not None and frame.owner_user_id is not None:
|
|
return _render_claim(request, db, device_id,
|
|
error="That frame is already claimed -- log in instead.")
|
|
username = _validate_credentials(username, password)
|
|
if db.scalars(select(User).where(User.username == username)).first() is not None:
|
|
return _render_claim(request, db, device_id,
|
|
error=f"Username '{username}' is taken -- log in instead?")
|
|
|
|
user = User(
|
|
username=username,
|
|
display_name=username,
|
|
password_hash=hash_password(password),
|
|
is_admin=False,
|
|
created_at=time.time(),
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
logger.info("User '%s' signed up via claim gate for device %s", username, device_id)
|
|
|
|
_attempt_claim(db, user, device_id)
|
|
cookie_value, _ = create_session(db, user)
|
|
response = RedirectResponse(f"/claim?device_id={device_id}", status_code=303)
|
|
_set_session_cookie(response, cookie_value)
|
|
return response
|
|
|
|
|
|
def _settings_context(request: Request, db: Session, user, saved: bool, error: str | None) -> dict:
|
|
from .common import shell_context
|
|
|
|
ctx = shell_context(request, db, user, active_nav="settings")
|
|
ctx.update({"saved": saved, "error": error})
|
|
return ctx
|
|
|
|
|
|
@router.get("/settings", response_class=HTMLResponse)
|
|
def settings_page(request: Request, db: Session = Depends(get_db)):
|
|
user = current_user(request, db)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
return templates.TemplateResponse(
|
|
"settings.html", _settings_context(request, db, user, saved=False, error=None)
|
|
)
|
|
|
|
|
|
@router.post("/settings", response_class=HTMLResponse)
|
|
def settings_submit(
|
|
request: Request,
|
|
csrf_token: str = Form(""),
|
|
display_name: str = Form(""),
|
|
immich_url: str = Form(""),
|
|
immich_api_key: str = Form(""),
|
|
current_password: str = Form(""),
|
|
new_password: str = Form(""),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
user = current_user(request, db)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
_check_form_csrf(request, db, csrf_token)
|
|
|
|
error = None
|
|
user.display_name = display_name.strip() or user.username
|
|
user.immich_url = immich_url.strip()
|
|
# 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
|
|
# in every browser's autofill store).
|
|
if immich_api_key.strip():
|
|
user.immich_api_key = immich_api_key.strip()
|
|
|
|
if new_password:
|
|
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
|
error = "Current password is wrong -- password not changed."
|
|
elif len(new_password) < PASSWORD_MIN_LEN:
|
|
error = f"New password must be at least {PASSWORD_MIN_LEN} characters."
|
|
else:
|
|
user.password_hash = hash_password(new_password)
|
|
|
|
db.commit()
|
|
return templates.TemplateResponse(
|
|
"settings.html", _settings_context(request, db, user, saved=error is None, error=error)
|
|
)
|
|
|
|
|
|
def _require_admin_page(request: Request, db: Session) -> User:
|
|
user = current_user(request, db)
|
|
if user is None or not user.is_admin:
|
|
raise HTTPException(403, "Admin only")
|
|
return user
|
|
|
|
|
|
def _render_admin(request: Request, db: Session, admin: User, notice: str | None = None,
|
|
error: str | None = None) -> HTMLResponse:
|
|
from .common import shell_context
|
|
|
|
users = list(db.scalars(select(User).order_by(User.id)))
|
|
frames = list(db.scalars(select(Frame).order_by(Frame.id)))
|
|
links = list(db.scalars(select(UserFrame)))
|
|
links_by_frame: dict[int, list[User]] = {}
|
|
users_by_id = {u.id: u for u in users}
|
|
for link in links:
|
|
links_by_frame.setdefault(link.frame_id, []).append(users_by_id[link.user_id])
|
|
ctx = shell_context(request, db, admin, active_nav="admin")
|
|
ctx.update({
|
|
"users": users,
|
|
"frames": frames,
|
|
"links_by_frame": links_by_frame,
|
|
"notice": notice,
|
|
"error": error,
|
|
})
|
|
return templates.TemplateResponse("admin.html", ctx)
|
|
|
|
|
|
@router.get("/admin", response_class=HTMLResponse)
|
|
def admin_page(request: Request, db: Session = Depends(get_db)):
|
|
user = current_user(request, db)
|
|
if user is None:
|
|
return RedirectResponse("/login", status_code=303)
|
|
if not user.is_admin:
|
|
raise HTTPException(403, "Admin only")
|
|
return _render_admin(request, db, user)
|
|
|
|
|
|
@router.post("/admin/users", response_class=HTMLResponse)
|
|
def admin_create_user(
|
|
request: Request,
|
|
csrf_token: str = Form(""),
|
|
username: str = Form(...),
|
|
password: str = Form(...),
|
|
is_admin: bool = Form(False),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
admin = _require_admin_page(request, db)
|
|
_check_form_csrf(request, db, csrf_token)
|
|
username = _validate_credentials(username, password)
|
|
if db.scalars(select(User).where(User.username == username)).first() is not None:
|
|
return _render_admin(request, db, admin, error=f"Username '{username}' already exists.")
|
|
db.add(User(
|
|
username=username,
|
|
display_name=username,
|
|
password_hash=hash_password(password),
|
|
is_admin=is_admin,
|
|
created_at=time.time(),
|
|
))
|
|
db.commit()
|
|
return _render_admin(request, db, admin, notice=f"User '{username}' created.")
|
|
|
|
|
|
@router.post("/admin/users/{user_id}/reset-password", response_class=HTMLResponse)
|
|
def admin_reset_password(
|
|
user_id: int,
|
|
request: Request,
|
|
csrf_token: str = Form(""),
|
|
password: str = Form(...),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
admin = _require_admin_page(request, db)
|
|
_check_form_csrf(request, db, csrf_token)
|
|
target = db.get(User, user_id)
|
|
if target is None:
|
|
return _render_admin(request, db, admin, error="No such user.")
|
|
if len(password) < PASSWORD_MIN_LEN:
|
|
return _render_admin(request, db, admin,
|
|
error=f"Password must be at least {PASSWORD_MIN_LEN} characters.")
|
|
target.password_hash = hash_password(password)
|
|
db.commit()
|
|
return _render_admin(request, db, admin, notice=f"Password reset for '{target.username}'.")
|
|
|
|
|
|
@router.post("/admin/users/{user_id}/delete", response_class=HTMLResponse)
|
|
def admin_delete_user(
|
|
user_id: int,
|
|
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 user_id == admin.id:
|
|
return _render_admin(request, db, admin, error="You can't delete your own account.")
|
|
target = db.get(User, user_id)
|
|
if target is None:
|
|
return _render_admin(request, db, admin, error="No such user.")
|
|
name = target.username
|
|
db.delete(target) # sessions/links cascade; frames.owner goes NULL
|
|
db.commit()
|
|
return _render_admin(request, db, admin, notice=f"User '{name}' deleted.")
|
|
|
|
|
|
@router.post("/admin/frames/{frame_id}/link-user", response_class=HTMLResponse)
|
|
def admin_link_user(
|
|
frame_id: int,
|
|
request: Request,
|
|
csrf_token: str = Form(""),
|
|
username: str = Form(...),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
admin = _require_admin_page(request, db)
|
|
_check_form_csrf(request, db, csrf_token)
|
|
frame = db.get(Frame, frame_id)
|
|
target = db.scalars(select(User).where(User.username == _normalize_username(username))).first()
|
|
if frame is None or target is None:
|
|
return _render_admin(request, db, admin, error="No such frame or user.")
|
|
if db.get(UserFrame, (target.id, frame_id)) is not None:
|
|
return _render_admin(request, db, admin, error=f"'{target.username}' is already linked.")
|
|
db.add(UserFrame(user_id=target.id, frame_id=frame_id))
|
|
if frame.owner_user_id is None:
|
|
# Linking to an unclaimed frame claims it -- the admin flow for
|
|
# adopting a frame that self-registered without a pending claim.
|
|
frame.owner_user_id = target.id
|
|
frame.claimed_at = time.time()
|
|
db.commit()
|
|
return _render_admin(request, db, admin,
|
|
notice=f"Linked '{target.username}' to frame #{frame_id}.")
|
|
|
|
|
|
@router.post("/admin/frames/{frame_id}/end-legacy", response_class=HTMLResponse)
|
|
def admin_end_legacy(
|
|
frame_id: int,
|
|
request: Request,
|
|
csrf_token: str = Form(""),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Closes the legacy-token migration window once the device is
|
|
confirmed on per-frame auth (device_token_ack + recent last_seen in
|
|
the frames table below)."""
|
|
admin = _require_admin_page(request, db)
|
|
_check_form_csrf(request, db, csrf_token)
|
|
frame = db.get(Frame, frame_id)
|
|
if frame is None:
|
|
return _render_admin(request, db, admin, error="No such frame.")
|
|
frame.legacy_token_enabled = False
|
|
db.commit()
|
|
return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.")
|
|
|
|
|
|
@router.post("/admin/frames/{frame_id}/delete", response_class=HTMLResponse)
|
|
def admin_delete_frame(
|
|
frame_id: int,
|
|
request: Request,
|
|
csrf_token: str = Form(""),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
admin = _require_admin_page(request, db)
|
|
_check_form_csrf(request, db, csrf_token)
|
|
frame = db.get(Frame, frame_id)
|
|
if frame is None:
|
|
return _render_admin(request, db, admin, error="No such frame.")
|
|
db.delete(frame) # links/battery log cascade
|
|
db.commit()
|
|
return _render_admin(request, db, admin, notice=f"Frame #{frame_id} deleted.")
|