tinyrelayfile
tinyrelay/internal/community/actions.go/

internal/community/actions.go raw
1package community
2
3import (
4 "context"
5 "crypto/sha256"
6 "database/sql"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "net"
12 "strings"
13)
14
15func (s *Service) setMember(ctx context.Context, actor string, p []json.RawMessage) (any, error) {
16 return s.setMemberVia(ctx, actor, p, "management")
17}
18
19// setMemberVia adds or updates a member and records how the key came in:
20// management for the setmember call, request for an approved access request.
21func (s *Service) setMemberVia(ctx context.Context, actor string, p []json.RawMessage, via string) (any, error) {
22 var pk, name, role string
23 if err := decode(p, 0, &pk); err != nil {
24 return nil, err
25 }
26 if !validPubKey(pk) {
27 return nil, errors.New("invalid: bad pubkey")
28 }
29 var patch struct {
30 Name *string `json:"name"`
31 Note string `json:"note"`
32 Role string `json:"role"`
33 }
34 if len(p) > 1 {
35 if actor != s.owner {
36 var targetRole string
37 err := s.store.DB().QueryRowContext(ctx, `SELECT role FROM community_members WHERE pubkey=?`, pk).Scan(&targetRole)
38 if err == nil && targetRole != "member" {
39 return nil, errors.New("restricted: moderators cannot edit the owner or other moderators")
40 }
41 }
42 raw := strings.TrimSpace(string(p[1]))
43 if len(raw) > 0 && raw[0] == '"' {
44 if err := json.Unmarshal(p[1], &patch.Note); err != nil {
45 return nil, err
46 }
47 } else if err := json.Unmarshal(p[1], &patch); err != nil {
48 return nil, fmt.Errorf("invalid: member patch: %w", err)
49 }
50 }
51 if patch.Name != nil {
52 name = *patch.Name
53 }
54 role = patch.Role
55 if role == "" {
56 role = "member"
57 }
58 if role != "member" && role != "moderator" {
59 return nil, errors.New("invalid: role must be member or moderator")
60 }
61 if name != "" {
62 name = strings.ToLower(strings.TrimSpace(name))
63 if !nameRE.MatchString(name) {
64 return nil, errors.New("invalid: bad member name")
65 }
66 }
67 if role == "moderator" && actor != s.owner {
68 return nil, errors.New("restricted: only the owner may appoint moderators")
69 }
70 err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
71 if _, err := tx.ExecContext(ctx, `DELETE FROM community_bans WHERE pubkey=?`, pk); err != nil {
72 return err
73 }
74 _, err := tx.ExecContext(ctx, `INSERT INTO community_members(pubkey,name,role,created_at,via) VALUES(?,?,?,?,?) ON CONFLICT(pubkey) DO UPDATE SET name=excluded.name,role=excluded.role`, pk, name, role, now(), via)
75 if err != nil {
76 return fmt.Errorf("set member: %w", err)
77 }
78 if patch.Note != "" {
79 if _, err := tx.ExecContext(ctx, `UPDATE community_members SET note=? WHERE pubkey=?`, patch.Note[:min(200, len(patch.Note))], pk); err != nil {
80 return err
81 }
82 }
83 return s.recordTx(ctx, tx, actor, "setmember", pk, role)
84 })
85 return map[string]any{"pubkey": pk, "role": role}, err
86}
87
88func (s *Service) removeMember(ctx context.Context, actor string, p []json.RawMessage) (any, error) {
89 var pk string
90 if err := decode(p, 0, &pk); err != nil {
91 return nil, err
92 }
93 if !validPubKey(pk) {
94 return nil, errors.New("invalid: bad pubkey")
95 }
96 if pk == s.owner {
97 return nil, errors.New("invalid: cannot remove owner")
98 }
99 if actor != s.owner {
100 var targetRole string
101 if err := s.store.DB().QueryRowContext(ctx, `SELECT role FROM community_members WHERE pubkey=?`, pk).Scan(&targetRole); err == nil && targetRole == "moderator" {
102 return nil, errors.New("restricted: moderators cannot remove the owner or other moderators")
103 }
104 }
105 err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
106 if _, err := tx.ExecContext(ctx, `DELETE FROM community_members WHERE pubkey=?`, pk); err != nil {
107 return err
108 }
109 return s.recordTx(ctx, tx, actor, "removemember", pk, "")
110 })
111 return map[string]any{"removed": pk}, err
112}
113
114func (s *Service) removeSubtree(ctx context.Context, actor string, p []json.RawMessage) (any, error) {
115 var root string
116 if err := decode(p, 0, &root); err != nil {
117 return nil, err
118 }
119 if !validPubKey(root) {
120 return nil, errors.New("invalid: bad pubkey")
121 }
122 if root == s.owner {
123 return nil, errors.New("invalid: cannot remove the owner")
124 }
125 role, _ := s.Role(ctx, root)
126 if role != "member" {
127 return nil, errors.New("restricted: moderators cannot remove the owner or other moderators")
128 }
129 seen := map[string]bool{root: true}
130 queue := []string{root}
131 for len(queue) > 0 {
132 parent := queue[0]
133 queue = queue[1:]
134 rows, err := s.store.DB().QueryContext(ctx, `SELECT pubkey FROM community_members WHERE invited_by=? AND role='member'`, parent)
135 if err != nil {
136 return nil, err
137 }
138 for rows.Next() {
139 var pk string
140 if err := rows.Scan(&pk); err != nil {
141 rows.Close()
142 return nil, err
143 }
144 if !seen[pk] {
145 seen[pk] = true
146 queue = append(queue, pk)
147 }
148 }
149 rows.Close()
150 }
151 err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
152 for pk := range seen {
153 if _, err := tx.ExecContext(ctx, `DELETE FROM community_members WHERE pubkey=?`, pk); err != nil {
154 return err
155 }
156 }
157 return s.recordTx(ctx, tx, actor, "removesubtree", root, fmt.Sprint(len(seen)))
158 })
159 return map[string]any{"removed": len(seen)}, err
160}
161
162func (s *Service) members(ctx context.Context) (any, error) {
163 rows, err := s.store.DB().QueryContext(ctx, `SELECT pubkey,name,note,role,invited_by,via,created_at,keep_days FROM community_members ORDER BY created_at,pubkey`)
164 if err != nil {
165 return nil, err
166 }
167 defer rows.Close()
168 out := []Member{}
169 for rows.Next() {
170 var m Member
171 if err := rows.Scan(&m.PubKey, &m.Name, &m.Note, &m.Role, &m.InvitedBy, &m.Via, &m.CreatedAt, &m.KeepDays); err != nil {
172 return nil, err
173 }
174 out = append(out, m)
175 }
176 return map[string]any{"self": s.owner, "members": out}, rows.Err()
177}
178
179func (s *Service) allowedMembers(ctx context.Context) (any, error) {
180 value, err := s.members(ctx)
181 if err != nil {
182 return nil, err
183 }
184 members := value.(map[string]any)["members"].([]Member)
185 out := make([]map[string]string, 0, len(members))
186 for _, member := range members {
187 if member.Role != "owner" {
188 out = append(out, map[string]string{"pubkey": member.PubKey, "reason": member.Note})
189 }
190 }
191 return out, nil
192}
193
194func (s *Service) createInvite(ctx context.Context, actor string, p []json.RawMessage, claim bool) (any, error) {
195 code := ""
196 if claim && len(p) != 1 {
197 return nil, errors.New("invalid: give one invite code of 4 to 64 letters, digits, dash or underscore")
198 }
199 if claim && len(p) > 0 {
200 if err := decode(p, 0, &code); err != nil {
201 return nil, err
202 }
203 }
204 if code != "" && !codeRE.MatchString(code) {
205 return nil, errors.New("invalid: invite code must be 4 to 64 letters, digits, dash or underscore")
206 }
207 if !claim && code == "" {
208 var err error
209 code, err = token()
210 if err != nil {
211 return nil, err
212 }
213 }
214 if role, _ := s.Role(ctx, actor); role == "member" {
215 if reason := s.memberInviteGate(ctx, actor); reason != "" {
216 return nil, errors.New(reason)
217 }
218 }
219 ttl, maxUses := int64(3*86400), 0
220 note := ""
221 if !claim {
222 if len(p) > 0 {
223 _ = decode(p, 0, &ttl)
224 }
225 if len(p) > 1 {
226 _ = decode(p, 1, &maxUses)
227 }
228 if len(p) > 2 {
229 _ = decode(p, 2, &note)
230 if len(note) > 200 {
231 note = note[:200]
232 }
233 }
234 }
235 if claim && !codeRE.MatchString(code) {
236 return nil, errors.New("invalid: give one invite code of 4 to 64 letters, digits, dash or underscore")
237 }
238 if !claim && ttl < 60 {
239 ttl = 60
240 }
241 if !claim && ttl > 30*86400 {
242 ttl = 30 * 86400
243 }
244 if maxUses < 0 {
245 maxUses = 0
246 }
247 inv := Invite{Code: code, CreatedBy: actor, CreatedAt: now(), ExpiresAt: now() + ttl, MaxUses: maxUses, Note: note}
248 err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
249 _, err := tx.ExecContext(ctx, `INSERT INTO community_invites(code,created_by,created_at,expires_at,max_uses,note) VALUES(?,?,?,?,?,?)`, inv.Code, inv.CreatedBy, inv.CreatedAt, inv.ExpiresAt, inv.MaxUses, inv.Note)
250 if err != nil {
251 return fmt.Errorf("create invite: %w", err)
252 }
253 return s.recordTx(ctx, tx, actor, "createinvite", code, "")
254 })
255 if claim {
256 if err != nil {
257 return nil, err
258 }
259 return true, nil
260 }
261 return inv, err
262}
263
264func (s *Service) Claim(ctx context.Context, code, pubkey string) (any, error) {
265 return s.ClaimWithTerms(ctx, code, pubkey, "")
266}
267
268// IssueNIP43Invite creates a short-lived claim for an authenticated member.
269// The relay root signs the resulting kind 28935 response; this package owns
270// claim policy and persistence but never signs relay events itself.
271func (s *Service) IssueNIP43Invite(ctx context.Context, requester string) (Invite, error) {
272 if !validPubKey(requester) {
273 return Invite{}, errors.New("invalid: invite requester pubkey")
274 }
275 role, err := s.Role(ctx, requester)
276 if err != nil {
277 return Invite{}, err
278 }
279 if role == "" {
280 return Invite{}, errors.New("restricted: invite request requires membership")
281 }
282 value, err := s.createInvite(ctx, requester, nil, false)
283 if err != nil {
284 return Invite{}, err
285 }
286 invite, ok := value.(Invite)
287 if !ok {
288 return Invite{}, errors.New("community: invalid invite result")
289 }
290 return invite, nil
291}
292
293// ClaimWithTerms joins through an invite. When the relay publishes join terms,
294// consent must include the SHA-256 hash of the exact terms text.
295func (s *Service) ClaimWithTerms(ctx context.Context, code, pubkey, termsHash string) (any, error) {
296 if !validPubKey(pubkey) {
297 return nil, errors.New("invalid: bad pubkey")
298 }
299 banned, err := s.IsBanned(ctx, pubkey)
300 if err != nil {
301 return nil, err
302 }
303 if banned {
304 return nil, errors.New("blocked: this pubkey is banned from this relay")
305 }
306 terms := s.currentPolicy().JoinTerms
307 if terms != "" {
308 sum := sha256.Sum256([]byte(terms))
309 if !strings.EqualFold(hex.EncodeToString(sum[:]), termsHash) {
310 return nil, errors.New("terms_mismatch")
311 }
312 }
313 var member bool
314 if err := s.store.DB().QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM community_members WHERE pubkey=?)`, pubkey).Scan(&member); err != nil {
315 return nil, err
316 }
317 if member {
318 role, _ := s.Role(ctx, pubkey)
319 return map[string]any{"status": "already_member", "role": role}, nil
320 }
321 err = s.store.WithTx(ctx, func(tx *sql.Tx) error {
322 var id, creator string
323 var exp int64
324 var max, uses int
325 err := tx.QueryRowContext(ctx, `SELECT code,created_by,expires_at,max_uses,uses FROM community_invites WHERE code=?`, code).Scan(&id, &creator, &exp, &max, &uses)
326 if errors.Is(err, sql.ErrNoRows) {
327 return errors.New("invite_invalid")
328 }
329 if err != nil {
330 return err
331 }
332 if exp < now() {
333 return errors.New("invite_expired")
334 }
335 if max > 0 && uses >= max {
336 return errors.New("invite_exhausted")
337 }
338 var creatorExists bool
339 if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM community_members WHERE pubkey=?)`, creator).Scan(&creatorExists); err != nil {
340 return err
341 }
342 if !creatorExists {
343 return errors.New("invite_revoked")
344 }
345 r, err := tx.ExecContext(ctx, `UPDATE community_invites SET uses=uses+1 WHERE code=? AND (max_uses=0 OR uses<max_uses)`, code)
346 if err != nil {
347 return err
348 }
349 n, _ := r.RowsAffected()
350 if n != 1 {
351 return errors.New("invite_exhausted")
352 }
353 if _, err = tx.ExecContext(ctx, `INSERT INTO community_members(pubkey,role,invited_by,via,created_at) VALUES(?,?,?,?,?)`, pubkey, "member", creator, "invite "+code, now()); err != nil {
354 return err
355 }
356 return s.recordTx(ctx, tx, pubkey, "claiminvite", code, "")
357 })
358 if err != nil {
359 return nil, err
360 }
361 return map[string]any{"status": "joined", "role": "member"}, nil
362}
363
364func (s *Service) memberInviteGate(ctx context.Context, pubkey string) string {
365 p := s.currentPolicy().MemberInvites
366 if p.Depth <= 0 || p.Quota <= 0 {
367 return "restricted: only the owner and moderators mint invites here"
368 }
369 depth := s.inviteDepth(ctx, pubkey)
370 if depth >= p.Depth {
371 return "restricted: invites do not reach this far down the tree"
372 }
373 var active int
374 if err := s.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM community_invites WHERE created_by=? AND expires_at>=? AND (max_uses=0 OR uses<max_uses)`, pubkey, now()).Scan(&active); err != nil {
375 return "restricted: invite policy unavailable"
376 }
377 if active >= p.Quota {
378 return fmt.Sprintf("restricted: you already hold %d live invite%s", p.Quota, map[bool]string{true: "", false: "s"}[p.Quota == 1])
379 }
380 return ""
381}
382
383func (s *Service) memberInviteGateTx(ctx context.Context, tx *sql.Tx, pubkey string) string {
384 p := s.currentPolicy().MemberInvites
385 if p.Depth <= 0 || p.Quota <= 0 {
386 return "restricted: only the owner and moderators mint invites here"
387 }
388 depth := 0
389 cur := pubkey
390 seen := map[string]bool{cur: true}
391 for depth < 100 {
392 var parent string
393 if err := tx.QueryRowContext(ctx, `SELECT invited_by FROM community_members WHERE pubkey=?`, cur).Scan(&parent); err != nil || parent == "" {
394 break
395 }
396 depth++
397 if parent == s.owner || seen[parent] {
398 break
399 }
400 seen[parent] = true
401 cur = parent
402 }
403 if depth >= p.Depth {
404 return "restricted: invites do not reach this far down the tree"
405 }
406 var active int
407 if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM community_invites WHERE created_by=? AND expires_at>=? AND (max_uses=0 OR uses<max_uses)`, pubkey, now()).Scan(&active); err != nil {
408 return "restricted: invite policy unavailable"
409 }
410 if active >= p.Quota {
411 return fmt.Sprintf("restricted: you already hold %d live invite%s", p.Quota, map[bool]string{true: "", false: "s"}[p.Quota == 1])
412 }
413 return ""
414}
415
416func (s *Service) inviteDepth(ctx context.Context, pubkey string) int {
417 depth := 0
418 seen := map[string]bool{pubkey: true}
419 cur := pubkey
420 for depth < 100 {
421 var parent string
422 if err := s.store.DB().QueryRowContext(ctx, `SELECT invited_by FROM community_members WHERE pubkey=?`, cur).Scan(&parent); err != nil || parent == "" {
423 return depth
424 }
425 depth++
426 if parent == s.owner || seen[parent] {
427 return depth
428 }
429 seen[parent] = true
430 cur = parent
431 }
432 return depth
433}
434
435func (s *Service) invites(ctx context.Context, actor, role string) (any, error) {
436 query := `SELECT code,created_by,created_at,expires_at,max_uses,uses,note FROM community_invites`
437 args := []any{}
438 if role == "member" {
439 query += ` WHERE created_by=?`
440 args = append(args, actor)
441 }
442 query += ` ORDER BY created_at DESC LIMIT 200`
443 rows, err := s.store.DB().QueryContext(ctx, query, args...)
444 if err != nil {
445 return nil, err
446 }
447 defer rows.Close()
448 out := []Invite{}
449 for rows.Next() {
450 var i Invite
451 if err := rows.Scan(&i.Code, &i.CreatedBy, &i.CreatedAt, &i.ExpiresAt, &i.MaxUses, &i.Uses, &i.Note); err != nil {
452 return nil, err
453 }
454 out = append(out, i)
455 }
456 return out, rows.Err()
457}
458func (s *Service) claims(ctx context.Context, actor, role string) (any, error) {
459 q := `SELECT code FROM community_invites WHERE expires_at>=? AND (max_uses=0 OR uses<max_uses)`
460 args := []any{now()}
461 if role == "member" {
462 q += ` AND created_by=?`
463 args = append(args, actor)
464 }
465 rows, err := s.store.DB().QueryContext(ctx, q, args...)
466 if err != nil {
467 return nil, err
468 }
469 defer rows.Close()
470 out := []string{}
471 for rows.Next() {
472 var c string
473 if err := rows.Scan(&c); err != nil {
474 return nil, err
475 }
476 out = append(out, c)
477 }
478 return out, rows.Err()
479}
480func (s *Service) revokeInvite(ctx context.Context, actor, role string, p []json.RawMessage) (any, error) {
481 var code string
482 if err := decode(p, 0, &code); err != nil {
483 return nil, err
484 }
485 if role == "member" {
486 var by string
487 if err := s.store.DB().QueryRowContext(ctx, `SELECT created_by FROM community_invites WHERE code=?`, code).Scan(&by); err != nil || by != actor {
488 return nil, errors.New("restricted: not your invite")
489 }
490 }
491 r, err := s.store.DB().ExecContext(ctx, `DELETE FROM community_invites WHERE code=?`, code)
492 if err != nil {
493 return nil, err
494 }
495 n, _ := r.RowsAffected()
496 return map[string]any{"revoked": n == 1}, nil
497}
498
499func (s *Service) transferOwner(ctx context.Context, p []json.RawMessage) (any, error) {
500 var pk string
501 if err := decode(p, 0, &pk); err != nil {
502 return nil, err
503 }
504 if !validPubKey(pk) {
505 return nil, errors.New("invalid: bad pubkey")
506 }
507 old := s.owner
508 err := s.store.WithTx(ctx, func(tx *sql.Tx) error { return s.ApplyOwnerTx(ctx, tx, old, pk) })
509 if err == nil {
510 _ = s.SetOwner(pk)
511 }
512 return map[string]any{"owner": pk}, err
513}
514
515func (s *Service) ban(ctx context.Context, actor string, p []json.RawMessage) (any, error) {
516 var pk, reason string
517 if err := decode(p, 0, &pk); err != nil {
518 return nil, err
519 }
520 if len(p) > 1 {
521 _ = decode(p, 1, &reason)
522 }
523 if pk == s.owner {
524 return nil, errors.New("invalid: cannot ban the owner")
525 }
526 if actor != s.owner {
527 var targetRole string
528 if err := s.store.DB().QueryRowContext(ctx, `SELECT role FROM community_members WHERE pubkey=?`, pk).Scan(&targetRole); err == nil && targetRole == "moderator" {
529 return nil, errors.New("restricted: moderators cannot ban other moderators")
530 }
531 }
532 if !validPubKey(pk) {
533 return nil, errors.New("invalid: bad pubkey")
534 }
535 err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
536 if _, err := tx.ExecContext(ctx, `INSERT INTO community_bans(pubkey,reason,at) VALUES(?,?,?) ON CONFLICT(pubkey) DO UPDATE SET reason=excluded.reason,at=excluded.at`, pk, reason, now()); err != nil {
537 return err
538 }
539 if _, err := tx.ExecContext(ctx, `DELETE FROM community_members WHERE pubkey=?`, pk); err != nil {
540 return err
541 }
542 _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE pubkey=?`, pk)
543 return err
544 })
545 return map[string]any{"banned": pk}, err
546}
547func (s *Service) listBans(ctx context.Context) (any, error) {
548 rows, err := s.store.DB().QueryContext(ctx, `SELECT pubkey,reason,at FROM community_bans ORDER BY at DESC`)
549 if err != nil {
550 return nil, err
551 }
552 defer rows.Close()
553 out := []map[string]any{}
554 for rows.Next() {
555 var p, r string
556 var at int64
557 if err := rows.Scan(&p, &r, &at); err != nil {
558 return nil, err
559 }
560 out = append(out, map[string]any{"pubkey": p, "reason": r, "at": at})
561 }
562 return out, rows.Err()
563}
564func (s *Service) banEvent(ctx context.Context, actor string, p []json.RawMessage) (any, error) {
565 var id, reason string
566 if err := decode(p, 0, &id); err != nil {
567 return nil, err
568 }
569 if len(p) > 1 {
570 _ = decode(p, 1, &reason)
571 }
572 _, err := s.store.DB().ExecContext(ctx, `INSERT INTO community_event_bans(id,reason,at) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET reason=excluded.reason,at=excluded.at`, id, reason, now())
573 return map[string]any{"banned": id}, err
574}
575
576func (s *Service) allowEvent(ctx context.Context, actor string, p []json.RawMessage) (any, error) {
577 var id string
578 if err := decode(p, 0, &id); err != nil {
579 return nil, err
580 }
581 _, err := s.store.DB().ExecContext(ctx, `DELETE FROM community_event_bans WHERE id=?`, id)
582 return true, err
583}
584
585func (s *Service) setKind(ctx context.Context, actor, method string, p []json.RawMessage) (any, error) {
586 var kind int
587 if err := decode(p, 0, &kind); err != nil {
588 return nil, err
589 }
590 if kind < 0 || kind > 65535 {
591 return nil, errors.New("invalid: kind out of range")
592 }
593 rule := ""
594 if method == "allowkind" {
595 rule = "allow"
596 } else if method == "disallowkind" {
597 rule = "block"
598 }
599 _, err := s.store.DB().ExecContext(ctx, `DELETE FROM community_kind_rules WHERE kind=?`, kind)
600 if err == nil && rule != "" {
601 _, err = s.store.DB().ExecContext(ctx, `INSERT INTO community_kind_rules(kind,rule) VALUES(?,?)`, kind, rule)
602 }
603 return true, err
604}
605func (s *Service) listKinds(ctx context.Context, rule string) (any, error) {
606 rows, err := s.store.DB().QueryContext(ctx, `SELECT kind FROM community_kind_rules WHERE rule=? ORDER BY kind`, strings.TrimSuffix(rule, "kinds"))
607 if err != nil {
608 return nil, err
609 }
610 defer rows.Close()
611 out := []int{}
612 for rows.Next() {
613 var k int
614 if err := rows.Scan(&k); err != nil {
615 return nil, err
616 }
617 out = append(out, k)
618 }
619 return out, rows.Err()
620}
621func (s *Service) listBannedEvents(ctx context.Context) (any, error) {
622 rows, err := s.store.DB().QueryContext(ctx, `SELECT id,reason,at FROM community_event_bans ORDER BY at DESC`)
623 if err != nil {
624 return nil, err
625 }
626 defer rows.Close()
627 out := []map[string]any{}
628 for rows.Next() {
629 var id, r string
630 var at int64
631 if err := rows.Scan(&id, &r, &at); err != nil {
632 return nil, err
633 }
634 out = append(out, map[string]any{"id": id, "reason": r, "at": at})
635 }
636 return out, rows.Err()
637}
638func (s *Service) blockIP(ctx context.Context, actor string, p []json.RawMessage, block bool) (any, error) {
639 var ip, reason string
640 if err := decode(p, 0, &ip); err != nil {
641 return nil, err
642 }
643 if net.ParseIP(ip) == nil {
644 return nil, errors.New("invalid: not an IP address")
645 }
646 if len(p) > 1 {
647 _ = decode(p, 1, &reason)
648 }
649 var err error
650 if block {
651 _, err = s.store.DB().ExecContext(ctx, `INSERT INTO community_ip_blocks(ip,reason,at) VALUES(?,?,?) ON CONFLICT(ip) DO UPDATE SET reason=excluded.reason,at=excluded.at`, ip, reason, now())
652 } else {
653 _, err = s.store.DB().ExecContext(ctx, `DELETE FROM community_ip_blocks WHERE ip=?`, ip)
654 }
655 return map[string]any{"ip": ip, "blocked": block}, err
656}
657func (s *Service) listIPs(ctx context.Context) (any, error) {
658 rows, err := s.store.DB().QueryContext(ctx, `SELECT ip,reason,at FROM community_ip_blocks ORDER BY at DESC`)
659 if err != nil {
660 return nil, err
661 }
662 defer rows.Close()
663 out := []map[string]any{}
664 for rows.Next() {
665 var ip, r string
666 var at int64
667 if err := rows.Scan(&ip, &r, &at); err != nil {
668 return nil, err
669 }
670 out = append(out, map[string]any{"ip": ip, "reason": r, "at": at})
671 }
672 return out, rows.Err()
673}
674
675func (s *Service) audit(ctx context.Context) (any, error) {
676 rows, err := s.store.DB().QueryContext(ctx, `SELECT id,at,actor,action,target,detail FROM community_audit ORDER BY id DESC LIMIT 100`)
677 if err != nil {
678 return nil, err
679 }
680 defer rows.Close()
681 out := []AuditRow{}
682 for rows.Next() {
683 var a AuditRow
684 if err := rows.Scan(&a.ID, &a.At, &a.Actor, &a.Action, &a.Target, &a.Detail); err != nil {
685 return nil, err
686 }
687 out = append(out, a)
688 }
689 return out, rows.Err()
690}
691
692// Record writes one audit row for an action another service carried out.
693func (s *Service) Record(ctx context.Context, actor, action, target, detail string) error {
694 return s.store.WithTx(ctx, func(tx *sql.Tx) error { return s.recordTx(ctx, tx, actor, action, target, detail) })
695}
696
697func (s *Service) recordTx(ctx context.Context, tx *sql.Tx, actor, action, target, detail string) error {
698 _, err := tx.ExecContext(ctx, `INSERT INTO community_audit(at,actor,action,target,detail) VALUES(?,?,?,?,?)`, now(), actor, action, target, detail)
699 return err
700}
701func (s *Service) reports(ctx context.Context, p []json.RawMessage) (any, error) {
702 status := "open"
703 if len(p) > 0 {
704 _ = decode(p, 0, &status)
705 }
706 rows, err := s.store.DB().QueryContext(ctx, `SELECT id,reporter,target_event,type,content,at,status,resolved_by,resolved_at,action FROM community_reports WHERE status=? ORDER BY at DESC LIMIT 200`, status)
707 if err != nil {
708 return nil, err
709 }
710 defer rows.Close()
711 out := []Report{}
712 for rows.Next() {
713 var r Report
714 if err := rows.Scan(&r.ID, &r.Reporter, &r.TargetEvent, &r.Type, &r.Content, &r.At, &r.Status, &r.ResolvedBy, &r.ResolvedAt, &r.Action); err != nil {
715 return nil, err
716 }
717 out = append(out, r)
718 }
719 return out, rows.Err()
720}
721func (s *Service) resolveReport(ctx context.Context, actor string, p []json.RawMessage) (any, error) {
722 var id int64
723 var action string
724 if err := decode(p, 0, &id); err != nil {
725 return nil, err
726 }
727 if err := decode(p, 1, &action); err != nil {
728 return nil, err
729 }
730 if action != "ban" && action != "delete" && action != "dismiss" {
731 return nil, errors.New("invalid: action must be ban, delete or dismiss")
732 }
733 r, err := s.store.DB().ExecContext(ctx, `UPDATE community_reports SET status='resolved',resolved_by=?,resolved_at=?,action=? WHERE id=? AND status='open'`, actor, now(), action, id)
734 if err != nil {
735 return nil, err
736 }
737 n, _ := r.RowsAffected()
738 if n != 1 {
739 return nil, errors.New("invalid: no such open report")
740 }
741 // Blossom keeps a native report index for its endpoint. Keep that index
742 // aligned when the shared moderation record is resolved; older stores may
743 // not have the table yet.
744 _, blobErr := s.store.DB().ExecContext(ctx, `UPDATE blob_reports SET status='resolved',resolved_by=?,resolved_at=?,action=? WHERE target_blob=(SELECT target_event FROM community_reports WHERE id=?) AND status='open'`, actor, now(), action, id)
745 if blobErr != nil && !strings.Contains(blobErr.Error(), "no such table") {
746 return nil, blobErr
747 }
748 return map[string]any{"resolved": id, "action": action}, nil
749}
750
751func (s *Service) moderationQueue(ctx context.Context) (any, error) {
752 rows, err := s.store.DB().QueryContext(ctx, `SELECT target_event,max(type||CASE WHEN content='' THEN '' ELSE ': '||substr(content,1,200) END) FROM community_reports WHERE status='open' AND target_event<>'' GROUP BY target_event ORDER BY max(at) DESC LIMIT 500`)
753 if err != nil {
754 return nil, err
755 }
756 defer rows.Close()
757 out := []map[string]string{}
758 for rows.Next() {
759 var id, reason string
760 if err := rows.Scan(&id, &reason); err != nil {
761 return nil, err
762 }
763 out = append(out, map[string]string{"id": id, "reason": reason})
764 }
765 return out, rows.Err()
766}
767
768func (s *Service) setBlockedWords(ctx context.Context, actor string, p []json.RawMessage) (any, error) {
769 var words []string
770 if err := decode(p, 0, &words); err != nil {
771 return nil, errors.New("invalid: give a list of words")
772 }
773 data, _ := json.Marshal(words)
774 _, err := s.store.DB().ExecContext(ctx, `INSERT INTO community_meta(key,value) VALUES('blocked_words',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, string(data))
775 return words, err
776}
777
778func (s *Service) purgeKind(ctx context.Context, actor string, p []json.RawMessage) (any, error) {
779 var kind int
780 var days int
781 if len(p) > 0 && string(p[0]) != "null" {
782 if err := decode(p, 0, &kind); err != nil {
783 return nil, err
784 }
785 }
786 if len(p) > 1 {
787 if err := decode(p, 1, &days); err != nil {
788 return nil, err
789 }
790 }
791 if days < 0 {
792 return nil, errors.New("invalid: days out of range")
793 }
794 cutoff := int64(^uint64(0) >> 1)
795 if days > 0 {
796 cutoff = now() - int64(days)*86400
797 }
798 var result sql.Result
799 var err error
800 if len(p) > 0 && string(p[0]) == "null" {
801 result, err = s.store.DB().ExecContext(ctx, `DELETE FROM events WHERE created_at<?`, cutoff)
802 } else {
803 result, err = s.store.DB().ExecContext(ctx, `DELETE FROM events WHERE kind=? AND created_at<?`, kind, cutoff)
804 }
805 if err != nil {
806 return nil, err
807 }
808 n, _ := result.RowsAffected()
809 return map[string]any{"deleted": n}, nil
810}
811
812func (s *Service) setRetention(ctx context.Context, actor string, p []json.RawMessage) (any, error) {
813 var kind, days int
814 if err := decode(p, 0, &kind); err != nil {
815 return nil, err
816 }
817 if err := decode(p, 1, &days); err != nil {
818 return nil, err
819 }
820 if kind < 0 || days < 1 {
821 return nil, errors.New("invalid: retention needs a nonnegative kind and positive days")
822 }
823 err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
824 if _, err := tx.ExecContext(ctx, `INSERT INTO community_retention(kind,days) VALUES(?,?) ON CONFLICT(kind) DO UPDATE SET days=excluded.days`, kind, days); err != nil {
825 return err
826 }
827 return s.recordTx(ctx, tx, actor, "setretention", fmt.Sprint(kind), fmt.Sprint(days))
828 })
829 return map[string]any{"kind": kind, "days": days}, err
830}
831
832func (s *Service) listRetention(ctx context.Context) (any, error) {
833 rows, err := s.store.DB().QueryContext(ctx, `SELECT kind,days FROM community_retention ORDER BY kind`)
834 if err != nil {
835 return nil, err
836 }
837 defer rows.Close()
838 out := []map[string]int{}
839 for rows.Next() {
840 var kind, days int
841 if err := rows.Scan(&kind, &days); err != nil {
842 return nil, err
843 }
844 out = append(out, map[string]int{"kind": kind, "days": days})
845 }
846 return out, rows.Err()
847}
848
849// SubmitReport records one report per reporter and hides the target once the
850// configured distinct-reporter threshold is reached.
851func (s *Service) SubmitReport(ctx context.Context, reporter, target, reportType, content string, threshold int) error {
852 return s.SubmitReportTarget(ctx, reporter, target, "event", reportType, content, threshold)
853}
854
855// SubmitReportTarget records a NIP-56 report and applies threshold hiding only
856// to event targets. Pubkey and blob targets remain moderation records; they
857// must not be inserted into hidden_events as if they were event IDs.
858func (s *Service) SubmitReportTarget(ctx context.Context, reporter, target, targetType, reportType, content string, threshold int) error {
859 if !validPubKey(reporter) || target == "" {
860 return errors.New("invalid: report target or reporter")
861 }
862 if targetType == "" {
863 targetType = "event"
864 }
865 return s.store.WithTx(ctx, func(tx *sql.Tx) error {
866 return s.SubmitReportTx(ctx, tx, reporter, target, targetType, reportType, content, threshold)
867 })
868}
869
870// SubmitReportTx records a report in a caller-owned transaction. Blob and
871// other protocol doors use this to commit their native report row and the
872// shared moderation row atomically.
873func (s *Service) SubmitReportTx(ctx context.Context, tx *sql.Tx, reporter, target, targetType, reportType, content string, threshold int) error {
874 if tx == nil || !validPubKey(reporter) || target == "" {
875 return errors.New("invalid: report target or reporter")
876 }
877 if targetType == "" {
878 targetType = "event"
879 }
880 if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO community_reports(reporter,target_event,type,content,at) VALUES(?,?,?,?,?)`, reporter, target, reportType, content, now()); err != nil {
881 return err
882 }
883 if threshold <= 0 || targetType != "event" {
884 return nil
885 }
886 var count int
887 if err := tx.QueryRowContext(ctx, `SELECT count(DISTINCT reporter) FROM community_reports WHERE target_event=? AND status='open'`, target).Scan(&count); err != nil {
888 return err
889 }
890 if count >= threshold {
891 _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO hidden_events(id,reason) VALUES(?,?)`, target, "report")
892 return err
893 }
894 return nil
895}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs