@ -4,7 +4,10 @@ import (
"context"
"context"
"encoding/json"
"encoding/json"
"io"
"io"
"log"
"net/http"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strconv"
"strings"
"strings"
"sync"
"sync"
@ -27,7 +30,10 @@ type Server struct {
publishers * publish . S
publishers * publish . S
Admins [ ] [ ] byte
Admins [ ] [ ] byte
* database . D
* database . D
// optional reverse proxy for dev web server
devProxy * httputil . ReverseProxy
// Challenge storage for HTTP UI authentication
// Challenge storage for HTTP UI authentication
challengeMutex sync . RWMutex
challengeMutex sync . RWMutex
challenges map [ string ] [ ] byte
challenges map [ string ] [ ] byte
@ -37,31 +43,40 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Set CORS headers for all responses
// Set CORS headers for all responses
w . Header ( ) . Set ( "Access-Control-Allow-Origin" , "*" )
w . Header ( ) . Set ( "Access-Control-Allow-Origin" , "*" )
w . Header ( ) . Set ( "Access-Control-Allow-Methods" , "GET, POST, OPTIONS" )
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
// Handle preflight OPTIONS requests
if r . Method == "OPTIONS" {
if r . Method == "OPTIONS" {
w . WriteHeader ( http . StatusOK )
w . WriteHeader ( http . StatusOK )
return
return
}
}
// log.T.C(
// If this is a websocket request, only intercept the relay root path.
// func() string {
// This allows other websocket paths (e.g., Vite HMR) to be handled by the dev proxy when enabled.
// return fmt.Sprintf("path %v header %v", r.URL, r.Header)
// },
// )
if r . Header . Get ( "Upgrade" ) == "websocket" {
if r . Header . Get ( "Upgrade" ) == "websocket" {
s . HandleWebsocket ( w , r )
if s . mux != nil && s . Config != nil && s . Config . WebDisableEmbedded && s . Config . WebDevProxyURL != "" && r . URL . Path != "/" {
} else if r . Header . Get ( "Accept" ) == "application/nostr+json" {
// forward to mux (which will proxy to dev server)
s . HandleRelayInfo ( w , r )
} else {
if s . mux == nil {
http . Error ( w , "Upgrade required" , http . StatusUpgradeRequired )
} else {
s . mux . ServeHTTP ( w , r )
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 ) {
func ( s * Server ) ServiceURL ( req * http . Request ) ( st string ) {
host := req . Header . Get ( "X-Forwarded-Host" )
host := req . Header . Get ( "X-Forwarded-Host" )
if host == "" {
if host == "" {
@ -98,17 +113,43 @@ func (s *Server) UserInterface() {
if s . mux == nil {
if s . mux == nil {
s . mux = http . NewServeMux ( )
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
// Initialize challenge storage if not already done
if s . challenges == nil {
if s . challenges == nil {
s . challengeMutex . Lock ( )
s . challengeMutex . Lock ( )
s . challenges = make ( map [ string ] [ ] byte )
s . challenges = make ( map [ string ] [ ] byte )
s . challengeMutex . Unlock ( )
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 )
s . mux . HandleFunc ( "/" , s . handleLoginInterface )
// API endpoints for authentication
// API endpoints for authentication
s . mux . HandleFunc ( "/api/auth/challenge" , s . handleAuthChallenge )
s . mux . HandleFunc ( "/api/auth/challenge" , s . handleAuthChallenge )
s . mux . HandleFunc ( "/api/auth/login" , s . handleAuthLogin )
s . mux . HandleFunc ( "/api/auth/login" , s . handleAuthLogin )
@ -119,10 +160,20 @@ func (s *Server) UserInterface() {
// handleLoginInterface serves the main user interface for login
// handleLoginInterface serves the main user interface for login
func ( s * Server ) handleLoginInterface ( w http . ResponseWriter , r * http . Request ) {
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 ( ) )
fileServer := http . FileServer ( GetReactAppFS ( ) )
// Serve the React app files
fileServer . ServeHTTP ( w , r )
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 )
http . Error ( w , "Method not allowed" , http . StatusMethodNotAllowed )
return
return
}
}
// Generate a proper challenge using the auth package
// Generate a proper challenge using the auth package
challenge := auth . GenerateChallenge ( )
challenge := auth . GenerateChallenge ( )
challengeHex := hex . Enc ( challenge )
challengeHex := hex . Enc ( challenge )
// Store the challenge using the hex value as the key for easy lookup
// Store the challenge using the hex value as the key for easy lookup
s . challengeMutex . Lock ( )
s . challengeMutex . Lock ( )
s . challenges [ challengeHex ] = challenge
s . challenges [ challengeHex ] = challenge
s . challengeMutex . Unlock ( )
s . challengeMutex . Unlock ( )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . Write ( [ ] byte ( ` { "challenge": " ` + challengeHex + ` "} ` ) )
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 )
http . Error ( w , "Method not allowed" , http . StatusMethodNotAllowed )
return
return
}
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
// Read the request body
// Read the request body
body , err := io . ReadAll ( r . Body )
body , err := io . ReadAll ( r . Body )
if chk . E ( err ) {
if chk . E ( err ) {
w . Write ( [ ] byte ( ` { "success": false, "error": "Failed to read request body"} ` ) )
w . Write ( [ ] byte ( ` { "success": false, "error": "Failed to read request body"} ` ) )
return
return
}
}
// Parse the signed event
// Parse the signed event
var evt event . E
var evt event . E
if err = json . Unmarshal ( body , & evt ) ; chk . E ( err ) {
if err = json . Unmarshal ( body , & evt ) ; chk . E ( err ) {
w . Write ( [ ] byte ( ` { "success": false, "error": "Invalid event format"} ` ) )
w . Write ( [ ] byte ( ` { "success": false, "error": "Invalid event format"} ` ) )
return
return
}
}
// Extract the challenge from the event to look up the stored challenge
// Extract the challenge from the event to look up the stored challenge
challengeTag := evt . Tags . GetFirst ( [ ] byte ( "challenge" ) )
challengeTag := evt . Tags . GetFirst ( [ ] byte ( "challenge" ) )
if challengeTag == nil {
if challengeTag == nil {
w . Write ( [ ] byte ( ` { "success": false, "error": "Challenge tag missing from event"} ` ) )
w . Write ( [ ] byte ( ` { "success": false, "error": "Challenge tag missing from event"} ` ) )
return
return
}
}
challengeHex := string ( challengeTag . Value ( ) )
challengeHex := string ( challengeTag . Value ( ) )
// Retrieve the stored challenge
// Retrieve the stored challenge
s . challengeMutex . RLock ( )
s . challengeMutex . RLock ( )
_ , exists := s . challenges [ challengeHex ]
_ , exists := s . challenges [ challengeHex ]
s . challengeMutex . RUnlock ( )
s . challengeMutex . RUnlock ( )
if ! exists {
if ! exists {
w . Write ( [ ] byte ( ` { "success": false, "error": "Invalid or expired challenge"} ` ) )
w . Write ( [ ] byte ( ` { "success": false, "error": "Invalid or expired challenge"} ` ) )
return
return
}
}
// Clean up the used challenge
// Clean up the used challenge
s . challengeMutex . Lock ( )
s . challengeMutex . Lock ( )
delete ( s . challenges , challengeHex )
delete ( s . challenges , challengeHex )
s . challengeMutex . Unlock ( )
s . challengeMutex . Unlock ( )
relayURL := s . ServiceURL ( r )
relayURL := s . ServiceURL ( r )
// Validate the authentication event with the correct challenge
// 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
// 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 )
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 + ` "} ` ) )
w . Write ( [ ] byte ( ` { "success": false, "error": " ` + errorMsg + ` "} ` ) )
return
return
}
}
// Authentication successful: set a simple session cookie with the pubkey
// Authentication successful: set a simple session cookie with the pubkey
cookie := & http . Cookie {
cookie := & http . Cookie {
Name : "orly_auth" ,
Name : "orly_auth" ,
@ -246,14 +297,16 @@ func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
return
return
}
}
// Expire the cookie
// Expire the cookie
http . SetCookie ( w , & http . Cookie {
http . SetCookie (
Name : "orly_auth" ,
w , & http . Cookie {
Value : "" ,
Name : "orly_auth" ,
Path : "/" ,
Value : "" ,
MaxAge : - 1 ,
Path : "/" ,
HttpOnly : true ,
MaxAge : - 1 ,
SameSite : http . SameSiteLaxMode ,
HttpOnly : true ,
} )
SameSite : http . SameSiteLaxMode ,
} ,
)
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . Write ( [ ] byte ( ` { "success": true} ` ) )
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 )
http . Error ( w , "Method not allowed" , http . StatusMethodNotAllowed )
return
return
}
}
// Extract pubkey from URL path
// Extract pubkey from URL path
pubkeyHex := strings . TrimPrefix ( r . URL . Path , "/api/permissions/" )
pubkeyHex := strings . TrimPrefix ( r . URL . Path , "/api/permissions/" )
if pubkeyHex == "" || pubkeyHex == "/" {
if pubkeyHex == "" || pubkeyHex == "/" {
http . Error ( w , "Invalid pubkey" , http . StatusBadRequest )
http . Error ( w , "Invalid pubkey" , http . StatusBadRequest )
return
return
}
}
// Convert hex to binary pubkey
// Convert hex to binary pubkey
pubkey , err := hex . Dec ( pubkeyHex )
pubkey , err := hex . Dec ( pubkeyHex )
if chk . E ( err ) {
if chk . E ( err ) {
http . Error ( w , "Invalid pubkey format" , http . StatusBadRequest )
http . Error ( w , "Invalid pubkey format" , http . StatusBadRequest )
return
return
}
}
// Get access level using acl registry
// Get access level using acl registry
permission := acl . Registry . GetAccessLevel ( pubkey , r . RemoteAddr )
permission := acl . Registry . GetAccessLevel ( pubkey , r . RemoteAddr )
// Set content type and write JSON response
// Set content type and write JSON response
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
// Format response as proper JSON
// Format response as proper JSON
response := struct {
response := struct {
Permission string ` json:"permission" `
Permission string ` json:"permission" `
} {
} {
Permission : permission ,
Permission : permission ,
}
}
// Marshal and write the response
// Marshal and write the response
jsonData , err := json . Marshal ( response )
jsonData , err := json . Marshal ( response )
if chk . E ( err ) {
if chk . E ( err ) {
http . Error ( w , "Error generating response" , http . StatusInternalServerError )
http . Error (
w , "Error generating response" , http . StatusInternalServerError ,
)
return
return
}
}
w . Write ( jsonData )
w . Write ( jsonData )
}
}