This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / state / comment.go
15 kB 502 lines
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}