clone

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

feat/standalone-relay
tinyrelay / internal / community / requests.go
requests.go 6.0K
1package community23import (4 "context"5 "database/sql"6 "encoding/json"7 "errors"8 "strings"9 "unicode/utf8"1011 "github.com/FelineStateMachine/tinyrelay/internal/event"12)1314// A NIP-43 join (kind 28934) without a claim tag is an access request: the15// relay holds it for the owner and moderators instead of refusing it. The16// request is a row, never a stored event, and one key holds one row that a17// repeat refreshes.1819const (20 // JoinReasonMax bounds the reason kept from the request's content.21 JoinReasonMax = 50022 // JoinRequestReceived is the OK reason answered to an access request.23 JoinRequestReceived = "info: access request received, the relay owner will review it"24)2526// IsAccessRequest reports whether a membership event asks for access rather27// than claiming an invite.28func IsAccessRequest(ev event.Event) bool {29 return ev.Kind == event.KIND_NIP43_JOIN && tagValue(ev, "claim") == ""30}3132// JoinReason is the request content trimmed of surrounding space and cut to33// JoinReasonMax characters on a character boundary.34func JoinReason(content string) string {35 reason := strings.TrimSpace(content)36 if utf8.RuneCountInString(reason) <= JoinReasonMax {37 return reason38 }39 count := 040 for i := range reason {41 if count == JoinReasonMax {42 return strings.TrimSpace(reason[:i])43 }44 count++45 }46 return reason47}4849// recordJoinRequestTx stores or refreshes the key's access request and50// reports whether a new pending request was created. A pending request from51// the same key takes the new reason and time; a decided one becomes pending52// again. A banned key is refused.53func (s *Service) recordJoinRequestTx(ctx context.Context, tx *sql.Tx, pubkey, reason string) (bool, error) {54 var banned bool55 if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM community_bans WHERE pubkey=?)`, pubkey).Scan(&banned); err != nil {56 return false, err57 }58 if banned {59 return false, errors.New("restricted: this key is banned from the relay")60 }61 var status string62 err := tx.QueryRowContext(ctx, `SELECT status FROM join_requests WHERE pubkey=?`, pubkey).Scan(&status)63 if err != nil && !errors.Is(err, sql.ErrNoRows) {64 return false, err65 }66 if _, err := tx.ExecContext(ctx, `INSERT INTO join_requests(pubkey,reason,requested_at,status,decided_by,decided_at) VALUES(?,?,?,'pending','',0) ON CONFLICT(pubkey) DO UPDATE SET reason=excluded.reason,requested_at=excluded.requested_at,status='pending',decided_by='',decided_at=0`, pubkey, reason, now()); err != nil {67 return false, err68 }69 return status != "pending", nil70}7172// RecordJoinRequest is the non-transactional form of recordJoinRequestTx.73func (s *Service) RecordJoinRequest(ctx context.Context, pubkey, reason string) (bool, error) {74 created := false75 err := s.store.WithTx(ctx, func(tx *sql.Tx) error {76 var err error77 created, err = s.recordJoinRequestTx(ctx, tx, pubkey, reason)78 return err79 })80 return created, err81}8283// listJoinRequests lists pending requests newest first, then the decided84// ones newest decision first.85func (s *Service) listJoinRequests(ctx context.Context) (any, error) {86 rows, err := s.store.DB().QueryContext(ctx, `SELECT pubkey,reason,requested_at,status,decided_by,decided_at FROM join_requests ORDER BY status<>'pending', CASE WHEN status='pending' THEN requested_at ELSE decided_at END DESC, pubkey LIMIT 200`)87 if err != nil {88 return nil, err89 }90 defer rows.Close()91 out := []JoinRequest{}92 for rows.Next() {93 var r JoinRequest94 if err := rows.Scan(&r.PubKey, &r.Reason, &r.RequestedAt, &r.Status, &r.DecidedBy, &r.DecidedAt); err != nil {95 return nil, err96 }97 out = append(out, r)98 }99 return out, rows.Err()100}101102// PendingJoinRequests counts the requests that wait for a decision.103func (s *Service) PendingJoinRequests(ctx context.Context) (int, error) {104 var count int105 err := s.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM join_requests WHERE status='pending'`).Scan(&count)106 return count, err107}108109func (s *Service) joinRequestTarget(ctx context.Context, p []json.RawMessage) (string, string, error) {110 var pk string111 if err := decode(p, 0, &pk); err != nil {112 return "", "", err113 }114 if !validPubKey(pk) {115 return "", "", errors.New("invalid: bad pubkey")116 }117 var status string118 err := s.store.DB().QueryRowContext(ctx, `SELECT status FROM join_requests WHERE pubkey=?`, pk).Scan(&status)119 if errors.Is(err, sql.ErrNoRows) {120 return "", "", errors.New("not found: no access request from that key")121 }122 return pk, status, err123}124125// approveJoin admits the key through the member path setmember uses, so the126// signed member list and add-user record follow, and marks the request127// approved. A key that already became a member only has its request marked.128func (s *Service) approveJoin(ctx context.Context, actor string, p []json.RawMessage) (any, error) {129 pk, _, err := s.joinRequestTarget(ctx, p)130 if err != nil {131 return nil, err132 }133 role, err := s.Role(ctx, pk)134 if err != nil {135 return nil, err136 }137 if role == "" {138 if _, err := s.setMemberVia(ctx, actor, []json.RawMessage{mustJSON(pk), mustJSON(map[string]any{"role": "member"})}, "request"); err != nil {139 return nil, err140 }141 }142 return s.decideJoin(ctx, actor, pk, "approved")143}144145// denyJoin marks the request denied. The key stays outside the relay and146// may ask again.147func (s *Service) denyJoin(ctx context.Context, actor string, p []json.RawMessage) (any, error) {148 pk, status, err := s.joinRequestTarget(ctx, p)149 if err != nil {150 return nil, err151 }152 if status == "approved" {153 return nil, errors.New("invalid: the request was approved; remove the member instead")154 }155 return s.decideJoin(ctx, actor, pk, "denied")156}157158func (s *Service) decideJoin(ctx context.Context, actor, pk, status string) (any, error) {159 err := s.store.WithTx(ctx, func(tx *sql.Tx) error {160 if _, err := tx.ExecContext(ctx, `UPDATE join_requests SET status=?,decided_by=?,decided_at=? WHERE pubkey=?`, status, actor, now(), pk); err != nil {161 return err162 }163 action := "denyjoin"164 if status == "approved" {165 action = "approvejoin"166 }167 return s.recordTx(ctx, tx, actor, action, pk, "")168 })169 if err != nil {170 return nil, err171 }172 return map[string]any{"pubkey": pk, "status": status}, nil173}