diff --git a/app/main.go b/app/main.go
index ee6caca..50651d8 100644
--- a/app/main.go
+++ b/app/main.go
@@ -45,6 +45,8 @@ func Run(
publishers: publish.New(NewPublisher(ctx)),
Admins: adminKeys,
}
+ // Initialize the user interface
+ l.UserInterface()
addr := fmt.Sprintf("%s:%d", cfg.Listen, cfg.Port)
log.I.F("starting listener on http://%s", addr)
go func() {
diff --git a/app/server.go b/app/server.go
index faec112..e71f789 100644
--- a/app/server.go
+++ b/app/server.go
@@ -2,13 +2,19 @@ package app
import (
"context"
+ "encoding/json"
+ "io"
"net/http"
"strconv"
"strings"
+ "sync"
"lol.mleku.dev/chk"
"next.orly.dev/app/config"
"next.orly.dev/pkg/database"
+ "next.orly.dev/pkg/encoders/event"
+ "next.orly.dev/pkg/encoders/hex"
+ "next.orly.dev/pkg/protocol/auth"
"next.orly.dev/pkg/protocol/publish"
)
@@ -20,6 +26,10 @@ type Server struct {
publishers *publish.S
Admins [][]byte
*database.D
+
+ // Challenge storage for HTTP UI authentication
+ challengeMutex sync.RWMutex
+ challenges map[string][]byte
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -70,3 +80,288 @@ func (s *Server) ServiceURL(req *http.Request) (st string) {
}
return proto + "://" + host
}
+
+// UserInterface sets up a basic Nostr NDK interface that allows users to log into the relay user interface
+func (s *Server) UserInterface() {
+ if s.mux == nil {
+ s.mux = http.NewServeMux()
+ }
+
+ // Initialize challenge storage if not already done
+ if s.challenges == nil {
+ s.challengeMutex.Lock()
+ s.challenges = make(map[string][]byte)
+ s.challengeMutex.Unlock()
+ }
+
+ // Serve the main login interface
+ s.mux.HandleFunc("/", s.handleLoginInterface)
+
+ // API endpoints for authentication
+ s.mux.HandleFunc("/api/auth/challenge", s.handleAuthChallenge)
+ s.mux.HandleFunc("/api/auth/login", s.handleAuthLogin)
+ s.mux.HandleFunc("/api/auth/status", s.handleAuthStatus)
+}
+
+// handleLoginInterface serves the main user interface for login
+func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ html := `
+
+
+ Nostr Relay Login
+
+
+
+
+
+
+
Nostr Relay Authentication
+
Connect to this Nostr relay using your private key or browser extension.
+
+
+ Ready to authenticate
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(html))
+}
+
+// handleAuthChallenge generates and returns an authentication challenge
+func (s *Server) handleAuthChallenge(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Generate a proper challenge using the auth package
+ challenge := auth.GenerateChallenge()
+ challengeHex := hex.Enc(challenge)
+
+ // Store the challenge using the hex value as the key for easy lookup
+ s.challengeMutex.Lock()
+ s.challenges[challengeHex] = challenge
+ s.challengeMutex.Unlock()
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"challenge": "` + challengeHex + `"}`))
+}
+
+// handleAuthLogin processes authentication requests
+func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+
+ // Read the request body
+ body, err := io.ReadAll(r.Body)
+ if chk.E(err) {
+ w.Write([]byte(`{"success": false, "error": "Failed to read request body"}`))
+ return
+ }
+
+ // Parse the signed event
+ var evt event.E
+ if err = json.Unmarshal(body, &evt); chk.E(err) {
+ w.Write([]byte(`{"success": false, "error": "Invalid event format"}`))
+ return
+ }
+
+ // Extract the challenge from the event to look up the stored challenge
+ challengeTag := evt.Tags.GetFirst([]byte("challenge"))
+ if challengeTag == nil {
+ w.Write([]byte(`{"success": false, "error": "Challenge tag missing from event"}`))
+ return
+ }
+
+ challengeHex := string(challengeTag.Value())
+
+ // Retrieve the stored challenge
+ s.challengeMutex.RLock()
+ _, exists := s.challenges[challengeHex]
+ s.challengeMutex.RUnlock()
+
+ if !exists {
+ w.Write([]byte(`{"success": false, "error": "Invalid or expired challenge"}`))
+ return
+ }
+
+ // Clean up the used challenge
+ s.challengeMutex.Lock()
+ delete(s.challenges, challengeHex)
+ s.challengeMutex.Unlock()
+
+ relayURL := s.ServiceURL(r)
+
+ // Validate the authentication event with the correct challenge
+ // The challenge in the event tag is hex-encoded, so we need to pass the hex string as bytes
+ ok, err := auth.Validate(&evt, []byte(challengeHex), relayURL)
+ if chk.E(err) || !ok {
+ errorMsg := "Authentication validation failed"
+ if err != nil {
+ errorMsg = err.Error()
+ }
+ w.Write([]byte(`{"success": false, "error": "` + errorMsg + `"}`))
+ return
+ }
+
+ // Authentication successful
+ w.Write([]byte(`{"success": true, "pubkey": "` + hex.Enc(evt.Pubkey) + `", "message": "Authentication successful"}`))
+}
+
+// handleAuthStatus returns the current authentication status
+func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"authenticated": false}`))
+}