package httpapi import ( "errors" "fmt" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/szabadpalya/api/internal/hu" "github.com/szabadpalya/api/internal/shell" "github.com/szabadpalya/api/internal/store" ) const ( siteTitle = "Szabadpálya — közösségi finanszírozás közéleti projekteknek" siteDesc = "Szabad a pálya. Közösségi finanszírozás magyar közéleti projekteknek, platformdíj nélkül." ) func pageTitle(s string) string { return s + " · Szabadpálya" } // bootstrap is what gets inlined into the document so the first route does not // have to fetch what the server already loaded to build the meta tags. type bootstrap struct { Route string `json:"route"` Data any `json:"data"` } // document renders the HTML shell for any path the API and the asset handlers // did not claim. React takes over from #root; everything here is . func (s *Server) document(c *gin.Context) { path := strings.TrimSuffix(c.Request.URL.Path, "/") if path == "" { path = "/" } // A stray request for a missing asset must not be answered with an HTML // page pretending to be a script — that only produces a confusing MIME // error in the console instead of an honest 404. if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/assets/") { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } ctx := c.Request.Context() switch { case path == "/": stats, err := s.store.PlatformStats(ctx) if err != nil { s.failDocument(c, err) return } s.render(c, http.StatusOK, shell.Document{ Title: siteTitle, Description: siteDesc, Canonical: s.shell.Abs("/"), OG: shell.OpenGraph{URL: s.shell.Abs("/")}, }, bootstrap{Route: "home", Data: gin.H{"stats": stats}}) case path == "/kampanyok": campaigns, err := s.store.ListCampaigns(ctx) if err != nil { s.failDocument(c, err) return } s.render(c, http.StatusOK, shell.Document{ Title: pageTitle("Projektek"), Description: "Böngészd a futó és lezárult közösségi kampányokat a Szabadpályán.", Canonical: s.shell.Abs("/kampanyok"), OG: shell.OpenGraph{URL: s.shell.Abs("/kampanyok")}, }, bootstrap{Route: "campaigns", Data: gin.H{"campaigns": campaigns}}) case strings.HasPrefix(path, "/kampanyok/"): s.campaignDocument(c, strings.TrimPrefix(path, "/kampanyok/")) case path == "/belepes": s.render(c, http.StatusOK, shell.Document{ Title: pageTitle("Belépés"), Description: siteDesc, }, bootstrap{Route: "login"}) case path == "/admin" || strings.HasPrefix(path, "/admin/"): // The admin surface is gated by the API, not by this document: the // shell is public HTML and carries no data. Keeping it out of search // results is the only thing to do here. c.Header("X-Robots-Tag", "noindex") s.render(c, http.StatusOK, shell.Document{ Title: pageTitle("Admin"), Description: siteDesc, }, bootstrap{Route: "admin"}) default: s.render(c, http.StatusNotFound, shell.Document{ Title: pageTitle("Nincs ilyen oldal"), Description: siteDesc, }, bootstrap{Route: "notfound"}) } } func (s *Server) campaignDocument(c *gin.Context, slug string) { campaign, err := s.store.GetCampaignBySlug(c.Request.Context(), slug) if errors.Is(err, store.ErrNotFound) { s.render(c, http.StatusNotFound, shell.Document{ Title: pageTitle("Nincs ilyen kampány"), Description: siteDesc, }, bootstrap{Route: "notfound"}) return } if err != nil { s.failDocument(c, err) return } // This description is the share card's subtitle, so it leads with the // numbers a reader decides on before it repeats the campaign's own summary. pct := hu.ProgressPct(campaign.Raised, campaign.Goal) description := fmt.Sprintf("%s gyűlt össze · %d%% · %s támogató. %s", hu.FormatHUF(campaign.Raised), pct, hu.FormatNumber(campaign.Backers), campaign.Summary) url := s.shell.Abs("/kampanyok/" + campaign.Slug) s.render(c, http.StatusOK, shell.Document{ Title: pageTitle(campaign.Title), Description: description, Canonical: url, OG: shell.OpenGraph{ Title: campaign.Title, Description: description, Type: "article", URL: url, Image: s.shell.Abs("/kampanyok/" + campaign.Slug + "/og.png"), ImageAlt: campaign.Title + " — Szabadpálya kampány", }, }, bootstrap{Route: "campaign", Data: campaign}) } func (s *Server) render(c *gin.Context, status int, doc shell.Document, data bootstrap) { if err := s.shell.Render(c.Writer, status, doc, data); err != nil { c.Error(err) } } // failDocument keeps a database outage from returning a blank page: the shell // still renders, and the client shows its own error state. func (s *Server) failDocument(c *gin.Context, err error) { c.Error(err) s.render(c, http.StatusInternalServerError, shell.Document{ Title: pageTitle("Hiba"), Description: siteDesc, }, bootstrap{Route: "error"}) }