feat: add Nostr NIP-07 browser extension login and invite by pubkey
- Server: nostr crate, migration 008 (nostr_pubkey column), challenge/verify endpoints for Schnorr-signed NIP-07 auth, invite-by-nostr endpoint - Client: NIP-07 extension detection, relay profile fetch, Nostr login button on login/register pages, Nostr tab in invite modal, profile page handles no-email Nostr users - Sentinel emails (nostr:<prefix>) hidden at API boundary via public_email() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9634c275b3
commit
1a2f0e7951
@ -10,7 +10,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form if={!state.inviteUrl} onsubmit={handleSubmit}>
|
<div class="invite-tabs">
|
||||||
|
<button
|
||||||
|
class={'invite-tab' + (state.mode === 'email' ? ' active' : '')}
|
||||||
|
onclick={() => update({ mode: 'email', error: null, nostrResult: null })}
|
||||||
|
>Email</button>
|
||||||
|
<button
|
||||||
|
class={'invite-tab' + (state.mode === 'nostr' ? ' active' : '')}
|
||||||
|
onclick={() => update({ mode: 'nostr', error: null, inviteUrl: null })}
|
||||||
|
>Nostr</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form if={state.mode === 'email' && !state.inviteUrl} onsubmit={handleSubmit}>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="invite-email">Email address</label>
|
<label for="invite-email">Email address</label>
|
||||||
<input
|
<input
|
||||||
@ -33,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div if={state.inviteUrl} class="invite-success">
|
<div if={state.mode === 'email' && state.inviteUrl} class="invite-success">
|
||||||
<p>Invite link generated!</p>
|
<p>Invite link generated!</p>
|
||||||
<div class="invite-link-box">
|
<div class="invite-link-box">
|
||||||
<code>{state.inviteUrl}</code>
|
<code>{state.inviteUrl}</code>
|
||||||
@ -44,6 +55,39 @@
|
|||||||
<button class="btn btn-primary" onclick={props.cbClose}>Done</button>
|
<button class="btn btn-primary" onclick={props.cbClose}>Done</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form if={state.mode === 'nostr' && !state.nostrResult} onsubmit={handleNostrInvite}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="invite-npub">Nostr public key</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="invite-npub"
|
||||||
|
placeholder="npub1... or hex pubkey"
|
||||||
|
value={state.nostrPubkey}
|
||||||
|
oninput={e => update({ nostrPubkey: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p if={state.error} class="error-text">{state.error}</p>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={props.cbClose}>Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={state.nostrLoading}>
|
||||||
|
{state.nostrLoading ? 'Adding...' : 'Add to Room'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div if={state.mode === 'nostr' && state.nostrResult} class="invite-success">
|
||||||
|
<p if={state.nostrResult === 'added'} class="success-msg">Added {state.nostrDisplayName} to room</p>
|
||||||
|
<p if={state.nostrResult === 'not_found'} class="info-msg">
|
||||||
|
This Nostr user hasn't joined GroupChat yet. They'll need to log in with their Nostr extension first.
|
||||||
|
</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" onclick={props.cbClose}>Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -150,6 +194,49 @@
|
|||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||||
|
transition: color var(--transition-fast), background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom: 2px solid var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-msg {
|
||||||
|
color: var(--success);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-msg {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -157,10 +244,15 @@
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
state: {
|
state: {
|
||||||
|
mode: 'email',
|
||||||
email: '',
|
email: '',
|
||||||
|
nostrPubkey: '',
|
||||||
error: null,
|
error: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
nostrLoading: false,
|
||||||
inviteUrl: null,
|
inviteUrl: null,
|
||||||
|
nostrResult: null,
|
||||||
|
nostrDisplayName: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
handleOverlayClick() {
|
handleOverlayClick() {
|
||||||
@ -185,6 +277,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async handleNostrInvite(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.update({ nostrLoading: true, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.inviteByNostr({
|
||||||
|
room_id: this.props.roomId,
|
||||||
|
nostr_pubkey: this.state.nostrPubkey.trim(),
|
||||||
|
})
|
||||||
|
this.update({
|
||||||
|
nostrResult: result.status,
|
||||||
|
nostrDisplayName: result.display_name || '',
|
||||||
|
nostrLoading: false,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
this.update({ error: err.message, nostrLoading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
copyLink() {
|
copyLink() {
|
||||||
navigator.clipboard.writeText(this.state.inviteUrl)
|
navigator.clipboard.writeText(this.state.inviteUrl)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -38,6 +38,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div if={state.hasNostr} class="nostr-divider">
|
||||||
|
<span>or</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button if={state.hasNostr} class="btn btn-nostr btn-full" onclick={handleNostrLogin} disabled={state.nostrLoading}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 8px; vertical-align: -2px;">
|
||||||
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
|
||||||
|
</svg>
|
||||||
|
{state.nostrLoading ? 'Connecting...' : 'Login with Nostr'}
|
||||||
|
</button>
|
||||||
|
|
||||||
<p class="auth-footer">
|
<p class="auth-footer">
|
||||||
Don't have an account?
|
Don't have an account?
|
||||||
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Register</a>
|
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Register</a>
|
||||||
@ -104,6 +115,47 @@
|
|||||||
margin-bottom: var(--space-sm);
|
margin-bottom: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nostr-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin: var(--space-lg) 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nostr-divider::before,
|
||||||
|
.nostr-divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nostr {
|
||||||
|
background: #8B5CF6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nostr:hover:not(:disabled) {
|
||||||
|
background: #7C3AED;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nostr:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-footer {
|
.auth-footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: var(--space-lg);
|
margin-top: var(--space-lg);
|
||||||
@ -114,6 +166,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { api } from '../services/api.js'
|
import { api } from '../services/api.js'
|
||||||
|
import { hasNostrExtension, getPublicKey, signEvent, fetchNostrProfile } from '../services/nostr.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
state: {
|
state: {
|
||||||
@ -121,6 +174,15 @@
|
|||||||
password: '',
|
password: '',
|
||||||
error: null,
|
error: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
hasNostr: false,
|
||||||
|
nostrLoading: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onMounted() {
|
||||||
|
// Detect after a tick — extensions inject window.nostr asynchronously
|
||||||
|
setTimeout(() => {
|
||||||
|
this.update({ hasNostr: hasNostrExtension() })
|
||||||
|
}, 100)
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleSubmit(e) {
|
async handleSubmit(e) {
|
||||||
@ -137,6 +199,47 @@
|
|||||||
this.update({ error: err.message, loading: false })
|
this.update({ error: err.message, loading: false })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async handleNostrLogin() {
|
||||||
|
this.update({ nostrLoading: true, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get challenge
|
||||||
|
const { challenge } = await api.nostrChallenge()
|
||||||
|
|
||||||
|
// Decode JWT payload to get nonce (middle segment)
|
||||||
|
const nonce = JSON.parse(atob(challenge.split('.')[1])).nonce
|
||||||
|
|
||||||
|
// 2. Get pubkey
|
||||||
|
const pubkey = await getPublicKey()
|
||||||
|
|
||||||
|
// 3. Fetch profile (best-effort)
|
||||||
|
const profile = await fetchNostrProfile(pubkey)
|
||||||
|
|
||||||
|
// 4. Build unsigned event (NIP-07 compatible)
|
||||||
|
const unsignedEvent = {
|
||||||
|
kind: 27235,
|
||||||
|
content: nonce,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['u', window.location.origin]],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Sign via extension
|
||||||
|
const signed = await signEvent(unsignedEvent)
|
||||||
|
|
||||||
|
// 6. Verify with server
|
||||||
|
const data = await api.nostrVerify({
|
||||||
|
signed_event: JSON.stringify(signed),
|
||||||
|
challenge,
|
||||||
|
profile_name: profile?.name || null,
|
||||||
|
profile_picture: profile?.picture || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.props.cbLogin(data)
|
||||||
|
} catch (err) {
|
||||||
|
this.update({ error: err.message || 'Nostr login failed', nostrLoading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</login-page>
|
</login-page>
|
||||||
|
|||||||
@ -41,8 +41,8 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
<input type="email" value={props.user?.email} disabled class="input-disabled" />
|
<input type="email" value={props.user?.email || 'Nostr user'} disabled class="input-disabled" />
|
||||||
<span class="form-hint">Email cannot be changed</span>
|
<span class="form-hint">{props.user?.email ? 'Email cannot be changed' : 'Logged in via Nostr'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p if={state.error} class="error-text">{state.error}</p>
|
<p if={state.error} class="error-text">{state.error}</p>
|
||||||
|
|||||||
@ -51,6 +51,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div if={state.hasNostr} class="nostr-divider">
|
||||||
|
<span>or</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button if={state.hasNostr} class="btn btn-nostr btn-full" onclick={handleNostrLogin} disabled={state.nostrLoading}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 8px; vertical-align: -2px;">
|
||||||
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
|
||||||
|
</svg>
|
||||||
|
{state.nostrLoading ? 'Connecting...' : 'Sign up with Nostr'}
|
||||||
|
</button>
|
||||||
|
|
||||||
<p class="auth-footer">
|
<p class="auth-footer">
|
||||||
Already have an account?
|
Already have an account?
|
||||||
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Sign in</a>
|
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Sign in</a>
|
||||||
@ -117,6 +128,47 @@
|
|||||||
margin-bottom: var(--space-sm);
|
margin-bottom: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nostr-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin: var(--space-lg) 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nostr-divider::before,
|
||||||
|
.nostr-divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nostr {
|
||||||
|
background: #8B5CF6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nostr:hover:not(:disabled) {
|
||||||
|
background: #7C3AED;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nostr:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-footer {
|
.auth-footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: var(--space-lg);
|
margin-top: var(--space-lg);
|
||||||
@ -127,6 +179,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { api } from '../services/api.js'
|
import { api } from '../services/api.js'
|
||||||
|
import { hasNostrExtension, getPublicKey, signEvent, fetchNostrProfile } from '../services/nostr.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
state: {
|
state: {
|
||||||
@ -135,6 +188,14 @@
|
|||||||
password: '',
|
password: '',
|
||||||
error: null,
|
error: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
hasNostr: false,
|
||||||
|
nostrLoading: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onMounted() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.update({ hasNostr: hasNostrExtension() })
|
||||||
|
}, 100)
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleSubmit(e) {
|
async handleSubmit(e) {
|
||||||
@ -152,6 +213,38 @@
|
|||||||
this.update({ error: err.message, loading: false })
|
this.update({ error: err.message, loading: false })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async handleNostrLogin() {
|
||||||
|
this.update({ nostrLoading: true, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { challenge } = await api.nostrChallenge()
|
||||||
|
const nonce = JSON.parse(atob(challenge.split('.')[1])).nonce
|
||||||
|
const pubkey = await getPublicKey()
|
||||||
|
const profile = await fetchNostrProfile(pubkey)
|
||||||
|
|
||||||
|
const unsignedEvent = {
|
||||||
|
kind: 27235,
|
||||||
|
content: nonce,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['u', window.location.origin]],
|
||||||
|
}
|
||||||
|
|
||||||
|
const signed = await signEvent(unsignedEvent)
|
||||||
|
|
||||||
|
const data = await api.nostrVerify({
|
||||||
|
signed_event: JSON.stringify(signed),
|
||||||
|
challenge,
|
||||||
|
profile_name: profile?.name || null,
|
||||||
|
profile_picture: profile?.picture || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nostr login auto-creates — use same callback as register
|
||||||
|
this.props.cbRegister(data)
|
||||||
|
} catch (err) {
|
||||||
|
this.update({ error: err.message || 'Nostr login failed', nostrLoading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</register-page>
|
</register-page>
|
||||||
|
|||||||
@ -33,7 +33,7 @@ async function request(method, path, body) {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// Auto-logout on 401 for any authenticated request (not login/register)
|
// Auto-logout on 401 for any authenticated request (not login/register)
|
||||||
if (res.status === 401 && path !== '/auth/login' && path !== '/auth/register') {
|
if (res.status === 401 && path !== '/auth/login' && path !== '/auth/register' && path !== '/auth/nostr/verify') {
|
||||||
if (onUnauthorized) onUnauthorized()
|
if (onUnauthorized) onUnauthorized()
|
||||||
throw new Error('Session expired — please log in again')
|
throw new Error('Session expired — please log in again')
|
||||||
}
|
}
|
||||||
@ -110,6 +110,11 @@ export const api = {
|
|||||||
// Invites
|
// Invites
|
||||||
createInvite: (data) => request('POST', '/invites', data),
|
createInvite: (data) => request('POST', '/invites', data),
|
||||||
acceptInvite: (token) => request('POST', `/invites/${token}/accept`),
|
acceptInvite: (token) => request('POST', `/invites/${token}/accept`),
|
||||||
|
inviteByNostr: (data) => request('POST', '/invites/nostr', data),
|
||||||
|
|
||||||
|
// Nostr auth
|
||||||
|
nostrChallenge: () => request('GET', '/auth/nostr/challenge'),
|
||||||
|
nostrVerify: (data) => request('POST', '/auth/nostr/verify', data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveAuth(token, user) {
|
export function saveAuth(token, user) {
|
||||||
|
|||||||
75
client/src/services/nostr.js
Normal file
75
client/src/services/nostr.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* NIP-07 browser extension helpers + relay profile fetch
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function hasNostrExtension() {
|
||||||
|
return typeof window !== 'undefined' && !!window.nostr
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublicKey() {
|
||||||
|
return window.nostr.getPublicKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signEvent(event) {
|
||||||
|
return window.nostr.signEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a Nostr kind:0 profile from a relay via WebSocket.
|
||||||
|
* Returns { name, picture } or null on timeout/error.
|
||||||
|
*/
|
||||||
|
export function fetchNostrProfile(pubkeyHex, timeoutMs = 5000) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const relay = 'wss://relay.damus.io'
|
||||||
|
let ws
|
||||||
|
let timer
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
try { ws?.close() } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
cleanup()
|
||||||
|
resolve(null)
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(relay)
|
||||||
|
} catch {
|
||||||
|
cleanup()
|
||||||
|
resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
const subId = 'profile_' + Math.random().toString(36).slice(2, 8)
|
||||||
|
ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pubkeyHex], limit: 1 }]))
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (msg) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(msg.data)
|
||||||
|
if (data[0] === 'EVENT' && data[2]?.kind === 0) {
|
||||||
|
const profile = JSON.parse(data[2].content)
|
||||||
|
cleanup()
|
||||||
|
resolve({
|
||||||
|
name: profile.name || profile.display_name || null,
|
||||||
|
picture: profile.picture || null,
|
||||||
|
})
|
||||||
|
} else if (data[0] === 'EOSE') {
|
||||||
|
// End of stored events — no profile found
|
||||||
|
cleanup()
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors, wait for timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
cleanup()
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
239
server/Cargo.lock
generated
239
server/Cargo.lock
generated
@ -8,6 +8,16 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aead"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@ -78,6 +88,12 @@ dependencies = [
|
|||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.41"
|
version = "0.4.41"
|
||||||
@ -235,6 +251,40 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bech32"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bip39"
|
||||||
|
version = "2.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
|
||||||
|
dependencies = [
|
||||||
|
"bitcoin_hashes",
|
||||||
|
"serde",
|
||||||
|
"unicode-normalization",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitcoin-io"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitcoin_hashes"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
|
||||||
|
dependencies = [
|
||||||
|
"bitcoin-io",
|
||||||
|
"hex-conservative",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@ -262,6 +312,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-padding"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "8.0.2"
|
version = "8.0.2"
|
||||||
@ -301,6 +360,15 @@ version = "1.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbc"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.56"
|
version = "1.2.56"
|
||||||
@ -317,6 +385,30 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20poly1305"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"chacha20",
|
||||||
|
"cipher",
|
||||||
|
"poly1305",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@ -331,6 +423,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.37"
|
version = "0.4.37"
|
||||||
@ -436,6 +539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -858,6 +962,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"md-5",
|
"md-5",
|
||||||
|
"nostr",
|
||||||
"rand",
|
"rand",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"scraper",
|
"scraper",
|
||||||
@ -971,6 +1076,15 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex-conservative"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hkdf"
|
name = "hkdf"
|
||||||
version = "0.12.4"
|
version = "0.12.4"
|
||||||
@ -1285,6 +1399,28 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"block-padding",
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "instant"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@ -1564,6 +1700,30 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nostr"
|
||||||
|
version = "0.44.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bech32",
|
||||||
|
"bip39",
|
||||||
|
"bitcoin_hashes",
|
||||||
|
"cbc",
|
||||||
|
"chacha20",
|
||||||
|
"chacha20poly1305",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"hex",
|
||||||
|
"instant",
|
||||||
|
"scrypt",
|
||||||
|
"secp256k1",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"unicode-normalization",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@ -1641,6 +1801,12 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.75"
|
version = "0.10.75"
|
||||||
@ -1725,6 +1891,16 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pbkdf2"
|
||||||
|
version = "0.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
"hmac",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
@ -1847,6 +2023,17 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "poly1305"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||||
|
dependencies = [
|
||||||
|
"cpufeatures",
|
||||||
|
"opaque-debug",
|
||||||
|
"universal-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@ -2117,6 +2304,15 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "salsa20"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.28"
|
version = "0.1.28"
|
||||||
@ -2147,6 +2343,38 @@ dependencies = [
|
|||||||
"tendril",
|
"tendril",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scrypt"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
|
||||||
|
dependencies = [
|
||||||
|
"password-hash",
|
||||||
|
"pbkdf2",
|
||||||
|
"salsa20",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "secp256k1"
|
||||||
|
version = "0.29.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
||||||
|
dependencies = [
|
||||||
|
"rand",
|
||||||
|
"secp256k1-sys",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "secp256k1-sys"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@ -3164,6 +3392,16 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "universal-hash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@ -3180,6 +3418,7 @@ dependencies = [
|
|||||||
"idna",
|
"idna",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -27,3 +27,4 @@ scraper = "0.22"
|
|||||||
md-5 = "0.10"
|
md-5 = "0.10"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
nostr = { version = "0.44", default-features = false, features = ["std"] }
|
||||||
|
|||||||
1
server/migrations/008_nostr.sql
Normal file
1
server/migrations/008_nostr.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN nostr_pubkey TEXT UNIQUE;
|
||||||
@ -8,7 +8,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
middleware::auth::{create_token, AuthUser},
|
middleware::auth::{create_token, AuthUser},
|
||||||
models::{AuthResponse, LoginRequest, RegisterRequest, UserPublic},
|
models::{self, AuthResponse, LoginRequest, RegisterRequest, UserPublic},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ pub async fn register(
|
|||||||
token,
|
token,
|
||||||
user: UserPublic {
|
user: UserPublic {
|
||||||
id: user_id,
|
id: user_id,
|
||||||
email,
|
email: models::public_email(&email),
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
},
|
},
|
||||||
@ -100,7 +100,7 @@ pub async fn login(
|
|||||||
token,
|
token,
|
||||||
user: UserPublic {
|
user: UserPublic {
|
||||||
id: user_id,
|
id: user_id,
|
||||||
email,
|
email: models::public_email(&email),
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
},
|
},
|
||||||
@ -121,7 +121,7 @@ pub async fn me(
|
|||||||
|
|
||||||
Ok(Json(UserPublic {
|
Ok(Json(UserPublic {
|
||||||
id: auth.user_id,
|
id: auth.user_id,
|
||||||
email: auth.email,
|
email: models::public_email(&auth.email),
|
||||||
display_name: auth.display_name,
|
display_name: auth.display_name,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -9,7 +9,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
middleware::auth::AuthUser,
|
middleware::auth::AuthUser,
|
||||||
models::CreateInviteRequest,
|
models::{CreateInviteRequest, NostrInviteRequest},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -118,3 +118,73 @@ pub async fn accept_invite(
|
|||||||
|
|
||||||
Ok(Json(AcceptInviteResponse { room_id }))
|
Ok(Json(AcceptInviteResponse { room_id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct NostrInviteResponse {
|
||||||
|
pub status: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invite_by_nostr(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Json(body): Json<NostrInviteRequest>,
|
||||||
|
) -> Result<Json<NostrInviteResponse>, (StatusCode, String)> {
|
||||||
|
// Normalize pubkey: if it starts with "npub", decode bech32
|
||||||
|
let pubkey_hex = if body.nostr_pubkey.starts_with("npub") {
|
||||||
|
nostr::prelude::PublicKey::parse(&body.nostr_pubkey)
|
||||||
|
.map(|pk| pk.to_hex())
|
||||||
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid npub format".to_string()))?
|
||||||
|
} else {
|
||||||
|
// Validate it's valid hex
|
||||||
|
if body.nostr_pubkey.len() != 64 || !body.nostr_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Invalid pubkey: must be 64-char hex or npub".to_string()));
|
||||||
|
}
|
||||||
|
body.nostr_pubkey.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify inviter is a member of the room
|
||||||
|
let is_member = sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT user_id FROM room_members WHERE room_id = ? AND user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&body.room_id)
|
||||||
|
.bind(&auth.user_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if is_member.is_none() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Not a member of this room".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup user by nostr_pubkey
|
||||||
|
let target_user = sqlx::query_as::<_, (String, String)>(
|
||||||
|
"SELECT id, display_name FROM users WHERE nostr_pubkey = ?",
|
||||||
|
)
|
||||||
|
.bind(&pubkey_hex)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
match target_user {
|
||||||
|
Some((user_id, display_name)) => {
|
||||||
|
// Add to room
|
||||||
|
sqlx::query("INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)")
|
||||||
|
.bind(&body.room_id)
|
||||||
|
.bind(&user_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(NostrInviteResponse {
|
||||||
|
status: "added".to_string(),
|
||||||
|
display_name: Some(display_name),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Ok(Json(NostrInviteResponse {
|
||||||
|
status: "not_found".to_string(),
|
||||||
|
display_name: None,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod invites;
|
pub mod invites;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
pub mod nostr_auth;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod rooms;
|
pub mod rooms;
|
||||||
pub mod upload;
|
pub mod upload;
|
||||||
|
|||||||
167
server/src/handlers/nostr_auth.rs
Normal file
167
server/src/handlers/nostr_auth.rs
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use nostr::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
middleware::auth::create_token,
|
||||||
|
models::{AuthResponse, NostrChallengeResponse, NostrVerifyRequest, UserPublic},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
struct ChallengeClaims {
|
||||||
|
pub nonce: String,
|
||||||
|
pub exp: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/auth/nostr/challenge — return a short-lived JWT containing a random nonce
|
||||||
|
pub async fn challenge(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<NostrChallengeResponse>, (StatusCode, String)> {
|
||||||
|
// Generate 32 random bytes as hex nonce
|
||||||
|
let mut nonce_bytes = [0u8; 32];
|
||||||
|
use rand::RngCore;
|
||||||
|
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||||
|
let nonce = hex::encode(&nonce_bytes);
|
||||||
|
|
||||||
|
let exp = (chrono::Utc::now().timestamp() + 120) as usize; // 2 minutes
|
||||||
|
|
||||||
|
let claims = ChallengeClaims {
|
||||||
|
nonce,
|
||||||
|
exp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(NostrChallengeResponse { challenge: token }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple hex encoder (avoid adding the `hex` crate just for this)
|
||||||
|
mod hex {
|
||||||
|
pub fn encode(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/auth/nostr/verify — verify signed event, create/login user
|
||||||
|
pub async fn verify(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(body): Json<NostrVerifyRequest>,
|
||||||
|
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||||
|
// 1. Decode challenge JWT, verify not expired, extract nonce
|
||||||
|
let challenge_data = decode::<ChallengeClaims>(
|
||||||
|
&body.challenge,
|
||||||
|
&DecodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
)
|
||||||
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid or expired challenge".to_string()))?;
|
||||||
|
|
||||||
|
let nonce = &challenge_data.claims.nonce;
|
||||||
|
|
||||||
|
// 2. Deserialize signed_event as nostr::Event
|
||||||
|
let event: Event = serde_json::from_str(&body.signed_event)
|
||||||
|
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid event JSON: {}", e)))?;
|
||||||
|
|
||||||
|
// 3. Verify Schnorr signature
|
||||||
|
if !event.verify_signature() {
|
||||||
|
return Err((StatusCode::UNAUTHORIZED, "Invalid event signature".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verify event.content == nonce
|
||||||
|
if event.content.as_str() != nonce.as_str() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Nonce mismatch".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Verify event.created_at within 5 minutes
|
||||||
|
let now = chrono::Utc::now().timestamp() as u64;
|
||||||
|
let event_ts = event.created_at.as_secs();
|
||||||
|
if now.abs_diff(event_ts) > 300 {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Event timestamp too far off".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Extract pubkey hex
|
||||||
|
let pubkey_hex = event.pubkey.to_hex();
|
||||||
|
|
||||||
|
// 7. Lookup user by nostr_pubkey
|
||||||
|
let existing = sqlx::query_as::<_, (String, String, String, Option<String>)>(
|
||||||
|
"SELECT id, email, display_name, avatar_url FROM users WHERE nostr_pubkey = ?",
|
||||||
|
)
|
||||||
|
.bind(&pubkey_hex)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let (user_id, email, display_name, avatar_url) = if let Some(user) = existing {
|
||||||
|
// Update avatar if provided and user doesn't have a custom one
|
||||||
|
if let Some(ref pic) = body.profile_picture {
|
||||||
|
if user.3.is_none() || user.3.as_deref() == Some("") {
|
||||||
|
let _ = sqlx::query("UPDATE users SET avatar_url = ? WHERE id = ?")
|
||||||
|
.bind(pic)
|
||||||
|
.bind(&user.0)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
(user.0, user.1, user.2, Some(pic.clone()))
|
||||||
|
} else {
|
||||||
|
user
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
let user_id = Uuid::new_v4().to_string();
|
||||||
|
let sentinel_email = format!("nostr:{}", &pubkey_hex[..16]);
|
||||||
|
let display_name = body
|
||||||
|
.profile_name
|
||||||
|
.clone()
|
||||||
|
.filter(|n| !n.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let npub = PublicKey::from_hex(&pubkey_hex)
|
||||||
|
.map(|pk| pk.to_bech32().unwrap_or_default())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if npub.len() > 8 {
|
||||||
|
format!("npub...{}", &npub[npub.len() - 8..])
|
||||||
|
} else {
|
||||||
|
format!("nostr-{}", &pubkey_hex[..8])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let avatar_url = body.profile_picture.clone();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id, email, display_name, password_hash, nostr_pubkey, avatar_url) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.bind(&sentinel_email)
|
||||||
|
.bind(&display_name)
|
||||||
|
.bind("")
|
||||||
|
.bind(&pubkey_hex)
|
||||||
|
.bind(&avatar_url)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
(user_id, sentinel_email, display_name, avatar_url)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 8. Issue JWT, return AuthResponse
|
||||||
|
let token = create_token(&user_id, &email, &display_name, &state.jwt_secret)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(AuthResponse {
|
||||||
|
token,
|
||||||
|
user: UserPublic {
|
||||||
|
id: user_id,
|
||||||
|
email: crate::models::public_email(&email),
|
||||||
|
display_name,
|
||||||
|
avatar_url,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
middleware::auth::{create_token, AuthUser},
|
middleware::auth::{create_token, AuthUser},
|
||||||
models::{AuthResponse, UserPublic},
|
models::{self, AuthResponse, UserPublic},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ pub async fn update_profile(
|
|||||||
token,
|
token,
|
||||||
user: UserPublic {
|
user: UserPublic {
|
||||||
id: auth.user_id,
|
id: auth.user_id,
|
||||||
email: auth.email,
|
email: models::public_email(&auth.email),
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
},
|
},
|
||||||
@ -136,7 +136,7 @@ pub async fn upload_avatar(
|
|||||||
token,
|
token,
|
||||||
user: UserPublic {
|
user: UserPublic {
|
||||||
id: auth.user_id,
|
id: auth.user_id,
|
||||||
email: auth.email,
|
email: models::public_email(&auth.email),
|
||||||
display_name: auth.display_name,
|
display_name: auth.display_name,
|
||||||
avatar_url: Some(avatar_url),
|
avatar_url: Some(avatar_url),
|
||||||
},
|
},
|
||||||
@ -173,7 +173,7 @@ pub async fn delete_avatar(
|
|||||||
token,
|
token,
|
||||||
user: UserPublic {
|
user: UserPublic {
|
||||||
id: auth.user_id,
|
id: auth.user_id,
|
||||||
email: auth.email,
|
email: models::public_email(&auth.email),
|
||||||
display_name: auth.display_name,
|
display_name: auth.display_name,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -8,7 +8,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
middleware::auth::AuthUser,
|
middleware::auth::AuthUser,
|
||||||
models::{CreateRoomRequest, MessagePayload, PaginationParams, Room, RoomResponse, UserPublic},
|
models::{self, CreateRoomRequest, MessagePayload, PaginationParams, Room, RoomResponse, UserPublic},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ pub async fn create_room(
|
|||||||
created_at: chrono::Utc::now().to_rfc3339(),
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
members: vec![UserPublic {
|
members: vec![UserPublic {
|
||||||
id: auth.user_id,
|
id: auth.user_id,
|
||||||
email: auth.email,
|
email: models::public_email(&auth.email),
|
||||||
display_name: auth.display_name,
|
display_name: auth.display_name,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
}],
|
}],
|
||||||
@ -94,7 +94,7 @@ pub async fn list_rooms(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, email, display_name, avatar_url)| UserPublic {
|
.map(|(id, email, display_name, avatar_url)| UserPublic {
|
||||||
id,
|
id,
|
||||||
email,
|
email: models::public_email(&email),
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -253,6 +253,16 @@ async fn main() {
|
|||||||
Err(e) => panic!("Failed to run migration 007: {}", e),
|
Err(e) => panic!("Failed to run migration 007: {}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run migration 008 - nostr pubkey on users
|
||||||
|
let migration_008 = include_str!("../migrations/008_nostr.sql");
|
||||||
|
match sqlx::raw_sql(migration_008).execute(&db).await {
|
||||||
|
Ok(_) => tracing::info!("Migration 008 applied"),
|
||||||
|
Err(e) if e.to_string().contains("duplicate column") => {
|
||||||
|
tracing::debug!("Migration 008 already applied, skipping");
|
||||||
|
}
|
||||||
|
Err(e) => panic!("Failed to run migration 008: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!("Database initialized");
|
tracing::info!("Database initialized");
|
||||||
|
|
||||||
let (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(4096);
|
let (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(4096);
|
||||||
@ -278,6 +288,9 @@ async fn main() {
|
|||||||
.route("/api/auth/register", post(handlers::auth::register))
|
.route("/api/auth/register", post(handlers::auth::register))
|
||||||
.route("/api/auth/login", post(handlers::auth::login))
|
.route("/api/auth/login", post(handlers::auth::login))
|
||||||
.route("/api/auth/me", get(handlers::auth::me))
|
.route("/api/auth/me", get(handlers::auth::me))
|
||||||
|
// Nostr auth routes
|
||||||
|
.route("/api/auth/nostr/challenge", get(handlers::nostr_auth::challenge))
|
||||||
|
.route("/api/auth/nostr/verify", post(handlers::nostr_auth::verify))
|
||||||
// Profile routes
|
// Profile routes
|
||||||
.route("/api/auth/profile", put(handlers::profile::update_profile))
|
.route("/api/auth/profile", put(handlers::profile::update_profile))
|
||||||
.route("/api/auth/avatar", post(handlers::profile::upload_avatar).delete(handlers::profile::delete_avatar))
|
.route("/api/auth/avatar", post(handlers::profile::upload_avatar).delete(handlers::profile::delete_avatar))
|
||||||
@ -295,6 +308,7 @@ async fn main() {
|
|||||||
// Invite routes
|
// Invite routes
|
||||||
.route("/api/invites", post(handlers::invites::create_invite))
|
.route("/api/invites", post(handlers::invites::create_invite))
|
||||||
.route("/api/invites/:token/accept", post(handlers::invites::accept_invite))
|
.route("/api/invites/:token/accept", post(handlers::invites::accept_invite))
|
||||||
|
.route("/api/invites/nostr", post(handlers::invites::invite_by_nostr))
|
||||||
// Uploaded files (avatars)
|
// Uploaded files (avatars)
|
||||||
.nest_service("/uploads", ServeDir::new("uploads"))
|
.nest_service("/uploads", ServeDir::new("uploads"))
|
||||||
// WebSocket
|
// WebSocket
|
||||||
|
|||||||
@ -285,3 +285,33 @@ pub struct PaginationParams {
|
|||||||
fn default_limit() -> i64 {
|
fn default_limit() -> i64 {
|
||||||
50
|
50
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns "" if the email is a sentinel nostr: value, otherwise returns it as-is.
|
||||||
|
pub fn public_email(email: &str) -> String {
|
||||||
|
if email.starts_with("nostr:") {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
email.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nostr auth types ──
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct NostrChallengeResponse {
|
||||||
|
pub challenge: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct NostrVerifyRequest {
|
||||||
|
pub signed_event: String,
|
||||||
|
pub challenge: String,
|
||||||
|
pub profile_name: Option<String>,
|
||||||
|
pub profile_picture: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct NostrInviteRequest {
|
||||||
|
pub room_id: String,
|
||||||
|
pub nostr_pubkey: String,
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user