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