diff --git a/backend/app/api/extension.py b/backend/app/api/extension.py index 20b1b30..082bc1e 100644 --- a/backend/app/api/extension.py +++ b/backend/app/api/extension.py @@ -6,6 +6,7 @@ from __future__ import annotations import asyncio import hashlib +import hmac import re from pathlib import Path @@ -41,7 +42,15 @@ async def _ext_key_required(session) -> bool: stored = (await session.execute( select(AppSetting.value).where(AppSetting.key == "extension_api_key") )).scalar_one_or_none() - return stored is not None and supplied == stored + if stored is None: + return False + # compare_digest, not `==`: the stored key is a shared secret, and a + # short-circuiting compare leaks its prefix through timing. Costs nothing + # here — it is not that this route is exposed (#3072). Compared as BYTES: + # compare_digest's str form rejects non-ASCII with TypeError, and this + # header is attacker-supplied, so a str compare would turn a junk key into + # a 500 instead of a 403. + return hmac.compare_digest(supplied.encode("utf-8"), stored.encode("utf-8")) def _extract_version(xpi_name: str) -> str: diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f06d74d..a11d267 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -27,7 +27,7 @@ from .patreon_seen_media import PatreonSeenMedia from .pixiv_failed_media import PixivFailedMedia from .pixiv_seen_media import PixivSeenMedia from .post import Post -from .post_attachment import PostAttachment +from .post_attachment import PostAttachment, attachment_download_url from .presentation_review import PresentationReview from .series_chapter import SeriesChapter from .series_page import SeriesPage @@ -58,6 +58,7 @@ __all__ = [ "SubscribeStarSeenMedia", "Post", "PostAttachment", + "attachment_download_url", "PresentationReview", "SeriesChapter", "SeriesPage", diff --git a/backend/app/models/post_attachment.py b/backend/app/models/post_attachment.py index 1edb7f3..f522cdf 100644 --- a/backend/app/models/post_attachment.py +++ b/backend/app/models/post_attachment.py @@ -65,3 +65,15 @@ class PostAttachment(Base): captured_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) + + +def attachment_download_url(attachment_id: int) -> str: + """The path that streams this attachment's bytes. + + Both serializers that expose an attachment to the frontend + (`provenance_service`, `post_feed_service`) built this literal themselves, + so changing the route in `api/attachments.py` meant two edits and only one + would be remembered (#3072). `test_attachment_download_url` pins it against + the app's registered rule, so the drift is caught rather than trusted to. + """ + return f"/api/attachments/{attachment_id}/download" diff --git a/backend/app/services/post_feed_service.py b/backend/app/services/post_feed_service.py index c1304ac..d4aec83 100644 --- a/backend/app/services/post_feed_service.py +++ b/backend/app/services/post_feed_service.py @@ -24,6 +24,7 @@ from ..models import ( Post, PostAttachment, Source, + attachment_download_url, ) from ..utils.html_sanitize import ( extract_img_srcs, @@ -360,7 +361,7 @@ class PostFeedService: "ext": att.ext, "mime": att.mime, "size_bytes": att.size_bytes, - "download_url": f"/api/attachments/{att.id}/download", + "download_url": attachment_download_url(att.id), }) return out diff --git a/backend/app/services/provenance_service.py b/backend/app/services/provenance_service.py index 6fee555..02f1a64 100644 --- a/backend/app/services/provenance_service.py +++ b/backend/app/services/provenance_service.py @@ -16,6 +16,7 @@ from ..models import ( Post, PostAttachment, Source, + attachment_download_url, ) from ..utils.html_sanitize import sanitize_post_html @@ -53,7 +54,7 @@ def _attachment_dict(a: PostAttachment) -> dict: "original_filename": a.original_filename, "size_bytes": a.size_bytes, "ext": a.ext, - "download_url": f"/api/attachments/{a.id}/download", + "download_url": attachment_download_url(a.id), } diff --git a/frontend/src/components/gallery/GalleryItem.vue b/frontend/src/components/gallery/GalleryItem.vue index cab8b9e..f5fd815 100644 --- a/frontend/src/components/gallery/GalleryItem.vue +++ b/frontend/src/components/gallery/GalleryItem.vue @@ -139,9 +139,9 @@ function onThumbError() { thumbError.value = true } position: absolute; top: 8px; left: 8px; width: 22px; height: 22px; border-radius: 4px; border: 2px solid rgba(232, 228, 216, 0.8); - background: rgba(20, 23, 26, 0.45); + background: rgba(var(--v-theme-background), 0.45); display: grid; place-items: center; - color: #14171A; z-index: 11; + color: rgb(var(--v-theme-background)); z-index: 11; } .fc-gallery-item__checkbox.on { background: rgb(var(--v-theme-accent)); @@ -152,7 +152,7 @@ function onThumbError() { thumbError.value = true } min-width: 22px; height: 22px; padding: 0 5px; border-radius: 11px; background: rgb(var(--v-theme-accent)); - color: #14171A; font-size: 12px; font-weight: 700; + color: rgb(var(--v-theme-background)); font-size: 12px; font-weight: 700; display: grid; place-items: center; z-index: 11; pointer-events: none; } @@ -160,7 +160,8 @@ function onThumbError() { thumbError.value = true } position: absolute; left: 0; right: 0; bottom: 0; padding: 14px 8px 6px; background: linear-gradient( - to top, rgba(20, 23, 26, 0.78), rgba(20, 23, 26, 0) + to top, rgba(var(--v-theme-background), 0.78), + rgba(var(--v-theme-background), 0) ); font-size: 12px; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/frontend/src/components/settings/DownloadsActivityPanel.vue b/frontend/src/components/settings/DownloadsActivityPanel.vue index d8f9d16..380b44a 100644 --- a/frontend/src/components/settings/DownloadsActivityPanel.vue +++ b/frontend/src/components/settings/DownloadsActivityPanel.vue @@ -35,7 +35,7 @@ All subscription sources healthy.

