From 0b69ea6d805b5082c8702c3489a64465fe4247d0 Mon Sep 17 00:00:00 2001 From: mleku Date: Sat, 20 Sep 2025 19:03:25 +0100 Subject: [PATCH] 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. --- .gitignore | 4 + app/server.go | 212 +++++++++----------------------------- app/web.go | 19 ++++ app/web/.gitignore | 31 ++++++ app/web/README.md | 60 +++++++++++ app/web/bun.lock | 36 +++++++ app/web/package.json | 18 ++++ app/web/public/index.html | 12 +++ app/web/src/App.jsx | 159 ++++++++++++++++++++++++++++ app/web/src/index.jsx | 11 ++ app/web/src/styles.css | 122 ++++++++++++++++++++++ 11 files changed, 522 insertions(+), 162 deletions(-) create mode 100644 app/web.go create mode 100644 app/web/.gitignore create mode 100644 app/web/README.md create mode 100644 app/web/bun.lock create mode 100644 app/web/package.json create mode 100644 app/web/public/index.html create mode 100644 app/web/src/App.jsx create mode 100644 app/web/src/index.jsx create mode 100644 app/web/src/styles.css diff --git a/.gitignore b/.gitignore index 6963eeb..0ccf0c3 100644 --- a/.gitignore +++ b/.gitignore @@ -92,6 +92,10 @@ cmd/benchmark/data !strfry.conf !config.toml !.dockerignore +!*.jsx +!*.tsx +!/dist +!bun.lock # ...even if they are in subdirectories !*/ /blocklist.json diff --git a/app/server.go b/app/server.go index e71f789..01cf6d6 100644 --- a/app/server.go +++ b/app/server.go @@ -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 := ` - - - Nostr Relay Login - - - - - -
-

Nostr Relay Authentication

-

Connect to this Nostr relay using your private key or browser extension.

