granite_assets.repositories

Local Nginx Repository

Local filesystem asset repository served by Nginx (or any static HTTP server).

Design constraints

  • Nginx itself is responsible for serving files; this library only writes/reads the filesystem.

  • Public assets are placed under <storage_path>/<public_prefix>/ and served at <base_url>/<public_prefix>/.

  • Private assets are placed under <storage_path>/<private_prefix>/.

  • Signed URLs for private assets are supported when LocalNginxAssetRepositoryConfig.secure_link_secret is set. The token algorithm matches Nginx’s ngx_http_secure_link_module with the secure_link_md5 directive. Nginx validates tokens server-side without any round-trip to the application.

  • When secure_link_secret is not set, private asset URL methods raise AssetAccessNotSupportedError so that callers know they must route access through their own application layer.

class granite_assets.repositories.local_nginx.LocalNginxAssetRepository(config: LocalNginxAssetRepositoryConfig)[source]

Bases: object

Asset repository backed by the local filesystem.

Files are organised under two sub-directories:

  • <storage_path>/<public_prefix>/ – publicly served assets.

  • <storage_path>/<private_prefix>/ – private assets (Nginx-protected).

Public URLs are constructed by joining base_url, the relevant prefix, and the logical key.

Example:

config = LocalNginxAssetRepositoryConfig(
    storage_path="/var/www/assets",
    base_url="https://cdn.example.com/assets",
)
repo = LocalNginxAssetRepository(config)
__init__(config: LocalNginxAssetRepositoryConfig) None[source]
save(request: AssetSaveRequest) AssetSaveResult[source]

Write the asset to disk.

Raises:

AssetError – If the file already exists and overwrite is False.

delete(key: str) None[source]

Remove the asset file from disk.

Tries both visibility prefixes so the caller does not need to know where the file is stored.

copy(source_key: str, dest_key: str, *, overwrite: bool = True) None[source]

Copy a file on disk using shutil (server-side, no re-upload).

move(source_key: str, dest_key: str, *, overwrite: bool = True) None[source]

Move (rename) a file on disk.

exists(key: str) bool[source]

Return True if the key exists under either visibility prefix.

get_descriptor(key: str) AssetDescriptor[source]

Return file metadata without reading the file body.

build_public_url(key: str) AssetAccessUrl[source]

Return a permanent public URL.

Only valid for assets stored with AssetVisibility.PUBLIC.

Raises:

AssetAccessNotSupportedError – If the asset is private (no signed URL support in this backend).

build_download_url(key: str, ttl_seconds: int | None = None) AssetAccessUrl[source]

Return a download URL for the asset.

  • Public assets → permanent URL (no token required).

  • Private assets with ``secure_link_secret`` configured → signed URL valid for ttl_seconds seconds (default: config.secure_link_ttl_seconds).

  • Private assets without ``secure_link_secret`` → raises AssetAccessNotSupportedError. The caller must proxy downloads through the application layer.

Parameters:
  • key – Logical key of the asset (no leading slash).

  • ttl_seconds – Override the default signed-URL TTL for this call only.

Returns:

AssetAccessUrl with expires_at=None for public assets or a UTC datetime for signed private URLs.

Raises:

AssetAccessNotSupportedError – For private assets when no secret is configured, or when the asset is not found.

build_upload_url(key: str, content_type: str, ttl_seconds: int | None = None, *, visibility: AssetVisibility = AssetVisibility.PRIVATE) UploadUrlResult[source]

Return a tus upload-creation URL pointing to the configured tusd server.

The returned UploadUrlResult carries method="POST" and the required tus headers. The upload flow is:

  1. Client sends POST {url} with the headers from result.headers and Content-Length: 0 (tus creation request).

  2. tusd calls your pre-create hook to validate upload-token.

  3. Client sends PATCH {location} chunks until the upload is complete.

  4. tusd calls your post-finish hook to move the file into {storage_path}/{visibility_prefix}/{key}.

Upload-Metadata fields embedded in the request:

  • asset-key — logical key for the asset.

  • content-type — MIME type.

  • visibility"public" or "private".

  • upload-expires — Unix timestamp when the token expires.

  • upload-token — HMAC-SHA256 (hex) of "{expires}:{key}:{visibility}:{content_type}" signed with upload_secret.

