forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
at master 10 kB view raw
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}