| 1 | package community |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "strings" |
| 9 | "unicode/utf8" |
| 10 | |
| 11 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 12 | ) |
| 13 | |
| 14 | // A NIP-43 join (kind 28934) without a claim tag is an access request: the |
| 15 | // relay holds it for the owner and moderators instead of refusing it. The |
| 16 | // request is a row, never a stored event, and one key holds one row that a |
| 17 | // repeat refreshes. |
| 18 | |
| 19 | const ( |
| 20 | // JoinReasonMax bounds the reason kept from the request's content. |
| 21 | JoinReasonMax = 500 |
| 22 | // JoinRequestReceived is the OK reason answered to an access request. |
| 23 | JoinRequestReceived = "info: access request received, the relay owner will review it" |
| 24 | ) |
| 25 | |
| 26 | // IsAccessRequest reports whether a membership event asks for access rather |
| 27 | // than claiming an invite. |
| 28 | func IsAccessRequest(ev event.Event) bool { |
| 29 | return ev.Kind == event.KIND_NIP43_JOIN && tagValue(ev, "claim" ) == "" |
| 30 | } |
| 31 | |
| 32 | // JoinReason is the request content trimmed of surrounding space and cut to |
| 33 | // JoinReasonMax characters on a character boundary. |
| 34 | func JoinReason(content string) string { |
| 35 | reason := strings.TrimSpace(content) |
| 36 | if utf8.RuneCountInString(reason) <= JoinReasonMax { |
| 37 | return reason |
| 38 | } |
| 39 | count := 0 |
| 40 | for i := range reason { |
| 41 | if count == JoinReasonMax { |
| 42 | return strings.TrimSpace(reason[:i]) |
| 43 | } |
| 44 | count++ |
| 45 | } |
| 46 | return reason |
| 47 | } |
| 48 | |
| 49 | // recordJoinRequestTx stores or refreshes the key's access request and |
| 50 | // reports whether a new pending request was created. A pending request from |
| 51 | // the same key takes the new reason and time; a decided one becomes pending |
| 52 | // again. A banned key is refused. |
| 53 | func (s *Service) recordJoinRequestTx(ctx context.Context, tx *sql.Tx, pubkey, reason string) (bool, error) { |
| 54 | var banned bool |
| 55 | if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM community_bans WHERE pubkey=?)` , pubkey).Scan(&banned); err != nil { |
| 56 | return false, err |
| 57 | } |
| 58 | if banned { |
| 59 | return false, errors.New("restricted: this key is banned from the relay" ) |
| 60 | } |
| 61 | var status string |
| 62 | 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, err |
| 65 | } |
| 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, err |
| 68 | } |
| 69 | return status != "pending" , nil |
| 70 | } |
| 71 | |
| 72 | // RecordJoinRequest is the non-transactional form of recordJoinRequestTx. |
| 73 | func (s *Service) RecordJoinRequest(ctx context.Context, pubkey, reason string) (bool, error) { |
| 74 | created := false |
| 75 | err := s.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 76 | var err error |
| 77 | created, err = s.recordJoinRequestTx(ctx, tx, pubkey, reason) |
| 78 | return err |
| 79 | }) |
| 80 | return created, err |
| 81 | } |
| 82 | |
| 83 | // listJoinRequests lists pending requests newest first, then the decided |
| 84 | // ones newest decision first. |
| 85 | func (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, err |
| 89 | } |
| 90 | defer rows.Close() |
| 91 | out := []JoinRequest{} |
| 92 | for rows.Next() { |
| 93 | var r JoinRequest |
| 94 | if err := rows.Scan(&r.PubKey, &r.Reason, &r.RequestedAt, &r.Status, &r.DecidedBy, &r.DecidedAt); err != nil { |
| 95 | return nil, err |
| 96 | } |
| 97 | out = append(out, r) |
| 98 | } |
| 99 | return out, rows.Err() |
| 100 | } |
| 101 | |
| 102 | // PendingJoinRequests counts the requests that wait for a decision. |
| 103 | func (s *Service) PendingJoinRequests(ctx context.Context) (int, error) { |
| 104 | var count int |
| 105 | err := s.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM join_requests WHERE status='pending'` ).Scan(&count) |
| 106 | return count, err |
| 107 | } |
| 108 | |
| 109 | func (s *Service) joinRequestTarget(ctx context.Context, p []json.RawMessage) (string, string, error) { |
| 110 | var pk string |
| 111 | if err := decode(p, 0, &pk); err != nil { |
| 112 | return "" , "" , err |
| 113 | } |
| 114 | if !validPubKey(pk) { |
| 115 | return "" , "" , errors.New("invalid: bad pubkey" ) |
| 116 | } |
| 117 | var status string |
| 118 | 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, err |
| 123 | } |
| 124 | |
| 125 | // approveJoin admits the key through the member path setmember uses, so the |
| 126 | // signed member list and add-user record follow, and marks the request |
| 127 | // approved. A key that already became a member only has its request marked. |
| 128 | func (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, err |
| 132 | } |
| 133 | role, err := s.Role(ctx, pk) |
| 134 | if err != nil { |
| 135 | return nil, err |
| 136 | } |
| 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, err |
| 140 | } |
| 141 | } |
| 142 | return s.decideJoin(ctx, actor, pk, "approved" ) |
| 143 | } |
| 144 | |
| 145 | // denyJoin marks the request denied. The key stays outside the relay and |
| 146 | // may ask again. |
| 147 | func (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, err |
| 151 | } |
| 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 | } |
| 157 | |
| 158 | func (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 err |
| 162 | } |
| 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, err |
| 171 | } |
| 172 | return map[string]any{"pubkey" : pk, "status" : status}, nil |
| 173 | } |