Blossom file server

Media hosting on its own hostname: content-addressed uploads, local or S3 storage, signed by kind-24242 auth.

Overview

nostrfy can act as a Blossom blob server: clients upload files addressed by their SHA-256 hash, and the relay serves them back. Like the REST API, it lives on a dedicated hostname on the same port.

Configuration

toml
[blossom]
host = "media.example.com"          # required — enables the feature
storage = "local"                   # "local" or "s3"
local_path = "./data/images"        # local storage root
max_upload_bytes = 20971520         # 20 MiB
min_free_bytes = 33554432           # refuse uploads when the disk has less free space
restrict_uploads = false            # only allow-listed pubkeys may upload

# For S3 / Cloudflare R2:
s3_endpoint = "https://<account>.r2.cloudflarestorage.com"
s3_region = "auto"
s3_bucket = "nostr-media"
s3_access_key = "..."
s3_secret_key = "..."

Point media.example.com at the same port in your reverse proxy, then restart. GET / on that host answers with the Blossom server info document. With storage = "s3" the endpoint must be HTTPS unless the host is loopback (e.g. a local MinIO for testing).

Storage layout

Both backends use the <npub1...> hierarchy, keyed by the file's SHA-256:

  • local — files under <local_path>/<npub1...>/<sha256>
  • s3 / R2 — objects <npub1...>/<sha256> in the configured bucket

Blob bytes never touch the relay database — LMDB holds only the sha256 → owner mapping and the upload allowlist.

Endpoints

MethodPathAuthDescription
GET/Blossom server info
GET / HEAD/<sha256>[.ext]Fetch / probe a blob (byte ranges, 206)
PUT/uploadkind 24242 (t=upload, x=sha256, expiration)Upload a blob — 201 new, 200 already exists
HEAD/uploadkind 24242 (t=upload, x=sha256, expiration)BUD-06 pre-flight — would the upload be accepted?
PUT/mediakind 24242 (t=media, x=sha256, expiration)BUD-05 media upload (stored verbatim)
HEAD/mediakind 24242 (t=media, x=sha256, expiration)BUD-05 pre-flight — would the upload be accepted?
GET/list/<pubkey>kind 24242 (t=list, expiration)Blobs uploaded by the requesting pubkey (cursor + limit)
DELETE/<sha256>kind 24242 (t=delete, x=sha256, expiration)Delete a blob (uploader only)

Security notes

  • User-uploaded bytes are served with X-Content-Type-Options: nosniff.
  • HTML/SVG/XML/JavaScript additionally get Content-Disposition: attachment and a sandbox CSP, so the media origin cannot be used for stored XSS.
  • Tokens are accepted in the spec's base64url (no padding) form and in the padded standard form (BUD-11).
  • The X-SHA-256 header is verified against the actual bytes — a mismatch returns 409.
  • Files are served with ETag, Cache-Control: immutable and the stored content type.
  • A pubkey banned with NIP-86 banpubkey is refused on every endpoint.

Example

sh
# Server info
curl https://media.example.com/

# Upload (auth event from your Blossom client, e.g. via nak or the nostr-tools blossom helper)
curl -X PUT -H "Authorization: Nostr <auth>" -H "Content-Type: image/png" --data-binary @photo.png https://media.example.com/upload

# Fetch
curl https://media.example.com/<sha256>

# List your own uploads (auth event with t=list; the path pubkey must be yours)
curl -H "Authorization: Nostr <auth>" https://media.example.com/list/<pubkey-hex>

# Delete (auth event with t=delete and x=<sha256>)
curl -X DELETE -H "Authorization: Nostr <auth>" https://media.example.com/<sha256>

Restricting uploads

Set restrict_uploads = true in the [blossom] section:

toml
[blossom]
host = "media.example.com"
restrict_uploads = true

The allowlist lives in the relay database (LMDB), managed with dedicated commands — no restart needed, the daemon reloads automatically:

sh
nostrfy blossom allow npub1...          # allow a pubkey (npub1... or hex)
nostrfy blossom deny npub1...           # revoke a pubkey
nostrfy blossom list                    # show the list and restrict_uploads

Uploads from unlisted pubkeys are rejected with 403.

Backups and migration

Back up both the configured blob storage and database.path to preserve the complete inventory and authorization state. The sha256 → owner mapping is persisted in LMDB, so restarts are instant and no in-memory index or startup scan is needed — lookups read the mapping directly from the database. An automatic one-time migration rebuilds the mapping from legacy blobs on the first start after an upgrade; a marker skips later restarts.