# 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.

### Signed URL algorithm (compatible with `ngx_http_secure_link_module`)

Given:

- `expires` — Unix timestamp (int) when the URL expires.

- `uri` — Full URI path component of the asset URL, e.g.

    `/assets/private/reports/q1.pdf`.

- `secret` — Shared secret string (`secure_link_secret`).

The token is computed as:

    raw   = f"{expires}{uri} {secret}".encode("utf-8")
    token = base64.urlsafe_b64encode(md5(raw).digest()).rstrip(b"=").decode()

The resulting URL is:

    {base_url}/{private_prefix}/{key}?md5={token}&expires={expires}

The Nginx directive that validates this token is:

    secure_link $arg_md5,$arg_expires;
    secure_link_md5 "$secure_link_expires$uri YOUR_SECRET_HERE";

class granite_assets.repositories.local_nginx.LocalNginxAssetRepository(config: LocalNginxAssetRepositoryConfig)

  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
  save(request: AssetSaveRequest) → AssetSaveResult

    Write the asset to disk.

    Raises:

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

  delete(key: str) → None

    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

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

  move(source_key: str, dest_key: str, *, overwrite: bool = True) → None

    Move (rename) a file on disk.

  exists(key: str) → bool

    Return True if the key exists under either visibility prefix.

  get_descriptor(key: str) → AssetDescriptor

    Return file metadata without reading the file body.

  build_public_url(key: str) → AssetAccessUrl

    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

    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

    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

    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:

      - AssetNotFoundError – If the key does not exist under any visibility prefix.

      - AssetAccessNotSupportedError – If the asset is private and `secure_link_secret` is not set.

## 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)

  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
  save(request: AssetSaveRequest) → AssetSaveResult

    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

    Delete an S3 object.

    Raises:

      AssetNotFoundError – If the key does not exist.

  copy(source_key: str, dest_key: str, *, overwrite: bool = True) → None

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

  move(source_key: str, dest_key: str, *, overwrite: bool = True) → None

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

  exists(key: str) → bool

    Check object existence using a lightweight `head_object` call.

  get_descriptor(key: str) → AssetDescriptor

    Return S3 object metadata via `head_object`.

  build_public_url(key: str) → AssetAccessUrl

    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

    Generate a download URL for a private asset.

    Priority order:

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

    2. `cf_key_id` + `cf_private_key` set and `cf_signing_method=COOKIE` → plain 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

    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_signed_cookies(key_pattern: str, ttl_seconds: int | None = None) → CfSignedCookies

    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

    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

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