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.FeedCommentAtUri())
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.FeedCommentAtUri())
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", "err", err)
75 }
76}
77
78func (s *State) NewReplyCommentFragment(w http.ResponseWriter, r *http.Request) {
79 s.pages.ReplyCommentFragment(w, pages.ReplyCommentFragmentParams{
80 BaseParams: pages.BaseParamsFromContext(r.Context()),
81 })
82}
83
84func (s *State) ReplyPlaceholderFragment(w http.ResponseWriter, r *http.Request) {
85 s.pages.ReplyPlaceholderFragment(w, pages.ReplyPlaceholderFragmentParams{
86 BaseParams: pages.BaseParamsFromContext(r.Context()),
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 var pullRoundIdx *int
98 if pullRoundIdxRaw := r.FormValue("pull-round-idx"); pullRoundIdxRaw != "" {
99 roundIdx, err := strconv.Atoi(pullRoundIdxRaw)
100 if err != nil {
101 l.Warn("invalid round idx", "err", err)
102 s.pages.Notice(w, noticeId, "pull round index should be valid integer")
103 return
104 }
105 pullRoundIdx = &roundIdx
106 noticeId = fmt.Sprintf("comment-error-%d", roundIdx)
107 }
108
109 body := r.FormValue("body")
110 if body == "" {
111 s.pages.Notice(w, noticeId, "Body is required")
112 return
113 }
114
115 // TODO(boltless): normalize markdown body
116 normalizedBody := body
117 mentions, references := s.mentionsResolver.Resolve(ctx, body)
118
119 markdownBody := tangled.MarkupMarkdown{
120 Text: normalizedBody,
121 Original: &body,
122 Blobs: nil,
123 }
124
125 subjectUri, err := syntax.ParseATURI(r.FormValue("subject-uri"))
126 if err != nil {
127 l.Warn("invalid subject uri", "err", err)
128 s.pages.Notice(w, noticeId, "Subject URI should be valid AT-URI")
129 return
130 }
131 l = l.With("subject.uri", subjectUri)
132
133 // ingest CID of subject record on-demand.
134 // TODO(boltless): appview should ingest CID of all atproto records
135 var subjectCid syntax.CID
136 if subjectCidRaw := r.FormValue("subject-cid"); subjectCidRaw != "" {
137 subjectCid, err = syntax.ParseCID(subjectCidRaw)
138 if err != nil {
139 l.Warn("invalid subject cid", "err", err)
140 s.pages.Notice(w, noticeId, "Subject CID should be valid CID")
141 return
142 }
143 } else {
144 l.Debug("fetching subject record CID")
145 subjectCid, err = func(uri syntax.ATURI) (syntax.CID, error) {
146 ident, err := s.idResolver.ResolveIdent(ctx, uri.Authority().String())
147 if err != nil {
148 return "", err
149 }
150
151 xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()}
152 out, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String())
153 if err != nil {
154 return "", err
155 }
156 if out.Cid == nil {
157 return "", fmt.Errorf("record CID is empty")
158 }
159
160 cid, err := syntax.ParseCID(*out.Cid)
161 if err != nil {
162 return "", err
163 }
164
165 return cid, nil
166 }(subjectUri)
167 if err != nil {
168 l.Error("failed to backfill subject record", "err", err)
169 s.pages.Notice(w, noticeId, "failed to backfill subject record")
170 return
171 }
172 }
173 l = l.With("subject.cid", subjectCid)
174
175 subject := comatproto.RepoStrongRef{
176 Uri: subjectUri.String(),
177 Cid: subjectCid.String(),
178 }
179
180 var replyTo *comatproto.RepoStrongRef
181 replyToUriRaw := r.FormValue("reply-to-uri")
182 replyToCidRaw := r.FormValue("reply-to-cid")
183 if replyToUriRaw != "" {
184 replyToUri, err := syntax.ParseATURI(replyToUriRaw)
185 if err != nil {
186 s.pages.Notice(w, noticeId, "reply-to-uri should be valid AT-URI")
187 return
188 }
189 // force replyTo.uri to `sh.tangled.feed.comment` collection, even when they aren't.
190 // we are expecting parent comment will be migrated later.
191 replyToUri = syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", replyToUri.Authority(), tangled.FeedCommentNSID, replyToUri.RecordKey()))
192
193 var replyToCid syntax.CID
194 if replyToCidRaw != "" {
195 replyToCid, err = syntax.ParseCID(replyToCidRaw)
196 if err != nil {
197 s.pages.Notice(w, noticeId, "reply-to-cid should be valid CID")
198 return
199 }
200 } else {
201 // guess parent comment cid
202 subjectComment, err := db.GetComment(s.db, orm.FilterEq("did", replyToUri.Authority()), orm.FilterEq("rkey", replyToUri.RecordKey()))
203 if err != nil {
204 l.Warn("db: failed to query subject comment", "err", err)
205 s.pages.Notice(w, noticeId, "Subject record is unknown.")
206 return
207 }
208 if subjectComment.Deleted != nil {
209 // leave cid empty. reply comment won't pass the schema validation.
210 } else {
211 // guess cid from content
212 c, err := func() (cid.Cid, error) {
213 buf := new(bytes.Buffer)
214 if subjectComment.Subject.Cid == "" {
215 subjectComment.Subject.Cid = subject.Cid
216 }
217 if err := subjectComment.AsRecord().MarshalCBOR(buf); err != nil {
218 return cid.Undef, fmt.Errorf("MarshalCBOR: %w", err)
219 }
220 return cid.NewPrefixV1(cid.DagCBOR, multihash.SHA2_256).Sum(buf.Bytes())
221 }()
222 if err != nil {
223 l.Warn("cbor: failed to guess parent comment cid", "err", err)
224 s.pages.Notice(w, noticeId, "Parent comment is invalid.")
225 return
226 }
227 replyToCid = syntax.CID(c.String())
228 }
229 }
230 replyTo = &comatproto.RepoStrongRef{
231 Uri: replyToUri.String(),
232 Cid: replyToCid.String(),
233 }
234 }
235
236 comment := models.Comment{
237 Did: syntax.DID(user.Did),
238 Collection: tangled.FeedCommentNSID,
239 Rkey: syntax.RecordKey(tid.TID()),
240
241 Subject: subject,
242 Body: markdownBody,
243 Created: time.Now(),
244 ReplyTo: replyTo,
245 PullRoundIdx: pullRoundIdx,
246 }
247 if err = comment.Validate(); err != nil {
248 l.Error("failed to validate comment", "err", err)
249 s.pages.Notice(w, noticeId, "Failed to create comment.")
250 return
251 }
252
253 client, err := s.oauth.AuthorizedClient(r)
254 if err != nil {
255 l.Error("failed to get authorized client", "err", err)
256 s.pages.Notice(w, noticeId, "Failed to create comment.")
257 return
258 }
259
260 // create a record first
261 out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
262 Collection: comment.Collection.String(),
263 Repo: comment.Did.String(),
264 Rkey: comment.Rkey.String(),
265 Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()},
266 })
267 if err != nil {
268 l.Error("failed to create comment", "err", err)
269 s.pages.Notice(w, noticeId, "Failed to create comment.")
270 return
271 }
272
273 comment.Cid = syntax.CID(out.Cid)
274
275 tx, err := s.db.Begin()
276 if err != nil {
277 l.Error("failed to start transaction", "err", err)
278 s.pages.Notice(w, noticeId, "Failed to create comment, try again later.")
279 return
280 }
281 defer tx.Rollback()
282
283 _, err = db.PutComment(tx, &comment, references)
284 if err != nil {
285 l.Error("failed to create comment", "err", err)
286 s.pages.Notice(w, noticeId, "Failed to create comment.")
287 return
288 }
289
290 err = tx.Commit()
291 if err != nil {
292 l.Error("failed to commit transaction", "err", err)
293 s.pages.Notice(w, noticeId, "Failed to create comment, try again later.")
294 return
295 }
296
297 s.notifier.NewComment(ctx, &comment, mentions)
298
299 if pullRoundIdx != nil {
300 var buf bytes.Buffer
301 if err := s.pages.PullCommentFragment(&buf, pages.PullCommentFragmentParams{
302 LoggedInUser: user,
303 Comment: comment,
304 }); err != nil {
305 l.Error("failed to render pull comment fragment", "err", err)
306 } else {
307 w.Header().Set("Content-Type", "text/html")
308 w.Write(buf.Bytes())
309 return
310 }
311 }
312
313 target, err := s.pages.MakeCommentUrl(ctx, comment.AtUri())
314 if err != nil {
315 s.pages.HxRefresh(w)
316 }
317
318 s.pages.HxLocation(w, target)
319}
320
321func (s *State) EditComment(w http.ResponseWriter, r *http.Request) {
322 l := s.logger.With("handler", "EditComment")
323 user := s.oauth.GetMultiAccountUser(r)
324
325 noticeId := "comment-error"
326 ctx := r.Context()
327
328 commentAt := r.FormValue("aturi")
329 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt))
330 if err != nil {
331 l.Error("failed to fetch comment", "aturi", commentAt, "err", err)
332 s.pages.Notice(w, noticeId, "Failed to fetch comment")
333 return
334 }
335
336 if comment.Did.String() != user.Did {
337 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did)
338 s.pages.Notice(w, noticeId, "You are not the author of this comment")
339 return
340 }
341
342 body := r.FormValue("body")
343 if body == "" {
344 s.pages.Notice(w, noticeId, "Body is required")
345 return
346 }
347
348 // TODO(boltless): normalize markdown body
349 normalizedBody := body
350 _, references := s.mentionsResolver.Resolve(ctx, body)
351
352 now := time.Now()
353 newComment := comment
354 newComment.Body = tangled.MarkupMarkdown{
355 Text: normalizedBody,
356 Original: &body,
357 Blobs: nil,
358 }
359 newComment.Edited = &now
360 if err := newComment.Validate(); err != nil {
361 l.Error("failed to validate comment", "err", err)
362 s.pages.Notice(w, noticeId, "Failed to update comment.")
363 return
364 }
365
366 client, err := s.oauth.AuthorizedClient(r)
367 if err != nil {
368 l.Error("failed to get authorized client", "err", err)
369 s.pages.Notice(w, noticeId, "Failed to create comment. try again later.")
370 return
371 }
372
373 // update the record first
374 exCid := comment.Cid.String()
375 out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
376 Collection: newComment.Collection.String(),
377 Repo: newComment.Did.String(),
378 Rkey: newComment.Rkey.String(),
379 SwapRecord: &exCid,
380 Record: &lexutil.LexiconTypeDecoder{
381 Val: newComment.AsRecord(),
382 },
383 })
384 if err != nil {
385 l.Error("failed to update comment", "err", err)
386 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
387 return
388 }
389
390 newComment.Cid = syntax.CID(out.Cid)
391
392 tx, err := s.db.Begin()
393 if err != nil {
394 l.Error("failed to start transaction", "err", err)
395 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
396 return
397 }
398 defer tx.Rollback()
399
400 _, err = db.PutComment(tx, &newComment, references)
401 if err != nil {
402 l.Error("failed to perform update-description query", "err", err)
403 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
404 return
405 }
406 err = tx.Commit()
407 if err != nil {
408 l.Error("failed to commit transaction", "err", err)
409 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
410 return
411 }
412
413 reactions, err := db.GetReactionMap(s.db, 20, comment.FeedCommentAtUri())
414 if err != nil {
415 l.Error("failed to get reactions", "err", err)
416 }
417 userReactions, err := db.GetReactionStatusMap(s.db, syntax.DID(user.Did), comment.FeedCommentAtUri())
418 if err != nil {
419 l.Error("failed to get user reactions", "err", err)
420 }
421
422 // TODO: return full comment fragment so we can update comment header too
423 s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{
424 Comment: newComment,
425 Reactions: reactions,
426 UserReacted: userReactions,
427 })
428}
429
430func (s *State) DeleteComment(w http.ResponseWriter, r *http.Request) {
431 l := s.logger.With("handler", "DeleteComment")
432 user := s.oauth.GetMultiAccountUser(r)
433
434 noticeId := "comment"
435 ctx := r.Context()
436
437 commentAt := r.URL.Query().Get("aturi")
438 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt))
439 if err != nil {
440 l.Error("failed to fetch comment", "aturi", commentAt)
441 s.pages.Notice(w, noticeId, "Failed to fetch comment.")
442 return
443 }
444
445 if comment.Did.String() != user.Did {
446 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
447 s.pages.Notice(w, noticeId, "you are not the author of this comment")
448 return
449 }
450
451 if comment.Deleted != nil {
452 s.pages.Notice(w, noticeId, "Comment already deleted")
453 return
454 }
455
456 client, err := s.oauth.AuthorizedClient(r)
457 if err != nil {
458 l.Error("failed to get authorized client", "err", err)
459 s.pages.Notice(w, "comment", "Failed to delete comment.")
460 return
461 }
462 _, err = comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
463 Collection: comment.Collection.String(),
464 Repo: comment.Did.String(),
465 Rkey: comment.Rkey.String(),
466 })
467 if err != nil {
468 l.Error("failed to delete from PDS", "err", err)
469 s.pages.Notice(w, noticeId, "Failed to delete comment, try again later.")
470 return
471 }
472
473 // optimistic update for htmx response
474 now := time.Now()
475 comment.Body = tangled.MarkupMarkdown{}
476 comment.Deleted = &now
477
478 s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{
479 Comment: comment,
480 })
481 s.pages.CommentHeaderFragment(w, pages.CommentHeaderFragmentParams{
482 Comment: comment,
483 HxSwapOob: true,
484 })
485}