Monorepo for Tangled tangled.org
10

Configure Feed

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

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