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