Monorepo for Tangled tangled.org
5

Configure Feed

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

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