105 lines
4.0 KiB
Go
105 lines
4.0 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Cross-origin access, for local development.
|
|
//
|
|
// In Phase 2D the frontend fetches this API directly from the Vite dev server,
|
|
// which is a different origin (http://localhost:5173 → http://127.0.0.1:8080).
|
|
// Without these headers the browser makes the request and then refuses to let
|
|
// the page read the response, which surfaces in the app as an opaque "Failed to
|
|
// fetch" with a perfectly healthy 200 in the server log.
|
|
//
|
|
// This is a transport concern only. No endpoint, request shape, response shape
|
|
// or status code in docs/api-contract.md changes because of it.
|
|
|
|
// corsMaxAge is how long a browser may cache a preflight result. Ten minutes
|
|
// keeps preflight off the hot path without making an allowlist change take an
|
|
// awkwardly long time to be noticed in development.
|
|
const corsMaxAge = 600
|
|
|
|
// allowedCORSMethods is every method the router actually registers, plus
|
|
// OPTIONS for the preflight itself. It is a fixed list rather than something
|
|
// derived per path: the browser asks about one method at a time and only needs
|
|
// to know it is permitted in general.
|
|
var allowedCORSMethods = []string{
|
|
http.MethodGet, http.MethodPost, http.MethodPatch,
|
|
http.MethodDelete, http.MethodOptions,
|
|
}
|
|
|
|
// cors answers preflights and marks cross-origin responses as readable.
|
|
//
|
|
// Origins are matched exactly against the allowlist and echoed back one at a
|
|
// time — never "*" — so adding credentials later does not require rewriting
|
|
// this. A request whose Origin is not on the list is served normally, with no
|
|
// CORS headers: the API does not refuse it, the browser simply will not hand
|
|
// the response to the page. That distinction matters, because curl, the health
|
|
// checker and any server-to-server caller send no Origin at all and must not be
|
|
// affected by this middleware.
|
|
//
|
|
// With an empty allowlist the middleware is not installed at all (see New), so
|
|
// the same-origin deployment pays nothing for it.
|
|
func cors(origins []string) func(http.Handler) http.Handler {
|
|
allowed := make(map[string]bool, len(origins))
|
|
for _, o := range origins {
|
|
allowed[o] = true
|
|
}
|
|
methods := strings.Join(allowedCORSMethods, ", ")
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
|
|
// Vary on Origin whether or not this particular origin matched: the
|
|
// response differs by Origin, so a cache that ignored it could hand
|
|
// one origin's headers to another.
|
|
w.Header().Add("Vary", "Origin")
|
|
|
|
if origin == "" || !allowed[origin] {
|
|
if isPreflight(r) {
|
|
// A preflight is never a real request. Answering it with
|
|
// the router's 404 for "OPTIONS /api/v1/…" would be
|
|
// misleading; 403 says plainly that the origin was refused.
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
|
|
if isPreflight(r) {
|
|
w.Header().Add("Vary", "Access-Control-Request-Method")
|
|
w.Header().Add("Vary", "Access-Control-Request-Headers")
|
|
w.Header().Set("Access-Control-Allow-Methods", methods)
|
|
// Echo the requested headers rather than listing them. The
|
|
// frontend sends only Content-Type today; echoing means a
|
|
// future header does not need a change here to be allowed from
|
|
// an origin that is already trusted.
|
|
if h := r.Header.Get("Access-Control-Request-Headers"); h != "" {
|
|
w.Header().Set("Access-Control-Allow-Headers", h)
|
|
} else {
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
}
|
|
w.Header().Set("Access-Control-Max-Age", strconv.Itoa(corsMaxAge))
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// isPreflight identifies the browser's OPTIONS probe. A bare OPTIONS with no
|
|
// Access-Control-Request-Method is not a preflight and is left to the router.
|
|
func isPreflight(r *http.Request) bool {
|
|
return r.Method == http.MethodOptions &&
|
|
r.Header.Get("Access-Control-Request-Method") != ""
|
|
}
|