Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions docs/pages/api-reference/types.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ Types

.. autodata:: imgproxykit.types.ResizeType

.. autodata:: imgproxykit.types.SourceEncoding

.. autodata:: imgproxykit.types.WatermarkPosition
20 changes: 17 additions & 3 deletions imgproxykit/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 = ':',
Expand All @@ -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
<https://docs.imgproxy.net/configuration/options#url-signature>`__.
salt: Nonempty hex-encoded signing salt matching the server.
Supply together with ``key``; ``None`` selects unsigned mode.
See `IMGPROXY_SALT
<https://docs.imgproxy.net/configuration/options#url-signature>`__.
signature_size: Positive digest byte count to encode. Must match
the server's `IMGPROXY_SIGNATURE_SIZE
<https://docs.imgproxy.net/latest/configuration/options#url-signature>`__.
Expand All @@ -102,6 +110,11 @@ def __init__( # noqa: WPS211
`IMGPROXY_ARGUMENTS_SEPARATOR
<https://docs.imgproxy.net/latest/configuration/options#miscellaneous>`__.
Defaults to ``':'``.
source_encoding: Source URL representation: ``'base64'`` or
``'plain'`` with full percent-encoding. See
`imgproxy source URLs
<https://docs.imgproxy.net/usage/processing#source-url>`__.
Defaults to ``'base64'``.
use_short_options: Use short processing option aliases when
available, falling back to full names. Defaults to ``False``.

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions imgproxykit/integrations/django/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down
18 changes: 18 additions & 0 deletions imgproxykit/internal/encoding.py
Original file line number Diff line number Diff line change
@@ -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
<https://docs.imgproxy.net/usage/processing#plain>`_.
"""
return quote(value, safe='')


def encode_base64(value: str | bytes) -> str:
Expand Down
58 changes: 49 additions & 9 deletions imgproxykit/serialization.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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:
"""
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
Expand All @@ -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
Expand All @@ -43,10 +46,17 @@ def __init__(
<https://docs.imgproxy.net/latest/configuration/options#miscellaneous>`__.
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
<https://docs.imgproxy.net/usage/processing#source-url>`_.

"""
self._argument_separator = argument_separator
self._use_short_options = use_short_options
self._source_serializer = self._resolve_source_serializer(
source_encoding,
)

def __call__(
self,
Expand All @@ -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.

Expand All @@ -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'),
Expand All @@ -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
<https://docs.imgproxy.net/usage/processing#plain>`_.
"""
return f'plain/{encode_plain(source)}'

def _serialize_segment(
self,
Expand Down
13 changes: 13 additions & 0 deletions imgproxykit/types.py
Original file line number Diff line number Diff line change
@@ -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
<https://docs.imgproxy.net/usage/processing#source-url>`_.
"""

type Argument = str | int | float | bool | None
"""
Scalar value accepted in an option's positional arguments.
Expand Down
6 changes: 5 additions & 1 deletion tests/fixtures/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
19 changes: 19 additions & 0 deletions tests/test_unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
[
Expand Down
7 changes: 3 additions & 4 deletions tests/test_unit/test_integrations/test_django/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)


Expand Down
Loading
Loading