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/notify"
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 Notifier notify.Notifier
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 http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound)
166}
167
168func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
169 l := s.Logger.With("handler", "edit")
170
171 user := s.OAuth.GetUser(r)
172
173 id, ok := r.Context().Value("resolvedId").(identity.Identity)
174 if !ok {
175 l.Error("malformed middleware")
176 w.WriteHeader(http.StatusInternalServerError)
177 return
178 }
179 l = l.With("did", id.DID, "handle", id.Handle)
180
181 rkey := chi.URLParam(r, "rkey")
182 if rkey == "" {
183 l.Error("malformed url, empty rkey")
184 w.WriteHeader(http.StatusBadRequest)
185 return
186 }
187 l = l.With("rkey", rkey)
188
189 // get the string currently being edited
190 all, err := db.GetStrings(
191 s.Db,
192 0,
193 db.FilterEq("did", id.DID),
194 db.FilterEq("rkey", rkey),
195 )
196 if err != nil {
197 l.Error("failed to fetch string", "err", err)
198 w.WriteHeader(http.StatusInternalServerError)
199 return
200 }
201 if len(all) != 1 {
202 l.Error("incorrect number of records returned", "len(strings)", len(all))
203 w.WriteHeader(http.StatusInternalServerError)
204 return
205 }
206 first := all[0]
207
208 // verify that the logged in user owns this string
209 if user.Did != id.DID.String() {
210 l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
211 w.WriteHeader(http.StatusUnauthorized)
212 return
213 }
214
215 switch r.Method {
216 case http.MethodGet:
217 // return the form with prefilled fields
218 s.Pages.PutString(w, pages.PutStringParams{
219 LoggedInUser: s.OAuth.GetUser(r),
220 Action: "edit",
221 String: first,
222 })
223 case http.MethodPost:
224 fail := func(msg string, err error) {
225 l.Error(msg, "err", err)
226 s.Pages.Notice(w, "error", msg)
227 }
228
229 filename := r.FormValue("filename")
230 if filename == "" {
231 fail("Empty filename.", nil)
232 return
233 }
234
235 content := r.FormValue("content")
236 if content == "" {
237 fail("Empty contents.", nil)
238 return
239 }
240
241 description := r.FormValue("description")
242
243 // construct new string from form values
244 entry := db.String{
245 Did: first.Did,
246 Rkey: first.Rkey,
247 Filename: filename,
248 Description: description,
249 Contents: content,
250 Created: first.Created,
251 }
252
253 record := entry.AsRecord()
254
255 client, err := s.OAuth.AuthorizedClient(r)
256 if err != nil {
257 fail("Failed to create record.", err)
258 return
259 }
260
261 // first replace the existing record in the PDS
262 ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
263 if err != nil {
264 fail("Failed to updated existing record.", err)
265 return
266 }
267 resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
268 Collection: tangled.StringNSID,
269 Repo: entry.Did.String(),
270 Rkey: entry.Rkey,
271 SwapRecord: ex.Cid,
272 Record: &lexutil.LexiconTypeDecoder{
273 Val: &record,
274 },
275 })
276 if err != nil {
277 fail("Failed to updated existing record.", err)
278 return
279 }
280 l := l.With("aturi", resp.Uri)
281 l.Info("edited string")
282
283 // if that went okay, updated the db
284 if err = db.AddString(s.Db, entry); err != nil {
285 fail("Failed to update string.", err)
286 return
287 }
288
289 s.Notifier.EditString(r.Context(), &entry)
290
291 // if that went okay, redir to the string
292 s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
293 }
294
295}
296
297func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
298 l := s.Logger.With("handler", "create")
299 user := s.OAuth.GetUser(r)
300
301 switch r.Method {
302 case http.MethodGet:
303 s.Pages.PutString(w, pages.PutStringParams{
304 LoggedInUser: s.OAuth.GetUser(r),
305 Action: "new",
306 })
307 case http.MethodPost:
308 fail := func(msg string, err error) {
309 l.Error(msg, "err", err)
310 s.Pages.Notice(w, "error", msg)
311 }
312
313 filename := r.FormValue("filename")
314 if filename == "" {
315 fail("Empty filename.", nil)
316 return
317 }
318
319 content := r.FormValue("content")
320 if content == "" {
321 fail("Empty contents.", nil)
322 return
323 }
324
325 description := r.FormValue("description")
326
327 string := db.String{
328 Did: syntax.DID(user.Did),
329 Rkey: tid.TID(),
330 Filename: filename,
331 Description: description,
332 Contents: content,
333 Created: time.Now(),
334 }
335
336 record := string.AsRecord()
337
338 client, err := s.OAuth.AuthorizedClient(r)
339 if err != nil {
340 fail("Failed to create record.", err)
341 return
342 }
343
344 resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
345 Collection: tangled.StringNSID,
346 Repo: user.Did,
347 Rkey: string.Rkey,
348 Record: &lexutil.LexiconTypeDecoder{
349 Val: &record,
350 },
351 })
352 if err != nil {
353 fail("Failed to create record.", err)
354 return
355 }
356 l := l.With("aturi", resp.Uri)
357 l.Info("created record")
358
359 // insert into DB
360 if err = db.AddString(s.Db, string); err != nil {
361 fail("Failed to create string.", err)
362 return
363 }
364
365 s.Notifier.NewString(r.Context(), &string)
366
367 // successful
368 s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
369 }
370}
371
372func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
373 l := s.Logger.With("handler", "create")
374 user := s.OAuth.GetUser(r)
375 fail := func(msg string, err error) {
376 l.Error(msg, "err", err)
377 s.Pages.Notice(w, "error", msg)
378 }
379
380 id, ok := r.Context().Value("resolvedId").(identity.Identity)
381 if !ok {
382 l.Error("malformed middleware")
383 w.WriteHeader(http.StatusInternalServerError)
384 return
385 }
386 l = l.With("did", id.DID, "handle", id.Handle)
387
388 rkey := chi.URLParam(r, "rkey")
389 if rkey == "" {
390 l.Error("malformed url, empty rkey")
391 w.WriteHeader(http.StatusBadRequest)
392 return
393 }
394
395 if user.Did != id.DID.String() {
396 fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
397 return
398 }
399
400 if err := db.DeleteString(
401 s.Db,
402 db.FilterEq("did", user.Did),
403 db.FilterEq("rkey", rkey),
404 ); err != nil {
405 fail("Failed to delete string.", err)
406 return
407 }
408
409 s.Notifier.DeleteString(r.Context(), user.Did, rkey)
410
411 s.Pages.HxRedirect(w, "/strings/"+user.Handle)
412}
413
414func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
415}