clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / internal / records / records.go
records.go 5.6K
1package records23import (4 "context"5 "errors"6 "fmt"7 "strconv"8 "sync"9 "time"1011 "github.com/FelineStateMachine/tinyrelay/internal/communityread"12 "github.com/FelineStateMachine/tinyrelay/internal/event"13 "github.com/FelineStateMachine/tinyrelay/internal/policy"14 "github.com/FelineStateMachine/tinyrelay/internal/storage"15)1617type Config struct {18 // Store holds the tenant database and records settings.19 Store *storage.Store20 // Policy returns the current durable tenant policy when records evaluates21 // access or builds a projection.22 Policy func() policy.Policy23 // SetPolicy persists a policy change requested by records management.24 SetPolicy func(policy.Policy) error25 RelayURL string26 GroupID string27 // OnGenerated receives a signed event after its records clock transaction28 // commits. The callback commonly persists, publishes or queues the event.29 OnGenerated func(context.Context, event.Event) error30 // DeliverNotification delivers a sealed notification event to its target.31 DeliverNotification func(context.Context, event.Event, string) error32 // PushNotification receives a plaintext summary of each notification for33 // device delivery. The gift wrap itself is opaque to the relay once sealed.34 PushNotification func(context.Context, string, string, string, string) error35 OnTransfer func(context.Context, string, string) error36 // Community supplies membership and moderation projections through the37 // CommunityReader boundary.38 Community CommunityReader39}4041// CommunityReader is the read-only boundary records needs from community.42// Implementations own the community schema and may use a transaction-backed43// snapshot when the host is publishing a projection.44type CommunityReader interface {45 Members(ctx context.Context) ([]communityread.Member, error)46 ModerationCounts(ctx context.Context, since int64) (communityread.ModerationCounts, error)47 MemberStatus(ctx context.Context, pubkey string) (member bool, memberCount int, err error)48}4950type Member = communityread.Member51type ModerationCounts = communityread.ModerationCounts5253type Service struct {54 store *storage.Store55 policy func() policy.Policy56 setPolicy func(policy.Policy) error57 relayURL string58 groupID string59 secret string60 onGenerated func(context.Context, event.Event) error61 deliverNotification func(context.Context, event.Event, string) error62 pushNotification func(context.Context, string, string, string, string) error63 onTransfer func(context.Context, string, string) error64 community CommunityReader65 mu sync.Mutex66}6768type MembershipChange struct {69 PubKey string70 Added *bool71 Roles []string72}7374const secretSetting = "records.relay_secret"7576const recordsSchema = `77CREATE TABLE IF NOT EXISTS records_audit(78 id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, actor TEXT NOT NULL,79 action TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', detail TEXT NOT NULL DEFAULT '');80CREATE TABLE IF NOT EXISTS records_presence(81 pubkey TEXT PRIMARY KEY, seen_at INTEGER NOT NULL);82CREATE TABLE IF NOT EXISTS records_pins(83 position INTEGER PRIMARY KEY, ref TEXT NOT NULL UNIQUE);84CREATE TABLE IF NOT EXISTS records_view_runs(85 name TEXT NOT NULL, at INTEGER NOT NULL, rows INTEGER NOT NULL);86`8788func New(ctx context.Context, cfg Config) (*Service, error) {89 if cfg.Store == nil {90 return nil, errors.New("records: nil store")91 }92 if cfg.Policy == nil {93 return nil, errors.New("records: nil policy function")94 }95 if cfg.Community == nil {96 return nil, errors.New("records: nil community reader")97 }98 if _, err := cfg.Store.DB().ExecContext(ctx, recordsSchema); err != nil {99 return nil, fmt.Errorf("records schema: %w", err)100 }101 secret, err := loadOrCreateSecret(ctx, cfg.Store)102 if err != nil {103 return nil, err104 }105 groupID := cfg.GroupID106 if groupID == "" {107 groupID = cfg.RelayURL108 }109 return &Service{store: cfg.Store, policy: cfg.Policy, setPolicy: cfg.SetPolicy, relayURL: cfg.RelayURL, groupID: groupID, secret: secret, onGenerated: cfg.OnGenerated, deliverNotification: cfg.DeliverNotification, pushNotification: cfg.PushNotification, onTransfer: cfg.OnTransfer, community: cfg.Community}, nil110}111112var roleOrder = []string{"owner", "moderator", "member", "agent"}113var rolesAbout = map[string]string{"owner": "relay owner", "moderator": "relay moderator", "member": "relay member", "agent": "agent under an owner-signed grant"}114115type Presence struct {116 PubKey string `json:"pubkey"`117 SeenAt int64 `json:"seen_at"`118}119120type View struct {121 Name string `json:"name"`122 Event event.Event `json:"event"`123 Rows int `json:"rows"`124}125126type ViewRun struct {127 At int64 `json:"at"`128 Rows int `json:"rows"`129}130131var viewNames = []string{"profiles", "relays", "calendar", "moderation", "articles", "zaps", "presence"}132133func calendarStart(e event.Event) int64 {134 s := event.Tag(e, "start")135 if e.Kind == 31922 {136 t, err := time.Parse("2006-01-02", s)137 if err != nil {138 return 0139 }140 return t.Unix()141 }142 n, err := strconv.ParseInt(s, 10, 64)143 if err != nil {144 return 0145 }146 return n147}148func articlePublished(e event.Event) int64 {149 s := event.Tag(e, "published_at")150 if n, err := strconv.ParseInt(s, 10, 64); err == nil {151 return n152 }153 return e.CreatedAt154}155156func minInt(a, b int) int {157 if a < b {158 return a159 }160 return b161}162163func trimString(v any, n int) string {164 s, ok := v.(string)165 if !ok {166 return ""167 }168 if len(s) > n {169 return s[:n]170 }171 return s172}173174type successionLog struct {175 At int64 `json:"at"`176 From string `json:"from"`177 To string `json:"to"`178}179180func maxInt(a, b int) int {181 if a > b {182 return a183 }184 return b185}186187var _ = strconv.IntSize