Skip to content

Letdown2491/hanami

Repository files navigation

hanami

A self-hosted Blossom server with a UI on top: and a generic client for any Blossom server. One Go binary: run your own content-addressed blob store for a trusted group of npubs, and browse your blobs across every server you declare, from a server-rendered interface.

Sibling to torii ("a relay with a UI on top"): same shape, same sumi-e identity, same stances, keyless (your nsec never touches the daemon), private-by-default, thin, dependency-light, ~zero JS.

It is a Go rewrite of Bloom, a TypeScript Blossom client, rebuilt in Go for a single static binary, native crypto, and consistency with the Go-based infrastructure in the same ecosystem. Because it targets the same on-relay formats, it stays interoperable with Bloom: the same bloom-folder: folder lists, the same private-list format, and byte-for-byte- compatible private-blob encryption, so your folders and files move between the two without conversion. The Go packages carry // Ports …ts attributions to their Bloom origins.

What it does

  • Built-in Blossom server: built on khatru/blossom, the same production-proven Blossom module haven uses, over a content-addressed filesystem store, gated to a trusted allowlist of npubs (BUD-01/02/04: upload / get / list / delete / mirror).
  • Keyless sign-in: a NIP-07 browser extension (Alby, nos2x, …) or a NIP-46 bunker; your key never touches the daemon. Any whitelisted npub can sign in, the owner_npub is the admin. The session persists across restarts and reconnects without re-approval.
  • Multi-member: whitelist several npubs, each with an isolated space (their own files, folders, and settings; identical uploads are not deduped across members). The owner admin section adds/removes members, sets per-member storage quotas, and removes with keep-or-purge of their files. Deletion is refcounted, so removing a shared blob never affects another member's copy.
  • Cross-server library: lists your blobs across every server in your kind-10063 list, deduped by hash, each showing where it lives (local and/or how many servers), corrected with HEAD-verified placement. The built-in store is folded in in-process.
  • Encrypted drive: files and folders are one encrypted event kind (30630), private by default, so relays and Blossom servers hold only opaque ciphertext and never learn your folder structure. Id-based parents make folder move and rename O(1) (no per-file rewrite); folders carry a color and emoji. Create, move, rename, merge (unioning same-named subfolders), download a folder's public files as a zip, and recursively delete, all as node edits, with drill-down navigation and folder-tree pickers. The library is defined by this drive index rather than reassembled from separate events; a one-way, non-destructive Bloom Migration imports a legacy library, and an Unindexed view surfaces blobs not yet in the drive so nothing looks lost. The drive key and nodes, which otherwise live only on relays, are kept in a durable local store (and backed up to peers): reads are served from that local store first, so the library paints instantly even right after a restart, and the relays are reconciled in the background, with any relay that dropped an event re-seeded from the local copy, so a relay prune or loss can't take the index or your private-file keys with it. The drive is the default storage model: a fresh instance starts on an empty drive, and a pre-drive library is brought in via a one-time, non-destructive Bloom import. Private files additionally encrypt the blob with a per-file key before upload (Blossom only ever stores ciphertext); decrypt-and-serve with Range support for media seeking.
  • Settings follow your account (NIP-78): layout, sort, upload and share defaults, wallpaper, pinned favorites, and Blossom-server display names sync through an encrypted app-data record keyed to your npub, so a new device inherits them after sign-in. A per-account toggle keeps them device-local instead. Pin any file or folder to a dedicated Pinned section in the sidebar.
  • Upload / delete / bulk actions: store on the built-in server and replicate to your declared servers via your signer, with an optional downscale-on-upload that caps a large image's longest edge before it's stored (off by default; Settings → Uploads); multi-select for batch delete, copy, move, and zip download.
  • Share links: mint a link to a file, or a whole folder, that anyone can view in a browser, or restrict it to a specific npub (they sign in with that Nostr key). Server-mediated so it can actually expire or be revoked: for a private file hanami records the key at creation and /s/<id> decrypts-and-serves (so Blossom and relays still only ever see ciphertext); a public file is proxied as-is; a folder share is a browsable snapshot of its subtree. A "Shared" view in the sidebar lists every link with its audience, expiry, and age, and revokes any. A Settings → Sharing pane holds the instance controls (public-read toggle, a master switch) and per-folder "don't allow sharing". Works while your instance is online at a public URL.
  • Durability: keep every file on at least N locations. Placement is HEAD-verified (BUD-12 marks /list optional and servers under-report it) and trusted as the source of truth, kept fresh by a background scrub that runs by default, so the "x of y synced" status and per-server sidebar counts are right without a manual repair; a check-&-repair panel tops up under-replicated files.
  • Federation peers: a private backup tier of your own other instances, kept out of your public kind:10063 list. Each instance has a persistent node key and, in the background (no sign-in, no signer prompts), streams a copy of its blobs to every peer it has a URL for, staying the sole bridge (a peer never learns your public servers, so it's NAT/Tailscale-friendly), private files replicate as ciphertext. A peer's node key authorizes its backups on its own, so the member list stays humans-only. Settings → Peers manages them, with live backup status.
  • Inline preview: images (proxied, resized thumbnails) and text / Markdown / code, rendered with an owned CommonMark-subset renderer (no extra dependencies). Videos get grid posters captured client-side (no ffmpeg) and cached locally, with a play badge; public posters are shared by hash, private ones cached per-owner.
  • Make it yours: name your Blossom servers (they show throughout the UI instead of bare hostnames, stored as a NIP-78 event that follows your npub) and set any image as the desktop wallpaper behind the window.

Run

Download a release binary for your platform (Linux, macOS, or Windows; amd64/arm64) from the Releases page, verify it against SHA256SUMS, then:

cp config.example.toml config.toml     # then set owner_npub to your npub (you become the admin)
./hanami -config config.toml           # → http://127.0.0.1:8787

Or run from source (requires Go ≥ 1.26, no separate build step):

cp config.example.toml config.toml     # then set owner_npub to your npub
go run ./cmd/hanami                     # → http://127.0.0.1:8787

Open http://127.0.0.1:8787 and sign in with your NIP-07 browser extension or a bunker://… URI. The owner_npub is the admin and is always a member; add more npubs to members to let others in.

New to Nostr? You need a key and a signer that holds it. Create one with a browser extension (Alby, nos2x) or a bunker signer (Signet, Amber on Android), copy your npub1… into owner_npub, and sign in with that same signer. hanami never sees your secret key.

Docker

Pull the pre-built image (published to GitHub Container Registry on every release, multi-arch linux/amd64 + linux/arm64):

cp config.docker.toml.example config.docker.toml   # add your owner_npub; keep data_dir = "/data"
docker run -d --name hanami \
  -p 127.0.0.1:8787:8787 \
  -v hanami-data:/data \
  -v "$PWD/config.docker.toml:/etc/hanami/config.toml:ro" \
  ghcr.io/letdown2491/hanami:latest         # or pin a version, e.g. :0.12.4

Open http://127.0.0.1:8787. The image runs as a non-root user and stores blobs + the index in the hanami-data volume.

Or build from source with Compose (handy for local hacking):

cp config.docker.toml.example config.docker.toml   # add your npub
docker compose up -d --build                       # → http://127.0.0.1:8787

Both bind to loopback by default. Publish it wider (a LAN or Tailscale IP) only behind a TLS reverse proxy — see DEPLOY.md.

On first boot the log may warn that the dashboard is served over plaintext HTTP. With the loopback-published port above that is expected and safe — hanami can't see the host-side -p 127.0.0.1:… mapping, only its own 0.0.0.0 bind inside the container. It matters only if you publish the container to a non-loopback address without a TLS proxy.

Config (config.toml)

listen_addr = "127.0.0.1:8787"
data_dir = ".data"
service_url = "http://127.0.0.1:8787"
owner_npub = "npub1youownernpub…"   # the instance admin (manages members + quotas)
members = []                         # whitelisted npubs; each gets an isolated space
allow_public_reads = true
redundancy_target = 3
# scrub_interval_min = 180           # background durability scrub default; 0/unset = default, negative = off (owner can change it live in Settings)
# max_blob_size = 100000000          # per-upload byte cap (default 100 MB)
# max_concurrent_uploads = 8         # cap on in-flight ingestions (peak memory ≈ this × max_blob_size)
# restrict_servers_to_public = false # true = reject member server URLs on loopback/LAN/CGNAT (public instances)
# request_timeout_sec = 1800         # whole-request read+write deadline; 0/unset = 30 min, negative = off (rely on your reverse proxy)
# bootstrap_relays = ["wss://relay.primal.net", "wss://purplepag.es", "wss://nos.lol"]  # relays used only to find a user's NIP-65 relay list; set to ones your host can reach
# nip11_icon_url = "https://cdn.example/icon.png"    # image URL advertised in the public NIP-11 doc (defaults to a CDN so directory crawlers don't hit your server)
# nip11_banner_url = "https://cdn.example/banner.jpg"

# Federation peers (private backup tier; also addable live in Settings → Peers):
# peer_quota = 10000000000            # global cap (bytes, 10 GB here) on what a peer may back up here; 0 = unlimited
# [[peers]]
# url   = "https://backup.example"    # where this instance backs up to (outbound)
# npub  = "npub1peernodekey…"         # the peer's node key, whose backups we accept (inbound)
# name  = "Backup box"                # optional label
# quota = 5000000000                  # optional per-peer inbound cap (bytes, 5 GB here); overrides peer_quota
  • owner_npub: the instance admin. Always a member, and the only key that can manage the whitelist and per-member storage quotas (Settings → Members).
  • members: the whitelisted npubs. Each can sign into the dashboard with its own isolated space and upload/delete/list on the built-in Blossom server over the wire (BUD-11). Empty = owner-only.
  • allow_public_reads: when false, GET /<sha> is member-gated too (a fully private instance). Blobs are content-addressed, so public reads are the common default.
  • redundancy_target: keep each file on at least this many locations (built-in store + declared servers); drives the durability status and repair.
  • [[peers]]: federation peers, a private backup tier of your own other instances, out of the public kind:10063 list. Each entry has a url this instance backs up to and/or an npub (the peer's node key) whose backups this instance accepts; name is optional. Config peers are pinned; more can be added live in Settings → Peers. This instance's own node key (shown there) is what a peer whitelists to accept your backups. A peer's inbound backups can be capped: peer_quota sets a global default (bytes; 0 = unlimited) and a per-peer quota overrides it (also editable in Settings → Peers).
    • The node key itself lives in <data_dir>/instance_key (0600), auto-generated on first launch. To use a specific identity instead, write it there before first launch as either raw hex or a bech32 nsec1…. The same key can drive a FIPS mesh transport (FIPS_NSEC); hanami accepts any peer URL, including a <npub>.fips host (those are auto-allowed, no allowed_hosts entry needed).
  • scrub_interval_min: how often the background durability scrub HEAD-verifies placement and tops up under-replicated files (keeps the "synced" counts fresh without a manual repair). 0/unset uses a sensible default; a negative value disables it. This is only the seed default: the owner can change the interval, or turn it off, live in Settings > Storage (persisted, no restart).
  • bootstrap_relays: the relays used only to find a signed-in user's NIP-65 relay list (kind:10002); once found, reads go to the user's own relays, and a user's discovered servers/relays persist across restarts. Set this to relays your host can actually reach that hold your list, so discovery doesn't depend on hardcoded relays that may be dead or unreachable from where hanami runs. Unset uses a built-in default.
  • nip11_icon_url / nip11_banner_url: the image URLs advertised in the public NIP-11 relay document (what relay-directory crawlers like nostr.watch read). These default to a CDN, not your own server, so those crawlers don't put traffic on your instance. Set them to your own CDN if you want custom directory branding, or to your service_url + /icon / /banner if you'd rather self-host and accept the crawler traffic. (The sign-in card always shows the built-in hanami banner, served same-origin as its CSP requires.)
  • request_timeout_sec: a finite whole-request read+write deadline so no reverse proxy is strictly required to bound a slow client. 0/unset is 30 minutes (generous for a large upload/download on a slow link); a negative value disables it to rely on your reverse proxy.
  • For a public instance set allowed_hosts and trusted_proxies and put it behind a TLS-terminating reverse proxy, see deploy/PUBLIC-DEPLOYMENT.md. Otherwise bind to loopback and reach it over Tailscale/Tor.

Reaching a peer over FIPS (optional)

A peer needs a URL you can reach. If both instances are behind NAT with no public address, FIPS (a separate mesh daemon, never bundled) can give each a stable <npub>.fips address to back up over. hanami just speaks HTTP to that hostname; FIPS does the routing at the OS layer.

The trick is that one identity drives both: hanami's node key is the FIPS node identity, so <npub>.fips is the same npub your peers already authorize. On each box:

  1. Install FIPS (github.com/jmcorgan/fips) and point it at hanami's key. FIPS's node.identity.nsec config field accepts raw hex and always overrides its auto-generated fips.key, and hanami's key is hex, so in /etc/fips/fips.yaml:

    node:
      identity:
        nsec: "<paste the contents of your hanami data_dir/instance_key>"

    chmod 600 /etc/fips/fips.yaml (it now holds a secret) and restart FIPS. Confirm fipsctl show status | jq .npub matches the node key shown in Settings → Peers.

  2. Bind hanami to the mesh: listen_addr = "[::]:8443". .fips hosts are auto-allowed, so no allowed_hosts entry is needed.

  3. Add the peer in Settings → Peers with URL http://<their-npub>.fips:8443 and authorize its node key: the normal peer flow, just over a .fips URL. FIPS handles discovery (a Nostr relay for internet peers, or mDNS on a LAN).

A containerized sidecar starter (identity injected from hanami's key file) is in deploy/fips-sidecar/: experimental, verify against your FIPS release.

Test

go test ./...          # unit + in-process integration (client ↔ server, bunker ↔ relay)
go test -race ./...    # data-race check (hanami's own code)

The crypto is vector-verified: private-blob and passphrase-envelope decryption run against golden vectors produced by WebCrypto, and NIP-44 runs the official paulmillr/nip44 reference set, so the on-wire formats stay byte-compatible with existing data.

See CHANGELOG.md for notable changes.

Contributing & security

Contributions and bug reports are welcome, see CONTRIBUTING.md. Please report security vulnerabilities privately, see SECURITY.md, not as a public issue.

License

MIT © 2026 Letdown2491

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages