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
14 kB 486 lines
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}