fix: serve every gallery video format, validate settings before applying, dedupe output dirs

Review findings on #153 (Codex):
- the image serve route allowed only three video types while the gallery
  lists seven, so .avi/.mkv/.m4v/.wmv cards opened to a 403; the serve
  allow-list now covers every gallery video format
- a settings payload with one invalid field still applied the valid ones
  before returning 400; the whole payload is validated first and nothing
  changes on failure. webui_display_mode is validated too

Third Windows CI run:
- the same output directory spelled twice (different case or a link) was
  listed twice in diagnostics and served roots; both dedupe on the
  resolved, case-folded path
- the root-less subfolder test expects the "/" form

Code-quality bot: index definitions are (name, target) pairs instead of
implicitly concatenated strings; redundant local asyncio imports and
two trivial lambdas in test stubs removed.
This commit is contained in:
Vito Sansevero
2026-10-08 18:39:46 -07:00
parent 8462ecbb03
commit 4c6dbc92fe
9 changed files with 146 additions and 70 deletions
+9 -3
View File
@@ -639,12 +639,18 @@ class PromptManagerAPI(
configured = list(GalleryConfig.MONITORING_DIRECTORIES)
output_dirs = []
seen = set()
for d in configured:
ok, reason = GalleryConfig.validate_gallery_root(d)
if ok:
output_dirs.append(Path(os.path.realpath(d)))
else:
if not ok:
self.logger.warning(f"Ignoring configured gallery root: {reason}")
continue
real = os.path.realpath(d)
key = os.path.normcase(real) # one entry per directory, however spelled
if key in seen:
continue
seen.add(key)
output_dirs.append(Path(real))
if output_dirs:
return output_dirs
+49 -37
View File
@@ -242,16 +242,23 @@ def _diagnose_output_dirs(configured_dirs):
pass
output_dirs = []
seen = set()
def add(path):
# One entry per directory: resolve links and fold case (Windows)
real = os.path.realpath(path)
key = os.path.normcase(real)
if os.path.exists(real) and key not in seen:
seen.add(key)
output_dirs.append(real)
for candidate in candidates:
abs_path = os.path.abspath(candidate) if candidate else None
if abs_path and os.path.exists(abs_path) and abs_path not in output_dirs:
output_dirs.append(abs_path)
if candidate:
add(candidate)
if not output_dirs:
for rel in ("output", "../output", "../../output"):
abs_path = os.path.abspath(rel)
if os.path.exists(abs_path) and abs_path not in output_dirs:
output_dirs.append(abs_path)
add(rel)
return {
"status": "ok" if output_dirs else "warning",
@@ -742,7 +749,7 @@ class AdminRoutesMixin:
return list(GalleryConfig.MONITORING_DIRECTORIES)
async def save_settings(self, request):
"""Save settings."""
"""Save settings: the whole payload is validated before anything changes."""
try:
from ..config import PromptManagerConfig
@@ -750,41 +757,19 @@ class AdminRoutesMixin:
if error_response is not None:
return error_response
if "result_timeout" in data:
try:
PromptManagerConfig.RESULT_TIMEOUT = validate_result_timeout(
data["result_timeout"]
)
except ValueError as ve:
return web.json_response(
{"success": False, "error": str(ve)}, status=400
)
if "webui_display_mode" in data:
PromptManagerConfig.WEBUI_DISPLAY_MODE = data["webui_display_mode"]
if "infinite_scroll" in data:
if not isinstance(data["infinite_scroll"], bool):
return web.json_response(
{
"success": False,
"error": "infinite_scroll must be true or false",
},
status=400,
)
PromptManagerConfig.INFINITE_SCROLL = data["infinite_scroll"]
if "worker_threads" in data:
try:
PromptManagerConfig.WORKER_THREADS = validate_worker_threads(
data["worker_threads"], PromptManagerConfig.max_worker_threads()
)
except ValueError as ve:
return web.json_response(
{"success": False, "error": str(ve)}, status=400
)
try:
updates = self._validate_settings(data)
except ValueError as ve:
return web.json_response(
{"success": False, "error": str(ve)}, status=400
)
new_roots, error = self._parse_gallery_roots(data)
if error:
return web.json_response({"success": False, "error": error}, status=400)
for name, value in updates.items():
setattr(PromptManagerConfig, name, value)
restart_required = False
if new_roots is not None:
restart_required = self._apply_gallery_roots(new_roots)
@@ -805,6 +790,33 @@ class AdminRoutesMixin:
status=500,
)
@staticmethod
def _validate_settings(data):
"""Scalar settings of a payload as ``{CONFIG_ATTR: value}``.
Raises ValueError with a user-facing message on the first bad value;
nothing is applied until every value passed.
"""
from ..config import PromptManagerConfig
updates = {}
if "result_timeout" in data:
updates["RESULT_TIMEOUT"] = validate_result_timeout(data["result_timeout"])
if "webui_display_mode" in data:
mode = data["webui_display_mode"]
if mode not in ("popup", "newtab"):
raise ValueError("webui_display_mode must be 'popup' or 'newtab'")
updates["WEBUI_DISPLAY_MODE"] = mode
if "infinite_scroll" in data:
if not isinstance(data["infinite_scroll"], bool):
raise ValueError("infinite_scroll must be true or false")
updates["INFINITE_SCROLL"] = data["infinite_scroll"]
if "worker_threads" in data:
updates["WORKER_THREADS"] = validate_worker_threads(
data["worker_threads"], PromptManagerConfig.max_worker_threads()
)
return updates
def _parse_gallery_roots(self, data):
"""Extract and validate gallery roots from a settings payload.
+6 -3
View File
@@ -79,7 +79,7 @@ def _media_access_error(path, allowed_dirs):
Fails closed: an empty *allowed_dirs* denies everything.
"""
if Path(path).suffix.lower() not in IMAGE_EXTENSIONS:
if Path(path).suffix.lower() not in SERVABLE_EXTENSIONS:
return _forbidden("Only media files can be served")
if not allowed_dirs:
return _forbidden("No allowed image directories configured")
@@ -92,6 +92,9 @@ def _media_access_error(path, allowed_dirs):
GALLERY_IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif"})
VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".avi", ".mov", ".mkv", ".m4v", ".wmv"})
MEDIA_EXTENSIONS = GALLERY_IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
# What the serve routes hand out: every image type above plus every video type
# the gallery lists, so a card never points at a 403.
SERVABLE_EXTENSIONS = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
# Caps on request-triggered filesystem work.
MAX_SCAN_DEPTH = 12
@@ -707,7 +710,7 @@ class ImageRoutesMixin:
filepath = request.match_info["filepath"]
if _has_traversal(filepath):
return _forbidden()
if Path(filepath).suffix.lower() not in IMAGE_EXTENSIONS:
if Path(filepath).suffix.lower() not in SERVABLE_EXTENSIONS:
return _forbidden("Only media files can be served")
output_dirs = self._select_root(
@@ -1347,7 +1350,7 @@ class ImageRoutesMixin:
allowed_dirs = list(self._get_all_output_dirs())
resolved = self._resolve_client_media_path(image_path, allowed_dirs)
if resolved is None:
if Path(image_path).suffix.lower() not in IMAGE_EXTENSIONS:
if Path(image_path).suffix.lower() not in SERVABLE_EXTENSIONS:
return _forbidden("Only media files can be served")
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404