Skip to content

Blob Storage

Try the interactive example ↓

Binary file storage on AWS S3, or any S3-compatible backend such as Garage, with per-user namespacing: uploads with optional TTL auto-expiry, signed URLs for unauthenticated download, prefix listing with cursor pagination, copy/move, batch delete, and per-user storage quotas with admin controls.

When to reach for it: avatars, PDFs, videos, generated reports - any binary asset that needs temporary shareable download links or per-user storage accounting.

When not to: JSON configuration and structured values belong to KV Store, whose binary endpoints also suit small blobs that never need signed URLs; collaboratively edited JSON documents belong to Live JSON.

Concepts

  • Paths are storage keys, namespaced per user - user/avatars/123.png is a key, not a filesystem path. Two users can write the same path without collision, and reading another user's path is a plain 404 blob_not_found, never a 403. Traversal attempts (.. components) fail with 400 invalid_blob_path.
  • Content type is auto-detected from the file extension on upload; pass -t to override.
  • TTL is optional and per blob - an expired blob answers 410 blob_expired on read; physical deletion is deferred to the retention worker (off by default).
  • Signed URLs are the sharing mechanism: presign mints a /blob/public?token=... link the API serves without authentication.
  • Quotas - every user gets a byte budget (1 GB by default) and optionally a file-count cap; admins can override either per user.

Upload and download

snug blobs upload -f logo.png -p docs4/avatars/logo.png
snug blobs upload -f data.bin -p docs4/tmp/short.bin -t application/octet-stream --ttl 120
snug blobs download -p docs4/avatars/logo.png -o copy.png   # -o defaults to the blob's filename
snug blobs head --path docs4/avatars/logo.png
snug blobs delete --path docs4/avatars/logo.png --force

Over HTTP an upload is a PUT of the raw bytes (or a multipart POST) to /api/v1/blobs/{path}, with the TTL as a query parameter:

printf 'report body' | curl -X PUT -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/pdf" --data-binary @- \
  "http://localhost:4000/api/v1/blobs/docs4/drafts/report.pdf?ttl_seconds=600"
{
  "status": 201,
  "msg": "Created",
  "data": {
    "path": "docs4/drafts/report.pdf",
    "size": 11,
    "content_type": "application/pdf",
    "created_at": 1787891618,
    "expires_at": 1787892218
  }
}

Timestamps are Unix seconds; expires_at is null when no TTL was set.

GET /api/v1/blobs/{path} does not stream by default - it answers 307 with a Location pointing at a freshly signed /blob/public URL. Pass ?direct=true to stream the bytes in-band, which is what the CLI's download does.

Signed URLs and public serving

snug --output json blobs generate-url -p docs4/avatars/logo.png -e 300
{
  "url": "http://localhost:4000/blob/public?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
  "expires_at": 1787891903
}

Anyone holding the URL can download the blob until it expires - no Authorization header, no account. Validity defaults to 3600 seconds. The URL points at the API's own public route, GET /blob/public?token=... at the server root (not under /api/v1), never directly at S3; a tampered or expired token gets 401 blob_unauthorized.

Listing

snug blobs list --filter 'path[prefix]=docs4/' --limit 50
snug blobs list --filter 'path[prefix]=docs4/' --cursor <page.next_cursor-from-previous-page>
{
  "blobs": [
    {
      "path": "docs4/avatars/logo.png",
      "size": 23,
      "content_type": "image/png",
      "last_modified": 1787891591,
      "expires_at": null
    }
  ],
  "page": { "next_cursor": "eyJ2IjoxLCJzIjoi...", "has_more": true }
}

The list is in key order and pages by cursor only: storage resumes from its own continuation token, so page= and total=true are refused with a 400. Keep passing page.next_cursor until has_more is false, or use --all. One gotcha: list entries always report expires_at: null, even for blobs uploaded with a TTL - head the individual path to see the real expiry.

Copy, move, batch delete

snug blobs copy -s docs4/drafts/report.pdf -d docs4/documents/report.pdf
snug blobs move -s docs4/documents/report.pdf -d docs4/final/report.pdf
snug blobs batch-delete -p docs4/final/report.pdf -p docs4/old.bin --force

copy carries both the content type and the remaining TTL to the destination. move is copy-then-delete: afterwards the source is 404 blob_not_found (verified). batch-delete returns {"deleted": N, "failed": []} and counts paths that never existed as deleted - failed only reports backend errors, so don't use it to detect typos.

Usage and quotas

snug --output json blobs usage
{
  "file_count": 2,
  "total_size_bytes": 46,
  "quota_max_bytes": 1073741824,
  "quota_max_files": null,
  "usage_percent": 4.284083843231201e-6
}

An upload that would cross the byte or file budget fails with 413 quota_exceeded, and the message states current usage, upload size, and the limit.

Admin operations

These commands hit /admin/blobs/* and require a platform admin token; other callers get 403 FORBIDDEN.

snug blobs admin set-quota -u user-123 --max-bytes 1073741824
snug blobs admin set-quota -u user-123 --unlimited -n "Premium tier"
snug blobs admin get-quota -u user-123
snug blobs admin list-quotas
snug blobs admin delete-quota -u user-123 --force
snug blobs admin stats                      # global bytes, files, user count
snug blobs admin user-details -u user-123   # one user's usage plus quota
snug blobs admin list --filter 'user_id[eq]=user-123' --filter 'path[prefix]=docs4/'

admin list shows fully qualified backing keys ({tenant}/{user}/{path}), the clearest picture of how per-user namespacing is laid out in the bucket. It pages through the whole bucket and applies its filters to each page, so a filtered page can hold fewer rows than --limit while has_more is still true.

Limits and configuration

Blobs up to 100 MB and per-user budgets of 1 GB with unlimited files by default - along with the store itself, signed-URL lifetime and the retention worker for expired blobs - via the BLOB_* variables. BLOB_BUCKET, BLOB_REGION and BLOB_SIGNING_SECRET are required; with BLOB_ENDPOINT and the access keys unset the store is AWS S3 through the default credential chain, and setting them points at an S3-compatible server such as Garage.

Reference

Interactive example

Keep files in private storage and share them through temporary links. Uploads, downloads, and storage limits stay behind the same API.

An illustrative example that runs in your browser. No account or API calls required.