Add CORS headers and update UI for enhanced user profile handling.
- Added CORS support in server responses for cross-origin requests (`Access-Control-Allow-Origin`, etc.). - Improved header panel behavior with a sticky position and refined CSS styling. - Integrated profile data fetching (Kind 0 metadata) for user personalization. - Enhanced login functionality to support dynamic profile display based on fetched metadata. - Updated `index.html` to include Tailwind CSS for better design consistency.
This commit is contained in:
@@ -34,6 +34,17 @@ type Server struct {
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Set CORS headers for all responses
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
// Handle preflight OPTIONS requests
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// log.T.C(
|
||||
// func() string {
|
||||
// return fmt.Sprintf("path %v header %v", r.URL, r.Header)
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nostr Relay</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-white">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="index.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -4,12 +4,28 @@ function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [status, setStatus] = useState('Ready to authenticate');
|
||||
const [statusType, setStatusType] = useState('info');
|
||||
const [profileData, setProfileData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check authentication status on page load
|
||||
checkStatus();
|
||||
}, []);
|
||||
|
||||
// Effect to fetch profile when user changes
|
||||
useEffect(() => {
|
||||
if (user?.pubkey) {
|
||||
fetchUserProfile(user.pubkey);
|
||||
}
|
||||
}, [user?.pubkey]);
|
||||
|
||||
function relayURL() {
|
||||
try {
|
||||
return window.location.protocol.replace('http', 'ws') + '//' + window.location.host;
|
||||
} catch (_) {
|
||||
return 'ws://localhost:3333';
|
||||
}
|
||||
}
|
||||
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/status');
|
||||
@@ -37,6 +53,19 @@ function App() {
|
||||
setStatusType(type);
|
||||
}
|
||||
|
||||
function statusClassName() {
|
||||
const base = 'mt-5 mb-5 p-3 rounded';
|
||||
switch (statusType) {
|
||||
case 'success':
|
||||
return base + ' bg-green-100 text-green-800';
|
||||
case 'error':
|
||||
return base + ' bg-red-100 text-red-800';
|
||||
case 'info':
|
||||
default:
|
||||
return base + ' bg-cyan-100 text-cyan-800';
|
||||
}
|
||||
}
|
||||
|
||||
async function getChallenge() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/challenge');
|
||||
@@ -68,7 +97,7 @@ function App() {
|
||||
kind: 22242,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['relay', window.location.protocol.replace('http', 'ws') + '//' + window.location.host],
|
||||
['relay', relayURL()],
|
||||
['challenge', challenge]
|
||||
],
|
||||
content: ''
|
||||
@@ -85,6 +114,167 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchKind0FromRelay(pubkeyHex, timeoutMs = 4000) {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let events = [];
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(relayURL());
|
||||
} catch (e) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const subId = 'profile-' + Math.random().toString(36).slice(2);
|
||||
const timer = setTimeout(() => {
|
||||
if (ws && ws.readyState === 1) {
|
||||
try { ws.close(); } catch (_) {}
|
||||
}
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(null);
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
ws.onopen = () => {
|
||||
try {
|
||||
const req = [
|
||||
'REQ',
|
||||
subId,
|
||||
{ kinds: [0], authors: [pubkeyHex] }
|
||||
];
|
||||
ws.send(JSON.stringify(req));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data);
|
||||
const type = data[0];
|
||||
if (type === 'EVENT' && data[1] === subId) {
|
||||
const event = data[2];
|
||||
if (event && event.kind === 0 && event.content) {
|
||||
events.push(event);
|
||||
}
|
||||
} else if (type === 'EOSE' && data[1] === subId) {
|
||||
try {
|
||||
ws.send(JSON.stringify(['CLOSE', subId]));
|
||||
} catch (_) {}
|
||||
try { ws.close(); } catch (_) {}
|
||||
clearTimeout(timer);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
if (events.length) {
|
||||
const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b));
|
||||
try {
|
||||
const meta = JSON.parse(latest.content);
|
||||
resolve(meta || null);
|
||||
} catch (_) {
|
||||
resolve(null);
|
||||
}
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
try { ws.close(); } catch (_) {}
|
||||
clearTimeout(timer);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearTimeout(timer);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
if (events.length) {
|
||||
const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b));
|
||||
try {
|
||||
const meta = JSON.parse(latest.content);
|
||||
resolve(meta || null);
|
||||
} catch (_) {
|
||||
resolve(null);
|
||||
}
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Function to fetch user profile metadata (kind 0)
|
||||
async function fetchUserProfile(pubkeyHex) {
|
||||
try {
|
||||
// Create a simple placeholder with the pubkey
|
||||
const placeholderProfile = {
|
||||
name: `user:${pubkeyHex.slice(0, 8)}`,
|
||||
about: 'No profile data available'
|
||||
};
|
||||
|
||||
// Always set the placeholder profile first
|
||||
setProfileData(placeholderProfile);
|
||||
|
||||
// First, try to get profile kind:0 from the relay itself
|
||||
let relayMetadata = null;
|
||||
try {
|
||||
relayMetadata = await fetchKind0FromRelay(pubkeyHex);
|
||||
} catch (_) {}
|
||||
|
||||
if (relayMetadata) {
|
||||
const parsed = typeof relayMetadata === 'string' ? JSON.parse(relayMetadata) : relayMetadata;
|
||||
setProfileData({
|
||||
name: parsed.name || placeholderProfile.name,
|
||||
display_name: parsed.display_name,
|
||||
picture: parsed.picture,
|
||||
banner: parsed.banner,
|
||||
about: parsed.about || placeholderProfile.about
|
||||
});
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Fallback: try extension metadata if available
|
||||
if (window.nostr && window.nostr.getPublicKey) {
|
||||
try {
|
||||
if (window.nostr.getUserMetadata) {
|
||||
const metadata = await window.nostr.getUserMetadata();
|
||||
if (metadata) {
|
||||
try {
|
||||
const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
|
||||
setProfileData({
|
||||
name: parsedMetadata.name || placeholderProfile.name,
|
||||
display_name: parsedMetadata.display_name,
|
||||
picture: parsedMetadata.picture,
|
||||
banner: parsedMetadata.banner,
|
||||
about: parsedMetadata.about || placeholderProfile.about
|
||||
});
|
||||
return parsedMetadata;
|
||||
} catch (parseError) {
|
||||
console.log('Error parsing user metadata:', parseError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (nostrError) {
|
||||
console.log('Could not get profile from extension:', nostrError);
|
||||
}
|
||||
}
|
||||
|
||||
return placeholderProfile;
|
||||
} catch (error) {
|
||||
console.error('Error handling profile data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(signedEvent) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
@@ -104,6 +294,9 @@ function App() {
|
||||
const permData = await permResponse.json();
|
||||
if (permData && permData.permission) {
|
||||
setUser({pubkey: result.pubkey, permission: permData.permission});
|
||||
|
||||
// Fetch user profile data
|
||||
await fetchUserProfile(result.pubkey);
|
||||
}
|
||||
} else {
|
||||
updateStatus('Authentication failed: ' + result.error, 'error');
|
||||
@@ -119,40 +312,58 @@ function App() {
|
||||
}
|
||||
|
||||
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"}
|
||||
<>
|
||||
{user?.permission ? (
|
||||
// Logged in view with user profile
|
||||
<div className="sticky top-0 left-0 w-full bg-gray-100 z-50 h-16 flex items-center overflow-hidden">
|
||||
<div className="flex items-center h-full w-full box-border">
|
||||
<div className="flex items-center justify-start h-full">
|
||||
<img src="/docs/orly.png" alt="Logo" className="h-full aspect-square w-auto object-cover shrink-0" />
|
||||
</div>
|
||||
<button className="logout-button" onClick={logout}>✕</button>
|
||||
<div className="relative overflow-hidden flex flex-grow items-center justify-start h-full">
|
||||
{profileData?.banner && (
|
||||
<div className="absolute inset-0 opacity-70 bg-cover bg-center" style={{ backgroundImage: `url(${profileData.banner})` }}></div>
|
||||
)}
|
||||
<div className="relative z-10 p-2 flex items-center h-full">
|
||||
{profileData?.picture && <img src={profileData.picture} alt="User Avatar" className="h-full aspect-square w-auto rounded-full object-cover border-2 border-white mr-2 shadow box-border" />}
|
||||
<div>
|
||||
<div className="font-bold text-base block">
|
||||
{profileData?.display_name || profileData?.name || user.pubkey.slice(0, 8)}
|
||||
{profileData?.name && profileData?.display_name && ` (${profileData.name})`}
|
||||
</div>
|
||||
<div className="font-bold text-lg text-left">
|
||||
{user.permission === "admin" ? "Admin Dashboard" : "Subscriber Dashboard"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end h-full">
|
||||
<button className="bg-transparent text-gray-500 border-0 text-2xl cursor-pointer p-0 flex items-center justify-center w-12 h-full ml-2 mr-0 shrink-0 hover:bg-transparent hover:text-gray-800" onClick={logout}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Not logged in view - shows the login form
|
||||
<div className="max-w-3xl mx-auto mt-5 p-6 bg-gray-100 rounded">
|
||||
<h1 className="text-2xl font-bold mb-2">Nostr Relay Authentication</h1>
|
||||
<p className="mb-4">Connect to this Nostr relay using your private key or browser extension.</p>
|
||||
|
||||
<div className={statusClassName()}>
|
||||
{status}
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<button className="bg-blue-600 text-white px-5 py-3 rounded hover:bg-blue-700" onClick={loginWithExtension}>Login with Browser Extension (NIP-07)</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<label className="block mb-1 font-bold" htmlFor="nsec">Or login with private key (nsec):</label>
|
||||
<input className="w-full p-2 border border-gray-300 rounded" type="password" id="nsec" placeholder="nsec1..." />
|
||||
<button className="mt-2 bg-red-600 text-white px-5 py-2 rounded hover:bg-red-700" onClick={() => updateStatus('Private key login not implemented in this basic interface. Please use a proper Nostr client or extension.', 'error')}>Login with Private Key</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ body {
|
||||
background: #f9f9f9;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
margin-top: 80px; /* Space for the header panel */
|
||||
margin-top: 20px; /* Reduced space since header is now sticky */
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -73,32 +73,100 @@ button:hover {
|
||||
}
|
||||
|
||||
.header-panel {
|
||||
position: fixed;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: #f8f9fa;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 20px;
|
||||
height: 100%;
|
||||
padding: 0 0 0 12px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 40px;
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: auto;
|
||||
border-radius: 0;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
border-radius: 4px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid white;
|
||||
margin-right: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex-grow: 1;
|
||||
padding-left: 20px;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-banner {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
@@ -111,12 +179,14 @@ button:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 48px;
|
||||
height: 100%;
|
||||
margin-left: 10px;
|
||||
margin-right: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: rgba(108, 117, 125, 0.1);
|
||||
background: transparent;
|
||||
color: #343a40;
|
||||
}
|
||||
Reference in New Issue
Block a user