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