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
12 kB 422 lines
1package state 2 3import ( 4 "fmt" 5 "net/http" 6 "strconv" 7 "time" 8 9 comatproto "github.com/bluesky-social/indigo/api/atproto" 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 14 "tangled.org/core/api/tangled" 15 "tangled.org/core/appview/db" 16 "tangled.org/core/appview/models" 17 "tangled.org/core/appview/pages" 18 "tangled.org/core/orm" 19 "tangled.org/core/tid" 20) 21 22func (s *State) CommentBodyFragment(w http.ResponseWriter, r *http.Request) { 23 l := s.logger.With("handler", "CommentBodyFragment") 24 user := s.oauth.GetMultiAccountUser(r) 25 26 commentAt := r.URL.Query().Get("aturi") 27 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 28 if err != nil { 29 l.Error("failed to fetch comment", "aturi", commentAt) 30 http.Error(w, "Failed to fetch comment", http.StatusInternalServerError) 31 return 32 } 33 34 reactions, err := db.GetReactionMap(s.db, 20, comment.AtUri()) 35 if err != nil { 36 l.Error("failed to get reactions", "err", err) 37 } 38 var userReactions map[models.ReactionKind]bool 39 if user != nil { 40 userReactions, err = db.GetReactionStatusMap(s.db, syntax.DID(user.Did), comment.AtUri()) 41 if err != nil { 42 l.Error("failed to get user reactions", "err", err) 43 } 44 } 45 46 err = s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 47 Comment: comment, 48 Reactions: reactions, 49 UserReacted: userReactions, 50 }) 51 if err != nil { 52 l.Error("failed to render") 53 } 54} 55 56func (s *State) EditCommentFragment(w http.ResponseWriter, r *http.Request) { 57 l := s.logger.With("handler", "EditCommentFragment") 58 59 commentAt := r.URL.Query().Get("aturi") 60 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 61 if err != nil { 62 l.Error("failed to fetch comment", "aturi", commentAt) 63 http.Error(w, "Failed to fetch comment", http.StatusInternalServerError) 64 return 65 } 66 67 err = s.pages.EditCommentFragment(w, pages.EditCommentFragmentParams{ 68 Comment: comment, 69 }) 70 if err != nil { 71 l.Error("failed to render") 72 } 73} 74 75func (s *State) NewReplyCommentFragment(w http.ResponseWriter, r *http.Request) { 76 s.pages.ReplyCommentFragment(w, pages.ReplyCommentFragmentParams{ 77 LoggedInUser: s.oauth.GetMultiAccountUser(r), 78 }) 79} 80 81func (s *State) ReplyPlaceholderFragment(w http.ResponseWriter, r *http.Request) { 82 s.pages.ReplyPlaceholderFragment(w, pages.ReplyPlaceholderFragmentParams{ 83 LoggedInUser: s.oauth.GetMultiAccountUser(r), 84 }) 85} 86 87func (s *State) NewComment(w http.ResponseWriter, r *http.Request) { 88 l := s.logger.With("handler", "NewComment") 89 user := s.oauth.GetMultiAccountUser(r) 90 91 noticeId := "comment-error" 92 ctx := r.Context() 93 94 body := r.FormValue("body") 95 if body == "" { 96 s.pages.Notice(w, noticeId, "Body is required") 97 return 98 } 99 100 // TODO(boltless): normalize markdown body 101 normalizedBody := body 102 _, references := s.mentionsResolver.Resolve(ctx, body) 103 104 markdownBody := tangled.MarkupMarkdown{ 105 Text: normalizedBody, 106 Original: &body, 107 Blobs: nil, 108 } 109 110 subjectUri, err := syntax.ParseATURI(r.FormValue("subject-uri")) 111 if err != nil { 112 l.Warn("invalid subject uri", "err", err) 113 s.pages.Notice(w, noticeId, "Subject URI should be valid AT-URI") 114 return 115 } 116 l = l.With("subject.uri", subjectUri) 117 118 // ingest CID of subject record on-demand. 119 // TODO(boltless): appview should ingest CID of all atproto records 120 var subjectCid syntax.CID 121 if subjectCidRaw := r.FormValue("subject-cid"); subjectCidRaw != "" { 122 subjectCid, err = syntax.ParseCID(subjectCidRaw) 123 if err != nil { 124 l.Warn("invalid subject cid", "err", err) 125 s.pages.Notice(w, noticeId, "Subject CID should be valid CID") 126 return 127 } 128 } else { 129 l.Debug("fetching subject record CID") 130 subjectCid, err = func(uri syntax.ATURI) (syntax.CID, error) { 131 ident, err := s.idResolver.ResolveIdent(ctx, uri.Authority().String()) 132 if err != nil { 133 return "", err 134 } 135 136 xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 137 out, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String()) 138 if err != nil { 139 return "", err 140 } 141 if out.Cid == nil { 142 return "", fmt.Errorf("record CID is empty") 143 } 144 145 cid, err := syntax.ParseCID(*out.Cid) 146 if err != nil { 147 return "", err 148 } 149 150 return cid, nil 151 }(subjectUri) 152 if err != nil { 153 l.Error("failed to backfill subject record", "err", err) 154 s.pages.Notice(w, noticeId, "failed to backfill subject record") 155 return 156 } 157 } 158 l = l.With("subject.cid", subjectCid) 159 160 subject := comatproto.RepoStrongRef{ 161 Uri: subjectUri.String(), 162 Cid: subjectCid.String(), 163 } 164 165 var pullRoundIdx *int 166 if pullRoundIdxRaw := r.FormValue("pull-round-idx"); pullRoundIdxRaw != "" { 167 roundIdx, err := strconv.Atoi(pullRoundIdxRaw) 168 if err != nil { 169 l.Warn("invalid round idx", "err", err) 170 s.pages.Notice(w, noticeId, "pull round index should be valid integer") 171 return 172 } 173 pullRoundIdx = &roundIdx 174 } 175 176 var replyTo *comatproto.RepoStrongRef 177 replyToUriRaw := r.FormValue("reply-to-uri") 178 replyToCidRaw := r.FormValue("reply-to-cid") 179 if replyToUriRaw != "" && replyToCidRaw != "" { 180 uri, err := syntax.ParseATURI(replyToUriRaw) 181 if err != nil { 182 s.pages.Notice(w, noticeId, "reply-to-uri should be valid AT-URI") 183 return 184 } 185 cid, err := syntax.ParseCID(replyToCidRaw) 186 if err != nil { 187 s.pages.Notice(w, noticeId, "reply-to-cid should be valid CID") 188 return 189 } 190 replyTo = &comatproto.RepoStrongRef{ 191 Uri: uri.String(), 192 Cid: cid.String(), 193 } 194 } 195 196 comment := models.Comment{ 197 Did: syntax.DID(user.Did), 198 Collection: tangled.FeedCommentNSID, 199 Rkey: syntax.RecordKey(tid.TID()), 200 201 Subject: subject, 202 Body: markdownBody, 203 Created: time.Now(), 204 ReplyTo: replyTo, 205 PullRoundIdx: pullRoundIdx, 206 } 207 if err = comment.Validate(); err != nil { 208 l.Error("failed to validate comment", "err", err) 209 s.pages.Notice(w, noticeId, "Failed to create comment.") 210 return 211 } 212 213 client, err := s.oauth.AuthorizedClient(r) 214 if err != nil { 215 l.Error("failed to get authorized client", "err", err) 216 s.pages.Notice(w, noticeId, "Failed to create comment.") 217 return 218 } 219 220 // create a record first 221 out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 222 Collection: comment.Collection.String(), 223 Repo: comment.Did.String(), 224 Rkey: comment.Rkey.String(), 225 Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 226 }) 227 if err != nil { 228 l.Error("failed to create comment", "err", err) 229 s.pages.Notice(w, noticeId, "Failed to create comment.") 230 return 231 } 232 233 comment.Cid = syntax.CID(out.Cid) 234 235 tx, err := s.db.Begin() 236 if err != nil { 237 l.Error("failed to start transaction", "err", err) 238 s.pages.Notice(w, noticeId, "Failed to create comment, try again later.") 239 return 240 } 241 defer tx.Rollback() 242 243 err = db.PutComment(tx, &comment, references) 244 if err != nil { 245 l.Error("failed to create comment", "err", err) 246 s.pages.Notice(w, noticeId, "Failed to create comment.") 247 return 248 } 249 250 err = tx.Commit() 251 if err != nil { 252 l.Error("failed to commit transaction", "err", err) 253 s.pages.Notice(w, noticeId, "Failed to create comment, try again later.") 254 return 255 } 256 257 // TODO: return comment or reply-comment fragment 258 // onattach, htmx-callback to focus on comment. 259 s.pages.HxRefresh(w) 260} 261 262func (s *State) EditComment(w http.ResponseWriter, r *http.Request) { 263 l := s.logger.With("handler", "EditComment") 264 user := s.oauth.GetMultiAccountUser(r) 265 266 noticeId := "comment-error" 267 ctx := r.Context() 268 269 commentAt := r.FormValue("aturi") 270 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 271 if err != nil { 272 l.Error("failed to fetch comment", "aturi", commentAt, "err", err) 273 s.pages.Notice(w, noticeId, "Failed to fetch comment") 274 return 275 } 276 277 if comment.Did.String() != user.Did { 278 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 279 s.pages.Notice(w, noticeId, "You are not the author of this comment") 280 return 281 } 282 283 body := r.FormValue("body") 284 if body == "" { 285 s.pages.Notice(w, noticeId, "Body is required") 286 return 287 } 288 289 // TODO(boltless): normalize markdown body 290 normalizedBody := body 291 _, references := s.mentionsResolver.Resolve(ctx, body) 292 293 now := time.Now() 294 newComment := comment 295 newComment.Body = tangled.MarkupMarkdown{ 296 Text: normalizedBody, 297 Original: &body, 298 Blobs: nil, 299 } 300 newComment.Edited = &now 301 if err := newComment.Validate(); err != nil { 302 l.Error("failed to validate comment", "err", err) 303 s.pages.Notice(w, noticeId, "Failed to update comment.") 304 return 305 } 306 307 client, err := s.oauth.AuthorizedClient(r) 308 if err != nil { 309 l.Error("failed to get authorized client", "err", err) 310 s.pages.Notice(w, noticeId, "Failed to create comment. try again later.") 311 return 312 } 313 314 // update the record first 315 exCid := comment.Cid.String() 316 out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 317 Collection: newComment.Collection.String(), 318 Repo: newComment.Did.String(), 319 Rkey: newComment.Rkey.String(), 320 SwapRecord: &exCid, 321 Record: &lexutil.LexiconTypeDecoder{ 322 Val: newComment.AsRecord(), 323 }, 324 }) 325 if err != nil { 326 l.Error("failed to update comment", "err", err) 327 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 328 return 329 } 330 331 newComment.Cid = syntax.CID(out.Cid) 332 333 tx, err := s.db.Begin() 334 if err != nil { 335 l.Error("failed to start transaction", "err", err) 336 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 337 return 338 } 339 defer tx.Rollback() 340 341 err = db.PutComment(tx, &newComment, references) 342 if err != nil { 343 l.Error("failed to perform update-description query", "err", err) 344 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 345 return 346 } 347 err = tx.Commit() 348 if err != nil { 349 l.Error("failed to commit transaction", "err", err) 350 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 351 return 352 } 353 354 reactions, err := db.GetReactionMap(s.db, 20, comment.AtUri()) 355 if err != nil { 356 l.Error("failed to get reactions", "err", err) 357 } 358 userReactions, err := db.GetReactionStatusMap(s.db, syntax.DID(user.Did), comment.AtUri()) 359 if err != nil { 360 l.Error("failed to get user reactions", "err", err) 361 } 362 363 // TODO: return full comment fragment so we can update comment header too 364 s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 365 Comment: newComment, 366 Reactions: reactions, 367 UserReacted: userReactions, 368 }) 369} 370 371func (s *State) DeleteComment(w http.ResponseWriter, r *http.Request) { 372 l := s.logger.With("handler", "DeleteComment") 373 user := s.oauth.GetMultiAccountUser(r) 374 375 noticeId := "comment" 376 ctx := r.Context() 377 378 commentAt := r.URL.Query().Get("aturi") 379 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 380 if err != nil { 381 l.Error("failed to fetch comment", "aturi", commentAt) 382 s.pages.Notice(w, noticeId, "Failed to fetch comment.") 383 return 384 } 385 386 if comment.Did.String() != user.Did { 387 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 388 s.pages.Notice(w, noticeId, "you are not the author of this comment") 389 return 390 } 391 392 if comment.Deleted != nil { 393 s.pages.Notice(w, noticeId, "Comment already deleted") 394 return 395 } 396 397 client, err := s.oauth.AuthorizedClient(r) 398 if err != nil { 399 l.Error("failed to get authorized client", "err", err) 400 s.pages.Notice(w, "comment", "Failed to delete comment.") 401 return 402 } 403 _, err = comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 404 Collection: comment.Collection.String(), 405 Repo: comment.Did.String(), 406 Rkey: comment.Rkey.String(), 407 }) 408 if err != nil { 409 l.Error("failed to delete from PDS", "err", err) 410 s.pages.Notice(w, noticeId, "Failed to delete comment, try again later.") 411 return 412 } 413 414 // optimistic update for htmx response 415 now := time.Now() 416 comment.Body = tangled.MarkupMarkdown{} 417 comment.Deleted = &now 418 419 s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 420 Comment: comment, 421 }) 422}