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