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