Browse Source

bug-fixes

master
Silberengel 4 weeks ago
parent
commit
95dac270e9
  1. 15
      internal/generator/html.go
  2. 15
      internal/nostr/client.go
  3. 25
      internal/nostr/ebooks.go
  4. 76
      internal/nostr/feed.go
  5. 11
      internal/nostr/naddr.go
  6. 4
      internal/server/handlers.go
  7. 23
      static/css/main.css
  8. 6
      static/css/responsive.css
  9. 80
      templates/base.html
  10. 4
      templates/blog.html
  11. 12
      templates/components.html
  12. 2
      templates/ebooks.html

15
internal/generator/html.go

@ -31,6 +31,20 @@ func getTemplateFuncs() template.FuncMap {
"shortenPubkey": func(pubkey string) string { "shortenPubkey": func(pubkey string) string {
return nostr.ShortenPubkey(pubkey) return nostr.ShortenPubkey(pubkey)
}, },
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict requires an even number of arguments")
}
dict := make(map[string]interface{})
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
},
} }
} }
@ -463,6 +477,7 @@ func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, event
SiteURL: g.siteURL, SiteURL: g.siteURL,
CurrentYear: time.Now().Year(), CurrentYear: time.Now().Year(),
WikiPages: []WikiPageInfo{}, // Will be populated if needed WikiPages: []WikiPageInfo{}, // Will be populated if needed
BlogItems: []BlogItemInfo{}, // Empty - no blog items on contact page
FeedItems: []FeedItemInfo{}, // Empty - feed only on landing page FeedItems: []FeedItemInfo{}, // Empty - feed only on landing page
Profiles: make(map[string]*nostr.Profile), // Empty profiles for contact page Profiles: make(map[string]*nostr.Profile), // Empty profiles for contact page
}, },

15
internal/nostr/client.go

@ -255,6 +255,21 @@ func (c *Client) IsConnected() bool {
return true // We connect on-demand return true // We connect on-demand
} }
// GetRelays returns all configured relay URLs (primary, fallback, additional fallback)
func (c *Client) GetRelays() []string {
relays := []string{}
if c.primaryRelay != "" {
relays = append(relays, c.primaryRelay)
}
if c.fallbackRelay != "" {
relays = append(relays, c.fallbackRelay)
}
if c.additionalFallback != "" {
relays = append(relays, c.additionalFallback)
}
return relays
}
// HealthCheck performs a health check on the relays // HealthCheck performs a health check on the relays
func (c *Client) HealthCheck(ctx context.Context, timeout time.Duration) error { func (c *Client) HealthCheck(ctx context.Context, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout) ctx, cancel := context.WithTimeout(ctx, timeout)

25
internal/nostr/ebooks.go

@ -142,10 +142,27 @@ func (es *EBooksService) FetchTopLevelIndexEvents(ctx context.Context) ([]EBookI
} }
} }
// Build naddr // Build naddr - create proper bech32-encoded naddr
// Format: naddr1 + bech32 encoded (kind:pubkey:dtag:relay) // Extract relay hints from event tags if available
// For simplicity, we'll use the format: kind:pubkey:dtag var relays []string
ebook.Naddr = fmt.Sprintf("%d:%s:%s", es.indexKind, ebook.Author, ebook.DTag) for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "relays" {
relays = append(relays, tag[1:]...)
}
}
// If no relays in tags, use the relay we fetched from
if len(relays) == 0 {
relays = []string{es.relayURL}
}
naddr, err := CreateNaddr(es.indexKind, ebook.Author, ebook.DTag, relays)
if err != nil {
log.Printf("Failed to create naddr for ebook %s: %v", ebook.DTag, err)
// Fallback to kind:pubkey:dtag format
ebook.Naddr = fmt.Sprintf("%d:%s:%s", es.indexKind, ebook.Author, ebook.DTag)
} else {
ebook.Naddr = naddr
}
ebooks = append(ebooks, ebook) ebooks = append(ebooks, ebook)
} }

76
internal/nostr/feed.go

