Monorepo for Tangled tangled.org
6

Configure Feed

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

1package state 2 3import ( 4 "bytes" 5 "encoding/json" 6 "fmt" 7 "net/http" 8 "slices" 9 "strconv" 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 lexutil "github.com/bluesky-social/indigo/lex/util" 15 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 "github.com/ipfs/go-cid" 17 "github.com/multiformats/go-multihash" 18 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/db" 21 "tangled.org/core/appview/models" 22 "tangled.org/core/appview/pages" 23 "tangled.org/core/orm" 24 "tangled.org/core/tid" 25) 26 27func (s *State) CommentBodyFragment(w http.ResponseWriter, r *http.Request) { 28 l := s.logger.With("handler", "CommentBodyFragment") 29 user := s.oauth.GetMultiAccountUser(r) 30 31 commentAt := r.URL.Query().Get("aturi") 32 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 33 if err != nil { 34 l.Error("failed to fetch comment", "aturi", commentAt) 35 http.Error(w, "Failed to fetch comment", http.StatusInternalServerError) 36 return 37 } 38 39 reactions, err := db.GetReactionMap(s.db, 20, comment.FeedCommentAtUri()) 40 if err != nil { 41 l.Error("failed to get reactions", "err", err) 42 } 43 var userReactions map[models.ReactionKind]bool 44 if user != nil { 45 userReactions, err = db.GetReactionStatusMap(s.db, syntax.DID(user.Did), comment.FeedCommentAtUri()) 46 if err != nil { 47 l.Error("failed to get user reactions", "err", err) 48 } 49 } 50 51 err = s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 52 Comment: comment, 53 Reactions: reactions, 54 UserReacted: userReactions, 55 }) 56 if err != nil { 57 l.Error("failed to render") 58 } 59} 60 61func (s *State) EditCommentFragment(w http.ResponseWriter, r *http.Request) { 62 l := s.logger.With("handler", "EditCommentFragment") 63 64 commentAt := r.URL.Query().Get("aturi") 65 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 66 if err != nil { 67 l.Error("failed to fetch comment", "aturi", commentAt) 68 http.Error(w, "Failed to fetch comment", http.StatusInternalServerError) 69 return 70 } 71 72 err = s.pages.EditCommentFragment(w, pages.EditCommentFragmentParams{ 73 Comment: comment, 74 }) 75 if err != nil { 76 l.Error("failed to render", "err", err) 77 } 78} 79 80func (s *State) NewReplyCommentFragment(w http.ResponseWriter, r *http.Request) { 81 s.pages.ReplyCommentFragment(w, pages.ReplyCommentFragmentParams{ 82 BaseParams: pages.BaseParamsFromContext(r.Context()), 83 }) 84} 85 86func (s *State) ReplyPlaceholderFragment(w http.ResponseWriter, r *http.Request) { 87 s.pages.ReplyPlaceholderFragment(w, pages.ReplyPlaceholderFragmentParams{ 88 BaseParams: pages.BaseParamsFromContext(r.Context()), 89 }) 90} 91 92func (s *State) NewComment(w http.ResponseWriter, r *http.Request) { 93 l := s.logger.With("handler", "NewComment") 94 user := s.oauth.GetMultiAccountUser(r) 95 96 noticeId := "comment-error" 97 ctx := r.Context() 98 99 body := r.FormValue("body") 100 if body == "" { 101 s.pages.Notice(w, noticeId, "Body is required") 102 return 103 } 104 105 // TODO(boltless): normalize markdown body 106 normalizedBody := body 107 mentions, references, blobCIDs := s.mentionsResolver.Resolve(ctx, body) 108 109 rawBlobs := r.Form["blob"] 110 blobs := make([]*lexutil.LexBlob, 0, len(rawBlobs)) 111 if len(rawBlobs) > 0 { 112 blobs = make([]*lexutil.LexBlob, len(rawBlobs)); 113 for _, rawBlob := range rawBlobs { 114 var blob lexutil.LexBlob 115 if err := json.Unmarshal([]byte(rawBlob), &blob); err != nil { 116 l.Error("failed to decode blob", "err", err) 117 s.pages.Notice(w, noticeId, "Failed to decode blob object. Please try again later.") 118 return 119 } 120 if !slices.Contains(blobCIDs, syntax.CID(blob.Ref.String())) { 121 continue 122 } 123 blobs = append(blobs, &blob) 124 } 125 } 126 127 markdownBody := tangled.MarkupMarkdown{ 128 Text: normalizedBody, 129 Original: &body, 130 Blobs: blobs, 131 } 132 133 subjectUri, err := syntax.ParseATURI(r.FormValue("subject-uri")) 134 if err != nil { 135 l.Warn("invalid subject uri", "err", err) 136 s.pages.Notice(w, noticeId, "Subject URI should be valid AT-URI") 137 return 138 } 139 l = l.With("subject.uri", subjectUri) 140 141 // ingest CID of subject record on-demand. 142 // TODO(boltless): appview should ingest CID of all atproto records 143 var subjectCid syntax.CID 144 if subjectCidRaw := r.FormValue("subject-cid"); subjectCidRaw != "" { 145 subjectCid, err = syntax.ParseCID(subjectCidRaw) 146 if err != nil { 147 l.Warn("invalid subject cid", "err", err) 148 s.pages.Notice(w, noticeId, "Subject CID should be valid CID") 149 return 150 } 151 } else { 152 l.Debug("fetching subject record CID") 153 subjectCid, err = func(uri syntax.ATURI) (syntax.CID, error) { 154 ident, err := s.idResolver.ResolveIdent(ctx, uri.Authority().String()) 155 if err != nil { 156 return "", err 157 } 158 159 xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 160 out, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String()) 161 if err != nil { 162 return "", err 163 } 164 if out.Cid == nil { 165 return "", fmt.Errorf("record CID is empty") 166 } 167 168 cid, err := syntax.ParseCID(*out.Cid) 169 if err != nil { 170 return "", err 171 } 172 173 return cid, nil 174 }(subjectUri) 175 if err != nil { 176 l.Error("failed to backfill subject record", "err", err) 177 s.pages.Notice(w, noticeId, "failed to backfill subject record") 178 return 179 } 180 } 181 l = l.With("subject.cid", subjectCid) 182 183 subject := comatproto.RepoStrongRef{ 184 Uri: subjectUri.String(), 185 Cid: subjectCid.String(), 186 } 187 188 var pullRoundIdx *int 189 if pullRoundIdxRaw := r.FormValue("pull-round-idx"); pullRoundIdxRaw != "" { 190 roundIdx, err := strconv.Atoi(pullRoundIdxRaw) 191 if err != nil { 192 l.Warn("invalid round idx", "err", err) 193 s.pages.Notice(w, noticeId, "pull round index should be valid integer") 194 return 195 } 196 pullRoundIdx = &roundIdx 197 } 198 199 var replyTo *comatproto.RepoStrongRef 200 replyToUriRaw := r.FormValue("reply-to-uri") 201 replyToCidRaw := r.FormValue("reply-to-cid") 202 if replyToUriRaw != "" { 203 replyToUri, err := syntax.ParseATURI(replyToUriRaw) 204 if err != nil { 205 s.pages.Notice(w, noticeId, "reply-to-uri should be valid AT-URI") 206 return 207 } 208 // force replyTo.uri to `sh.tangled.feed.comment` collection, even when they aren't. 209 // we are expecting parent comment will be migrated later. 210 replyToUri = syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", replyToUri.Authority(), tangled.FeedCommentNSID, replyToUri.RecordKey())) 211 212 var replyToCid syntax.CID 213 if replyToCidRaw != "" { 214 replyToCid, err = syntax.ParseCID(replyToCidRaw) 215 if err != nil { 216 s.pages.Notice(w, noticeId, "reply-to-cid should be valid CID") 217 return 218 } 219 } else { 220 // guess parent comment cid 221 subjectComment, err := db.GetComment(s.db, orm.FilterEq("did", replyToUri.Authority()), orm.FilterEq("rkey", replyToUri.RecordKey())) 222 if err != nil { 223 l.Warn("db: failed to query subject comment", "err", err) 224 s.pages.Notice(w, noticeId, "Subject record is unknown.") 225 return 226 } 227 if subjectComment.Deleted != nil { 228 // leave cid empty. reply comment won't pass the schema validation. 229 } else { 230 // guess cid from content 231 c, err := func() (cid.Cid, error) { 232 buf := new(bytes.Buffer) 233 if subjectComment.Subject.Cid == "" { 234 subjectComment.Subject.Cid = subject.Cid 235 } 236 if err := subjectComment.AsRecord().MarshalCBOR(buf); err != nil { 237 return cid.Undef, fmt.Errorf("MarshalCBOR: %w", err) 238 } 239 return cid.NewPrefixV1(cid.DagCBOR, multihash.SHA2_256).Sum(buf.Bytes()) 240 }() 241 if err != nil { 242 l.Warn("cbor: failed to guess parent comment cid", "err", err) 243 s.pages.Notice(w, noticeId, "Parent comment is invalid.") 244 return 245 } 246 replyToCid = syntax.CID(c.String()) 247 } 248 } 249 replyTo = &comatproto.RepoStrongRef{ 250 Uri: replyToUri.String(), 251 Cid: replyToCid.String(), 252 } 253 } 254 255 comment := models.Comment{ 256 Did: syntax.DID(user.Did), 257 Collection: tangled.FeedCommentNSID, 258 Rkey: syntax.RecordKey(tid.TID()), 259 260 Subject: subject, 261 Body: markdownBody, 262 Created: time.Now(), 263 ReplyTo: replyTo, 264 PullRoundIdx: pullRoundIdx, 265 } 266 if err = comment.Validate(); err != nil { 267 l.Error("failed to validate comment", "err", err) 268 s.pages.Notice(w, noticeId, "Failed to create comment.") 269 return 270 } 271 272 client, err := s.oauth.AuthorizedClient(r) 273 if err != nil { 274 l.Error("failed to get authorized client", "err", err) 275 s.pages.Notice(w, noticeId, "Failed to create comment.") 276 return 277 } 278 279 // create a record first 280 out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 281 Collection: comment.Collection.String(), 282 Repo: comment.Did.String(), 283 Rkey: comment.Rkey.String(), 284 Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 285 }) 286 if err != nil { 287 l.Error("failed to create comment", "err", err) 288 s.pages.Notice(w, noticeId, "Failed to create comment.") 289 return 290 } 291 292 comment.Cid = syntax.CID(out.Cid) 293 294 tx, err := s.db.Begin() 295 if err != nil { 296 l.Error("failed to start transaction", "err", err) 297 s.pages.Notice(w, noticeId, "Failed to create comment, try again later.") 298 return 299 } 300 defer tx.Rollback() 301 302 _, err = db.PutComment(tx, &comment, references) 303 if err != nil { 304 l.Error("failed to create comment", "err", err) 305 s.pages.Notice(w, noticeId, "Failed to create comment.") 306 return 307 } 308 309 err = tx.Commit() 310 if err != nil { 311 l.Error("failed to commit transaction", "err", err) 312 s.pages.Notice(w, noticeId, "Failed to create comment, try again later.") 313 return 314 } 315 316 s.notifier.NewComment(ctx, &comment, mentions) 317 318 target, err := s.pages.MakeCommentUrl(ctx, comment.AtUri()) 319 if err != nil { 320 s.pages.HxRefresh(w) 321 } 322 323 s.pages.HxLocation(w, target) 324} 325 326func (s *State) EditComment(w http.ResponseWriter, r *http.Request) { 327 l := s.logger.With("handler", "EditComment") 328 user := s.oauth.GetMultiAccountUser(r) 329 330 noticeId := "comment-error" 331 ctx := r.Context() 332 333 commentAt := r.FormValue("aturi") 334 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 335 if err != nil { 336 l.Error("failed to fetch comment", "aturi", commentAt, "err", err) 337 s.pages.Notice(w, noticeId, "Failed to fetch comment") 338 return 339 } 340 341 if comment.Did.String() != user.Did { 342 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 343 s.pages.Notice(w, noticeId, "You are not the author of this comment") 344 return 345 } 346 347 body := r.FormValue("body") 348 if body == "" { 349 s.pages.Notice(w, noticeId, "Body is required") 350 return 351 } 352 353 // TODO(boltless): normalize markdown body 354 normalizedBody := body 355 _, references, blobCIDs := s.mentionsResolver.Resolve(ctx, body) 356 357 rawBlobs := r.Form["blob"] 358 blobs := make([]*lexutil.LexBlob, 0, len(rawBlobs)) 359 if len(rawBlobs) > 0 { 360 blobs = make([]*lexutil.LexBlob, len(rawBlobs)); 361 for _, rawBlob := range rawBlobs { 362 var blob lexutil.LexBlob 363 if err := json.Unmarshal([]byte(rawBlob), &blob); err != nil { 364 l.Error("failed to decode blob", "err", err) 365 s.pages.Notice(w, noticeId, "Failed to decode blob object. Please try again later.") 366 return 367 } 368 if !slices.Contains(blobCIDs, syntax.CID(blob.Ref.String())) { 369 continue 370 } 371 blobs = append(blobs, &blob) 372 } 373 } 374 375 now := time.Now() 376 newComment := comment 377 newComment.Body = tangled.MarkupMarkdown{ 378 Text: normalizedBody, 379 Original: &body, 380 Blobs: blobs, 381 } 382 newComment.Edited = &now 383 if err := newComment.Validate(); err != nil { 384 l.Error("failed to validate comment", "err", err) 385 s.pages.Notice(w, noticeId, "Failed to update comment.") 386 return 387 } 388 389 client, err := s.oauth.AuthorizedClient(r) 390 if err != nil { 391 l.Error("failed to get authorized client", "err", err) 392 s.pages.Notice(w, noticeId, "Failed to create comment. try again later.") 393 return 394 } 395 396 // update the record first 397 exCid := comment.Cid.String() 398 out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 399 Collection: newComment.Collection.String(), 400 Repo: newComment.Did.String(), 401 Rkey: newComment.Rkey.String(), 402 SwapRecord: &exCid, 403 Record: &lexutil.LexiconTypeDecoder{ 404 Val: newComment.AsRecord(), 405 }, 406 }) 407 if err != nil { 408 l.Error("failed to update comment", "err", err) 409 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 410 return 411 } 412 413 newComment.Cid = syntax.CID(out.Cid) 414 415 tx, err := s.db.Begin() 416 if err != nil { 417 l.Error("failed to start transaction", "err", err) 418 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 419 return 420 } 421 defer tx.Rollback() 422 423 _, err = db.PutComment(tx, &newComment, references) 424 if err != nil { 425 l.Error("failed to perform update-description query", "err", err) 426 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 427 return 428 } 429 err = tx.Commit() 430 if err != nil { 431 l.Error("failed to commit transaction", "err", err) 432 s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 433 return 434 } 435 436 reactions, err := db.GetReactionMap(s.db, 20, comment.FeedCommentAtUri()) 437 if err != nil { 438 l.Error("failed to get reactions", "err", err) 439 } 440 userReactions, err := db.GetReactionStatusMap(s.db, syntax.DID(user.Did), comment.FeedCommentAtUri()) 441 if err != nil { 442 l.Error("failed to get user reactions", "err", err) 443 } 444 445 // TODO: return full comment fragment so we can update comment header too 446 s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 447 Comment: newComment, 448 Reactions: reactions, 449 UserReacted: userReactions, 450 }) 451} 452 453func (s *State) DeleteComment(w http.ResponseWriter, r *http.Request) { 454 l := s.logger.With("handler", "DeleteComment") 455 user := s.oauth.GetMultiAccountUser(r) 456 457 noticeId := "comment" 458 ctx := r.Context() 459 460 commentAt := r.URL.Query().Get("aturi") 461 comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 462 if err != nil { 463 l.Error("failed to fetch comment", "aturi", commentAt) 464 s.pages.Notice(w, noticeId, "Failed to fetch comment.") 465 return 466 } 467 468 if comment.Did.String() != user.Did { 469 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 470 s.pages.Notice(w, noticeId, "you are not the author of this comment") 471 return 472 } 473 474 if comment.Deleted != nil { 475 s.pages.Notice(w, noticeId, "Comment already deleted") 476 return 477 } 478 479 client, err := s.oauth.AuthorizedClient(r) 480 if err != nil { 481 l.Error("failed to get authorized client", "err", err) 482 s.pages.Notice(w, "comment", "Failed to delete comment.") 483 return 484 } 485 _, err = comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 486 Collection: comment.Collection.String(), 487 Repo: comment.Did.String(), 488 Rkey: comment.Rkey.String(), 489 }) 490 if err != nil { 491 l.Error("failed to delete from PDS", "err", err) 492 s.pages.Notice(w, noticeId, "Failed to delete comment, try again later.") 493 return 494 } 495 496 // optimistic update for htmx response 497 now := time.Now() 498 comment.Body = tangled.MarkupMarkdown{} 499 comment.Deleted = &now 500 501 s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 502 Comment: comment, 503 }) 504 s.pages.CommentHeaderFragment(w, pages.CommentHeaderFragmentParams{ 505 Comment: comment, 506 HxSwapOob: true, 507 }) 508}