Monorepo for Tangled tangled.org
812
fork

Configure Feed

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

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