fix(trash): trash on an output folder that is a mount point (#543) (#544)

Closes #543
This commit is contained in:
Vito Sansevero
2026-10-30 16:54:50 -07:00
parent 4be48c2ee5
commit cdaf4122de
20 changed files with 1441 additions and 37 deletions
+27 -7
View File
@@ -36,10 +36,18 @@ except ImportError:
try:
from ...utils import exit_signal
from ...utils.media_folders import is_skipped_folder
from ...utils.media_folders import (
in_trash_below_roots,
in_trash_folder,
is_skipped_folder,
)
except ImportError:
from utils import exit_signal
from utils.media_folders import is_skipped_folder
from utils.media_folders import (
in_trash_below_roots,
in_trash_folder,
is_skipped_folder,
)
try:
from ...database.image_removal import mark_hides
@@ -1089,13 +1097,21 @@ class AdminRoutesMixin:
return result
def _delete_one_duplicate(self, image_path, output_path):
"""Delete one file and its thumbnail; return a failure reason or None."""
"""Delete one file and its thumbnail; return a failure reason or None.
What is removed is exactly the folder's real path plus the name: a
link is removed itself, never its target, and the preview removed is
that name's. Both the link's place and what it leads to are checked.
"""
file_path = Path(image_path)
if not file_path.is_absolute():
file_path = output_path / file_path
try:
rel_path = file_path.resolve().relative_to(output_path.resolve())
real_root = output_path.resolve()
rel_path = file_path.resolve().relative_to(real_root)
exact = Path(os.path.realpath(file_path.parent)) / file_path.name
exact_rel = exact.relative_to(real_root)
except ValueError:
self.logger.warning(
f"Attempted to delete file outside output directory: {image_path}"
@@ -1104,15 +1120,19 @@ class AdminRoutesMixin:
if file_path.suffix.lower() not in DELETABLE_MEDIA_EXTENSIONS:
return "not a media file"
if "thumbnails" in rel_path.parts:
if "thumbnails" in rel_path.parts or "thumbnails" in exact_rel.parts:
return "thumbnails are managed by the gallery"
if in_trash_below_roots(file_path, [output_path]) or in_trash_folder(
str(exact_rel)
):
return "it is in a trash folder" # never unlinked from a trash (#543)
if not file_path.is_file():
return "file not found"
os.remove(file_path)
os.remove(exact)
self.logger.info(f"Deleted duplicate image: {image_path}")
_remove_thumbnail(output_path.resolve(), rel_path, self.logger)
_remove_thumbnail(real_root, exact_rel, self.logger)
return None
async def get_statistics(self, request):
+6 -3
View File
@@ -24,9 +24,9 @@ from . import thumbnail_settings
from .. import model_gate
try:
from ...utils.media_folders import is_skipped_folder
from ...utils.media_folders import in_trash_below_roots, is_skipped_folder
except ImportError:
from utils.media_folders import is_skipped_folder
from utils.media_folders import in_trash_below_roots, is_skipped_folder
from aiohttp import web
@@ -849,9 +849,12 @@ class AutotagRoutesMixin:
"""Return the real path when it is a media file inside an output directory."""
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
return None
for output_dir in self._get_all_output_dirs():
roots = list(self._get_all_output_dirs())
for output_dir in roots:
if path_is_within(raw_path, output_dir):
real = os.path.realpath(raw_path)
if in_trash_below_roots(real, roots):
return None # thrown away, not an output (#543)
return real if os.path.isfile(real) else None
return None
+9
View File
@@ -16,6 +16,11 @@ from pathlib import Path
from .admin import DELETABLE_MEDIA_EXTENSIONS
from .maintenance import DUPLICATE_FILES
try:
from ...utils.media_folders import in_trash_below_roots, is_trash_folder
except ImportError:
from utils.media_folders import in_trash_below_roots, is_trash_folder
# Keep entries one review takes; with paths of a few hundred characters the
# list stays inside the preview body cap (DUPLICATE_PREVIEW_BODY_MAX, 1 MiB).
MAX_KEEP = 2000
@@ -197,6 +202,8 @@ def file_problem(
parts = Path(os.path.relpath(lexical, base)).parts
if "thumbnails" in parts or ".." in parts:
return OUTSIDE_SOURCE
if any(is_trash_folder(part) for part in parts[:-1]):
return OUTSIDE_SOURCE # never removed from a trash folder (#543)
if os.path.splitext(lexical)[1].lower() not in DELETABLE_MEDIA_EXTENSIONS:
return OUTSIDE_SOURCE
try:
@@ -212,6 +219,8 @@ def file_problem(
try:
if not _lexically_inside(_canonical_path(path), [_canonical_path(root)]):
return OUTSIDE_SOURCE # a linked folder on the way
if in_trash_below_roots(path, [root]):
return OUTSIDE_SOURCE # a linked folder into a trash folder
if keeper is not None:
same = keeper_problem(path, root, info, keeper, keeper_root)
if same:
+8
View File
@@ -17,6 +17,7 @@ from aiohttp import web
from . import thumbnail_settings
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
from .images import in_trash_below_roots
from .images import existing_media_thumbnail, media_thumbnail_rel
try:
@@ -198,6 +199,9 @@ def resolve_current_candidate(
if namespace is None or not namespace.is_dir():
return _unavailable("root_unavailable")
target = _canonical(lexical)
if in_trash_below_roots(target, [namespace]):
# Every kind and the outputs listing come through here (#543).
return _unavailable("missing")
if not _authorized(target, namespace, scope):
return _unavailable("not_authorized")
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
@@ -248,6 +252,10 @@ def _open_current_target(
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317)."""
if _canonical(target) != target:
raise CurrentLocationUnavailable("not_authorized", stale=True)
if in_trash_below_roots(target, [namespace]):
# Also at the open: the cover chooses its preview after the candidate
# check, and a preview can be a link into a trash folder (#543).
raise CurrentLocationUnavailable("missing")
if not _authorized(target, namespace, scope):
raise CurrentLocationUnavailable("not_authorized")
suffix = target.suffix.lower()
+37 -7
View File
@@ -66,7 +66,12 @@ 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_below_roots,
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 +97,12 @@ 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_below_roots,
in_trash_folder,
is_skipped_folder,
)
from utils.parallel import map_parallel
from utils.json_strict import dumps_strict
from utils.inspection_response import (
@@ -277,10 +287,20 @@ def _forbidden(message="Access denied"):
return web.json_response({"success": False, "error": message}, status=403)
def _media_access_error(path, allowed_dirs):
"""Return a 403 response when *path* must not be served, else None.
def _file_not_found():
return web.json_response(
{"success": False, "error": "Image file not found"},
status=404,
headers={"Cache-Control": "no-store"},
)
Fails closed: an empty *allowed_dirs* denies everything.
def _media_access_error(path, allowed_dirs):
"""The response refusing *path*, or None when it may be read.
Fails closed: an empty *allowed_dirs* denies everything (403). A file in a
trash folder below its root is thrown away, not an output: a 404, as if it
were not there (#543), also through a link or another spelling.
"""
if Path(path).suffix.lower() not in SERVABLE_EXTENSIONS:
return _forbidden("Only media files can be served")
@@ -288,6 +308,8 @@ def _media_access_error(path, allowed_dirs):
return _forbidden("No allowed image directories configured")
if not any(_is_within(path, root) for root in allowed_dirs):
return _forbidden()
if in_trash_below_roots(path, allowed_dirs):
return _file_not_found()
return None
@@ -979,6 +1001,8 @@ def _settled_entry(root, relative):
return None
if in_skipped_folder(folders) or len(folders) > MAX_SCAN_DEPTH:
return None
if in_trash_below_roots(path, [root]):
return None # a linked folder on the way leads into a trash folder
folder = os.fspath(root)
for part in ("", *folders):
folder = os.path.join(folder, part) if part else folder
@@ -2761,6 +2785,9 @@ class ImageRoutesMixin:
if _has_traversal(filepath):
return _forbidden()
suffix = Path(filepath).suffix.lower()
if in_trash_folder(filepath):
# Before any file access, so no preview is made for it (#543).
return _file_not_found()
if suffix not in SERVABLE_EXTENSIONS and not (
# A JPEG XL preview is served from thumbnails/ only (#317).
suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
@@ -2784,6 +2811,8 @@ class ImageRoutesMixin:
# a fall-through to the next root.
if not _is_within(image_path, output_path):
return _forbidden()
if in_trash_below_roots(image_path, output_dirs):
return _file_not_found()
if image_path.is_file():
return self._file_response(image_path)
@@ -2835,8 +2864,9 @@ class ImageRoutesMixin:
if _is_thumbnail_cache_path(filepath):
return _forbidden()
image_path = root / filepath
if not _is_within(image_path, root):
return _forbidden()
denied = _media_access_error(image_path, [root])
if denied is not None:
return denied
if not image_path.is_file():
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404
+8 -2
View File
@@ -9,13 +9,15 @@ by the caller.
import os
import re
from .images import _forbidden, _has_traversal
from .images import _file_not_found, _forbidden, _has_traversal
from .prompts import bad_request
try:
from ...utils.media_capture import CAPTURE_EXTENSIONS
from ...utils.media_folders import in_trash_folder
except ImportError:
from utils.media_capture import CAPTURE_EXTENSIONS
from utils.media_folders import in_trash_folder
MAX_TARGET_PATH = 1024
TARGET_FIELDS = frozenset({"root_index", "root_scope", "path"})
@@ -29,7 +31,9 @@ def media_target(data: object, *, media_message: str = TAGGING_MEDIA_MESSAGE):
Only the listing identity is accepted: an int root index, the root's
64-hex scope and a bounded root-relative POSIX media path, never an
absolute, drive-qualified, backslash or ``..`` path (a 400 for a malformed
shape, a 403 for an unsafe or non-media path, carrying *media_message*).
shape, a 403 for an unsafe or non-media path, carrying *media_message*, a
404 for a path in a trash folder, #543). A link or a short-name spelling
is refused later by the capture store's alias check.
"""
if not isinstance(data, dict) or set(data) != TARGET_FIELDS:
return None, bad_request("target must name root_index, root_scope and path")
@@ -51,4 +55,6 @@ def media_target(data: object, *, media_message: str = TAGGING_MEDIA_MESSAGE):
return None, _forbidden()
if os.path.splitext(parts[-1])[1].lower() not in CAPTURE_EXTENSIONS:
return None, _forbidden(media_message)
if in_trash_folder(relative):
return None, _file_not_found()
return (index, scope, relative), None
+9 -2
View File
@@ -41,6 +41,13 @@ def _coded(status: int, code: str, message: str) -> web.Response:
)
def _missing_if_not_found(response: web.Response) -> web.Response:
"""A file in a trash folder answers as a missing file does (#543)."""
if response.status == 404:
return _coded(404, "file-missing", "Image file not found")
return response
def _no_store(response: web.Response) -> web.Response:
response.headers["Cache-Control"] = "no-store"
return response
@@ -71,7 +78,7 @@ class SourceRescopeMixin:
path = root / relative
denied = _media_access_error(path, [root])
if denied is not None:
return denied
return _missing_if_not_found(denied)
try:
mtime = path.stat().st_mtime if path.is_file() else None
except OSError:
@@ -89,7 +96,7 @@ class SourceRescopeMixin:
return bad_request("Send exactly one target")
target, error = media_target(data["target"])
if error is not None:
return error
return _missing_if_not_found(error)
old_index, scope, relative = target
if old_index > MAX_ROOT_INDEX:
return bad_request("Invalid gallery root")
+6 -1
View File
@@ -67,6 +67,7 @@ from aiohttp import web
from . import thumbnail_settings
from .images import (
in_trash_below_roots,
MEDIA_EXTENSIONS,
THUMBNAIL_CLEAR_TITLE,
VIDEO_EXTENSIONS,
@@ -224,6 +225,8 @@ def _quick_source(root, parts):
for name in (base + extension, base + extension.upper()):
path = folder / name
if _regular_file(path) and _is_within(path, root):
if in_trash_below_roots(path, [root]):
return None # also the fallback past the gate's cap (#543)
return path, extension in VIDEO_EXTENSIONS
return None
@@ -248,6 +251,8 @@ def _thumbnail_source(root, parts):
found = _scan_folder(root.joinpath(*folder_parts), base, set(wanted))
if found is None or not _is_within(found, root):
return None
if in_trash_below_roots(found, [root]):
return None
rel = Path(*folder_parts, found.name)
dst = root / media_thumbnail_rel(rel)
if dst != _current_preview(root, parts):
@@ -835,7 +840,7 @@ class AutoThumbnailMixin:
if not folded.startswith(base) or not _is_within(path, root):
continue
rel = Path(os.path.relpath(path, root))
if "thumbnails" in rel.parts[:-1]:
if "thumbnails" in rel.parts[:-1] or in_trash_below_roots(path, [root]):
return None
dst = root / media_thumbnail_rel(rel)
if not _is_within(root / "thumbnails", root) or not _is_within(