From 96bbbc05ed79ea16ee76116f8c87c8053249e9ff Mon Sep 17 00:00:00 2001 From: deymosh Date: Tue, 29 Sep 2026 17:37:29 +0200 Subject: [PATCH 1/6] chore(deps): bump fiatjaf.com/nostr 2026-07-08 -> 2026-09-28 Pulls in three months of upstream library work, notably: - blossom moved from nipb0/ to nipb7/ (pure rename, identical API) - the only code change needed for the bump - websocket WriteWait deadlines: writes to stuck peers now time out instead of blocking a handler forever - nil-subscription guards in the listener path - NostrAddressesInTheText no longer flags npubs that are part of a URL - eventstore/mmm stats cursor key fixed (8+2+4, not 8+4+4) and stops early once the cursor leaves the author's prefix - new OverwriteSupportedMethods relay hook and assignmethod/ unassignmethod NIP-86 methods The lmdb CountEvents infinite-loop that upstream haven #135 had to work around does not exist in this lineage - the monorepo lmdb backend already advances its cursor on every counting path. go build and go vet are clean. Co-Authored-By: Claude Code --- blossomMigration.go | 8 ++++---- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/blossomMigration.go b/blossomMigration.go index d85c0564b..4238fb851 100644 --- a/blossomMigration.go +++ b/blossomMigration.go @@ -4,9 +4,9 @@ import ( "context" "log/slog" - "fiatjaf.com/nostr/khatru/blossom" - nipb0blossom "fiatjaf.com/nostr/nipb0/blossom" "fiatjaf.com/nostr" + "fiatjaf.com/nostr/khatru/blossom" + nipb7blossom "fiatjaf.com/nostr/nipb7/blossom" ) func migrateBlossomMetadata(ctx context.Context, bl *blossom.BlossomServer) { @@ -15,7 +15,7 @@ func migrateBlossomMetadata(ctx context.Context, bl *blossom.BlossomServer) { // List all BlobDescriptor for the relay owner pubkey ownerPubkey := nostr.MustPubKeyFromHex(config.OwnerPubKey) - var blobs []nipb0blossom.BlobDescriptor + var blobs []nipb7blossom.BlobDescriptor for blob := range outboxDBWrapper.List(ctx, ownerPubkey) { blobs = append(blobs, blob) } @@ -26,7 +26,7 @@ func migrateBlossomMetadata(ctx context.Context, bl *blossom.BlossomServer) { } // Create a map to track migrated blobs - migrated := make(map[string]nipb0blossom.BlobDescriptor, len(blobs)) + migrated := make(map[string]nipb7blossom.BlobDescriptor, len(blobs)) slog.Info("BlobDescriptors will be migrated from Outbox to Blossom's DB", "count", len(blobs)) diff --git a/go.mod b/go.mod index 9fc31bc66..fb1ef6e11 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/barrydeen/haven go 1.25 require ( - fiatjaf.com/nostr v0.0.0-20260708142249-c591b5592910 + fiatjaf.com/nostr v0.0.0-20260928115942-58e4c715304e github.com/joho/godotenv v1.5.1 github.com/minio/minio-go/v7 v7.0.98 github.com/puzpuzpuz/xsync/v4 v4.4.0 diff --git a/go.sum b/go.sum index 6525474fe..7d4c906fa 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI= fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8= -fiatjaf.com/nostr v0.0.0-20260708142249-c591b5592910 h1:LplP012gQeGf2hwMcUllEfDrZvyQR6zvrXSNaNVcm20= -fiatjaf.com/nostr v0.0.0-20260708142249-c591b5592910/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM= +fiatjaf.com/nostr v0.0.0-20260928115942-58e4c715304e h1:ZHPQsQzd0rGdOq/vCMUOy9FfrzlmAxvQ+V+4ucJ1WXY= +fiatjaf.com/nostr v0.0.0-20260928115942-58e4c715304e/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM= github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc= github.com/FastFilter/xorfilter v0.2.1/go.mod h1:aumvdkhscz6YBZF9ZA/6O4fIoNod4YR50kIVGGZ7l9I= github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= From bdeb2c729787aeee7feb78881029b088d5ba8fb0 Mon Sep 17 00:00:00 2001 From: deymosh Date: Tue, 29 Sep 2026 17:42:41 +0200 Subject: [PATCH 2/6] feat(deletions): let the relay owner delete any event Port of upstream barrydeen/haven#133 (Barry Deen), adapted from nbd-wtf/go-nostr + fiatjaf/khatru to the fiatjaf.com/nostr monorepo. The owner (OWNER_NPUB) can now delete any event stored on the relay endpoint the request is sent to; authors keep deleting their own events and everybody else is unaffected: - OwnerCanDeleteAnyEvent is wired into AllowDeleting - the monorepo khatru hook that replaces the old OverwriteDeletionOutcome - on all four relays - the tombstone is stored: the owner's kind 5 passes the chat-relay kind policy, the inbox whitelist-tag policy and the blanket AUTH requirement in MustNotBeBlacklistedToPost (the event already carries the owner's signature) - deleted events don't come back: MustNotBeDeleted rejects a re-publish and the three import paths skip deleted events. The old khatru "#e"/"#a" filter bug this guarded against does not exist in the monorepo's handleDeleteRequest, but the import paths write to the databases directly and still need the check Only delete requests signed by an event's author or by the owner count as deletions, so nobody can tombstone an event the relay hasn't seen yet and block it from ever being stored. Co-Authored-By: Claude Code --- deletions.go | 73 ++++++++++++++++++++++++++++++++++++++++++ docs/access-control.md | 19 +++++++++++ import.go | 13 ++++++++ init.go | 24 +++++++++++--- policies.go | 44 +++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 deletions.go diff --git a/deletions.go b/deletions.go new file mode 100644 index 000000000..2b03c0ff3 --- /dev/null +++ b/deletions.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + + "fiatjaf.com/nostr" +) + +// isDeleted reports whether db holds a NIP-09 delete request covering the event. +// +// The import paths write to the databases directly and don't check at all, and +// without this a deleted note comes straight back on the next publish or on the +// next pull from the seed relays. +func isDeleted(db DBBackend, event nostr.Event) bool { + // only the author of an event and the relay owner can delete it, so a delete + // request from anybody else doesn't count + authors := []nostr.PubKey{event.PubKey, nostr.MustPubKeyFromHex(config.OwnerPubKey)} + + filters := []nostr.Filter{{ + Kinds: []nostr.Kind{nostr.KindDeletion}, + Authors: authors, + Tags: nostr.TagMap{"e": []string{event.ID.Hex()}}, + Limit: 1, + }} + + // replaceable and addressable events are also deleted by address, and such a + // request only covers the versions that existed when it was made + if !event.Kind.IsRegular() { + address := fmt.Sprintf("%d:%s:%s", event.Kind, event.PubKey.Hex(), event.Tags.GetD()) + filters = append(filters, nostr.Filter{ + Kinds: []nostr.Kind{nostr.KindDeletion}, + Authors: authors, + Tags: nostr.TagMap{"a": []string{address}}, + Since: event.CreatedAt, + Limit: 1, + }) + } + + for _, filter := range filters { + for range db.QueryEvents(filter, filter.Limit) { + return true + } + } + + return false +} + +// OwnerCanDeleteAnyEvent replaces khatru's default NIP-09 outcome, which only +// lets authors delete their own events, so that the owner can delete anything +// stored on their relay. Everybody else is still limited to their own events. +// +// It is wired into AllowDeleting, the monorepo khatru hook that replaces the +// old OverwriteDeletionOutcome: returning true allows the deletion. +func OwnerCanDeleteAnyEvent(_ context.Context, target nostr.Event, deletion nostr.Event) bool { + if isBanned(deletion.PubKey.Hex()) { + slog.Debug("🚫 deletion rejected: user is banned", "event", target.ID, "pubkey", deletion.PubKey) + return false + } + + if target.PubKey == deletion.PubKey { + return true + } + + if deletion.PubKey.Hex() == config.OwnerPubKey { + slog.Info("πŸ—‘οΈ owner deleted an event", "event", target.ID, "kind", target.Kind, "author", target.PubKey) + return true + } + + slog.Debug("🚫 deletion rejected: user is not the author of the event", "event", target.ID, "pubkey", deletion.PubKey) + return false +} diff --git a/docs/access-control.md b/docs/access-control.md index 383ef363c..71efb171a 100644 --- a/docs/access-control.md +++ b/docs/access-control.md @@ -60,6 +60,25 @@ otherwise be allowed by the Web of Trust. BLACKLISTED_NPUBS_FILE=blacklisted_npubs.json ``` +## Deleting Events + +The relay owner (`OWNER_NPUB`) can delete **any** event stored on their relay by publishing a +[NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md) delete request (kind 5) to it, even when the event +was written by somebody else. Everybody else can only delete their own events. + +A delete request applies to the relay it is sent to, so publish it to the endpoint that holds the event, for example: + +```sh +nak event -k 5 -t e= --sec wss://your.relay/inbox +``` + +Deletions stick: the delete request is kept, and the deleted event is refused if anybody tries to publish it again or +if it shows up while importing from your seed relays. + +> [!NOTE] +> Deleting an event only removes it from your Haven relay. Copies on other relays are unaffected, though a delete +> request published to your outbox relay is blasted onwards like any other event. + --- [README](../README.md) diff --git a/import.go b/import.go index 8b4a0b188..b7fa5ee49 100644 --- a/import.go +++ b/import.go @@ -110,6 +110,10 @@ func importOwnerNotes(ctx context.Context) { slog.Debug("🚫 skipping event from blacklisted pubkey", "pubkey", ev.PubKey, "id", ev.ID) continue } + if isDeleted(outboxDB, ev.Event) { + slog.Debug("🚫 skipping deleted event", "id", ev.ID) + continue + } if err := outboxDB.SaveEvent(ev.Event); err != nil { log.Println("🚫 error importing note", ev.ID, ":", err) nFailedImportNotes++ @@ -188,6 +192,10 @@ func importTaggedNotes(ctx context.Context) { if ev.Kind == nostr.KindGiftWrap { dbToWrite = chatStore } + if isDeleted(dbToWrite, ev.Event) { + slog.Debug("🚫 skipping deleted tagged event", "id", ev.ID) + break + } if err := dbToWrite.SaveEvent(ev.Event); err != nil { log.Println("🚫 error importing tagged note", ev.ID, ":", err) } @@ -243,6 +251,11 @@ func subscribeInboxAndChat(ctx context.Context) { dbToPublish = chatDB } + if isDeleted(dbToPublish, ev.Event) { + slog.Debug("🚫 skipping deleted event", "id", ev.ID) + break // Deleted events must not come back + } + slog.Debug("ℹ️ importing event", "kind", ev.Kind, "id", ev.ID, "relay", ev.Relay.URL) if isDuplicate(ctx, dbToPublish, ev.Event) { diff --git a/init.go b/init.go index 7f124b145..a74b32dcf 100644 --- a/init.go +++ b/init.go @@ -152,7 +152,10 @@ func initRelays(ctx context.Context) { if reject, msg := EventMustBeLatest(ctx, &event, privateDB); reject { return reject, msg } - return MustBeWhitelistedToPost(ctx, &event) + if reject, msg := MustBeWhitelistedToPost(ctx, &event); reject { + return reject, msg + } + return MustNotBeDeleted(privateDB)(ctx, &event) } privateRelay.RejectConnection = policies.ConnectionRateLimiter( @@ -164,6 +167,7 @@ func initRelays(ctx context.Context) { privateRelay.OnConnect = khatru.RequestAuth privateRelay.UseEventstore(privateDB, 1000) + privateRelay.AllowDeleting = OwnerCanDeleteAnyEvent SetupManagementAPI(privateRelay) mux := privateRelay.Router() @@ -230,7 +234,10 @@ func initRelays(ctx context.Context) { if reject, msg := MustBeInWotToPost(ctx, &event); reject { return reject, msg } - return EventMustBeChatRelated(ctx, &event) + if reject, msg := EventMustBeChatRelated(ctx, &event); reject { + return reject, msg + } + return MustNotBeDeleted(chatDB)(ctx, &event) } chatRelay.RejectConnection = policies.ConnectionRateLimiter( @@ -242,6 +249,7 @@ func initRelays(ctx context.Context) { chatRelay.OnConnect = khatru.RequestAuth chatRelay.UseEventstore(chatDB, 1000) + chatRelay.AllowDeleting = OwnerCanDeleteAnyEvent SetupManagementAPI(chatRelay) mux = chatRelay.Router() @@ -302,7 +310,10 @@ func initRelays(ctx context.Context) { if reject, msg := EventMustBeLatest(ctx, &event, outboxDB); reject { return reject, msg } - return MustBeWhitelistedToPost(ctx, &event) + if reject, msg := MustBeWhitelistedToPost(ctx, &event); reject { + return reject, msg + } + return MustNotBeDeleted(outboxDB)(ctx, &event) } outboxRelay.RejectConnection = policies.ConnectionRateLimiter( @@ -312,6 +323,7 @@ func initRelays(ctx context.Context) { ) outboxRelay.UseEventstore(outboxDB, 1000) + outboxRelay.AllowDeleting = OwnerCanDeleteAnyEvent outboxRelay.OnEventSaved = func(ctx context.Context, event nostr.Event) { go blast(ctx, &event) } @@ -420,7 +432,10 @@ func initRelays(ctx context.Context) { if reject, msg := MustBeInWotToPost(ctx, &event); reject { return reject, msg } - return MustTagWhitelistedPubKey(ctx, &event) + if reject, msg := MustTagWhitelistedPubKey(ctx, &event); reject { + return reject, msg + } + return MustNotBeDeleted(inboxDB)(ctx, &event) } inboxRelay.RejectConnection = policies.ConnectionRateLimiter( @@ -430,6 +445,7 @@ func initRelays(ctx context.Context) { ) inboxRelay.UseEventstore(inboxDB, 1000) + inboxRelay.AllowDeleting = OwnerCanDeleteAnyEvent SetupManagementAPI(inboxRelay) mux = inboxRelay.Router() diff --git a/policies.go b/policies.go index d7e88430e..568649c1c 100644 --- a/policies.go +++ b/policies.go @@ -60,7 +60,29 @@ func MustBeInWotToPost(ctx context.Context, event *nostr.Event) (bool, string) { return false, "" } +// isBanned reports whether a pubkey is banned. With the management API in +// place this becomes the union of the npubs file, the owner's kind 10084 +// list and whatever was banned over NIP-86; every rule that enforces a ban +// reads it through here. +func isBanned(pubKey string) bool { + _, ok := config.BlacklistedPubKeys[pubKey] + return ok +} + +// isOwnerDeleteRequest reports whether the event is a NIP-09 delete request from +// the relay owner. Signatures are verified before any policy runs, so the pubkey +// on the event is proof enough that the owner sent it. +func isOwnerDeleteRequest(event *nostr.Event) bool { + return event.Kind == nostr.KindDeletion && event.PubKey.Hex() == config.OwnerPubKey +} + func MustNotBeBlacklistedToPost(ctx context.Context, event *nostr.Event) (bool, string) { + // The owner's delete requests carry the owner's signature, so there is nothing + // left for an AUTH round trip to prove + if isOwnerDeleteRequest(event) { + return false, "" + } + // Events from a blacklisted pubkey ARE always rejected if _, ok := config.BlacklistedPubKeys[event.PubKey.Hex()]; ok { slog.Debug("🚫 event rejected: event author is blacklisted", "event", event.ID, "pubkey", event.PubKey) @@ -107,6 +129,11 @@ var allowedChatKinds = map[nostr.Kind]struct{}{ } func EventMustBeChatRelated(_ context.Context, event *nostr.Event) (bool, string) { + // the owner's delete requests are stored so the deletion survives a re-publish + if isOwnerDeleteRequest(event) { + return false, "" + } + if _, ok := allowedChatKinds[event.Kind]; ok { return false, "" } @@ -161,6 +188,11 @@ func OnlyGiftWrappedDMs(_ context.Context, event *nostr.Event) (bool, string) { } func MustTagWhitelistedPubKey(_ context.Context, event *nostr.Event) (bool, string) { + // the owner's delete requests are stored so the deletion survives a re-publish + if isOwnerDeleteRequest(event) { + return false, "" + } + // User must tag at least one whitelisted pubkey in this relay tags := event.Tags.FindAll("p") for tag := range tags { @@ -176,3 +208,15 @@ func MustTagWhitelistedPubKey(_ context.Context, event *nostr.Event) (bool, stri return true, "you can only post notes if you've tagged a whitelisted pubkey in this relay" } + +// MustNotBeDeleted rejects events that have already been deleted from db, so a +// re-publish can't resurrect what the owner or the author deleted. +func MustNotBeDeleted(db DBBackend) func(context.Context, *nostr.Event) (bool, string) { + return func(_ context.Context, event *nostr.Event) (bool, string) { + if isDeleted(db, *event) { + slog.Debug("🚫 event rejected: event has been deleted", "event", event.ID, "pubkey", event.PubKey) + return true, "this event has been deleted" + } + return false, "" + } +} From 3c7410c43298bd7b0422fa26c807ee4bfa82c6e9 Mon Sep 17 00:00:00 2001 From: deymosh Date: Wed, 30 Sep 2026 18:56:38 +0200 Subject: [PATCH 3/6] feat(admin): relay management console, notes browser and dashboard Port of upstream haven #135 onto the fiatjaf.com/nostr monorepo. Adds the NIP-86 management API on each of the four relay URIs, the /admin console that drives it, a read-only notes browser per relay, an analytics dashboard with persisted hourly history, blossom media management and the kind 10084 ban list. State lives in management.json and metrics.json. The NIP-86 dispatcher stays in haven rather than in khatru's RelayManagementAPI: nip86.DecodeRequest rejects unknown methods before the Generic hook runs, so the console's own methods could not be served, and khatru still answers auth failures with HTTP 200, skips the kind and method tag checks and reads the body unbounded. This replaces the earlier ManagementAPI hooks, whose ban, allow, stats and icon methods it covers. Adapted to the monorepo rather than copied: - policies compose into the single OnEvent/OnRequest/RejectConnection functions, and metrics wrap those hooks instead of appending to slices - the lmdb CountEvents infinite-loop workaround is gone; counts call db.CountEvents directly - events are walked through the iter.Seq QueryEvents with an explicit page size, deleted by id, and looked up with a full-id check because the store indexes only the first eight bytes - blob URLs use nipb7/blossom.GetExtension instead of a copied helper - deleteBlobs queries the x tag index directly instead of walking every index entry - the per-relay event rate limiter is built once per relay; built inside the hook it was a fresh bucket per event and never limited anything Tests load their settings from testdata/test.env (chosen by testing.Testing() in loadConfig), because config is loaded during package variable initialisation, before any test file can seed the environment. Co-Authored-By: Claude Code --- .env.example | 20 + .gitignore | 5 + README.md | 44 +- admin.go | 160 ++ banlist.go | 109 ++ blossom.go | 990 ++++++++++ blossomMigration.go | 70 +- blossom_test.go | 61 + config.go | 71 +- dashboard.go | 266 +++ dashboard_report.go | 236 +++ dashboard_test.go | 227 +++ docs/access-control.md | 52 +- docs/relay-management.md | 380 ++++ events.go | 643 +++++++ events_test.go | 331 ++++ eventscan.go | 232 +++ eventscan_test.go | 249 +++ import.go | 62 +- init.go | 651 +++---- limits.go | 42 +- main.go | 63 +- management.go | 556 +++--- metrics.go | 320 +++ metrics_state.go | 475 +++++ nip86.go | 855 +++++++++ nip86_events_http_test.go | 210 ++ nip86_methods.go | 491 +++++ nip98.go | 114 ++ pkg/wot/simple_in_memory.go | 18 +- policies.go | 96 +- templates/admin.html | 937 +++++++++ templates/static/admin-charts.js | 534 ++++++ templates/static/admin-dashboard.js | 475 +++++ templates/static/admin-notes.js | 1165 +++++++++++ templates/static/admin.js | 2774 +++++++++++++++++++++++++++ testdata/test.env | 23 + 37 files changed, 13281 insertions(+), 726 deletions(-) create mode 100644 admin.go create mode 100644 banlist.go create mode 100644 blossom.go create mode 100644 blossom_test.go create mode 100644 dashboard.go create mode 100644 dashboard_report.go create mode 100644 dashboard_test.go create mode 100644 docs/relay-management.md create mode 100644 events.go create mode 100644 events_test.go create mode 100644 eventscan.go create mode 100644 eventscan_test.go create mode 100644 metrics.go create mode 100644 metrics_state.go create mode 100644 nip86.go create mode 100644 nip86_events_http_test.go create mode 100644 nip86_methods.go create mode 100644 nip98.go create mode 100644 templates/admin.html create mode 100644 templates/static/admin-charts.js create mode 100644 templates/static/admin-dashboard.js create mode 100644 templates/static/admin-notes.js create mode 100644 templates/static/admin.js create mode 100644 testdata/test.env diff --git a/.env.example b/.env.example index 7d9b086d0..8fdac1d8c 100644 --- a/.env.example +++ b/.env.example @@ -103,6 +103,26 @@ WOT_REFRESH_INTERVAL="24h" WHITELISTED_NPUBS_FILE="whitelisted_npubs.json" BLACKLISTED_NPUBS_FILE="blacklisted_npubs.json" +## Relay Management API (NIP-86) and the /admin page +MANAGEMENT_API_ENABLED=true # set to false to make the endpoint and the admin page unreachable +# Where the relay stores what you change over the management API. Created on +# first use; do not edit it by hand while the relay is running. +MANAGEMENT_STATE_FILE="management.json" + +## Analytics, and the dashboard on the admin page +ANALYTICS_ENABLED=true # set to false to stop counting anything at all +# Where the counters are written so they survive a restart. Created on first use; +# do not edit it by hand while the relay is running. If it cannot be read at +# startup Haven keeps counting and keeps serving, but stops writing to it. +ANALYTICS_STATE_FILE="metrics.json" +ANALYTICS_FLUSH_MINUTES=5 # how often the file is written; a crash loses at most this much +ANALYTICS_HOURLY_RETENTION_DAYS=8 # full hourly resolution, for the 24h and 7d charts +ANALYTICS_DAILY_RETENTION_DAYS=90 # hours older than the above are rolled up into days +ANALYTICS_AGGREGATE_MINUTES=15 # how often every database is walked for the kind mix, + # top authors and stored totals +ANALYTICS_MAX_KINDS=64 # distinct kinds tracked before the rest become "other" +ANALYTICS_MAX_AUTHORS=100 # how many authors the top-authors list holds + ## LOGGING HAVEN_LOG_LEVEL="INFO" # DEBUG, INFO, WARNING or ERROR diff --git a/.gitignore b/.gitignore index 51a25c79c..a1da26a70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,15 @@ .DS_Store .env +.claude/ .idea/ db.zip dist/ blacklisted_npubs.json haven +management.json +management.json.tmp +metrics.json +metrics.json.tmp relays_blastr.json relays_import.json whitelisted_npubs.json diff --git a/README.md b/README.md index 9bf5ffac6..9082a9a0e 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,15 @@ relay, but anyone can view the images and videos. **Web of Trust**: Protected from DM and Inbox spam by using a Web of Trust (WoT). See the [Web of Trust Documentation](docs/wot.md) for more details. -**Access Control**: Whitelist and blacklist npubs. See the [Access Control Documentation](docs/access-control.md) -for more details. +**Access Control**: Whitelist and blacklist npubs, ban them from a nostr list you publish, and delete any event from +your relay. See the [Access Control Documentation](docs/access-control.md) for more details. + +**Relay Management**: Run the relay from a web page signed in with a nostr browser extension, or over the +NIP-86 API: ban pubkeys, drop events, block addresses and rename each relay. The same page also **browses the notes +each relay is storing** β€” filter, search, inspect and delete them β€” and graphs the relay on a **dashboard**: traffic, +connections, accepted against rejected, the kind mix, top authors and storage, with 24-hour, 7-day and 30-day +history that survives a restart. See the [Relay Management Documentation](docs/relay-management.md) for more +details. **Inbox Relay**: Notes are pulled from other relays and stored in the inbox relay. @@ -262,7 +269,7 @@ server { proxy_pass http://localhost:3355; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; @@ -273,11 +280,11 @@ server { Replace `yourdomain.com` with your actual domain name. -> **Note:** -> [`client_max_body_size`](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size) is set to -> `100m` to allow for larger media files to be uploaded to Blossom. `0` can be used to allow for unlimited file -> sizes. If you are using Cloudflare proxy, be mindful of -> [upload limits](https://community.cloudflare.com/t/maximum-upload-size-is-limit/418490/2). +> [!IMPORTANT] +> `X-Forwarded-For` is set to `$remote_addr`, not the more common `$proxy_add_x_forwarded_for`. The latter *appends* +> the real client address to whatever the client sent, which lets a client prepend a forged one β€” Haven reads the +> leftmost entry, so the forgery would win. This matters for the rate limiters and for +> [IP blocking](docs/relay-management.md#blocking-addresses). After adding the configuration, restart nginx: @@ -308,6 +315,10 @@ sudo systemctl restart nginx ProxyPass / http://localhost:3355/ ProxyPassReverse / http://localhost:3355/ + # Overwrite whatever the client sent, so a forged X-Forwarded-For + # cannot win: mod_proxy appends to the header rather than replacing it + RequestHeader set X-Forwarded-For "expr=%{REMOTE_ADDR}" + # Optional: Add HSTS header for enhanced security Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" @@ -431,6 +442,9 @@ Once everything is set up, the relay will be running on `localhost:3355` with th - `localhost:3355/private` - `localhost:3355/chat` - `localhost:3355/inbox` +- `localhost:3355/admin` (relay management, notes browser and dashboard, signed in with a nostr browser extension) + +See [Relay Management](docs/relay-management.md) for the admin page and the NIP-86 API behind it. ## Database @@ -482,6 +496,20 @@ but anyone can view the hosted images and videos. Media files are stored in the file system based on the `BLOSSOM_PATH` environment variable set in the `.env` file. The default path is `./blossom`. +> [!IMPORTANT] +> Give `BLOSSOM_PATH` a trailing slash, as `.env.example` does. The path is joined to the file name by +> concatenation, so `BLOSSOM_PATH="blossom"` writes media *beside* the `blossom` directory rather than inside it, +> as `./blossom`. Haven warns at startup when it sees this. It is not corrected automatically, because doing +> so would hide every file already written that way. + +Browse and manage what has been uploaded from the **Media** view of the [admin page](docs/relay-management.md#media): +filter, search and sort the whole library, delete media, block a hash so the same bytes cannot be uploaded again, +and reconcile the index against what is actually on disk. + +> [!WARNING] +> Cloud backups cover the media **index**, not the media **files**. Back up your `BLOSSOM_PATH` directory +> separately. Restoring a backup without it leaves every entry pointing at a file that is no longer there. + ## Cloud Backups Haven can back up and restore your notes using a portable JSONL format. This can be done either with the built-in diff --git a/admin.go b/admin.go new file mode 100644 index 000000000..f78de11d8 --- /dev/null +++ b/admin.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + "html/template" + "log/slog" + "net/http" + "os" + "strconv" + "sync" +) + +// adminRelay is one entry in the admin page's relay switcher. Path is the +// same-origin path the browser POSTs to; ServiceURL is the absolute URL the +// relay expects in the NIP-98 u tag. They are deliberately different, and the +// page must sign ServiceURL rather than anything derived from window.location: +// an owner reaching the relay through an SSH tunnel is browsing +// http://localhost:3355 while ServiceURL still says https://relay.example.com. +type adminRelay struct { + Key string + Label string + Path string + ServiceURL string +} + +type adminPageData struct { + OwnerPubKey string + OwnerNpub string + RelayHost string + RelayVersion string + Relays []adminRelay + Scripts []adminScript +} + +// adminScript is one of the page's classic scripts, with the cache buster for +// the file currently on disk. +type adminScript struct { + Src string + Version string +} + +// adminScripts lists the page's scripts in load order. +// +// They are classic scripts sharing one global scope, not modules, so two of them +// declaring the same top level const would throw and the second would not execute +// at all. Each file therefore prefixes its own bindings, and the order here is +// only about readability β€” boot() waits for DOMContentLoaded, by which point +// every deferred script has run, so nothing can execute out of order. +func adminScripts() []adminScript { + paths := []string{ + "/static/admin-charts.js", + "/static/admin.js", + "/static/admin-notes.js", + "/static/admin-dashboard.js", + } + out := make([]adminScript, 0, len(paths)) + for _, p := range paths { + out = append(out, adminScript{Src: p, Version: assetVersion("templates" + p)}) + } + return out +} + +// adminCSP limits what a compromised third party script could do with the +// window.nostr handle this page holds: connect-src 'self' means it cannot send +// anything it signs anywhere, and frame-ancestors blocks clickjacking, which +// matters on a page whose buttons sit one habitual signer approval away from +// taking effect. +// +// media-src is listed because default-src 'none' otherwise blocks every