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