Monorepo for Tangled
tangled.org
1package strings
2
3import (
4 "fmt"
5 "log/slog"
6 "net/http"
7 "path"
8 "strconv"
9 "time"
10
11 "tangled.org/core/api/tangled"
12 "tangled.org/core/appview/db"
13 "tangled.org/core/appview/middleware"
14 "tangled.org/core/appview/models"
15 "tangled.org/core/appview/notify"
16 "tangled.org/core/appview/oauth"
17 "tangled.org/core/appview/pages"
18 "tangled.org/core/appview/pages/markup"
19 "tangled.org/core/idresolver"
20 "tangled.org/core/orm"
21 "tangled.org/core/tid"
22
23 "github.com/bluesky-social/indigo/api/atproto"
24 "github.com/bluesky-social/indigo/atproto/identity"
25 "github.com/bluesky-social/indigo/atproto/syntax"
26 "github.com/go-chi/chi/v5"
27
28 comatproto "github.com/bluesky-social/indigo/api/atproto"
29 lexutil "github.com/bluesky-social/indigo/lex/util"
30)
31
32type Strings struct {
33 Db *db.DB
34 OAuth *oauth.OAuth
35 Pages *pages.Pages
36 IdResolver *idresolver.Resolver
37 Logger *slog.Logger
38 Notifier notify.Notifier
39}
40
41func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
42 r := chi.NewRouter()
43
44 r.
45 Get("/", s.timeline)
46
47 r.
48 With(mw.ResolveIdent()).
49 Route("/{user}", func(r chi.Router) {
50 r.Get("/", s.dashboard)
51
52 r.Route("/{rkey}", func(r chi.Router) {
53 r.Get("/", s.contents)
54 r.Delete("/", s.delete)
55 r.Get("/raw", s.contents)
56 r.Get("/edit", s.edit)
57 r.Post("/edit", s.edit)
58 })
59 })
60
61 r.
62 With(middleware.AuthMiddleware(s.OAuth)).
63 Route("/new", func(r chi.Router) {
64 r.Get("/", s.create)
65 r.Post("/", s.create)
66 })
67
68 return r
69}
70
71func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) {
72 l := s.Logger.With("handler", "timeline")
73
74 strings, err := db.GetStrings(s.Db, 50)
75 if err != nil {
76 l.Error("failed to fetch string", "err", err)
77 w.WriteHeader(http.StatusInternalServerError)
78 return
79 }
80
81 s.Pages.StringsTimeline(w, pages.StringTimelineParams{
82 LoggedInUser: s.OAuth.GetMultiAccountUser(r),
83 Strings: strings,
84 })
85}
86
87func (s *Strings) contents(w http.ResponseWriter, r *http.Request) {
88 l := s.Logger.With("handler", "contents")
89
90 id, ok := r.Context().Value("resolvedId").(identity.Identity)
91 if !ok {
92 l.Error("malformed middleware")
93 w.WriteHeader(http.StatusInternalServerError)
94 return
95 }
96 l = l.With("did", id.DID, "handle", id.Handle)
97
98 rkey := chi.URLParam(r, "rkey")
99 if rkey == "" {
100 l.Error("malformed url, empty rkey")
101 w.WriteHeader(http.StatusBadRequest)
102 return
103 }
104 l = l.With("rkey", rkey)
105
106 strings, err := db.GetStrings(
107 s.Db,
108 0,
109 orm.FilterEq("did", id.DID),
110 orm.FilterEq("rkey", rkey),
111 )
112 if err != nil {
113 l.Error("failed to fetch string", "err", err)
114 w.WriteHeader(http.StatusInternalServerError)
115 return
116 }
117 if len(strings) < 1 {
118 l.Error("string not found")
119 s.Pages.Error404(w)
120 return
121 }
122 if len(strings) != 1 {
123 l.Error("incorrect number of records returned", "len(strings)", len(strings))
124 w.WriteHeader(http.StatusInternalServerError)
125 return
126 }
127 string := strings[0]
128
129 if path.Base(r.URL.Path) == "raw" {
130 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
131 if string.Filename != "" {
132 w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename))
133 }
134 w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents)))
135
136 _, err = w.Write([]byte(string.Contents))
137 if err != nil {
138 l.Error("failed to write raw response", "err", err)
139 }
140 return
141 }
142
143 var showRendered, renderToggle bool
144 if markup.GetFormat(string.Filename) == markup.FormatMarkdown {
145 renderToggle = true
146 showRendered = r.URL.Query().Get("code") != "true"
147 }
148
149 stringUri := string.AtUri().String()
150 starCount, err := db.GetStarCount(s.Db, models.StarSubjectString, stringUri)
151 if err != nil {
152 l.Error("failed to get star count", "err", err)
153 }
154 user := s.OAuth.GetMultiAccountUser(r)
155 isStarred := false
156 if user != nil {
157 isStarred = db.GetStarStatus(s.Db, user.Did, stringUri)
158 }
159
160 comments, err := db.GetComments(s.Db, orm.FilterEq("subject_uri", string.AtUri()))
161 if err != nil {
162 l.Error("failed to get comments", "err", err)
163 }
164
165 var entities []syntax.ATURI
166 for _, c := range comments {
167 entities = append(entities, c.AtUri())
168 }
169 reactions, err := db.ListReactionDisplayDataMap(s.Db, entities, 20)
170 if err != nil {
171 l.Error("failed to get reactions", "err", err)
172 }
173
174 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool
175 if user != nil {
176 userReactions, err = db.ListReactionStatusMap(s.Db, entities, syntax.DID(user.Did))
177 if err != nil {
178 l.Error("failed to get user reactions", "err", err)
179 }
180 }
181
182 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
183 if user != nil {
184 var participants []syntax.DID
185 for _, c := range comments {
186 participants = append(participants, c.Did)
187 }
188 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.Db, syntax.DID(user.Did), participants)
189 if err != nil {
190 l.Error("failed to fetch vouch relationships", "err", err)
191 }
192 }
193
194 err = s.Pages.SingleString(w, pages.SingleStringParams{
195 LoggedInUser: user,
196 RenderToggle: renderToggle,
197 ShowRendered: showRendered,
198 String: &string,
199 Stats: string.Stats(),
200 IsStarred: isStarred,
201 StarCount: starCount,
202 Owner: id,
203 CommentList: models.NewCommentList(comments),
204
205 Reactions: reactions,
206 UserReacted: userReactions,
207 VouchRelationships: vouchRelationships,
208 })
209}
210
211func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
212 http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound)
213}
214
215func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
216 l := s.Logger.With("handler", "edit")
217
218 user := s.OAuth.GetMultiAccountUser(r)
219
220 id, ok := r.Context().Value("resolvedId").(identity.Identity)
221 if !ok {
222 l.Error("malformed middleware")
223 w.WriteHeader(http.StatusInternalServerError)
224 return
225 }
226 l = l.With("did", id.DID, "handle", id.Handle)
227
228 rkey := chi.URLParam(r, "rkey")
229 if rkey == "" {
230 l.Error("malformed url, empty rkey")
231 w.WriteHeader(http.StatusBadRequest)
232 return
233 }
234 l = l.With("rkey", rkey)
235
236 // get the string currently being edited
237 all, err := db.GetStrings(
238 s.Db,
239 0,
240 orm.FilterEq("did", id.DID),
241 orm.FilterEq("rkey", rkey),
242 )
243 if err != nil {
244 l.Error("failed to fetch string", "err", err)
245 w.WriteHeader(http.StatusInternalServerError)
246 return
247 }
248 if len(all) != 1 {
249 l.Error("incorrect number of records returned", "len(strings)", len(all))
250 w.WriteHeader(http.StatusInternalServerError)
251 return
252 }
253 first := all[0]
254
255 // verify that the logged in user owns this string
256 if user.Did != id.DID.String() {
257 l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
258 w.WriteHeader(http.StatusUnauthorized)
259 return
260 }
261
262 switch r.Method {
263 case http.MethodGet:
264 // return the form with prefilled fields
265 s.Pages.PutString(w, pages.PutStringParams{
266 LoggedInUser: s.OAuth.GetMultiAccountUser(r),
267 Action: "edit",
268 String: first,
269 })
270 case http.MethodPost:
271 fail := func(msg string, err error) {
272 l.Error(msg, "err", err)
273 s.Pages.Notice(w, "error", msg)
274 }
275
276 filename := r.FormValue("filename")
277 if filename == "" {
278 fail("Empty filename.", nil)
279 return
280 }
281
282 content := r.FormValue("content")
283 if content == "" {
284 fail("Empty contents.", nil)
285 return
286 }
287
288 description := r.FormValue("description")
289
290 // construct new string from form values
291 entry := models.String{
292 Did: first.Did,
293 Rkey: first.Rkey,
294 Filename: filename,
295 Description: description,
296 Contents: content,
297 Created: first.Created,
298 }
299
300 record := entry.AsRecord()
301
302 client, err := s.OAuth.AuthorizedClient(r)
303 if err != nil {
304 fail("Failed to create record.", err)
305 return
306 }
307
308 // first replace the existing record in the PDS
309 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
310 if err != nil {
311 fail("Failed to updated existing record.", err)
312 return
313 }
314 resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
315 Collection: tangled.StringNSID,
316 Repo: entry.Did.String(),
317 Rkey: entry.Rkey,
318 SwapRecord: ex.Cid,
319 Record: &lexutil.LexiconTypeDecoder{
320 Val: &record,
321 },
322 })
323 if err != nil {
324 fail("Failed to updated existing record.", err)
325 return
326 }
327 l := l.With("aturi", resp.Uri)
328 l.Info("edited string")
329
330 // if that went okay, updated the db
331 if err = db.AddString(s.Db, entry); err != nil {
332 fail("Failed to update string.", err)
333 return
334 }
335
336 s.Notifier.EditString(r.Context(), &entry)
337
338 // if that went okay, redir to the string
339 s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey)
340 }
341
342}
343
344func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
345 l := s.Logger.With("handler", "create")
346 user := s.OAuth.GetMultiAccountUser(r)
347
348 switch r.Method {
349 case http.MethodGet:
350 s.Pages.PutString(w, pages.PutStringParams{
351 LoggedInUser: s.OAuth.GetMultiAccountUser(r),
352 Action: "new",
353 })
354 case http.MethodPost:
355 fail := func(msg string, err error) {
356 l.Error(msg, "err", err)
357 s.Pages.Notice(w, "error", msg)
358 }
359
360 filename := r.FormValue("filename")
361 if filename == "" {
362 fail("Empty filename.", nil)
363 return
364 }
365
366 content := r.FormValue("content")
367 if content == "" {
368 fail("Empty contents.", nil)
369 return
370 }
371
372 description := r.FormValue("description")
373
374 string := models.String{
375 Did: syntax.DID(user.Did),
376 Rkey: tid.TID(),
377 Filename: filename,
378 Description: description,
379 Contents: content,
380 Created: time.Now(),
381 }
382
383 record := string.AsRecord()
384
385 client, err := s.OAuth.AuthorizedClient(r)
386 if err != nil {
387 fail("Failed to create record.", err)
388 return
389 }
390
391 resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
392 Collection: tangled.StringNSID,
393 Repo: user.Did,
394 Rkey: string.Rkey,
395 Record: &lexutil.LexiconTypeDecoder{
396 Val: &record,
397 },
398 })
399 if err != nil {
400 fail("Failed to create record.", err)
401 return
402 }
403 l := l.With("aturi", resp.Uri)
404 l.Info("created record")
405
406 // insert into DB
407 if err = db.AddString(s.Db, string); err != nil {
408 fail("Failed to create string.", err)
409 return
410 }
411
412 s.Notifier.NewString(r.Context(), &string)
413
414 // successful
415 s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey)
416 }
417}
418
419func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
420 l := s.Logger.With("handler", "create")
421 user := s.OAuth.GetMultiAccountUser(r)
422 fail := func(msg string, err error) {
423 l.Error(msg, "err", err)
424 s.Pages.Notice(w, "error", msg)
425 }
426
427 id, ok := r.Context().Value("resolvedId").(identity.Identity)
428 if !ok {
429 l.Error("malformed middleware")
430 w.WriteHeader(http.StatusInternalServerError)
431 return
432 }
433 l = l.With("did", id.DID, "handle", id.Handle)
434
435 rkey := chi.URLParam(r, "rkey")
436 if rkey == "" {
437 l.Error("malformed url, empty rkey")
438 w.WriteHeader(http.StatusBadRequest)
439 return
440 }
441
442 if user.Did != id.DID.String() {
443 fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
444 return
445 }
446
447 client, err := s.OAuth.AuthorizedClient(r)
448 if err != nil {
449 fail("Failed to authorize client.", err)
450 return
451 }
452
453 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
454 Collection: tangled.StringNSID,
455 Repo: user.Did,
456 Rkey: rkey,
457 })
458 if err != nil {
459 fail("Failed to delete string record from PDS.", err)
460 return
461 }
462
463 if err := db.DeleteString(
464 s.Db,
465 orm.FilterEq("did", user.Did),
466 orm.FilterEq("rkey", rkey),
467 ); err != nil {
468 fail("Failed to delete string.", err)
469 return
470 }
471
472 s.Notifier.DeleteString(r.Context(), user.Did, rkey)
473
474 s.Pages.HxRedirect(w, "/strings/"+user.Did)
475}