Enable dev mode for React app with proxy support; refine build, styles, and UI.
- Adjusted `package.json` scripts for Bun dev server and build flow. - Added `dev.html` for standalone web development with hot-reload enabled. - Introduced `WebDisableEmbedded` and `WebDevProxyURL` configurations to support proxying non-API paths. - Refactored server logic to handle reverse proxy for development mode. - Updated `App.jsx` structure, styles, and layout for responsiveness and dynamic padding. - Improved login interface with logo support and cleaner design. - Enhanced development flow documentation in `README.md`.
This commit is contained in:
161
app/server.go
161
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user