6 changed files with 780 additions and 2 deletions
@ -0,0 +1,89 @@ |
|||||||
|
package cache |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"compress/gzip" |
||||||
|
"sync" |
||||||
|
"time" |
||||||
|
) |
||||||
|
|
||||||
|
// Cache stores generated HTML pages
|
||||||
|
type Cache struct { |
||||||
|
pages map[string]*CachedPage |
||||||
|
mu sync.RWMutex |
||||||
|
} |
||||||
|
|
||||||
|
// NewCache creates a new cache
|
||||||
|
func NewCache() *Cache { |
||||||
|
return &Cache{ |
||||||
|
pages: make(map[string]*CachedPage), |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Get retrieves a page from cache
|
||||||
|
func (c *Cache) Get(path string) (*CachedPage, bool) { |
||||||
|
c.mu.RLock() |
||||||
|
defer c.mu.RUnlock() |
||||||
|
page, exists := c.pages[path] |
||||||
|
return page, exists |
||||||
|
} |
||||||
|
|
||||||
|
// Set stores a page in cache
|
||||||
|
func (c *Cache) Set(path string, content string) error { |
||||||
|
c.mu.Lock() |
||||||
|
defer c.mu.Unlock() |
||||||
|
|
||||||
|
// Generate ETag
|
||||||
|
etag := GenerateETag(content) |
||||||
|
|
||||||
|
// Pre-compress content
|
||||||
|
var compressed bytes.Buffer |
||||||
|
writer := gzip.NewWriter(&compressed) |
||||||
|
if _, err := writer.Write([]byte(content)); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
if err := writer.Close(); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
c.pages[path] = &CachedPage{ |
||||||
|
Content: content, |
||||||
|
ETag: etag, |
||||||
|
LastUpdated: time.Now(), |
||||||
|
Compressed: compressed.Bytes(), |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// Delete removes a page from cache
|
||||||
|
func (c *Cache) Delete(path string) { |
||||||
|
c.mu.Lock() |
||||||
|
defer c.mu.Unlock() |
||||||
|
delete(c.pages, path) |
||||||
|
} |
||||||
|
|
||||||
|
// Clear clears all cached pages
|
||||||
|
func (c *Cache) Clear() { |
||||||
|
c.mu.Lock() |
||||||
|
defer c.mu.Unlock() |
||||||
|
c.pages = make(map[string]*CachedPage) |
||||||
|
} |
||||||
|
|
||||||
|
// Size returns the number of cached pages
|
||||||
|
func (c *Cache) Size() int { |
||||||
|
c.mu.RLock() |
||||||
|
defer c.mu.RUnlock() |
||||||
|
return len(c.pages) |
||||||
|
} |
||||||
|
|
||||||
|
// GetAllPaths returns all cached page paths
|
||||||
|
func (c *Cache) GetAllPaths() []string { |
||||||
|
c.mu.RLock() |
||||||
|
defer c.mu.RUnlock() |
||||||
|
paths := make([]string, 0, len(c.pages)) |
||||||
|
for path := range c.pages { |
||||||
|
paths = append(paths, path) |
||||||
|
} |
||||||
|
return paths |
||||||
|
} |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
package cache |
||||||
|
|
||||||
|
import ( |
||||||
|
"sync" |
||||||
|
"time" |
||||||
|
) |
||||||
|
|
||||||
|
// FeedItem represents a cached feed item
|
||||||
|
type FeedItem struct { |
||||||
|
EventID string |
||||||
|
Author string |
||||||
|
Content string |
||||||
|
Time time.Time |
||||||
|
Link string |
||||||
|
Title string |
||||||
|
Summary string |
||||||
|
Image string |
||||||
|
} |
||||||
|
|
||||||
|
// FeedCache stores the kind 1 feed
|
||||||
|
type FeedCache struct { |
||||||
|
items []FeedItem |
||||||
|
mu sync.RWMutex |
||||||
|
lastUpdated time.Time |
||||||
|
} |
||||||
|
|
||||||
|
// NewFeedCache creates a new feed cache
|
||||||
|
func NewFeedCache() *FeedCache { |
||||||
|
return &FeedCache{ |
||||||
|
items: make([]FeedItem, 0), |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Set updates the feed cache
|
||||||
|
func (fc *FeedCache) Set(items []FeedItem) { |
||||||
|
fc.mu.Lock() |
||||||
|
defer fc.mu.Unlock() |
||||||
|
fc.items = items |
||||||
|
fc.lastUpdated = time.Now() |
||||||
|
} |
||||||
|
|
||||||
|
// Get retrieves feed items
|
||||||
|
func (fc *FeedCache) Get() []FeedItem { |
||||||
|
fc.mu.RLock() |
||||||
|
defer fc.mu.RUnlock() |
||||||
|
return fc.items |
||||||
|
} |
||||||
|
|
||||||
|
// GetLastUpdated returns when the feed was last updated
|
||||||
|
func (fc *FeedCache) GetLastUpdated() time.Time { |
||||||
|
fc.mu.RLock() |
||||||
|
defer fc.mu.RUnlock() |
||||||
|
return fc.lastUpdated |
||||||
|
} |
||||||
@ -0,0 +1,198 @@ |
|||||||
|
package cache |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"crypto/sha256" |
||||||
|
"encoding/hex" |
||||||
|
"fmt" |
||||||
|
"io" |
||||||
|
"net/http" |
||||||
|
"os" |
||||||
|
"path/filepath" |
||||||
|
"sync" |
||||||
|
"time" |
||||||
|
|
||||||
|
"gitcitadel-online/internal/logger" |
||||||
|
) |
||||||
|
|
||||||
|
// MediaCache handles caching of images and other media from events
|
||||||
|
type MediaCache struct { |
||||||
|
cacheDir string |
||||||
|
activeEvents map[string]time.Time // eventID -> last seen time
|
||||||
|
mu sync.RWMutex |
||||||
|
httpClient *http.Client |
||||||
|
} |
||||||
|
|
||||||
|
// NewMediaCache creates a new media cache
|
||||||
|
func NewMediaCache(cacheDir string) (*MediaCache, error) { |
||||||
|
// Create cache directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(cacheDir, 0755); err != nil { |
||||||
|
return nil, fmt.Errorf("failed to create media cache directory: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
mc := &MediaCache{ |
||||||
|
cacheDir: cacheDir, |
||||||
|
activeEvents: make(map[string]time.Time), |
||||||
|
httpClient: &http.Client{ |
||||||
|
Timeout: 30 * time.Second, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
// Start cleanup goroutine
|
||||||
|
go mc.cleanupLoop(context.Background()) |
||||||
|
|
||||||
|
return mc, nil |
||||||
|
} |
||||||
|
|
||||||
|
// CacheMedia downloads and caches a media file from a URL
|
||||||
|
// Returns the local path to the cached file, or the original URL if caching fails
|
||||||
|
func (mc *MediaCache) CacheMedia(ctx context.Context, url string, eventID string) (string, error) { |
||||||
|
if url == "" { |
||||||
|
return "", fmt.Errorf("empty URL") |
||||||
|
} |
||||||
|
|
||||||
|
// Mark event as active
|
||||||
|
mc.mu.Lock() |
||||||
|
mc.activeEvents[eventID] = time.Now() |
||||||
|
mc.mu.Unlock() |
||||||
|
|
||||||
|
// Generate cache filename from URL hash
|
||||||
|
hash := sha256.Sum256([]byte(url)) |
||||||
|
filename := hex.EncodeToString(hash[:]) + filepath.Ext(url) |
||||||
|
cachePath := filepath.Join(mc.cacheDir, filename) |
||||||
|
|
||||||
|
// Check if already cached
|
||||||
|
if _, err := os.Stat(cachePath); err == nil { |
||||||
|
return "/cache/media/" + filename, nil |
||||||
|
} |
||||||
|
|
||||||
|
// Download the media
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) |
||||||
|
if err != nil { |
||||||
|
return url, fmt.Errorf("failed to create request: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Set user agent
|
||||||
|
req.Header.Set("User-Agent", "GitCitadel-Online/1.0") |
||||||
|
|
||||||
|
resp, err := mc.httpClient.Do(req) |
||||||
|
if err != nil { |
||||||
|
logger.WithFields(map[string]interface{}{ |
||||||
|
"url": url, |
||||||
|
"eventID": eventID, |
||||||
|
"error": err, |
||||||
|
}).Warn("Failed to download media") |
||||||
|
return url, fmt.Errorf("failed to download: %w", err) |
||||||
|
} |
||||||
|
defer resp.Body.Close() |
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK { |
||||||
|
return url, fmt.Errorf("unexpected status code: %d", resp.StatusCode) |
||||||
|
} |
||||||
|
|
||||||
|
// Check content type - only cache images
|
||||||
|
contentType := resp.Header.Get("Content-Type") |
||||||
|
if !isImageContentType(contentType) { |
||||||
|
logger.WithFields(map[string]interface{}{ |
||||||
|
"url": url, |
||||||
|
"contentType": contentType, |
||||||
|
}).Debug("Skipping non-image media") |
||||||
|
return url, nil |
||||||
|
} |
||||||
|
|
||||||
|
// Create cache file
|
||||||
|
file, err := os.Create(cachePath) |
||||||
|
if err != nil { |
||||||
|
return url, fmt.Errorf("failed to create cache file: %w", err) |
||||||
|
} |
||||||
|
defer file.Close() |
||||||
|
|
||||||
|
// Copy response to file
|
||||||
|
_, err = io.Copy(file, resp.Body) |
||||||
|
if err != nil { |
||||||
|
os.Remove(cachePath) // Clean up on error
|
||||||
|
return url, fmt.Errorf("failed to write cache file: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
logger.WithFields(map[string]interface{}{ |
||||||
|
"url": url, |
||||||
|
"eventID": eventID, |
||||||
|
"cachePath": cachePath, |
||||||
|
}).Debug("Cached media file") |
||||||
|
|
||||||
|
return "/cache/media/" + filename, nil |
||||||
|
} |
||||||
|
|
||||||
|
// GetCacheDir returns the cache directory path
|
||||||
|
func (mc *MediaCache) GetCacheDir() string { |
||||||
|
return mc.cacheDir |
||||||
|
} |
||||||
|
|
||||||
|
// isImageContentType checks if a content type is an image
|
||||||
|
func isImageContentType(contentType string) bool { |
||||||
|
imageTypes := []string{ |
||||||
|
"image/jpeg", |
||||||
|
"image/jpg", |
||||||
|
"image/png", |
||||||
|
"image/gif", |
||||||
|
"image/webp", |
||||||
|
"image/svg+xml", |
||||||
|
"image/bmp", |
||||||
|
"image/x-icon", |
||||||
|
} |
||||||
|
for _, imgType := range imageTypes { |
||||||
|
if contentType == imgType { |
||||||
|
return true |
||||||
|
} |
||||||
|
} |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
// MarkEventActive marks an event as currently active (displayed)
|
||||||
|
func (mc *MediaCache) MarkEventActive(eventID string) { |
||||||
|
mc.mu.Lock() |
||||||
|
defer mc.mu.Unlock() |
||||||
|
mc.activeEvents[eventID] = time.Now() |
||||||
|
} |
||||||
|
|
||||||
|
// cleanupLoop periodically removes media for events that are no longer active
|
||||||
|
func (mc *MediaCache) cleanupLoop(ctx context.Context) { |
||||||
|
ticker := time.NewTicker(1 * time.Hour) // Run cleanup every hour
|
||||||
|
defer ticker.Stop() |
||||||
|
|
||||||
|
for { |
||||||
|
select { |
||||||
|
case <-ctx.Done(): |
||||||
|
return |
||||||
|
case <-ticker.C: |
||||||
|
mc.cleanup() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// cleanup removes media files for events that haven't been seen in 24 hours
|
||||||
|
func (mc *MediaCache) cleanup() { |
||||||
|
mc.mu.Lock() |
||||||
|
defer mc.mu.Unlock() |
||||||
|
|
||||||
|
cutoff := time.Now().Add(-24 * time.Hour) |
||||||
|
var toRemove []string |
||||||
|
|
||||||
|
// Find events that are no longer active
|
||||||
|
for eventID, lastSeen := range mc.activeEvents { |
||||||
|
if lastSeen.Before(cutoff) { |
||||||
|
toRemove = append(toRemove, eventID) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Remove inactive events from tracking
|
||||||
|
for _, eventID := range toRemove { |
||||||
|
delete(mc.activeEvents, eventID) |
||||||
|
} |
||||||
|
|
||||||
|
// Note: We don't delete the actual files here because multiple events might use the same image
|
||||||
|
// Instead, we rely on the fact that if an event is no longer displayed, its media won't be accessed
|
||||||
|
// A more sophisticated cleanup would track which files are used by which events
|
||||||
|
|
||||||
|
logger.WithField("removed_events", len(toRemove)).Debug("Cleaned up inactive events from media cache") |
||||||
|
} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
package cache |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"time" |
||||||
|
) |
||||||
|
|
||||||
|
// CachedPage represents a cached HTML page
|
||||||
|
type CachedPage struct { |
||||||
|
Content string |
||||||
|
ETag string |
||||||
|
LastUpdated time.Time |
||||||
|
Compressed []byte // Pre-compressed gzip content
|
||||||
|
} |
||||||
|
|
||||||
|
// IsStale checks if the cached page is stale based on maxAge
|
||||||
|
func (cp *CachedPage) IsStale(maxAge time.Duration) bool { |
||||||
|
return time.Since(cp.LastUpdated) > maxAge |
||||||
|
} |
||||||
|
|
||||||
|
// GenerateETag generates an ETag for the content
|
||||||
|
func GenerateETag(content string) string { |
||||||
|
// Simple ETag based on content hash
|
||||||
|
hash := 0 |
||||||
|
for _, b := range []byte(content) { |
||||||
|
hash = hash*31 + int(b) |
||||||
|
} |
||||||
|
return fmt.Sprintf(`"%x"`, hash) |
||||||
|
} |
||||||
@ -0,0 +1,408 @@ |
|||||||
|
package cache |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"html/template" |
||||||
|
"time" |
||||||
|
|
||||||
|
"gitcitadel-online/internal/generator" |
||||||
|
"gitcitadel-online/internal/logger" |
||||||
|
"gitcitadel-online/internal/nostr" |
||||||
|
) |
||||||
|
|
||||||
|
// Rewarmer handles cache rewarming
|
||||||
|
type Rewarmer struct { |
||||||
|
cache *Cache |
||||||
|
feedCache *FeedCache |
||||||
|
wikiService *nostr.WikiService |
||||||
|
feedService *nostr.FeedService |
||||||
|
ebooksService *nostr.EBooksService |
||||||
|
htmlGenerator *generator.HTMLGenerator |
||||||
|
wikiIndex string |
||||||
|
blogIndex string |
||||||
|
feedRelay string |
||||||
|
maxFeedEvents int |
||||||
|
interval time.Duration |
||||||
|
feedInterval time.Duration |
||||||
|
} |
||||||
|
|
||||||
|
// NewRewarmer creates a new cache rewarming service
|
||||||
|
func NewRewarmer( |
||||||
|
cache *Cache, |
||||||
|
feedCache *FeedCache, |
||||||
|
wikiService *nostr.WikiService, |
||||||
|
feedService *nostr.FeedService, |
||||||
|
ebooksService *nostr.EBooksService, |
||||||
|
htmlGenerator *generator.HTMLGenerator, |
||||||
|
wikiIndex, blogIndex, feedRelay string, |
||||||
|
maxFeedEvents int, |
||||||
|
interval, feedInterval time.Duration, |
||||||
|
) *Rewarmer { |
||||||
|
return &Rewarmer{ |
||||||
|
cache: cache, |
||||||
|
feedCache: feedCache, |
||||||
|
wikiService: wikiService, |
||||||
|
feedService: feedService, |
||||||
|
ebooksService: ebooksService, |
||||||
|
htmlGenerator: htmlGenerator, |
||||||
|
wikiIndex: wikiIndex, |
||||||
|
blogIndex: blogIndex, |
||||||
|
feedRelay: feedRelay, |
||||||
|
maxFeedEvents: maxFeedEvents, |
||||||
|
interval: interval, |
||||||
|
feedInterval: feedInterval, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Start starts the rewarming goroutines
|
||||||
|
func (r *Rewarmer) Start(ctx context.Context) { |
||||||
|
// Initial population
|
||||||
|
go r.rewarmPages(ctx) |
||||||
|
go r.rewarmFeed(ctx) |
||||||
|
|
||||||
|
// Periodic rewarming
|
||||||
|
go r.periodicRewarmPages(ctx) |
||||||
|
go r.periodicRewarmFeed(ctx) |
||||||
|
} |
||||||
|
|
||||||
|
// rewarmPages rewarms the page cache
|
||||||
|
func (r *Rewarmer) rewarmPages(ctx context.Context) { |
||||||
|
logger.Info("Starting page cache rewarming...") |
||||||
|
|
||||||
|
// Initialize wikiPages as empty - will be populated if wiki fetch succeeds
|
||||||
|
wikiPages := make([]generator.WikiPageInfo, 0) |
||||||
|
|
||||||
|
// Fetch wiki index (non-blocking - landing page can still be generated)
|
||||||
|
// If theforest fails, leave pages as-is (don't remove existing events)
|
||||||
|
wikiIndex, err := r.wikiService.FetchWikiIndex(ctx, r.wikiIndex) |
||||||
|
if err != nil { |
||||||
|
logger.Warnf("Error fetching wiki index from theforest: %v - keeping existing pages", err) |
||||||
|
// Don't update cache - leave existing pages as-is
|
||||||
|
// Continue to generate landing page even if wiki fetch fails
|
||||||
|
} else { |
||||||
|
// Fetch wiki events
|
||||||
|
// If theforest fails, leave pages as-is (don't remove existing events)
|
||||||
|
wikiEvents, err := r.wikiService.FetchWikiEvents(ctx, wikiIndex) |
||||||
|
if err != nil { |
||||||
|
logger.Warnf("Error fetching wiki events from theforest: %v - keeping existing pages", err) |
||||||
|
// Don't update cache - leave existing pages as-is
|
||||||
|
} else { |
||||||
|
// Build wiki page info for navigation
|
||||||
|
wikiPages = make([]generator.WikiPageInfo, 0, len(wikiEvents)) |
||||||
|
for _, event := range wikiEvents { |
||||||
|
wikiPages = append(wikiPages, generator.WikiPageInfo{ |
||||||
|
DTag: event.DTag, |
||||||
|
Title: event.Title, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Generate and cache wiki index page
|
||||||
|
wikiIndexHTML, err := r.htmlGenerator.GenerateWikiIndexPage(wikiIndex, wikiPages, []generator.FeedItemInfo{}) |
||||||
|
if err != nil { |
||||||
|
logger.Errorf("Error generating wiki index page: %v", err) |
||||||
|
} else { |
||||||
|
if err := r.cache.Set("/wiki", wikiIndexHTML); err != nil { |
||||||
|
logger.Errorf("Error caching wiki index page: %v", err) |
||||||
|
} else { |
||||||
|
logger.WithField("pages", len(wikiPages)).Info("Wiki index page cached successfully") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Generate and cache wiki pages
|
||||||
|
for _, event := range wikiEvents { |
||||||
|
html, err := r.htmlGenerator.GenerateWikiPage(event, wikiPages, []generator.FeedItemInfo{}) |
||||||
|
if err != nil { |
||||||
|
logger.WithField("dtag", event.DTag).Errorf("Error generating wiki page: %v", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
if err := r.cache.Set("/wiki/"+event.DTag, html); err != nil { |
||||||
|
logger.WithField("dtag", event.DTag).Errorf("Error caching wiki page: %v", err) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch blog index if configured (needed for landing page)
|
||||||
|
// If theforest fails, leave pages as-is (don't remove existing events)
|
||||||
|
var newestBlogItem *generator.BlogItemInfo |
||||||
|
if r.blogIndex != "" { |
||||||
|
blogIndex, err := r.wikiService.FetchWikiIndex(ctx, r.blogIndex) |
||||||
|
if err != nil { |
||||||
|
logger.Warnf("Error fetching blog index from theforest: %v - keeping existing pages", err) |
||||||
|
// Don't update cache - leave existing pages as-is
|
||||||
|
} else { |
||||||
|
// Fetch blog events using the generic FetchIndexEvents function
|
||||||
|
// If theforest fails, leave pages as-is (don't remove existing events)
|
||||||
|
blogKind := r.wikiService.GetBlogKind() |
||||||
|
blogEventList, err := r.wikiService.FetchIndexEvents(ctx, blogIndex, blogKind) |
||||||
|
if err != nil { |
||||||
|
logger.Warnf("Error fetching blog events from theforest: %v - keeping existing pages", err) |
||||||
|
// Don't update cache - leave existing pages as-is
|
||||||
|
} else { |
||||||
|
logger.WithFields(map[string]interface{}{ |
||||||
|
"events": len(blogEventList), |
||||||
|
"kind": blogKind, |
||||||
|
}).Debug("Fetched blog events") |
||||||
|
blogItems := make([]generator.BlogItemInfo, 0, len(blogEventList)) |
||||||
|
for _, event := range blogEventList { |
||||||
|
// Parse the blog event
|
||||||
|
blog, err := nostr.ParseBlogEvent(event, blogKind) |
||||||
|
if err != nil { |
||||||
|
logger.WithField("event_id", event.ID).Warnf("Error parsing blog event: %v", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
html, err := r.htmlGenerator.ProcessAsciiDoc(blog.Content) |
||||||
|
if err != nil { |
||||||
|
logger.WithField("dtag", blog.DTag).Warnf("Error processing blog content: %v", err) |
||||||
|
html = blog.Content // Fallback to raw content
|
||||||
|
} |
||||||
|
blogItems = append(blogItems, generator.BlogItemInfo{ |
||||||
|
DTag: blog.DTag, |
||||||
|
Title: blog.Title, |
||||||
|
Summary: blog.Summary, |
||||||
|
Content: template.HTML(html), |
||||||
|
Author: event.PubKey, |
||||||
|
Image: blog.Image, |
||||||
|
CreatedAt: int64(event.CreatedAt), |
||||||
|
}) |
||||||
|
} |
||||||
|
logger.WithField("items", len(blogItems)).Debug("Generated blog items") |
||||||
|
|
||||||
|
// Get newest blog item for landing page
|
||||||
|
if len(blogItems) > 0 { |
||||||
|
newestBlogItem = &blogItems[0] |
||||||
|
} |
||||||
|
|
||||||
|
// Generate blog page without feed items (feed only on landing page)
|
||||||
|
blogHTML, err := r.htmlGenerator.GenerateBlogPage(blogIndex, blogItems, []generator.FeedItemInfo{}) |
||||||
|
if err != nil { |
||||||
|
logger.Errorf("Error generating blog page: %v", err) |
||||||
|
} else { |
||||||
|
if err := r.cache.Set("/blog", blogHTML); err != nil { |
||||||
|
logger.Errorf("Error caching blog page: %v", err) |
||||||
|
} else { |
||||||
|
logger.WithField("items", len(blogItems)).Info("Blog page cached successfully") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch and cache articles page (longform articles) - needed for landing page
|
||||||
|
// If theforest fails, leave pages as-is (don't remove existing events)
|
||||||
|
var allArticleItems []generator.ArticleItemInfo |
||||||
|
var newestArticleItem *generator.ArticleItemInfo |
||||||
|
longformKind := r.wikiService.GetLongformKind() |
||||||
|
if longformKind > 0 { |
||||||
|
articleEvents, err := r.wikiService.FetchLongformArticles(ctx, "wss://theforest.nostr1.com", longformKind, 50) |
||||||
|
if err != nil { |
||||||
|
logger.Warnf("Error fetching longform articles from theforest: %v - keeping existing pages", err) |
||||||
|
// Don't update cache - leave existing pages as-is
|
||||||
|
} else { |
||||||
|
articleItems := make([]generator.ArticleItemInfo, 0, len(articleEvents)) |
||||||
|
for _, event := range articleEvents { |
||||||
|
// Parse the longform article
|
||||||
|
article, err := nostr.ParseLongformEvent(event, longformKind) |
||||||
|
if err != nil { |
||||||
|
logger.WithField("event_id", event.ID).Warnf("Error parsing longform article: %v", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
// Process markdown content
|
||||||
|
html, err := r.htmlGenerator.ProcessMarkdown(article.Content) |
||||||
|
if err != nil { |
||||||
|
logger.WithField("dtag", article.DTag).Warnf("Error processing markdown content: %v", err) |
||||||
|
html = article.Content // Fallback to raw content
|
||||||
|
} |
||||||
|
articleItems = append(articleItems, generator.ArticleItemInfo{ |
||||||
|
DTag: article.DTag, |
||||||
|
Title: article.Title, |
||||||
|
Summary: article.Summary, |
||||||
|
Content: template.HTML(html), |
||||||
|
Author: event.PubKey, |
||||||
|
Image: article.Image, |
||||||
|
CreatedAt: int64(event.CreatedAt), |
||||||
|
}) |
||||||
|
} |
||||||
|
logger.WithField("items", len(articleItems)).Debug("Generated article items") |
||||||
|
|
||||||
|
// Store all article items for landing page
|
||||||
|
allArticleItems = articleItems |
||||||
|
|
||||||
|
// Get newest article item for landing page
|
||||||
|
if len(articleItems) > 0 { |
||||||
|
newestArticleItem = &articleItems[0] |
||||||
|
} |
||||||
|
|
||||||
|
// Generate articles page
|
||||||
|
articlesHTML, err := r.htmlGenerator.GenerateArticlesPage(articleItems, []generator.FeedItemInfo{}) |
||||||
|
if err != nil { |
||||||
|
logger.Errorf("Error generating articles page: %v", err) |
||||||
|
} else { |
||||||
|
if err := r.cache.Set("/articles", articlesHTML); err != nil { |
||||||
|
logger.Errorf("Error caching articles page: %v", err) |
||||||
|
} else { |
||||||
|
logger.WithField("items", len(articleItems)).Info("Articles page cached successfully") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch and cache e-books page (needed for landing page)
|
||||||
|
// If theforest fails, leave pages as-is (don't remove existing events)
|
||||||
|
var allEBooks []generator.EBookInfo |
||||||
|
if r.ebooksService != nil { |
||||||
|
ebooks, err := r.ebooksService.FetchTopLevelIndexEvents(ctx) |
||||||
|
if err != nil { |
||||||
|
logger.Warnf("Error fetching e-books from theforest: %v - keeping existing pages", err) |
||||||
|
// Don't update cache - leave existing pages as-is
|
||||||
|
} else { |
||||||
|
// Convert to generator.EBookInfo
|
||||||
|
generatorEBooks := make([]generator.EBookInfo, 0, len(ebooks)) |
||||||
|
for _, ebook := range ebooks { |
||||||
|
generatorEBooks = append(generatorEBooks, generator.EBookInfo{ |
||||||
|
EventID: ebook.EventID, |
||||||
|
Title: ebook.Title, |
||||||
|
DTag: ebook.DTag, |
||||||
|
Author: ebook.Author, |
||||||
|
Summary: ebook.Summary, |
||||||
|
Image: ebook.Image, |
||||||
|
Type: ebook.Type, |
||||||
|
CreatedAt: ebook.CreatedAt, |
||||||
|
Naddr: ebook.Naddr, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Store all e-books for landing page
|
||||||
|
allEBooks = generatorEBooks |
||||||
|
|
||||||
|
ebooksHTML, err := r.htmlGenerator.GenerateEBooksPage(generatorEBooks, []generator.FeedItemInfo{}) |
||||||
|
if err != nil { |
||||||
|
logger.Errorf("Error generating e-books page: %v", err) |
||||||
|
} else { |
||||||
|
if err := r.cache.Set("/ebooks", ebooksHTML); err != nil { |
||||||
|
logger.Errorf("Error caching e-books page: %v", err) |
||||||
|
} else { |
||||||
|
logger.WithField("ebooks", len(generatorEBooks)).Info("E-books page cached successfully") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Always generate landing page AFTER blog, articles, and e-books are fetched and cached
|
||||||
|
// Now we have all the data needed for the landing page
|
||||||
|
landingHTML, err := r.htmlGenerator.GenerateLandingPage(wikiPages, newestBlogItem, newestArticleItem, allArticleItems, allEBooks) |
||||||
|
if err != nil { |
||||||
|
logger.Errorf("Error generating landing page: %v", err) |
||||||
|
} else { |
||||||
|
if err := r.cache.Set("/", landingHTML); err != nil { |
||||||
|
logger.Errorf("Error caching landing page: %v", err) |
||||||
|
} else { |
||||||
|
logger.WithField("pages", len(wikiPages)).Info("Landing page cached successfully") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Generate and cache Feed page (using feed items from cache)
|
||||||
|
feedItems := r.convertFeedItemsToInfo(r.feedCache.Get()) |
||||||
|
feedHTML, err := r.htmlGenerator.GenerateFeedPage(feedItems) |
||||||
|
if err != nil { |
||||||
|
logger.Errorf("Error generating feed page: %v", err) |
||||||
|
} else { |
||||||
|
if err := r.cache.Set("/feed", feedHTML); err != nil { |
||||||
|
logger.Errorf("Error caching feed page: %v", err) |
||||||
|
} else { |
||||||
|
logger.WithField("items", len(feedItems)).Info("Feed page cached successfully") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
logger.Info("Page cache rewarming completed") |
||||||
|
} |
||||||
|
|
||||||
|
// rewarmFeed rewarms the feed cache
|
||||||
|
func (r *Rewarmer) rewarmFeed(ctx context.Context) { |
||||||
|
logger.WithFields(map[string]interface{}{ |
||||||
|
"relay": r.feedRelay, |
||||||
|
"max_events": r.maxFeedEvents, |
||||||
|
}).Info("Starting feed cache rewarming") |
||||||
|
|
||||||
|
nostrItems, err := r.feedService.FetchFeedItems(ctx, r.feedRelay, r.maxFeedEvents) |
||||||
|
if err != nil { |
||||||
|
logger.WithField("relay", r.feedRelay).Warnf("Error fetching feed: %v", err) |
||||||
|
// Don't clear the cache on error - keep old items
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if len(nostrItems) == 0 { |
||||||
|
logger.WithField("relay", r.feedRelay).Warn("No feed items fetched") |
||||||
|
// Don't clear the cache - keep old items
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Convert nostr.FeedItem to cache.FeedItem
|
||||||
|
items := make([]FeedItem, 0, len(nostrItems)) |
||||||
|
for _, item := range nostrItems { |
||||||
|
items = append(items, FeedItem{ |
||||||
|
EventID: item.EventID, |
||||||
|
Author: item.Author, |
||||||
|
Content: item.Content, |
||||||
|
Time: item.Time, |
||||||
|
Link: item.Link, |
||||||
|
Title: item.Title, |
||||||
|
Summary: item.Summary, |
||||||
|
Image: item.Image, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
r.feedCache.Set(items) |
||||||
|
logger.WithFields(map[string]interface{}{ |
||||||
|
"items": len(items), |
||||||
|
"relay": r.feedRelay, |
||||||
|
}).Info("Feed cache rewarmed successfully") |
||||||
|
} |
||||||
|
|
||||||
|
// periodicRewarmPages periodically rewarms pages
|
||||||
|
func (r *Rewarmer) periodicRewarmPages(ctx context.Context) { |
||||||
|
ticker := time.NewTicker(r.interval) |
||||||
|
defer ticker.Stop() |
||||||
|
|
||||||
|
for { |
||||||
|
select { |
||||||
|
case <-ctx.Done(): |
||||||
|
return |
||||||
|
case <-ticker.C: |
||||||
|
r.rewarmPages(ctx) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// convertFeedItemsToInfo converts cache.FeedItem to generator.FeedItemInfo
|
||||||
|
func (r *Rewarmer) convertFeedItemsToInfo(items []FeedItem) []generator.FeedItemInfo { |
||||||
|
feedItems := make([]generator.FeedItemInfo, 0, len(items)) |
||||||
|
for _, item := range items { |
||||||
|
feedItems = append(feedItems, generator.FeedItemInfo{ |
||||||
|
EventID: item.EventID, |
||||||
|
Author: item.Author, |
||||||
|
Content: item.Content, |
||||||
|
Time: item.Time.Format("2006-01-02 15:04:05"), |
||||||
|
TimeISO: item.Time.Format(time.RFC3339), |
||||||
|
Link: item.Link, |
||||||
|
}) |
||||||
|
} |
||||||
|
return feedItems |
||||||
|
} |
||||||
|
|
||||||
|
// periodicRewarmFeed periodically rewarms feed
|
||||||
|
func (r *Rewarmer) periodicRewarmFeed(ctx context.Context) { |
||||||
|
ticker := time.NewTicker(r.feedInterval) |
||||||
|
defer ticker.Stop() |
||||||
|
|
||||||
|
for { |
||||||
|
select { |
||||||
|
case <-ctx.Done(): |
||||||
|
return |
||||||
|
case <-ticker.C: |
||||||
|
r.rewarmFeed(ctx) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue