A community based topic aggregation platform built on atproto
1package userblock
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "strconv"
10 "time"
11
12 "Coves/internal/api/middleware"
13 "Coves/internal/core/userblocks"
14)
15
16// BlockHandler handles user-to-user blocking operations
17type BlockHandler struct {
18 service userblocks.Service
19}
20
21// NewBlockHandler creates a new user block handler
22func NewBlockHandler(service userblocks.Service) *BlockHandler {
23 if service == nil {
24 panic("userblock: NewBlockHandler requires a non-nil service")
25 }
26 return &BlockHandler{
27 service: service,
28 }
29}
30
31// blockRequest is the input for block/unblock operations.
32type blockRequest struct {
33 Subject string `json:"subject"`
34}
35
36// blockResponse is the output for a successful block operation.
37type blockResponse struct {
38 Block blockRecord `json:"block"`
39}
40
41type blockRecord struct {
42 RecordURI string `json:"recordUri"`
43 RecordCID string `json:"recordCid"`
44}
45
46// unblockResponse is the output for a successful unblock operation.
47type unblockResponse struct {
48 Success bool `json:"success"`
49}
50
51// blockedUsersResponse is the output for the get-blocked-users endpoint.
52type blockedUsersResponse struct {
53 Blocks []blockedUserEntry `json:"blocks"`
54}
55
56type blockedUserEntry struct {
57 BlockedDID string `json:"blockedDid"`
58 RecordURI string `json:"recordUri"`
59 RecordCID string `json:"recordCid"`
60 BlockedAt time.Time `json:"blockedAt"`
61}
62
63// HandleBlock blocks a user
64// POST /xrpc/social.coves.actor.blockUser
65//
66// Request body: { "subject": "did-or-handle" }
67// The subject can be a DID (did:plc:xxx) or a handle.
68func (h *BlockHandler) HandleBlock(w http.ResponseWriter, r *http.Request) {
69 r.Body = http.MaxBytesReader(w, r.Body, 10*1024)
70
71 var req blockRequest
72 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
73 writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
74 return
75 }
76
77 if req.Subject == "" {
78 writeError(w, http.StatusBadRequest, "InvalidRequest", "subject is required")
79 return
80 }
81
82 session := middleware.GetOAuthSession(r)
83 if session == nil {
84 writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
85 return
86 }
87
88 result, err := h.service.BlockUser(r.Context(), session, req.Subject)
89 if err != nil {
90 handleServiceError(w, err)
91 return
92 }
93
94 writeJSON(w, http.StatusOK, blockResponse{
95 Block: blockRecord{
96 RecordURI: result.RecordURI,
97 RecordCID: result.RecordCID,
98 },
99 })
100}
101
102// HandleUnblock unblocks a user
103// POST /xrpc/social.coves.actor.unblockUser
104//
105// Request body: { "subject": "did-or-handle" }
106func (h *BlockHandler) HandleUnblock(w http.ResponseWriter, r *http.Request) {
107 r.Body = http.MaxBytesReader(w, r.Body, 10*1024)
108
109 var req blockRequest
110 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
111 writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
112 return
113 }
114
115 if req.Subject == "" {
116 writeError(w, http.StatusBadRequest, "InvalidRequest", "subject is required")
117 return
118 }
119
120 session := middleware.GetOAuthSession(r)
121 if session == nil {
122 writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
123 return
124 }
125
126 err := h.service.UnblockUser(r.Context(), session, req.Subject)
127 if err != nil {
128 handleServiceError(w, err)
129 return
130 }
131
132 writeJSON(w, http.StatusOK, unblockResponse{Success: true})
133}
134
135// HandleGetBlocked returns the list of users blocked by the authenticated user
136// GET /xrpc/social.coves.actor.getBlockedUsers?limit=50&offset=0
137func (h *BlockHandler) HandleGetBlocked(w http.ResponseWriter, r *http.Request) {
138 session := middleware.GetOAuthSession(r)
139 if session == nil {
140 writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
141 return
142 }
143
144 limit := 50
145 offset := 0
146
147 if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
148 parsed, err := strconv.Atoi(limitStr)
149 if err != nil {
150 writeError(w, http.StatusBadRequest, "InvalidRequest", fmt.Sprintf("invalid limit parameter: %q", limitStr))
151 return
152 }
153 limit = parsed
154 }
155
156 if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
157 parsed, err := strconv.Atoi(offsetStr)
158 if err != nil {
159 writeError(w, http.StatusBadRequest, "InvalidRequest", fmt.Sprintf("invalid offset parameter: %q", offsetStr))
160 return
161 }
162 offset = parsed
163 }
164
165 userDID := session.AccountDID.String()
166
167 blocks, err := h.service.GetBlockedUsers(r.Context(), userDID, limit, offset)
168 if err != nil {
169 handleServiceError(w, err)
170 return
171 }
172
173 entries := make([]blockedUserEntry, 0, len(blocks))
174 for _, b := range blocks {
175 entries = append(entries, blockedUserEntry{
176 BlockedDID: b.BlockedDID,
177 RecordURI: b.RecordURI,
178 RecordCID: b.RecordCID,
179 BlockedAt: b.BlockedAt,
180 })
181 }
182
183 writeJSON(w, http.StatusOK, blockedUsersResponse{Blocks: entries})
184}
185
186// writeJSON encodes the response to a buffer first, then writes headers and body.
187// This avoids sending a 200 status with a broken/empty body if encoding fails.
188func writeJSON(w http.ResponseWriter, status int, v any) {
189 var buf bytes.Buffer
190 if err := json.NewEncoder(&buf).Encode(v); err != nil {
191 slog.Error("Failed to encode response", "error", err)
192 writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred")
193 return
194 }
195 w.Header().Set("Content-Type", "application/json")
196 w.WriteHeader(status)
197 if _, err := w.Write(buf.Bytes()); err != nil {
198 slog.Error("Failed to write response", "error", err)
199 }
200}