2025-03-09 20:40:25 +01:00

610 lines
16 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"time"
"git.highperfocused.tech/highperfocused/lumina-relay/relay/cache"
"git.highperfocused.tech/highperfocused/lumina-relay/relay/trending"
"github.com/fiatjaf/eventstore/postgresql"
"github.com/fiatjaf/khatru"
"github.com/fiatjaf/khatru/policies"
)
// Cache for storing generic data like event counts
var dataCache = cache.New()
const eventCountCacheKey = "total_event_count"
const eventCountCacheDuration = 1 * time.Minute
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
// Gets total event count, using cache if available
func getTotalEventCount(db *postgresql.PostgresBackend) (int, error) {
// Try getting from cache first
if cachedCount, ok := dataCache.Get(eventCountCacheKey); ok {
return cachedCount.(int), nil
}
// If not in cache, query the database
count := 0
row := db.DB.QueryRow("SELECT COUNT(*) FROM event")
if err := row.Scan(&count); err != nil {
return 0, err
}
// Update the cache
dataCache.Set(eventCountCacheKey, count, eventCountCacheDuration)
return count, nil
}
// Updates event count in the background periodically
func startEventCountUpdater(db *postgresql.PostgresBackend) {
go func() {
ticker := time.NewTicker(eventCountCacheDuration)
defer ticker.Stop()
for range ticker.C {
count := 0
row := db.DB.QueryRow("SELECT COUNT(*) FROM event")
if err := row.Scan(&count); err != nil {
fmt.Printf("Error updating event count: %v\n", err)
continue
}
dataCache.Set(eventCountCacheKey, count, eventCountCacheDuration)
fmt.Printf("Updated event count cache: %d events\n", count)
}
}()
}
func main() {
fmt.Print(`
LUMINA RELAY
`)
// create the relay instance
relay := khatru.NewRelay()
// set up relay properties with environment variable configuration
relay.Info.Name = getEnv("RELAY_NAME", "LUMINA Relay")
relay.Info.PubKey = getEnv("RELAY_PUBKEY", "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798")
relay.Info.Description = getEnv("RELAY_DESCRIPTION", "LUMINA Relay")
relay.Info.Icon = getEnv("RELAY_ICON", "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fliquipedia.net%2Fcommons%2Fimages%2F3%2F35%2FSCProbe.jpg&f=1&nofb=1&ipt=0cbbfef25bce41da63d910e86c3c343e6c3b9d63194ca9755351bb7c2efa3359&ipo=images")
relay.Info.Software = "lumina-relay"
relay.Info.Version = "0.1.0"
// Print relay information
fmt.Printf("Name: %s\n", relay.Info.Name)
fmt.Printf("Public Key: %s\n", relay.Info.PubKey)
fmt.Printf("Description: %s\n\n", relay.Info.Description)
// Configure PostgreSQL connection with environment variable
postgresURL := getEnv("POSTGRES_URL", "postgres://postgres:postgres@postgres/postgres?sslmode=disable")
db := postgresql.PostgresBackend{DatabaseURL: postgresURL}
if err := db.Init(); err != nil {
panic(err)
}
// Initialize trending system to start background calculations
fmt.Println("Initializing trending system...")
if err := trending.Initialize(db.DB.DB); err != nil {
fmt.Printf("Warning: Error initializing trending system: %v\n", err)
}
// Initialize event count cache and start periodic updates
fmt.Println("Initializing event count cache...")
_, err := getTotalEventCount(&db)
if err != nil {
fmt.Printf("Warning: Error initializing event count cache: %v\n", err)
}
startEventCountUpdater(&db)
relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent)
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.RejectEvent = append(
relay.RejectEvent,
policies.PreventLargeTags(120),
policies.PreventTimestampsInThePast(time.Hour*2),
policies.PreventTimestampsInTheFuture(time.Minute*30),
)
mux := relay.Router()
// set up other http handlers
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "text/html")
// Get event count from cache
count, err := getTotalEventCount(&db)
if err != nil {
fmt.Printf("Error getting event count: %v\n", err)
count = 0 // Fall back to zero if there's an error
}
// Improved HTML content with link to stats page
fmt.Fprintf(w, `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LUMINA Relay</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.container {
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
text-align: center;
}
h1 {
color: #333;
}
p {
color: #666;
margin: 10px 0;
}
a {
color: #007bff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<h1>Welcome to LUMINA Relay!</h1>
<p>Number of events stored: %d</p>
<p><a href="/stats">View Event Stats</a></p>
<p><a href="/trending/history">View Trending History</a></p>
</div>
</body>
</html>
`, count)
})
mux.HandleFunc("/stats", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "text/html")
// Query the number of events for each kind, sorted by kind
rows, err := db.DB.Query("SELECT kind, COUNT(*) FROM event GROUP BY kind ORDER BY kind")
if err != nil {
fmt.Printf("Error querying event kinds: %v\n", err)
return
}
defer rows.Close()
stats := make(map[string]int)
for rows.Next() {
var kind string
var count int
if err := rows.Scan(&kind, &count); err != nil {
fmt.Printf("Error scanning row: %v\n", err)
return
}
stats[kind] = count
}
// Improved HTML content for stats
fmt.Fprintf(w, `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LUMINA Relay Stats</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.container {
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
text-align: center;
}
h1 {
color: #333;
}
table {
width: 100%%;
border-collapse: collapse;
}
th, td {
padding: 10px;
border: 1px solid #ddd;
}
th {
background-color: #f4f4f4;
}
</style>
</head>
<body>
<div class="container">
<h1>Event Stats</h1>
<table>
<tr>
<th>Kind</th>
<th>Count</th>
</tr>
`)
for kind, count := range stats {
fmt.Fprintf(w, `
<tr>
<td>%s</td>
<td>%d</td>
</tr>
`, kind, count)
}
fmt.Fprintf(w, `
</table>
</div>
</body>
</html>
`)
})
mux.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Get total count from cache
totalCount, err := getTotalEventCount(&db)
if err != nil {
http.Error(w, fmt.Sprintf("Error getting event count: %v", err), http.StatusInternalServerError)
return
}
// Query the number of events for each kind, sorted by kind
rows, err := db.DB.Query("SELECT kind, COUNT(*) FROM event GROUP BY kind ORDER BY kind")
if err != nil {
http.Error(w, fmt.Sprintf("Error querying event kinds: %v", err), http.StatusInternalServerError)
return
}
defer rows.Close()
stats := make(map[string]int)
for rows.Next() {
var kind string
var count int
if err := rows.Scan(&kind, &count); err != nil {
http.Error(w, fmt.Sprintf("Error scanning row: %v", err), http.StatusInternalServerError)
return
}
stats[kind] = count
}
// Add total count to the stats
response := map[string]interface{}{
"total": totalCount,
"kinds": stats,
}
// Encode stats to JSON and write to response
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, fmt.Sprintf("Error encoding JSON: %v", err), http.StatusInternalServerError)
}
})
mux.HandleFunc("/api/trending/kind20", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
trendingPosts, err := trending.GetTrendingKind20(db.DB.DB)
if err != nil {
http.Error(w, fmt.Sprintf("Error getting trending posts: %v", err), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"trending": trendingPosts,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, fmt.Sprintf("Error encoding JSON: %v", err), http.StatusInternalServerError)
}
})
// Add endpoint for trending history
mux.HandleFunc("/api/trending/history/kind20", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Parse query parameters for pagination
limitStr := r.URL.Query().Get("limit")
offsetStr := r.URL.Query().Get("offset")
limit := 10 // Default limit
offset := 0 // Default offset
if limitStr != "" {
if val, err := strconv.Atoi(limitStr); err == nil && val > 0 {
limit = val
}
}
if offsetStr != "" {
if val, err := strconv.Atoi(offsetStr); err == nil && val >= 0 {
offset = val
}
}
// Get trending history for kind 20
history, err := trending.GetTrendingHistoryForKind(db.DB.DB, 20, limit, offset)
if err != nil {
http.Error(w, fmt.Sprintf("Error getting trending history: %v", err), http.StatusInternalServerError)
return
}
// Get total count for pagination info
totalCount, err := trending.GetTrendingHistoryCount(db.DB.DB, 20)
if err != nil {
http.Error(w, fmt.Sprintf("Error getting trending history count: %v", err), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"history": history,
"pagination": map[string]interface{}{
"total": totalCount,
"limit": limit,
"offset": offset,
},
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, fmt.Sprintf("Error encoding JSON: %v", err), http.StatusInternalServerError)
}
})
// Add UI for trending history
mux.HandleFunc("/trending/history", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "text/html")
fmt.Fprintf(w, `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LUMINA Relay - Trending History</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1, h2 {
color: #333;
}
.history-item {
margin-bottom: 30px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
}
.history-date {
font-weight: bold;
margin-bottom: 10px;
color: #555;
}
.posts-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
}
.post {
border: 1px solid #eee;
padding: 10px;
border-radius: 5px;
}
.post-content {
max-height: 100px;
overflow: hidden;
margin-bottom: 10px;
}
.post-id {
max-height: 100px;
overflow: hidden;
margin-bottom: 10px;
}
.post-reactions {
font-weight: bold;
color: #007bff;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: center;
}
.pagination a {
margin: 0 5px;
padding: 8px 12px;
border: 1px solid #ddd;
color: #007bff;
text-decoration: none;
border-radius: 3px;
}
.pagination a:hover {
background-color: #f8f8f8;
}
.loading {
text-align: center;
padding: 20px;
font-style: italic;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<h1>Trending History</h1>
<p>Archive of trending posts calculations</p>
<div id="history-container">
<div class="loading">Loading trending history data...</div>
</div>
<div id="pagination" class="pagination"></div>
</div>
<script>
let currentOffset = 0;
const limit = 5;
let totalItems = 0;
// Format date for display
function formatDate(dateStr) {
const date = new Date(dateStr);
return date.toLocaleString();
}
// Truncate content for preview
function truncateContent(content, maxLength = 100) {
if (content.length <= maxLength) return content;
return content.substr(0, maxLength) + '...';
}
// Load trending history data
async function loadTrendingHistory(offset = 0) {
try {
const response = await fetch('/api/trending/history/kind20?limit=' + limit + '&offset=' + offset);
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Error loading trending history');
totalItems = data.pagination.total;
currentOffset = offset;
renderTrendingHistory(data.history);
renderPagination();
} catch (error) {
document.getElementById('history-container').innerHTML =
'<div class="error">Error loading trending history: ' + error.message + '</div>';
}
}
// Render trending history
function renderTrendingHistory(historyItems) {
const container = document.getElementById('history-container');
if (historyItems.length === 0) {
container.innerHTML = '<p>No trending history data available yet.</p>';
return;
}
let html = '';
historyItems.forEach(item => {
html += '<div class="history-item">' +
'<div class="history-date">' +
'Calculated on: ' + formatDate(item.calculation_time) +
'</div>' +
'<div class="posts-container">';
item.posts.forEach(post => {
html += '<div class="post">' +
'<div class="post-id">' + truncateContent(post.id) + '</div><hr />' +
'<div class="post-content">' + truncateContent(post.content) + '</div>' +
'<div class="post-reactions">Reactions: ' + post.reaction_count + '</div>' +
'</div>';
});
html += '</div></div>';
});
container.innerHTML = html;
}
// Render pagination controls
function renderPagination() {
const container = document.getElementById('pagination');
const totalPages = Math.ceil(totalItems / limit);
const currentPage = Math.floor(currentOffset / limit) + 1;
let html = '';
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
// Previous button
if (currentPage > 1) {
html += '<a href="#" onclick="loadTrendingHistory(' + ((currentPage - 2) * limit) + '); return false;">Previous</a>';
}
// Page numbers
const maxPagesToShow = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
if (endPage - startPage + 1 < maxPagesToShow) {
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
for (let i = startPage; i <= endPage; i++) {
const offset = (i - 1) * limit;
const active = i === currentPage ? 'active' : '';
html += '<a href="#" class="' + active + '" onclick="loadTrendingHistory(' + offset + '); return false;">' + i + '</a>';
}
// Next button
if (currentPage < totalPages) {
html += '<a href="#" onclick="loadTrendingHistory(' + (currentPage * limit) + '); return false;">Next</a>';
}
container.innerHTML = html;
}
// Initial load
document.addEventListener('DOMContentLoaded', () => {
loadTrendingHistory();
});
</script>
</body>
</html>
`)
})
fmt.Println("running on :3334")
http.ListenAndServe(":3334", relay)
}