Appearance
Private images for apps
Private images let an owner app and its runtime exchange JPEG/PNG files through Hooka without sending a notification, invoking APNs, using S3, or creating a public image URL. A scoped bearer token can upload with topic publish permission and download with topic read permission. Both endpoints reject anonymous callers, even when the topic allows anonymous access. Never put credentials in image URLs.
Upload
POST /topics/{topic}/images accepts multipart/form-data containing exactly one file field named image. An Authorization: Bearer … header and a nonempty Idempotency-Key header of at most 256 bytes are required. The topic must have both anonymous_publish and anonymous_read disabled; otherwise upload returns 403, including for master-key callers.
sh
curl --fail-with-body http://localhost:8080/topics/app-example-in/images \
-H "Authorization: Bearer $HOOKA_TOKEN" \
-H 'Idempotency-Key: example-image-001' \
-F '[email protected]'This is an example only: running it stores the file in the selected server. The response is HTTP 200, including for an identical retry:
json
{"ok":true,"image":{"message_id":123,"name":"example.png","content_type":"image/png","size":1024,"sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","width":320,"height":240}}The server sniffs the bytes, checks the image header and fully decodes it. It accepts only JPEG and PNG, at most 10 MiB (10,485,760 bytes), at most 20,000,000 pixels, and at most 16,384 pixels in either dimension. The global MAX_UPLOAD_BYTES request limit can impose a smaller envelope limit. A client should resize unusually large photos before upload; bytes are not silently truncated, recompressed, or stripped of embedded image metadata.
The returned name is sanitized, bounded, and given an extension matching the actual image type. The checksum describes the exact stored bytes. Reusing a key within the same topic with different bytes or a different sanitized name returns 409. Identical concurrent retries create one receipt and one stored image. Image keys are separate from ordinary publish keys and last as long as the image, independent of IDEMPOTENCY_RETENTION_DAYS.
Invalid images, absent keys, or malformed multipart requests return 400. Oversize files and exceeded topic quotas return 413. Storage failures return 500. No successful receipt exists unless its bytes were committed in the same database transaction. Storage errors and quota rejection leave no partial image message.
Download
GET /topics/{topic}/images/{message_id} returns the original bytes with:
Content-Type: image/jpegorimage/pngContent-Lengthand a sanitized attachment filenameCache-Control: private, no-storeX-Content-Type-Options: nosniff
The bearer token must have read permission for that exact topic (or its matching scope). The server checks the stored message's topic and private_image source; a publisher-controlled JSON payload cannot forge an image. Missing, expired, wrong-topic or wrong-provenance image IDs return 404. Missing/invalid credentials or insufficient scope return 401. A later change to a topic's anonymous ACL does not make these bytes anonymously accessible. No redirect or signed URL is issued.
Task integration and storage
Uploading creates only a lightweight history/SSE marker with server-owned source: "private_image", text [Image], and payload {"kind":"private_image"}. Workers must ignore that marker as a task. Publish the actual task separately using normal JSON store_only: true and an application_payload containing the returned descriptor. An app must validate image references and fetch through the authenticated endpoint; descriptors must never be interpreted as arbitrary URLs or local file paths. History/SSE follows normal topic ACLs, so descriptors placed in later task messages must not be treated as secrets if an operator later makes the topic public. The image bytes still require authentication.
An additive migration creates message_images, storing the bytes in Postgres BYTEA, with a foreign key to the corresponding message. Deleting/expiring the message cascades to its bytes and key, reclaiming quota. Default message retention is 30 days, swept hourly; downloads are not permanent archives. No separate S3 service or storage credentials are required.
PRIVATE_IMAGE_MAX_TOPIC_BYTES caps retained image bytes per topic at 268,435,456 (256 MiB) by default. The value is a positive integer number of bytes, at most 1,099,511,627,776 (1 TiB); zero, negative and malformed values fail startup. Concurrent uploads enforce the limit atomically. Identical retries still succeed at the limit, and changing the limit does not delete existing images. A lower limit blocks new uploads until deletion/retention frees sufficient capacity.
This quota counts original image bytes, not total database size. Allow for WAL, indexes, replication, and backup retention. It is per topic, so operators must still provision aggregate database capacity across their authorized topics.
The existing delivery APIs (/publish/{topic}/photo, /photos, /document) have different routing and retry semantics and do not offer this private contract.