Browse Source
- 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.main
11 changed files with 522 additions and 162 deletions
@ -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) |
||||||
|
} |
||||||
@ -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 |
||||||
@ -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`. |
||||||
@ -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=="], |
||||||
|
} |
||||||
|
} |
||||||
@ -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" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||||
|
<title>Nostr Relay</title> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div id="root"></div> |
||||||
|
<script type="module" src="index.js"></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -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 ( |
||||||
|
<div className="container"> |
||||||
|
{user?.permission && ( |
||||||
|
<div className="header-panel"> |
||||||
|
<div className="header-content"> |
||||||
|
<img src="/docs/orly.png" alt="Logo" className="header-logo" /> |
||||||
|
<div className="user-info"> |
||||||
|
{user.permission === "admin" ? "Admin Dashboard" : "Subscriber Dashboard"} |
||||||
|
</div> |
||||||
|
<button className="logout-button" onClick={logout}>✕</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<h1>Nostr Relay Authentication</h1> |
||||||
|
<p>Connect to this Nostr relay using your private key or browser extension.</p> |
||||||
|
|
||||||
|
<div className={`status ${statusType}`}> |
||||||
|
{status} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="form-group"> |
||||||
|
<button onClick={loginWithExtension}>Login with Browser Extension (NIP-07)</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="form-group"> |
||||||
|
<label htmlFor="nsec">Or login with private key (nsec):</label> |
||||||
|
<input type="password" id="nsec" placeholder="nsec1..." /> |
||||||
|
<button onClick={() => updateStatus('Private key login not implemented in this basic interface. Please use a proper Nostr client or extension.', 'error')} style={{marginTop: '10px'}}>Login with Private Key</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="form-group"> |
||||||
|
<button onClick={logout} className="danger-button">Logout</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export default App; |
||||||
@ -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( |
||||||
|
<React.StrictMode> |
||||||
|
<App /> |
||||||
|
</React.StrictMode> |
||||||
|
); |
||||||
@ -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; |
||||||
|
} |
||||||
Loading…
Reference in new issue