Skip to content

Commit 8629995

Browse files
committed
fix: withhold the cover image of a locked album
A locked album listed its cover like any other, so the grid showed a photo from inside an album whose whole point is that you need a password to see inside it. The lock badge sat on top of the very content it was meant to be gating. Both reads now send no cover path for a locked album, and skip the lookup rather than fetching a path they will not return. The card already falls back to the placeholder when the path is absent.
1 parent 261e013 commit 8629995

3 files changed

Lines changed: 88 additions & 4 deletions

File tree

backend/app/routes/albums.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,19 @@ def get_albums():
4141
# Get image count for each album
4242
image_ids = db_get_album_images(album[0])
4343
image_count = len(image_ids)
44+
is_locked = bool(album[3])
4445

4546
album_list.append(
4647
Album(
4748
album_id=album[0],
4849
album_name=album[1],
4950
description=album[2] or "",
50-
is_locked=bool(album[3]),
51-
cover_image_path=db_get_album_cover_path(album[0]),
51+
is_locked=is_locked,
52+
# A locked album's cover would show the very content the
53+
# password is protecting, so never send it.
54+
cover_image_path=(
55+
None if is_locked else db_get_album_cover_path(album[0])
56+
),
5257
image_count=image_count,
5358
)
5459
)
@@ -103,12 +108,14 @@ def get_album(album_id: str = Path(...)):
103108
image_ids = db_get_album_images(album_id)
104109
image_count = len(image_ids)
105110

111+
is_locked = bool(album[3])
106112
album_obj = Album(
107113
album_id=album[0],
108114
album_name=album[1],
109115
description=album[2] or "",
110-
is_locked=bool(album[3]),
111-
cover_image_path=db_get_album_cover_path(album_id),
116+
is_locked=is_locked,
117+
# Same reasoning as the listing: the cover gives away the contents.
118+
cover_image_path=(None if is_locked else db_get_album_cover_path(album_id)),
112119
image_count=image_count,
113120
)
114121
return GetAlbumResponse(success=True, data=album_obj)

backend/tests/test_albums.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,63 @@ def test_get_all_albums_include_hidden(self, mock_db_album, mock_db_locked_album
184184

185185
mock_get_all.assert_called_once()
186186

187+
def test_locked_album_cover_is_withheld(self, mock_db_album, mock_db_locked_album):
188+
"""A locked album's cover would reveal the content the password gates."""
189+
with patch("app.routes.albums.db_get_all_albums") as mock_get_all, patch(
190+
"app.routes.albums.db_get_album_cover_path"
191+
) as mock_cover:
192+
mock_get_all.return_value = [
193+
(
194+
mock_db_album["album_id"],
195+
mock_db_album["album_name"],
196+
mock_db_album["description"],
197+
mock_db_album["is_locked"],
198+
mock_db_album["password_hash"],
199+
None,
200+
),
201+
(
202+
mock_db_locked_album["album_id"],
203+
mock_db_locked_album["album_name"],
204+
mock_db_locked_album["description"],
205+
mock_db_locked_album["is_locked"],
206+
mock_db_locked_album["password_hash"],
207+
None,
208+
),
209+
]
210+
mock_cover.return_value = "/photos/secret.jpg"
211+
212+
response = client.get("/albums/")
213+
assert response.status_code == 200
214+
215+
covers = {
216+
album["album_id"]: album["cover_image_path"]
217+
for album in response.json()["albums"]
218+
}
219+
assert covers[mock_db_album["album_id"]] == "/photos/secret.jpg"
220+
assert covers[mock_db_locked_album["album_id"]] is None
221+
# The path is never even looked up for a locked album
222+
mock_cover.assert_called_once_with(mock_db_album["album_id"])
223+
224+
def test_get_album_by_id_withholds_a_locked_cover(self, mock_db_locked_album):
225+
"""The single-album read must not leak what the listing hides."""
226+
with patch("app.routes.albums.db_get_album") as mock_get_album, patch(
227+
"app.routes.albums.db_get_album_cover_path"
228+
) as mock_cover:
229+
mock_get_album.return_value = (
230+
mock_db_locked_album["album_id"],
231+
mock_db_locked_album["album_name"],
232+
mock_db_locked_album["description"],
233+
mock_db_locked_album["is_locked"],
234+
mock_db_locked_album["password_hash"],
235+
None,
236+
)
237+
mock_cover.return_value = "/photos/secret.jpg"
238+
239+
response = client.get(f"/albums/{mock_db_locked_album['album_id']}")
240+
assert response.status_code == 200
241+
assert response.json()["data"]["cover_image_path"] is None
242+
mock_cover.assert_not_called()
243+
187244
def test_get_all_albums_empty_list(self):
188245
"""
189246
Test fetching albums when none exist.

frontend/src/pages/__tests__/Album.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,26 @@ describe('Albums page', () => {
109109
);
110110
};
111111

112+
// The backend withholds cover_image_path for locked albums; the card has to
113+
// fall back to the placeholder rather than render a broken or empty tile.
114+
test('shows a placeholder instead of a cover for a locked album', async () => {
115+
serverAlbums = [
116+
{
117+
album_id: 'a2',
118+
album_name: 'Private',
119+
description: '',
120+
is_locked: true,
121+
cover_image_path: null,
122+
image_count: 4,
123+
},
124+
];
125+
126+
render(<AlbumsWithGlobalOverlays />);
127+
128+
const cover = await screen.findByAltText('Private');
129+
expect(cover.getAttribute('src')).toMatch(/placeholder-album/);
130+
}, 30000);
131+
112132
test('shows skeletons while loading instead of a blocking loader', async () => {
113133
let releaseAlbums: (value: unknown) => void = () => {};
114134
mockGetAllAlbums.mockImplementation(

0 commit comments

Comments
 (0)