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 && (
+
+
+

+
+ {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