@ -3,6 +3,7 @@ package nostr
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"time" "time"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
@ -22,38 +23,71 @@ func NewFeedService(client *Client, feedKind int) *FeedService {
} }
} }
// FetchFeedItems fetches recent feed events from a specific relay // FetchFeedItems fetches recent feed events from the configured feed relay only, with retries
func (fs *FeedService) FetchFeedItems(ctx context.Context, relay string, maxEvents int) ([]FeedItem, error) { func (fs *FeedService) FetchFeedItems(ctx context.Context, feedRelay string, maxEvents int) ([]FeedItem, error) {
if feedRelay == "" {
return nil, fmt.Errorf("feed relay not configured")
}
filter := nostr.Filter{ filter := nostr.Filter{
Kinds: []int{fs.feedKind}, Kinds: []int{fs.feedKind},
Limit: maxEvents, Limit: maxEvents,
} }
logFilter(filter, fmt.Sprintf("feed (kind %d)", fs.feedKind)) logFilter(filter, fmt.Sprintf("feed (kind %d)", fs.feedKind))
// Connect to the specific feed relay (not the default client relays) const maxRetries = 3
relayConn, err := fs.client.ConnectToRelay(ctx, relay) var lastErr error
if err != nil {
return nil, fmt.Errorf("failed to connect to feed relay %s: %w", relay, err)
}
defer relayConn.Close()
events, err := relayConn.QuerySync(ctx, filter) // Retry up to 3 times for the configured feed relay only
if err != nil { for attempt := 1; attempt <= maxRetries; attempt++ {
return nil, fmt.Errorf("failed to fetch feed events from %s: %w", relay, err) if attempt > 1 {
} log.Printf("Retrying feed relay %s (attempt %d/%d)", feedRelay, attempt, maxRetries)
// Wait a bit before retrying (exponential backoff: 1s, 2s)
waitTime := time.Duration(attempt-1) * time.Second
time.Sleep(waitTime)
}
// Create a context with timeout for this attempt
attemptCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
items := make([]FeedItem, 0, len(events)) // Connect to the relay
for _, event := range events { relayConn, err := fs.client.ConnectToRelay(attemptCtx, feedRelay)
item := FeedItem{ if err != nil {
Author: event.PubKey, log.Printf("Failed to connect to feed relay %s (attempt %d/%d): %v", feedRelay, attempt, maxRetries, err)
Content: event.Content, lastErr = err
Time: time.Unix(int64(event.CreatedAt), 0), cancel()
Link: fmt.Sprintf("https://alexandria.gitcitadel.eu/events?id=nevent1%s", event.ID), continue // Try next attempt
} }
items = append(items, item)
// Try to fetch events
events, err := relayConn.QuerySync(attemptCtx, filter)
relayConn.Close()
cancel()
if err != nil {
log.Printf("Failed to fetch feed events from %s (attempt %d/%d): %v", feedRelay, attempt, maxRetries, err)
lastErr = err
continue // Try next attempt
}
// Success! Convert events to feed items
items := make([]FeedItem, 0, len(events))
for _, event := range events {
item := FeedItem{
Author: event.PubKey,
Content: event.Content,
Time: time.Unix(int64(event.CreatedAt), 0),
Link: fmt.Sprintf("https://alexandria.gitcitadel.eu/events?id=nevent1%s", event.ID),
}
items = append(items, item)
}
log.Printf("Successfully fetched %d feed items from %s (attempt %d/%d)", len(items), feedRelay, attempt, maxRetries)
return items, nil
} }
return items, nil // All retries failed
return nil, fmt.Errorf("failed to fetch feed from %s after %d retries (last error: %w)", feedRelay, maxRetries, lastErr)
} }
// FeedItem represents a feed item // FeedItem represents a feed item

11
internal/nostr/naddr.go

@ -69,3 +69,14 @@ func (n *Naddr) ToFilter() nostr.Filter {
}, },
} }
} }
// CreateNaddr creates a bech32-encoded naddr from kind, pubkey, dtag, and optional relays
func CreateNaddr(kind int, pubkey string, dtag string, relays []string) (string, error) {
// nip19.EncodeEntity signature: (publicKey string, kind int, identifier string, relays []string)
naddr, err := nip19.EncodeEntity(pubkey, kind, dtag, relays)
if err != nil {
return "", fmt.Errorf("failed to encode naddr: %w", err)
}
return naddr, nil
}

4
internal/server/handlers.go

