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