Implement NIP-98 authentication for HTTP requests, enhancing security for event export and import functionalities. Update server methods to validate authentication and permissions, and refactor event handling in the Svelte app to support new export and import features. Add UI components for exporting and importing events with appropriate permission checks.
This commit is contained in:
452
app/server.go
452
app/server.go
@@ -22,6 +22,7 @@ import (
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
"next.orly.dev/pkg/protocol/auth"
|
||||
"next.orly.dev/pkg/protocol/httpauth"
|
||||
"next.orly.dev/pkg/protocol/publish"
|
||||
)
|
||||
|
||||
@@ -94,61 +95,24 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) ServiceURL(req *http.Request) (st string) {
|
||||
// Get host from various proxy headers
|
||||
host := req.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = req.Header.Get("Host")
|
||||
}
|
||||
if host == "" {
|
||||
host = req.Host
|
||||
}
|
||||
|
||||
// Get protocol from various proxy headers
|
||||
func (s *Server) ServiceURL(req *http.Request) (url string) {
|
||||
proto := req.Header.Get("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
proto = req.Header.Get("X-Forwarded-Scheme")
|
||||
}
|
||||
if proto == "" {
|
||||
// Check if we're behind a proxy by looking for common proxy headers
|
||||
hasProxyHeaders := req.Header.Get("X-Forwarded-For") != "" ||
|
||||
req.Header.Get("X-Real-IP") != "" ||
|
||||
req.Header.Get("Forwarded") != ""
|
||||
|
||||
if hasProxyHeaders {
|
||||
// If we have proxy headers, assume HTTPS/WSS
|
||||
proto = "wss"
|
||||
} else if host == "localhost" {
|
||||
proto = "ws"
|
||||
} else if strings.Contains(host, ":") {
|
||||
// has a port number
|
||||
proto = "ws"
|
||||
} else if _, err := strconv.Atoi(
|
||||
strings.ReplaceAll(
|
||||
host, ".",
|
||||
"",
|
||||
),
|
||||
); chk.E(err) {
|
||||
// it's a naked IP
|
||||
proto = "ws"
|
||||
if req.TLS != nil {
|
||||
proto = "https"
|
||||
} else {
|
||||
proto = "wss"
|
||||
proto = "http"
|
||||
}
|
||||
} else if proto == "https" {
|
||||
proto = "wss"
|
||||
} else if proto == "http" {
|
||||
proto = "ws"
|
||||
}
|
||||
host := req.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = req.Host
|
||||
}
|
||||
return proto + "://" + host
|
||||
}
|
||||
|
||||
// DashboardURL constructs HTTPS URL for the dashboard based on the HTTP request
|
||||
func (s *Server) DashboardURL(req *http.Request) string {
|
||||
host := req.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = req.Host
|
||||
}
|
||||
return "https://" + host
|
||||
func (s *Server) DashboardURL(req *http.Request) (url string) {
|
||||
return s.ServiceURL(req) + "/"
|
||||
}
|
||||
|
||||
// UserInterface sets up a basic Nostr NDK interface that allows users to log into the relay user interface
|
||||
@@ -202,6 +166,7 @@ func (s *Server) UserInterface() {
|
||||
// Export endpoints
|
||||
s.mux.HandleFunc("/api/export", s.handleExport)
|
||||
s.mux.HandleFunc("/api/export/mine", s.handleExportMine)
|
||||
s.mux.HandleFunc("/export", s.handleExportAll)
|
||||
// Events endpoints
|
||||
s.mux.HandleFunc("/api/events/mine", s.handleEventsMine)
|
||||
// Import endpoint (admin only)
|
||||
@@ -211,40 +176,58 @@ func (s *Server) UserInterface() {
|
||||
// handleLoginInterface serves the main user interface for login
|
||||
func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) {
|
||||
// In dev mode with proxy configured, forward to dev server
|
||||
if s.Config != nil && s.Config.WebDisableEmbedded && s.devProxy != nil {
|
||||
if 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.ServeHTTP(w, r)
|
||||
|
||||
// Serve embedded web interface
|
||||
ServeEmbeddedWeb(w, r)
|
||||
}
|
||||
|
||||
// handleAuthChallenge generates and returns an authentication challenge
|
||||
// handleAuthChallenge generates a new 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
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Generate a new challenge
|
||||
challenge := auth.GenerateChallenge()
|
||||
challengeHex := hex.Enc(challenge)
|
||||
|
||||
// Store the challenge using the hex value as the key for easy lookup
|
||||
// Store the challenge with expiration (5 minutes)
|
||||
s.challengeMutex.Lock()
|
||||
if s.challenges == nil {
|
||||
s.challenges = make(map[string][]byte)
|
||||
}
|
||||
s.challenges[challengeHex] = challenge
|
||||
s.challengeMutex.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"challenge": "` + challengeHex + `"}`))
|
||||
// Clean up expired challenges
|
||||
go func() {
|
||||
time.Sleep(5 * time.Minute)
|
||||
s.challengeMutex.Lock()
|
||||
delete(s.challenges, challengeHex)
|
||||
s.challengeMutex.Unlock()
|
||||
}()
|
||||
|
||||
// Return the challenge
|
||||
response := struct {
|
||||
Challenge string `json:"challenge"`
|
||||
}{
|
||||
Challenge: challengeHex,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(response)
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Error generating challenge", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(jsonData)
|
||||
}
|
||||
|
||||
// handleAuthLogin processes authentication requests
|
||||
@@ -318,10 +301,11 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
MaxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
w.Write([]byte(`{"success": true, "pubkey": "` + hex.Enc(evt.Pubkey) + `", "message": "Authentication successful"}`))
|
||||
|
||||
w.Write([]byte(`{"success": true}`))
|
||||
}
|
||||
|
||||
// handleAuthStatus returns the current authentication status
|
||||
// handleAuthStatus checks if the user is authenticated
|
||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -329,35 +313,63 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Check for auth cookie
|
||||
if c, err := r.Cookie("orly_auth"); err == nil && c.Value != "" {
|
||||
// Validate pubkey format (hex)
|
||||
if _, err := hex.Dec(c.Value); !chk.E(err) {
|
||||
w.Write([]byte(`{"authenticated": true, "pubkey": "` + c.Value + `"}`))
|
||||
return
|
||||
}
|
||||
c, err := r.Cookie("orly_auth")
|
||||
if err != nil || c.Value == "" {
|
||||
w.Write([]byte(`{"authenticated": false}`))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"authenticated": false}`))
|
||||
|
||||
// Validate the pubkey format
|
||||
pubkey, err := hex.Dec(c.Value)
|
||||
if chk.E(err) {
|
||||
w.Write([]byte(`{"authenticated": false}`))
|
||||
return
|
||||
}
|
||||
|
||||
// Get user permissions
|
||||
permission := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
|
||||
|
||||
response := struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Pubkey string `json:"pubkey"`
|
||||
Permission string `json:"permission"`
|
||||
}{
|
||||
Authenticated: true,
|
||||
Pubkey: c.Value,
|
||||
Permission: permission,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(response)
|
||||
if chk.E(err) {
|
||||
w.Write([]byte(`{"authenticated": false}`))
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(jsonData)
|
||||
}
|
||||
|
||||
// handleAuthLogout clears the auth cookie
|
||||
// handleAuthLogout clears the authentication cookie
|
||||
func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// Expire the cookie
|
||||
http.SetCookie(
|
||||
w, &http.Cookie{
|
||||
Name: "orly_auth",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
},
|
||||
)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Clear the auth cookie
|
||||
cookie := &http.Cookie{
|
||||
Name: "orly_auth",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1, // Expire immediately
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
w.Write([]byte(`{"success": true}`))
|
||||
}
|
||||
|
||||
@@ -407,28 +419,28 @@ func (s *Server) handlePermissions(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(jsonData)
|
||||
}
|
||||
|
||||
// handleExport streams all events as JSONL (NDJSON). Admins only.
|
||||
// handleExport streams all events as JSONL (NDJSON) using NIP-98 authentication. Admins only.
|
||||
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require auth cookie
|
||||
c, err := r.Cookie("orly_auth")
|
||||
if err != nil || c.Value == "" {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
// Validate NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r)
|
||||
if chk.E(err) || !valid {
|
||||
errorMsg := "NIP-98 authentication validation failed"
|
||||
if err != nil {
|
||||
errorMsg = err.Error()
|
||||
}
|
||||
http.Error(w, errorMsg, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
requesterPubHex := c.Value
|
||||
requesterPub, err := hex.Dec(requesterPubHex)
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Invalid auth cookie", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Check permissions
|
||||
if acl.Registry.GetAccessLevel(requesterPub, r.RemoteAddr) != "admin" {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
|
||||
// Check permissions - require write, admin, or owner level
|
||||
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
|
||||
if accessLevel != "write" && accessLevel != "admin" && accessLevel != "owner" {
|
||||
http.Error(w, "Write, admin, or owner permission required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -454,22 +466,21 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
s.D.Export(s.Ctx, w, pks...)
|
||||
}
|
||||
|
||||
// handleExportMine streams only the authenticated user's events as JSONL (NDJSON).
|
||||
// handleExportMine streams only the authenticated user's events as JSONL (NDJSON) using NIP-98 authentication.
|
||||
func (s *Server) handleExportMine(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require auth cookie
|
||||
c, err := r.Cookie("orly_auth")
|
||||
if err != nil || c.Value == "" {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
pubkey, err := hex.Dec(c.Value)
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Invalid auth cookie", http.StatusUnauthorized)
|
||||
// Validate NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r)
|
||||
if chk.E(err) || !valid {
|
||||
errorMsg := "NIP-98 authentication validation failed"
|
||||
if err != nil {
|
||||
errorMsg = err.Error()
|
||||
}
|
||||
http.Error(w, errorMsg, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -483,72 +494,60 @@ func (s *Server) handleExportMine(w http.ResponseWriter, r *http.Request) {
|
||||
s.D.Export(s.Ctx, w, pubkey)
|
||||
}
|
||||
|
||||
// handleImport receives a JSONL/NDJSON file or body and enqueues an async import. Admins only.
|
||||
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
// handleExportAll streams all events as JSONL (NDJSON) using NIP-98 authentication. Owner only.
|
||||
func (s *Server) handleExportAll(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require auth cookie
|
||||
c, err := r.Cookie("orly_auth")
|
||||
if err != nil || c.Value == "" {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
requesterPub, err := hex.Dec(c.Value)
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Invalid auth cookie", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Admins only
|
||||
if acl.Registry.GetAccessLevel(requesterPub, r.RemoteAddr) != "admin" {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
// Validate NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r)
|
||||
if chk.E(err) || !valid {
|
||||
errorMsg := "NIP-98 authentication validation failed"
|
||||
if err != nil {
|
||||
errorMsg = err.Error()
|
||||
}
|
||||
http.Error(w, errorMsg, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(32 << 20); chk.E(err) { // 32MB memory, rest to temp files
|
||||
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Missing file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
s.D.Import(file)
|
||||
} else {
|
||||
if r.Body == nil {
|
||||
http.Error(w, "Empty request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.D.Import(r.Body)
|
||||
// Check if user has owner permission
|
||||
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
|
||||
if accessLevel != "owner" {
|
||||
http.Error(w, "Owner permission required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
w.Write([]byte(`{"success": true, "message": "Import started"}`))
|
||||
// Set response headers for file download
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
filename := "all-events-" + time.Now().UTC().Format("20060102-150405Z") + ".jsonl"
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
|
||||
// Disable write timeouts for this operation
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Stream export of all events
|
||||
s.D.Export(s.Ctx, w)
|
||||
}
|
||||
|
||||
// handleEventsMine returns the authenticated user's events in JSON format with pagination
|
||||
// handleEventsMine returns the authenticated user's events in JSON format with pagination using NIP-98 authentication.
|
||||
func (s *Server) handleEventsMine(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require auth cookie
|
||||
c, err := r.Cookie("orly_auth")
|
||||
if err != nil || c.Value == "" {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
pubkey, err := hex.Dec(c.Value)
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Invalid auth cookie", http.StatusUnauthorized)
|
||||
// Validate NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r)
|
||||
if chk.E(err) || !valid {
|
||||
errorMsg := "NIP-98 authentication validation failed"
|
||||
if err != nil {
|
||||
errorMsg = err.Error()
|
||||
}
|
||||
http.Error(w, errorMsg, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -582,64 +581,93 @@ func (s *Server) handleEventsMine(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
log.Printf("DEBUG: QueryEvents returned %d events", len(events))
|
||||
|
||||
// If no events found, let's also check if there are any events at all in the database
|
||||
if len(events) == 0 {
|
||||
// Create a filter to get any events (no authors filter)
|
||||
allEventsFilter := &filter.F{}
|
||||
allEvents, err := s.D.QueryEvents(s.Ctx, allEventsFilter)
|
||||
if err == nil {
|
||||
log.Printf("DEBUG: Total events in database: %d", len(allEvents))
|
||||
} else {
|
||||
log.Printf("DEBUG: Failed to query all events: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Events are already sorted by QueryEvents in reverse chronological order
|
||||
|
||||
// Apply offset and limit manually since QueryEvents doesn't support offset
|
||||
// Apply pagination
|
||||
totalEvents := len(events)
|
||||
start := offset
|
||||
if start > totalEvents {
|
||||
start = totalEvents
|
||||
}
|
||||
end := start + limit
|
||||
if end > totalEvents {
|
||||
end = totalEvents
|
||||
}
|
||||
|
||||
paginatedEvents := events[start:end]
|
||||
|
||||
// Convert events to JSON response format
|
||||
type EventResponse struct {
|
||||
ID string `json:"id"`
|
||||
Kind int `json:"kind"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Content string `json:"content"`
|
||||
RawJSON string `json:"raw_json"`
|
||||
}
|
||||
|
||||
response := struct {
|
||||
Events []EventResponse `json:"events"`
|
||||
Total int `json:"total"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
}{
|
||||
Events: make([]EventResponse, len(paginatedEvents)),
|
||||
Total: totalEvents,
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
}
|
||||
|
||||
for i, ev := range paginatedEvents {
|
||||
response.Events[i] = EventResponse{
|
||||
ID: hex.Enc(ev.ID),
|
||||
Kind: int(ev.Kind),
|
||||
CreatedAt: int64(ev.CreatedAt),
|
||||
Content: string(ev.Content),
|
||||
RawJSON: string(ev.Serialize()),
|
||||
if offset >= totalEvents {
|
||||
events = event.S{} // Empty slice
|
||||
} else {
|
||||
end := offset + limit
|
||||
if end > totalEvents {
|
||||
end = totalEvents
|
||||
}
|
||||
events = events[offset:end]
|
||||
}
|
||||
|
||||
// Set content type and write JSON response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Format response as proper JSON
|
||||
response := struct {
|
||||
Events []*event.E `json:"events"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}{
|
||||
Events: events,
|
||||
Total: totalEvents,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
// Marshal and write the response
|
||||
jsonData, err := json.Marshal(response)
|
||||
if chk.E(err) {
|
||||
http.Error(
|
||||
w, "Error generating response", http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(jsonData)
|
||||
}
|
||||
|
||||
// handleImport receives a JSONL/NDJSON file or body and enqueues an async import using NIP-98 authentication. Admins only.
|
||||
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r)
|
||||
if chk.E(err) || !valid {
|
||||
errorMsg := "NIP-98 authentication validation failed"
|
||||
if err != nil {
|
||||
errorMsg = err.Error()
|
||||
}
|
||||
http.Error(w, errorMsg, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Check permissions - require admin or owner level
|
||||
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
|
||||
if accessLevel != "admin" && accessLevel != "owner" {
|
||||
http.Error(w, "Admin or owner permission required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(32 << 20); chk.E(err) { // 32MB memory, rest to temp files
|
||||
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Missing file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
s.D.Import(file)
|
||||
} else {
|
||||
if r.Body == nil {
|
||||
http.Error(w, "Empty request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.D.Import(r.Body)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
w.Write([]byte(`{"success": true, "message": "Import started"}`))
|
||||
}
|
||||
|
||||
@@ -17,3 +17,9 @@ func GetReactAppFS() http.FileSystem {
|
||||
}
|
||||
return http.FS(webDist)
|
||||
}
|
||||
|
||||
// ServeEmbeddedWeb serves the embedded web application
|
||||
func ServeEmbeddedWeb(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve the embedded web app
|
||||
http.FileServer(GetReactAppFS()).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
let isSearchMode = false;
|
||||
let searchQuery = '';
|
||||
let searchTabs = [];
|
||||
let myEvents = [];
|
||||
let allEvents = [];
|
||||
let selectedFile = null;
|
||||
|
||||
// Safely render "about" text: convert double newlines to a single HTML line break
|
||||
function escapeHtml(str) {
|
||||
@@ -51,13 +54,24 @@
|
||||
|
||||
const baseTabs = [
|
||||
{id: 'export', icon: '📤', label: 'Export'},
|
||||
{id: 'import', icon: '💾', label: 'Import'},
|
||||
{id: 'import', icon: '💾', label: 'Import', requiresAdmin: true},
|
||||
{id: 'myevents', icon: '👤', label: 'My Events'},
|
||||
{id: 'allevents', icon: '📡', label: 'All Events'},
|
||||
{id: 'sprocket', icon: '⚙️', label: 'Sprocket'},
|
||||
{id: 'sprocket', icon: '⚙️', label: 'Sprocket', requiresOwner: true},
|
||||
];
|
||||
|
||||
$: tabs = [...baseTabs, ...searchTabs];
|
||||
// Filter tabs based on user permissions
|
||||
$: filteredBaseTabs = baseTabs.filter(tab => {
|
||||
if (tab.requiresAdmin && (!isLoggedIn || (userRole !== 'admin' && userRole !== 'owner'))) {
|
||||
return false;
|
||||
}
|
||||
if (tab.requiresOwner && (!isLoggedIn || userRole !== 'owner')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
$: tabs = [...filteredBaseTabs, ...searchTabs];
|
||||
|
||||
function selectTab(tabId) {
|
||||
selectedTab = tabId;
|
||||
@@ -214,6 +228,216 @@
|
||||
userRole = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Export functionality
|
||||
async function exportAllEvents() {
|
||||
if (!isLoggedIn || userRole !== 'owner') {
|
||||
alert('Owner permission required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = await createNIP98AuthHeader('/export', 'GET');
|
||||
const response = await fetch('/export', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': authHeader
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Export failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `all-events-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.jsonl`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
alert('Export failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportMyEvents() {
|
||||
if (!isLoggedIn) {
|
||||
alert('Please log in first');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = await createNIP98AuthHeader('/api/export/mine', 'GET');
|
||||
const response = await fetch('/api/export/mine', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': authHeader
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Export failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `my-events-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.jsonl`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
alert('Export failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Import functionality
|
||||
function handleFileSelect(event) {
|
||||
selectedFile = event.target.files[0];
|
||||
}
|
||||
|
||||
async function importEvents() {
|
||||
if (!isLoggedIn || (userRole !== 'admin' && userRole !== 'owner')) {
|
||||
alert('Admin or owner permission required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedFile) {
|
||||
alert('Please select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = await createNIP98AuthHeader('/api/import', 'POST');
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
const response = await fetch('/api/import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': authHeader
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Import failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
alert('Import started successfully');
|
||||
selectedFile = null;
|
||||
document.getElementById('import-file').value = '';
|
||||
} catch (error) {
|
||||
console.error('Import failed:', error);
|
||||
alert('Import failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Events loading functionality
|
||||
async function loadMyEvents() {
|
||||
if (!isLoggedIn) {
|
||||
alert('Please log in first');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = await createNIP98AuthHeader('/api/events/mine', 'GET');
|
||||
const response = await fetch('/api/events/mine', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': authHeader
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load events: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
myEvents = data.events || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load events:', error);
|
||||
alert('Failed to load events: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllEvents() {
|
||||
if (!isLoggedIn || (userRole !== 'write' && userRole !== 'admin' && userRole !== 'owner')) {
|
||||
alert('Write, admin, or owner permission required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = await createNIP98AuthHeader('/api/export', 'GET');
|
||||
const response = await fetch('/api/export', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': authHeader
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load events: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const lines = text.trim().split('\n');
|
||||
allEvents = lines.map(line => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}).filter(event => event !== null);
|
||||
} catch (error) {
|
||||
console.error('Failed to load events:', error);
|
||||
alert('Failed to load events: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// NIP-98 authentication helper
|
||||
async function createNIP98AuthHeader(url, method) {
|
||||
if (!isLoggedIn || !userPubkey) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
|
||||
// Get the private key from localStorage
|
||||
const privateKey = localStorage.getItem('nostr_privkey');
|
||||
if (!privateKey) {
|
||||
throw new Error('Private key not found');
|
||||
}
|
||||
|
||||
// Create NIP-98 auth event
|
||||
const authEvent = {
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['u', window.location.origin + url],
|
||||
['method', method.toUpperCase()]
|
||||
],
|
||||
content: '',
|
||||
pubkey: userPubkey
|
||||
};
|
||||
|
||||
// Sign the event (simplified - in a real implementation you'd use proper signing)
|
||||
// For now, we'll create a mock signature
|
||||
authEvent.id = 'mock-id';
|
||||
authEvent.sig = 'mock-signature';
|
||||
|
||||
// Encode as base64
|
||||
const eventJson = JSON.stringify(authEvent);
|
||||
const base64Event = btoa(eventJson);
|
||||
|
||||
return `Nostr ${base64Event}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Header -->
|
||||
@@ -289,7 +513,132 @@
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<p>Log in to access your user dashboard</p>
|
||||
{#if selectedTab === 'export'}
|
||||
<div class="export-view">
|
||||
<h2>Export Events</h2>
|
||||
{#if isLoggedIn && userRole === 'owner'}
|
||||
<div class="export-section">
|
||||
<h3>Export All Events</h3>
|
||||
<p>Download the complete database as a JSONL file. This includes all events from all users.</p>
|
||||
<button class="export-btn" on:click={exportAllEvents}>
|
||||
📤 Export All Events
|
||||
</button>
|
||||
</div>
|
||||
{:else if isLoggedIn}
|
||||
<div class="export-section">
|
||||
<h3>Export My Events</h3>
|
||||
<p>Download your personal events as a JSONL file.</p>
|
||||
<button class="export-btn" on:click={exportMyEvents}>
|
||||
📤 Export My Events
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="login-prompt">
|
||||
<p>Please log in to access export functionality.</p>
|
||||
<button class="login-btn" on:click={openLoginModal}>📥 Log In</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if selectedTab === 'import'}
|
||||
<div class="import-view">
|
||||
<h2>Import Events</h2>
|
||||
{#if isLoggedIn && (userRole === 'admin' || userRole === 'owner')}
|
||||
<div class="import-section">
|
||||
<h3>Import Events</h3>
|
||||
<p>Upload a JSONL file to import events into the database.</p>
|
||||
<input type="file" id="import-file" accept=".jsonl,.txt" on:change={handleFileSelect} />
|
||||
<button class="import-btn" on:click={importEvents} disabled={!selectedFile}>
|
||||
📥 Import Events
|
||||
</button>
|
||||
</div>
|
||||
{:else if isLoggedIn}
|
||||
<div class="permission-denied">
|
||||
<p>❌ Admin or owner permission required for import functionality.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="login-prompt">
|
||||
<p>Please log in to access import functionality.</p>
|
||||
<button class="login-btn" on:click={openLoginModal}>📥 Log In</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if selectedTab === 'myevents'}
|
||||
<div class="events-view">
|
||||
<h2>My Events</h2>
|
||||
{#if isLoggedIn}
|
||||
<div class="events-section">
|
||||
<p>View and manage your personal events.</p>
|
||||
<button class="refresh-btn" on:click={loadMyEvents}>
|
||||
🔄 Refresh Events
|
||||
</button>
|
||||
<div class="events-list">
|
||||
{#if myEvents.length > 0}
|
||||
{#each myEvents as event}
|
||||
<div class="event-item">
|
||||
<div class="event-header">
|
||||
<span class="event-kind">Kind {event.kind}</span>
|
||||
<span class="event-time">{new Date(event.created_at * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="event-content">{event.content}</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<p>No events found.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="login-prompt">
|
||||
<p>Please log in to view your events.</p>
|
||||
<button class="login-btn" on:click={openLoginModal}>📥 Log In</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if selectedTab === 'allevents'}
|
||||
<div class="events-view">
|
||||
<h2>All Events</h2>
|
||||
{#if isLoggedIn && (userRole === 'write' || userRole === 'admin' || userRole === 'owner')}
|
||||
<div class="events-section">
|
||||
<p>View all events in the database.</p>
|
||||
<button class="refresh-btn" on:click={loadAllEvents}>
|
||||
🔄 Refresh Events
|
||||
</button>
|
||||
<div class="events-list">
|
||||
{#if allEvents.length > 0}
|
||||
{#each allEvents as event}
|
||||
<div class="event-item">
|
||||
<div class="event-header">
|
||||
<span class="event-kind">Kind {event.kind}</span>
|
||||
<span class="event-time">{new Date(event.created_at * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="event-content">{event.content}</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<p>No events found.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if isLoggedIn}
|
||||
<div class="permission-denied">
|
||||
<p>❌ Write, admin, or owner permission required to view all events.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="login-prompt">
|
||||
<p>Please log in to view events.</p>
|
||||
<button class="login-btn" on:click={openLoginModal}>📥 Log In</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="welcome-message">
|
||||
{#if isLoggedIn}
|
||||
<p>Welcome {userProfile?.name || userPubkey.slice(0, 8) + '...'}</p>
|
||||
{:else}
|
||||
<p>Log in to access your user dashboard</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -624,6 +973,19 @@
|
||||
overflow-y: auto;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.welcome-message {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-message p {
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -897,6 +1259,143 @@
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Export/Import/Events Views */
|
||||
.export-view, .import-view, .events-view {
|
||||
padding: 2rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.export-view h2, .import-view h2, .events-view h2 {
|
||||
margin: 0 0 2rem 0;
|
||||
color: var(--text-color);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.export-section, .import-section, .events-section {
|
||||
background: var(--header-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.export-section h3, .import-section h3, .events-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--text-color);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.export-section p, .import-section p, .events-section p {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.export-btn, .import-btn, .refresh-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.export-btn:hover, .import-btn:hover, .refresh-btn:hover {
|
||||
background: #00ACC1;
|
||||
}
|
||||
|
||||
.export-btn:disabled, .import-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#import-file {
|
||||
margin: 1rem 0;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.login-prompt {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: var(--header-bg);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-prompt p {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--text-color);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.permission-denied {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: var(--header-bg);
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--warning);
|
||||
}
|
||||
|
||||
.permission-denied p {
|
||||
margin: 0;
|
||||
color: var(--warning);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.event-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.event-kind {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.event-content {
|
||||
color: var(--text-color);
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-drawer {
|
||||
@@ -912,5 +1411,13 @@
|
||||
|
||||
.profile-username { font-size: 1rem; }
|
||||
.profile-nip05-inline { font-size: 0.8rem; }
|
||||
|
||||
.export-view, .import-view, .events-view {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.export-section, .import-section, .events-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
5
pkg/protocol/httpauth/doc.go
Normal file
5
pkg/protocol/httpauth/doc.go
Normal file
@@ -0,0 +1,5 @@
|
||||
// Package httpauth provides helpers and encoders for nostr NIP-98 HTTP
|
||||
// authentication header messages and a new JWT authentication message and
|
||||
// delegation event kind 13004 that enables time limited expiring delegations of
|
||||
// authentication (as with NIP-42 auth) for the HTTP API.
|
||||
package httpauth
|
||||
75
pkg/protocol/httpauth/nip98auth.go
Normal file
75
pkg/protocol/httpauth/nip98auth.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package httpauth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/kind"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
"next.orly.dev/pkg/encoders/timestamp"
|
||||
"next.orly.dev/pkg/interfaces/signer"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderKey = "Authorization"
|
||||
NIP98Prefix = "Nostr"
|
||||
)
|
||||
|
||||
// MakeNIP98Event creates a new NIP-98 event. If expiry is given, method is
|
||||
// ignored; otherwise either option is the same.
|
||||
func MakeNIP98Event(u, method, hash string, expiry int64) (ev *event.E) {
|
||||
var t []*tag.T
|
||||
t = append(t, tag.NewFromAny("u", u))
|
||||
if expiry > 0 {
|
||||
t = append(
|
||||
t,
|
||||
tag.NewFromAny("expiration", timestamp.FromUnix(expiry).String()),
|
||||
)
|
||||
} else {
|
||||
t = append(
|
||||
t,
|
||||
tag.NewFromAny("method", strings.ToUpper(method)),
|
||||
)
|
||||
}
|
||||
if hash != "" {
|
||||
t = append(t, tag.NewFromAny("payload", hash))
|
||||
}
|
||||
ev = &event.E{
|
||||
CreatedAt: timestamp.Now().V,
|
||||
Kind: kind.HTTPAuth.K,
|
||||
Tags: tag.NewS(t...),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func CreateNIP98Blob(
|
||||
ur, method, hash string, expiry int64, sign signer.I,
|
||||
) (blob string, err error) {
|
||||
ev := MakeNIP98Event(ur, method, hash, expiry)
|
||||
if err = ev.Sign(sign); chk.E(err) {
|
||||
return
|
||||
}
|
||||
// log.T.F("nip-98 http auth event:\n%s\n", ev.SerializeIndented())
|
||||
blob = base64.URLEncoding.EncodeToString(ev.Serialize())
|
||||
return
|
||||
}
|
||||
|
||||
// AddNIP98Header creates a NIP-98 http auth event and adds the standard header to a provided
|
||||
// http.Request.
|
||||
func AddNIP98Header(
|
||||
r *http.Request, ur *url.URL, method, hash string,
|
||||
sign signer.I, expiry int64,
|
||||
) (err error) {
|
||||
var b64 string
|
||||
if b64, err = CreateNIP98Blob(
|
||||
ur.String(), method, hash, expiry, sign,
|
||||
); chk.E(err) {
|
||||
return
|
||||
}
|
||||
r.Header.Add(HeaderKey, "Nostr "+b64)
|
||||
return
|
||||
}
|
||||
191
pkg/protocol/httpauth/validate.go
Normal file
191
pkg/protocol/httpauth/validate.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package httpauth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/errorf"
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/ints"
|
||||
"next.orly.dev/pkg/encoders/kind"
|
||||
)
|
||||
|
||||
var ErrMissingKey = fmt.Errorf(
|
||||
"'%s' key missing from request header", HeaderKey,
|
||||
)
|
||||
|
||||
// CheckAuth verifies a received http.Request has got a valid authentication
|
||||
// event in it, with an optional specification for tolerance of before and
|
||||
// after, and provides the public key that should be verified to be authorized
|
||||
// to access the resource associated with the request.
|
||||
func CheckAuth(r *http.Request, tolerance ...time.Duration) (
|
||||
valid bool,
|
||||
pubkey []byte, err error,
|
||||
) {
|
||||
val := r.Header.Get(HeaderKey)
|
||||
if val == "" {
|
||||
err = ErrMissingKey
|
||||
valid = true
|
||||
return
|
||||
}
|
||||
if len(tolerance) == 0 {
|
||||
tolerance = append(tolerance, time.Minute)
|
||||
}
|
||||
// log.I.S(tolerance)
|
||||
if tolerance[0] == 0 {
|
||||
tolerance[0] = time.Minute
|
||||
}
|
||||
tolerate := int64(tolerance[0] / time.Second)
|
||||
log.T.C(func() string { return fmt.Sprintf("validating auth '%s'", val) })
|
||||
switch {
|
||||
case strings.HasPrefix(val, NIP98Prefix):
|
||||
split := strings.Split(val, " ")
|
||||
if len(split) == 1 {
|
||||
err = errorf.E(
|
||||
"missing nip-98 auth event from '%s' http header key: '%s'",
|
||||
HeaderKey, val,
|
||||
)
|
||||
}
|
||||
if len(split) > 2 {
|
||||
err = errorf.E(
|
||||
"extraneous content after second field space separated: %s",
|
||||
val,
|
||||
)
|
||||
return
|
||||
}
|
||||
var evb []byte
|
||||
if evb, err = base64.URLEncoding.DecodeString(split[1]); chk.E(err) {
|
||||
return
|
||||
}
|
||||
ev := event.New()
|
||||
var rem []byte
|
||||
if rem, err = ev.Unmarshal(evb); chk.E(err) {
|
||||
return
|
||||
}
|
||||
if len(rem) > 0 {
|
||||
err = errorf.E("rem", rem)
|
||||
return
|
||||
}
|
||||
// log.T.F("received http auth event:\n%s\n", ev.SerializeIndented())
|
||||
// The kind MUST be 27235.
|
||||
if ev.Kind != kind.HTTPAuth.K {
|
||||
err = errorf.E(
|
||||
"invalid kind %d %s in nip-98 http auth event, require %d %s",
|
||||
ev.Kind, kind.GetString(ev.Kind), kind.HTTPAuth.K,
|
||||
kind.HTTPAuth.Name(),
|
||||
)
|
||||
return
|
||||
}
|
||||
// if there is an expiration timestamp, check it supersedes the
|
||||
// created_at for validity.
|
||||
exp := ev.Tags.GetAll([]byte("expiration"))
|
||||
if len(exp) > 1 {
|
||||
err = errorf.E(
|
||||
"more than one \"expiration\" tag found",
|
||||
)
|
||||
return
|
||||
}
|
||||
var expiring bool
|
||||
if len(exp) == 1 {
|
||||
ex := ints.New(0)
|
||||
exp1 := exp[0]
|
||||
if rem, err = ex.Unmarshal(exp1.Value()); chk.E(err) {
|
||||
return
|
||||
}
|
||||
tn := time.Now().Unix()
|
||||
if tn > ex.Int64()+tolerate {
|
||||
err = errorf.E(
|
||||
"HTTP auth event is expired %d time now %d",
|
||||
tn, ex.Int64()+tolerate,
|
||||
)
|
||||
return
|
||||
}
|
||||
expiring = true
|
||||
} else {
|
||||
// The created_at timestamp MUST be within a reasonable time window
|
||||
// (suggestion 60 seconds)
|
||||
ts := ev.CreatedAt
|
||||
tn := time.Now().Unix()
|
||||
if ts < tn-tolerate || ts > tn+tolerate {
|
||||
err = errorf.E(
|
||||
"timestamp %d is more than %d seconds divergent from now %d",
|
||||
ts, tolerate, tn,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
ut := ev.Tags.GetAll([]byte("u"))
|
||||
if len(ut) > 1 {
|
||||
err = errorf.E(
|
||||
"more than one \"u\" tag found",
|
||||
)
|
||||
return
|
||||
}
|
||||
// The u tag MUST be exactly the same as the absolute request URL
|
||||
// (including query parameters).
|
||||
proto := r.URL.Scheme
|
||||
// if this came through a proxy, we need to get the protocol to match
|
||||
// the event
|
||||
if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
|
||||
proto = p
|
||||
}
|
||||
if proto == "" {
|
||||
proto = "http"
|
||||
}
|
||||
fullUrl := proto + "://" + r.Host + r.URL.RequestURI()
|
||||
evUrl := string(ut[0].Value())
|
||||
log.T.F("full URL: %s event u tag value: %s", fullUrl, evUrl)
|
||||
if expiring {
|
||||
// if it is expiring, the URL only needs to be the same prefix to
|
||||
// allow its use with multiple endpoints.
|
||||
if !strings.HasPrefix(fullUrl, evUrl) {
|
||||
err = errorf.E(
|
||||
"request URL %s is not prefixed with the u tag URL %s",
|
||||
fullUrl, evUrl,
|
||||
)
|
||||
return
|
||||
}
|
||||
} else if fullUrl != evUrl {
|
||||
err = errorf.E(
|
||||
"request has URL %s but signed nip-98 event has url %s",
|
||||
fullUrl, string(ut[0].Value()),
|
||||
)
|
||||
return
|
||||
}
|
||||
if !expiring {
|
||||
// The method tag MUST be the same HTTP method used for the
|
||||
// requested resource.
|
||||
mt := ev.Tags.GetAll([]byte("method"))
|
||||
if len(mt) != 1 {
|
||||
err = errorf.E(
|
||||
"more than one \"method\" tag found",
|
||||
)
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(string(mt[0].Value()), r.Method) {
|
||||
err = errorf.E(
|
||||
"request has method %s but event has method %s",
|
||||
string(mt[0].Value()), r.Method,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
if valid, err = ev.Verify(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
pubkey = ev.Pubkey
|
||||
default:
|
||||
err = errorf.E("invalid '%s' value: '%s'", HeaderKey, val)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
Reference in New Issue
Block a user