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
162 lines
6.0 KiB
Python
162 lines
6.0 KiB
Python
"""Read-only provenance queries.
|
|
|
|
Provenance is its own system, intentionally separate from the tag/ML
|
|
system (see project_provenance_separation). This service joins
|
|
ImageProvenance -> Post/Source/Artist and returns plain dicts. It never
|
|
mutates and never imports tag/ML modules.
|
|
"""
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..models import (
|
|
Artist,
|
|
ImageProvenance,
|
|
ImageRecord,
|
|
Post,
|
|
PostAttachment,
|
|
Source,
|
|
attachment_download_url,
|
|
)
|
|
from ..utils.html_sanitize import sanitize_post_html
|
|
|
|
|
|
def _post_dict(p: Post) -> dict:
|
|
return {
|
|
"id": p.id,
|
|
"external_post_id": p.external_post_id,
|
|
"url": p.post_url,
|
|
"title": p.post_title,
|
|
"date": p.post_date.isoformat() if p.post_date else None,
|
|
"description_html": sanitize_post_html(p.description),
|
|
"attachment_count": p.attachment_count,
|
|
# Translation (#143): the English title/description shown by default when
|
|
# a translation exists; the UI toggles to the original. Source lang labels
|
|
# the original.
|
|
"title_translated": p.post_title_translated,
|
|
"description_translated": p.description_translated,
|
|
"translated_source_lang": p.translated_source_lang,
|
|
"translation_override": p.translation_override,
|
|
}
|
|
|
|
|
|
def _source_dict(s: Source) -> dict:
|
|
return {"id": s.id, "platform": s.platform, "url": s.url}
|
|
|
|
|
|
def _artist_dict(a: Artist) -> dict:
|
|
return {"id": a.id, "name": a.name, "slug": a.slug}
|
|
|
|
|
|
def _attachment_dict(a: PostAttachment) -> dict:
|
|
return {
|
|
"id": a.id,
|
|
"original_filename": a.original_filename,
|
|
"size_bytes": a.size_bytes,
|
|
"ext": a.ext,
|
|
"download_url": attachment_download_url(a.id),
|
|
}
|
|
|
|
|
|
class ProvenanceService:
|
|
def __init__(self, session: AsyncSession):
|
|
self.session = session
|
|
|
|
async def _attachments_for_posts(self, post_ids: list[int]) -> list[dict]:
|
|
if not post_ids:
|
|
return []
|
|
rows = (
|
|
await self.session.execute(
|
|
select(PostAttachment)
|
|
.where(PostAttachment.post_id.in_(post_ids))
|
|
.order_by(PostAttachment.id.asc())
|
|
)
|
|
).scalars().all()
|
|
return [_attachment_dict(a) for a in rows]
|
|
|
|
async def _attachment_by_id(self, attachment_id: int) -> list[dict]:
|
|
att = await self.session.get(PostAttachment, attachment_id)
|
|
return [_attachment_dict(att)] if att is not None else []
|
|
|
|
async def for_image(self, image_id: int) -> dict | None:
|
|
rec = await self.session.get(ImageRecord, image_id)
|
|
if rec is None:
|
|
return None
|
|
# Artist via Post.artist_id (alembic 0030); Source via LEFT JOIN
|
|
# since both Post.source_id and ImageProvenance.source_id can be
|
|
# NULL for filesystem-imported content. Frontend renders source=
|
|
# null as "filesystem import."
|
|
stmt = (
|
|
select(ImageProvenance, Post, Source, Artist)
|
|
.join(Post, Post.id == ImageProvenance.post_id)
|
|
.join(Artist, Artist.id == Post.artist_id)
|
|
.outerjoin(Source, Source.id == ImageProvenance.source_id)
|
|
.where(ImageProvenance.image_record_id == image_id)
|
|
.order_by(ImageProvenance.captured_at.asc(),
|
|
ImageProvenance.id.asc())
|
|
)
|
|
rows = (await self.session.execute(stmt)).all()
|
|
post_ids = [ip.post_id for ip, _p, _s, _a in rows]
|
|
# Prefer the EXACT archive this file came out of (milestone #87): if the
|
|
# originating post's provenance row records from_attachment_id, the image
|
|
# was extracted from that one .zip/.rar, so show only it — not the dozens
|
|
# of unrelated archives a "High Resolution Files" bundle post carries.
|
|
from_att_id = next(
|
|
(
|
|
ip.from_attachment_id
|
|
for ip, _p, _s, _a in rows
|
|
if ip.post_id == rec.primary_post_id
|
|
and ip.from_attachment_id is not None
|
|
),
|
|
None,
|
|
)
|
|
if from_att_id is not None:
|
|
attachments = await self._attachment_by_id(from_att_id)
|
|
else:
|
|
# No recorded containing archive (loose download, or pre-backfill):
|
|
# scope to the originating post only, not every pHash-linked post.
|
|
# primary_post_id is the post this file was actually captured from;
|
|
# fall back to all linked posts when it's unset (older rows /
|
|
# filesystem imports).
|
|
attach_post_ids = (
|
|
[rec.primary_post_id]
|
|
if rec.primary_post_id is not None
|
|
else post_ids
|
|
)
|
|
attachments = await self._attachments_for_posts(attach_post_ids)
|
|
return {
|
|
"image_id": image_id,
|
|
"provenance": [
|
|
{
|
|
"provenance_id": ip.id,
|
|
"captured_at": ip.captured_at.isoformat()
|
|
if ip.captured_at else None,
|
|
"post": _post_dict(post),
|
|
"source": _source_dict(src) if src is not None else None,
|
|
"artist": _artist_dict(art),
|
|
}
|
|
for ip, post, src, art in rows
|
|
],
|
|
"attachments": attachments,
|
|
}
|
|
|
|
async def for_post(self, post_id: int) -> dict | None:
|
|
# Same LEFT JOIN to Source — get_post must succeed for a
|
|
# NULL-source post.
|
|
stmt = (
|
|
select(Post, Source, Artist)
|
|
.join(Artist, Artist.id == Post.artist_id)
|
|
.outerjoin(Source, Source.id == Post.source_id)
|
|
.where(Post.id == post_id)
|
|
)
|
|
row = (await self.session.execute(stmt)).first()
|
|
if row is None:
|
|
return None
|
|
post, src, art = row
|
|
return {
|
|
"post": _post_dict(post),
|
|
"source": _source_dict(src) if src is not None else None,
|
|
"artist": _artist_dict(art),
|
|
"attachments": await self._attachments_for_posts([post.id]),
|
|
}
|