Monorepo for Tangled
tangled.org
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")
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 mentions, 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 s.notifier.NewComment(ctx, &comment, mentions)
297
298 // TODO: return comment or reply-comment fragment
299 // onattach, htmx-callback to focus on comment.
300 s.pages.HxRefresh(w)
301}
302
303func (s *State) EditComment(w http.ResponseWriter, r *http.Request) {
304 l := s.logger.With("handler", "EditComment")
305 user := s.oauth.GetMultiAccountUser(r)
306
307 noticeId := "comment-error"
308 ctx := r.Context()
309
310 commentAt := r.FormValue("aturi")
311 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt))
312 if err != nil {
313 l.Error("failed to fetch comment", "aturi", commentAt, "err", err)
314 s.pages.Notice(w, noticeId, "Failed to fetch comment")
315 return
316 }
317
318 if comment.Did.String() != user.Did {
319 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did)
320 s.pages.Notice(w, noticeId, "You are not the author of this comment")
321 return
322 }
323
324 body := r.FormValue("body")
325 if body == "" {
326 s.pages.Notice(w, noticeId, "Body is required")
327 return
328 }
329
330 // TODO(boltless): normalize markdown body
331 normalizedBody := body
332 _, references := s.mentionsResolver.Resolve(ctx, body)
333
334 now := time.Now()
335 newComment := comment
336 newComment.Body = tangled.MarkupMarkdown{
337 Text: normalizedBody,
338 Original: &body,
339 Blobs: nil,
340 }
341 newComment.Edited = &now
342 if err := newComment.Validate(); err != nil {
343 l.Error("failed to validate comment", "err", err)
344 s.pages.Notice(w, noticeId, "Failed to update comment.")
345 return
346 }
347
348 client, err := s.oauth.AuthorizedClient(r)
349 if err != nil {
350 l.Error("failed to get authorized client", "err", err)
351 s.pages.Notice(w, noticeId, "Failed to create comment. try again later.")
352 return
353 }
354
355 // update the record first
356 exCid := comment.Cid.String()
357 out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
358 Collection: newComment.Collection.String(),
359 Repo: newComment.Did.String(),
360 Rkey: newComment.Rkey.String(),
361 SwapRecord: &exCid,
362 Record: &lexutil.LexiconTypeDecoder{
363 Val: newComment.AsRecord(),
364 },
365 })
366 if err != nil {
367 l.Error("failed to update comment", "err", err)
368 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
369 return
370 }
371
372 newComment.Cid = syntax.CID(out.Cid)
373
374 tx, err := s.db.Begin()
375 if err != nil {
376 l.Error("failed to start transaction", "err", err)
377 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
378 return
379 }
380 defer tx.Rollback()
381
382 _, err = db.PutComment(tx, &newComment, references)
383 if err != nil {
384 l.Error("failed to perform update-description query", "err", err)
385 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
386 return
387 }
388 err = tx.Commit()
389 if err != nil {
390 l.Error("failed to commit transaction", "err", err)
391 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
392 return
393 }
394
395 reactions, err := db.GetReactionMap(s.db, 20, comment.FeedCommentAtUri())
396 if err != nil {
397 l.Error("failed to get reactions", "err", err)
398 }
399 userReactions, err := db.GetReactionStatusMap(s.db, syntax.DID(user.Did), comment.FeedCommentAtUri())
400 if err != nil {
401 l.Error("failed to get user reactions", "err", err)
402 }
403
404 // TODO: return full comment fragment so we can update comment header too
405 s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{
406 Comment: newComment,
407 Reactions: reactions,
408 UserReacted: userReactions,
409 })
410}
411
412func (s *State) DeleteComment(w http.ResponseWriter, r *http.Request) {
413 l := s.logger.With("handler", "DeleteComment")
414 user := s.oauth.GetMultiAccountUser(r)
415
416 noticeId := "comment"
417 ctx := r.Context()
418
419 commentAt := r.URL.Query().Get("aturi")
420 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt))
421 if err != nil {
422 l.Error("failed to fetch comment", "aturi", commentAt)
423 s.pages.Notice(w, noticeId, "Failed to fetch comment.")
424 return
425 }
426
427 if comment.Did.String() != user.Did {
428 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
429 s.pages.Notice(w, noticeId, "you are not the author of this comment")
430 return
431 }
432
433 if comment.Deleted != nil {
434 s.pages.Notice(w, noticeId, "Comment already deleted")
435 return
436 }
437
438 client, err := s.oauth.AuthorizedClient(r)
439 if err != nil {
440 l.Error("failed to get authorized client", "err", err)
441 s.pages.Notice(w, "comment", "Failed to delete comment.")
442 return
443 }
444 _, err = comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
445 Collection: comment.Collection.String(),
446 Repo: comment.Did.String(),
447 Rkey: comment.Rkey.String(),
448 })
449 if err != nil {
450 l.Error("failed to delete from PDS", "err", err)
451 s.pages.Notice(w, noticeId, "Failed to delete comment, try again later.")
452 return
453 }
454
455 // optimistic update for htmx response
456 now := time.Now()
457 comment.Body = tangled.MarkupMarkdown{}
458 comment.Deleted = &now
459
460 s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{
461 Comment: comment,
462 })
463 s.pages.CommentHeaderFragment(w, pages.CommentHeaderFragmentParams{
464 Comment: comment,
465 HxSwapOob: true,
466 })
467}