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