From 7c4b24c80d16c2a8956fe465b1595066e949f5aa Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 12 Jun 2026 00:36:44 -0400 Subject: [PATCH] fix(images): percent-encode original-image URLs ('#' in paths 404'd) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An image whose on-disk path contains '#' (post folders like 'BLUE#59') served its hash-named thumbnail fine but 404'd the original: the unencoded '#' in image_url was parsed by the browser as a URL fragment, so '#59/01_timelapse.jpg' never reached the /images route. Add a shared image_url(path) helper that percent-encodes the path (safe='/') and route the 3 raw builders (gallery detail + 2 in series) through it. Not a cleanup-tool deletion — the file is on disk; only the URL was wrong. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/services/gallery_service.py | 18 +++++++++++++++++- backend/app/services/series_service.py | 6 +++--- tests/test_gallery_service.py | 11 +++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index b8a0721..ac5a975 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -16,6 +16,7 @@ translates that to HTTP 400. from dataclasses import dataclass from datetime import datetime +from urllib.parse import quote from sqlalchemy import Select, and_, distinct, exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -120,6 +121,21 @@ def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str return f"/images/thumbs/{bucket}/{sha256_hex}{ext}" +def image_url(path: str) -> str: + """Return the URL to fetch the full-size original from /images. + + The on-disk `path` mirrors the source tree (artist/post folders), so it can + contain characters that are special in a URL — most importantly '#' (post + titles like 'BLUE#59'), but also spaces, '?', '%'. The serve_image route + URL-decodes its fine, but only if the browser sends the whole + path; an unencoded '#' is parsed as a fragment, so '#59/01_timelapse.jpg' + never reaches the server and the original 404s while the (hash-named) + thumbnail still loads. Percent-encode the path, keeping '/' as the segment + separator. Operator-flagged 2026-06-12.""" + rel = path.split("/images/", 1)[-1] + return f"/images/{quote(rel, safe='/')}" + + def _require_single_filter(tag_ids, post_id, artist_id) -> None: """post_id is the post-detail view — it can't combine with the composable filters. tag_ids + artist_id (+ media_type) compose freely @@ -589,7 +605,7 @@ class GalleryService: "created_at": record.created_at.isoformat(), "posted_at": posted_at.isoformat() if posted_at else None, "thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime), - "image_url": f"/images/{record.path.split('/images/', 1)[-1]}", + "image_url": image_url(record.path), "artist": ( {"id": artist.id, "name": artist.name, "slug": artist.slug} if artist is not None else None diff --git a/backend/app/services/series_service.py b/backend/app/services/series_service.py index 2bcbc32..21ec99a 100644 --- a/backend/app/services/series_service.py +++ b/backend/app/services/series_service.py @@ -19,7 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..models import Artist, ImageRecord, Post, Tag, TagKind from ..models.series_chapter import SeriesChapter from ..models.series_page import SeriesPage -from .gallery_service import thumbnail_url +from .gallery_service import image_url, thumbnail_url from .page_number_parser import parse_page_range @@ -194,7 +194,7 @@ class SeriesService: "thumbnail_url": thumbnail_url( r.thumbnail_path, r.sha256, r.mime ), - "image_url": f"/images/{r.path.split('/images/', 1)[-1]}", + "image_url": image_url(r.path), "source_post": ( {"id": r.primary_post_id, "title": r.post_title} if r.primary_post_id is not None @@ -282,7 +282,7 @@ class SeriesService: "thumbnail_url": thumbnail_url( r.thumbnail_path, r.sha256, r.mime ), - "image_url": f"/images/{r.path.split('/images/', 1)[-1]}", + "image_url": image_url(r.path), } ) # Group start page = the post's parsed starting page (stored on the diff --git a/tests/test_gallery_service.py b/tests/test_gallery_service.py index 13405d8..ddb9f61 100644 --- a/tests/test_gallery_service.py +++ b/tests/test_gallery_service.py @@ -8,11 +8,22 @@ from backend.app.services.gallery_service import ( GalleryService, decode_cursor, encode_cursor, + image_url, ) pytestmark = pytest.mark.integration +def test_image_url_percent_encodes_special_chars(): + # A '#' in a post folder ('BLUE#59') would otherwise be parsed as a URL + # fragment, dropping the rest of the path and 404'ing the original while the + # hash-named thumbnail still loads. Operator-flagged 2026-06-12. + url = image_url("/images/dismassd/patreon/2024-04-28_BLUE#59/01 a.jpg") + assert url == "/images/dismassd/patreon/2024-04-28_BLUE%2359/01%20a.jpg" + # '/' stays a separator; a plain path is unchanged. + assert image_url("/images/a/b/c.jpg") == "/images/a/b/c.jpg" + + def _now(): return datetime.now(UTC)