Monorepo for Tangled tangled.org

appview/strings: init strings router with basic CRUD routes

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li 80f5ba4d 4f50d6c3

verified
Changed files
+468
appview
state
strings
+19
appview/state/router.go
··· 17 17 "tangled.sh/tangled.sh/core/appview/signup" 18 18 "tangled.sh/tangled.sh/core/appview/spindles" 19 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 20 + avstrings "tangled.sh/tangled.sh/core/appview/strings" 20 21 "tangled.sh/tangled.sh/core/log" 21 22 ) 22 23 ··· 136 137 }) 137 138 138 139 r.Mount("/settings", s.SettingsRouter()) 140 + r.Mount("/strings", s.StringsRouter(mw)) 139 141 r.Mount("/knots", s.KnotsRouter(mw)) 140 142 r.Mount("/spindles", s.SpindlesRouter()) 141 143 r.Mount("/signup", s.SignupRouter()) ··· 199 201 } 200 202 201 203 return knots.Router(mw) 204 + } 205 + 206 + func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 207 + logger := log.New("strings") 208 + 209 + strs := &avstrings.Strings{ 210 + Db: s.db, 211 + OAuth: s.oauth, 212 + Pages: s.pages, 213 + Config: s.config, 214 + Enforcer: s.enforcer, 215 + IdResolver: s.idResolver, 216 + Knotstream: s.knotstream, 217 + Logger: logger, 218 + } 219 + 220 + return strs.Router(mw) 202 221 } 203 222 204 223 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
+449
appview/strings/strings.go
··· 1 + package strings 2 + 3 + import ( 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 + 32 + type 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 + 43 + func (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 + 73 + func (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 + 139 + func (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 + 199 + func (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 + 330 + func (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 + 408 + func (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 + 448 + func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 449 + }