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:
2025-10-08 20:06:58 +01:00
parent 332b9b05f7
commit 2bdc1b7bc0
6 changed files with 1028 additions and 216 deletions

View File

@@ -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"}`))
}

View File

@@ -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)
}

View File

@@ -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>

View 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

View 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
}

View 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
}