- -
- Ready to authenticate -
- -
- -
- -
- - - -
- -
- -
-
- - - -` - - 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) +} diff --git a/app/web.go b/app/web.go new file mode 100644 index 0000000..71faa29 --- /dev/null +++ b/app/web.go @@ -0,0 +1,19 @@ +package app + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed web/dist +var reactAppFS embed.FS + +// GetReactAppFS returns a http.FileSystem from the embedded React app +func GetReactAppFS() http.FileSystem { + webDist, err := fs.Sub(reactAppFS, "web/dist") + if err != nil { + panic("Failed to load embedded web app: " + err.Error()) + } + return http.FS(webDist) +} \ No newline at end of file diff --git a/app/web/.gitignore b/app/web/.gitignore new file mode 100644 index 0000000..c96e155 --- /dev/null +++ b/app/web/.gitignore @@ -0,0 +1,31 @@ +# Dependencies +node_modules +.pnp +.pnp.js + +# Bun +.bunfig.toml +bun.lockb + +# Build directories +dist +build + +# Cache and logs +.cache +.temp +.log +*.log + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Editor directories and files +.idea +.vscode +*.swp +*.swo \ No newline at end of file diff --git a/app/web/README.md b/app/web/README.md new file mode 100644 index 0000000..65c5e35 --- /dev/null +++ b/app/web/README.md @@ -0,0 +1,60 @@ +# Orly Web Application + +This is a React web application that uses Bun for building and bundling, and is automatically embedded into the Go binary when built. + +## Prerequisites + +- [Bun](https://bun.sh/) - JavaScript runtime and toolkit +- Go 1.16+ (for embedding functionality) + +## Development + +To run the development server: + +```bash +cd app/web +bun install +bun run dev +``` + +## Building + +The React application needs to be built before compiling the Go binary to ensure that the embedded files are available: + +```bash +# Build the React application +cd app/web +bun install +bun run build + +# Build the Go binary from project root +cd ../../ +go build +``` + +## How it works + +1. The React application is built to the `app/web/dist` directory +2. The Go embed directive in `app/web.go` embeds these files into the binary +3. When the server runs, it serves the embedded React app at the root path + +## Build Automation + +You can create a shell script to automate the build process: + +```bash +#!/bin/bash +# build.sh +echo "Building React app..." +cd app/web +bun install +bun run build + +echo "Building Go binary..." +cd ../../ +go build + +echo "Build complete!" +``` + +Make it executable with `chmod +x build.sh` and run with `./build.sh`. \ No newline at end of file diff --git a/app/web/bun.lock b/app/web/bun.lock new file mode 100644 index 0000000..2b1aba0 --- /dev/null +++ b/app/web/bun.lock @@ -0,0 +1,36 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "orly-web", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + }, + "devDependencies": { + "bun-types": "latest", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="], + + "@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="], + + "bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="], + } +} diff --git a/app/web/package.json b/app/web/package.json new file mode 100644 index 0000000..4f668d0 --- /dev/null +++ b/app/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "orly-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --hot src/index.jsx", + "build": "bun build ./src/index.jsx --outdir ./dist --minify && cp public/index.html dist/", + "start": "bun run dist/index.js" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "bun-types": "latest" + } +} \ No newline at end of file diff --git a/app/web/public/index.html b/app/web/public/index.html new file mode 100644 index 0000000..7e1613f --- /dev/null +++ b/app/web/public/index.html @@ -0,0 +1,12 @@ + + + + + + Nostr Relay + + +
+ + + \ No newline at end of file diff --git a/app/web/src/App.jsx b/app/web/src/App.jsx new file mode 100644 index 0000000..50ae915 --- /dev/null +++ b/app/web/src/App.jsx @@ -0,0 +1,159 @@ +import React, { useState, useEffect } from 'react'; + +function App() { + const [user, setUser] = useState(null); + const [status, setStatus] = useState('Ready to authenticate'); + const [statusType, setStatusType] = useState('info'); + + useEffect(() => { + // Check authentication status on page load + checkStatus(); + }, []); + + async function checkStatus() { + try { + const response = await fetch('/api/auth/status'); + const data = await response.json(); + if (data.authenticated) { + setUser(data.pubkey); + updateStatus(`Already authenticated as: ${data.pubkey.slice(0, 16)}...`, 'success'); + + // Check permissions if authenticated + if (data.pubkey) { + const permResponse = await fetch(`/api/permissions/${data.pubkey}`); + const permData = await permResponse.json(); + if (permData && permData.permission) { + setUser({...data, permission: permData.permission}); + } + } + } + } catch (error) { + // Ignore errors for status check + } + } + + function updateStatus(message, type = 'info') { + setStatus(message); + setStatusType(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 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) { + setUser(result.pubkey); + updateStatus('Successfully authenticated as: ' + result.pubkey.slice(0, 16) + '...', 'success'); + + // Check permissions after login + const permResponse = await fetch(`/api/permissions/${result.pubkey}`); + const permData = await permResponse.json(); + if (permData && permData.permission) { + setUser({pubkey: result.pubkey, permission: permData.permission}); + } + } else { + updateStatus('Authentication failed: ' + result.error, 'error'); + } + } catch (error) { + updateStatus('Authentication request failed: ' + error.message, 'error'); + } + } + + function logout() { + setUser(null); + updateStatus('Logged out', 'info'); + } + + return ( +
+ {user?.permission && ( +
+
+ Logo +
+ {user.permission === "admin" ? "Admin Dashboard" : "Subscriber Dashboard"} +
+ +
+
+ )} + +

Nostr Relay Authentication

+

Connect to this Nostr relay using your private key or browser extension.

+ +
+ {status} +
+ +
+ +
+ +
+ + + +
+ +
+ +
+
+ ); +} + +export default App; \ No newline at end of file diff --git a/app/web/src/index.jsx b/app/web/src/index.jsx new file mode 100644 index 0000000..12536f6 --- /dev/null +++ b/app/web/src/index.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './styles.css'; + +const root = createRoot(document.getElementById('root')); +root.render( + + + +); \ No newline at end of file diff --git a/app/web/src/styles.css b/app/web/src/styles.css new file mode 100644 index 0000000..00ff721 --- /dev/null +++ b/app/web/src/styles.css @@ -0,0 +1,122 @@ +body { + font-family: Arial, sans-serif; + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.container { + background: #f9f9f9; + padding: 30px; + border-radius: 8px; + margin-top: 80px; /* Space for the header panel */ +} + +.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; +} + +.danger-button { + background: #dc3545; +} + +.danger-button:hover { + background: #c82333; +} + +.status { + margin-top: 20px; + margin-bottom: 20px; + padding: 10px; + border-radius: 4px; +} + +.success { + background: #d4edda; + color: #155724; +} + +.error { + background: #f8d7da; + color: #721c24; +} + +.info { + background: #d1ecf1; + color: #0c5460; +} + +.header-panel { + position: fixed; + top: 0; + left: 0; + width: 100%; + background-color: #f8f9fa; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + z-index: 1000; +} + +.header-content { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; +} + +.header-logo { + height: 40px; + width: 40px; + border-radius: 4px; +} + +.user-info { + flex-grow: 1; + padding-left: 20px; + font-weight: bold; +} + +.logout-button { + background: transparent; + color: #6c757d; + border: none; + font-size: 20px; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin-right: 0; +} + +.logout-button:hover { + background: rgba(108, 117, 125, 0.1); + color: #343a40; +} \ No newline at end of file