Extension channels: dev and main each carry their own signed extension #237
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -41,7 +42,15 @@ async def _ext_key_required(session) -> bool:
|
|||||||
stored = (await session.execute(
|
stored = (await session.execute(
|
||||||
select(AppSetting.value).where(AppSetting.key == "extension_api_key")
|
select(AppSetting.value).where(AppSetting.key == "extension_api_key")
|
||||||
)).scalar_one_or_none()
|
)).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:
|
def _extract_version(xpi_name: str) -> str:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from .patreon_seen_media import PatreonSeenMedia
|
|||||||
from .pixiv_failed_media import PixivFailedMedia
|
from .pixiv_failed_media import PixivFailedMedia
|
||||||
from .pixiv_seen_media import PixivSeenMedia
|
from .pixiv_seen_media import PixivSeenMedia
|
||||||
from .post import Post
|
from .post import Post
|
||||||
from .post_attachment import PostAttachment
|
from .post_attachment import PostAttachment, attachment_download_url
|
||||||
from .presentation_review import PresentationReview
|
from .presentation_review import PresentationReview
|
||||||
from .series_chapter import SeriesChapter
|
from .series_chapter import SeriesChapter
|
||||||
from .series_page import SeriesPage
|
from .series_page import SeriesPage
|
||||||
@@ -58,6 +58,7 @@ __all__ = [
|
|||||||
"SubscribeStarSeenMedia",
|
"SubscribeStarSeenMedia",
|
||||||
"Post",
|
"Post",
|
||||||
"PostAttachment",
|
"PostAttachment",
|
||||||
|
"attachment_download_url",
|
||||||
"PresentationReview",
|
"PresentationReview",
|
||||||
"SeriesChapter",
|
"SeriesChapter",
|
||||||
"SeriesPage",
|
"SeriesPage",
|
||||||
|
|||||||
@@ -65,3 +65,15 @@ class PostAttachment(Base):
|
|||||||
captured_at: Mapped[datetime] = mapped_column(
|
captured_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
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"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from ..models import (
|
|||||||
Post,
|
Post,
|
||||||
PostAttachment,
|
PostAttachment,
|
||||||
Source,
|
Source,
|
||||||
|
attachment_download_url,
|
||||||
)
|
)
|
||||||
from ..utils.html_sanitize import (
|
from ..utils.html_sanitize import (
|
||||||
extract_img_srcs,
|
extract_img_srcs,
|
||||||
@@ -360,7 +361,7 @@ class PostFeedService:
|
|||||||
"ext": att.ext,
|
"ext": att.ext,
|
||||||
"mime": att.mime,
|
"mime": att.mime,
|
||||||
"size_bytes": att.size_bytes,
|
"size_bytes": att.size_bytes,
|
||||||
"download_url": f"/api/attachments/{att.id}/download",
|
"download_url": attachment_download_url(att.id),
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from ..models import (
|
|||||||
Post,
|
Post,
|
||||||
PostAttachment,
|
PostAttachment,
|
||||||
Source,
|
Source,
|
||||||
|
attachment_download_url,
|
||||||
)
|
)
|
||||||
from ..utils.html_sanitize import sanitize_post_html
|
from ..utils.html_sanitize import sanitize_post_html
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ def _attachment_dict(a: PostAttachment) -> dict:
|
|||||||
"original_filename": a.original_filename,
|
"original_filename": a.original_filename,
|
||||||
"size_bytes": a.size_bytes,
|
"size_bytes": a.size_bytes,
|
||||||
"ext": a.ext,
|
"ext": a.ext,
|
||||||
"download_url": f"/api/attachments/{a.id}/download",
|
"download_url": attachment_download_url(a.id),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -139,9 +139,9 @@ function onThumbError() { thumbError.value = true }
|
|||||||
position: absolute; top: 8px; left: 8px;
|
position: absolute; top: 8px; left: 8px;
|
||||||
width: 22px; height: 22px; border-radius: 4px;
|
width: 22px; height: 22px; border-radius: 4px;
|
||||||
border: 2px solid rgba(232, 228, 216, 0.8);
|
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;
|
display: grid; place-items: center;
|
||||||
color: #14171A; z-index: 11;
|
color: rgb(var(--v-theme-background)); z-index: 11;
|
||||||
}
|
}
|
||||||
.fc-gallery-item__checkbox.on {
|
.fc-gallery-item__checkbox.on {
|
||||||
background: rgb(var(--v-theme-accent));
|
background: rgb(var(--v-theme-accent));
|
||||||
@@ -152,7 +152,7 @@ function onThumbError() { thumbError.value = true }
|
|||||||
min-width: 22px; height: 22px; padding: 0 5px;
|
min-width: 22px; height: 22px; padding: 0 5px;
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: rgb(var(--v-theme-accent));
|
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;
|
display: grid; place-items: center; z-index: 11;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,8 @@ function onThumbError() { thumbError.value = true }
|
|||||||
position: absolute; left: 0; right: 0; bottom: 0;
|
position: absolute; left: 0; right: 0; bottom: 0;
|
||||||
padding: 14px 8px 6px;
|
padding: 14px 8px 6px;
|
||||||
background: linear-gradient(
|
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;
|
font-size: 12px; line-height: 1.2;
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
All subscription sources healthy.
|
All subscription sources healthy.
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-body-2 mb-0">
|
<p v-else class="text-body-2 mb-0">
|
||||||
<b class="fc-bad">{{ failing.length }}</b> failing source(s):
|
<b class="fc-weak">{{ failing.length }}</b> failing source(s):
|
||||||
<span class="fc-muted">{{ failingNames }}</span>
|
<span class="fc-muted">{{ failingNames }}</span>
|
||||||
</p>
|
</p>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
@@ -72,5 +72,4 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-bad { color: rgb(var(--v-theme-error)); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<div class="fc-cell__l">done</div>
|
<div class="fc-cell__l">done</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-cell">
|
<div class="fc-cell">
|
||||||
<div class="fc-cell__n" :class="q.error ? 'fc-bad' : ''">{{ q.error }}</div>
|
<div class="fc-cell__n" :class="q.error ? 'fc-weak' : ''">{{ q.error }}</div>
|
||||||
<div class="fc-cell__l">errored</div>
|
<div class="fc-cell__l">errored</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,5 +104,4 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
|||||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
}
|
}
|
||||||
.fc-bad { color: rgb(var(--v-theme-error)); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -50,7 +50,12 @@
|
|||||||
|
|
||||||
/* Status text colours (DRY pass #161): fc-good = success, fc-weak = error,
|
/* Status text colours (DRY pass #161): fc-good = success, fc-weak = error,
|
||||||
consolidated from the GPU / heads cards. fc-ok is intentionally NOT global —
|
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-good { color: rgb(var(--v-theme-success)); }
|
||||||
.fc-weak { color: rgb(var(--v-theme-error)); }
|
.fc-weak { color: rgb(var(--v-theme-error)); }
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from backend.app.models import Artist, PostAttachment
|
from backend.app.models import Artist, PostAttachment, attachment_download_url
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
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):
|
async def test_download_404(client):
|
||||||
resp = await client.get("/api/attachments/999999/download")
|
resp = await client.get("/api/attachments/999999/download")
|
||||||
assert resp.status_code == 404
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user