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