Files
Snip/backend/internal/httpx/api/server.go
T
2026-06-15 21:25:57 +07:00

153 lines
4.5 KiB
Go

// 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)
}