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