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
The native Patreon backfill flooded the feed with bare 'Post <id>' shells
(1589 for Anduo). Root cause: PostAttachment.sha256 was GLOBALLY unique, so a
non-art file reused across posts only ever linked to the first one, and
_capture_attachment created the Post before that dedup check — leaving later
posts with no image and no attachment. Duplicate IMAGES had the mirror gap:
attach_in_place returned duplicate_hash/duplicate_phash before _apply_sidecar,
so the second post got no provenance row, and the feed only rendered via
primary_post_id (one post per image).
Operator requirement: a duplicate item must show on EVERY post it appears in.
Unify the fix as link-not-suppress:
- importer: on duplicate_hash / duplicate_phash(larger_exists), append an
image_provenance row for the new post (keep primary on the first). Both the
download path (attach_in_place) and the filesystem path (_import_media).
- post_feed_service: render thumbnails by image_provenance UNION primary_post_id,
so a cross-posted image shows on every post (and legacy primary-only images
still show).
- PostAttachment: per-post uniqueness — drop UNIQUE(sha256), add partial
UNIQUE(post_id, sha256) + partial UNIQUE(sha256) WHERE post_id IS NULL
(migration 0043); _capture_attachment dedups per-(post,sha) over the shared
sha-addressed blob, so no post is left bare.
- cleanup: new prune-bare-posts maintenance action (cleanup_service
_bare_post_conditions shared by preview/count/delete per preview/apply parity;
admin endpoint; PostMaintenanceCard). Deletes posts with zero image links
(primary or provenance) AND zero attachments. Run after the feed fix so a
hidden provenance link spares the post instead of deleting it.
Tests: dup image shows on both posts; dup attachment shows on both posts; feed
renders provenance-linked duplicates; prune-bare delete-path == preview.
Operator redeploys (migration 0043) then runs the prune to clear the shells.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>