Compare commits
11 Commits
v0.1.0
...
v0.1.1-dev
Author | SHA1 | Date | |
---|---|---|---|
a55f4be7db | |||
406b288223 | |||
f06846b51c | |||
12d9d2d215 | |||
4d5561d132 | |||
28d04b7e0c | |||
a0689ea568 | |||
14c6770a72 | |||
3c2b8b75fa | |||
|
cabc153ee8 | ||
557a6f4ba8 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1 @@
|
||||
postgres/
|
||||
postgres**/
|
24
LICENSE
Normal file
24
LICENSE
Normal 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>
|
@@ -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
|
||||
|
194
relay/database.go
Normal file
194
relay/database.go
Normal 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
|
||||
}
|
11
relay/go.mod
11
relay/go.mod
@@ -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
|
||||
|
12
relay/go.sum
12
relay/go.sum
@@ -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=
|
||||
|
428
relay/main.go
428
relay/main.go
@@ -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)
|
||||
}
|
||||
|
@@ -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
140
relay/trending/schema.go
Normal 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"`
|
||||
}
|
Reference in New Issue
Block a user