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