Previously privacy dismiss unconditionally resumed the embedded
preview video, overriding a manual pause. Now captures whether
the video was playing before privacy activated and only resumes
if it was.
behavior change: manually paused videos stay paused after
privacy screen dismiss.
Context menu now shows either Save to Library or Unsave from Library
based on saved state, never both.
behavior change: popout context menu shows either Save or Unsave.
Context menu now shows either Save to Library or Unsave from Library
based on saved state, never both.
behavior change: preview context menu shows either Save or Unsave.
Previously both Save to Library submenu and Unsave from Library
showed simultaneously for saved posts. Now only the relevant action
appears based on whether the post is already in the library.
Also removed stale _current_post override on unsave — get_preview_post
already resolves the right-clicked post via grid selection index.
behavior change: browse grid context menu shows either Save or
Unsave, never both.
Save to Library and Unsave from Library are now mutually exclusive
in both single and multi-select context menus (previously both
showed simultaneously).
Replaced full grid refresh() after save/unsave with targeted dot
updates — save_done signal fires per-post after async save completes
and lights the saved dot on just that thumbnail. Unsave clears the
dot inline. Eliminates the visible flash from grid rebuild.
behavior change: context menus show either Save or Unsave, never
both. Saved dots appear without grid flash.
Post ID sort used filepath.stem which sorted templated filenames
like artist_12345.jpg alphabetically instead of by post ID. Now
resolves post_id via library_meta DB lookup, falls back to digit-stem
for legacy files, unknowns sort to the end.
Single-delete and multi-delete used filepath.stem for the thumbnail
path, but library thumbnails are keyed by post_id. Templated filenames
like artist_12345.jpg would look for thumbnails/library/artist_12345.jpg
instead of thumbnails/library/12345.jpg, leaving orphan thumbnails.
Now uses the resolved post_id when available, falls back to stem for
legacy digit-stem files.
Fixed self._state → self._state_machine (latent AttributeError when
copying video to clipboard from popout context menu).
Rewrote copy logic to use QMimeData with file URL + image data,
matching main_window's Ctrl+C. For streaming URLs, resolves to the
cached local file. Added Copy Image URL entry for the source URL.
behavior change: clipboard copy now includes file URL; new context
menu entry for URL copy; video copy no longer crashes.
Copy File to Clipboard now sets QMimeData with both the file URL
and image data, matching main_window's Ctrl+C behavior. Previously
it only called setPixmap which didn't work in file managers.
Added Copy Image URL context menu entry that copies the booru CDN
URL as text.
behavior change: clipboard copy now includes file URL for paste
into file managers; new context menu entry for URL copy.
Same hi-res scroll fix — accumulate angleDelta to ±120 boundaries
before applying a zoom step. Uses 1.15^steps so multi-step scrolls
on standard mice still feel the same.
behavior change
Hi-res scroll mice (e.g. G502) send many small angleDelta events
per physical notch instead of one ±120. Without accumulation, each
micro-event triggered a ±5 volume jump, making volume unusable on
hi-res hardware. Now accumulates to ±120 boundaries before firing.
behavior change
The cache_pause=yes change (ac3939e) broke first-click popout
playback — mpv paused indefinitely waiting for cache fill on
uncached videos. Reverted to cache_pause=no.
Kept the demuxer_max_bytes bump (50→150MiB) which reduces stutter
on network streams by giving mpv more buffer headroom without
changing the pause/play behavior.
behavior change
cache_pause_initial=yes made mpv wait for a full buffer before
showing the first frame on uncached videos, which looked like the
popout was broken on first click. Removing it restores immediate
playback start — cache_pause=yes still handles mid-playback
underruns.
behavior change
cache_pause=no caused frame-wait-frame-wait on uncached videos
because mpv kept playing through buffer underruns instead of
pausing to refill. Flip to cache_pause=yes with a 2s resume
threshold so playback is smooth after the initial buffer fill.
Also: bump demuxer buffers (50→150MiB forward, add 75MiB back for
backward seek without refetch), increase stream_buffer_size from
default 128KiB to 4MiB to reduce syscall overhead, extend network
timeout (10→30s) for slow CDNs, and set a browser-like user agent
to avoid 403s from boorus that block mpv's default UA.
behavior change
Same fix as audit #5 applied to get_bookmarks (lines 490-499) but
missed here. Without ESCAPE, searching 'cat_ear' also matches
'catxear' because _ is a SQL LIKE wildcard that matches any single
character.
Unused: Path, Post, QPainterPath, QMenu, QApplication.
FlowLayout.clear() now stops any in-flight fade animation before
calling deleteLater() on thumbnails. Without this, a mid-flight
QPropertyAnimation can fire property updates on a widget that's
queued for deletion.
Two fixes:
1. Stale state cleanup. If a rubber band drag is interrupted without a
matching release event (Wayland focus steal, drag outside window,
tab switch, alt-tab), _rb_origin and the rubber band widget stay
stuck. The next click then reuses the stale origin and rubber band
stops working until the app is restarted. New _clear_stale_rubber_band
helper is called at the top of every mouse press entry point
(Grid.mousePressEvent, on_padding_click, ThumbnailWidget pixmap
press) so the next interaction starts from a clean slate.
2. Scroll offset sign error in _rb_drag. The intersection test
translated thumb geometry by +vp_offset, but thumb.geometry() is in
widget coords and rb_rect is in viewport coords — the translation
needs to convert between them. Switched to translating rb_rect into
widget coords (rb_widget = rb_rect.translated(vp_offset)) before the
intersection test, which is the mathematically correct direction.
Rubber band selection now tracks the visible band when scrolled.
behavior change: rubber band stays responsive after interrupted drags
Targets the internal qt_msgboxex_icon_label by objectName via the
base stylesheet, so confirm/warn/info dialogs across all 36+ call
sites render text-only without per-call setIcon plumbing.
behavior change
make_thumbnail and image_dimensions were both unreferenced. The
library's actual thumbnailing happens inline in gui/library.py
(PIL for stills, ffmpeg subprocess for videos), and the live
image_dimensions used by main_window.py is the static method on
gui/media_controller.py — not the standalone function this file
exposed. Audit finding #15 follow-up.
The audit #8 explanation no longer needs to name core.images as the
example case — the invariant holds for any submodule, and core.images
is about to be removed entirely as dead code.
Distro pyside6 builds linked against system Qt pick up the system
platform theme plugin (Breeze on KDE, Adwaita-ish on GNOME, etc.),
which gave AUR users a different widget style than the source-from-pip
build that uses bundled Qt. Force Fusion in the no-custom.qss path so
both routes render identically.
The inherited palette is intentionally untouched: KDE writes
~/.config/Trolltech.conf which every Qt app reads, so KDE users
still get their color scheme — just under Fusion widgets instead
of Breeze.
Calls lavf_options() post mpv.MPV() init and writes each entry into
the demuxer-lavf-o property. This is the consumer side of the split
helpers introduced in the previous commit. Verified end-to-end by
launching the GUI: mpv constructs cleanly and m['demuxer-lavf-o']
reads back as {'protocol_whitelist': 'file,http,https,tls,tcp'}.
Audit-Ref: SECURITY_AUDIT.md finding #2
Severity: High
The previous attempt set ``demuxer_lavf_o`` as an init kwarg with a
comma-laden ``protocol_whitelist=file,http,https,tls,tcp`` value.
mpv rejected it with -7 OPT_FORMAT because python-mpv's init path
goes through ``mpv_set_option_string``, which routes through mpv's
keyvalue list parser — that parser splits on ``,`` to find entries,
shredding the protocol list into orphan tokens. Backslash-escaping
``\,`` did not unescape on this code path either.
Splits the option set into two helpers:
- ``build_mpv_kwargs`` — init kwargs only (ytdl=no, load_scripts=no,
POSIX input_conf null, all the existing playback/audio/network
tuning). The lavf option is intentionally absent.
- ``lavf_options`` — a dict applied post-construction via the
python-mpv property API, which uses the node API and accepts
dict values for keyvalue-list options without splitting on
commas inside the value.
Tests cover both paths: that ``demuxer_lavf_o`` is NOT in the init
kwargs (regression guard), and that ``lavf_options`` returns the
expected protocol set.
Audit-Ref: SECURITY_AUDIT.md finding #2
Severity: High
CategoryFetcher.fetch_post pulls a post-view HTML page and runs
_TAG_ELEMENT_RE.finditer over the full body. The regex itself is
linear (no catastrophic backtracking shape), but a hostile server
returning hundreds of MB of HTML still pegs CPU walking the buffer.
Caps the body the regex sees at 2MB — well above any legit
Gelbooru/Moebooru post page (~30-150KB).
Truncation rather than streaming because httpx already buffers the
body before _request returns; the cost we're cutting is the regex
walk, not the memory hit. A full streaming refactor of fetch_post
is a follow-up that the audit explicitly flagged as out of scope
("not catastrophic — defense in depth").
Audit-Ref: SECURITY_AUDIT.md finding #14
Severity: Informational
The previous flow streamed the full body to disk and called
_is_valid_media after completion. A hostile server that omits
Content-Type (so the early text/html guard doesn't fire) could
burn up to MAX_DOWNLOAD_BYTES (500MB) of bandwidth and cache-dir
write/delete churn before the post-download check rejected.
Refactors _do_download to accumulate chunks into a small header
buffer until at least 16 bytes have arrived, then runs
_looks_like_media against the buffer before committing to writing
the full payload. The 16-byte minimum handles servers that send
tiny chunks (chunked encoding with 1-byte chunks, slow trickle,
TCP MSS fragmentation) without false-failing on the first chunk.
Extracts _looks_like_media(bytes) as a sibling to _is_valid_media
(path) sharing the same magic-byte recognition. _looks_like_media
fails closed on empty input — when called from the streaming
validator, an empty header means the server returned nothing
useful. _is_valid_media keeps its OSError-fallback open behavior
for the on-disk path so transient EBUSY doesn't trigger a delete
+ re-download loop.
Audit-Ref: SECURITY_AUDIT.md finding #10
Severity: Low
The cap is now installed by core/__init__.py (previous commit), so
the line in cache.py is redundant. Removing it leaves a single
authoritative location for the security-critical PIL setting.
Audit-Ref: SECURITY_AUDIT.md finding #8
Severity: Low
PIL's decompression-bomb cap previously lived as a side effect of
importing core/cache.py. Any future code path that touched core/images
(or any other core submodule) without first importing cache would
silently revert to PIL's default 89M-pixel *warning* (not an error),
re-opening the bomb surface.
Moves the cap into core/__init__.py so any import of any
booru_viewer.core.* submodule installs it first. The duplicate set
in cache.py is left in place by this commit and removed in the next
one — both writes are idempotent so this commit is bisect-safe.
Audit-Ref: SECURITY_AUDIT.md finding #8
Severity: Low
render_filename_template's sanitization stripped reserved chars,
control codes, whitespace, and `..` prefixes — but did not catch
Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9).
On Windows, opening `con.jpg` for writing redirects to the CON
device, so a tag value of `con` from a hostile booru would silently
break Save to Library.
Adds a frozenset of reserved stems and prefixes the rendered name
with `_` if its lowercased stem matches. The check runs
unconditionally (not Windows-gated) so a library saved on Linux
can be copied to a Windows machine without breaking on these
filenames.
Audit-Ref: SECURITY_AUDIT.md finding #7
Severity: Low
Replaces the inline f-string concatenation of post.source into the
RichText document with a call through build_source_html(), which
escapes both the href value and the visible display text.
Also escapes the filetype field for defense-in-depth — the value
comes from a parsed URL suffix (effectively booru-controlled) and
the previous code interpolated it raw.
Removes the dead duplicate setText() call that wrote a plain-text
version before being overwritten by the RichText version on the
next line.
Audit-Ref: SECURITY_AUDIT.md finding #6
Severity: Medium
Extracts the rich-text Source-line builder out of info_panel.py
into a Qt-free module so it can be unit-tested under CI (which
installs only httpx + Pillow + pytest, no PySide6).
The helper html.escape()s both the href and the visible display
text, and only emits an <a> tag for http(s) URLs — non-URL
sources (including javascript: and data: schemes) get rendered
as escaped plain text without a clickable anchor.
Not yet wired into InfoPanel.set_post; that lands in the next
commit.
Audit-Ref: SECURITY_AUDIT.md finding #6
Severity: Medium
Replaces the unbounded defaultdict(asyncio.Lock) with an OrderedDict
guarded by _get_url_lock() and _evict_url_locks(). The cap is 4096
entries; LRU semantics keep the hot working set alive and oldest-
unlocked-first eviction trims back toward the cap on each new
insertion.
Eviction skips locks that are currently held — popping a lock that
a coroutine is mid-`async with` on would break its __aexit__. The
inner loop's evicted-flag handles the edge case where every
remaining entry is either the freshly inserted hash or held; in
that state the cap is briefly exceeded and the next insertion
retries, instead of looping forever.
Audit-Ref: SECURITY_AUDIT.md finding #5
Severity: Medium
The sites table stores api_key + api_user in plaintext. Previous
behavior left the DB file at the inherited umask (0o644 on most
Linux systems) so any other local user could sqlite3 it open and
exfiltrate every booru API key.
Adds Database._restrict_perms(), called from the lazy conn init
right after _migrate(). Tightens the main file plus the -wal and
-shm sidecars to 0o600. The sidecars only exist after the first
write, so the FileNotFoundError path is expected and silenced.
Filesystem chmod failures are also swallowed for FUSE-mount
compatibility.
behavior change from v0.2.5: ~/.local/share/booru-viewer/booru.db
is now 0o600 even if a previous version created it 0o644.
Audit-Ref: SECURITY_AUDIT.md finding #4
Severity: Medium
The data directory holds the SQLite database whose `sites` table
stores api_key and api_user in plaintext. Previous behavior used
the inherited umask (typically 0o755), which leaves the dir
world-traversable on shared workstations and on networked home
dirs whose home is 0o755. Tighten to 0o700 unconditionally on
every data_dir() call so the fix is applied even when an older
version (or external tooling) left the directory loose.
Failures from filesystems that don't support chmod (some FUSE
mounts) are swallowed — better to keep working than refuse to
start. Windows: no-op, NTFS ACLs handle this separately.
behavior change from v0.2.5: ~/.local/share/booru-viewer is now
0o700 even if it was previously 0o755.
Audit-Ref: SECURITY_AUDIT.md finding #4
Severity: Medium
Same fix as danbooru.py and e621.py — Gelbooru's params dict
carries api_key + user_id when configured. Route through
redact_params() before the debug log emits them.
Audit-Ref: SECURITY_AUDIT.md finding #3
Severity: Medium
Same fix as danbooru.py — the search() log.debug params line
previously emitted login + api_key. Route through redact_params().
Audit-Ref: SECURITY_AUDIT.md finding #3
Severity: Medium
The log.debug(f" params: {params}") line in search() previously
dumped login + api_key to the booru logger at DEBUG level. Route
the params dict through redact_params() so the keys are replaced
with *** before formatting.
Audit-Ref: SECURITY_AUDIT.md finding #3
Severity: Medium
The httpx request event hook converts request.url to a str so
log_connection can parse it — at that point the credential query
params (login, api_key, etc.) are in scope and could be captured
by any traceback, debug hook, or monitoring agent observing the
hook call. Pipe through redact_url() first so the rendered string
never carries the secrets, even transiently.
Audit-Ref: SECURITY_AUDIT.md finding #3
Severity: Medium
detect_site_type constructs a fresh BooruClient._shared_client
directly (bypassing the BooruClient.client property) for the
/posts.json, /index.php, and /post.json probes. The hooks set
here are the ones installed on that initial construction — if
detection runs before any BooruClient instance's .client is
accessed, the shared singleton must still have SSRF validation
and connection logging.
This additionally closes finding #16 for the detect client — site
detection requests now appear in the connection log instead of
being invisible.
behavior change from v0.2.5: Test Connection from the site dialog
now rejects private-IP targets. Adding a local/RFC1918 booru via
the "auto-detect type" dialog will fail with "blocked request
target ..." instead of probing it. Explicit api_type selection
still goes through the BooruClient.client path, which is also
now protected.
Audit-Ref: SECURITY_AUDIT.md finding #1
Also-Closes: SECURITY_AUDIT.md finding #16 (detect half)
Severity: High
E621 maintains its own httpx.AsyncClient because their TOS requires
a per-user User-Agent string that BooruClient's shared client can't
carry. The client is rebuilt on User-Agent change, so the hook must
be installed in the same construction path.
Also installs BooruClient._log_request as a second hook (this
additionally closes finding #16 for the e621 client — e621 requests
previously bypassed the connection log entirely, and this wires
them in consistently with the base client).
Audit-Ref: SECURITY_AUDIT.md finding #1
Also-Closes: SECURITY_AUDIT.md finding #16 (e621 half)
Severity: High
Adds validate_public_request to the cache module's shared httpx
client event_hooks. Covers image/video/thumbnail downloads, which
are the most likely exfil path — file_url comes straight from the
booru JSON response and previously followed any 3xx that landed,
so a hostile booru could point downloads at a private IP. Every
redirect hop is now rejected if the target is non-public.
The import is lazy inside _get_shared_client because
core.api.base imports log_connection from this module; a top-level
`from .api._safety import ...` would circular-import through
api/__init__.py during cache.py load. By the time
_get_shared_client is called the api package is fully loaded.
Audit-Ref: SECURITY_AUDIT.md finding #1
Severity: High
Adds validate_public_request to the BooruClient event_hooks list so
every request (and every redirect hop) is checked against the block
list from _safety.py. Danbooru, Gelbooru, and Moebooru subclasses
all go through BooruClient.client and inherit the protection.
Preserves the existing _log_request hook by listing both hooks in
order: validate first (so blocked hops never reach the log), then
log.
Audit-Ref: SECURITY_AUDIT.md finding #1
Severity: High
Introduces core/api/_safety.py containing check_public_host and the
validate_public_request async request-hook. The hook rejects any URL
whose host is (or resolves to) loopback, RFC1918, link-local
(including 169.254.169.254 cloud metadata), CGNAT, unique-local v6,
or multicast. Called on every request hop so it covers both the
initial URL and every redirect target that httpx would otherwise
follow blindly.
Also exports redact_url / redact_params for finding #3 — the
secret-key set lives in the same module since both #1 and #3 work
is wired through httpx client event_hooks. Helper is stdlib-only
(ipaddress, socket, urllib.parse) plus httpx; no new deps.
Not yet wired into any httpx client; per-file wiring commits follow.
Audit-Ref: SECURITY_AUDIT.md finding #1
Severity: High
Replaces the inline mpv.MPV(...) literal kwargs with a call through
build_mpv_kwargs(), which adds ytdl=no, load_scripts=no, a lavf
protocol whitelist (file,http,https,tls,tcp), and POSIX input_conf
lockdown. Closes the yt-dlp delegation surface (CVE-prone extractors
invoked on attacker-supplied URLs) and the concat:/subfile: local-
file-read gadget via ffmpeg's lavf demuxer.
behavior change from v0.2.5: any file_url whose host is only
handled by yt-dlp (youtube.com, reddit.com, etc.) will no longer
play. Boorus do not legitimately return such URLs, so in practice
this only affects hostile responses. Cached local files and direct
https .mp4/.webm/.mkv continue to work.
Manually smoke tested: played a cached local .mp4 from the library
(file: protocol) and a fresh network .webm from a danbooru search
(https: protocol) — both work.
Audit-Ref: SECURITY_AUDIT.md finding #2
Severity: High