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