clone

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

feat/standalone-relay
tinyrelay / internal / community / community.go
community.go 14.3K
1package community23import (4 "context"5 "crypto/rand"6 "database/sql"7 "encoding/hex"8 "encoding/json"9 "errors"10 "fmt"11 "regexp"12 "sync"13 "time"1415 "github.com/FelineStateMachine/tinyrelay/internal/policy"16 "github.com/FelineStateMachine/tinyrelay/internal/storage"17)1819const schema = `20CREATE TABLE IF NOT EXISTS community_members(pubkey TEXT PRIMARY KEY,name TEXT NOT NULL DEFAULT '',note TEXT NOT NULL DEFAULT '',role TEXT NOT NULL,invited_by TEXT NOT NULL DEFAULT '',via TEXT NOT NULL DEFAULT '',created_at INTEGER NOT NULL,keep_days INTEGER NOT NULL DEFAULT 0);21CREATE UNIQUE INDEX IF NOT EXISTS community_member_names ON community_members(name) WHERE name<>'';22CREATE TABLE IF NOT EXISTS community_bans(pubkey TEXT PRIMARY KEY,reason TEXT NOT NULL,at INTEGER NOT NULL);23CREATE TABLE IF NOT EXISTS community_event_bans(id TEXT PRIMARY KEY,reason TEXT NOT NULL,at INTEGER NOT NULL);24CREATE TABLE IF NOT EXISTS community_ip_blocks(ip TEXT PRIMARY KEY,reason TEXT NOT NULL,at INTEGER NOT NULL);25CREATE TABLE IF NOT EXISTS community_retention(kind INTEGER PRIMARY KEY,days INTEGER NOT NULL);26CREATE TABLE IF NOT EXISTS community_kind_rules(kind INTEGER PRIMARY KEY,rule TEXT NOT NULL);27CREATE TABLE IF NOT EXISTS community_invites(code TEXT PRIMARY KEY,created_by TEXT NOT NULL,created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL,max_uses INTEGER NOT NULL,uses INTEGER NOT NULL DEFAULT 0,note TEXT NOT NULL DEFAULT '');28CREATE TABLE IF NOT EXISTS community_reports(id INTEGER PRIMARY KEY AUTOINCREMENT,reporter TEXT NOT NULL,target_event TEXT NOT NULL,type TEXT NOT NULL,content TEXT NOT NULL,at INTEGER NOT NULL,status TEXT NOT NULL DEFAULT 'open',resolved_by TEXT NOT NULL DEFAULT '',resolved_at INTEGER NOT NULL DEFAULT 0,action TEXT NOT NULL DEFAULT '',UNIQUE(reporter,target_event));29CREATE TABLE IF NOT EXISTS community_audit(id INTEGER PRIMARY KEY AUTOINCREMENT,at INTEGER NOT NULL,actor TEXT NOT NULL,action TEXT NOT NULL,target TEXT NOT NULL DEFAULT '',detail TEXT NOT NULL DEFAULT '');30CREATE TABLE IF NOT EXISTS community_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);31CREATE TABLE IF NOT EXISTS join_requests(pubkey TEXT PRIMARY KEY,reason TEXT NOT NULL DEFAULT '',requested_at INTEGER NOT NULL,status TEXT NOT NULL DEFAULT 'pending',decided_by TEXT NOT NULL DEFAULT '',decided_at INTEGER NOT NULL DEFAULT 0);32CREATE TRIGGER IF NOT EXISTS community_member_projection_insert AFTER INSERT ON community_members33BEGIN34 INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)35 VALUES('community-member-'||NEW.pubkey||'-'||NEW.created_at,'records-projection','community-member-'||NEW.pubkey||'-'||NEW.created_at,'membership',json_object('action','membership','pubkey',NEW.pubkey,'role',NEW.role),strftime('%s','now'),strftime('%s','now'),strftime('%s','now'));36END;37CREATE TRIGGER IF NOT EXISTS community_member_projection_update AFTER UPDATE OF name,note,role,invited_by,via,keep_days ON community_members38BEGIN39 INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)40 VALUES('community-member-update-'||lower(hex(randomblob(16))),'records-projection','community-member-update-'||lower(hex(randomblob(16))),'membership',json_object('action','membership','pubkey',NEW.pubkey,'role',NEW.role),strftime('%s','now'),strftime('%s','now'),strftime('%s','now'));41END;42CREATE TRIGGER IF NOT EXISTS community_member_projection_delete AFTER DELETE ON community_members43BEGIN44 INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)45 VALUES('community-member-delete-'||lower(hex(randomblob(16))),'records-projection','community-member-delete-'||lower(hex(randomblob(16))),'membership',json_object('action','membership','pubkey',OLD.pubkey,'role',OLD.role,'removed',1),strftime('%s','now'),strftime('%s','now'),strftime('%s','now'));46END;47`4849type Member struct {50 PubKey string `json:"pubkey"`51 Name string `json:"name"`52 Note string `json:"note"`53 Role string `json:"role"`54 InvitedBy string `json:"invited_by"`55 Via string `json:"via"`56 CreatedAt int64 `json:"created_at"`57 KeepDays int `json:"keep_days"`58}59type Invite struct {60 Code string `json:"code"`61 CreatedBy string `json:"created_by"`62 CreatedAt int64 `json:"created_at"`63 ExpiresAt int64 `json:"expires_at"`64 MaxUses int `json:"max_uses"`65 Uses int `json:"uses"`66 Note string `json:"note"`67}6869// JoinRequest is an access request from a key that asked to join without70// an invite. It waits for the owner or a moderator to approve or deny it.71type JoinRequest struct {72 PubKey string `json:"pubkey"`73 Reason string `json:"reason"`74 RequestedAt int64 `json:"requested_at"`75 Status string `json:"status"`76 DecidedBy string `json:"decided_by"`77 DecidedAt int64 `json:"decided_at"`78}79type Report struct {80 ID int64 `json:"id"`81 Reporter string `json:"reporter"`82 TargetEvent string `json:"target_event"`83 Type string `json:"type"`84 Content string `json:"content"`85 At int64 `json:"at"`86 Status string `json:"status"`87 ResolvedBy string `json:"resolved_by"`88 ResolvedAt int64 `json:"resolved_at"`89 Action string `json:"action"`90}91type AuditRow struct {92 ID int64 `json:"id"`93 At int64 `json:"at"`94 Actor string `json:"actor"`95 Action string `json:"action"`96 Target string `json:"target"`97 Detail string `json:"detail"`98}99100type Service struct {101 store *storage.Store102 mu sync.RWMutex103 owner string104 slug string105 policy func() policy.Policy106}107108func New(ctx context.Context, store *storage.Store, owner string) (*Service, error) {109 if store == nil {110 return nil, errors.New("community: nil store")111 }112 if !validPubKey(owner) {113 return nil, errors.New("community: invalid owner pubkey")114 }115 s := &Service{store: store, owner: owner}116 if _, err := store.DB().ExecContext(ctx, schema+agentSchema); err != nil {117 return nil, fmt.Errorf("community schema: %w", err)118 }119 if _, err := store.DB().ExecContext(ctx, roomSchema); err != nil {120 return nil, fmt.Errorf("room schema: %w", err)121 }122 if err := store.WithTx(ctx, func(tx *sql.Tx) error {123 _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO community_members(pubkey,role,created_at) VALUES(?,?,?)`, owner, "owner", time.Now().Unix())124 return err125 }); err != nil {126 return nil, err127 }128 return s, nil129}130131// ConfigurePolicy supplies the current durable tenant policy. It is read at132// admission time so invitation depth, quota, and terms changes take effect133// without duplicating policy state in the community database.134func (s *Service) ConfigurePolicy(getter func() policy.Policy) {135 s.mu.Lock()136 s.policy = getter137 s.mu.Unlock()138}139140func (s *Service) currentPolicy() policy.Policy {141 s.mu.RLock()142 getter := s.policy143 s.mu.RUnlock()144 if getter == nil {145 return policy.Policy{}146 }147 return getter()148}149150// ApplyOwnerTx changes the community owner rows in a caller-owned151// transaction. The root must persist the corresponding policy owner in this152// same transaction, then call SetOwner after commit.153func (s *Service) ApplyOwnerTx(ctx context.Context, tx *sql.Tx, oldOwner, newOwner string) error {154 if !validPubKey(newOwner) || newOwner == oldOwner {155 return errors.New("invalid: new owner pubkey")156 }157 if oldOwner == "" {158 s.mu.RLock()159 oldOwner = s.owner160 s.mu.RUnlock()161 }162 var role string163 if err := tx.QueryRowContext(ctx, `SELECT role FROM community_members WHERE pubkey=?`, newOwner).Scan(&role); err != nil {164 if errors.Is(err, sql.ErrNoRows) {165 return errors.New("invalid: new owner must be a member")166 }167 return err168 }169 if _, err := tx.ExecContext(ctx, `UPDATE community_members SET role='moderator' WHERE pubkey=? AND role='owner'`, oldOwner); err != nil {170 return err171 }172 if _, err := tx.ExecContext(ctx, `UPDATE community_members SET role='owner' WHERE pubkey=?`, newOwner); err != nil {173 return err174 }175 return nil176}177178// SetOwner updates only the in-memory owner cache and is intended to run179// after the transaction that persists policy and membership rows commits.180func (s *Service) SetOwner(newOwner string) error {181 if !validPubKey(newOwner) {182 return errors.New("invalid: owner pubkey")183 }184 s.mu.Lock()185 s.owner = newOwner186 s.mu.Unlock()187 return nil188}189190func (s *Service) Role(ctx context.Context, pubkey string) (string, error) {191 s.mu.RLock()192 owner := s.owner193 s.mu.RUnlock()194 if pubkey == owner {195 return "owner", nil196 }197 var role string198 err := s.store.DB().QueryRowContext(ctx, `SELECT role FROM community_members WHERE pubkey=?`, pubkey).Scan(&role)199 if errors.Is(err, sql.ErrNoRows) {200 return "", nil201 }202 if err != nil {203 return "", fmt.Errorf("role: %w", err)204 }205 return role, nil206}207208// Moderators lists the keys that hold the moderator role.209func (s *Service) Moderators(ctx context.Context) ([]string, error) {210 rows, err := s.store.DB().QueryContext(ctx, `SELECT pubkey FROM community_members WHERE role='moderator' ORDER BY pubkey`)211 if err != nil {212 return nil, fmt.Errorf("moderators: %w", err)213 }214 defer rows.Close()215 var moderators []string216 for rows.Next() {217 var pubkey string218 if err := rows.Scan(&pubkey); err != nil {219 return nil, err220 }221 moderators = append(moderators, pubkey)222 }223 return moderators, rows.Err()224}225226func (s *Service) Execute(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {227 if method == "supportedmethods" {228 return s.Methods(), nil229 }230 role, err := s.Role(ctx, actor)231 if err != nil {232 return nil, err233 }234 if !allowed(method, role) {235 return nil, fmt.Errorf("restricted: %s may not %s", roleOrGuest(role), method)236 }237 switch method {238 case "setmember", "allowpubkey":239 return s.setMember(ctx, actor, params)240 case "removemember", "unrulepubkey":241 return s.removeMember(ctx, actor, params)242 case "removesubtree":243 return s.removeSubtree(ctx, actor, params)244 case "listmembers", "listpeople":245 return s.members(ctx)246 case "listjoinrequests":247 return s.listJoinRequests(ctx)248 case "approvejoin":249 return s.approveJoin(ctx, actor, params)250 case "denyjoin":251 return s.denyJoin(ctx, actor, params)252 case "listallowedpubkeys":253 return s.allowedMembers(ctx)254 case "createinvite":255 return s.createInvite(ctx, actor, params, false)256 case "createclaim":257 return s.createInvite(ctx, actor, params, true)258 case "listinvites":259 return s.invites(ctx, actor, role)260 case "listclaims":261 return s.claims(ctx, actor, role)262 case "revokeinvite", "deleteclaim":263 return s.revokeInvite(ctx, actor, role, params)264 case "transferowner":265 return s.transferOwner(ctx, params)266 case "banpubkey":267 return s.ban(ctx, actor, params)268 case "listbannedpubkeys":269 return s.listBans(ctx)270 case "banevent":271 return s.banEvent(ctx, actor, params)272 case "listbannedevents":273 return s.listBannedEvents(ctx)274 case "blockip":275 return s.blockIP(ctx, actor, params, true)276 case "unblockip":277 return s.blockIP(ctx, actor, params, false)278 case "listblockedips":279 return s.listIPs(ctx)280 case "listaudit":281 return s.audit(ctx)282 case "listreports":283 return s.reports(ctx, params)284 case "listeventsneedingmoderation":285 return s.moderationQueue(ctx)286 case "setblockedwords":287 return s.setBlockedWords(ctx, actor, params)288 case "purgekind":289 return s.purgeKind(ctx, actor, params)290 case "setretention":291 return s.setRetention(ctx, actor, params)292 case "listretention":293 return s.listRetention(ctx)294 case "resolvereport":295 return s.resolveReport(ctx, actor, params)296 case "allowevent":297 return s.allowEvent(ctx, actor, params)298 case "allowkind", "disallowkind", "unrulekind":299 return s.setKind(ctx, actor, method, params)300 case "listallowedkinds", "listblockedkinds":301 rule := "block"302 if method == "listallowedkinds" {303 rule = "allow"304 }305 return s.listKinds(ctx, rule)306 default:307 return nil, fmt.Errorf("unsupported: unknown community method %q", method)308 }309}310311func allowed(method, role string) bool {312 if method == "listmembers" || method == "listpeople" || method == "listjoinrequests" || method == "listallowedpubkeys" || method == "listbannedpubkeys" || method == "listbannedevents" || method == "listblockedips" || method == "listreports" || method == "listeventsneedingmoderation" || method == "listallowedkinds" || method == "listblockedkinds" || method == "listretention" {313 return role == "owner" || role == "moderator"314 }315 if role == "owner" {316 return true317 }318 if role == "member" {319 for _, name := range []string{"createinvite", "createclaim", "listinvites", "listclaims", "revokeinvite", "deleteclaim"} {320 if method == name {321 return true322 }323 }324 return false325 }326 if role != "moderator" {327 return false328 }329 for _, name := range []string{"setmember", "allowpubkey", "removemember", "unrulepubkey", "removesubtree", "approvejoin", "denyjoin", "createinvite", "createclaim", "listinvites", "listclaims", "revokeinvite", "deleteclaim", "banpubkey", "banevent", "allowevent", "blockip", "unblockip", "listaudit", "resolvereport", "allowkind", "disallowkind", "unrulekind", "setblockedwords"} {330 if method == name {331 return true332 }333 }334 return false335}336337func (s *Service) Methods() []string {338 return []string{"supportedmethods", "listaudit", "listmembers", "listpeople", "listjoinrequests", "approvejoin", "denyjoin", "listallowedpubkeys", "listbannedpubkeys", "setmember", "allowpubkey", "removemember", "unrulepubkey", "removesubtree", "createinvite", "listinvites", "revokeinvite", "listclaims", "createclaim", "deleteclaim", "banpubkey", "banevent", "allowevent", "listbannedevents", "blockip", "unblockip", "listblockedips", "listreports", "listeventsneedingmoderation", "resolvereport", "allowkind", "disallowkind", "unrulekind", "listallowedkinds", "listblockedkinds", "setretention", "listretention", "purgekind", "setblockedwords"}339}340341func roleOrGuest(role string) string {342 if role == "" {343 return "guest"344 }345 return role346}347348func validPubKey(value string) bool {349 if len(value) != 64 {350 return false351 }352 for _, c := range value {353 if c < '0' || c > 'f' || c > '9' && c < 'a' {354 return false355 }356 }357 return true358}359360var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)361var codeRE = regexp.MustCompile(`^[A-Za-z0-9_-]{4,64}$`)362363func decode[T any](params []json.RawMessage, index int, out *T) error {364 if index >= len(params) {365 return errors.New("invalid: missing parameter")366 }367 if err := json.Unmarshal(params[index], out); err != nil {368 return fmt.Errorf("invalid: parameter %d: %w", index, err)369 }370 return nil371}372func now() int64 { return time.Now().Unix() }373func token() (string, error) {374 b := make([]byte, 16)375 if _, err := rand.Read(b); err != nil {376 return "", err377 }378 return hex.EncodeToString(b), nil379}