diff --git a/app/config/config.go b/app/config/config.go index 23a2194..6471a52 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -42,6 +42,10 @@ type C struct { ACLMode string `env:"ORLY_ACL_MODE" usage:"ACL mode: follows,none" default:"none"` SpiderMode string `env:"ORLY_SPIDER_MODE" usage:"spider mode: none,follow" default:"none"` SpiderFrequency time.Duration `env:"ORLY_SPIDER_FREQUENCY" usage:"spider frequency in seconds" default:"1h"` + + // Web UI and dev mode settings + WebDisableEmbedded bool `env:"ORLY_WEB_DISABLE" default:"false" usage:"disable serving the embedded web UI; useful for hot-reload during development"` + WebDevProxyURL string `env:"ORLY_WEB_DEV_PROXY_URL" usage:"when ORLY_WEB_DISABLE is true, reverse-proxy non-API paths to this dev server URL (e.g. http://localhost:5173)"` } // New creates and initializes a new configuration object for the relay diff --git a/app/server.go b/app/server.go index c194fa0..5014f21 100644 --- a/app/server.go +++ b/app/server.go @@ -4,7 +4,10 @@ import ( "context" "encoding/json" "io" + "log" "net/http" + "net/http/httputil" + "net/url" "strconv" "strings" "sync" @@ -27,7 +30,10 @@ type Server struct { publishers *publish.S Admins [][]byte *database.D - + + // optional reverse proxy for dev web server + devProxy *httputil.ReverseProxy + // Challenge storage for HTTP UI authentication challengeMutex sync.RWMutex challenges map[string][]byte @@ -37,31 +43,40 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Set CORS headers for all responses w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - + w.Header().Set( + "Access-Control-Allow-Headers", "Content-Type, Authorization", + ) + // Handle preflight OPTIONS requests if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } - - // log.T.C( - // func() string { - // return fmt.Sprintf("path %v header %v", r.URL, r.Header) - // }, - // ) + + // If this is a websocket request, only intercept the relay root path. + // This allows other websocket paths (e.g., Vite HMR) to be handled by the dev proxy when enabled. if r.Header.Get("Upgrade") == "websocket" { - s.HandleWebsocket(w, r) - } else if r.Header.Get("Accept") == "application/nostr+json" { - s.HandleRelayInfo(w, r) - } else { - if s.mux == nil { - http.Error(w, "Upgrade required", http.StatusUpgradeRequired) - } else { + if s.mux != nil && s.Config != nil && s.Config.WebDisableEmbedded && s.Config.WebDevProxyURL != "" && r.URL.Path != "/" { + // forward to mux (which will proxy to dev server) s.mux.ServeHTTP(w, r) + return } + s.HandleWebsocket(w, r) + return } + + if r.Header.Get("Accept") == "application/nostr+json" { + s.HandleRelayInfo(w, r) + return + } + + if s.mux == nil { + http.Error(w, "Upgrade required", http.StatusUpgradeRequired) + return + } + s.mux.ServeHTTP(w, r) } + func (s *Server) ServiceURL(req *http.Request) (st string) { host := req.Header.Get("X-Forwarded-Host") if host == "" { @@ -98,17 +113,43 @@ func (s *Server) UserInterface() { if s.mux == nil { s.mux = http.NewServeMux() } - + + // If dev proxy is configured, initialize it + if s.Config != nil && s.Config.WebDisableEmbedded && s.Config.WebDevProxyURL != "" { + proxyURL := s.Config.WebDevProxyURL + // Add default scheme if missing to avoid: proxy error: unsupported protocol scheme "" + if !strings.Contains(proxyURL, "://") { + proxyURL = "http://" + proxyURL + } + if target, err := url.Parse(proxyURL); !chk.E(err) { + if target.Scheme == "" || target.Host == "" { + // invalid URL, disable proxy + log.Printf( + "invalid ORLY_WEB_DEV_PROXY_URL: %q — disabling dev proxy\n", + s.Config.WebDevProxyURL, + ) + } else { + s.devProxy = httputil.NewSingleHostReverseProxy(target) + // Ensure Host header points to upstream for dev servers that care + origDirector := s.devProxy.Director + s.devProxy.Director = func(req *http.Request) { + origDirector(req) + req.Host = target.Host + } + } + } + } + // 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 + + // Serve the main login interface (and static assets) or proxy in dev mode 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) @@ -119,10 +160,20 @@ func (s *Server) UserInterface() { // handleLoginInterface serves the main user interface for login func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) { - // Create a file server handler for the embedded React app + // In dev mode with proxy configured, forward to dev server + if s.Config != nil && s.Config.WebDisableEmbedded && s.devProxy != nil { + s.devProxy.ServeHTTP(w, r) + return + } + // If embedded UI is disabled but no proxy configured, return a helpful message + if s.Config != nil && s.Config.WebDisableEmbedded { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("Web UI disabled (ORLY_WEB_DISABLE=true). Run the web app in standalone dev mode (e.g., npm run dev) or set ORLY_WEB_DEV_PROXY_URL to proxy through this server.")) + return + } + // Default: serve embedded React app fileServer := http.FileServer(GetReactAppFS()) - - // Serve the React app files fileServer.ServeHTTP(w, r) } @@ -132,16 +183,16 @@ func (s *Server) handleAuthChallenge(w http.ResponseWriter, r *http.Request) { 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 + `"}`)) } @@ -152,49 +203,49 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { 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) @@ -206,7 +257,7 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"success": false, "error": "` + errorMsg + `"}`)) return } - + // Authentication successful: set a simple session cookie with the pubkey cookie := &http.Cookie{ Name: "orly_auth", @@ -246,14 +297,16 @@ func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { return } // Expire the cookie - http.SetCookie(w, &http.Cookie{ - Name: "orly_auth", - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }) + http.SetCookie( + w, &http.Cookie{ + Name: "orly_auth", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }, + ) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"success": true}`)) } @@ -264,40 +317,42 @@ func (s *Server) handlePermissions(w http.ResponseWriter, r *http.Request) { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - + // Extract pubkey from URL path pubkeyHex := strings.TrimPrefix(r.URL.Path, "/api/permissions/") if pubkeyHex == "" || pubkeyHex == "/" { http.Error(w, "Invalid pubkey", http.StatusBadRequest) return } - + // Convert hex to binary pubkey pubkey, err := hex.Dec(pubkeyHex) if chk.E(err) { http.Error(w, "Invalid pubkey format", http.StatusBadRequest) return } - + // Get access level using acl registry permission := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr) - + // Set content type and write JSON response w.Header().Set("Content-Type", "application/json") - + // Format response as proper JSON response := struct { Permission string `json:"permission"` }{ Permission: permission, } - + // Marshal and write the response jsonData, err := json.Marshal(response) if chk.E(err) { - http.Error(w, "Error generating response", http.StatusInternalServerError) + http.Error( + w, "Error generating response", http.StatusInternalServerError, + ) return } - + w.Write(jsonData) } diff --git a/app/web/README.md b/app/web/README.md index 65c5e35..6226e13 100644 --- a/app/web/README.md +++ b/app/web/README.md @@ -9,12 +9,41 @@ This is a React web application that uses Bun for building and bundling, and is ## Development -To run the development server: +There are two ways to develop the web app: + +1) Standalone (recommended for hot reload) +- Start the Go relay with the embedded web UI disabled so the React app can run on its own dev server with HMR. +- Configure the relay via environment variables: + +```bash +# In another shell at repo root +export ORLY_WEB_DISABLE=true +# Optional: if you want same-origin URLs, you can set a proxy target and access the relay on the same port +# export ORLY_WEB_DEV_PROXY_URL=http://localhost:5173 + +# Start the relay as usual +go run . +``` + +- Then start the React dev server: ```bash cd app/web bun install -bun run dev +bun dev +``` + +When ORLY_WEB_DISABLE=true is set, the Go server still serves the API and websocket endpoints and sends permissive CORS headers, so the dev server can access them cross-origin. If ORLY_WEB_DEV_PROXY_URL is set, the Go server will reverse-proxy non-/api paths to the dev server so you can use the same origin. + +2) Embedded (no hot reload) +- Build the web app and run the Go server with defaults: + +```bash +cd app/web +bun install +bun run build +cd ../../ +go run . ``` ## Building diff --git a/app/web/package.json b/app/web/package.json index 050b32e..ff06943 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "bun run --hot src/index.jsx", - "build": "bun build ./src/index.jsx --outdir ./dist --minify && mkdir -p dist && cp -r public/* dist/", - "start": "bun run dist/index.js" + "dev": "bun --hot --port 5173 public/dev.html", + "build": "rm -rf dist && bun build ./public/index.html --outdir ./dist --minify --splitting && cp -r public/tailwind.min.css dist/", + "preview": "bun x serve dist" }, "dependencies": { "react": "^18.2.0", diff --git a/app/web/public/dev.html b/app/web/public/dev.html new file mode 100644 index 0000000..7d08952 --- /dev/null +++ b/app/web/public/dev.html @@ -0,0 +1,13 @@ + + + + + + Nostr Relay (Dev) + + + +
+ + + diff --git a/app/web/src/App.jsx b/app/web/src/App.jsx index 073f1ed..888d7be 100644 --- a/app/web/src/App.jsx +++ b/app/web/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; function App() { const [user, setUser] = useState(null); @@ -8,6 +8,23 @@ function App() { const [checkingAuth, setCheckingAuth] = useState(true); + // Login view layout measurements + const titleRef = useRef(null); + const [loginPadding, setLoginPadding] = useState(16); // default fallback padding in px + + useEffect(() => { + function updatePadding() { + if (titleRef.current) { + const h = titleRef.current.offsetHeight || 0; + // Pad area around the text by half the title text height + setLoginPadding(Math.max(0, Math.round(h / 2))); + } + } + updatePadding(); + window.addEventListener('resize', updatePadding); + return () => window.removeEventListener('resize', updatePadding); + }, []); + useEffect(() => { // Check authentication status on page load (async () => { @@ -357,22 +374,35 @@ function App() { ) : ( // Not logged in view - shows the login form -
-

Nostr Relay Authentication

-

Connect to this Nostr relay using your private key or browser extension.

+
+
+
+ Orly logo { + // fallback to repo docs image if public asset missing + e.currentTarget.onerror = null; + e.currentTarget.src = "/docs/orly.png"; + }} + /> +

ORLY🦉 Dashboard Login

+
-
- {status} -
+

Connect to this Nostr relay using your browser extension.

-
- -
+
+ {status} +
-
- - - +
+ +
)} diff --git a/app/web/src/styles.css b/app/web/src/styles.css index c7b584a..3ae12ad 100644 --- a/app/web/src/styles.css +++ b/app/web/src/styles.css @@ -1,8 +1,7 @@ body { font-family: Arial, sans-serif; - max-width: 800px; - margin: 0 auto; - padding: 20px; + margin: 0; + padding: 0; } .container {