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")
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 s.pages.HxRefresh(w)
307 return
308 }
309 w.Header().Set("Content-Type", "text/html")
310 w.Write(buf.Bytes())
311 return
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 // update the record first
375 exCid := comment.Cid.String()
376 out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
377 Collection: newComment.Collection.String(),
378 Repo: newComment.Did.String(),
379 Rkey: newComment.Rkey.String(),
380 SwapRecord: &exCid,
381 Record: &lexutil.LexiconTypeDecoder{
382 Val: newComment.AsRecord(),
383 },
384 })
385 if err != nil {
386 l.Error("failed to update comment", "err", err)
387 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
388 return
389 }
390
391 newComment.Cid = syntax.CID(out.Cid)
392
393 tx, err := s.db.Begin()
394 if err != nil {
395 l.Error("failed to start transaction", "err", err)
396 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
397 return
398 }
399 defer tx.Rollback()
400
401 _, err = db.PutComment(tx, &newComment, references)
402 if err != nil {
403 l.Error("failed to perform update-description query", "err", err)
404 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
405 return
406 }
407 err = tx.Commit()
408 if err != nil {
409 l.Error("failed to commit transaction", "err", err)
410 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.")
411 return
412 }
413
414 reactions, err := db.GetReactionMap(s.db, 20, comment.FeedCommentAtUri())
415 if err != nil {
416 l.Error("failed to get reactions", "err", err)
417 }
418 userReactions, err := db.GetReactionStatusMap(s.db, syntax.DID(user.Did), comment.FeedCommentAtUri())
419 if err != nil {
420 l.Error("failed to get user reactions", "err", err)
421 }
422
423 // TODO: return full comment fragment so we can update comment header too
424 s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{
425 Comment: newComment,
426 Reactions: reactions,
427 UserReacted: userReactions,
428 })
429}
430
431func (s *State) DeleteComment(w http.ResponseWriter, r *http.Request) {
432 l := s.logger.With("handler", "DeleteComment")
433 user := s.oauth.GetMultiAccountUser(r)
434
435 noticeId := "comment"
436 ctx := r.Context()
437
438 commentAt := r.URL.Query().Get("aturi")
439 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt))
440 if err != nil {
441 l.Error("failed to fetch comment", "aturi", commentAt)
442 s.pages.Notice(w, noticeId, "Failed to fetch comment.")
443 return
444 }
445
446 if comment.Did.String() != user.Did {
447 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
448 s.pages.Notice(w, noticeId, "you are not the author of this comment")
449 return
450 }
451
452 if comment.Deleted != nil {
453 s.pages.Notice(w, noticeId, "Comment already deleted")
454 return
455 }
456
457 client, err := s.oauth.AuthorizedClient(r)
458 if err != nil {
459 l.Error("failed to get authorized client", "err", err)
460 s.pages.Notice(w, "comment", "Failed to delete comment.")
461 return
462 }
463 _, err = comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
464 Collection: comment.Collection.String(),
465 Repo: comment.Did.String(),
466 Rkey: comment.Rkey.String(),
467 })
468 if err != nil {
469 l.Error("failed to delete from PDS", "err", err)
470 s.pages.Notice(w, noticeId, "Failed to delete comment, try again later.")
471 return
472 }
473
474 // optimistic update for htmx response
475 now := time.Now()
476 comment.Body = tangled.MarkupMarkdown{}
477 comment.Deleted = &now
478
479 s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{
480 Comment: comment,
481 })
482 s.pages.CommentHeaderFragment(w, pages.CommentHeaderFragmentParams{
483 Comment: comment,
484 HxSwapOob: true,
485 })
486}