fix(thumbnails): gate the preview fallback past the cap, and History outputs (#543)

The gate's OVER branch looked its source up by name and served it without the
trash check. A History output recorded inside a trash folder now answers missing.
This commit is contained in:
Vito Sansevero
2026-10-30 12:57:35 -07:00
parent f0b471d3fb
commit 8a8e111865
4 changed files with 33 additions and 2 deletions
+2 -1
View File
@@ -2494,7 +2494,8 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
`_media_access_error` is the one gate (by-id, inspect, reveal, link, rescope as
`file-missing`, a prompt's media listing); `/images/serve` and the autotag target ask
`in_trash_below_roots` too, `media_target` refuses by name (annotations, collections,
whose capture store denies links and aliases), and previews are not made. The delete
whose capture store denies links and aliases), and previews are neither made nor served, the fallback past the preview gate's cap
included (`_quick_source`). A History output in one answers `missing`. The delete
and Return flows never use this gate. Windows spellings (trailing dots and spaces, case,
8.3) run for real on the Windows job in `tests/test_trash_gate_routes.py`.
- **An output folder that is a mount point** (#543): the mount's own `.Trash-$uid` and
+4
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:
@@ -248,6 +249,9 @@ 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]):
# An output recorded before #501 skipped trash folders (#543).
raise CurrentLocationUnavailable("missing")
if not _authorized(target, namespace, scope):
raise CurrentLocationUnavailable("not_authorized")
suffix = target.suffix.lower()
+2
View File
@@ -225,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
+25 -1
View File
@@ -11,6 +11,7 @@ import os
import unittest
import urllib.parse
from pathlib import Path
from unittest.mock import AsyncMock, patch
try:
from tests.test_api_admin import _write_png
@@ -20,7 +21,8 @@ except ImportError: # discovered with tests/ as the top-level directory
from test_image_delete_routes import ImageDeleteTestCase
from py.api.images import media_thumbnail_rel
from py.api.thumbnail_auto import _thumbnail_source
from py.api.history_current import CurrentLocationUnavailable, _open_current_target
from py.api.thumbnail_auto import OVER, _quick_source, _thumbnail_source
from utils.media_folders import in_trash_below_roots, in_trash_folder, is_trash_folder
ON_CI = bool(os.environ.get("GITHUB_ACTIONS"))
@@ -204,6 +206,28 @@ class TestTheGate(_GateCase):
kept = Path(media_thumbnail_rel(Path("kept.png"))).parts
self.assertIsNotNone(_thumbnail_source(self.root, kept))
async def test_the_preview_fallback_past_the_cap_is_gated_too(self):
# The gate's OVER branch looks the source up by name and serves it.
self.links()
gate = self.api._preview_gate()
with patch.object(gate, "run", AsyncMock(return_value=OVER)):
linked = media_thumbnail_rel(Path("linkdir/x.png"))
self.assertEqual(await self.serve(linked), 404)
kept = media_thumbnail_rel(Path("kept.png"))
self.assertEqual(await self.serve(kept), 200)
self.assertIsNone(
_quick_source(
self.root, Path(media_thumbnail_rel(Path("linkdir/x.png"))).parts
)
)
async def test_a_history_output_in_a_trash_folder_does_not_open(self):
self.links()
target = self.root / ".Trash-1000" / "files" / "x.png"
with self.assertRaises(CurrentLocationUnavailable) as caught:
_open_current_target(Path(os.path.realpath(target)), self.root, None)
self.assertEqual(caught.exception.reason, "missing")
async def test_a_source_named_like_a_trash_folder_still_reads(self):
source = self.root / ".Trash-77"
source.mkdir()