fix(trash): allow the mount's own trash when the output folder is a mount point (#543)

An output root that is exactly a mount root may hold that mount's .Trash-$uid
and .Trash/$uid, with the spec's checks intact. Any other trash inside a root
stays refused, and so does a root inside the trash. Media routes no longer
serve files in trash folders, and a review with nothing to move shows a
neutral disabled primary.
This commit is contained in:
Vito Sansevero
2026-10-30 12:46:19 -07:00
parent a6d6129ec6
commit 7eaf13b006
11 changed files with 708 additions and 18 deletions
+45 -4
View File
@@ -66,7 +66,11 @@ try:
from ...database.models import normalize_image_path
from ...database.run_attribution import recorded_output_lookup
from ...utils.card_seed import card_seed, header_params, parsed_positive
from ...utils.media_folders import in_skipped_folder, is_skipped_folder
from ...utils.media_folders import (
in_skipped_folder,
in_trash_folder,
is_skipped_folder,
)
from ...utils.parallel import map_parallel
from ...utils.json_strict import dumps_strict
from ...utils.inspection_response import (
@@ -92,7 +96,11 @@ except ImportError:
from database.models import normalize_image_path
from database.run_attribution import recorded_output_lookup
from utils.card_seed import card_seed, header_params, parsed_positive
from utils.media_folders import in_skipped_folder, is_skipped_folder
from utils.media_folders import (
in_skipped_folder,
in_trash_folder,
is_skipped_folder,
)
from utils.parallel import map_parallel
from utils.json_strict import dumps_strict
from utils.inspection_response import (
@@ -291,6 +299,21 @@ def _media_access_error(path, allowed_dirs):
return None
def _record_in_trash_folder(path, roots) -> bool:
"""Whether a recorded *path* lies in a trash folder below every root holding it.
A source named like a trash folder is still a source, so a path it holds
without a trash folder below it stays servable (#543).
"""
real = os.path.realpath(path)
below = []
for root in roots:
real_root = os.path.realpath(root)
if real.startswith(os.path.join(real_root, "")):
below.append(os.path.relpath(real, real_root))
return bool(below) and all(in_trash_folder(relative) for relative in below)
# Media types the gallery scans and thumbnails (a superset is never served).
GALLERY_IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif"})
MEDIA_EXTENSIONS = GALLERY_IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
@@ -2640,7 +2663,10 @@ class ImageRoutesMixin:
if denied is not None:
return denied
if not image_path.is_file():
if (
_record_in_trash_folder(image_path, allowed_dirs)
or not image_path.is_file()
):
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404
)
@@ -2761,6 +2787,13 @@ class ImageRoutesMixin:
if _has_traversal(filepath):
return _forbidden()
suffix = Path(filepath).suffix.lower()
if in_trash_folder(filepath):
# Thrown away, not an output (#543): as if it were not there.
return web.json_response(
{"success": False, "error": "Image file not found"},
status=404,
headers={"Cache-Control": "no-store"},
)
if suffix not in SERVABLE_EXTENSIONS and not (
# A JPEG XL preview is served from thumbnails/ only (#317).
suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
@@ -3593,7 +3626,9 @@ class ImageRoutesMixin:
if denied is not None:
return denied
if not os.path.isfile(image_path):
if _record_in_trash_folder(image_path, allowed_dirs) or not os.path.isfile(
image_path
):
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404
)
@@ -3673,6 +3708,8 @@ class ImageRoutesMixin:
@staticmethod
def _reveal_path(relative: str, root: Path) -> Path | None:
"""The real path of a regular media file under *root*, else None."""
if in_trash_folder(relative):
return None
resolved = ImageRoutesMixin._resolve_client_media_path(relative, [root])
if resolved is None or _media_access_error(resolved, [root]) is not None:
return None
@@ -3907,6 +3944,10 @@ class ImageRoutesMixin:
if _is_thumbnail_cache_path(relative):
# A preview is a placeholder: it carries no metadata (#317).
return _forbidden("Previews are not inspected; inspect the original")
if in_trash_folder(relative):
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404
)
path = Path(roots[index]) / relative
denied = _media_access_error(path, [roots[index]])
if denied is None:
+3 -1
View File
@@ -15,7 +15,7 @@ from pathlib import Path
from aiohttp import web
from .images import _canonical, _media_access_error
from .images import _canonical, _media_access_error, _record_in_trash_folder
from .pairs import pair_response
try:
@@ -268,6 +268,8 @@ class LibraryRoutesMixin:
denied = _media_access_error(path, roots)
if denied is not None or not path.is_file():
continue
if _record_in_trash_folder(path, roots):
continue
for index, root in enumerate(roots):
canonical_root = _canonical(root)
if path.is_relative_to(canonical_root):