szabadpalya/api/internal/httpapi/document.go

171 lines
5.6 KiB
Go

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" }
// adminEntryPath must match ADMIN_ENTRY_PATH in web/src/lib/admin-entry.ts.
const adminEntryPath = "/admin-belepes"
// 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 <head>.
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/"))
// The way in for an admin. It is the landing page — the client only adds a
// sign-in link to the header — so it loads exactly what "/" loads. Not
// linked from anywhere public, hence noindex; the actual gate is still the
// magic link and the admin allowlist, never this path being hard to guess.
case path == adminEntryPath:
stats, err := s.store.PlatformStats(ctx)
if err != nil {
s.failDocument(c, err)
return
}
c.Header("X-Robots-Tag", "noindex")
s.render(c, http.StatusOK, shell.Document{
Title: siteTitle,
Description: siteDesc,
}, bootstrap{Route: "home", Data: gin.H{"stats": stats}})
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"})
}