diff --git a/HISTORY.md b/HISTORY.md index e7e86a19..5a381895 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -10,15 +10,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Summary of this update: -1. Added support for the `save_in_group` parameter in [multipage conversion](https://uploadcare.com/docs/transformations/document-conversion/#multipage-conversion); -2. Implemented the [AWS Rekognition Moderation](https://uploadcare.com/docs/unsafe-content/) addon API; -3. Added `signed_uploads` setting for Django projects; -4. Various bug fixes. +1. Added support for the `save_in_group` parameter in [multipage conversion](https://uploadcare.com/docs/transformations/document-conversion/#multipage-conversion) (#258); +2. Implemented the [AWS Rekognition Moderation](https://uploadcare.com/docs/unsafe-content/) addon API (#260); +3. Added `signed_uploads` setting for Django projects (#262); +4. Secure URL generation improvements (#263 & #264); +5. Various bug fixes. There are no breaking changes in this release. ### Added +- For `Uploadcare`: + - Added a type for the `event` parameter of the `create_webhook` and `update_webhook` methods. + - Added the `generate_upload_signature` method. This shortcut could be useful for signed uploads from your website's frontend, where the signature needs to be passed outside of your website's Python part (e.g., for the uploading widget). + - Added `generate_secure_url_token` method. Similar to `generate_secure_url`, it returns only a token, not the full URL. + - Added an optional `wildcard` parameter to the `generate_secure_url` method. + - For `File`: - Added the `save_in_group` parameter to the `convert` and `convert_document` methods. It defaults to `False`. When set to `True`, multi-page documents will additionally be saved as a file group. - Added the `get_converted_document_group` method. It returns a `FileGroup` instance for converted multi-page documents. @@ -26,16 +33,22 @@ There are no breaking changes in this release. - For `DocumentConvertAPI`: - Added the `retrieve` method, which corresponds to the [`GET /convert/document/:uuid/`](https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Conversion/operation/documentConvertInfo) API endpoint. -- For `Uploadcare`: - - Added type for the `event` parameter of the `create_webhook` and `update_webhook` methods. - - Added the `generate_upload_signature` method. This shortcut could be useful for signed uploads from your website's frontend, where the signature needs to be passed outside of your website's Python part (e.g., for the uploading widget). - - For `AddonsAPI` / `AddonLabels`: - Added support for the [Unsafe content detection](https://uploadcare.com/docs/unsafe-content/) addon (`AddonLabels.AWS_MODERATION_LABELS`). - For Django integration: - Added the `signed_uploads` setting for Django projects. When enabled, this setting exposes the generated signature to the uploading widget. +- For `AkamaiSecureUrlBuilderWithAclToken`: + - Added `get_token` method. + - Added an optional `wildcard` parameter to the `build` method. + +- Introduced `AkamaiSecureUrlBuilderWithUrlToken` class. + +### Changed + +- `AkamaiSecureUrlBuilder` has been renamed to `AkamaiSecureUrlBuilderWithAclToken`. It is still available under the old name and works as before, but it will issue a deprecation warning when used. + ### Fixed - For `AddonsAPI` / `AddonExecutionParams`: diff --git a/docs/core_api.rst b/docs/core_api.rst index d23b2ff3..af203bcc 100644 --- a/docs/core_api.rst +++ b/docs/core_api.rst @@ -399,12 +399,15 @@ Secure delivery You can use your own custom domain and CDN provider for deliver files with authenticated URLs (see `original documentation`_). -Generate secure for file:: +Generate secure URL for file:: from pyuploadcare import Uploadcare - from pyuploadcare.secure_url import AkamaiSecureUrlBuilder + from pyuploadcare.secure_url import AkamaiSecureUrlBuilderWithAclToken - secure_url_bulder = AkamaiSecureUrlBuilder("your cdn>", "") + secure_url_bulder = AkamaiSecureUrlBuilderWithAclToken( + "", + "" + ) uploadcare = Uploadcare( public_key='', @@ -414,12 +417,44 @@ Generate secure for file:: secure_url = uploadcare.generate_secure_url('52da3bfc-7cd8-4861-8b05-126fef7a6994') -Generate secure for file with transformations:: +Generate just the token:: + + token = uploadcare.get_secure_url_token('52da3bfc-7cd8-4861-8b05-126fef7a6994') + +Generate secure URL for file with transformations:: secure_url = uploadcare.generate_secure_url( '52da3bfc-7cd8-4861-8b05-126fef7a6994/-/resize/640x/other/transformations/' ) +Generate secure URL for file, with the same signature valid for its transformations:: + + secure_url = uploadcare.generate_secure_url( + '52da3bfc-7cd8-4861-8b05-126fef7a6994', + wildcard=True + ) + +Generate secure URL for file by its URL (please notice the usage of a different builder class):: + + from pyuploadcare import Uploadcare + from pyuploadcare.secure_url import AkamaiSecureUrlBuilderWithUrlToken + + secure_url_bulder = AkamaiSecureUrlBuilderWithUrlToken( + "", + "" + ) + + uploadcare = Uploadcare( + public_key='', + secret_key='', + secure_url_builder=secure_url_bulder, + ) + + secure_url = uploadcare.generate_secure_url( + 'https://cdn.yourdomain.com/52da3bfc-7cd8-4861-8b05-126fef7a6994/' + ) + + Useful links ------------ diff --git a/pyuploadcare/client.py b/pyuploadcare/client.py index 119ce427..4ff57447 100644 --- a/pyuploadcare/client.py +++ b/pyuploadcare/client.py @@ -825,7 +825,9 @@ def generate_upload_signature(self) -> Tuple[int, str]: ) return expire, signature - def generate_secure_url(self, uuid: Union[str, UUID]) -> str: + def generate_secure_url( + self, uuid: Union[str, UUID], wildcard: bool = False + ) -> str: """Generate authenticated URL.""" if isinstance(uuid, UUID): uuid = str(uuid) @@ -833,4 +835,16 @@ def generate_secure_url(self, uuid: Union[str, UUID]) -> str: if not self.secure_url_builder: raise ValueError("secure_url_builder must be set") - return self.secure_url_builder.build(uuid) + return self.secure_url_builder.build(uuid, wildcard=wildcard) + + def generate_secure_url_token( + self, uuid: Union[str, UUID], wildcard: bool = False + ) -> str: + """Generate token for authenticated URL.""" + if isinstance(uuid, UUID): + uuid = str(uuid) + + if not self.secure_url_builder: + raise ValueError("secure_url_builder must be set") + + return self.secure_url_builder.get_token(uuid, wildcard=wildcard) diff --git a/pyuploadcare/secure_url.py b/pyuploadcare/secure_url.py index 035017f0..270f21af 100644 --- a/pyuploadcare/secure_url.py +++ b/pyuploadcare/secure_url.py @@ -2,24 +2,30 @@ import hashlib import hmac import time +import warnings from abc import ABC, abstractmethod from typing import Optional class BaseSecureUrlBuilder(ABC): @abstractmethod - def build(self, uuid: str) -> str: + def build(self, uuid: str, wildcard: bool = False) -> str: raise NotImplementedError + def get_token(self, uuid: str, wildcard: bool = False) -> str: + raise NotImplementedError( + f"{self.__class__} doesn't provide get_token()" + ) + -class AkamaiSecureUrlBuilder(BaseSecureUrlBuilder): +class BaseAkamaiSecureUrlBuilder(BaseSecureUrlBuilder): """Akamai secure url builder. See https://uploadcare.com/docs/security/secure_delivery/ for more details. """ - template = "https://{cdn}/{uuid}/?token={token}" + template = "{base}?token={token}" field_delimeter = "~" def __init__( @@ -34,63 +40,124 @@ def __init__( self.window = window self.hash_algo = hash_algo - def build(self, uuid: str) -> str: - uuid = uuid.lstrip("/").rstrip("/") + def build(self, uuid: str, wildcard: bool = False) -> str: + uuid_or_url = self._format_uuid_or_url(uuid) + token = self.get_token(uuid_or_url, wildcard=wildcard) + secure_url = self._build_url(uuid_or_url, token) + return secure_url + def get_token(self, uuid: str, wildcard: bool = False) -> str: + uuid_or_url = self._format_uuid_or_url(uuid) expire = self._build_expire_time() + acl = self._format_acl(uuid_or_url, wildcard=wildcard) + signature = self._build_signature(uuid_or_url, expire, acl) + token = self._build_token(expire, acl, signature) + return token - acl = self._format_acl(uuid) - - signature = self._build_signature(expire, acl) - - secure_url = self._build_url(uuid, expire, acl, signature) - return secure_url + def _build_expire_time(self) -> int: + return int(time.time()) + self.window - def _build_url( - self, - uuid: str, - expire: int, - acl: str, - signature: str, + def _build_signature( + self, uuid_or_url: str, expire: int, acl: Optional[str] ) -> str: - req_parameters = [ + + hash_source = [ f"exp={expire}", - f"acl={acl}", - f"hmac={signature}", + f"acl={acl}" if acl else f"url={uuid_or_url}", ] - token = self.field_delimeter.join(req_parameters) + signature = hmac.new( + binascii.a2b_hex(self.secret_key.encode()), + self.field_delimeter.join(hash_source).encode(), + self.hash_algo, + ).hexdigest() - return self.template.format( - cdn=self.cdn_url, - uuid=uuid, - token=token, - ) + return signature def _build_token(self, expire: int, acl: Optional[str], signature: str): + token_parts = [ f"exp={expire}", - f"acl={acl}", + f"acl={acl}" if acl else None, f"hmac={signature}", ] - return self.field_delimeter.join(token_parts) - def _format_acl(self, uuid: str) -> str: - return f"/{uuid}/" + return self.field_delimeter.join( + part for part in token_parts if part is not None + ) - def _build_expire_time(self) -> int: - return int(time.time()) + self.window + @abstractmethod + def _build_base_url(self, uuid_or_url: str): + raise NotImplementedError - def _build_signature(self, expire: int, acl: str) -> str: - hash_source = [ - f"exp={expire}", - f"acl={acl}", - ] + def _build_url( + self, + uuid_or_url: str, + token: str, + ) -> str: + base_url = self._build_base_url(uuid_or_url) + return self.template.format( + base=base_url, + token=token, + ) - signature = hmac.new( - binascii.a2b_hex(self.secret_key.encode()), - self.field_delimeter.join(hash_source).encode(), - self.hash_algo, - ).hexdigest() + @abstractmethod + def _format_acl(self, uuid_or_url: str, wildcard: bool) -> Optional[str]: + raise NotImplementedError - return signature + @abstractmethod + def _format_uuid_or_url(self, uuid_or_url: str) -> str: + raise NotImplementedError + + +class AkamaiSecureUrlBuilderWithAclToken(BaseAkamaiSecureUrlBuilder): + base_template = "https://{cdn}/{uuid}/" + + def _build_base_url(self, uuid_or_url: str): + return self.base_template.format(cdn=self.cdn_url, uuid=uuid_or_url) + + def _format_acl(self, uuid_or_url: str, wildcard: bool) -> str: + if wildcard: + return f"/{uuid_or_url}/*" + return f"/{uuid_or_url}/" + + def _format_uuid_or_url(self, uuid_or_url: str) -> str: + return uuid_or_url.lstrip("/").rstrip("/") + + +class AkamaiSecureUrlBuilderWithUrlToken(BaseAkamaiSecureUrlBuilder): + def _build_base_url(self, uuid_or_url: str): + return uuid_or_url + + def _format_acl(self, uuid_or_url: str, wildcard: bool) -> None: + if wildcard: + raise ValueError( + "Wildcards are not supported in AkamaiSecureUrlBuilderWithUrlToken." + ) + return None + + def _format_uuid_or_url(self, uuid_or_url: str) -> str: + if "://" not in uuid_or_url: + raise ValueError(f"{uuid_or_url} doesn't look like a URL") + return uuid_or_url + + +class AkamaiSecureUrlBuilder(AkamaiSecureUrlBuilderWithAclToken): + def __init__( + self, + cdn_url: str, + secret_key: str, + window: int = 300, + hash_algo=hashlib.sha256, + ): + warnings.warn( + "AkamaiSecureUrlBuilder class was renamed to AkamaiSecureUrlBuilderWithAclToken", + DeprecationWarning, + stacklevel=2, + ) + super().__init__( + cdn_url=cdn_url, + secret_key=secret_key, + window=window, + hash_algo=hash_algo, + ) diff --git a/tests/functional/test_secure_url.py b/tests/functional/test_secure_url.py index 3ff7bd46..a7d9106c 100644 --- a/tests/functional/test_secure_url.py +++ b/tests/functional/test_secure_url.py @@ -1,7 +1,10 @@ import pytest from pyuploadcare import Uploadcare -from pyuploadcare.secure_url import AkamaiSecureUrlBuilder +from pyuploadcare.secure_url import ( + AkamaiSecureUrlBuilderWithAclToken, + AkamaiSecureUrlBuilderWithUrlToken, +) known_secret = ( @@ -10,8 +13,21 @@ @pytest.mark.freeze_time("2021-10-12") -def test_generate_secure_url(): - secure_url_bulder = AkamaiSecureUrlBuilder( +def test_get_acl_token(): + secure_url_bulder = AkamaiSecureUrlBuilderWithAclToken( + "cdn.yourdomain.com", known_secret + ) + token = secure_url_bulder.get_token("52da3bfc-7cd8-4861-8b05-126fef7a6994") + assert token == ( + "exp=1633997100~" + "acl=/52da3bfc-7cd8-4861-8b05-126fef7a6994/~" + "hmac=81852547d9dbd9eefd24bee2cada6eab02244b9013533bc8511511923098df72" + ) + + +@pytest.mark.freeze_time("2021-10-12") +def test_generate_secure_url_acl_token(): + secure_url_bulder = AkamaiSecureUrlBuilderWithAclToken( "cdn.yourdomain.com", known_secret ) secure_url = secure_url_bulder.build( @@ -26,8 +42,8 @@ def test_generate_secure_url(): @pytest.mark.freeze_time("2021-10-12") -def test_generate_secure_url_with_transformation(): - secure_url_bulder = AkamaiSecureUrlBuilder( +def test_generate_secure_url_with_transformation_acl_token(): + secure_url_bulder = AkamaiSecureUrlBuilderWithAclToken( "cdn.yourdomain.com", known_secret ) secure_url = secure_url_bulder.build( @@ -43,8 +59,24 @@ def test_generate_secure_url_with_transformation(): @pytest.mark.freeze_time("2021-10-12") -def test_client_generate_secure_url(): - secure_url_bulder = AkamaiSecureUrlBuilder( +def test_generate_secure_url_with_wildcard_acl_token(): + secure_url_bulder = AkamaiSecureUrlBuilderWithAclToken( + "cdn.yourdomain.com", known_secret + ) + secure_url = secure_url_bulder.build( + "52da3bfc-7cd8-4861-8b05-126fef7a6994", wildcard=True + ) + assert secure_url == ( + "https://cdn.yourdomain.com/52da3bfc-7cd8-4861-8b05-126fef7a6994/?token=" + "exp=1633997100~" + "acl=/52da3bfc-7cd8-4861-8b05-126fef7a6994/*~" + "hmac=b2c7526a29d0588b121aa78bc2b2c9399bfb6e1cad3d95397efed722fdbc5a78" + ) + + +@pytest.mark.freeze_time("2021-10-12") +def test_client_generate_secure_url_acl_token(): + secure_url_bulder = AkamaiSecureUrlBuilderWithAclToken( "cdn.yourdomain.com", known_secret ) @@ -62,3 +94,75 @@ def test_client_generate_secure_url(): "acl=/52da3bfc-7cd8-4861-8b05-126fef7a6994/~" "hmac=81852547d9dbd9eefd24bee2cada6eab02244b9013533bc8511511923098df72" ) + + +@pytest.mark.freeze_time("2021-10-12") +def test_client_generate_secure_url_token_acl_token(): + secure_url_bulder = AkamaiSecureUrlBuilderWithAclToken( + "cdn.yourdomain.com", known_secret + ) + + uploadcare = Uploadcare( + public_key="public", + secret_key="secret", + secure_url_builder=secure_url_bulder, + ) + secure_url = uploadcare.generate_secure_url_token( + "52da3bfc-7cd8-4861-8b05-126fef7a6994" + ) + assert secure_url == ( + "exp=1633997100~" + "acl=/52da3bfc-7cd8-4861-8b05-126fef7a6994/~" + "hmac=81852547d9dbd9eefd24bee2cada6eab02244b9013533bc8511511923098df72" + ) + + +@pytest.mark.freeze_time("2021-10-12") +def test_get_url_token(): + secure_url_bulder = AkamaiSecureUrlBuilderWithUrlToken( + "cdn.yourdomain.com", known_secret + ) + token = secure_url_bulder.get_token( + "https://cdn.yourdomain.com/52da3bfc-7cd8-4861-8b05-126fef7a6994/" + ) + assert token == ( + "exp=1633997100~" + "hmac=32b696b855ddc911b366f11dcecb75789adf6211a72c1dbdf234b83f22aaa368" + ) + + +@pytest.mark.freeze_time("2021-10-12") +def test_generate_secure_url_url_token(): + secure_url_bulder = AkamaiSecureUrlBuilderWithUrlToken( + "cdn.yourdomain.com", known_secret + ) + secure_url = secure_url_bulder.build( + "https://cdn.yourdomain.com/52da3bfc-7cd8-4861-8b05-126fef7a6994/" + ) + assert secure_url == ( + "https://cdn.yourdomain.com/52da3bfc-7cd8-4861-8b05-126fef7a6994/?token=" + "exp=1633997100~" + "hmac=32b696b855ddc911b366f11dcecb75789adf6211a72c1dbdf234b83f22aaa368" + ) + + +@pytest.mark.freeze_time("2021-10-12") +def test_client_generate_secure_url_with_wildcard_acl_token(): + secure_url_bulder = AkamaiSecureUrlBuilderWithAclToken( + "cdn.yourdomain.com", known_secret + ) + + uploadcare = Uploadcare( + public_key="public", + secret_key="secret", + secure_url_builder=secure_url_bulder, + ) + secure_url = uploadcare.generate_secure_url( + "52da3bfc-7cd8-4861-8b05-126fef7a6994", wildcard=True + ) + assert secure_url == ( + "https://cdn.yourdomain.com/52da3bfc-7cd8-4861-8b05-126fef7a6994/?token=" + "exp=1633997100~" + "acl=/52da3bfc-7cd8-4861-8b05-126fef7a6994/*~" + "hmac=b2c7526a29d0588b121aa78bc2b2c9399bfb6e1cad3d95397efed722fdbc5a78" + )