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