// Package api is the inbound HTTP adapter for the /api/v1 surface. package api import ( "crypto/rand" "encoding/base64" "encoding/json" "errors" "net/http" "strings" "time" "github.com/snip/backend/internal/config" "github.com/snip/backend/internal/domain" "github.com/snip/backend/internal/port" "github.com/snip/backend/internal/service" ) const ( sessionCookie = "snip_session" stateCookie = "snip_oauth_state" ) // API wires the link and auth use cases to HTTP. type API struct { links *service.LinkService auth *service.AuthService sessions port.SessionManager cfg config.Config } func New(links *service.LinkService, auth *service.AuthService, sessions port.SessionManager, cfg config.Config) *API { return &API{links: links, auth: auth, sessions: sessions, cfg: cfg} } // Handler returns the routed, CORS-wrapped /api/v1 handler. func (a *API) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/api/v1/healthz", a.health) mux.HandleFunc("/api/v1/config", a.config) mux.HandleFunc("/api/v1/links/stats", a.linkStats) // exact: wins over /links/ mux.HandleFunc("/api/v1/links", a.linksCollection) mux.HandleFunc("/api/v1/links/", a.linkItem) mux.HandleFunc("/api/v1/auth/", a.authRoutes) return cors(mux) } func (a *API) health(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // config exposes client-relevant, non-secret settings (e.g. the short domain), // so the frontend doesn't hardcode them. func (a *API) config(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"shortDomain": a.cfg.ShortDomain}) } // --- helpers --- func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, err error) { status := http.StatusInternalServerError switch { case errors.Is(err, domain.ErrUnauthorized): status = http.StatusUnauthorized case errors.Is(err, domain.ErrForbidden): status = http.StatusForbidden case errors.Is(err, domain.ErrNotFound): status = http.StatusNotFound case errors.Is(err, domain.ErrCodeTaken), errors.Is(err, domain.ErrReserved): status = http.StatusConflict case errors.Is(err, domain.ErrInvalidURL), errors.Is(err, domain.ErrInvalidAlias), errors.Is(err, domain.ErrInvalidPin), errors.Is(err, domain.ErrPinInvalid), errors.Is(err, domain.ErrPinRequired): status = http.StatusBadRequest } writeJSON(w, status, map[string]string{"error": err.Error()}) } // userID extracts and verifies the caller's session, or "" if anonymous. func (a *API) userID(r *http.Request) string { c, err := r.Cookie(sessionCookie) if err != nil { return "" } id, err := a.sessions.Verify(c.Value) if err != nil { return "" } return id } func (a *API) setSession(w http.ResponseWriter, token string) { http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: token, Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, Expires: time.Now().Add(30 * 24 * time.Hour), }) } func (a *API) clearCookie(w http.ResponseWriter, name string) { http.SetCookie(w, &http.Cookie{ Name: name, Value: "", Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) } func randomState() string { b := make([]byte, 24) _, _ = rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } func decode(r *http.Request, v any) error { defer r.Body.Close() return json.NewDecoder(r.Body).Decode(v) } // cors reflects the request Origin and allows credentials (cookies). In // production the SPA and API are same-origin behind a gateway; this keeps local // dev (vite :5173 → api :8080) working. func cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if origin != "" { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,PUT,DELETE,OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } // pathTail returns the part of the request path after a prefix. func pathTail(path, prefix string) string { return strings.TrimPrefix(path, prefix) }