tinyrelayfile
tinyrelay/internal/community/requests.go/

internal/community/requests.go raw
1package community
2
3import (
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
19const (
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.
28func 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.
34func 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.
53func (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.
73func (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.
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, 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.
103func (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
109func (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.
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, 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.
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, 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
158func (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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs