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