nice clean recipes pear.dunkirk.sh
recipes
1
fork

Configure Feed

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

at main 662 lines 17 kB view raw
1package main 2 3import ( 4 "encoding/json" 5 "flag" 6 "fmt" 7 "html/template" 8 "io/fs" 9 "sync" 10 "log" 11 "net/http" 12 "net/url" 13 "strconv" 14 "os" 15 "strings" 16 "time" 17 18 "github.com/go-chi/chi/v5" 19 "github.com/go-chi/chi/v5/middleware" 20 "github.com/joho/godotenv" 21 22 "tangled.org/dunkirk.sh/pear/internal/cache" 23 "tangled.org/dunkirk.sh/pear/internal/cooklang" 24 "tangled.org/dunkirk.sh/pear/internal/extract" 25 "tangled.org/dunkirk.sh/pear/internal/models" 26 "tangled.org/dunkirk.sh/pear/ui" 27) 28 29var gitHash = "dev" 30 31func main() { 32 godotenv.Load() 33 port := flag.Int("port", 3000, "port to listen on") 34 dbPath := flag.String("db", "pear.db", "path to SQLite database") 35 baseURL := flag.String("base-url", "", "base URL of this service") 36 flag.Parse() 37 38 if *baseURL == "" { 39 *baseURL = os.Getenv("BASE_URL") 40 } 41 if *baseURL == "" { 42 *baseURL = fmt.Sprintf("http://localhost:%d", *port) 43 } 44 45 c, err := cache.New(*dbPath) 46 if err != nil { 47 log.Fatalf("opening cache: %v", err) 48 } 49 defer c.Close() 50 51 tmpl, err := template.New("").Funcs(template.FuncMap{ 52 "fmtDuration": fmtDuration, 53 "isoToSeconds": isoToSeconds, 54 "cleanSource": cleanSource, 55 "renderStep": renderStep, 56 "cookHighlight": cookHighlight, 57 "groupIngredients": groupIngredients, 58 "json": func(v string) string { b, _ := json.Marshal(v); return string(b) }, 59 "trimProto": func(s string) string { return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") }, 60 "shortHash": func(s string) string { if len(s) > 7 { return s[:7] }; return s }, 61 }).ParseFS(ui.Templates, "templates/*.html") 62 if err != nil { 63 log.Fatalf("parsing templates: %v", err) 64 } 65 66 srv := &Server{ 67 pipeline: extract.NewPipeline(), 68 cache: c, 69 templates: tmpl, 70 baseURL: *baseURL, 71 gitHash: gitHash, 72 pending: make(map[string]chan extractResult), 73 failed: make(map[string]failedEntry), 74 } 75 76 r := chi.NewRouter() 77 r.Use(middleware.Logger) 78 r.Use(middleware.Recoverer) 79 r.Use(middleware.CleanPath) 80 81 staticContent, err := fs.Sub(ui.Static, "static") 82 if err != nil { 83 log.Fatalf("failed to get static fs: %v", err) 84 } 85 86 r.Get("/", srv.handleIndex) 87 r.Get("/cook", srv.handleCookView) 88 r.Get("/export.cook", srv.handleCookExport) 89 r.Get("/recipe", srv.handleRecipeQuery) 90 r.Get("/userscript", srv.handleUserscript) 91 r.Get("/status", srv.handleStatus) 92 r.Get("/flagged", srv.handleFlagged) 93 r.Get("/flag", srv.handleFlag) 94 r.Get("/*", srv.handleRecipePath) 95 r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent)))) 96 97 addr := fmt.Sprintf(":%d", *port) 98 log.Printf("http://localhost:%d", *port) 99 if err := http.ListenAndServe(addr, r); err != nil { 100 log.Fatal(err) 101 } 102} 103 104type Server struct { 105 pipeline *extract.Pipeline 106 cache *cache.Cache 107 templates *template.Template 108 baseURL string 109 gitHash string 110 pendingMu sync.Mutex 111 pending map[string]chan extractResult 112 failedMu sync.Mutex 113 failed map[string]failedEntry 114} 115 116type failedEntry struct { 117 msg string 118 failedAt time.Time 119} 120 121type extractResult struct { 122 recipe *models.Recipe 123 err error 124} 125 126type indexRecentRecipe struct { 127 Name string 128 ImageURL string 129 SourceURL string 130 Domain string 131} 132 133func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { 134 targetURL := r.URL.Query().Get("url") 135 if targetURL != "" { 136 if strings.HasPrefix(targetURL, "https://") { 137 targetURL = targetURL[8:] 138 } else if strings.HasPrefix(targetURL, "http://") { 139 targetURL = targetURL[7:] 140 } 141 http.Redirect(w, r, "/"+targetURL, http.StatusFound) 142 return 143 } 144 145 var recentRecipes []indexRecentRecipe 146 cached, err := s.cache.Recent(6) 147 if err != nil { 148 log.Printf("cache recent error: %v", err) 149 } 150 for _, cr := range cached { 151 var recipe models.Recipe 152 if err := json.Unmarshal(cr.Recipe, &recipe); err != nil { 153 continue 154 } 155 recentRecipes = append(recentRecipes, indexRecentRecipe{ 156 Name: recipe.Name, 157 ImageURL: recipe.ImageURL, 158 SourceURL: cr.URL, 159 Domain: recipe.SourceDomain, 160 }) 161 } 162 163 data := map[string]interface{}{ 164 "GitHash": s.gitHash, 165 "BaseURL": s.baseURL, 166 "Recent": recentRecipes, 167 } 168 s.templates.ExecuteTemplate(w, "index_page", data) 169} 170 171func (s *Server) handleRecipeQuery(w http.ResponseWriter, r *http.Request) { 172 targetURL := r.URL.Query().Get("url") 173 if targetURL == "" { 174 http.Redirect(w, r, "/", http.StatusFound) 175 return 176 } 177 if strings.HasPrefix(targetURL, "https://") { 178 targetURL = targetURL[8:] 179 } else if strings.HasPrefix(targetURL, "http://") { 180 targetURL = targetURL[7:] 181 } 182 http.Redirect(w, r, "/"+targetURL, http.StatusMovedPermanently) 183} 184 185func (s *Server) handleRecipePath(w http.ResponseWriter, r *http.Request) { 186 path := r.URL.Path 187 if path == "/" || path == "" { 188 http.Redirect(w, r, "/", http.StatusFound) 189 return 190 } 191 192 targetURL := "https://" + path[1:] 193 194 // Handle ?unflag query param 195 if r.URL.Query().Has("unflag") { 196 s.cache.Unflag(targetURL) 197 http.Redirect(w, r, path, http.StatusSeeOther) 198 return 199 } 200 201 // Handle ?refresh query param — clear cache and re-extract 202 if r.URL.Query().Has("refresh") { 203 s.cache.Invalidate(targetURL) 204 s.failedMu.Lock() 205 delete(s.failed, targetURL) 206 s.failedMu.Unlock() 207 // Redirect to same path without query params to avoid loop 208 http.Redirect(w, r, path, http.StatusSeeOther) 209 return 210 } 211 212 recipe, err := s.cache.Get(targetURL) 213 if err != nil { 214 log.Printf("cache read error: %v", err) 215 } 216 if recipe != nil { 217 s.renderRecipe(w, recipe, targetURL) 218 return 219 } 220 221 // Check if extraction already failed (5min TTL) 222 s.failedMu.Lock() 223 entry, alreadyFailed := s.failed[targetURL] 224 s.failedMu.Unlock() 225 if alreadyFailed && time.Since(entry.failedAt) < 5*time.Minute { 226 s.renderError(w, entry.msg, targetURL) 227 return 228 } 229 if alreadyFailed { 230 s.failedMu.Lock() 231 delete(s.failed, targetURL) 232 s.failedMu.Unlock() 233 } 234 235 // Not cached — start extraction if not already in flight 236 s.startExtraction(targetURL) 237 238 // Render loading interstitial 239 data := map[string]interface{}{ 240 "TargetURL": targetURL, 241 "GitHash": s.gitHash, 242 "BaseURL": s.baseURL, 243 } 244 w.Header().Set("Content-Type", "text/html; charset=utf-8") 245 s.templates.ExecuteTemplate(w, "loading_page", data) 246} 247 248func (s *Server) startExtraction(targetURL string) { 249 s.pendingMu.Lock() 250 defer s.pendingMu.Unlock() 251 252 if _, ok := s.pending[targetURL]; ok { 253 return 254 } 255 256 ch := make(chan extractResult, 1) 257 s.pending[targetURL] = ch 258 259 go func() { 260 result := s.pipeline.Extract(targetURL) 261 if result.Error != nil { 262 s.failedMu.Lock() 263 s.failed[targetURL] = failedEntry{msg: result.Error.Error(), failedAt: time.Now()} 264 s.failedMu.Unlock() 265 266 ch <- extractResult{err: result.Error} 267 } else { 268 if err := s.cache.Set(targetURL, result.Recipe); err != nil { 269 log.Printf("cache write error: %v", err) 270 } 271 ch <- extractResult{recipe: result.Recipe} 272 } 273 close(ch) 274 275 s.pendingMu.Lock() 276 delete(s.pending, targetURL) 277 s.pendingMu.Unlock() 278 }() 279} 280 281func (s *Server) handleUserscript(w http.ResponseWriter, r *http.Request) { 282 data := map[string]interface{}{ 283 "GitHash": s.gitHash, 284 "BaseURL": s.baseURL, 285 } 286 w.Header().Set("Content-Type", "text/html; charset=utf-8") 287 s.templates.ExecuteTemplate(w, "userscript_page", data) 288} 289 290func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { 291 targetURL := r.URL.Query().Get("url") 292 if targetURL == "" { 293 w.Header().Set("Content-Type", "application/json") 294 w.Write([]byte(`{"ready":false,"error":"missing url"}`)) 295 return 296 } 297 298 recipe, err := s.cache.Get(targetURL) 299 if err != nil { 300 log.Printf("cache read error: %v", err) 301 } 302 if recipe != nil { 303 w.Header().Set("Content-Type", "application/json") 304 w.Write([]byte(`{"ready":true}`)) 305 return 306 } 307 308 s.pendingMu.Lock() 309 ch, pending := s.pending[targetURL] 310 s.pendingMu.Unlock() 311 if !pending { 312 // Check if it already failed (5min TTL) 313 s.failedMu.Lock() 314 entry, failed := s.failed[targetURL] 315 s.failedMu.Unlock() 316 if failed { 317 if time.Since(entry.failedAt) < 5*time.Minute { 318 errMsg := strings.ReplaceAll(entry.msg, `"`, `\\"`) 319 w.Header().Set("Content-Type", "application/json") 320 w.Write([]byte(fmt.Sprintf(`{"ready":false,"error":"%s"}`, errMsg))) 321 return 322 } 323 s.failedMu.Lock() 324 delete(s.failed, targetURL) 325 s.failedMu.Unlock() 326 } 327 w.Header().Set("Content-Type", "application/json") 328 w.Write([]byte(`{"ready":false,"error":"extraction not started"}`)) 329 return 330 } 331 332 // Non-blocking read from channel 333 select { 334 case res := <-ch: 335 w.Header().Set("Content-Type", "application/json") 336 if res.err != nil { 337 errMsg := strings.ReplaceAll(res.err.Error(), `"`, `\\"`) 338 w.Write([]byte(fmt.Sprintf(`{"ready":false,"error":"%s"}`, errMsg))) 339 } else { 340 w.Write([]byte(`{"ready":true}`)) 341 } 342 default: 343 w.Header().Set("Content-Type", "application/json") 344 w.Write([]byte(`{"ready":false}`)) 345 } 346} 347 348func (s *Server) renderRecipe(w http.ResponseWriter, recipe *models.Recipe, targetURL string) { 349 filename := strings.ReplaceAll(recipe.Name, " ", "-") + ".cook" 350 data := map[string]interface{}{ 351 "Recipe": recipe, 352 "TargetURL": targetURL, 353 "Filename": filename, 354 "GitHash": s.gitHash, 355 "BaseURL": s.baseURL, 356 "IsFlagged": s.cache.IsFlagged(targetURL), 357 } 358 w.Header().Set("Content-Type", "text/html; charset=utf-8") 359 s.templates.ExecuteTemplate(w, "recipe_page", data) 360} 361 362func (s *Server) handleCookView(w http.ResponseWriter, r *http.Request) { 363 targetURL := r.URL.Query().Get("url") 364 if targetURL == "" { 365 http.Error(w, "missing url parameter", http.StatusBadRequest) 366 return 367 } 368 if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") { 369 targetURL = "https://" + targetURL 370 } 371 372 recipe, err := s.cache.Get(targetURL) 373 if err != nil { 374 log.Printf("cache read error: %v", err) 375 } 376 if recipe == nil { 377 result := s.pipeline.Extract(targetURL) 378 if result.Error != nil { 379 s.renderError(w, result.Error.Error(), targetURL) 380 return 381 } 382 recipe = result.Recipe 383 } 384 385 cook := cooklang.Export(recipe) 386 filename := strings.ReplaceAll(recipe.Name, " ", "-") + ".cook" 387 388 data := map[string]interface{}{ 389 "Recipe": recipe, 390 "TargetURL": targetURL, 391 "CookFile": cook, 392 "Filename": filename, 393 "GitHash": s.gitHash, 394 "BaseURL": s.baseURL, 395 } 396 w.Header().Set("Content-Type", "text/html; charset=utf-8") 397 s.templates.ExecuteTemplate(w, "cook_page", data) 398} 399 400func (s *Server) handleCookExport(w http.ResponseWriter, r *http.Request) { 401 targetURL := r.URL.Query().Get("url") 402 if targetURL == "" { 403 http.Error(w, "missing url parameter", http.StatusBadRequest) 404 return 405 } 406 if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") { 407 targetURL = "https://" + targetURL 408 } 409 410 recipe, err := s.cache.Get(targetURL) 411 if err != nil { 412 log.Printf("cache read error: %v", err) 413 } 414 if recipe == nil { 415 result := s.pipeline.Extract(targetURL) 416 if result.Error != nil { 417 http.Error(w, result.Error.Error(), http.StatusBadGateway) 418 return 419 } 420 recipe = result.Recipe 421 } 422 423 cook := cooklang.Export(recipe) 424 filename := strings.ReplaceAll(recipe.Name, " ", "-") + ".cook" 425 426 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 427 w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) 428 w.Write([]byte(cook)) 429} 430 431func (s *Server) handleFlag(w http.ResponseWriter, r *http.Request) { 432 targetURL := r.URL.Query().Get("url") 433 if targetURL == "" { 434 targetURL = r.FormValue("url") 435 } 436 if targetURL == "" { 437 http.Error(w, "missing url", http.StatusBadRequest) 438 return 439 } 440 441 recipe, err := s.cache.Get(targetURL) 442 if err != nil { 443 log.Printf("cache read error: %v", err) 444 } 445 if recipe == nil { 446 http.Error(w, "recipe not found", http.StatusNotFound) 447 return 448 } 449 450 if err := s.cache.Flag(targetURL, recipe); err != nil { 451 log.Printf("flag error: %v", err) 452 http.Error(w, "flag failed", http.StatusInternalServerError) 453 return 454 } 455 456 data := map[string]interface{}{ 457 "SourceURL": targetURL, 458 "GitHash": s.gitHash, 459 "BaseURL": s.baseURL, 460 } 461 w.Header().Set("Content-Type", "text/html; charset=utf-8") 462 s.templates.ExecuteTemplate(w, "flag_page", data) 463} 464 465func (s *Server) handleFlagged(w http.ResponseWriter, r *http.Request) { 466 flagged, err := s.cache.ListFlagged() 467 if err != nil { 468 log.Printf("list flagged error: %v", err) 469 } 470 471 var recipes []indexRecentRecipe 472 for _, cr := range flagged { 473 var recipe models.Recipe 474 if err := json.Unmarshal(cr.Recipe, &recipe); err != nil { 475 continue 476 } 477 recipes = append(recipes, indexRecentRecipe{ 478 Name: recipe.Name, 479 ImageURL: recipe.ImageURL, 480 SourceURL: cr.URL, 481 Domain: recipe.SourceDomain, 482 }) 483 } 484 485 data := map[string]interface{}{ 486 "GitHash": s.gitHash, 487 "BaseURL": s.baseURL, 488 "Flagged": recipes, 489 } 490 w.Header().Set("Content-Type", "text/html; charset=utf-8") 491 s.templates.ExecuteTemplate(w, "flagged_page", data) 492} 493 494func (s *Server) renderError(w http.ResponseWriter, errMsg, sourceURL string) { 495 data := map[string]interface{}{ 496 "Error": errMsg, 497 "SourceURL": sourceURL, 498 "BaseURL": s.baseURL, 499 "GitHash": s.gitHash, 500 } 501 w.Header().Set("Content-Type", "text/html; charset=utf-8") 502 w.WriteHeader(http.StatusBadGateway) 503 s.templates.ExecuteTemplate(w, "error_page", data) 504} 505 506func fmtDuration(iso string) string { 507 if !strings.HasPrefix(iso, "PT") { 508 return iso 509 } 510 d := strings.TrimPrefix(iso, "PT") 511 var parts []string 512 if h := before(d, "H"); h != "" { 513 parts = append(parts, h+"h") 514 d = after(d, "H") 515 } 516 if m := before(d, "M"); m != "" { 517 parts = append(parts, m+"m") 518 d = after(d, "M") 519 } 520 if s := before(d, "S"); s != "" { 521 parts = append(parts, s+"s") 522 } 523 if len(parts) == 0 { 524 return iso 525 } 526 return strings.Join(parts, " ") 527} 528 529func isoToSeconds(iso string) int { 530 if !strings.HasPrefix(iso, "PT") { 531 return 0 532 } 533 d := strings.TrimPrefix(iso, "PT") 534 secs := 0 535 if h := before(d, "H"); h != "" { 536 if v, err := strconv.Atoi(h); err == nil { 537 secs += v * 3600 538 } 539 d = after(d, "H") 540 } 541 if m := before(d, "M"); m != "" { 542 if v, err := strconv.Atoi(m); err == nil { 543 secs += v * 60 544 } 545 d = after(d, "M") 546 } 547 if s := before(d, "S"); s != "" { 548 if v, err := strconv.Atoi(s); err == nil { 549 secs += v 550 } 551 } 552 return secs 553} 554 555func cleanSource(rawURL string) map[string]string { 556 u, err := url.Parse(rawURL) 557 if err != nil { 558 return map[string]string{"host": rawURL, "path": ""} 559 } 560 host := strings.TrimPrefix(u.Host, "www.") 561 path := u.Path 562 if path == "/" { 563 path = "" 564 } 565 if len(path) > 35 { 566 path = path[:32] + "…" 567 } 568 return map[string]string{"host": host, "path": path} 569} 570 571func before(s, sep string) string { 572 i := strings.Index(s, sep) 573 if i < 0 { 574 return "" 575 } 576 return s[:i] 577} 578 579func after(s, sep string) string { 580 i := strings.Index(s, sep) 581 if i < 0 { 582 return s 583 } 584 return s[i+len(sep):] 585} 586 587func renderStep(text string, ingredients []models.Ingredient, lang string) template.HTML { 588 if lang == "" || lang == "en" || strings.HasPrefix(lang, "en-") { 589 annotated := cooklang.AnnotateStepForDisplay(text, ingredients) 590 return cooklang.ParseAndRender(annotated) 591 } 592 return cooklang.ParseAndRender(cooklang.AnnotateTimersOnly(text, lang)) 593} 594 595func groupIngredients(ings []models.Ingredient) []ingredientGroup { 596 var groups []ingredientGroup 597 var current *ingredientGroup 598 for i, ing := range ings { 599 if current == nil || ing.Group != current.Name { 600 groups = append(groups, ingredientGroup{Name: ing.Group, StartIdx: i}) 601 current = &groups[len(groups)-1] 602 } 603 current.Items = append(current.Items, ing) 604 } 605 return groups 606} 607 608type ingredientGroup struct { 609 Name string 610 Items []models.Ingredient 611 StartIdx int 612} 613 614func cookHighlight(raw string) template.HTML { 615 return cooklang.Highlight(raw) 616} 617 618func recipeToJSONLD(r *models.Recipe) map[string]interface{} { 619 ld := map[string]interface{}{ 620 "@context": "https://schema.org", 621 "@type": "Recipe", 622 "name": r.Name, 623 "description": r.Description, 624 "recipeIngredient": ingredientStrings(r.Ingredients), 625 "recipeInstructions": instructionStrings(r.Instructions), 626 } 627 if r.ImageURL != "" { 628 ld["image"] = r.ImageURL 629 } 630 if r.PrepTime != "" { 631 ld["prepTime"] = r.PrepTime 632 } 633 if r.CookTime != "" { 634 ld["cookTime"] = r.CookTime 635 } 636 if r.TotalTime != "" { 637 ld["totalTime"] = r.TotalTime 638 } 639 if r.Yield != "" { 640 ld["recipeYield"] = r.Yield 641 } 642 return ld 643} 644 645func ingredientStrings(ings []models.Ingredient) []string { 646 out := make([]string, len(ings)) 647 for i, ing := range ings { 648 out[i] = ing.RawText 649 } 650 return out 651} 652 653func instructionStrings(steps []models.Instruction) []map[string]string { 654 out := make([]map[string]string, len(steps)) 655 for i, step := range steps { 656 out[i] = map[string]string{ 657 "@type": "HowToStep", 658 "text": step.Text, 659 } 660 } 661 return out 662}