clone

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

feat/tinyagent
tinyrelay / internal / community / actions.go
actions.go 29.1K
1package community23import (4 "context"5 "crypto/sha256"6 "database/sql"7 "encoding/hex"8 "encoding/json"9 "errors"10 "fmt"11 "net"12 "strings"13)1415func (s *Service) setMember(ctx context.Context, actor string, p []json.RawMessage) (any, error) {16 return s.setMemberVia(ctx, actor, p, "management")17}1819// 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 string23 if err := decode(p, 0, &pk); err != nil {24 return nil, err25 }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 string37 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, err46 }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.Name53 }54 role = patch.Role55 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 err73 }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 err81 }82 }83 return s.recordTx(ctx, tx, actor, "setmember", pk, role)84 })85 return map[string]any{"pubkey": pk, "role": role}, err86}8788func (s *Service) removeMember(ctx context.Context, actor string, p []json.RawMessage) (any, error) {89 var pk string90 if err := decode(p, 0, &pk); err != nil {91 return nil, err92 }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 string101 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 err108 }109 return s.recordTx(ctx, tx, actor, "removemember", pk, "")110 })111 return map[string]any{"removed": pk}, err112}113114func (s *Service) removeSubtree(ctx context.Context, actor string, p []json.RawMessage) (any, error) {115 var root string116 if err := decode(p, 0, &root); err != nil {117 return nil, err118 }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, err137 }138 for rows.Next() {139 var pk string140 if err := rows.Scan(&pk); err != nil {141 rows.Close()142 return nil, err143 }144 if !seen[pk] {145 seen[pk] = true146 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 err155 }156 }157 return s.recordTx(ctx, tx, actor, "removesubtree", root, fmt.Sprint(len(seen)))158 })159 return map[string]any{"removed": len(seen)}, err160}161162func (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, err166 }167 defer rows.Close()168 out := []Member{}169 for rows.Next() {170 var m Member171 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, err173 }174 out = append(out, m)175 }176 return map[string]any{"self": s.owner, "members": out}, rows.Err()177}178179func (s *Service) allowedMembers(ctx context.Context) (any, error) {180 value, err := s.members(ctx)181 if err != nil {182 return nil, err183 }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, nil192}193194func (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, err202 }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 error209 code, err = token()210 if err != nil {211 return nil, err212 }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), 0220 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 = 60240 }241 if !claim && ttl > 30*86400 {242 ttl = 30 * 86400243 }244 if maxUses < 0 {245 maxUses = 0246 }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, err258 }259 return true, nil260 }261 return inv, err262}263264func (s *Service) Claim(ctx context.Context, code, pubkey string) (any, error) {265 return s.ClaimWithTerms(ctx, code, pubkey, "")266}267268// IssueNIP43Invite creates a short-lived claim for an authenticated member.269// The relay root signs the resulting kind 28935 response; this package owns270// 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{}, err278 }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{}, err285 }286 invite, ok := value.(Invite)287 if !ok {288 return Invite{}, errors.New("community: invalid invite result")289 }290 return invite, nil291}292293// 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, err302 }303 if banned {304 return nil, errors.New("blocked: this pubkey is banned from this relay")305 }306 terms := s.currentPolicy().JoinTerms307 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 bool314 if err := s.store.DB().QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM community_members WHERE pubkey=?)`, pubkey).Scan(&member); err != nil {315 return nil, err316 }317 if member {318 role, _ := s.Role(ctx, pubkey)319 return map[string]any{"status": "already_member", "role": role}, nil320 }321 err = s.store.WithTx(ctx, func(tx *sql.Tx) error {322 var id, creator string323 var exp int64324 var max, uses int325 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 err331 }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 bool339 if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM community_members WHERE pubkey=?)`, creator).Scan(&creatorExists); err != nil {340 return err341 }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 err348 }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 err355 }356 return s.recordTx(ctx, tx, pubkey, "claiminvite", code, "")357 })358 if err != nil {359 return nil, err360 }361 return map[string]any{"status": "joined", "role": "member"}, nil362}363364func (s *Service) memberInviteGate(ctx context.Context, pubkey string) string {365 p := s.currentPolicy().MemberInvites366 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 int374 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}382383func (s *Service) memberInviteGateTx(ctx context.Context, tx *sql.Tx, pubkey string) string {384 p := s.currentPolicy().MemberInvites385 if p.Depth <= 0 || p.Quota <= 0 {386 return "restricted: only the owner and moderators mint invites here"387 }388 depth := 0389 cur := pubkey390 seen := map[string]bool{cur: true}391 for depth < 100 {392 var parent string393 if err := tx.QueryRowContext(ctx, `SELECT invited_by FROM community_members WHERE pubkey=?`, cur).Scan(&parent); err != nil || parent == "" {394 break395 }396 depth++397 if parent == s.owner || seen[parent] {398 break399 }400 seen[parent] = true401 cur = parent402 }403 if depth >= p.Depth {404 return "restricted: invites do not reach this far down the tree"405 }406 var active int407 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}415416func (s *Service) inviteDepth(ctx context.Context, pubkey string) int {417 depth := 0418 seen := map[string]bool{pubkey: true}419 cur := pubkey420 for depth < 100 {421 var parent string422 if err := s.store.DB().QueryRowContext(ctx, `SELECT invited_by FROM community_members WHERE pubkey=?`, cur).Scan(&parent); err != nil || parent == "" {423 return depth424 }425 depth++426 if parent == s.owner || seen[parent] {427 return depth428 }429 seen[parent] = true430 cur = parent431 }432 return depth433}434435func (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, err446 }447 defer rows.Close()448 out := []Invite{}449 for rows.Next() {450 var i Invite451 if err := rows.Scan(&i.Code, &i.CreatedBy, &i.CreatedAt, &i.ExpiresAt, &i.MaxUses, &i.Uses, &i.Note); err != nil {452 return nil, err453 }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, err468 }469 defer rows.Close()470 out := []string{}471 for rows.Next() {472 var c string473 if err := rows.Scan(&c); err != nil {474 return nil, err475 }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 string482 if err := decode(p, 0, &code); err != nil {483 return nil, err484 }485 if role == "member" {486 var by string487 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, err494 }495 n, _ := r.RowsAffected()496 return map[string]any{"revoked": n == 1}, nil497}498499func (s *Service) transferOwner(ctx context.Context, p []json.RawMessage) (any, error) {500 var pk string501 if err := decode(p, 0, &pk); err != nil {502 return nil, err503 }504 if !validPubKey(pk) {505 return nil, errors.New("invalid: bad pubkey")506 }507 old := s.owner508 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}, err513}514515func (s *Service) ban(ctx context.Context, actor string, p []json.RawMessage) (any, error) {516 var pk, reason string517 if err := decode(p, 0, &pk); err != nil {518 return nil, err519 }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 string528 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 err538 }539 if _, err := tx.ExecContext(ctx, `DELETE FROM community_members WHERE pubkey=?`, pk); err != nil {540 return err541 }542 _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE pubkey=?`, pk)543 return err544 })545 return map[string]any{"banned": pk}, err546}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, err551 }552 defer rows.Close()553 out := []map[string]any{}554 for rows.Next() {555 var p, r string556 var at int64557 if err := rows.Scan(&p, &r, &at); err != nil {558 return nil, err559 }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 string566 if err := decode(p, 0, &id); err != nil {567 return nil, err568 }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}, err574}575576func (s *Service) allowEvent(ctx context.Context, actor string, p []json.RawMessage) (any, error) {577 var id string578 if err := decode(p, 0, &id); err != nil {579 return nil, err580 }581 _, err := s.store.DB().ExecContext(ctx, `DELETE FROM community_event_bans WHERE id=?`, id)582 return true, err583}584585func (s *Service) setKind(ctx context.Context, actor, method string, p []json.RawMessage) (any, error) {586 var kind int587 if err := decode(p, 0, &kind); err != nil {588 return nil, err589 }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, err604}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, err609 }610 defer rows.Close()611 out := []int{}612 for rows.Next() {613 var k int614 if err := rows.Scan(&k); err != nil {615 return nil, err616 }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, err625 }626 defer rows.Close()627 out := []map[string]any{}628 for rows.Next() {629 var id, r string630 var at int64631 if err := rows.Scan(&id, &r, &at); err != nil {632 return nil, err633 }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 string640 if err := decode(p, 0, &ip); err != nil {641 return nil, err642 }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 error650 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}, err656}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, err661 }662 defer rows.Close()663 out := []map[string]any{}664 for rows.Next() {665 var ip, r string666 var at int64667 if err := rows.Scan(&ip, &r, &at); err != nil {668 return nil, err669 }670 out = append(out, map[string]any{"ip": ip, "reason": r, "at": at})671 }672 return out, rows.Err()673}674675func (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, err679 }680 defer rows.Close()681 out := []AuditRow{}682 for rows.Next() {683 var a AuditRow684 if err := rows.Scan(&a.ID, &a.At, &a.Actor, &a.Action, &a.Target, &a.Detail); err != nil {685 return nil, err686 }687 out = append(out, a)688 }689 return out, rows.Err()690}691692// 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}696697func (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 err700}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, err709 }710 defer rows.Close()711 out := []Report{}712 for rows.Next() {713 var r Report714 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, err716 }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 int64723 var action string724 if err := decode(p, 0, &id); err != nil {725 return nil, err726 }727 if err := decode(p, 1, &action); err != nil {728 return nil, err729 }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, err736 }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 index742 // aligned when the shared moderation record is resolved; older stores may743 // 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, blobErr747 }748 return map[string]any{"resolved": id, "action": action}, nil749}750751func (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, err755 }756 defer rows.Close()757 out := []map[string]string{}758 for rows.Next() {759 var id, reason string760 if err := rows.Scan(&id, &reason); err != nil {761 return nil, err762 }763 out = append(out, map[string]string{"id": id, "reason": reason})764 }765 return out, rows.Err()766}767768func (s *Service) setBlockedWords(ctx context.Context, actor string, p []json.RawMessage) (any, error) {769 var words []string770 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, err776}777778func (s *Service) purgeKind(ctx context.Context, actor string, p []json.RawMessage) (any, error) {779 var kind int780 var days int781 if len(p) > 0 && string(p[0]) != "null" {782 if err := decode(p, 0, &kind); err != nil {783 return nil, err784 }785 }786 if len(p) > 1 {787 if err := decode(p, 1, &days); err != nil {788 return nil, err789 }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)*86400797 }798 var result sql.Result799 var err error800 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, err807 }808 n, _ := result.RowsAffected()809 return map[string]any{"deleted": n}, nil810}811812func (s *Service) setRetention(ctx context.Context, actor string, p []json.RawMessage) (any, error) {813 var kind, days int814 if err := decode(p, 0, &kind); err != nil {815 return nil, err816 }817 if err := decode(p, 1, &days); err != nil {818 return nil, err819 }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 err826 }827 return s.recordTx(ctx, tx, actor, "setretention", fmt.Sprint(kind), fmt.Sprint(days))828 })829 return map[string]any{"kind": kind, "days": days}, err830}831832func (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, err836 }837 defer rows.Close()838 out := []map[string]int{}839 for rows.Next() {840 var kind, days int841 if err := rows.Scan(&kind, &days); err != nil {842 return nil, err843 }844 out = append(out, map[string]int{"kind": kind, "days": days})845 }846 return out, rows.Err()847}848849// SubmitReport records one report per reporter and hides the target once the850// 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}854855// SubmitReportTarget records a NIP-56 report and applies threshold hiding only856// to event targets. Pubkey and blob targets remain moderation records; they857// 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}869870// SubmitReportTx records a report in a caller-owned transaction. Blob and871// other protocol doors use this to commit their native report row and the872// 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 err882 }883 if threshold <= 0 || targetType != "event" {884 return nil885 }886 var count int887 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 err889 }890 if count >= threshold {891 _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO hidden_events(id,reason) VALUES(?,?)`, target, "report")892 return err893 }894 return nil895}