# stapel-cdn 0.12.0

Media management: image/video/file/audio upload, processing (thumbnail/preview generation via libvips) and CDN ref tracking, with a media-submodule opt-in (images core, video/recordings behind ENABLED_SUBMODULES + ffmpeg system checks), a watermark seam, upload speed-limiting, and cdn.media_exists / cdn.refs_sync comm Functions for other modules to check/sync media references without importing this package.

Contract: axes 3 · surface 6 · extension points 4 · operations 9 · error codes 53.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- ASSET_TYPES [list, default ["avatar"]] — Which image categories this deployment accepts
  Image.type choices — the same STAPEL_CDN key the client-side CdnImageField reads, so server validation and client typing agree on what's legal. Default is the zero-infrastructure 'avatar' only; marketplace-specific types (product/chat/review) are added explicitly by the host (conf.py, MODULE.md Extension points table).
- ENABLED_SUBMODULES [list, default ["images"]] — Enabled media types (images / video / audio recordings)
  Which of images/video/recordings this deployment turns on. images needs no opt-in; adding video/recordings activates the ffmpeg system-check probe for that submodule (conf.py, MODULE.md 'Media submodules').
- WATERMARK [enum, default ""] — Watermark preview images
  Dotted path to (or direct callable) a watermark engine applied to preview variants; empty disables watermarking (conf.py, MODULE.md 'Storage / processing backends').

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- detect_image_extension — stapel_cdn.fetch.detect_image_extension
  instead of: trusting the URL or Content-Type extension
  Magic-byte content check for fetched or uploaded image bytes: reads the DETECTED format from the file signature — never the URL suffix or the Content-Type header — and confirms it with a real libvips decode, raising ImageImportError otherwise. Pair with fetch_image_bytes for any external-image intake: trusting a URL's own extension is how a '.jpg' that is actually an HTML/script payload reaches storage.
- enforce_rate_limit — stapel_cdn.fetch.enforce_rate_limit
  instead of: hand-rolled cache-based counters (race-prone get+set)
  Per-caller fixed-window quota (STAPEL_CDN['IMPORT_FROM_URL_RATE']) guarding cdn's import-from-url egress path — call it before any DNS/network work on a caller-supplied URL, which is what stops the fetch from being turned into an open proxy/amplifier. Scoped to this one feature's bucket namespace; for a NEW feature's own rate limiting reach for stapel_core.gateway.ratelimit.CacheRateLimiter directly instead of repurposing this wrapper.
- fetch_image_bytes — stapel_cdn.fetch.fetch_image_bytes
  instead of: requests.get(url), urllib.request.urlopen(url)
  SSRF-hardened fetch of a remote image over HTTPS: resolves and allowlists every hop's IP (rejects private/loopback/link-local/CGNAT/cloud-metadata ranges, including IPv4-mapped/6to4/NAT64 IPv6 forms), pins the TCP connection to the validated address to defeat DNS-rebinding, and caps size/timeout/redirect hops. Reach for this — not a bare HTTP client — anywhere a deployment fetches an attacker-influenced URL (an OAuth-provider avatar, a webhook-supplied image link); this is the only outbound-HTTP path stapel-cdn ships and it exists because that shape of fetch is a classic SSRF sink.
- sniff_is_active_content — stapel_cdn.validators.sniff_is_active_content
  instead of: hand-rolled startswith(b'<') checks, trusting the upload's extension or Content-Type
  Answer, from the leading BYTES alone, whether an upload is markup or script a browser would execute — HTML, SVG, XML, PHP, a shebang — ignoring the caller-supplied filename and Content-Type, and ignoring leading whitespace or a UTF-8 BOM used to walk past a naive prefix check. Reach for this on any non-image intake whose bytes end up under a path that a web server hands to a browser: extension and Content-Type are both written by the uploader, so a '.csv' or '.pdf' that actually starts with <script> runs in the media origin. Returns a bool; it is a refusal test, not a sanitiser — keep the extension/MIME allowlists in front of it.