- {{ failing.length }} failing source(s): + {{ failing.length }} failing source(s): {{ failingNames }}

@@ -72,5 +72,4 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) }) diff --git a/frontend/src/components/settings/GpuActivityPanel.vue b/frontend/src/components/settings/GpuActivityPanel.vue index 39bba29..1f85a5b 100644 --- a/frontend/src/components/settings/GpuActivityPanel.vue +++ b/frontend/src/components/settings/GpuActivityPanel.vue @@ -25,7 +25,7 @@
done
-
{{ q.error }}
+
{{ q.error }}
errored
@@ -104,5 +104,4 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) }) font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: rgb(var(--v-theme-on-surface-variant)); } -.fc-bad { color: rgb(var(--v-theme-error)); } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 3e24a75..fbd9380 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -50,7 +50,12 @@ /* Status text colours (DRY pass #161): fc-good = success, fc-weak = error, consolidated from the GPU / heads cards. fc-ok is intentionally NOT global — - it means on-surface in HeadsCard but success in QueuesTable. */ + it means on-surface in HeadsCard but success in QueuesTable. + + No `.fc-bad` (#3072): it was defined locally and identically in the Downloads + and GPU activity panels, and it is fc-weak under a second name — GpuAgentCard + and GpuActivityPanel were colouring the same "errored" count with different + class names. Both now use fc-weak. Reach for fc-weak, not a new synonym. */ .fc-good { color: rgb(var(--v-theme-success)); } .fc-weak { color: rgb(var(--v-theme-error)); } diff --git a/tests/test_api_attachments.py b/tests/test_api_attachments.py index 95e31f2..7918008 100644 --- a/tests/test_api_attachments.py +++ b/tests/test_api_attachments.py @@ -1,6 +1,6 @@ import pytest -from backend.app.models import Artist, PostAttachment +from backend.app.models import Artist, PostAttachment, attachment_download_url pytestmark = pytest.mark.integration @@ -33,3 +33,16 @@ async def test_download_streams_with_disposition(client, db, tmp_path): async def test_download_404(client): resp = await client.get("/api/attachments/999999/download") assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_attachment_download_url_routes_to_the_download_endpoint(app): + """The two serializers no longer hand-format this path (#3072) — but a + single definition is only worth having if it still matches the route. Pin + it by MATCHING against the real URL map rather than comparing to a literal: + a string equality test would pass just as happily after someone renamed the + route, which is the exact drift the helper exists to prevent.""" + built = attachment_download_url(4242) + endpoint, args = app.url_map.bind("localhost").match(built) + assert endpoint == "attachments.download" + assert args == {"attachment_id": 4242}