Embed React app and add new user authentication interface.

- Integrated a React-based web frontend into the Go server using the `embed` package, serving it from `/`.
- Added build and development scripts utilizing Bun for the React app (`package.json`, `README.md`).
- Enhanced auth interface to support better user experience and permissions (`App.jsx`, CSS updates).
- Refactored `/api/auth/login` to serve React UI, removing hardcoded HTML template.
- Implemented `/api/permissions/` with ACL support for user access management.
This commit is contained in:
2025-09-20 19:03:25 +01:00
parent 9c85dca598
commit 0b69ea6d80
11 changed files with 522 additions and 162 deletions

View File

@@ -11,6 +11,7 @@ import (
"lol.mleku.dev/chk"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
@@ -101,173 +102,16 @@ func (s *Server) UserInterface() {
s.mux.HandleFunc("/api/auth/challenge", s.handleAuthChallenge)
s.mux.HandleFunc("/api/auth/login", s.handleAuthLogin)
s.mux.HandleFunc("/api/auth/status", s.handleAuthStatus)
s.mux.HandleFunc("/api/permissions/", s.handlePermissions)
}
// handleLoginInterface serves the main user interface for login
func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Create a file server handler for the embedded React app
fileServer := http.FileServer(GetReactAppFS())
html := `<!DOCTYPE html>
<html>
<head>
<title>Nostr Relay Login</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.container { background: #f9f9f9; padding: 30px; border-radius: 8px; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input, textarea { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; }
button { background: #007cba; color: white; padding: 12px 20px; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #005a87; }
.status { margin-top: 20px; padding: 10px; border-radius: 4px; }
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
.info { background: #d1ecf1; color: #0c5460; }
</style>
</head>
<body>
<div class="container">
<h1>Nostr Relay Authentication</h1>
<p>Connect to this Nostr relay using your private key or browser extension.</p>
<div id="status" class="status info">
Ready to authenticate
</div>
<div class="form-group">
<button onclick="loginWithExtension()">Login with Browser Extension (NIP-07)</button>
</div>
<div class="form-group">
<label for="nsec">Or login with private key (nsec):</label>
<input type="password" id="nsec" placeholder="nsec1...">
<button onclick="loginWithPrivateKey()" style="margin-top: 10px;">Login with Private Key</button>
</div>
<div class="form-group">
<button onclick="logout()" style="background: #dc3545;">Logout</button>
</div>
</div>
<script>
let currentUser = null;
function updateStatus(message, type = 'info') {
const status = document.getElementById('status');
status.textContent = message;
status.className = 'status ' + type;
}
async function getChallenge() {
try {
const response = await fetch('/api/auth/challenge');
const data = await response.json();
return data.challenge;
} catch (error) {
updateStatus('Failed to get authentication challenge: ' + error.message, 'error');
throw error;
}
}
async function loginWithExtension() {
if (!window.nostr) {
updateStatus('No Nostr extension found. Please install a NIP-07 compatible extension like nos2x or Alby.', 'error');
return;
}
try {
updateStatus('Connecting to extension...', 'info');
// Get public key from extension
const pubkey = await window.nostr.getPublicKey();
// Get challenge from server
const challenge = await getChallenge();
// Create authentication event
const authEvent = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', window.location.protocol.replace('http', 'ws') + '//' + window.location.host],
['challenge', challenge]
],
content: ''
};
// Sign the event with extension
const signedEvent = await window.nostr.signEvent(authEvent);
// Send to server
await authenticate(signedEvent);
} catch (error) {
updateStatus('Extension login failed: ' + error.message, 'error');
}
}
async function loginWithPrivateKey() {
const nsec = document.getElementById('nsec').value;
if (!nsec) {
updateStatus('Please enter your private key', 'error');
return;
}
updateStatus('Private key login not implemented in this basic interface. Please use a proper Nostr client or extension.', 'error');
}
async function authenticate(signedEvent) {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedEvent)
});
const result = await response.json();
if (result.success) {
currentUser = result.pubkey;
updateStatus('Successfully authenticated as: ' + result.pubkey.slice(0, 16) + '...', 'success');
} else {
updateStatus('Authentication failed: ' + result.error, 'error');
}
} catch (error) {
updateStatus('Authentication request failed: ' + error.message, 'error');
}
}
async function logout() {
currentUser = null;
updateStatus('Logged out', 'info');
}
// Check authentication status on page load
async function checkStatus() {
try {
const response = await fetch('/api/auth/status');
const data = await response.json();
if (data.authenticated) {
currentUser = data.pubkey;
updateStatus('Already authenticated as: ' + data.pubkey.slice(0, 16) + '...', 'success');
}
} catch (error) {
// Ignore errors for status check
}
}
checkStatus();
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(html))
// Serve the React app files
fileServer.ServeHTTP(w, r)
}
// handleAuthChallenge generates and returns an authentication challenge
@@ -365,3 +209,47 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"authenticated": false}`))
}
// handlePermissions returns the permission level for a given pubkey
func (s *Server) handlePermissions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract pubkey from URL path
pubkeyHex := strings.TrimPrefix(r.URL.Path, "/api/permissions/")
if pubkeyHex == "" || pubkeyHex == "/" {
http.Error(w, "Invalid pubkey", http.StatusBadRequest)
return
}
// Convert hex to binary pubkey
pubkey, err := hex.Dec(pubkeyHex)
if chk.E(err) {
http.Error(w, "Invalid pubkey format", http.StatusBadRequest)
return
}
// Get access level using acl registry
permission := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
// Set content type and write JSON response
w.Header().Set("Content-Type", "application/json")
// Format response as proper JSON
response := struct {
Permission string `json:"permission"`
}{
Permission: permission,
}
// Marshal and write the response
jsonData, err := json.Marshal(response)
if chk.E(err) {
http.Error(w, "Error generating response", http.StatusInternalServerError)
return
}
w.Write(jsonData)
}