Parameters:
  • key – Logical key for the asset (no leading slash).

  • content_type – MIME type of the file to be uploaded.

  • ttl_seconds – Override the default upload_ttl_seconds for this call only. Governs how long the token is valid.

  • visibility – Target visibility for the asset (keyword-only). Defaults to AssetVisibility.PRIVATE.

Returns:

UploadUrlResult with method="POST" and tus-specific headers.

Raises:

AssetAccessNotSupportedError – If tusd_url or upload_secret is not configured.

resolve_access(key: str, ttl_seconds: int | None = None) AssetAccessUrl[source]

Return the best available URL for the asset.

  • Public assets → permanent public URL.

  • Private assets with ``secure_link_secret`` configured → signed URL (same as build_download_url()).

  • Private assets without ``secure_link_secret`` → raises AssetAccessNotSupportedError.

Parameters:
  • key – Logical key of the asset (no leading slash).

  • ttl_seconds – TTL override forwarded to the signed-URL builder.

Raises:

S3 Repository

S3 asset repository backed by AWS S3 (or any S3-compatible store).

Design decisions

  • Presigned PUT is used for upload URLs instead of presigned POST. POST allows richer server-side validation (file-size limits, content-type enforcement) but requires a multipart form submission which complicates client-side HTTP libraries. PUT is a plain binary body, trivially consumed by fetch, axios, requests, curl, and native mobile SDKs.

  • boto3 is loaded lazily so that importing granite_assets in a project without the s3 extra does not raise ImportError at module level. Only instantiating S3AssetRepository triggers the import.

  • Object keys in S3 are prefixed with config.key_prefix when non-empty. The logical key exposed to callers never includes this prefix; the mapping is transparent.

  • Public vs private is implemented via S3 object ACLs when the bucket allows it, or purely by policy. To keep the library simple we set ACL='public-read' for public objects and no ACL for private objects. Callers must ensure their bucket policy is compatible. If you rely on a bucket policy instead of ACLs, set public_base_url and manage ACLs externally.

class granite_assets.repositories.s3.S3AssetRepository(config: S3AssetRepositoryConfig)[source]

Bases: object

Asset repository backed by AWS S3.

Instantiation is cheap; the boto3 session is created once and reused.

Example:

config = S3AssetRepositoryConfig(
    bucket="my-assets",
    region="eu-west-1",
    public_base_url="https://cdn.example.com",
    presign_ttl_seconds=3600,
)
repo = S3AssetRepository(config)
__init__(config: S3AssetRepositoryConfig) None[source]
save(request: AssetSaveRequest) AssetSaveResult[source]

Upload an asset to S3.

Sets ACL='public-read' for PUBLIC assets. Metadata and checksum are forwarded as S3 object metadata.

delete(key: str) None[source]

Delete an S3 object.

Raises:

AssetNotFoundError – If the key does not exist.

copy(source_key: str, dest_key: str, *, overwrite: bool = True) None[source]

Server-side S3 copy (no data transfer to/from this process).

move(source_key: str, dest_key: str, *, overwrite: bool = True) None[source]

Copy then delete (S3 has no native move operation).

exists(key: str) bool[source]

Check object existence using a lightweight head_object call.

get_descriptor(key: str) AssetDescriptor[source]

Return S3 object metadata via head_object.

build_public_url(key: str) AssetAccessUrl[source]

Return the permanent public URL for a PUBLIC asset.

If public_base_url is configured, uses that as base (CDN URL). Otherwise builds a standard virtual-hosted S3 URL.

Raises:

AssetAccessNotSupportedError – If called for a PRIVATE asset key that is known to be private (best-effort; requires a head_object call not performed here for performance).

build_download_url(key: str, ttl_seconds: int | None = None) AssetAccessUrl[source]

Generate a download URL for a private asset.

Priority order:

  1. cf_key_id + cf_private_key set and cf_signing_method=URLCloudFront signed URL (canned policy, query-param credentials).

  2. cf_key_id + cf_private_key set and cf_signing_method=COOKIEplain CloudFront URL (no signature). The browser must already hold the signed cookies obtained via build_signed_cookies().

  3. cf_unsigned_urls=True + public_base_url set → plain CloudFront URL (permanent, no signature).

  4. Fallback → S3 presigned URL (time-limited, exposes S3 domain).

build_path_signed_url(key: str, *, path_pattern: str | None = None, ttl_seconds: int | None = None) AssetAccessUrl[source]

