This repository has no description
1package state
2
3import (
4 "fmt"
5 "net/http"
6 "strconv"
7 "time"
8
9 comatproto "github.com/bluesky-social/indigo/api/atproto"
10 "github.com/bluesky-social/indigo/atproto/syntax"
11 lexutil "github.com/bluesky-social/indigo/lex/util"
12 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
13
14 "tangled.org/core/api/tangled"
15 "tangled.org/core/appview/db"
16 "tangled.org/core/appview/models"
17 "tangled.org/core/appview/pages"
18 "tangled.org/core/orm"
19 "tangled.org/core/tid"
20)
21
22func (s *State) CommentBodyFragment(w http.ResponseWriter, r *http.Request) {
23 l := s.logger.With("handler", "CommentBodyFragment")
24 user := s.oauth.GetMultiAccountUser(r)
25
26 commentAt := r.URL.Query().Get("aturi")
27 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt))
28 if err != nil {
29 l.Error("failed to fetch comment", "aturi", commentAt)
30 http.Error(w, "Failed to fetch comment", http.StatusInternalServerError)
31 return
32 }
33
34 reactions, err := db.GetReactionMap(s.db, 20, comment.AtUri())
35 if err != nil {
36 l.Error("failed to get reactions", "err", err)
37 }
38 var userReactions map[models.ReactionKind]bool
39 if user != nil {
40 userReactions, err = db.GetReactionStatusMap(s.db, syntax.DID(user.Did), comment.AtUri())
41 if err != nil {
42 l.Error("failed to get user reactions", "err", err)
43 }
44 }
45
46 err = s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{
47 Comment: comment,
48 Reactions: reactions,
49 UserReacted: userReactions,
50 })
51 if err != nil {
52 l.Error("failed to render")
53 }
54}
55
56func (s *State) EditCommentFragment(w http.ResponseWriter, r *http.Request) {
57 l := s.logger.With("handler", "EditCommentFragment")
58
59 commentAt := r.URL.Query().Get("aturi")
60 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt))
61 if err != nil {
62 l.Error("failed to fetch comment", "aturi", commentAt)
63 http.Error(w, "Failed to fetch comment", http.StatusInternalServerError)
64 return
65 }
66
67 err = s.pages.EditCommentFragment(w, pages.EditCommentFragmentParams{
68 Comment: comment,
69 })
70 if err != nil {
71 l.Error("failed to render")
72 }
73}
74
75func (s *State) NewReplyCommentFragment(w http.ResponseWriter, r *http.Request) {
76 s.pages.ReplyCommentFragment(w, pages.ReplyCommentFragmentParams{
77 LoggedInUser: s.oauth.GetMultiAccountUser(r),
78 })
79}
80
81func (s *State) ReplyPlaceholderFragment(w http.ResponseWriter, r *http.Request) {
82 s.pages.ReplyPlaceholderFragment(w, pages.ReplyPlaceholderFragmentParams{
83 LoggedInUser: s.oauth.GetMultiAccountUser(r),
84 })
85}
86
87func (s *State) NewComment(w http.ResponseWriter, r *http.Request) {
88 l := s.logger.With("handler", "NewComment")
89 user := s.oauth.GetMultiAccountUser(r)
90
91 noticeId := "comment-error"
92 ctx := r.Context()
93
94 body := r.FormValue("body")
95 if body == "" {
96 s.pages.Notice(w, noticeId, "Body is required")
97 return
98 }
99
100 // TODO(boltless): normalize markdown body
101 normalizedBody := body
102 _, references := s.mentionsResolver.Resolve(ctx, body)
103
104 markdownBody := tangled.MarkupMarkdown{
105 Text: normalizedBody,
106 Original: &body,
107 Blobs: nil,
108 }
109
110 subjectUri, err := syntax.ParseATURI(r.FormValue("subject-uri"))
111 if err != nil {
112 l.Warn("invalid subject uri", "err", err)
113 s.pages.Notice(w, noticeId, "Subject URI should be valid AT-URI")
114 return
115 }
116 l = l.With("subject.uri", subjectUri)
117
118 // ingest CID of subject record on-demand.
119 // TODO(boltless): appview should ingest CID of all atproto records
120 var subjectCid syntax.CID
121 if subjectCidRaw := r.FormValue("subject-cid"); subjectCidRaw != "" {
122 subjectCid, err = syntax.ParseCID(subjectCidRaw)
123 if err != nil {
124 l.Warn("invalid subject cid", "err", err)
125 s.pages.Notice(w, noticeId, "Subject CID should be valid CID")
126 return
127 }
128 } else {
129 l.Debug("fetching subject record CID")
130 subjectCid, err = func(uri syntax.ATURI) (syntax.CID, error) {
131 ident, err := s.idResolver.ResolveIdent(ctx, uri.Authority().String())
132 if err != nil {
133 return "", err
134 }
135
136 xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()}
137 out, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String())
138 if err != nil {
139 return "", err
140 }
141 if out.Cid == nil {
142 return "", fmt.Errorf("record CID is empty")
143 }
144
145 cid, err := syntax.ParseCID(*out.Cid)
146 if err != nil {
147 return "", err
148 }
149
150 return cid, nil
151 }(subjectUri)
152 if err != nil {
153 l.Error("failed to backfill subject record", "err", err)
154 s.pages.Notice(w, noticeId, "failed to backfill subject record")
155 return
156 }
157 }
158 l = l.With("subject.cid", subjectCid)
159
160 subject := comatproto.RepoStrongRef{
161 Uri: subjectUri.String(),
162 Cid: subjectCid.String(),
163 }
164
165 var pullRoundIdx *int
166 if pullRoundIdxRaw := r.FormValue("pull-round-idx"); pullRoundIdxRaw != "" {
167 roundIdx, err := strconv.Atoi(pullRoundIdxRaw)
168 if err != nil {
169 l.Warn("invalid round idx", "err", err)
170 s.pages.Notice(w, noticeId, "pull round index should be valid integer")
171 return
172 }
173 pullRoundIdx = &roundIdx
174 }
175
176 var replyTo *comatproto.RepoStrongRef
177 replyToUriRaw := r.FormValue("reply-to-uri")
178 replyToCidRaw := r.FormValue("reply-to-cid")
179 if replyToUriRaw != "" && replyToCidRaw != "" {
180 uri, err := syntax.ParseATURI(replyToUriRaw)
181 if err != nil {
182 s.pages.Notice(w, noticeId, "reply-to-uri should be valid AT-URI")
183 return
184 }
185 cid, err := syntax.ParseCID(replyToCidRaw)
186 if err != nil {
187 s.pages.Notice(w, noticeId, "reply-to-cid should be valid CID")
188 return
189 }
190 replyTo = &comatproto.RepoStrongRef{
191 Uri: uri.String(),
192 Cid: cid.String(),
193 }
194 }
195
196 comment := models.Comment{
197 Did: syntax.DID(user.Did),
198 Collection: tangled.FeedCommentNSID,
199 Rkey: syntax.RecordKey(tid.TID()),
200
201 Subject: subject,
202 Body: markdownBody,
203 Created: time.Now(),
204 ReplyTo: replyTo,
205 PullRoundIdx: pullRoundIdx,
206 }
207 if err = comment.Validate(); err != nil {
208 l.Error("failed to validate comment", "err", err)
209 s.pages.Notice(w, noticeId, "Failed to create comment.")
210 return
211 }
212
213 client, err := s.oauth.AuthorizedClient(r)
214 if err != nil {
215 l.Error("failed to get authorized client", "err", err)
216 s.pages.Notice(w, noticeId, "Failed to create comment.")
217 return
218 }
219
220 // create a record first
221 out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
222 Collection: comment.Collection.String(),
223 Repo: comment.Did.String(),
224 Rkey: comment.Rkey.String(),
225 Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()},
226 })
227 if err != nil {
228 l.Error("failed to create comment", "err", err)
229 s.pages.Notice(w, noticeId, "Failed to create comment.")
230 return
231 }
232
233 comment.Cid = syntax.CID(out.Cid)
234
235 tx, err := s.db.Begin()
236 if err != nil {
237 l.Error("failed to start transaction", "err", err)
238 s.pages.Notice(w, noticeId, "Failed to create comment, try again later.")
239 return
240 }
241 defer tx.Rollback()
242
243 err = db.PutComment(tx, &comment, references)
244 if err != nil {
245 l.Error("failed to create comment", "err", err)
246 s.pages.Notice(w, noticeId, "Failed to create comment.")
247 return
248 }
249
250 err = tx.Commit()
251 if err != nil {
252 l.Error("failed to commit transaction", "err", err)
253 s.pages.Notice(w, noticeId, "Failed to create comment, try again later.")
254 return
255 }
256
257 // TODO: return comment or reply-comment fragment
258 // onattach, htmx-callback to focus on comment.
259 s.pages.HxRefresh(w)
260}
261
262func (s *State) EditComment(w http.ResponseWriter, r *http.Request) {
263 l := s.logger.With("handler", "EditComment")
264 user := s.oauth.GetMultiAccountUser(r)
265
266 noticeId := "comment-error"
267 ctx := r.Context()
268
269 commentAt := r.FormValue("aturi")
270 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt))
271 if err != nil {
272 l.Error("failed to fetch comment", "aturi", commentAt, "err", err)
273 s.pages.Notice(w, noticeId, "Failed to fetch comment")
274 return
275 }
276
277 if comment.Did.String() != user.Did {
278 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did)
279 s.pages.Notice(w, noticeId, "You are not the author of this comment")
280 return
281 }
282
283 body := r.FormValue("body")
284 if body == "" {
285 s.pages.Notice(w, noticeId, "Body is required")
286 return
287 }
288
289 // TODO(boltless): normalize markdown body
290 normalizedBody := body
291 _, references := s.mentionsResolver.Resolve(ctx, body)
292
293 now := time.Now()
294 newComment := comment
295 newComment.Body = tangled.MarkupMarkdown{
296 Text: normalizedBody,
297 Original: &body,
298 Blobs: nil,
299 }
300 newComment.Edited = &now
301 if err := newComment.Validate(); err != nil {
302 l.Error("failed to validate comment", "err", err)
303 s.pages.Notice(w, noticeId, "Failed to update comment.")
304 return
305 }
306
307 client, err := s.oauth.AuthorizedClient(r)
308 if err != nil {
309 l.Error("failed to get authorized client", "err", err)
310 s.pages.Notice(w, noticeId, "Failed to create comment. try again later.")
311 return
312 }
313
314 // update the record first
315 exCid := comment.Cid.String()
316 out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
317 Collection: newComment.Collection.String(),
318 Repo: newComment.Did.String(),
319 Rkey: newComment.Rkey.String(),
320 SwapRecord: &exCid,
321 Record: &lexutil.LexiconTypeDecoder{
322 Val: newComment.AsRecord(),
323 },
324 })
325 if err != nil {
326 l.Error("failed to update comment", "err", err)
327 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
328 return
329 }
330
331 newComment.Cid = syntax.CID(out.Cid)
332
333 tx, err := s.db.Begin()
334 if err != nil {
335 l.Error("failed to start transaction", "err", err)
336 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
337 return
338 }
339 defer tx.Rollback()
340
341 err = db.PutComment(tx, &newComment, references)
342 if err != nil {
343 l.Error("failed to perform update-description query", "err", err)
344 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
345 return
346 }
347 err = tx.Commit()
348 if err != nil {
349 l.Error("failed to commit transaction", "err", err)
350 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
351 return
352 }
353
354 reactions, err := db.GetReactionMap(s.db, 20, comment.AtUri())
355 if err != nil {
356 l.Error("failed to get reactions", "err", err)
357 }
358 userReactions, err := db.GetReactionStatusMap(s.db, syntax.DID(user.Did), comment.AtUri())
359 if err != nil {
360 l.Error("failed to get user reactions", "err", err)
361 }
362
363 // TODO: return full comment fragment so we can update comment header too
364 s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{
365 Comment: newComment,
366 Reactions: reactions,
367 UserReacted: userReactions,
368 })
369}
370
371func (s *State) DeleteComment(w http.ResponseWriter, r *http.Request) {
372 l := s.logger.With("handler", "DeleteComment")
373 user := s.oauth.GetMultiAccountUser(r)
374
375 noticeId := "comment"
376 ctx := r.Context()
377
378 commentAt := r.URL.Query().Get("aturi")
379 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt))
380 if err != nil {
381 l.Error("failed to fetch comment", "aturi", commentAt)
382 s.pages.Notice(w, noticeId, "Failed to fetch comment.")
383 return
384 }
385
386 if comment.Did.String() != user.Did {
387 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
388 s.pages.Notice(w, noticeId, "you are not the author of this comment")
389 return
390 }
391
392 if comment.Deleted != nil {
393 s.pages.Notice(w, noticeId, "Comment already deleted")
394 return
395 }
396
397 client, err := s.oauth.AuthorizedClient(r)
398 if err != nil {
399 l.Error("failed to get authorized client", "err", err)
400 s.pages.Notice(w, "comment", "Failed to delete comment.")
401 return
402 }
403 _, err = comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
404 Collection: comment.Collection.String(),
405 Repo: comment.Did.String(),
406 Rkey: comment.Rkey.String(),
407 })
408 if err != nil {
409 l.Error("failed to delete from PDS", "err", err)
410 s.pages.Notice(w, noticeId, "Failed to delete comment, try again later.")
411 return
412 }
413
414 // optimistic update for htmx response
415 now := time.Now()
416 comment.Body = tangled.MarkupMarkdown{}
417 comment.Deleted = &now
418
419 s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{
420 Comment: comment,
421 })
422}