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