@ -364,8 +364,8 @@ func (s *Server) middleware(next http.Handler) http.Handler {
w.Header().Set("X-XSS-Protection", "1; mode=block") w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// CSP header // CSP header - allow unpkg.com for Lucide icons
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;")
// Log request // Log request
start := time.Now() start := time.Now()

23
static/css/main.css

@ -43,6 +43,8 @@ body {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin: 0;
padding: 0;
padding-top: 80px; /* Space for fixed header */ padding-top: 80px; /* Space for fixed header */
} }
@ -72,10 +74,15 @@ header {
right: 0; right: 0;
width: 100%; width: 100%;
z-index: 1000; z-index: 1000;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin: 0;
padding: 0;
} }
.navbar { .navbar {
padding: 1rem 0; padding: 1rem 0;
width: 100%;
margin: 0;
} }
.nav-container { .nav-container {
@ -111,12 +118,24 @@ header {
list-style: none; list-style: none;
gap: 2rem; gap: 2rem;
align-items: center; align-items: center;
margin: 0;
padding: 0;
}
.nav-menu li {
margin: 0;
padding: 0;
list-style: none;
} }
.nav-menu a { .nav-menu a {
color: var(--text-primary); color: var(--text-primary);
text-decoration: none; text-decoration: none;
transition: color 0.2s; transition: color 0.2s;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
} }
.nav-menu a:hover { .nav-menu a:hover {
@ -168,9 +187,12 @@ header {
list-style: none; list-style: none;
min-width: 200px; min-width: 200px;
padding: 0.5rem 0; padding: 0.5rem 0;
margin: 0;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s; transition: opacity 0.2s, visibility 0.2s;
z-index: 1001;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
} }
.dropdown:hover .dropdown-menu, .dropdown:hover .dropdown-menu,
@ -196,6 +218,7 @@ header {
padding: 2rem 1rem; padding: 2rem 1rem;
gap: 2rem; gap: 2rem;
flex: 1; flex: 1;
min-height: calc(100vh - 80px - 4rem); /* Account for header and padding */
} }
.wiki-layout { .wiki-layout {

6
static/css/responsive.css

@ -33,16 +33,18 @@
.nav-menu { .nav-menu {
position: fixed; position: fixed;
top: 60px; top: 80px; /* Match header height */
left: -100%; left: -100%;
width: 100%; width: 100%;
height: calc(100vh - 60px); height: calc(100vh - 80px);
background: var(--bg-secondary); background: var(--bg-secondary);
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
padding: 2rem; padding: 2rem;
transition: left 0.3s; transition: left 0.3s;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
z-index: 999;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.2);
} }
.nav-menu.active { .nav-menu.active {

80
templates/base.html

@ -74,7 +74,7 @@
{{block "content" .}}{{end}} {{block "content" .}}{{end}}
</main> </main>
{{if or (eq .CanonicalURL .SiteURL) (eq .CanonicalURL (printf "%s/" .SiteURL))}} {{if and (or (eq .CanonicalURL .SiteURL) (eq .CanonicalURL (printf "%s/" .SiteURL))) (gt (len .FeedItems) 0)}}
<aside class="feed-sidebar" aria-label="Recent notes"> <aside class="feed-sidebar" aria-label="Recent notes">
{{template "feed" .}} {{template "feed" .}}
</aside> </aside>
@ -87,12 +87,53 @@
<script> <script>
// Initialize Lucide icons // Initialize Lucide icons
let iconInitInProgress = false;
let iconInitTimeout = null;
function initIcons() { function initIcons() {
if (typeof lucide !== 'undefined') { if (iconInitInProgress || typeof lucide === 'undefined') {
lucide.createIcons(); return;
}
iconInitInProgress = true;
try {
// Only initialize icons on elements that haven't been processed yet
// Lucide marks processed elements, so we can target unprocessed ones
const unprocessedIcons = document.querySelectorAll('[data-lucide]:not([data-lucide-processed])');
if (unprocessedIcons.length > 0) {
lucide.createIcons();
// Mark as processed to prevent re-processing
unprocessedIcons.forEach(el => {
el.setAttribute('data-lucide-processed', 'true');
});
}
} catch (e) {
console.error('Error initializing Lucide icons:', e);
} finally {
iconInitInProgress = false;
}
}
// Debounced icon initialization to prevent excessive calls
function debouncedInitIcons() {
if (iconInitTimeout) {
clearTimeout(iconInitTimeout);
} }
iconInitTimeout = setTimeout(function() {
if (!iconInitInProgress) {
initIcons();
}
}, 200);
}
// Initialize icons on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
setTimeout(initIcons, 100);
});
} else {
setTimeout(initIcons, 100);
} }
initIcons();
// Mobile menu toggle (optional - menu works without JS) // Mobile menu toggle (optional - menu works without JS)
if (document.querySelector('.mobile-menu-toggle')) { if (document.querySelector('.mobile-menu-toggle')) {
@ -105,10 +146,37 @@
} }
// Reinitialize icons when DOM changes (for dynamically added content) // Reinitialize icons when DOM changes (for dynamically added content)
// Only observe for new elements with data-lucide that haven't been processed
const observer = new MutationObserver(function(mutations) { const observer = new MutationObserver(function(mutations) {
initIcons(); let hasNewIcons = false;
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) { // Element node
// Check if this node or its children have unprocessed icons
if (node.hasAttribute && node.hasAttribute('data-lucide') && !node.hasAttribute('data-lucide-processed')) {
hasNewIcons = true;
break;
}
if (node.querySelector && node.querySelector('[data-lucide]:not([data-lucide-processed])')) {
hasNewIcons = true;
break;
}
}
}
}
if (hasNewIcons) break;
}
if (hasNewIcons && !iconInitInProgress) {
debouncedInitIcons();
}
});
// Only observe for child additions, not attribute changes (to avoid Lucide's own DOM changes)
observer.observe(document.body, {
childList: true,
subtree: true
}); });
observer.observe(document.body, { childList: true, subtree: true });
</script> </script>
</body> </body>
</html> </html>

