Release v0.0.1 - Initial OAuth2 server implementation
- Add Nostr OAuth2 server with NIP-98 authentication support - Implement OAuth2 authorization and token endpoints - Add .well-known/openid-configuration discovery endpoint - Include Dockerfile for containerized deployment - Add Claude Code release command for version management - Create example configuration file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
323
internal/nostr/fetcher.go
Normal file
323
internal/nostr/fetcher.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
const (
|
||||
// FetchTimeout is how long to wait for relay responses
|
||||
FetchTimeout = 10 * time.Second
|
||||
// CacheTTL is how long to cache relay lists and profiles
|
||||
CacheTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Fetcher handles fetching relay lists and profiles from Nostr relays
|
||||
type Fetcher struct {
|
||||
fallbackRelays []string
|
||||
relayCache map[string]*relayListCacheEntry
|
||||
profileCache map[string]*profileCacheEntry
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type relayListCacheEntry struct {
|
||||
Relays []Nip65Relay
|
||||
FetchedAt time.Time
|
||||
}
|
||||
|
||||
type profileCacheEntry struct {
|
||||
Profile *ProfileMetadata
|
||||
FetchedAt time.Time
|
||||
}
|
||||
|
||||
// NewFetcher creates a new Fetcher with the given fallback relays
|
||||
func NewFetcher(fallbackRelays []string) *Fetcher {
|
||||
return &Fetcher{
|
||||
fallbackRelays: fallbackRelays,
|
||||
relayCache: make(map[string]*relayListCacheEntry),
|
||||
profileCache: make(map[string]*profileCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// FetchRelayList fetches a user's NIP-65 relay list (kind 10002)
|
||||
func (f *Fetcher) FetchRelayList(ctx context.Context, pubkey string) []Nip65Relay {
|
||||
// Check cache first
|
||||
f.mu.RLock()
|
||||
if entry, ok := f.relayCache[pubkey]; ok {
|
||||
if time.Since(entry.FetchedAt) < CacheTTL {
|
||||
f.mu.RUnlock()
|
||||
return entry.Relays
|
||||
}
|
||||
}
|
||||
f.mu.RUnlock()
|
||||
|
||||
// Fetch from relays
|
||||
relays := f.doFetchRelayList(ctx, pubkey)
|
||||
|
||||
// Cache result
|
||||
f.mu.Lock()
|
||||
f.relayCache[pubkey] = &relayListCacheEntry{
|
||||
Relays: relays,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
return relays
|
||||
}
|
||||
|
||||
func (f *Fetcher) doFetchRelayList(ctx context.Context, pubkey string) []Nip65Relay {
|
||||
ctx, cancel := context.WithTimeout(ctx, FetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
filter := nostr.Filter{
|
||||
Kinds: []int{10002},
|
||||
Authors: []string{pubkey},
|
||||
Limit: 10,
|
||||
}
|
||||
|
||||
events := f.queryRelays(ctx, f.fallbackRelays, filter)
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the most recent event
|
||||
var latest *nostr.Event
|
||||
for _, ev := range events {
|
||||
if latest == nil || ev.CreatedAt > latest.CreatedAt {
|
||||
latest = ev
|
||||
}
|
||||
}
|
||||
|
||||
// Parse relay tags
|
||||
var relays []Nip65Relay
|
||||
for _, tag := range latest.Tags {
|
||||
if len(tag) >= 2 && tag[0] == "r" {
|
||||
relay := Nip65Relay{
|
||||
URL: tag[1],
|
||||
Read: true,
|
||||
Write: true,
|
||||
}
|
||||
// Check for read/write marker
|
||||
if len(tag) >= 3 {
|
||||
switch tag[2] {
|
||||
case "read":
|
||||
relay.Write = false
|
||||
case "write":
|
||||
relay.Read = false
|
||||
}
|
||||
}
|
||||
relays = append(relays, relay)
|
||||
}
|
||||
}
|
||||
|
||||
return relays
|
||||
}
|
||||
|
||||
// FetchProfile fetches a user's profile metadata (kind 0)
|
||||
// It first fetches the user's relay list, then queries those relays + fallbacks
|
||||
func (f *Fetcher) FetchProfile(ctx context.Context, pubkey string) *ProfileMetadata {
|
||||
// Check cache first
|
||||
f.mu.RLock()
|
||||
if entry, ok := f.profileCache[pubkey]; ok {
|
||||
if time.Since(entry.FetchedAt) < CacheTTL {
|
||||
f.mu.RUnlock()
|
||||
return entry.Profile
|
||||
}
|
||||
}
|
||||
f.mu.RUnlock()
|
||||
|
||||
// First, get the user's relay list
|
||||
userRelays := f.FetchRelayList(ctx, pubkey)
|
||||
|
||||
// Build relay list: user's read relays + fallbacks
|
||||
relayURLs := make([]string, 0, len(userRelays)+len(f.fallbackRelays))
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// Add user's read relays first (more likely to have their profile)
|
||||
for _, r := range userRelays {
|
||||
if r.Read && !seen[r.URL] {
|
||||
relayURLs = append(relayURLs, r.URL)
|
||||
seen[r.URL] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Add fallback relays
|
||||
for _, url := range f.fallbackRelays {
|
||||
if !seen[url] {
|
||||
relayURLs = append(relayURLs, url)
|
||||
seen[url] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch profile
|
||||
profile := f.doFetchProfile(ctx, pubkey, relayURLs)
|
||||
|
||||
// Cache result (even if nil)
|
||||
f.mu.Lock()
|
||||
f.profileCache[pubkey] = &profileCacheEntry{
|
||||
Profile: profile,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
func (f *Fetcher) doFetchProfile(ctx context.Context, pubkey string, relayURLs []string) *ProfileMetadata {
|
||||
ctx, cancel := context.WithTimeout(ctx, FetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
filter := nostr.Filter{
|
||||
Kinds: []int{0},
|
||||
Authors: []string{pubkey},
|
||||
Limit: 10,
|
||||
}
|
||||
|
||||
events := f.queryRelays(ctx, relayURLs, filter)
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the most recent event
|
||||
var latest *nostr.Event
|
||||
for _, ev := range events {
|
||||
if latest == nil || ev.CreatedAt > latest.CreatedAt {
|
||||
latest = ev
|
||||
}
|
||||
}
|
||||
|
||||
// Parse profile content
|
||||
var content map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(latest.Content), &content); err != nil {
|
||||
log.Printf("Failed to parse profile content for %s: %v", pubkey, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
profile := &ProfileMetadata{
|
||||
Pubkey: pubkey,
|
||||
}
|
||||
|
||||
if v, ok := content["name"].(string); ok {
|
||||
profile.Name = v
|
||||
}
|
||||
if v, ok := content["display_name"].(string); ok {
|
||||
profile.DisplayName = v
|
||||
}
|
||||
if v, ok := content["displayName"].(string); ok && profile.DisplayName == "" {
|
||||
profile.DisplayName = v
|
||||
}
|
||||
if v, ok := content["picture"].(string); ok {
|
||||
profile.Picture = v
|
||||
}
|
||||
if v, ok := content["banner"].(string); ok {
|
||||
profile.Banner = v
|
||||
}
|
||||
if v, ok := content["about"].(string); ok {
|
||||
profile.About = v
|
||||
}
|
||||
if v, ok := content["website"].(string); ok {
|
||||
profile.Website = v
|
||||
}
|
||||
if v, ok := content["nip05"].(string); ok {
|
||||
profile.Nip05 = v
|
||||
}
|
||||
if v, ok := content["lud06"].(string); ok {
|
||||
profile.Lud06 = v
|
||||
}
|
||||
if v, ok := content["lud16"].(string); ok {
|
||||
profile.Lud16 = v
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
// queryRelays queries multiple relays and collects events
|
||||
func (f *Fetcher) queryRelays(ctx context.Context, relayURLs []string, filter nostr.Filter) []*nostr.Event {
|
||||
var (
|
||||
events []*nostr.Event
|
||||
eventsMu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
// Query each relay concurrently
|
||||
for _, url := range relayURLs {
|
||||
wg.Add(1)
|
||||
go func(relayURL string) {
|
||||
defer wg.Done()
|
||||
|
||||
relay, err := nostr.RelayConnect(ctx, relayURL)
|
||||
if err != nil {
|
||||
// Silently skip failed relays
|
||||
return
|
||||
}
|
||||
defer relay.Close()
|
||||
|
||||
sub, err := relay.Subscribe(ctx, []nostr.Filter{filter})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer sub.Unsub()
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-sub.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
eventsMu.Lock()
|
||||
events = append(events, ev)
|
||||
eventsMu.Unlock()
|
||||
case <-sub.EndOfStoredEvents:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}(url)
|
||||
}
|
||||
|
||||
// Wait for all queries to complete or timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
// GetCachedProfile returns a cached profile if available and not expired
|
||||
func (f *Fetcher) GetCachedProfile(pubkey string) *ProfileMetadata {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
|
||||
if entry, ok := f.profileCache[pubkey]; ok {
|
||||
if time.Since(entry.FetchedAt) < CacheTTL {
|
||||
return entry.Profile
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCachedRelayList returns a cached relay list if available and not expired
|
||||
func (f *Fetcher) GetCachedRelayList(pubkey string) []Nip65Relay {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
|
||||
if entry, ok := f.relayCache[pubkey]; ok {
|
||||
if time.Since(entry.FetchedAt) < CacheTTL {
|
||||
return entry.Relays
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
55
internal/nostr/pubkey.go
Normal file
55
internal/nostr/pubkey.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"github.com/nbd-wtf/go-nostr/nip19"
|
||||
)
|
||||
|
||||
// PubkeyToNpub converts a hex public key to bech32 npub format
|
||||
func PubkeyToNpub(hexPubkey string) string {
|
||||
npub, err := nip19.EncodePublicKey(hexPubkey)
|
||||
if err != nil {
|
||||
// If encoding fails, return truncated hex
|
||||
if len(hexPubkey) > 16 {
|
||||
return hexPubkey[:16] + "..."
|
||||
}
|
||||
return hexPubkey
|
||||
}
|
||||
return npub
|
||||
}
|
||||
|
||||
// NpubToPubkey converts a bech32 npub to hex public key
|
||||
func NpubToPubkey(npub string) (string, error) {
|
||||
prefix, data, err := nip19.Decode(npub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if prefix != "npub" {
|
||||
return "", err
|
||||
}
|
||||
return data.(string), nil
|
||||
}
|
||||
|
||||
// TruncateNpub returns a shortened npub for display
|
||||
func TruncateNpub(npub string) string {
|
||||
if len(npub) <= 20 {
|
||||
return npub
|
||||
}
|
||||
return npub[:12] + "..." + npub[len(npub)-8:]
|
||||
}
|
||||
|
||||
// GenerateUsername creates a username from a pubkey
|
||||
// Prefers NIP-05 identifier if available, otherwise uses npub prefix
|
||||
func GenerateUsername(hexPubkey string) string {
|
||||
npub := PubkeyToNpub(hexPubkey)
|
||||
// Use first 12 chars of npub (npub1 + 7 chars)
|
||||
if len(npub) > 12 {
|
||||
return npub[:12]
|
||||
}
|
||||
return npub
|
||||
}
|
||||
|
||||
// GeneratePlaceholderEmail creates a placeholder email for Gitea
|
||||
func GeneratePlaceholderEmail(hexPubkey string) string {
|
||||
username := GenerateUsername(hexPubkey)
|
||||
return username + "@nostr.local"
|
||||
}
|
||||
51
internal/nostr/relays.go
Normal file
51
internal/nostr/relays.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package nostr
|
||||
|
||||
// Nip65Relay represents a relay from a user's NIP-65 relay list
|
||||
type Nip65Relay struct {
|
||||
URL string `json:"url"`
|
||||
Read bool `json:"read"`
|
||||
Write bool `json:"write"`
|
||||
}
|
||||
|
||||
// ProfileMetadata represents parsed kind 0 profile data
|
||||
type ProfileMetadata struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name string `json:"name,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
Banner string `json:"banner,omitempty"`
|
||||
About string `json:"about,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Nip05 string `json:"nip05,omitempty"`
|
||||
Lud06 string `json:"lud06,omitempty"`
|
||||
Lud16 string `json:"lud16,omitempty"`
|
||||
}
|
||||
|
||||
// GetUsername returns the best username from profile metadata
|
||||
func (p *ProfileMetadata) GetUsername() string {
|
||||
// Prefer NIP-05 identifier (without domain for uniqueness)
|
||||
if p.Nip05 != "" {
|
||||
return p.Nip05
|
||||
}
|
||||
// Then name
|
||||
if p.Name != "" {
|
||||
return p.Name
|
||||
}
|
||||
// Then display_name
|
||||
if p.DisplayName != "" {
|
||||
return p.DisplayName
|
||||
}
|
||||
// Fallback to truncated npub
|
||||
return GenerateUsername(p.Pubkey)
|
||||
}
|
||||
|
||||
// GetDisplayName returns the best display name
|
||||
func (p *ProfileMetadata) GetDisplayName() string {
|
||||
if p.DisplayName != "" {
|
||||
return p.DisplayName
|
||||
}
|
||||
if p.Name != "" {
|
||||
return p.Name
|
||||
}
|
||||
return TruncateNpub(PubkeyToNpub(p.Pubkey))
|
||||
}
|
||||
Reference in New Issue
Block a user