Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ RELAY_BIND_ADDRESS="0.0.0.0" # Can be set to a specific IP4 or IP6 address ("" f
DB_ENGINE="badger" # badger, lmdb (lmdb works best with an nvme, otherwise you might have stability issues)
LMDB_MAPSIZE=0 # 0 for default (currently ~273GB), or set to a different size in bytes, e.g. 10737418240 for 10GB
BLOSSOM_PATH="blossom/"
NIP05_CONFIG_FILE="nostr.json"

## Private Relay Settings
PRIVATE_RELAY_NAME="utxo's private relay"
Expand Down
110 changes: 64 additions & 46 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"github.com/joho/godotenv"
"github.com/nbd-wtf/go-nostr/nip05"
"github.com/nbd-wtf/go-nostr/nip19"
)

Expand All @@ -23,50 +24,52 @@ type S3Config struct {
}

type Config struct {
OwnerNpub string `json:"owner_npub"`
OwnerPubKey string `json:"owner_pubkey"`
DBEngine string `json:"db_engine"`
LmdbMapSize int64 `json:"lmdb_map_size"`
BlossomPath string `json:"blossom_path"`
RelayURL string `json:"relay_url"`
RelayPort int `json:"relay_port"`
RelayBindAddress string `json:"relay_bind_address"`
RelaySoftware string `json:"relay_software"`
RelayVersion string `json:"relay_version"`
UserAgent string `json:"user_agent"`
PrivateRelayName string `json:"private_relay_name"`
PrivateRelayNpub string `json:"private_relay_npub"`
PrivateRelayDescription string `json:"private_relay_description"`
PrivateRelayIcon string `json:"private_relay_icon"`
ChatRelayName string `json:"chat_relay_name"`
ChatRelayNpub string `json:"chat_relay_npub"`
ChatRelayDescription string `json:"chat_relay_description"`
ChatRelayIcon string `json:"chat_relay_icon"`
OutboxRelayName string `json:"outbox_relay_name"`
OutboxRelayNpub string `json:"outbox_relay_npub"`
OutboxRelayDescription string `json:"outbox_relay_description"`
OutboxRelayIcon string `json:"outbox_relay_icon"`
InboxRelayName string `json:"inbox_relay_name"`
InboxRelayNpub string `json:"inbox_relay_npub"`
InboxRelayDescription string `json:"inbox_relay_description"`
InboxRelayIcon string `json:"inbox_relay_icon"`
InboxPullIntervalSeconds int `json:"inbox_pull_interval_seconds"`
ImportStartDate string `json:"import_start_date"`
ImportOwnerNotesFetchTimeoutSeconds int `json:"import_owned_notes_fetch_timeout_seconds"`
ImportTaggedNotesFetchTimeoutSeconds int `json:"import_tagged_fetch_timeout_seconds"`
ImportSeedRelays []string `json:"import_seed_relays"`
BackupProvider string `json:"backup_provider"`
BackupIntervalHours int `json:"backup_interval_hours"`
WotDepth int `json:"wot_depth"`
WotMinimumFollowers int `json:"wot_minimum_followers"`
WotFetchTimeoutSeconds int `json:"wot_fetch_timeout_seconds"`
WotRefreshInterval time.Duration `json:"wot_refresh_interval"`
WhitelistedPubKeys map[string]struct{} `json:"whitelisted_pubkeys"`
BlacklistedPubKeys map[string]struct{} `json:"blacklisted_pubkeys"`
LogLevel string `json:"log_level"`
BlastrRelays []string `json:"blastr_relays"`
BlastrTimeoutSeconds int `json:"blastr_timeout_seconds"`
S3Config *S3Config `json:"s3_config"`
OwnerNpub string `json:"owner_npub"`
OwnerPubKey string `json:"owner_pubkey"`
DBEngine string `json:"db_engine"`
LmdbMapSize int64 `json:"lmdb_map_size"`
BlossomPath string `json:"blossom_path"`
RelayURL string `json:"relay_url"`
RelayPort int `json:"relay_port"`
RelayBindAddress string `json:"relay_bind_address"`
RelaySoftware string `json:"relay_software"`
RelayVersion string `json:"relay_version"`
UserAgent string `json:"user_agent"`
PrivateRelayName string `json:"private_relay_name"`
PrivateRelayNpub string `json:"private_relay_npub"`
PrivateRelayDescription string `json:"private_relay_description"`
PrivateRelayIcon string `json:"private_relay_icon"`
ChatRelayName string `json:"chat_relay_name"`
ChatRelayNpub string `json:"chat_relay_npub"`
ChatRelayDescription string `json:"chat_relay_description"`
ChatRelayIcon string `json:"chat_relay_icon"`
OutboxRelayName string `json:"outbox_relay_name"`
OutboxRelayNpub string `json:"outbox_relay_npub"`
OutboxRelayDescription string `json:"outbox_relay_description"`
OutboxRelayIcon string `json:"outbox_relay_icon"`
InboxRelayName string `json:"inbox_relay_name"`
InboxRelayNpub string `json:"inbox_relay_npub"`
InboxRelayDescription string `json:"inbox_relay_description"`
InboxRelayIcon string `json:"inbox_relay_icon"`
InboxPullIntervalSeconds int `json:"inbox_pull_interval_seconds"`
ImportStartDate string `json:"import_start_date"`
ImportOwnerNotesFetchTimeoutSeconds int `json:"import_owned_notes_fetch_timeout_seconds"`
ImportTaggedNotesFetchTimeoutSeconds int `json:"import_tagged_fetch_timeout_seconds"`
ImportSeedRelays []string `json:"import_seed_relays"`
BackupProvider string `json:"backup_provider"`
BackupIntervalHours int `json:"backup_interval_hours"`
WotDepth int `json:"wot_depth"`
WotMinimumFollowers int `json:"wot_minimum_followers"`
WotFetchTimeoutSeconds int `json:"wot_fetch_timeout_seconds"`
WotRefreshInterval time.Duration `json:"wot_refresh_interval"`
WhitelistedPubKeys map[string]struct{} `json:"whitelisted_pubkeys"`
BlacklistedPubKeys map[string]struct{} `json:"blacklisted_pubkeys"`
NIP05ConfigFile string `json:"nip05_config_file"`
NIP05 *nip05.WellKnownResponse `json:"nip05_config"`
LogLevel string `json:"log_level"`
BlastrRelays []string `json:"blastr_relays"`
BlastrTimeoutSeconds int `json:"blastr_timeout_seconds"`
S3Config *S3Config `json:"s3_config"`
}

