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