Monorepo for Tangled
tangled.org
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}