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