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.
117 lines
3.6 KiB
Python
117 lines
3.6 KiB
Python
"""Settings, paths, constants, platform detection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import platform
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
APPNAME = "booru-viewer"
|
|
IS_WINDOWS = sys.platform == "win32"
|
|
|
|
|
|
def hypr_rules_enabled() -> bool:
|
|
"""Whether the in-code hyprctl dispatches that change window state
|
|
should run.
|
|
|
|
Returns False when BOORU_VIEWER_NO_HYPR_RULES is set in the environment.
|
|
Callers should skip any hyprctl `dispatch` that would mutate window
|
|
state (resize, move, togglefloating, setprop no_anim, the floating
|
|
"prime" sequence). Read-only queries (`hyprctl clients -j`) are still
|
|
fine — only mutations are blocked.
|
|
|
|
The popout's keep_aspect_ratio enforcement is gated by the separate
|
|
popout_aspect_lock_enabled() — it's a different concern.
|
|
"""
|
|
return not os.environ.get("BOORU_VIEWER_NO_HYPR_RULES")
|
|
|
|
|
|
def popout_aspect_lock_enabled() -> bool:
|
|
"""Whether the popout's keep_aspect_ratio setprop should run.
|
|
|
|
Returns False when BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK is set in the
|
|
environment. Independent of hypr_rules_enabled() so a ricer can free
|
|
up the popout's shape (e.g. for fixed-square or panoramic popouts)
|
|
while keeping the rest of the in-code hyprctl behavior, or vice versa.
|
|
"""
|
|
return not os.environ.get("BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK")
|
|
|
|
|
|
def data_dir() -> Path:
|
|
"""Return the platform-appropriate data/cache directory."""
|
|
if IS_WINDOWS:
|
|
base = Path.home() / "AppData" / "Roaming"
|
|
else:
|
|
base = Path(
|
|
__import__("os").environ.get(
|
|
"XDG_DATA_HOME", str(Path.home() / ".local" / "share")
|
|
)
|
|
)
|
|
path = base / APPNAME
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def cache_dir() -> Path:
|
|
"""Return the image cache directory."""
|
|
path = data_dir() / "cache"
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def thumbnails_dir() -> Path:
|
|
"""Return the thumbnail cache directory."""
|
|
path = data_dir() / "thumbnails"
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
_library_dir_override: Path | None = None
|
|
|
|
|
|
def set_library_dir(path: Path | None) -> None:
|
|
global _library_dir_override
|
|
_library_dir_override = path
|
|
|
|
|
|
def saved_dir() -> Path:
|
|
"""Return the saved images directory."""
|
|
if _library_dir_override:
|
|
path = _library_dir_override
|
|
else:
|
|
path = data_dir() / "saved"
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def saved_folder_dir(folder: str) -> Path:
|
|
"""Return a subfolder inside saved images, refusing path traversal.
|
|
|
|
Folder names should normally be filtered by `db._validate_folder_name`
|
|
before reaching the filesystem, but this is a defense-in-depth check:
|
|
resolve the candidate path and ensure it's still inside `saved_dir()`.
|
|
Anything that escapes (`..`, absolute paths, symlink shenanigans) raises
|
|
ValueError instead of silently writing to disk wherever the string points.
|
|
"""
|
|
base = saved_dir().resolve()
|
|
candidate = (base / folder).resolve()
|
|
try:
|
|
candidate.relative_to(base)
|
|
except ValueError as e:
|
|
raise ValueError(f"Folder escapes saved directory: {folder!r}") from e
|
|
candidate.mkdir(parents=True, exist_ok=True)
|
|
return candidate
|
|
|
|
|
|
def db_path() -> Path:
|
|
"""Return the path to the SQLite database."""
|
|
return data_dir() / "booru.db"
|
|
|
|
|
|
# Defaults
|
|
DEFAULT_THUMBNAIL_SIZE = (200, 200)
|
|
DEFAULT_PAGE_SIZE = 40
|
|
USER_AGENT = f"booru-viewer/0.1 ({platform.system()})"
|
|
MEDIA_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".webm", ".mkv", ".avi", ".mov")
|