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