const relaySoftware = "https://github.com/bitvora/haven"
Expand Down Expand Up @@ -113,6 +116,7 @@ func loadConfig() Config {
WotMinimumFollowers: getEnvInt("WOT_MINIMUM_FOLLOWERS", 0),
WotFetchTimeoutSeconds: getEnvInt("WOT_FETCH_TIMEOUT_SECONDS", 30),
WotRefreshInterval: getEnvDuration("WOT_REFRESH_INTERVAL", 24*time.Hour),
NIP05ConfigFile: getEnvString("NIP05_CONFIG_FILE", "nostr.json"),
WhitelistedPubKeys: getNpubsFromFile(getEnvString("WHITELISTED_NPUBS_FILE", "")),
BlacklistedPubKeys: getNpubsFromFile(getEnvString("BLACKLISTED_NPUBS_FILE", "")),
LogLevel: getEnvString("HAVEN_LOG_LEVEL", "INFO"),
Expand All @@ -124,8 +128,17 @@ func loadConfig() Config {
// Relay owner is always whitelisted
cfg.WhitelistedPubKeys[cfg.OwnerPubKey] = struct{}{}

return cfg
// Read, parse, and validate NIP-05 config
cfg.NIP05 = getNIP05FromFile(cfg.NIP05ConfigFile)

// Ensure that all hosted NIP-05 identifiers are whitelisted
if cfg.NIP05 != nil && cfg.NIP05.Names != nil {
for _, pubkey := range cfg.NIP05.Names {
cfg.WhitelistedPubKeys[pubkey] = struct{}{}
}
}

return cfg
}

func getVersion() string {
Expand Down Expand Up @@ -153,12 +166,17 @@ func getS3Config() *S3Config {
}

func getRelayListFromFile(filePath string) []string {
var relayList []string
if filePath == "" {
// No pubKeys file, only owner will be whitelisted"
return relayList
}

file, err := os.ReadFile(filePath)
if err != nil {
log.Fatalf("Failed to read file: %s", err)
}

var relayList []string
if err := json.Unmarshal(file, &relayList); err != nil {
log.Fatalf("Failed to parse JSON: %s", err)
}
Expand Down
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ func main() {
}()

http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("templates/static"))))
http.HandleFunc("/.well-known/nostr.json", nip05Handler(config.NIP05))
http.HandleFunc("/", dynamicRelayHandler)

addr := fmt.Sprintf("%s:%d", config.RelayBindAddress, config.RelayPort)
Expand Down
198 changes: 198 additions & 0 deletions nip05.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package main

import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"regexp"
"strings"

"github.com/nbd-wtf/go-nostr/nip05"
)

const MaxNIP05IdentifierLength = 253

// NIP-05 local-part regex
var localPartRegex = regexp.MustCompile("^[a-zA-Z0-9-_.]+$")

