// Package httpapi wires everything together: a JSON API under /api, the // generated share images, the built frontend's assets, and the HTML shell for // every other path. package httpapi import ( "net/http" "strings" "github.com/gin-gonic/gin" "github.com/szabadpalya/api/internal/auth" "github.com/szabadpalya/api/internal/config" "github.com/szabadpalya/api/internal/shell" "github.com/szabadpalya/api/internal/store" ) type Server struct { cfg *config.Config store *store.Store auth *auth.Service shell *shell.Renderer } func New(cfg *config.Config, s *store.Store, a *auth.Service, r *shell.Renderer) *Server { return &Server{cfg: cfg, store: s, auth: a, shell: r} } func (s *Server) Handler() http.Handler { if !s.cfg.Dev { gin.SetMode(gin.ReleaseMode) } r := gin.New() r.Use(gin.Logger(), gin.Recovery()) api := r.Group("/api") { api.GET("/health", s.health) api.GET("/campaigns", s.listCampaigns) api.GET("/campaigns/:slug", s.getCampaign) api.GET("/stats", s.stats) api.GET("/auth/session", s.session) api.GET("/auth/verify", s.verifyMagicLink) api.POST("/auth/magic-link", s.sameOrigin, s.requestMagicLink) api.POST("/auth/logout", s.sameOrigin, s.logout) admin := api.Group("/admin", s.requireAdmin) { admin.GET("/campaigns", s.adminListCampaigns) admin.GET("/campaigns/:slug", s.adminGetCampaign) admin.GET("/owners", s.adminOwners) admin.PUT("/campaigns", s.sameOrigin, s.adminSaveCampaign) admin.DELETE("/campaigns/:slug", s.sameOrigin, s.adminDeleteCampaign) } } r.GET("/kampanyok/:slug/og.png", s.ogImage) // Hashed filenames make these safe to cache forever; index.html is never // served from disk, because the shell is what carries the meta tags. r.Static("/assets", s.cfg.WebDir+"/assets") r.StaticFile("/favicon.ico", s.cfg.WebDir+"/favicon.ico") r.StaticFile("/robots.txt", s.cfg.WebDir+"/robots.txt") r.NoRoute(s.document) return r } // sameOrigin is the CSRF guard. The session cookie is SameSite=Lax, which // already blocks cross-site form posts; this closes the remaining gap for // requests a page could make with fetch. Same-origin requests from the app // always carry one of these headers. func (s *Server) sameOrigin(c *gin.Context) { origin := c.GetHeader("Origin") if origin == "" { if ref := c.GetHeader("Referer"); ref != "" { origin = originOf(ref) } } if origin == "" || origin == s.cfg.BaseURL { c.Next() return } // In development the Vite dev server is a different port and proxies here. if s.cfg.Dev && strings.HasPrefix(origin, "http://localhost:") { c.Next() return } c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "cross-origin request refused"}) } func originOf(rawURL string) string { i := strings.Index(rawURL, "://") if i < 0 { return "" } rest := rawURL[i+3:] if j := strings.IndexByte(rest, '/'); j >= 0 { rest = rest[:j] } return rawURL[:i+3] + rest } const userKey = "user" func (s *Server) requireAdmin(c *gin.Context) { user := s.auth.UserFromRequest(c.Request.Context(), c.Request) if !s.auth.IsAdmin(user) { // 401 rather than 403 even for a signed-in non-admin: the admin surface // does not confirm its own existence to accounts that cannot use it. c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } c.Set(userKey, user) c.Next() }