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