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