Monorepo for Tangled tangled.org
5

Configure Feed

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

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