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 starCount, err := db.GetStarCount(s.Db, string.AtUri())
150 if err != nil {
151 l.Error("failed to get star count", "err", err)
152 }
153 user := s.OAuth.GetMultiAccountUser(r)
154 isStarred := false
155 if user != nil {
156 isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri())
157 }
158
159 comments, err := db.GetComments(s.Db, orm.FilterEq("subject_uri", string.AtUri()))
160 if err != nil {
161 l.Error("failed to get comments", "err", err)
162 }
163
164 entities := []syntax.ATURI{string.AtUri()}
165 for _, c := range comments {
166 entities = append(entities, c.AtUri())
167 }
168 reactions, err := db.ListReactionDisplayDataMap(s.Db, entities, 20)
169 if err != nil {
170 l.Error("failed to get reactions", "err", err)
171 }
172
173 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool
174 if user != nil {
175 userReactions, err = db.ListReactionStatusMap(s.Db, entities, syntax.DID(user.Did))
176 if err != nil {
177 l.Error("failed to get user reactions", "err", err)
178 }
179 }
180
181 s.Pages.SingleString(w, pages.SingleStringParams{
182 LoggedInUser: user,
183 RenderToggle: renderToggle,
184 ShowRendered: showRendered,
185 String: &string,
186 Stats: string.Stats(),
187 IsStarred: isStarred,
188 StarCount: starCount,
189 Owner: id,
190 CommentList: models.NewCommentList(comments),
191 Reactions: reactions,
192 UserReacted: userReactions,
193 })
194}
195
196func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
197 http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound)
198}
199
200func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
201 l := s.Logger.With("handler", "edit")
202
203 user := s.OAuth.GetMultiAccountUser(r)
204
205 id, ok := r.Context().Value("resolvedId").(identity.Identity)
206 if !ok {
207 l.Error("malformed middleware")
208 w.WriteHeader(http.StatusInternalServerError)
209 return
210 }
211 l = l.With("did", id.DID, "handle", id.Handle)
212
213 rkey := chi.URLParam(r, "rkey")
214 if rkey == "" {
215 l.Error("malformed url, empty rkey")
216 w.WriteHeader(http.StatusBadRequest)
217 return
218 }
219 l = l.With("rkey", rkey)
220
221 // get the string currently being edited
222 all, err := db.GetStrings(
223 s.Db,
224 0,
225 orm.FilterEq("did", id.DID),
226 orm.FilterEq("rkey", rkey),
227 )
228 if err != nil {
229 l.Error("failed to fetch string", "err", err)
230 w.WriteHeader(http.StatusInternalServerError)
231 return
232 }
233 if len(all) != 1 {
234 l.Error("incorrect number of records returned", "len(strings)", len(all))
235 w.WriteHeader(http.StatusInternalServerError)
236 return
237 }
238 first := all[0]
239
240 // verify that the logged in user owns this string
241 if user.Did != id.DID.String() {
242 l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
243 w.WriteHeader(http.StatusUnauthorized)
244 return
245 }
246
247 switch r.Method {
248 case http.MethodGet:
249 // return the form with prefilled fields
250 s.Pages.PutString(w, pages.PutStringParams{
251 LoggedInUser: s.OAuth.GetMultiAccountUser(r),
252 Action: "edit",
253 String: first,
254 })
255 case http.MethodPost:
256 fail := func(msg string, err error) {
257 l.Error(msg, "err", err)
258 s.Pages.Notice(w, "error", msg)
259 }
260
261 filename := r.FormValue("filename")
262 if filename == "" {
263 fail("Empty filename.", nil)
264 return
265 }
266
267 content := r.FormValue("content")
268 if content == "" {
269 fail("Empty contents.", nil)
270 return
271 }
272
273 description := r.FormValue("description")
274
275 // construct new string from form values
276 entry := models.String{
277 Did: first.Did,
278 Rkey: first.Rkey,
279 Filename: filename,
280 Description: description,
281 Contents: content,
282 Created: first.Created,
283 }
284
285 record := entry.AsRecord()
286
287 client, err := s.OAuth.AuthorizedClient(r)
288 if err != nil {
289 fail("Failed to create record.", err)
290 return
291 }
292
293 // first replace the existing record in the PDS
294 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
295 if err != nil {
296 fail("Failed to updated existing record.", err)
297 return
298 }
299 resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
300 Collection: tangled.StringNSID,
301 Repo: entry.Did.String(),
302 Rkey: entry.Rkey,
303 SwapRecord: ex.Cid,
304 Record: &lexutil.LexiconTypeDecoder{
305 Val: &record,
306 },
307 })
308 if err != nil {
309 fail("Failed to updated existing record.", err)
310 return
311 }
312 l := l.With("aturi", resp.Uri)
313 l.Info("edited string")
314
315 // if that went okay, updated the db
316 if err = db.AddString(s.Db, entry); err != nil {
317 fail("Failed to update string.", err)
318 return
319 }
320
321 s.Notifier.EditString(r.Context(), &entry)
322
323 // if that went okay, redir to the string
324 s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey)
325 }
326
327}
328
329func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
330 l := s.Logger.With("handler", "create")
331 user := s.OAuth.GetMultiAccountUser(r)
332
333 switch r.Method {
334 case http.MethodGet:
335 s.Pages.PutString(w, pages.PutStringParams{
336 LoggedInUser: s.OAuth.GetMultiAccountUser(r),
337 Action: "new",
338 })
339 case http.MethodPost:
340 fail := func(msg string, err error) {
341 l.Error(msg, "err", err)
342 s.Pages.Notice(w, "error", msg)
343 }
344
345 filename := r.FormValue("filename")
346 if filename == "" {
347 fail("Empty filename.", nil)
348 return
349 }
350
351 content := r.FormValue("content")
352 if content == "" {
353 fail("Empty contents.", nil)
354 return
355 }
356
357 description := r.FormValue("description")
358
359 string := models.String{
360 Did: syntax.DID(user.Did),
361 Rkey: tid.TID(),
362 Filename: filename,
363 Description: description,
364 Contents: content,
365 Created: time.Now(),
366 }
367
368 record := string.AsRecord()
369
370 client, err := s.OAuth.AuthorizedClient(r)
371 if err != nil {
372 fail("Failed to create record.", err)
373 return
374 }
375
376 resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
377 Collection: tangled.StringNSID,
378 Repo: user.Did,
379 Rkey: string.Rkey,
380 Record: &lexutil.LexiconTypeDecoder{
381 Val: &record,
382 },
383 })
384 if err != nil {
385 fail("Failed to create record.", err)
386 return
387 }
388 l := l.With("aturi", resp.Uri)
389 l.Info("created record")
390
391 // insert into DB
392 if err = db.AddString(s.Db, string); err != nil {
393 fail("Failed to create string.", err)
394 return
395 }
396
397 s.Notifier.NewString(r.Context(), &string)
398
399 // successful
400 s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey)
401 }
402}
403
404func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
405 l := s.Logger.With("handler", "create")
406 user := s.OAuth.GetMultiAccountUser(r)
407 fail := func(msg string, err error) {
408 l.Error(msg, "err", err)
409 s.Pages.Notice(w, "error", msg)
410 }
411
412 id, ok := r.Context().Value("resolvedId").(identity.Identity)
413 if !ok {
414 l.Error("malformed middleware")
415 w.WriteHeader(http.StatusInternalServerError)
416 return
417 }
418 l = l.With("did", id.DID, "handle", id.Handle)
419
420 rkey := chi.URLParam(r, "rkey")
421 if rkey == "" {
422 l.Error("malformed url, empty rkey")
423 w.WriteHeader(http.StatusBadRequest)
424 return
425 }
426
427 if user.Did != id.DID.String() {
428 fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
429 return
430 }
431
432 client, err := s.OAuth.AuthorizedClient(r)
433 if err != nil {
434 fail("Failed to authorize client.", err)
435 return
436 }
437
438 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
439 Collection: tangled.StringNSID,
440 Repo: user.Did,
441 Rkey: rkey,
442 })
443 if err != nil {
444 fail("Failed to delete string record from PDS.", err)
445 return
446 }
447
448 if err := db.DeleteString(
449 s.Db,
450 orm.FilterEq("did", user.Did),
451 orm.FilterEq("rkey", rkey),
452 ); err != nil {
453 fail("Failed to delete string.", err)
454 return
455 }
456
457 s.Notifier.DeleteString(r.Context(), user.Did, rkey)
458
459 s.Pages.HxRedirect(w, "/strings/"+user.Did)
460}