ServicesBlob Storage

Blob Storage

Binary file storage on an S3-compatible backend (Garage by default) 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 --prefix docs4/ --limit 50
snug blobs list --prefix docs4/ --cursor <cursor-from-previous-page>
{
  "blobs": [
    {
      "path": "docs4/avatars/logo.png",
      "size": 23,
      "content_type": "image/png",
      "last_modified": 1787891591,
      "expires_at": null
    }
  ],
  "page_size": 1,
  "has_more": true,
  "cursor": "XVZFUjRRa1JpY0doTmRWZFhhR1pM...",
  "prefix": "docs4/"
}

Pagination is cursor-based, not page-numbered: keep passing the returned cursor until has_more is false. One gotcha, reproduced live: 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 (reproduced live against a deliberately tiny quota).

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 -u user-123 --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.

Limits and configuration

Blobs up to 100 MB and per-user budgets of 1 GB with unlimited files by default - along with the S3 endpoint and credentials, signed-URL lifetime, the required BLOB_SIGNING_SECRET, and the retention worker for expired blobs - via the BLOB_* variables in the Blob CONFIG reference.

Reference

On this page