Fix scan-to-download auth and share every photo widget's current photo

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.
This commit is contained in:
2026-07-27 14:38:26 +00:00
parent af513c1b5a
commit c323402895
8 changed files with 178 additions and 91 deletions
+3 -34
View File
@@ -1,7 +1,7 @@
"""End-to-end HTTP tests for the widget-system cutover in
routers/device.py -- /frame/image, /frame/advance, /frame/back, and
/frame/share against real widget rows (via the migration-backfilled
frame #1, or a purpose-built second frame), a real TestClient, real
routers/device.py -- /frame/image, /frame/advance, and /frame/back
against real widget rows (via the migration-backfilled frame #1, or a
purpose-built second frame), a real TestClient, real
render_panel/compose_into. Only Immich itself is mocked (monkeypatched
at the app.widgets.photos module boundary, same pattern as
test_widgets_photos.py) -- everything else in the pipeline is real.
@@ -23,7 +23,6 @@ from app.models import (
Frame,
FrameButtonAction,
PhotoWidgetConfig,
User,
Widget,
)
@@ -145,33 +144,3 @@ def test_two_widget_frame_composites_both_and_next_targets_calendar(client, db_s
photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id)
assert cal_cfg.browse_offset == 1
assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it
def test_frame_share_checks_widget_scoped_state(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
frame.owner_user_id = db_session.query(User).filter_by(username="alice").one().id
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.album_id = "album-1"
cfg.current_asset_id = "asset-1"
cfg.queue = ["asset-2"]
frame.immich_url = "http://immich.example.com"
frame.immich_api_key = "key"
db_session.commit()
# Not showing/queued -- rejected before ever touching Immich
resp = client.get("/frame/share/asset-not-on-this-frame")
assert resp.status_code == 404
# Currently showing -- allowed through to the (mocked) Immich call
monkeypatch.setattr(
"app.routers.device.immich_client_for",
lambda frame: type("C", (), {"create_share_link": lambda self, asset_id, expires_in_s: "https://immich.example.com/share/abc"})(),
)
resp = client.get("/frame/share/asset-1", follow_redirects=False)
assert resp.status_code in (302, 303, 307)
# Queued (not current) -- also allowed
resp = client.get("/frame/share/asset-2", follow_redirects=False)
assert resp.status_code in (302, 303, 307)
+103
View File
@@ -119,3 +119,106 @@ def test_manage_thumbnail_scoped_to_showing_or_queued(client, db_session, monkey
def test_unknown_manage_token_404s(client, db_session):
resp = client.get("/api/m/not-a-real-token/queue")
assert resp.status_code == 404
def test_manage_share_requires_current_photo(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame, _ = _configure_photo_widget(db_session)
resp = client.get(f"/frame/share/{frame.manage_token}", follow_redirects=False)
assert resp.status_code == 404
def test_manage_share_creates_link_and_redirects(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame, widget = _configure_photo_widget(db_session)
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.current_asset_id = "asset-1"
db_session.commit()
calls = []
monkeypatch.setattr(
"app.routers.manage.immich_client_for",
lambda frame: type("C", (), {
"create_share_link": lambda self, asset_ids, expires_in_s: (
calls.append(asset_ids) or "https://immich.example.com/share/abc"
),
})(),
)
resp = client.get(f"/frame/share/{frame.manage_token}", follow_redirects=False)
assert resp.status_code in (302, 303, 307)
assert calls == [["asset-1"]]
def test_manage_share_covers_every_photo_widget(client, db_session, monkeypatch):
from app.models import Widget
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame, widget = _configure_photo_widget(db_session)
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.current_asset_id = "asset-1"
second_widget = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
sort_order=1, created_at=0)
db_session.add(second_widget)
db_session.flush()
db_session.add(PhotoWidgetConfig(widget_id=second_widget.id, album_id="album-2", current_asset_id="asset-9"))
db_session.commit()
calls = []
monkeypatch.setattr(
"app.routers.manage.immich_client_for",
lambda frame: type("C", (), {
"create_share_link": lambda self, asset_ids, expires_in_s: (
calls.append(asset_ids) or "https://immich.example.com/share/abc"
),
})(),
)
resp = client.get(f"/frame/share/{frame.manage_token}", follow_redirects=False)
assert resp.status_code in (302, 303, 307)
assert calls == [["asset-1", "asset-9"]]
def test_manage_share_scoped_to_its_own_frame(client, db_session, monkeypatch):
"""The regression test for the original bug: each frame's own share
QR must only ever surface that frame's own current photo, never
another frame's -- previously it silently resolved via whichever
frame happened to still carry the legacy migration token, regardless
of which frame's QR was actually scanned."""
from app.models import Widget
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame_a, widget_a = _configure_photo_widget(db_session)
cfg_a = db_session.get(PhotoWidgetConfig, widget_a.id)
cfg_a.current_asset_id = "asset-a"
frame_b = Frame(
name="Frame B", device_id="112233445566", device_token="devtok-b",
manage_token="mtok-b", orientation="landscape", created_at=0,
immich_url="http://immich.example.com", immich_api_key="key",
)
db_session.add(frame_b)
db_session.flush()
widget_b = Widget(frame_id=frame_b.id, widget_type="photos", x=0, y=0, w=4, h=5,
sort_order=0, created_at=0)
db_session.add(widget_b)
db_session.flush()
db_session.add(PhotoWidgetConfig(widget_id=widget_b.id, album_id="album-b", current_asset_id="asset-b"))
db_session.commit()
calls = []
monkeypatch.setattr(
"app.routers.manage.immich_client_for",
lambda frame: type("C", (), {
"create_share_link": lambda self, asset_ids, expires_in_s: (
calls.append(asset_ids) or "https://immich.example.com/share/abc"
),
})(),
)
client.get(f"/frame/share/{frame_a.manage_token}", follow_redirects=False)
client.get(f"/frame/share/{frame_b.manage_token}", follow_redirects=False)
assert calls == [["asset-a"], ["asset-b"]]
def test_unknown_manage_share_token_404s(client, db_session):
resp = client.get("/frame/share/not-a-real-token")
assert resp.status_code == 404