func nip05Handler(cfg *nip05.WellKnownResponse) func(w http.ResponseWriter, r *http.Request) {
if cfg == nil || cfg.Names == nil || len(cfg.Names) == 0 {
slog.Info("⚠️ NIP-05 handler disabled: no NIP-05 config found with valid identifiers")
return http.NotFoundHandler().ServeHTTP
}

slog.Info("✅ NIP-05 handler enabled")
return func(w http.ResponseWriter, r *http.Request) {
// Extract and normalize the 'name' parameter
name := strings.ToLower(r.URL.Query().Get("name"))

var response nip05.WellKnownResponse

// optional: filter response by name
if name != "" {
// validate name parameter
if err := validateName(name); err != nil {
http.Error(w, "invalid name", http.StatusBadRequest)
slog.Error("🚫 provided NIP-05 name invalid", "name", name, "error", err)
return
}

pubkey, ok := cfg.Names[name]
if !ok {
// return 404 if name not found
w.WriteHeader(http.StatusNotFound)
slog.Error("🚫 provided NIP-05 name not found", "name", name)
return
}

// filter Names by name
response.Names = map[string]string{name: pubkey}
if cfg.Relays[pubkey] != nil {
// filter Relays by name
response.Relays = map[string][]string{
pubkey: cfg.Relays[pubkey],
}
}

response.NIP46 = cfg.NIP46
} else {
// fill response with the entire NIP-05 config
response = *cfg
}

// Set required headers for CORS
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "application/json")

// Encode response
err := json.NewEncoder(w).Encode(response)
if err != nil {
slog.Error("🚫 failed to write NIP-05 response", "name", name, "error", err)
return
}

slog.Debug("NIP-05 fetched", "name", name)
}
}

func getNIP05FromFile(filePath string) *nip05.WellKnownResponse {
// open the NIP-05 config file (default: nostr.json)
nip05ConfigFile, err := os.Open(filePath)
if err != nil {
slog.Error("🚫 failed to open NIP-05 config file:", "error", err)
return nil
}
defer nip05ConfigFile.Close()

// Parse and validate NIP-05 config file
nip05Config, err := parseAndValidateNIP05(nip05ConfigFile)
if err != nil {
slog.Error("🚫 failed to parse and validate NIP-05 config file", "error", err)
return nil
}

return nip05Config
}

// parseAndValidateNIP05 reads JSON from an io.Reader and validates NIP-05 data
func parseAndValidateNIP05(r io.Reader) (*nip05.WellKnownResponse, error) {
var resp nip05.WellKnownResponse

// Decode the JSON
decoder := json.NewDecoder(r)
if err := decoder.Decode(&resp); err != nil {
return nil, fmt.Errorf("failed to decode JSON: %w", err)
}

// Validate 'names' map exists
if resp.Names == nil || len(resp.Names) == 0 {
return nil, errors.New("invalid NIP-05: 'names' map is missing or empty")
}

// Validate 'names' public keys
for name, pubkey := range resp.Names {
if err := validateName(name); err != nil {
return nil, fmt.Errorf("invalid name '%s': %w", name, err)
}
if err := validatePubkey(pubkey); err != nil {
return nil, fmt.Errorf("invalid public key for name '%s': %w", name, err)
}
}

// Validate 'relays' map (optional)
if resp.Relays != nil {
for pubkey, relays := range resp.Relays {
if err := validatePubkey(pubkey); err != nil {
return nil, fmt.Errorf("invalid public key in 'relays' mapping: %w", err)
}
if err := validateRelayURLs(relays); err != nil {
return nil, fmt.Errorf("invalid relay URL for pubkey '%s': %w", pubkey, err)
}
}
}

// Validate 'nip46' map (optional)
if resp.NIP46 != nil {
for pubkey, relays := range resp.NIP46 {
if err := validatePubkey(pubkey); err != nil {
return nil, fmt.Errorf("invalid public key in 'nip46' mapping: %w", err)
}
if err := validateRelayURLs(relays); err != nil {
return nil, fmt.Errorf("invalid nip46 relay URL for pubkey '%s': %w", pubkey, err)
}
}
}

return &resp, nil
}

// validateName validates a NIP-05 name
func validateName(name string) error {
if !localPartRegex.MatchString(name) {
return errors.New("name contains invalid characters")
}

if len(fmt.Sprintf("")) > MaxNIP05IdentifierLength {
return errors.New("name too long")
}

return nil
}

// validatePubkey ensures the pubkey is a 64-character hex string
func validatePubkey(pubkey string) error {
if len(pubkey) != 64 {
return fmt.Errorf("must be exactly 64 characters, got %d", len(pubkey))
}
_, err := hex.DecodeString(pubkey)
if err != nil {
return errors.New("must contain only valid hex characters")
}
return nil
}

// validateRelayURLs ensures URLs are valid and use websocket protocols
func validateRelayURLs(urls []string) error {
for _, rawURL := range urls {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("failed to parse URL '%s': %w", rawURL, err)
}

scheme := strings.ToLower(parsedURL.Scheme)
if scheme != "ws" && scheme != "wss" {
return fmt.Errorf("URL '%s' must start with ws:// or wss://", rawURL)
}

if parsedURL.Host == "" {
return fmt.Errorf("URL '%s' is missing a host", rawURL)
}
}
return nil
}
5 changes: 5 additions & 0 deletions nostr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"names": {},
"relays": {},
"nip46": {}
}