4
templates/blog.html

@ -4,7 +4,7 @@
<aside class="blog-sidebar"> <aside class="blog-sidebar">
<div class="blog-header"> <div class="blog-header">
{{if .BlogIndexAuthor}} {{if .BlogIndexAuthor}}
<div class="blog-author-handle">{{template "user-badge-simple" .BlogIndexAuthor}}</div> <div class="blog-author-handle">{{template "user-badge-simple" (dict "Pubkey" .BlogIndexAuthor "Profiles" $.Profiles)}}</div>
{{end}} {{end}}
{{if .BlogIndexImage}} {{if .BlogIndexImage}}
<div class="blog-image"> <div class="blog-image">
@ -34,7 +34,7 @@
<div class="article-link-meta"> <div class="article-link-meta">
<span class="article-date"><i data-lucide="clock" class="icon-inline"></i> {{$item.Time}}</span> <span class="article-date"><i data-lucide="clock" class="icon-inline"></i> {{$item.Time}}</span>
{{if $item.Author}} {{if $item.Author}}
<span class="article-author">{{template "user-badge-simple" $item.Author}}</span> <span class="article-author">{{template "user-badge-simple" (dict "Pubkey" $item.Author "Profiles" $.Profiles)}}</span>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}

12
templates/components.html

@ -6,7 +6,7 @@
{{range .FeedItems}} {{range .FeedItems}}
<article class="feed-item"> <article class="feed-item">
<header class="feed-header"> <header class="feed-header">
<span class="feed-author">{{template "user-badge-simple" .Author}}</span> <span class="feed-author">{{template "user-badge-simple" (dict "Pubkey" .Author "Profiles" $.Profiles)}}</span>
<time class="feed-time" datetime="{{.TimeISO}}"><i data-lucide="clock" class="icon-inline"></i> {{.Time}}</time> <time class="feed-time" datetime="{{.TimeISO}}"><i data-lucide="clock" class="icon-inline"></i> {{.Time}}</time>
</header> </header>
<div class="feed-content">{{.Content}}</div> <div class="feed-content">{{.Content}}</div>
@ -76,10 +76,14 @@
</span> </span>
{{end}} {{end}}
{{/* Simple user badge - takes a pubkey string and looks up profile from .Profiles map */}} {{/* Simple user badge - takes a pubkey string and looks up profile from parent context's Profiles map
Usage: {{template "user-badge-simple" (dict "Pubkey" .Author "Profiles" $.Profiles)}}
Or with root context: {{template "user-badge-simple" (dict "Pubkey" .Author "Profiles" $)}}
*/}}
{{define "user-badge-simple"}} {{define "user-badge-simple"}}
{{$pubkey := .}} {{$pubkey := .Pubkey}}
{{$profile := index $.Profiles $pubkey}} {{$profiles := .Profiles}}
{{$profile := index $profiles $pubkey}}
<span class="user-badge" title="{{$pubkey}}"> <span class="user-badge" title="{{$pubkey}}">
{{if and $profile $profile.Picture}} {{if and $profile $profile.Picture}}
<img src="{{$profile.Picture}}" alt="{{if $profile.DisplayName}}{{$profile.DisplayName}}{{else if $profile.Name}}{{$profile.Name}}{{else}}User{{end}}" class="user-badge-avatar" loading="lazy"> <img src="{{$profile.Picture}}" alt="{{if $profile.DisplayName}}{{$profile.DisplayName}}{{else if $profile.Name}}{{$profile.Name}}{{else}}User{{end}}" class="user-badge-avatar" loading="lazy">

2
templates/ebooks.html

@ -23,7 +23,7 @@
<strong>{{.Title}}</strong> <strong>{{.Title}}</strong>
{{if .Summary}}<br><small class="text-muted">{{.Summary}}</small>{{end}} {{if .Summary}}<br><small class="text-muted">{{.Summary}}</small>{{end}}
</td> </td>
<td>{{template "user-badge-simple" .Author}}</td> <td>{{template "user-badge-simple" (dict "Pubkey" .Author "Profiles" $.Profiles)}}</td>
<td>{{if .Type}}{{.Type}}{{else}}—{{end}}</td> <td>{{if .Type}}{{.Type}}{{else}}—{{end}}</td>
<td> <td>
<time datetime="{{.TimeISO}}">{{.Time}}</time> <time datetime="{{.TimeISO}}">{{.Time}}</time>

Loading…
Cancel
Save