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