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