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