This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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