The share QR's URL carried no auth params at all, so it silently fell back through require_device's legacy-token resolution to whichever frame happened to still be flagged legacy -- working only by accident for a single frame, sharing the wrong frame's photos for any other, and going fully dead once that frame's legacy flag was cleared. Move the endpoint to manage.py, keyed on the frame's own manage_token (same pattern /m/<manage_token> already uses) instead of device auth. Since the server now resolves assets itself instead of trusting a caller-supplied asset_id, it naturally generalizes to gather every photo widget's current photo into one Immich share link, not just one "primary" widget's.
163 lines
6.7 KiB
Python
163 lines
6.7 KiB
Python
"""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, RedirectResponse, 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 get_db, widget_locked
|
|
from ..models import Frame, PhotoWidgetConfig
|
|
from .common import (
|
|
immich_client_for,
|
|
immich_creds,
|
|
list_assets,
|
|
photo_widget_config_or_404,
|
|
photo_widgets_for_frame,
|
|
)
|
|
|
|
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)):
|
|
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, pcfg.album_id)
|
|
|
|
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
|
|
photo_queue.get_current(locked_pcfg, assets, locked_frame,
|
|
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
|
|
photo_queue.sync_queue_length(locked_pcfg, assets)
|
|
current = locked_pcfg.current_asset_id
|
|
queue = list(locked_pcfg.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),
|
|
):
|
|
photo_widget, _ = photo_widget_config_or_404(db, frame)
|
|
with widget_locked(db, frame.id, photo_widget.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)."""
|
|
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, pcfg.album_id)
|
|
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
|
|
photo_queue.advance_forced(locked_pcfg, assets, locked_frame)
|
|
return {"status": "saved"}
|
|
|
|
|
|
@router.post("/api/m/{manage_token}/back")
|
|
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
|
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, pcfg.album_id)
|
|
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
|
|
photo_queue.back_forced(locked_pcfg, assets, locked_frame)
|
|
return {"status": "saved"}
|
|
|
|
|
|
@router.get("/api/m/{manage_token}/thumbnail/{asset_id}")
|
|
def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
|
"""Thumbnails scoped to what this frame is actually showing/queuing --
|
|
the manage token must not become a general Immich proxy."""
|
|
_, pcfg = photo_widget_config_or_404(db, frame)
|
|
if asset_id != pcfg.current_asset_id and asset_id not in pcfg.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)
|
|
|
|
|
|
@router.get("/frame/share/{manage_token}")
|
|
def manage_share(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
|
"""Creates a 30-minute public Immich share link covering every photo
|
|
widget's currently-displayed asset on this frame, and redirects to it
|
|
-- what the manage overlay's bottom-left QR code points to. Lazily
|
|
created (only when someone actually scans it, not when the manage
|
|
button was pressed), so the 30-minute window starts at actual use.
|
|
Keyed on this frame's own manage_token, like the rest of this router,
|
|
rather than device credentials -- a phone scanning a QR code has no
|
|
way to supply the device's ?id=/?token=, which is why this used to
|
|
silently fall back to whichever frame happened to still carry the
|
|
legacy migration token instead of the frame that was actually
|
|
scanned."""
|
|
photo_widgets = photo_widgets_for_frame(db, frame)
|
|
asset_ids: list[str] = []
|
|
for widget in photo_widgets:
|
|
cfg = db.get(PhotoWidgetConfig, widget.id)
|
|
if cfg.current_asset_id and cfg.current_asset_id not in asset_ids:
|
|
asset_ids.append(cfg.current_asset_id)
|
|
if not asset_ids:
|
|
raise HTTPException(404, "No photos currently showing on this frame")
|
|
|
|
url, key = immich_creds(frame)
|
|
if not url or not key:
|
|
raise HTTPException(400, "Immich URL/API key not configured yet")
|
|
|
|
client = immich_client_for(frame)
|
|
try:
|
|
share_url = client.create_share_link(asset_ids, expires_in_s=1800)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not create share link: {e}") from e
|
|
return RedirectResponse(share_url)
|