Generate a CloudFront URL with custom-policy signing for key.

Unlike the canned-policy URL returned by build_download_url(), the custom policy can authorise a wildcard path so that a single set of query-param credentials is valid for every file under a directory. This is the recommended approach for HLS/DASH video streaming where the player autonomously fetches dozens of segment files.

Parameters:
  • key – Logical key of the file whose URL is returned (e.g. "private/videos/uuid/master.m3u8"). Must not start with /.

  • path_pattern – CloudFront resource pattern to embed in the policy. Accepts a trailing * wildcard (e.g. "private/videos/uuid/*"). Defaults to the directory of key + /*.

  • ttl_seconds – URL lifetime in seconds (default: configured TTL).

Returns:

AssetAccessUrl whose url carries ?Policy=…&Signature=…&Key-Pair-Id=… query params.

Raises:

AssetConfigurationError – if CloudFront signing is not configured.

build_folder_signed_url(key: str, *, entry_filename: str, ttl_seconds: int | None = None) AssetAccessUrl[source]

Generate a CloudFront URL with wildcard custom-policy for the folder of key, pointing to entry_filename within that folder.

This is the recommended method for composite assets such as HLS/DASH video streams. The source asset (e.g. a transcoded .mp4) and the player entry point (e.g. master.m3u8) live in the same S3 folder. A single set of credentials authorises the manifest and all segment files that the player fetches from relative paths.

Usage example:

# key layout in S3:
#   assets/<uuid>/<uuid>.mp4       ← original source
#   assets/<uuid>/master.m3u8      ← HLS manifest
#   assets/<uuid>/1080p/index.m3u8
#   assets/<uuid>/1080p/seg000.ts  … seg009.ts

url = repo.build_folder_signed_url(
    "assets/<uuid>/<uuid>.mp4",
    entry_filename="master.m3u8",
    ttl_seconds=7200,
)
# Returns:
#   https://<cf>/assets/<uuid>/master.m3u8
#       ?Policy=<wildcard over assets/<uuid>/*>
#       &Signature=...&Key-Pair-Id=...

Pass url.url directly to HLS.js as the source — the query-string credentials are inherited by all relative segment requests.

Parameters:
  • key – Logical key of any file that belongs to the target folder. Used solely to derive the folder path; the URL will not point to this file. Example: "assets/<uuid>/<uuid>.mp4".

  • entry_filename – Filename of the player entry point within the same folder. Example: "master.m3u8".

  • ttl_seconds – Lifetime of the signing credentials in seconds (default: configured presign_ttl_seconds). A new URL with a new expiry is generated on every call; credentials are not cached.

Returns:

AssetAccessUrl whose url is https://<cf>/<folder>/<entry_filename>?Policy=…&Signature=…&Key-Pair-Id=… and whose expires_at reflects the policy expiry.

Raises:
  • AssetConfigurationError – CloudFront signing is not configured (cf_key_id or cf_private_key not set).

  • AssetErrorkey has no directory component (it is a root-level key with no / separator).

build_signed_cookies(key_pattern: str, ttl_seconds: int | None = None) CfSignedCookies[source]

Generate CloudFront signed-cookie values for key_pattern.

Call this once per session (or per resource group) and set the returned values as HttpOnly; Secure; SameSite=None cookies on the response. The browser will then include them automatically on every CloudFront request that matches the policy path.

Parameters:
  • key_pattern – Logical key pattern (may include a trailing * wildcard) relative to the configured key prefix. Example: "private/videos/uuid/*".

  • ttl_seconds – Cookie lifetime in seconds (default: configured TTL).

Returns:

CfSignedCookies with policy, signature, and key_pair_id values ready to set as cookies.

Raises:

AssetConfigurationError – if CloudFront signing is not configured.

build_upload_url(key: str, content_type: str, ttl_seconds: int | None = None) UploadUrlResult[source]

Generate a presigned PUT URL for client-side upload.

The client must send the file as an HTTP PUT with the Content-Type header set to exactly the value provided here. No other headers are required by default.

Example (using requests):

result = repo.build_upload_url("images/photo.jpg", "image/jpeg")
with open("photo.jpg", "rb") as f:
    requests.put(result.url, data=f, headers=result.headers)
resolve_access(key: str, ttl_seconds: int | None = None) AssetAccessUrl[source]

Return public URL for public assets, signed download URL for private.