14 Commits

Author SHA1 Message Date
a55f4be7db add pubkey ban check in event processing logic 2025-07-28 22:17:27 +02:00
406b288223 add RELAY_SERVICE_URL environment variable and update service URL handling in main 2025-07-28 21:48:00 +02:00
f06846b51c update go.mod and go.sum to manage dependencies more effectively 2025-07-28 21:42:21 +02:00
12d9d2d215 add GetBannedPubkeysWithReasons method and update management API to use it 2025-07-28 21:26:45 +02:00
4d5561d132 comment out ports configuration for postgres service in compose.yaml 2025-07-28 21:18:06 +02:00
28d04b7e0c add RELAY_ADMINS environment variable and update management API authorization logic 2025-07-28 21:14:22 +02:00
a0689ea568 remove default value for RELAY_PUBKEY in management API rejection logic 2025-07-28 21:07:16 +02:00
14c6770a72 implement nip86 2025-07-28 21:05:33 +02:00
3c2b8b75fa background_algo_update_with_table (#3)
Reviewed-on: #3
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-committed-by: highperfocused <highperfocused@pm.me>
2025-04-18 00:25:07 +02:00
highperfocused
cabc153ee8 add relay software and version info 2025-02-24 10:59:42 +01:00
557a6f4ba8 add LICENSE 2025-02-23 23:38:42 +01:00
813ced8bb0 rename example data file 2025-02-23 23:08:14 +01:00
4402ecb674 bump go version to 1.24.0 for docker relay 2025-02-23 22:44:23 +01:00
44f4c2df6a implement caching (#1)
Co-authored-by: mr0x50 <24775431+mroxso@users.noreply.github.com>
Reviewed-on: highperfocused/lumina-relay#1
2025-02-23 22:41:21 +01:00
11 changed files with 903 additions and 19 deletions

2
.gitignore vendored
View File

@@ -1 +1 @@
postgres/
postgres**/

24
LICENSE Normal file
View File

@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

View File

@@ -12,6 +12,8 @@ services:
RELAY_NAME: "LUMINA Relay"
RELAY_DESCRIPTION: "LUMINA Nostr Relay"
POSTGRES_URL: "postgres://postgres:postgres@postgres/postgres?sslmode=disable"
RELAY_ADMINS: "480ec1a7516406090dc042ddf67780ef30f26f3a864e83b417c053a5a611c838"
RELAY_SERVICE_URL: "https://relay.lumina.rocks"
restart: unless-stopped
scraper:
depends_on:
@@ -31,8 +33,8 @@ services:
POSTGRES_DB: postgres
volumes:
- ./postgres:/var/lib/postgresql/data
ports:
- 5432:5432
# ports:
# - 5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s

View File

@@ -8,7 +8,7 @@
################################################################################
# Create a stage for building the application.
ARG GO_VERSION=1.23.4
ARG GO_VERSION=1.24.0
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS build
WORKDIR /src

194
relay/database.go Normal file
View File

@@ -0,0 +1,194 @@
package main
import (
"database/sql"
"fmt"
_ "github.com/lib/pq"
)
// DBManager handles the normal PostgreSQL connection for non-event data
type DBManager struct {
db *sql.DB
}
// NewDBManager creates a new database manager with the given database URL.
// It establishes a connection, verifies connectivity, and initializes required tables.
func NewDBManager(databaseURL string) (*DBManager, error) {
db, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to open database connection: %w", err)
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to ping database: %w", err)
}
manager := &DBManager{db: db}
if err := manager.initTables(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to initialize database tables: %w", err)
}
return manager, nil
}
// initTables creates the necessary tables for the application.
// This method is called automatically during DBManager initialization.
func (dbm *DBManager) initTables() error {
query := `
CREATE TABLE IF NOT EXISTS banned_pubkeys (
pubkey VARCHAR(64) PRIMARY KEY,
reason TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`
if _, err := dbm.db.Exec(query); err != nil {
return fmt.Errorf("failed to create banned_pubkeys table: %w", err)
}
return nil
}
// AddBannedPubkey adds a pubkey to the banned list with an optional reason.
// If the pubkey already exists, the operation is ignored (no error returned).
func (dbm *DBManager) AddBannedPubkey(pubkey, reason string) error {
if pubkey == "" {
return fmt.Errorf("pubkey cannot be empty")
}
query := `INSERT INTO banned_pubkeys (pubkey, reason) VALUES ($1, $2) ON CONFLICT (pubkey) DO NOTHING`
if _, err := dbm.db.Exec(query, pubkey, reason); err != nil {
return fmt.Errorf("failed to add banned pubkey %s: %w", pubkey, err)
}
return nil
}
// RemoveBannedPubkey removes a pubkey from the banned list.
// Returns an error if the pubkey is not found in the banned list.
func (dbm *DBManager) RemoveBannedPubkey(pubkey string) error {
if pubkey == "" {
return fmt.Errorf("pubkey cannot be empty")
}
query := `DELETE FROM banned_pubkeys WHERE pubkey = $1`
result, err := dbm.db.Exec(query, pubkey)
if err != nil {
return fmt.Errorf("failed to remove banned pubkey %s: %w", pubkey, err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected for pubkey %s: %w", pubkey, err)
}
if rowsAffected == 0 {
return fmt.Errorf("pubkey %s not found in banned list", pubkey)
}
return nil
}
// IsBannedPubkey checks if a pubkey is in the banned list.
// Returns true if the pubkey is banned, false otherwise.
func (dbm *DBManager) IsBannedPubkey(pubkey string) (bool, error) {
if pubkey == "" {
return false, nil
}
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM banned_pubkeys WHERE pubkey = $1)`
if err := dbm.db.QueryRow(query, pubkey).Scan(&exists); err != nil {
return false, fmt.Errorf("failed to check if pubkey %s is banned: %w", pubkey, err)
}
return exists, nil
}
// GetBannedPubkeysWithReasons returns all banned pubkeys with their reasons ordered by creation time.
// Returns an empty slice if no pubkeys are found.
func (dbm *DBManager) GetBannedPubkeysWithReasons() ([]struct {
Pubkey string
Reason string
}, error) {
query := `SELECT pubkey, reason FROM banned_pubkeys ORDER BY created_at`
rows, err := dbm.db.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to query banned pubkeys with reasons: %w", err)
}
defer rows.Close()
var results []struct {
Pubkey string
Reason string
}
for rows.Next() {
var result struct {
Pubkey string
Reason string
}
if err := rows.Scan(&result.Pubkey, &result.Reason); err != nil {
return nil, fmt.Errorf("failed to scan pubkey and reason row: %w", err)
}
results = append(results, result)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error occurred while iterating over pubkey and reason rows: %w", err)
}
return results, nil
}
// GetBannedPubkeys returns all banned pubkeys ordered by creation time.
// Returns an empty slice if no pubkeys are found.
func (dbm *DBManager) GetBannedPubkeys() ([]string, error) {
query := `SELECT pubkey FROM banned_pubkeys ORDER BY created_at`
rows, err := dbm.db.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to query banned pubkeys: %w", err)
}
defer rows.Close()
var pubkeys []string
for rows.Next() {
var pubkey string
if err := rows.Scan(&pubkey); err != nil {
return nil, fmt.Errorf("failed to scan pubkey row: %w", err)
}
pubkeys = append(pubkeys, pubkey)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error occurred while iterating over pubkey rows: %w", err)
}
return pubkeys, nil
}
// Close closes the database connection.
// This should be called when the DBManager is no longer needed.
func (dbm *DBManager) Close() error {
if dbm.db != nil {
if err := dbm.db.Close(); err != nil {
return fmt.Errorf("failed to close database connection: %w", err)
}
}
return nil
}
// Health checks the database connection health.
// Returns nil if the connection is healthy, an error otherwise.
func (dbm *DBManager) Health() error {
if dbm.db == nil {
return fmt.Errorf("database connection is nil")
}
if err := dbm.db.Ping(); err != nil {
return fmt.Errorf("database ping failed: %w", err)
}
return nil
}

View File

@@ -2,6 +2,13 @@ module git.highperfocused.tech/highperfocused/lumina-relay/relay
go 1.23.4
require (
github.com/fiatjaf/eventstore v0.16.0
github.com/fiatjaf/khatru v0.15.2
github.com/lib/pq v1.10.9
github.com/nbd-wtf/go-nostr v0.46.0
)
require (
fiatjaf.com/lib v0.2.0 // indirect
github.com/andybalholm/brotli v1.0.5 // indirect
@@ -12,17 +19,13 @@ require (
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/fasthttp/websocket v1.5.7 // indirect
github.com/fiatjaf/eventstore v0.16.0
github.com/fiatjaf/khatru v0.15.2
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nbd-wtf/go-nostr v0.46.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect

View File

@@ -1,5 +1,6 @@
fiatjaf.com/lib v0.2.0 h1:TgIJESbbND6GjOgGHxF5jsO6EMjuAxIzZHPo5DXYexs=
fiatjaf.com/lib v0.2.0/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
@@ -12,6 +13,7 @@ github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtyd
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
@@ -19,12 +21,11 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnN
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/fasthttp/websocket v1.5.7 h1:0a6o2OfeATvtGgoMKleURhLT6JqWPg7fYfWnH4KHau4=
github.com/fasthttp/websocket v1.5.7/go.mod h1:bC4fxSono9czeXHQUVKxsC0sNjbm7lPJR04GDFqClfU=
github.com/fiatjaf/eventstore v0.15.0 h1:5UXe0+vIb30/cYcOWipks8nR3g+X8W224TFy5yPzivk=
github.com/fiatjaf/eventstore v0.15.0/go.mod h1:KAsld5BhkmSck48aF11Txu8X+OGNmoabw4TlYVWqInc=
github.com/fiatjaf/eventstore v0.16.0 h1:r26aJeOwJTCbEevU8RVqp9FlcAgzKKqUWFH//x+Y+7M=
github.com/fiatjaf/eventstore v0.16.0/go.mod h1:KAsld5BhkmSck48aF11Txu8X+OGNmoabw4TlYVWqInc=
github.com/fiatjaf/khatru v0.15.2 h1:4p0LGUFh+C0zFAPTQdzUdhZDabjmktyov9h5V32EdSw=
github.com/fiatjaf/khatru v0.15.2/go.mod h1:GBQJXZpitDatXF9RookRXcWB5zCJclCE4ufDK3jk80g=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
@@ -40,12 +41,15 @@ github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nbd-wtf/go-nostr v0.46.0 h1:aR+xXEC6MPutNMIRhNdi+2iBPEHW7SO10sFaOAVSz3Y=
github.com/nbd-wtf/go-nostr v0.46.0/go.mod h1:xVNOqkn0GImeTmaF6VDwgYsuSkfG3yrIbd0dT6NZDIQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
@@ -55,6 +59,8 @@ github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1Avp
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -70,3 +76,5 @@ golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsG
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,18 +1,30 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"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"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip86"
)
// 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
@@ -20,6 +32,43 @@ func getEnv(key, fallback string) string {
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
@@ -33,6 +82,19 @@ func main() {
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.1"
// Set the service URL explicitly to avoid 'u' tag validation issues
serviceUrl := getEnv("RELAY_SERVICE_URL", "")
if serviceUrl != "" {
relay.ServiceURL = serviceUrl
}
// load relay admins
admins := strings.Split(getEnv("RELAY_ADMINS", ""), ",")
// add default admin (relay pubkey)
admins = append(admins, relay.Info.PubKey)
// Print relay information
fmt.Printf("Name: %s\n", relay.Info.Name)
@@ -46,6 +108,27 @@ func main() {
panic(err)
}
// Initialize the normal database manager for other data
dbManager, dberr := NewDBManager(postgresURL)
if dberr != nil {
panic(fmt.Sprintf("Failed to initialize database manager: %v", dberr))
}
defer dbManager.Close()
// 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)
@@ -57,18 +140,73 @@ func main() {
policies.PreventLargeTags(120),
policies.PreventTimestampsInThePast(time.Hour*2),
policies.PreventTimestampsInTheFuture(time.Minute*30),
func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
// Check if the pubkey is banned
isBanned, err := dbManager.IsBannedPubkey(event.PubKey)
if err != nil {
fmt.Printf("Error checking banned pubkey: %v\n", err)
return false, "" // Allow the event if there's an error checking ban status
}
if isBanned {
return true, fmt.Sprintf("banned pubkey: %s", event.PubKey)
}
return false, ""
},
)
// management endpoints
relay.ManagementAPI.RejectAPICall = append(relay.ManagementAPI.RejectAPICall,
func(ctx context.Context, mp nip86.MethodParams) (reject bool, msg string) {
user := khatru.GetAuthed(ctx)
// Check if the user is in the admins array
isAdmin := false
for _, admin := range admins {
if user == admin {
isAdmin = true
break
}
}
if !isAdmin {
return true, "unauthorized: admin access required"
}
return false, ""
})
relay.ManagementAPI.AllowPubKey = func(ctx context.Context, pubkey string, reason string) error {
return dbManager.RemoveBannedPubkey(pubkey)
}
relay.ManagementAPI.BanPubKey = func(ctx context.Context, pubkey string, reason string) error {
return dbManager.AddBannedPubkey(pubkey, reason)
}
relay.ManagementAPI.ListBannedPubKeys = func(ctx context.Context) ([]nip86.PubKeyReason, error) {
results, err := dbManager.GetBannedPubkeysWithReasons()
if err != nil {
return nil, err
}
var pubkeyReasons []nip86.PubKeyReason
for _, result := range results {
pubkeyReasons = append(pubkeyReasons, nip86.PubKeyReason{
PubKey: result.Pubkey,
Reason: result.Reason,
})
}
return pubkeyReasons, nil
}
mux := relay.Router()
// set up other http handlers
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "text/html")
// Query the total number of events
count := 0
row := db.DB.QueryRow("SELECT COUNT(*) FROM event")
if err := row.Scan(&count); err != nil {
fmt.Printf("Error counting events: %v\n", err)
// 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
@@ -118,6 +256,7 @@ func main() {
<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>
@@ -216,6 +355,13 @@ func main() {
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 {
@@ -225,7 +371,6 @@ func main() {
defer rows.Close()
stats := make(map[string]int)
totalCount := 0
for rows.Next() {
var kind string
var count int
@@ -234,7 +379,6 @@ func main() {
return
}
stats[kind] = count
totalCount += count
}
// Add total count to the stats
@@ -267,6 +411,276 @@ func main() {
}
})
// 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)
}

View File

@@ -3,6 +3,7 @@ package trending
import (
"database/sql"
"encoding/json"
"fmt"
"time"
"git.highperfocused.tech/highperfocused/lumina-relay/relay/cache"
@@ -19,16 +20,71 @@ type Post struct {
}
var (
trendingCache = cache.New()
cacheDuration = 5 * time.Minute
trendingCache = cache.New()
cacheDuration = 35 * time.Minute // Slightly longer than update interval
updateInterval = 30 * time.Minute
)
// Initialize sets up the trending system
// - Creates database tables
// - Performs initial calculation
// - Sets up periodic updates
func Initialize(db *sql.DB) error {
// Create necessary database tables
if err := CreateTablesIfNotExist(db); err != nil {
return fmt.Errorf("failed to create trending database tables: %w", err)
}
// Perform initial calculation
if _, err := calculateTrendingKind20(db); err != nil {
fmt.Printf("Error in initial trending calculation: %v\n", err)
}
// Set up periodic updates
go func() {
ticker := time.NewTicker(updateInterval)
defer ticker.Stop()
for range ticker.C {
if _, err := calculateTrendingKind20(db); err != nil {
fmt.Printf("Error updating trending data: %v\n", err)
} else {
fmt.Printf("Successfully updated trending data at %s\n", time.Now().Format(time.RFC3339))
}
}
}()
return nil
}
// GetTrendingKind20 returns the top 20 trending posts of kind 20 from the last 24 hours
// It first tries the in-memory cache, then the database, and calculates on-demand if needed
func GetTrendingKind20(db *sql.DB) ([]Post, error) {
// Try in-memory cache first
if cached, ok := trendingCache.Get("trending_kind_20"); ok {
return cached.([]Post), nil
}
// If not in memory, try getting from database
posts, calculationTime, err := GetLatestTrendingFromHistory(db, 20)
if err != nil {
return nil, err
}
// If we got data from the database and it's not too old, use it
if len(posts) > 0 && time.Since(calculationTime) < cacheDuration {
// Update in-memory cache
trendingCache.Set("trending_kind_20", posts, cacheDuration)
return posts, nil
}
// Calculate on-demand as a last resort
return calculateTrendingKind20(db)
}
// calculateTrendingKind20 performs the actual calculation of trending posts,
// updates the cache, and saves to database
func calculateTrendingKind20(db *sql.DB) ([]Post, error) {
query := `
WITH reactions AS (
SELECT
@@ -76,6 +132,49 @@ func GetTrendingKind20(db *sql.DB) ([]Post, error) {
trendingPosts = append(trendingPosts, post)
}
// Update in-memory cache
trendingCache.Set("trending_kind_20", trendingPosts, cacheDuration)
// Save to database for historical records
postsJSON, err := json.Marshal(trendingPosts)
if err != nil {
return nil, fmt.Errorf("failed to marshal trending posts to JSON: %w", err)
}
_, err = db.Exec(`
INSERT INTO trending_history (calculation_time, kind, trending_data)
VALUES (NOW(), 20, $1)
`, postsJSON)
if err != nil {
fmt.Printf("Warning: failed to store trending data in database: %v\n", err)
// Don't return error here as we want to still return the trending posts even if saving fails
}
return trendingPosts, nil
}
// UnmarshalPosts unmarshals JSON data into a slice of Post objects
func UnmarshalPosts(data []byte) ([]Post, error) {
var posts []Post
if err := json.Unmarshal(data, &posts); err != nil {
return nil, err
}
return posts, nil
}
// GetTrendingHistoryCount returns the count of trending history entries for the given kind
func GetTrendingHistoryCount(db *sql.DB, kind int) (int, error) {
var count int
err := db.QueryRow(`
SELECT COUNT(*)
FROM trending_history
WHERE kind = $1
`, kind).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to count trending history: %w", err)
}
return count, nil
}

140
relay/trending/schema.go Normal file
View File

@@ -0,0 +1,140 @@
package trending
import (
"database/sql"
"fmt"
"time"
)
// Schema version to track database migrations
const SchemaVersion = 1
// CreateTablesIfNotExist ensures that all necessary database tables for the trending system exist
func CreateTablesIfNotExist(db *sql.DB) error {
// Create the trending_history table if it doesn't exist
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS trending_history (
id SERIAL PRIMARY KEY,
calculation_time TIMESTAMPTZ NOT NULL,
kind INTEGER NOT NULL,
trending_data JSONB NOT NULL
)
`)
if err != nil {
return fmt.Errorf("failed to create trending_history table: %w", err)
}
// Create an index on calculation_time and kind for faster queries
_, err = db.Exec(`
CREATE INDEX IF NOT EXISTS idx_trending_history_time_kind
ON trending_history (calculation_time DESC, kind)
`)
if err != nil {
return fmt.Errorf("failed to create index on trending_history: %w", err)
}
// Create schema version table if it doesn't exist
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS trending_schema_version (
version INTEGER PRIMARY KEY,
updated_at TIMESTAMPTZ NOT NULL
)
`)
if err != nil {
return fmt.Errorf("failed to create trending_schema_version table: %w", err)
}
// Check if we need to initialize the schema version
var count int
err = db.QueryRow(`SELECT COUNT(*) FROM trending_schema_version`).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check trending_schema_version: %w", err)
}
if count == 0 {
_, err = db.Exec(`
INSERT INTO trending_schema_version (version, updated_at)
VALUES ($1, $2)
`, SchemaVersion, time.Now())
if err != nil {
return fmt.Errorf("failed to initialize trending_schema_version: %w", err)
}
}
return nil
}
// GetLatestTrendingFromHistory retrieves the most recent trending data for the specified kind
func GetLatestTrendingFromHistory(db *sql.DB, kind int) ([]Post, time.Time, error) {
var (
trendingData []byte
calculationTime time.Time
)
err := db.QueryRow(`
SELECT trending_data, calculation_time
FROM trending_history
WHERE kind = $1
ORDER BY calculation_time DESC
LIMIT 1
`, kind).Scan(&trendingData, &calculationTime)
if err != nil {
if err == sql.ErrNoRows {
return []Post{}, time.Time{}, nil
}
return nil, time.Time{}, fmt.Errorf("failed to get latest trending data: %w", err)
}
posts, err := UnmarshalPosts(trendingData)
if err != nil {
return nil, time.Time{}, fmt.Errorf("failed to unmarshal trending posts: %w", err)
}
return posts, calculationTime, nil
}
// GetTrendingHistoryForKind retrieves trending history for the specified kind
// limit defines how many records to return, offset is for pagination
func GetTrendingHistoryForKind(db *sql.DB, kind int, limit, offset int) ([]TrendingHistoryEntry, error) {
rows, err := db.Query(`
SELECT id, calculation_time, trending_data
FROM trending_history
WHERE kind = $1
ORDER BY calculation_time DESC
LIMIT $2 OFFSET $3
`, kind, limit, offset)
if err != nil {
return nil, fmt.Errorf("failed to query trending history: %w", err)
}
defer rows.Close()
var entries []TrendingHistoryEntry
for rows.Next() {
var (
entry TrendingHistoryEntry
trendingData []byte
)
err := rows.Scan(&entry.ID, &entry.CalculationTime, &trendingData)
if err != nil {
return nil, fmt.Errorf("failed to scan trending history entry: %w", err)
}
entry.Posts, err = UnmarshalPosts(trendingData)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal trending posts: %w", err)
}
entries = append(entries, entry)
}
return entries, nil
}
// TrendingHistoryEntry represents a historical record of trending data
type TrendingHistoryEntry struct {
ID int `json:"id"`
CalculationTime time.Time `json:"calculation_time"`
Posts []Post `json:"posts"`
}