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