Merge pull request 'fix(images): percent-encode original-image URLs ('#' in paths 404'd)' (#100) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m15s
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m15s
This commit was merged in pull request #100.
This commit is contained in:
@@ -16,6 +16,7 @@ translates that to HTTP 400.
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from sqlalchemy import Select, and_, distinct, exists, func, or_, select
|
from sqlalchemy import Select, and_, distinct, exists, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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}"
|
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 <path:subpath> 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:
|
def _require_single_filter(tag_ids, post_id, artist_id) -> None:
|
||||||
"""post_id is the post-detail view — it can't combine with the
|
"""post_id is the post-detail view — it can't combine with the
|
||||||
composable filters. tag_ids + artist_id (+ media_type) compose freely
|
composable filters. tag_ids + artist_id (+ media_type) compose freely
|
||||||
@@ -589,7 +605,7 @@ class GalleryService:
|
|||||||
"created_at": record.created_at.isoformat(),
|
"created_at": record.created_at.isoformat(),
|
||||||
"posted_at": posted_at.isoformat() if posted_at else None,
|
"posted_at": posted_at.isoformat() if posted_at else None,
|
||||||
"thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
"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": (
|
"artist": (
|
||||||
{"id": artist.id, "name": artist.name, "slug": artist.slug}
|
{"id": artist.id, "name": artist.name, "slug": artist.slug}
|
||||||
if artist is not None else None
|
if artist is not None else None
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from ..models import Artist, ImageRecord, Post, Tag, TagKind
|
from ..models import Artist, ImageRecord, Post, Tag, TagKind
|
||||||
from ..models.series_chapter import SeriesChapter
|
from ..models.series_chapter import SeriesChapter
|
||||||
from ..models.series_page import SeriesPage
|
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
|
from .page_number_parser import parse_page_range
|
||||||
|
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ class SeriesService:
|
|||||||
"thumbnail_url": thumbnail_url(
|
"thumbnail_url": thumbnail_url(
|
||||||
r.thumbnail_path, r.sha256, r.mime
|
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": (
|
"source_post": (
|
||||||
{"id": r.primary_post_id, "title": r.post_title}
|
{"id": r.primary_post_id, "title": r.post_title}
|
||||||
if r.primary_post_id is not None
|
if r.primary_post_id is not None
|
||||||
@@ -282,7 +282,7 @@ class SeriesService:
|
|||||||
"thumbnail_url": thumbnail_url(
|
"thumbnail_url": thumbnail_url(
|
||||||
r.thumbnail_path, r.sha256, r.mime
|
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
|
# Group start page = the post's parsed starting page (stored on the
|
||||||
|
|||||||
@@ -8,11 +8,22 @@ from backend.app.services.gallery_service import (
|
|||||||
GalleryService,
|
GalleryService,
|
||||||
decode_cursor,
|
decode_cursor,
|
||||||
encode_cursor,
|
encode_cursor,
|
||||||
|
image_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
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():
|
def _now():
|
||||||
return datetime.now(UTC)
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user