diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a800b..f5788cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ but with a deprecation period. ### Features +- Added `Client(source_encoding='plain')` to generate URLs with + percent-encoded sources and imgproxy's `/plain/` marker. Base64 remains + the default, #106 - Added `Gravity` factories to configure crop positioning without using protocol codes, #104 - Added `Transform.extend()` to create a new transformation with additional @@ -26,6 +29,9 @@ but with a deprecation period. ### Breaking changes +- `PathSerializer` now requires the `source_encoding` keyword-only argument. + Custom path serializer constructors must accept it. Pass `'base64'` to + retain the previous source URL representation, #106 - Renamed `arguments_separator` to `argument_separator`, and `unsigned_signature` to `unsigned_marker` in `Client`, its serializer and signer components, and Django client settings. Update keyword arguments, diff --git a/docs/pages/api-reference/types.rst b/docs/pages/api-reference/types.rst index 1c5c1b5..3ee6a0b 100644 --- a/docs/pages/api-reference/types.rst +++ b/docs/pages/api-reference/types.rst @@ -9,4 +9,6 @@ Types .. autodata:: imgproxykit.types.ResizeType +.. autodata:: imgproxykit.types.SourceEncoding + .. autodata:: imgproxykit.types.WatermarkPosition diff --git a/imgproxykit/client.py b/imgproxykit/client.py index 9688467..f2a3e8e 100644 --- a/imgproxykit/client.py +++ b/imgproxykit/client.py @@ -4,6 +4,7 @@ from imgproxykit.serialization import PathSerializer from imgproxykit.signing import UrlSigner from imgproxykit.transform import Transform +from imgproxykit.types import SourceEncoding class Client: @@ -43,6 +44,7 @@ def __init__( *, key: None = None, salt: None = None, + source_encoding: SourceEncoding = 'base64', unsigned_marker: str = 'unsafe', argument_separator: str = ':', use_short_options: bool = False, @@ -56,6 +58,7 @@ def __init__( *, key: str, salt: str, + source_encoding: SourceEncoding = 'base64', signature_size: int = 32, argument_separator: str = ':', use_short_options: bool = False, @@ -68,6 +71,7 @@ def __init__( # noqa: WPS211 *, key: str | None = None, salt: str | None = None, + source_encoding: SourceEncoding = 'base64', signature_size: int = 32, unsigned_marker: str = 'unsafe', argument_separator: str = ':', @@ -86,8 +90,12 @@ def __init__( # noqa: WPS211 stored as supplied, without validation. key: Nonempty hex-encoded signing key matching the server. Supply together with ``salt``; ``None`` selects unsigned mode. + See `IMGPROXY_KEY + `__. salt: Nonempty hex-encoded signing salt matching the server. Supply together with ``key``; ``None`` selects unsigned mode. + See `IMGPROXY_SALT + `__. signature_size: Positive digest byte count to encode. Must match the server's `IMGPROXY_SIGNATURE_SIZE `__. @@ -102,6 +110,11 @@ def __init__( # noqa: WPS211 `IMGPROXY_ARGUMENTS_SEPARATOR `__. Defaults to ``':'``. + source_encoding: Source URL representation: ``'base64'`` or + ``'plain'`` with full percent-encoding. See + `imgproxy source URLs + `__. + Defaults to ``'base64'``. use_short_options: Use short processing option aliases when available, falling back to full names. Defaults to ``False``. @@ -130,6 +143,7 @@ def __init__( # noqa: WPS211 self._path_serializer = self.path_serializer_cls( argument_separator=argument_separator, use_short_options=use_short_options, + source_encoding=source_encoding, ) self._url_signer = self.url_signer_cls( key=key, @@ -157,9 +171,9 @@ def url( Args: source: Complete source URI understood by the imgproxy server. - The default serializer encodes its UTF-8 representation as - URL-safe Base64 without padding. The URI is neither validated - nor resolved against ``base_url``. + The default serializer uses URL-safe Base64 without padding + or percent-encoding according to ``source_encoding``. The URI + is neither validated nor resolved against ``base_url``. transform: Reusable processing options and optional output format. Omit or pass ``None`` to add no processing options. diff --git a/imgproxykit/integrations/django/settings.py b/imgproxykit/integrations/django/settings.py index 055a5a0..363fbb9 100644 --- a/imgproxykit/integrations/django/settings.py +++ b/imgproxykit/integrations/django/settings.py @@ -7,6 +7,7 @@ from typing_extensions import TypedDict from imgproxykit.transform import Transform +from imgproxykit.types import SourceEncoding #: Django setting containing the imgproxy client configuration. IMGPROXYKIT_SETTINGS: Final = 'IMGPROXYKIT' @@ -24,6 +25,7 @@ class ClientSettings(enum.StrEnum): unsigned_marker = 'unsigned_marker' argument_separator = 'argument_separator' use_short_options = 'use_short_options' + source_encoding = 'source_encoding' @final @@ -54,6 +56,8 @@ class ClientSettingsDict(TypedDict, total=False, closed=True): argument_separator: str #: Use short processing option aliases when available. use_short_options: bool + #: Source URL representation using Base64 or percent-encoded plain mode. + source_encoding: SourceEncoding class SettingsDict(TypedDict, total=False, closed=True): diff --git a/imgproxykit/internal/encoding.py b/imgproxykit/internal/encoding.py index e25d653..e75480c 100644 --- a/imgproxykit/internal/encoding.py +++ b/imgproxykit/internal/encoding.py @@ -1,4 +1,22 @@ import base64 +from urllib.parse import quote + + +def encode_plain(value: str) -> str: + """ + Percent-encode a source URL for imgproxy's plain source URL mode. + + imgproxy permits full percent-encoding or escaping only ``%``, ``?``, + and ``@``. Use full UTF-8 percent-encoding to keep source URL characters + from affecting the outer URL. Existing escapes are encoded too: + ``%20`` becomes ``%2520``, preserving it after outer URL decoding. + + Return only the encoded source; the serializer adds ``/plain/``. + + See `imgproxy plain source URLs + `_. + """ + return quote(value, safe='') def encode_base64(value: str | bytes) -> str: diff --git a/imgproxykit/serialization.py b/imgproxykit/serialization.py index f9cc394..3ca9dcd 100644 --- a/imgproxykit/serialization.py +++ b/imgproxykit/serialization.py @@ -1,6 +1,8 @@ -from imgproxykit.internal.encoding import encode_base64 +from collections.abc import Callable + +from imgproxykit.internal.encoding import encode_base64, encode_plain from imgproxykit.transform import Transform -from imgproxykit.types import Argument +from imgproxykit.types import Argument, SourceEncoding class PathSerializer: @@ -8,8 +10,8 @@ class PathSerializer: Serialize processing options into an unsigned imgproxy path. Used by :class:`~imgproxykit.Client` before signing the resulting path. - Sources are encoded as URL-safe Base64 without padding. Processing - options are included before the source in the supplied order. + Sources use URL-safe Base64 without padding or full percent-encoding. + Processing options precede the source in the supplied order. Pass processing options through :class:`~imgproxykit.Transform` as option instances or scalar keyword arguments. Preset options reference @@ -23,18 +25,19 @@ class PathSerializer: __slots__ = ( '_argument_separator', + '_source_serializer', '_use_short_options', ) - # TODO: Support the plain option for unencoded source URLs. def __init__( self, *, argument_separator: str, use_short_options: bool, + source_encoding: SourceEncoding, ) -> None: """ - Configure processing option serialization. + Configure processing options and select the source URL serializer. Args: argument_separator: Separator between processing option names @@ -43,10 +46,17 @@ def __init__( `__. use_short_options: Use short aliases when available, otherwise full names. + source_encoding: Source URL representation: ``'base64'`` without + padding or ``'plain'`` with full UTF-8 percent-encoding. + See `imgproxy source URLs + `_. """ self._argument_separator = argument_separator self._use_short_options = use_short_options + self._source_serializer = self._resolve_source_serializer( + source_encoding, + ) def __call__( self, @@ -63,8 +73,7 @@ def __call__( Args: source: Complete source URI understood by the imgproxy server. - Its UTF-8 representation is encoded as URL-safe Base64 - without padding. The URI is neither validated nor resolved. + Encoded according to ``source_encoding``. transform: Reusable processing options and optional output format. Omit or pass ``None`` to add no processing options. @@ -81,6 +90,7 @@ def __call__( >>> serializer = PathSerializer( ... argument_separator=':', ... use_short_options=False, + ... source_encoding='base64', ... ) >>> thumbnail = Transform( ... Resize(width=96, height=96, mode='fill'), @@ -101,7 +111,37 @@ def __call__( ) for option in transform.options ) - return '/'.join(('', *options, encode_base64(source))) + return '/'.join(('', *options, self._source_serializer(source))) + + def _resolve_source_serializer( + self, + source_encoding: SourceEncoding, + ) -> Callable[[str], str]: + """ + Select source path serialization once during initialization. + + The callable returns the encoded source with any required path marker, + ready to append after processing options and include in the signature. + """ + return ( + encode_base64 + if source_encoding == 'base64' + else self._serialize_plain_source + ) + + @classmethod + def _serialize_plain_source(cls, source: str) -> str: + """ + Prefix the percent-encoded source with imgproxy's plain path marker. + + Encode only the source so that the separating slash remains part of + the imgproxy path. Output formats remain processing option segments. + + The ``/plain/`` segment identifies a percent-encoded source URL. + See `imgproxy plain source URLs + `_. + """ + return f'plain/{encode_plain(source)}' def _serialize_segment( self, diff --git a/imgproxykit/types.py b/imgproxykit/types.py index 9b40d0f..6e6bb28 100644 --- a/imgproxykit/types.py +++ b/imgproxykit/types.py @@ -1,5 +1,18 @@ from typing import Literal +type SourceEncoding = Literal['base64', 'plain'] +""" +Source URL representations accepted by :class:`~imgproxykit.Client`. + +Values for the ``source_encoding`` keyword of :class:`~imgproxykit.Client` +and :class:`~imgproxykit.serialization.PathSerializer`. Use ``base64`` for +URL-safe Base64 without padding or ``plain`` for full UTF-8 percent-encoding. +The default is ``base64``. + +See `imgproxy source URLs +`_. +""" + type Argument = str | int | float | bool | None """ Scalar value accepted in an option's positional arguments. diff --git a/tests/fixtures/serialization.py b/tests/fixtures/serialization.py index 518020a..bb895c4 100644 --- a/tests/fixtures/serialization.py +++ b/tests/fixtures/serialization.py @@ -6,4 +6,8 @@ @pytest.fixture def path_serializer() -> PathSerializer: """Provide a serializer with the standard argument separator.""" - return PathSerializer(argument_separator=':', use_short_options=False) + return PathSerializer( + argument_separator=':', + use_short_options=False, + source_encoding='base64', + ) diff --git a/tests/test_unit/test_client.py b/tests/test_unit/test_client.py index aeb89f5..8614355 100644 --- a/tests/test_unit/test_client.py +++ b/tests/test_unit/test_client.py @@ -10,6 +10,25 @@ _SOURCE: Final = 'https://example.com/photo.jpg' +def test_signed_plain_url(credentials: Credentials) -> None: + """Sign the escaped source, plain marker, and processing options.""" + client = Client( + 'https://example.com/images', + source_encoding='plain', + **credentials, + ) + + assert client.url( + 'http://example.com/images/curiosity.jpg?size=300', + transform=Transform(format='webp'), + ) == ( + 'https://example.com/images/' + '0s1_Nbh2MUZKyCQSiyR84xwesu69AYqKEYO_ux_Qgc8/' + 'format:webp/plain/' + 'http%3A%2F%2Fexample.com%2Fimages%2Fcuriosity.jpg%3Fsize%3D300' + ) + + @pytest.mark.parametrize( 'base_url', [ diff --git a/tests/test_unit/test_integrations/test_django/test_client.py b/tests/test_unit/test_integrations/test_django/test_client.py index a008973..0c9d2bf 100644 --- a/tests/test_unit/test_integrations/test_django/test_client.py +++ b/tests/test_unit/test_integrations/test_django/test_client.py @@ -17,13 +17,12 @@ def test_client_configuration(settings: LazySettings) -> None: 'base_url': 'https://images.example.com', 'unsigned_marker': 'trusted', 'use_short_options': True, + 'source_encoding': 'plain', }, } - assert ( - get_client() - .url('source', transform=Transform(width=300)) - .startswith('https://images.example.com/trusted/w:300/') + assert get_client().url('source', transform=Transform(width=300)) == ( + 'https://images.example.com/trusted/w:300/plain/source' ) diff --git a/tests/test_unit/test_internal.py b/tests/test_unit/test_internal.py new file mode 100644 index 0000000..c5d37fc --- /dev/null +++ b/tests/test_unit/test_internal.py @@ -0,0 +1,64 @@ +import pytest + +from imgproxykit.internal.encoding import encode_base64, encode_plain + + +@pytest.mark.parametrize( + ('value', 'expected'), + [ + # Empty input: + ('', ''), + (b'', ''), + # Two, one, and no padding characters: + ('f', 'Zg'), + ('fo', 'Zm8'), + ('foo', 'Zm9v'), + # Text is encoded as UTF-8: + ('café', 'Y2Fmw6k'), + # Binary input uses the URL-safe alphabet: + (b'\xfb\xef', '--8'), + (b'\xff\xff', '__8'), + ], +) +def test_encode_base64(*, value: str | bytes, expected: str) -> None: + """Ensure that text and bytes use URL-safe Base64 without padding.""" + assert encode_base64(value) == expected + + +@pytest.mark.parametrize( + ('value', 'expected'), + [ + # Empty input: + ('', ''), + # Unreserved characters remain unchanged: + ('thumbnail-640_480~2.jpg', 'thumbnail-640_480~2.jpg'), + # Source from https://docs.imgproxy.net/usage/processing#plain, + # using the documented full percent-encoding alternative: + ( + 'http://example.com/images/curiosity.jpg', + 'http%3A%2F%2Fexample.com%2Fimages%2Fcuriosity.jpg', + ), + # The docs require escaping %, ?, and @; newly generated escapes + # must not be escaped again: + ('%?@', '%25%3F%40'), + # Query parameters belong to the source, not the imgproxy URL: + ( + 'https://example.com/photo.jpg?width=640&height=480', + ( + 'https%3A%2F%2Fexample.com%2Fphoto.jpg' + '%3Fwidth%3D640%26height%3D480' + ), + ), + # Fragments must not become fragments of the outer URL: + ('sprite.svg#logo', 'sprite.svg%23logo'), + # Existing percent escapes survive decoding of the outer URL: + ('summer%20holiday.jpg', 'summer%2520holiday.jpg'), + # Spaces use percent encoding, while literal plus signs stay distinct: + ('cats + dogs.jpg', 'cats%20%2B%20dogs.jpg'), + # Text is encoded as UTF-8: + ('café.jpg', 'caf%C3%A9.jpg'), + ], +) +def test_encode_plain(*, value: str, expected: str) -> None: + """Escape sources fully, preserving existing escapes and UTF-8 text.""" + assert encode_plain(value) == expected diff --git a/tests/test_unit/test_internal/test_encoding.py b/tests/test_unit/test_internal/test_encoding.py deleted file mode 100644 index 95a8745..0000000 --- a/tests/test_unit/test_internal/test_encoding.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -from imgproxykit.internal.encoding import encode_base64 - - -@pytest.mark.parametrize( - ('value', 'expected'), - [ - # Empty input: - ('', ''), - (b'', ''), - # Two, one, and no padding characters: - ('f', 'Zg'), - ('fo', 'Zm8'), - ('foo', 'Zm9v'), - # Text is encoded as UTF-8: - ('café', 'Y2Fmw6k'), - # Binary input uses the URL-safe alphabet: - (b'\xfb\xef', '--8'), - (b'\xff\xff', '__8'), - ], -) -def test_encode_base64(*, value: str | bytes, expected: str) -> None: - """Ensure that text and bytes use URL-safe Base64 without padding.""" - assert encode_base64(value) == expected diff --git a/tests/test_unit/test_serialization.py b/tests/test_unit/test_serialization.py index a84eaad..871dffc 100644 --- a/tests/test_unit/test_serialization.py +++ b/tests/test_unit/test_serialization.py @@ -127,6 +127,7 @@ def test_custom_argument_separator() -> None: serializer = PathSerializer( argument_separator=',', use_short_options=False, + source_encoding='base64', ) transform = Transform(Resize(width=300), format='webp') assert serializer(_SOURCE, transform=transform) == ( @@ -136,9 +137,29 @@ def test_custom_argument_separator() -> None: def test_short_names_with_custom_separator() -> None: """Combine short aliases, omitted arguments and a custom separator.""" - serializer = PathSerializer(argument_separator=',', use_short_options=True) + serializer = PathSerializer( + argument_separator=',', + use_short_options=True, + source_encoding='base64', + ) transform = Transform(Resize(width=300), format='webp', raw=False) assert serializer(_SOURCE, transform=transform) == ( '/rs,,300/f,webp/raw,0/aHR0cHM6Ly9leGFtcGxlLmNvbS9waG90by5qcGc' ) + + +def test_plain_source_path() -> None: + """Place the escaped source after options and the plain marker.""" + serializer = PathSerializer( + argument_separator=',', + use_short_options=True, + source_encoding='plain', + ) + assert serializer( + 'http://example.com/images/curiosity.jpg?size=300', + transform=Transform(width=300, format='webp'), + ) == ( + '/w,300/f,webp/plain/' + 'http%3A%2F%2Fexample.com%2Fimages%2Fcuriosity.jpg%3Fsize%3D300' + )