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 456 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 comments, err := db.GetComments(s.Db, orm.FilterEq("subject_uri", string.AtUri())) 161 if err != nil { 162 l.Error("failed to get comments", "err", err) 163 } 164 165 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 166 if user != nil { 167 var participants []syntax.DID 168 for _, c := range comments { 169 participants = append(participants, c.Did) 170 } 171 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.Db, syntax.DID(user.Did), participants) 172 if err != nil { 173 l.Error("failed to fetch vouch relationships", "err", err) 174 } 175 } 176 177 err = s.Pages.SingleString(w, pages.SingleStringParams{ 178 LoggedInUser: user, 179 RenderToggle: renderToggle, 180 ShowRendered: showRendered, 181 String: &string, 182 Stats: string.Stats(), 183 IsStarred: isStarred, 184 StarCount: starCount, 185 Owner: id, 186 CommentList: models.NewCommentList(comments), 187 188 VouchRelationships: vouchRelationships, 189 }) 190} 191 192func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 193 http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 194} 195 196func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 197 l := s.Logger.With("handler", "edit") 198 199 user := s.OAuth.GetMultiAccountUser(r) 200 201 id, ok := r.Context().Value("resolvedId").(identity.Identity) 202 if !ok { 203 l.Error("malformed middleware") 204 w.WriteHeader(http.StatusInternalServerError) 205 return 206 } 207 l = l.With("did", id.DID, "handle", id.Handle) 208 209 rkey := chi.URLParam(r, "rkey") 210 if rkey == "" { 211 l.Error("malformed url, empty rkey") 212 w.WriteHeader(http.StatusBadRequest) 213 return 214 } 215 l = l.With("rkey", rkey) 216 217 // get the string currently being edited 218 all, err := db.GetStrings( 219 s.Db, 220 0, 221 orm.FilterEq("did", id.DID), 222 orm.FilterEq("rkey", rkey), 223 ) 224 if err != nil { 225 l.Error("failed to fetch string", "err", err) 226 w.WriteHeader(http.StatusInternalServerError) 227 return 228 } 229 if len(all) != 1 { 230 l.Error("incorrect number of records returned", "len(strings)", len(all)) 231 w.WriteHeader(http.StatusInternalServerError) 232 return 233 } 234 first := all[0] 235 236 // verify that the logged in user owns this string 237 if user.Did != id.DID.String() { 238 l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 239 w.WriteHeader(http.StatusUnauthorized) 240 return 241 } 242 243 switch r.Method { 244 case http.MethodGet: 245 // return the form with prefilled fields 246 s.Pages.PutString(w, pages.PutStringParams{ 247 LoggedInUser: s.OAuth.GetMultiAccountUser(r), 248 Action: "edit", 249 String: first, 250 }) 251 case http.MethodPost: 252 fail := func(msg string, err error) { 253 l.Error(msg, "err", err) 254 s.Pages.Notice(w, "error", msg) 255 } 256 257 filename := r.FormValue("filename") 258 if filename == "" { 259 fail("Empty filename.", nil) 260 return 261 } 262 263 content := r.FormValue("content") 264 if content == "" { 265 fail("Empty contents.", nil) 266 return 267 } 268 269 description := r.FormValue("description") 270 271 // construct new string from form values 272 entry := models.String{ 273 Did: first.Did, 274 Rkey: first.Rkey, 275 Filename: filename, 276 Description: description, 277 Contents: content, 278 Created: first.Created, 279 } 280 281 record := entry.AsRecord() 282 283 client, err := s.OAuth.AuthorizedClient(r) 284 if err != nil { 285 fail("Failed to create record.", err) 286 return 287 } 288 289 // first replace the existing record in the PDS 290 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 291 if err != nil { 292 fail("Failed to updated existing record.", err) 293 return 294 } 295 resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 296 Collection: tangled.StringNSID, 297 Repo: entry.Did.String(), 298 Rkey: entry.Rkey, 299 SwapRecord: ex.Cid, 300 Record: &lexutil.LexiconTypeDecoder{ 301 Val: &record, 302 }, 303 }) 304 if err != nil { 305 fail("Failed to updated existing record.", err) 306 return 307 } 308 l := l.With("aturi", resp.Uri) 309 l.Info("edited string") 310 311 // if that went okay, updated the db 312 if err = db.AddString(s.Db, entry); err != nil { 313 fail("Failed to update string.", err) 314 return 315 } 316 317 s.Notifier.EditString(r.Context(), &entry) 318 319 // if that went okay, redir to the string 320 s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 321 } 322 323} 324 325func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 326 l := s.Logger.With("handler", "create") 327 user := s.OAuth.GetMultiAccountUser(r) 328 329 switch r.Method { 330 case http.MethodGet: 331 s.Pages.PutString(w, pages.PutStringParams{ 332 LoggedInUser: s.OAuth.GetMultiAccountUser(r), 333 Action: "new", 334 }) 335 case http.MethodPost: 336 fail := func(msg string, err error) { 337 l.Error(msg, "err", err) 338 s.Pages.Notice(w, "error", msg) 339 } 340 341 filename := r.FormValue("filename") 342 if filename == "" { 343 fail("Empty filename.", nil) 344 return 345 } 346 347 content := r.FormValue("content") 348 if content == "" { 349 fail("Empty contents.", nil) 350 return 351 } 352 353 description := r.FormValue("description") 354 355 string := models.String{ 356 Did: syntax.DID(user.Did), 357 Rkey: tid.TID(), 358 Filename: filename, 359 Description: description, 360 Contents: content, 361 Created: time.Now(), 362 } 363 364 record := string.AsRecord() 365 366 client, err := s.OAuth.AuthorizedClient(r) 367 if err != nil { 368 fail("Failed to create record.", err) 369 return 370 } 371 372 resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 373 Collection: tangled.StringNSID, 374 Repo: user.Did, 375 Rkey: string.Rkey, 376 Record: &lexutil.LexiconTypeDecoder{ 377 Val: &record, 378 }, 379 }) 380 if err != nil { 381 fail("Failed to create record.", err) 382 return 383 } 384 l := l.With("aturi", resp.Uri) 385 l.Info("created record") 386 387 // insert into DB 388 if err = db.AddString(s.Db, string); err != nil { 389 fail("Failed to create string.", err) 390 return 391 } 392 393 s.Notifier.NewString(r.Context(), &string) 394 395 // successful 396 s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 397 } 398} 399 400func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 401 l := s.Logger.With("handler", "create") 402 user := s.OAuth.GetMultiAccountUser(r) 403 fail := func(msg string, err error) { 404 l.Error(msg, "err", err) 405 s.Pages.Notice(w, "error", msg) 406 } 407 408 id, ok := r.Context().Value("resolvedId").(identity.Identity) 409 if !ok { 410 l.Error("malformed middleware") 411 w.WriteHeader(http.StatusInternalServerError) 412 return 413 } 414 l = l.With("did", id.DID, "handle", id.Handle) 415 416 rkey := chi.URLParam(r, "rkey") 417 if rkey == "" { 418 l.Error("malformed url, empty rkey") 419 w.WriteHeader(http.StatusBadRequest) 420 return 421 } 422 423 if user.Did != id.DID.String() { 424 fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 425 return 426 } 427 428 client, err := s.OAuth.AuthorizedClient(r) 429 if err != nil { 430 fail("Failed to authorize client.", err) 431 return 432 } 433 434 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 435 Collection: tangled.StringNSID, 436 Repo: user.Did, 437 Rkey: rkey, 438 }) 439 if err != nil { 440 fail("Failed to delete string record from PDS.", err) 441 return 442 } 443 444 if err := db.DeleteString( 445 s.Db, 446 orm.FilterEq("did", user.Did), 447 orm.FilterEq("rkey", rkey), 448 ); err != nil { 449 fail("Failed to delete string.", err) 450 return 451 } 452 453 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 454 455 s.Pages.HxRedirect(w, "/strings/"+user.Did) 456}