pax 54ccc40477 Defensive hardening across core/* and popout overlay fix
Sweep of defensive hardening across the core layers plus a related popout
overlay regression that surfaced during verification.

Database integrity (core/db.py)
- Wrap delete_site, add_search_history, remove_folder, rename_folder,
  and _migrate in `with self.conn:` so partial commits can't leave
  orphan rows on a crash mid-method.
- add_bookmark re-SELECTs the existing id when INSERT OR IGNORE
  collides on (site_id, post_id). Was returning Bookmark(id=0)
  silently, which then no-op'd update_bookmark_cache_path the next
  time the post was bookmarked.
- get_bookmarks LIKE clauses now ESCAPE '%', '_', '\\' so user search
  literals stop acting as SQL wildcards (cat_ear no longer matches
  catear).

Path traversal (core/db.py + core/config.py)
- Validate folder names at write time via _validate_folder_name —
  rejects '..', os.sep, leading '.' / '~'. Permits Unicode/spaces/
  parens so existing folders keep working.
- saved_folder_dir() resolves the candidate path and refuses anything
  that doesn't relative_to the saved-images base. Defense in depth
  against folder strings that bypass the write-time validator.
- gui/bookmarks.py and gui/app.py wrap add_folder calls in try/except
  ValueError and surface a QMessageBox.warning instead of crashing.

Download safety (core/cache.py)
- New _do_download(): payloads >=50MB stream to a tempfile in the
  destination dir and atomically os.replace into place; smaller
  payloads keep the existing buffer-then-write fast path. Both
  enforce a 500MB hard cap against the advertised Content-Length AND
  the running total inside the chunk loop (servers can lie).
- Per-URL asyncio.Lock coalesces concurrent downloads of the same
  URL so two callers don't race write_bytes on the same path.
- Image.MAX_IMAGE_PIXELS = 256M with DecompressionBombError handling
  in both converters.
- _convert_ugoira_to_gif checks frame count + cumulative uncompressed
  size against UGOIRA_MAX_FRAMES / UGOIRA_MAX_UNCOMPRESSED_BYTES from
  ZipInfo headers BEFORE decompressing — defends against zip bombs.
- _convert_animated_to_gif writes a .convfailed sentinel sibling on
  failure to break the re-decode-on-every-paint loop for malformed
  animated PNGs/WebPs.
- _is_valid_media returns True (don't delete) on OSError so a
  transient EBUSY/permissions hiccup no longer triggers a delete +
  re-download loop on every access.
- _referer_for() uses proper hostname suffix matching, not substring
  `in` (imgblahgelbooru.attacker.com no longer maps to gelbooru.com).
- PIL handles wrapped in `with` blocks for deterministic cleanup.

API client retry + visibility (core/api/*)
- base.py: _request retries on httpx.NetworkError + ConnectError in
  addition to TimeoutException. test_connection no longer echoes the
  HTTP response body in the error string (it was an SSRF body-leak
  gadget when used via detect_site_type's redirect-following client).
- detect.py + danbooru.py + e621.py + gelbooru.py + moebooru.py:
  every previously-swallowed exception in search/autocomplete/probe
  paths now logs at WARNING with type, message, and (where relevant)
  the response body prefix. Debugging "the site isn't working" used
  to be a total blackout.

main_gui.py
- file_dialog_platform DB probe failure prints to stderr instead of
  vanishing.

Popout overlay (gui/preview.py + gui/app.py)
- preview.py:79,141 — setAttribute(WA_StyledBackground, True) on
  _slideshow_toolbar and _slideshow_controls. Plain QWidget parents
  silently ignore QSS `background:` declarations without this
  attribute, which is why the popout overlay strip was rendering
  fully transparent (buttons styled, bar behind them showing the
  letterbox color).
- app.py: bake _BASE_POPOUT_OVERLAY_QSS as a fallback prepended
  before the user's custom.qss in the loader. Custom themes that
  don't define overlay rules now still get a translucent black
  bar with white text + hairline borders. Bundled themes win on
  tie because their identical-specificity rules come last in the
  prepended string.
2026-04-07 17:24:19 -05:00

425 lines
16 KiB
Python

"""Bookmarks browser widget with folder support."""
from __future__ import annotations
import logging
import threading
import asyncio
from pathlib import Path
from PySide6.QtCore import Qt, Signal, QObject, QTimer
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLineEdit,
QPushButton,
QLabel,
QComboBox,
QMenu,
QApplication,
QInputDialog,
QMessageBox,
)
from ..core.db import Database, Bookmark
from ..core.cache import download_thumbnail
from .grid import ThumbnailGrid
log = logging.getLogger("booru")
class BookmarkThumbSignals(QObject):
thumb_ready = Signal(int, str)
class BookmarksView(QWidget):
"""Browse and search local bookmarks with folder support."""
bookmark_selected = Signal(object)
bookmark_activated = Signal(object)
bookmarks_changed = Signal() # emitted after bookmark add/remove/unsave
open_in_browser_requested = Signal(int, int) # (site_id, post_id)
def __init__(self, db: Database, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._db = db
self._bookmarks: list[Bookmark] = []
self._signals = BookmarkThumbSignals()
self._signals.thumb_ready.connect(self._on_thumb_ready, Qt.ConnectionType.QueuedConnection)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Top bar: folder selector + search.
# 4px right margin so the rightmost button doesn't sit flush
# against the preview splitter handle.
top = QHBoxLayout()
top.setContentsMargins(0, 0, 4, 0)
# Compact horizontal padding matches the rest of the app's narrow
# toolbar buttons. Vertical padding (2px) and min-height (inherited
# from the global QPushButton rule = 16px) give a total height of
# 22px, lining up with the bundled themes' inputs/combos so the
# whole toolbar row sits at one consistent height — and matches
# what native Qt+Fusion produces with no QSS at all.
_btn_style = "padding: 2px 6px;"
self._folder_combo = QComboBox()
self._folder_combo.setMinimumWidth(120)
self._folder_combo.currentTextChanged.connect(lambda _: self.refresh())
top.addWidget(self._folder_combo)
manage_btn = QPushButton("+ Folder")
manage_btn.setToolTip("New folder")
manage_btn.setFixedWidth(75)
manage_btn.setStyleSheet(_btn_style)
manage_btn.clicked.connect(self._new_folder)
top.addWidget(manage_btn)
self._search_input = QLineEdit()
self._search_input.setPlaceholderText("Search bookmarks by tag")
# Enter still triggers an immediate search.
self._search_input.returnPressed.connect(self._do_search)
# Live search via debounced timer: every keystroke restarts a
# 150ms one-shot, when the user stops typing the search runs.
# Cheap enough since each search is just one SQLite query.
self._search_debounce = QTimer(self)
self._search_debounce.setSingleShot(True)
self._search_debounce.setInterval(150)
self._search_debounce.timeout.connect(self._do_search)
self._search_input.textChanged.connect(
lambda _: self._search_debounce.start()
)
top.addWidget(self._search_input, stretch=1)
layout.addLayout(top)
# Count label
self._count_label = QLabel()
layout.addWidget(self._count_label)
# Grid
self._grid = ThumbnailGrid()
self._grid.post_selected.connect(self._on_selected)
self._grid.post_activated.connect(self._on_activated)
self._grid.context_requested.connect(self._on_context_menu)
self._grid.multi_context_requested.connect(self._on_multi_context_menu)
layout.addWidget(self._grid)
def _refresh_folders(self) -> None:
current = self._folder_combo.currentText()
self._folder_combo.blockSignals(True)
self._folder_combo.clear()
self._folder_combo.addItem("All Bookmarks")
self._folder_combo.addItem("Unfiled")
for folder in self._db.get_folders():
self._folder_combo.addItem(folder)
# Restore selection
idx = self._folder_combo.findText(current)
if idx >= 0:
self._folder_combo.setCurrentIndex(idx)
self._folder_combo.blockSignals(False)
def refresh(self, search: str | None = None) -> None:
self._refresh_folders()
folder_text = self._folder_combo.currentText()
folder_filter = None
if folder_text == "Unfiled":
folder_filter = "" # sentinel for NULL folder
elif folder_text not in ("All Bookmarks", ""):
folder_filter = folder_text
if folder_filter == "":
# Get unfiled: folder IS NULL
self._bookmarks = [
f for f in self._db.get_bookmarks(search=search, limit=500)
if f.folder is None
]
elif folder_filter:
self._bookmarks = self._db.get_bookmarks(search=search, folder=folder_filter, limit=500)
else:
self._bookmarks = self._db.get_bookmarks(search=search, limit=500)
self._count_label.setText(f"{len(self._bookmarks)} bookmarks")
thumbs = self._grid.set_posts(len(self._bookmarks))
from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS
for i, (fav, thumb) in enumerate(zip(self._bookmarks, thumbs)):
thumb.set_bookmarked(True)
# Check if saved to library
saved = False
if fav.folder:
saved = any(
(saved_folder_dir(fav.folder) / f"{fav.post_id}{ext}").exists()
for ext in MEDIA_EXTENSIONS
)
else:
saved = any(
(saved_dir() / f"{fav.post_id}{ext}").exists()
for ext in MEDIA_EXTENSIONS
)
thumb.set_saved_locally(saved)
# Set cached path for drag-and-drop and copy
if fav.cached_path and Path(fav.cached_path).exists():
thumb._cached_path = fav.cached_path
if fav.preview_url:
self._load_thumb_async(i, fav.preview_url)
elif fav.cached_path and Path(fav.cached_path).exists():
pix = QPixmap(fav.cached_path)
if not pix.isNull():
thumb.set_pixmap(pix)
def _load_thumb_async(self, index: int, url: str) -> None:
async def _dl():
try:
path = await download_thumbnail(url)
self._signals.thumb_ready.emit(index, str(path))
except Exception as e:
log.warning(f"Bookmark thumb {index} failed: {e}")
threading.Thread(target=lambda: asyncio.run(_dl()), daemon=True).start()
def _on_thumb_ready(self, index: int, path: str) -> None:
thumbs = self._grid._thumbs
if 0 <= index < len(thumbs):
pix = QPixmap(path)
if not pix.isNull():
thumbs[index].set_pixmap(pix)
def _do_search(self) -> None:
text = self._search_input.text().strip()
self.refresh(search=text if text else None)
def _on_selected(self, index: int) -> None:
if 0 <= index < len(self._bookmarks):
self.bookmark_selected.emit(self._bookmarks[index])
def _on_activated(self, index: int) -> None:
if 0 <= index < len(self._bookmarks):
self.bookmark_activated.emit(self._bookmarks[index])
def _copy_to_library_unsorted(self, fav: Bookmark) -> None:
"""Copy a bookmarked image to the unsorted library folder."""
from ..core.config import saved_dir
if fav.cached_path and Path(fav.cached_path).exists():
import shutil
src = Path(fav.cached_path)
dest = saved_dir() / f"{fav.post_id}{src.suffix}"
if not dest.exists():
shutil.copy2(src, dest)
def _copy_to_library(self, fav: Bookmark, folder: str) -> None:
"""Copy a bookmarked image to the library folder on disk."""
from ..core.config import saved_folder_dir
if fav.cached_path and Path(fav.cached_path).exists():
import shutil
src = Path(fav.cached_path)
dest = saved_folder_dir(folder) / f"{fav.post_id}{src.suffix}"
if not dest.exists():
shutil.copy2(src, dest)
def _new_folder(self) -> None:
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip():
try:
self._db.add_folder(name.strip())
except ValueError as e:
QMessageBox.warning(self, "Invalid Folder Name", str(e))
return
self._refresh_folders()
def _on_context_menu(self, index: int, pos) -> None:
if index < 0 or index >= len(self._bookmarks):
return
fav = self._bookmarks[index]
from PySide6.QtGui import QDesktopServices
from PySide6.QtCore import QUrl
from .dialogs import save_file
menu = QMenu(self)
open_browser = menu.addAction("Open in Browser")
open_default = menu.addAction("Open in Default App")
menu.addSeparator()
save_as = menu.addAction("Save As...")
# Save to Library submenu
save_lib_menu = menu.addMenu("Save to Library")
save_lib_unsorted = save_lib_menu.addAction("Unsorted")
save_lib_menu.addSeparator()
save_lib_folders = {}
for folder in self._db.get_folders():
a = save_lib_menu.addAction(folder)
save_lib_folders[id(a)] = folder
save_lib_menu.addSeparator()
save_lib_new = save_lib_menu.addAction("+ New Folder...")
unsave_lib = None
# Only show unsave if the post is saved locally
from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS
_saved = False
_sd = saved_dir()
if _sd.exists():
_saved = any((_sd / f"{fav.post_id}{ext}").exists() for ext in MEDIA_EXTENSIONS)
if not _saved:
for folder in self._db.get_folders():
d = saved_folder_dir(folder)
if d.exists() and any((d / f"{fav.post_id}{ext}").exists() for ext in MEDIA_EXTENSIONS):
_saved = True
break
if _saved:
unsave_lib = menu.addAction("Unsave from Library")
copy_file = menu.addAction("Copy File to Clipboard")
copy_url = menu.addAction("Copy Image URL")
copy_tags = menu.addAction("Copy Tags")
# Move to folder submenu
menu.addSeparator()
move_menu = menu.addMenu("Move to Folder")
move_none = move_menu.addAction("Unfiled")
move_menu.addSeparator()
folder_actions = {}
for folder in self._db.get_folders():
a = move_menu.addAction(folder)
folder_actions[id(a)] = folder
move_menu.addSeparator()
move_new = move_menu.addAction("+ New Folder...")
menu.addSeparator()
remove_bookmark = menu.addAction("Remove Bookmark")
action = menu.exec(pos)
if not action:
return
if action == save_lib_unsorted:
self._copy_to_library_unsorted(fav)
self.refresh()
elif action == save_lib_new:
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip():
try:
self._db.add_folder(name.strip())
except ValueError as e:
QMessageBox.warning(self, "Invalid Folder Name", str(e))
return
self._copy_to_library(fav, name.strip())
self._db.move_bookmark_to_folder(fav.id, name.strip())
self.refresh()
elif id(action) in save_lib_folders:
folder_name = save_lib_folders[id(action)]
self._copy_to_library(fav, folder_name)
self.refresh()
elif action == open_browser:
self.open_in_browser_requested.emit(fav.site_id, fav.post_id)
elif action == open_default:
if fav.cached_path and Path(fav.cached_path).exists():
QDesktopServices.openUrl(QUrl.fromLocalFile(fav.cached_path))
elif action == save_as:
if fav.cached_path and Path(fav.cached_path).exists():
src = Path(fav.cached_path)
dest = save_file(self, "Save Image", f"post_{fav.post_id}{src.suffix}", f"Images (*{src.suffix})")
if dest:
import shutil
shutil.copy2(src, dest)
elif action == unsave_lib:
from ..core.cache import delete_from_library
if delete_from_library(fav.post_id, fav.folder):
self.refresh()
self.bookmarks_changed.emit()
elif action == copy_file:
path = fav.cached_path
if path and Path(path).exists():
from PySide6.QtCore import QMimeData, QUrl
from PySide6.QtGui import QPixmap
mime = QMimeData()
mime.setUrls([QUrl.fromLocalFile(str(Path(path).resolve()))])
pix = QPixmap(path)
if not pix.isNull():
mime.setImageData(pix.toImage())
QApplication.clipboard().setMimeData(mime)
elif action == copy_url:
QApplication.clipboard().setText(fav.file_url)
elif action == copy_tags:
QApplication.clipboard().setText(fav.tags)
elif action == move_none:
self._db.move_bookmark_to_folder(fav.id, None)
self.refresh()
elif action == move_new:
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip():
try:
self._db.add_folder(name.strip())
except ValueError as e:
QMessageBox.warning(self, "Invalid Folder Name", str(e))
return
self._db.move_bookmark_to_folder(fav.id, name.strip())
self._copy_to_library(fav, name.strip())
self.refresh()
elif id(action) in folder_actions:
folder_name = folder_actions[id(action)]
self._db.move_bookmark_to_folder(fav.id, folder_name)
self._copy_to_library(fav, folder_name)
self.refresh()
elif action == remove_bookmark:
self._db.remove_bookmark(fav.site_id, fav.post_id)
self.refresh()
self.bookmarks_changed.emit()
def _on_multi_context_menu(self, indices: list, pos) -> None:
favs = [self._bookmarks[i] for i in indices if 0 <= i < len(self._bookmarks)]
if not favs:
return
menu = QMenu(self)
save_all = menu.addAction(f"Save All ({len(favs)}) to Library")
unsave_all = menu.addAction(f"Unsave All ({len(favs)}) from Library")
menu.addSeparator()
move_menu = menu.addMenu(f"Move All ({len(favs)}) to Folder")
move_none = move_menu.addAction("Unfiled")
move_menu.addSeparator()
folder_actions = {}
for folder in self._db.get_folders():
a = move_menu.addAction(folder)
folder_actions[id(a)] = folder
menu.addSeparator()
remove_all = menu.addAction(f"Remove All Bookmarks ({len(favs)})")
action = menu.exec(pos)
if not action:
return
if action == save_all:
for fav in favs:
if fav.folder:
self._copy_to_library(fav, fav.folder)
else:
self._copy_to_library_unsorted(fav)
self.refresh()
elif action == unsave_all:
from ..core.cache import delete_from_library
for fav in favs:
delete_from_library(fav.post_id, fav.folder)
self.refresh()
self.bookmarks_changed.emit()
elif action == move_none:
for fav in favs:
self._db.move_bookmark_to_folder(fav.id, None)
self.refresh()
elif id(action) in folder_actions:
folder_name = folder_actions[id(action)]
for fav in favs:
self._db.move_bookmark_to_folder(fav.id, folder_name)
self._copy_to_library(fav, folder_name)
self.refresh()
elif action == remove_all:
for fav in favs:
self._db.remove_bookmark(fav.site_id, fav.post_id)
self.refresh()
self.bookmarks_changed.emit()