171 lines
5.6 KiB
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"})
|
|
}
|