Items 2-5 of #3072. Item 1 (the per-row sweep inserts) is separate. 2. .fc-bad was not merely duplicated — it is .fc-weak under a second name. Both local definitions were `color: rgb(var(--v-theme-error))`, identical to the global .fc-weak, and GpuAgentCard was already using .fc-weak to colour exactly what GpuActivityPanel coloured .fc-bad (an errored count, red when non-zero). So rather than promoting a synonym to app.css, both call sites now use .fc-weak and the local defs are gone. app.css's status-colour comment records why there is no .fc-bad, next to the existing note on why .fc-ok is deliberately NOT global. 3. GalleryItem.vue's obsidian literals now use --v-theme-background, which IS obsidian (vuetify-theme.js maps background -> surfaces. obsidian). Preferred over --fc-chrome-rgb: same value, but that variable is named for the nav fade, not for the palette entry. The ticket said these were the only three real uses in the tree. They are not — GalleryItem itself had two more in the artist-label gradient (fixed here, so the file is now consistent), and ~13 more live in SeriesView, SeriesReaderView, ImageViewer, ArtistHeader, ExploreView and GalleryFilterBar. Those are a separate sweep, filed rather than folded in here. 4. The attachment download path had two hand-formatted copies. One definition now, `attachment_download_url`, next to the model both serializers already import. The test pins it by MATCHING the built path against the app's real URL map rather than comparing to a literal — a string-equality test would still pass after someone renamed the route, which is the drift the helper exists to prevent. 5. Extension API key now compares with hmac.compare_digest. Compared as BYTES, not str: compare_digest's str form raises TypeError on non-ASCII, and this value comes straight from an attacker-controlled header, so the str form would turn a junk key into a 500 instead of a 403. Low stakes either way — the API is unauthenticated-by-design on a LAN — but it costs nothing. Refs #3072
80 lines
2.8 KiB
Python
80 lines
2.8 KiB
Python
"""PostAttachment — a non-art file preserved from a post.
|
|
|
|
Art images become ImageRecords; everything else a post contained
|
|
(archives, .exe, .pdf, ...) is captured here so nothing is lost.
|
|
post_id is nullable (set only when an adjacent sidecar yields a Post);
|
|
artist_id mirrors the canonical attribution model (FC-2d-vii-c). Both
|
|
FKs are SET NULL so deleting a Post/Artist never deletes the preserved
|
|
binary row.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import (
|
|
BigInteger,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
Text,
|
|
func,
|
|
text,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from .base import Base
|
|
|
|
|
|
class PostAttachment(Base):
|
|
__tablename__ = "post_attachment"
|
|
# Dedup is PER-POST, not global (2026-06-08): the same non-art file attached
|
|
# to many posts gets one row per post over a single sha-addressed blob, so no
|
|
# post is left a bare shell. Partial uniques: (post_id, sha256) for real posts;
|
|
# (sha256) alone for the NULL-post filesystem case (one row per file there).
|
|
__table_args__ = (
|
|
Index(
|
|
"uq_post_attachment_post_sha",
|
|
"post_id", "sha256",
|
|
unique=True,
|
|
postgresql_where=text("post_id IS NOT NULL"),
|
|
),
|
|
Index(
|
|
"uq_post_attachment_null_post_sha",
|
|
"sha256",
|
|
unique=True,
|
|
postgresql_where=text("post_id IS NULL"),
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
post_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("post.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
artist_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
sha256: Mapped[str] = mapped_column(
|
|
String(64), nullable=False, index=True
|
|
)
|
|
path: Mapped[str] = mapped_column(Text, nullable=False)
|
|
original_filename: Mapped[str] = mapped_column(Text, nullable=False)
|
|
ext: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
mime: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
|
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"
|