- validate_image_file — stapel_cdn.validators.validate_image_file
  instead of: PIL.Image.open() ad-hoc checks, hand-rolled image validation, pyvips.Image.new_from_file() ad-hoc checks
  Validate an uploaded image before it touches hashing or storage: extension allowlist + the decompression-bomb pixel cap (STAPEL_CDN['MAX_IMAGE_PIXELS']) + a real decode with libvips — the SAME decoder stapel_cdn.services uses to process the file, so this gate can never refuse input the pipeline behind it would have handled. Reach for this on ANY image-intake path in the deployment, not only cdn's own upload views. Catch stapel_cdn.decoders.ImageDecoderUnavailable separately from ValidationError: it is a ValidationError subclass meaning 'this deployment has no decoder for that format', an operator's problem, and answering it as bad input is the defect that produced this note — a valid HEIC was refused as 'Invalid image file' because the guard asked Pillow while the pipeline read HEIC natively.
### factory
- text_watermark — stapel_cdn.watermarks.text_watermark
  instead of: hand-rolled pyvips text/logo compositing
  The built-in WATERMARK engine (`(pyvips.Image) -> pyvips.Image`) the preview-generation pipeline applies when STAPEL_CDN['WATERMARK'] points at it. Point the setting here directly for a plain bottom-right text label (STAPEL_CDN['WATERMARK_TEXT']), or call it from your own callable to layer a logo/opacity effect on top — reach for this before writing a pyvips text/compositing routine from scratch.

## Extension points — what a product replaces, fork-free
- ASSET_TYPES [value_list_override]
  Read through the callable models.get_image_type_choices, so adding types never produces a model/migration change (MODULE.md Extension points table).
- WATERMARK [dotted_path]
  The only dotted-path key in the namespace: a callable (pyvips.Image) -> pyvips.Image; built-in reference stapel_cdn.watermarks.text_watermark renders WATERMARK_TEXT (MODULE.md 'Storage / processing backends').
- media_processed signal [django_signal]
  stapel_core.signals.media_processed sent after ImageProcessingService.process_image() generates all variants — in-process hook for cache warm-up/denormalization (MODULE.md 'Signals').
- serializer_seams [class_override]
  Every view in stapel_cdn.views mixes in SerializerSeamMixin (request_serializer_class/response_serializer_class + get_* getters); subclass and re-route the URL to swap (views.py:62-79, MODULE.md 'Serializer seams').

## Fits with — fleet dependencies
- stapel-core (required) — comm bus (cdn.media_exists/cdn.refs_sync Functions, user.deleted/user.deletion_initiated consume for GDPR), AppSettings, error registry (pyproject.toml dependency)

## HTTP operations (9) — call by operationId, never by a typed path
Paths are relative to `/cdn/api/v1/`.
### Files
- GET /file/exists/ — check_file_exists_get
- POST /file/exists/ — check_file_exists_post
- POST /upload/file/ — upload_file
### Images
- GET /images/{image_type}/random/ — random_image
- POST /upload/avatar/ — upload_avatar
- POST /upload/image/ — upload_image
- POST /images/{image_type}/upload/ — upload_typed_image
### Refs
- POST /refs/sync/ — sync_refs
### Videos
- POST /upload/video/ — upload_video

## Error codes (53) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.file_hash_required [400] fix_input
- error.400.file_type_not_allowed [400] fix_input
- error.400.invalid_ad_id [400] fix_input
- error.400.invalid_format [400] fix_input
- error.400.invalid_hash [400] fix_input
- error.400.invalid_image_type [400] fix_input
- error.400.missing_fields [400] fix_input
- error.400.no_file [400] fix_input
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.network_blocked [403] contact_support
- error.403.storage_quota_exceeded [403] retry
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.no_images [404] fix_input
- error.404.not_found [404] retry
- error.404.verification_challenge_not_found [404] verify
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.410.gone [410] retry
- error.413.file_too_large [413] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.503.image_decoder_unavailable [503] contact_support {extension}
- error.503.mandate_unavailable [503] retry
