tinyrelayfile
tinyrelay/internal/community/community.go/

internal/community/community.go raw
1package community
2
3import (
4 "context"
5 "crypto/rand"
6 "database/sql"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "regexp"
12 "sync"
13 "time"
14
15 "github.com/FelineStateMachine/tinyrelay/internal/policy"
16 "github.com/FelineStateMachine/tinyrelay/internal/storage"
17)
18
19const 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_members
33BEGIN
34 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_members
38BEGIN
39 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_members
43BEGIN
44 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`
48
49type 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}
68
69// JoinRequest is an access request from a key that asked to join without
70// 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}
99
100type Service struct {
101 store *storage.Store
102 mu sync.RWMutex
103 owner string
104 slug string
105 policy func() policy.Policy
106}
107
108func 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 err
125 }); err != nil {
126 return nil, err
127 }
128 return s, nil
129}
130
131// ConfigurePolicy supplies the current durable tenant policy. It is read at
132// admission time so invitation depth, quota, and terms changes take effect
133// without duplicating policy state in the community database.
134func (s *Service) ConfigurePolicy(getter func() policy.Policy) {
135 s.mu.Lock()
136 s.policy = getter
137 s.mu.Unlock()
138}
139
140func (s *Service) currentPolicy() policy.Policy {
141 s.mu.RLock()
142 getter := s.policy
143 s.mu.RUnlock()
144 if getter == nil {
145 return policy.Policy{}
146 }
147 return getter()
148}
149
150// ApplyOwnerTx changes the community owner rows in a caller-owned
151// transaction. The root must persist the corresponding policy owner in this
152// 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.owner
160 s.mu.RUnlock()
161 }
162 var role string
163 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 err
168 }
169 if _, err := tx.ExecContext(ctx, `UPDATE community_members SET role='moderator' WHERE pubkey=? AND role='owner'`, oldOwner); err != nil {
170 return err
171 }
172 if _, err := tx.ExecContext(ctx, `UPDATE community_members SET role='owner' WHERE pubkey=?`, newOwner); err != nil {
173 return err
174 }
175 return nil
176}
177
178// SetOwner updates only the in-memory owner cache and is intended to run
179// 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 = newOwner
186 s.mu.Unlock()
187 return nil
188}
189
190func (s *Service) Role(ctx context.Context, pubkey string) (string, error) {
191 s.mu.RLock()
192 owner := s.owner
193 s.mu.RUnlock()
194 if pubkey == owner {
195 return "owner", nil
196 }
197 var role string
198 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 "", nil
201 }
202 if err != nil {
203 return "", fmt.Errorf("role: %w", err)
204 }
205 return role, nil
206}
207
208// 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 []string
216 for rows.Next() {
217 var pubkey string
218 if err := rows.Scan(&pubkey); err != nil {
219 return nil, err
220 }
221 moderators = append(moderators, pubkey)
222 }
223 return moderators, rows.Err()
224}
225
226func (s *Service) Execute(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {
227 if method == "supportedmethods" {
228 return s.Methods(), nil
229 }
230 role, err := s.Role(ctx, actor)
231 if err != nil {
232 return nil, err
233 }
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}
310
311func 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 true
317 }
318 if role == "member" {
319 for _, name := range []string{"createinvite", "createclaim", "listinvites", "listclaims", "revokeinvite", "deleteclaim"} {
320 if method == name {
321 return true
322 }
323 }
324 return false
325 }
326 if role != "moderator" {
327 return false
328 }
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 true
332 }
333 }
334 return false
335}
336
337func (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}
340
341func roleOrGuest(role string) string {
342 if role == "" {
343 return "guest"
344 }
345 return role
346}
347
348func validPubKey(value string) bool {
349 if len(value) != 64 {
350 return false
351 }
352 for _, c := range value {
353 if c < '0' || c > 'f' || c > '9' && c < 'a' {
354 return false
355 }
356 }
357 return true
358}
359
360var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
361var codeRE = regexp.MustCompile(`^[A-Za-z0-9_-]{4,64}$`)
362
363func 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 nil
371}
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 "", err
377 }
378 return hex.EncodeToString(b), nil
379}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs