Redesign phase C: claim flow, limited manage page, device protocol
The frame-claiming pipeline, end to end. Firmware: every request now carries ?id=<12-hex STA MAC> via build_url (mirrored in build_ota_url), and the captive portal's success page became a redirect that hands the user's browser to <server>/claim?device_id=... after ~7s -- enough time for the phone to drop the provisioning AP while the device reboots. The server pushes a per-frame device token through /frame/config during a one-time handshake; the firmware persists it to NVS (a dedicated single-key write that deliberately doesn't reset the connected-once flag or WiFi cache) and prefers it over the provisioned shared token from the next request on. Config response buffer grows 256->512. Both board variants compile clean; new firmware also works against an old server (which ignores ?id=) and old firmware against this server (the phase A legacy mapping), so either deploy order survives. Server: /claim lands the captive-portal redirect -- claim-gated signup (a valid unclaimed/unregistered device id IS the enrollment invitation), pending claims for the user-beats-the-frame race (auto-attached at self-registration, 24h expiry), and a waiting page that refreshes until the frame checks in. Unclaimed/unconfigured frames get a rendered instruction placeholder with a QR from /frame/image (200, never an error loop) -- new qrcode dep, placeholder shares the exact quantize/pack path photos use. The on-frame manage QR now resolves to a limited no-login page: scans of / carrying device credentials (new ?id&token or the legacy shared token) 303 to /m/<manage_token>, which allows exactly view queue, show-next, advance, back, and scoped thumbnails -- no settings, no removal, no other frames. Full control means logging in. One real protocol hole found by simulating full wake cycles: after self-registration the device could never authenticate again (the wake cycle fetches the image BEFORE /frame/config delivers its token). require_device now treats the id itself as the credential until the first authenticated request flips device_token_ack -- the same trust level as open registration, closing permanently once the handshake completes.
This commit is contained in:
+14
-2
@@ -265,8 +265,20 @@ def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
|
||||
if token_ok and not frame.device_token_ack:
|
||||
frame.device_token_ack = True
|
||||
logger.info("Frame #%d acknowledged its device token", frame.id)
|
||||
if not token_ok and not (frame.legacy_token_enabled and legacy_ok):
|
||||
raise HTTPException(401, "Missing or invalid access token")
|
||||
if not token_ok:
|
||||
if frame.legacy_token_enabled and legacy_ok:
|
||||
pass
|
||||
elif not frame.device_token_ack:
|
||||
# Handshake window: the device registered but hasn't
|
||||
# received its token yet (the wake cycle fetches the
|
||||
# image BEFORE polling /frame/config, where the token
|
||||
# is delivered) -- the id stays the credential, same
|
||||
# trust level as the open registration that created
|
||||
# the row. Closes permanently on the first
|
||||
# authenticated request.
|
||||
pass
|
||||
else:
|
||||
raise HTTPException(401, "Missing or invalid access token")
|
||||
else:
|
||||
if not legacy_ok:
|
||||
raise HTTPException(401, "Missing or invalid access token")
|
||||
|
||||
@@ -174,7 +174,15 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||
else:
|
||||
fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
|
||||
|
||||
quantized = fitted.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
return _quantize_and_pack(fitted, orientation)
|
||||
|
||||
|
||||
def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes:
|
||||
"""The shared back half of rendering: 6-color Floyd-Steinberg
|
||||
quantization, rotation into native panel space, and 2-pixels/byte
|
||||
packing. Takes an RGB image already composed at logical_render_size()
|
||||
for the orientation."""
|
||||
quantized = logical_img.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
transpose = ORIENTATION_TRANSPOSE.get(orientation)
|
||||
if transpose is not None:
|
||||
quantized = quantized.transpose(transpose)
|
||||
@@ -190,3 +198,52 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||
i += 1
|
||||
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
orientation: str = "landscape") -> bytes:
|
||||
"""A readable full-panel message (plus an optional QR code) in the
|
||||
same packed format as render_frame -- what /frame/image serves for a
|
||||
frame that isn't claimed or configured yet, so a fresh device shows
|
||||
instructions instead of an error screen and never error-loops."""
|
||||
from PIL import ImageDraw, ImageFont
|
||||
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
title_font = ImageFont.load_default(size=34)
|
||||
body_font = ImageFont.load_default(size=24)
|
||||
|
||||
qr_img = None
|
||||
if qr_url:
|
||||
import qrcode
|
||||
|
||||
qr = qrcode.QRCode(border=1, box_size=1)
|
||||
qr.add_data(qr_url)
|
||||
qr.make(fit=True)
|
||||
raw = qr.make_image().get_image().convert("RGB")
|
||||
# Integer upscale with NEAREST keeps modules crisp on the panel.
|
||||
target = 220
|
||||
scale = max(1, target // raw.width)
|
||||
qr_img = raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
|
||||
|
||||
# Vertical layout: text block, then QR under it, centered as a group.
|
||||
line_heights = []
|
||||
for i, line in enumerate(lines):
|
||||
font = title_font if i == 0 else body_font
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
line_heights.append((line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
|
||||
gap = 14
|
||||
text_h = sum(h for _, _, _, h in line_heights) + gap * (len(line_heights) - 1 if line_heights else 0)
|
||||
total_h = text_h + (qr_img.height + 28 if qr_img else 0)
|
||||
y = max(20, (logical_h - total_h) // 2)
|
||||
|
||||
for line, font, w, h in line_heights:
|
||||
draw.text(((logical_w - w) // 2, y), line, fill=(0, 0, 0), font=font)
|
||||
y += h + gap
|
||||
|
||||
if qr_img:
|
||||
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
|
||||
|
||||
return _quantize_and_pack(img, orientation)
|
||||
|
||||
+43
-23
@@ -15,9 +15,10 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import migration
|
||||
from .auth import (
|
||||
MANAGEMENT_TOKEN_COOKIE,
|
||||
browser_token_valid,
|
||||
current_session,
|
||||
current_user,
|
||||
@@ -25,8 +26,9 @@ from .auth import (
|
||||
users_exist,
|
||||
)
|
||||
from .db import SessionLocal
|
||||
from .models import Frame
|
||||
from .quiet_hours import ALL_TIMEZONES
|
||||
from .routers import api, device, pages
|
||||
from .routers import api, device, manage, pages
|
||||
from .routers.common import default_frame, immich_creds
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -40,6 +42,30 @@ templates = Jinja2Templates(directory="app/templates")
|
||||
app.include_router(device.router)
|
||||
app.include_router(api.router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(manage.router)
|
||||
|
||||
|
||||
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
|
||||
"""The on-frame manage QR points at the server root with the device's
|
||||
own credentials (new firmware: ?id=&token=; deployed firmware:
|
||||
?token=<legacy shared token>). Those scans get the frame's limited
|
||||
manage page -- never the full UI, which now requires a login.
|
||||
allow_legacy is False before /setup has run: at that point a bare
|
||||
?token= hit is the admin coming through the token prompt to do
|
||||
first-run setup, not a QR scan."""
|
||||
device_id = request.query_params.get("id", "").strip().lower()
|
||||
token = request.query_params.get("token", "")
|
||||
if device_id and token:
|
||||
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
||||
if frame is not None and token == frame.device_token:
|
||||
return f"/m/{frame.manage_token}"
|
||||
if allow_legacy and token and management_token() and token == management_token():
|
||||
frame = db.scalars(
|
||||
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
|
||||
).first()
|
||||
if frame is not None:
|
||||
return f"/m/{frame.manage_token}"
|
||||
return None
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -50,30 +76,34 @@ def health() -> dict:
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
"""The web UI (still the single-frame page until Phase D). Access:
|
||||
a user session (normal path once /setup has run), the legacy shared
|
||||
token (the deployed frame's manage QR embeds ?token= -- kept working
|
||||
until Phase C replaces it with the limited manage page), or -- only
|
||||
while no users exist AND no token is configured -- fully open, the
|
||||
original trusted-LAN default."""
|
||||
a user session (the normal path once /setup has run), or -- only
|
||||
while no users exist AND no MANAGEMENT_TOKEN is configured -- fully
|
||||
open, the original trusted-LAN default. A hit carrying device
|
||||
credentials (the on-frame manage QR) redirects to that frame's
|
||||
limited manage page instead."""
|
||||
with SessionLocal() as db:
|
||||
have_users = users_exist(db)
|
||||
manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users)
|
||||
if manage_redirect is not None:
|
||||
return RedirectResponse(manage_redirect, status_code=303)
|
||||
|
||||
user = current_user(request, db)
|
||||
session = current_session(request, db) if user else None
|
||||
legacy_ok = browser_token_valid(request)
|
||||
|
||||
if user is None and not legacy_ok:
|
||||
if not users_exist(db):
|
||||
if management_token():
|
||||
if user is None:
|
||||
if not have_users:
|
||||
if management_token() and not browser_token_valid(request):
|
||||
supplied = request.query_params.get("token")
|
||||
return templates.TemplateResponse(
|
||||
"token_prompt.html", {"request": request, "wrong": supplied is not None}
|
||||
)
|
||||
# Fresh install, nothing configured: open, but nudge setup.
|
||||
# Pre-setup: reachable (optionally token-gated), nudge setup.
|
||||
return RedirectResponse("/setup", status_code=303)
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
frame = default_frame(db)
|
||||
immich_url, _ = immich_creds(frame)
|
||||
response = templates.TemplateResponse(
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{
|
||||
"request": request,
|
||||
@@ -84,13 +114,3 @@ def index(request: Request):
|
||||
"csrf_token": session.csrf_token if session else None,
|
||||
},
|
||||
)
|
||||
|
||||
supplied = request.query_params.get("token")
|
||||
if management_token() and supplied == management_token():
|
||||
# Query-param access (typically the manage-menu QR code) earns a
|
||||
# cookie so the rest of this visit's fetch()/<img> calls -- which
|
||||
# never carry the query string -- stay authorized too.
|
||||
response.set_cookie(
|
||||
MANAGEMENT_TOKEN_COOKIE, supplied, max_age=86400 * 365, httponly=True, samesite="lax"
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -22,12 +22,14 @@ from ..auth import require_device
|
||||
from ..db import frame_locked, get_db
|
||||
from ..face_labels import compute_face_labels
|
||||
from ..firmware import firmware_path
|
||||
from ..image_pipeline import render_placeholder
|
||||
from ..models import BatteryLog, Frame
|
||||
from .common import (
|
||||
BATTERY_HISTORY_MAX,
|
||||
BATTERY_LOG_MAX,
|
||||
RECHARGE_JUMP_PCT,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
render_asset,
|
||||
require_configured,
|
||||
@@ -38,10 +40,43 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _setup_placeholder(frame: Frame, request: Request) -> bytes:
|
||||
"""What an unclaimed or not-yet-configured frame displays instead of a
|
||||
photo -- instructions with a QR, rendered at 200 so the device treats
|
||||
it as a perfectly normal image and never error-loops. The URLs are
|
||||
built from the request's own base URL: whatever address the device
|
||||
reached us at is by definition an address that works on this
|
||||
network."""
|
||||
base = str(request.base_url).rstrip("/")
|
||||
if frame.owner_user_id is None and frame.device_id:
|
||||
claim_url = f"{base}/claim?device_id={frame.device_id}"
|
||||
return render_placeholder(
|
||||
["This frame isn't claimed yet", "Scan to link it to your account:"],
|
||||
qr_url=claim_url,
|
||||
orientation=frame.orientation,
|
||||
)
|
||||
if frame.owner_user_id is None:
|
||||
return render_placeholder(
|
||||
["Almost there!", f"Open {base} to finish setting up this frame."],
|
||||
orientation=frame.orientation,
|
||||
)
|
||||
return render_placeholder(
|
||||
["Almost there!", "Pick an album for this frame:", base],
|
||||
qr_url=base,
|
||||
orientation=frame.orientation,
|
||||
)
|
||||
|
||||
|
||||
def _frame_configured(frame: Frame) -> bool:
|
||||
url, key = immich_creds(frame)
|
||||
return bool(url and key and frame.album_id)
|
||||
|
||||
|
||||
# Renderer dispatch seam for future frame modes (calendar, canva, ...):
|
||||
# /frame/image looks up the frame's mode here. Only photos exists today.
|
||||
def _render_photos_mode(db: Session, frame: Frame) -> bytes:
|
||||
require_configured(frame)
|
||||
def _render_photos_mode(db: Session, frame: Frame, request: Request) -> bytes:
|
||||
if not _frame_configured(frame):
|
||||
return _setup_placeholder(frame, request)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
@@ -93,14 +128,18 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
|
||||
|
||||
|
||||
@router.get("/frame/image")
|
||||
def frame_image(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
def frame_image(
|
||||
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Returns the frame's current image. For photos mode: idempotent --
|
||||
only actually advances to the next photo once refresh_interval_s has
|
||||
elapsed since the current one was set (see app/photo_queue.py) --
|
||||
safe to call as often as the device wants, including after an
|
||||
unplanned reboot, without skipping ahead in the album."""
|
||||
unplanned reboot, without skipping ahead in the album. An unclaimed/
|
||||
unconfigured frame gets a rendered instruction placeholder (200, not
|
||||
an error) so a fresh device never error-loops."""
|
||||
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
|
||||
return Response(content=renderer(db, frame), media_type="application/octet-stream")
|
||||
return Response(content=renderer(db, frame, request), media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.post("/frame/advance")
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""The limited no-login manage surface behind the on-frame "scan to
|
||||
manage" QR. The QR resolves to /m/<manage_token> (see main.index's
|
||||
device-credential redirect); the token grants exactly: view the current
|
||||
photo + upcoming queue, promote ("show next"), advance, back, and
|
||||
thumbnails. No settings, no removal, no other frames -- full control
|
||||
requires logging in."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import photo_queue, quiet_hours
|
||||
from ..db import frame_locked, get_db
|
||||
from ..models import Frame
|
||||
from .common import immich_client_for, list_assets, require_configured
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
def require_manage(manage_token: str, db: Session = Depends(get_db)) -> Frame:
|
||||
frame = db.scalars(select(Frame).where(Frame.manage_token == manage_token)).first()
|
||||
if frame is None:
|
||||
raise HTTPException(404, "Unknown manage link")
|
||||
return frame
|
||||
|
||||
|
||||
@router.get("/m/{manage_token}", response_class=HTMLResponse)
|
||||
def manage_page(manage_token: str, request: Request, db: Session = Depends(get_db)):
|
||||
frame = require_manage(manage_token, db)
|
||||
return templates.TemplateResponse(
|
||||
"manage.html",
|
||||
{"request": request, "frame": frame, "manage_token": manage_token},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/m/{manage_token}/queue")
|
||||
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
||||
photo_queue.sync_queue_length(cfg, assets)
|
||||
current = cfg.current_asset_id
|
||||
queue = list(cfg.queue)
|
||||
|
||||
def entry(asset_id: str) -> dict:
|
||||
return {"id": asset_id, "thumbnail_url": f"/api/m/{frame.manage_token}/thumbnail/{asset_id}"}
|
||||
|
||||
return {
|
||||
"frame_name": frame.name,
|
||||
"current": entry(current) if current else None,
|
||||
"upcoming": [entry(asset_id) for asset_id in queue],
|
||||
}
|
||||
|
||||
|
||||
class ManagePromoteRequest(BaseModel):
|
||||
asset_id: str
|
||||
|
||||
|
||||
@router.post("/api/m/{manage_token}/promote")
|
||||
def manage_promote(
|
||||
body: ManagePromoteRequest,
|
||||
frame: Frame = Depends(require_manage),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
if body.asset_id not in cfg.queue:
|
||||
raise HTTPException(400, "That photo is no longer in the upcoming queue")
|
||||
cfg.queue = [body.asset_id] + [a for a in cfg.queue if a != body.asset_id]
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.post("/api/m/{manage_token}/advance")
|
||||
def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
"""Advances the server-side current photo; the panel itself updates
|
||||
on the device's next wake (or its next-photo button)."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.advance_forced(cfg, assets)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.post("/api/m/{manage_token}/back")
|
||||
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.back_forced(cfg, assets)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.get("/api/m/{manage_token}/thumbnail/{asset_id}")
|
||||
def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage)):
|
||||
"""Thumbnails scoped to what this frame is actually showing/queuing --
|
||||
the manage token must not become a general Immich proxy."""
|
||||
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
||||
raise HTTPException(404, "Not on this frame")
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
content, content_type = client.download_asset_thumbnail(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
|
||||
return Response(content=content, media_type=content_type)
|
||||
+163
-6
@@ -30,7 +30,7 @@ from ..auth import (
|
||||
verify_password,
|
||||
)
|
||||
from ..db import get_db
|
||||
from ..models import Frame, User, UserFrame
|
||||
from ..models import Frame, PendingClaim, User, UserFrame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,6 +39,7 @@ 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:
|
||||
@@ -129,13 +130,23 @@ def setup_submit(
|
||||
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, db: Session = Depends(get_db)):
|
||||
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("/", status_code=303)
|
||||
return templates.TemplateResponse("login.html", {"request": request, "error": None})
|
||||
return RedirectResponse(_safe_next(next), status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"login.html", {"request": request, "error": None, "next": next}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
@@ -143,6 +154,7 @@ def login_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
next: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = db.scalars(
|
||||
@@ -151,11 +163,11 @@ def login_submit(
|
||||
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."},
|
||||
{"request": request, "error": "Wrong username or password.", "next": next},
|
||||
status_code=401,
|
||||
)
|
||||
cookie_value, _ = create_session(db, user)
|
||||
response = RedirectResponse("/", status_code=303)
|
||||
response = RedirectResponse(_safe_next(next), status_code=303)
|
||||
_set_session_cookie(response, cookie_value)
|
||||
return response
|
||||
|
||||
@@ -169,6 +181,151 @@ def logout(request: Request, csrf_token: str = Form(""), db: Session = Depends(g
|
||||
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
|
||||
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
def settings_page(request: Request, db: Session = Depends(get_db)):
|
||||
user = current_user(request, db)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Claim your frame</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% if status == "unregistered" %}<meta http-equiv="refresh" content="6">{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Frame <code>{{ device_id }}</code></h2>
|
||||
|
||||
{% if status == "claimed_yours" %}
|
||||
<div class="status ok">This frame is linked to your account.</div>
|
||||
<p class="sub">It will show up in your frame list. If it was just
|
||||
provisioned, give it a minute to connect and fetch its first image.</p>
|
||||
<p><a href="/">Go to your frames</a></p>
|
||||
|
||||
{% elif status == "claimed" %}
|
||||
<p class="sub">This frame already belongs to someone. If it's yours,
|
||||
ask them (or an admin) to link your account to it.</p>
|
||||
|
||||
{% elif status == "unregistered" %}
|
||||
{% if pending_yours %}
|
||||
<div class="status ok">Claim recorded.</div>
|
||||
<p class="sub">Waiting for the frame to connect for the first time --
|
||||
it links to your account automatically the moment it checks in.
|
||||
This page refreshes itself; it's safe to close, too.</p>
|
||||
{% else %}
|
||||
<p class="sub">The frame hasn't checked in yet -- it's probably still
|
||||
restarting and joining your WiFi. This page refreshes itself.
|
||||
{% if user %}You can claim it now anyway; it'll attach when it
|
||||
arrives.{% endif %}</p>
|
||||
{% endif %}
|
||||
{% elif status == "unclaimed" %}
|
||||
<p class="sub">This frame is connected and ready to be claimed.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if user and status in ("unclaimed", "unregistered") and not pending_yours %}
|
||||
<form method="post" action="/claim">
|
||||
<input type="hidden" name="device_id" value="{{ device_id }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">Claim this frame</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if not user and status in ("unclaimed", "unregistered") %}
|
||||
<section class="card">
|
||||
<h2 class="card-title">Create your account</h2>
|
||||
<p class="sub">A valid frame is your invitation -- set up an account to
|
||||
claim it. Already have one?
|
||||
<a href="/login?next=/claim%3Fdevice_id%3D{{ device_id }}">Log in instead</a>.</p>
|
||||
<form method="post" action="/claim/signup">
|
||||
<input type="hidden" name="device_id" value="{{ device_id }}">
|
||||
<label>Username
|
||||
<input type="text" name="username" maxlength="64" required autocomplete="username">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" minlength="8" required autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">Create account & claim frame</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -11,6 +11,7 @@
|
||||
<h2 class="card-title">Log in</h2>
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<label>Username
|
||||
<input type="text" name="username" maxlength="64" required autofocus autocomplete="username">
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">{{ frame.name or "Frame" }} — quick controls</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Up next</h2>
|
||||
<p class="sub">Tap "Show next" to move a photo to the front. The frame
|
||||
picks it up on its next refresh. <a href="/login">Log in</a> for full
|
||||
settings.</p>
|
||||
<div id="upcoming-grid" class="photo-grid"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="side-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Now displaying</h2>
|
||||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button type="button" class="secondary" id="btn-back">← Previous</button>
|
||||
<button type="button" class="secondary" id="btn-advance">Next →</button>
|
||||
</div>
|
||||
<p class="sub" style="margin-top: 8px;">Changes what the frame shows on
|
||||
its next wake.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const TOKEN = {{ manage_token | tojson }};
|
||||
const resultEl = document.getElementById('result');
|
||||
|
||||
function showStatus(ok, message) {
|
||||
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
|
||||
}
|
||||
|
||||
async function post(path, body) {
|
||||
const resp = await fetch(`/api/m/${TOKEN}/${path}`, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
const currentEl = document.getElementById('current-thumb');
|
||||
const grid = document.getElementById('upcoming-grid');
|
||||
try {
|
||||
const resp = await fetch(`/api/m/${TOKEN}/queue`);
|
||||
if (!resp.ok) {
|
||||
currentEl.innerHTML = '<p class="sub">This frame isn\'t set up yet.</p>';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
currentEl.innerHTML = '';
|
||||
if (data.current) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'thumb';
|
||||
img.src = data.current.thumbnail_url;
|
||||
img.alt = '';
|
||||
currentEl.appendChild(img);
|
||||
} else {
|
||||
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
|
||||
}
|
||||
grid.innerHTML = '';
|
||||
for (const item of data.upcoming) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'photo-card';
|
||||
const img = document.createElement('img');
|
||||
img.src = item.thumbnail_url;
|
||||
img.alt = '';
|
||||
img.draggable = false;
|
||||
card.appendChild(img);
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'show-next';
|
||||
btn.textContent = 'Show next';
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
await post('promote', { asset_id: item.id });
|
||||
showStatus(true, 'Moved to the front.');
|
||||
loadQueue();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
card.appendChild(btn);
|
||||
grid.appendChild(card);
|
||||
}
|
||||
} catch (e) {
|
||||
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btn-advance').addEventListener('click', async () => {
|
||||
try { await post('advance'); showStatus(true, 'Advanced.'); loadQueue(); }
|
||||
catch (e) { showStatus(false, e.message); }
|
||||
});
|
||||
document.getElementById('btn-back').addEventListener('click', async () => {
|
||||
try { await post('back'); showStatus(true, 'Went back.'); loadQueue(); }
|
||||
catch (e) { showStatus(false, e.message); }
|
||||
});
|
||||
|
||||
loadQueue();
|
||||
setInterval(loadQueue, 15000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -6,3 +6,4 @@ pillow==12.3.0
|
||||
python-multipart==0.0.20
|
||||
jinja2==3.1.5
|
||||
sqlalchemy==2.0.51
|
||||
qrcode==8.2
|
||||
|
||||
Reference in New Issue
Block a user