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