initial commit
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
// Package web serves the Bootstrap management interface, the observability
|
||||
// endpoints and the static assets. The REST API is mounted underneath it.
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/owen/vibedns/internal/app"
|
||||
"github.com/owen/vibedns/internal/auditlog"
|
||||
"github.com/owen/vibedns/internal/auth"
|
||||
"github.com/owen/vibedns/internal/netutil"
|
||||
webui "github.com/owen/vibedns/web"
|
||||
)
|
||||
|
||||
// Server is the HTTP management interface.
|
||||
type Server struct {
|
||||
app *app.App
|
||||
log *slog.Logger
|
||||
tmpl *templates
|
||||
|
||||
http *http.Server
|
||||
apiMount http.Handler
|
||||
|
||||
limiter *httpLimiter
|
||||
mu sync.Mutex
|
||||
addr string
|
||||
}
|
||||
|
||||
// Options configures the HTTP server.
|
||||
type Options struct {
|
||||
App *app.App
|
||||
Log *slog.Logger
|
||||
// API is mounted at /api/v1 when non-nil.
|
||||
API http.Handler
|
||||
}
|
||||
|
||||
// New creates the HTTP server and parses the embedded templates.
|
||||
func New(opts Options) (*Server, error) {
|
||||
tmpl, err := loadTemplates(templateFuncs())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{
|
||||
app: opts.App,
|
||||
log: opts.Log,
|
||||
tmpl: tmpl,
|
||||
apiMount: opts.API,
|
||||
limiter: newHTTPLimiter(),
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Handler builds the fully wrapped HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
s.routes(mux)
|
||||
|
||||
var h http.Handler = mux
|
||||
h = s.withBodyLimit(h)
|
||||
h = s.withRateLimit(h)
|
||||
h = s.withRequestLog(h)
|
||||
h = s.withSecurityHeaders(h)
|
||||
h = s.withRecover(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// Start binds the management listener.
|
||||
func (s *Server) Start(addr string) error {
|
||||
h := s.Handler()
|
||||
s.mu.Lock()
|
||||
s.addr = addr
|
||||
s.http = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: h,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 2 * time.Minute, // large blocklist uploads
|
||||
WriteTimeout: 5 * time.Minute, // large exports
|
||||
IdleTimeout: 120 * time.Second,
|
||||
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelDebug),
|
||||
}
|
||||
srv := s.http
|
||||
s.mu.Unlock()
|
||||
|
||||
ln, err := listen(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.log.Error("management HTTP server stopped", "error", err)
|
||||
}
|
||||
}()
|
||||
s.log.Info("management interface started", "address", addr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown stops the management listener.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
srv := s.http
|
||||
s.mu.Unlock()
|
||||
if srv == nil {
|
||||
return nil
|
||||
}
|
||||
return srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// routes registers every endpoint.
|
||||
func (s *Server) routes(mux *http.ServeMux) {
|
||||
// Public endpoints. Static assets are unauthenticated because they are
|
||||
// Bootstrap and our own CSS: requiring credentials for them would force an
|
||||
// Argon2 verification on every asset request for no benefit.
|
||||
static := http.FileServer(http.FS(webui.Static()))
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(static)))
|
||||
mux.HandleFunc("GET /favicon.ico", s.handleFavicon)
|
||||
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
||||
mux.HandleFunc("GET /readyz", s.handleReadyz)
|
||||
mux.HandleFunc("GET /metrics", s.handleMetrics)
|
||||
|
||||
if s.apiMount != nil {
|
||||
mux.Handle("/api/v1/", s.apiMount)
|
||||
}
|
||||
|
||||
// Everything below requires the administrator.
|
||||
page := s.protect
|
||||
|
||||
mux.Handle("GET /{$}", page(s.handleDashboard))
|
||||
mux.Handle("GET /dashboard", page(s.handleDashboard))
|
||||
|
||||
// Zones.
|
||||
mux.Handle("GET /zones", page(s.handleZones))
|
||||
mux.Handle("GET /zones/reverse", page(s.handleZonesReverse))
|
||||
mux.Handle("GET /zones/new", page(s.handleZoneNew))
|
||||
mux.Handle("POST /zones/new", page(s.handleZoneCreate))
|
||||
mux.Handle("GET /zones/{id}", page(s.handleZoneRecords))
|
||||
mux.Handle("GET /zones/{id}/edit", page(s.handleZoneEdit))
|
||||
mux.Handle("POST /zones/{id}/edit", page(s.handleZoneUpdate))
|
||||
mux.Handle("POST /zones/{id}/delete", page(s.handleZoneDelete))
|
||||
mux.Handle("POST /zones/{id}/toggle", page(s.handleZoneToggle))
|
||||
mux.Handle("POST /zones/{id}/clone", page(s.handleZoneClone))
|
||||
mux.Handle("GET /zones/{id}/export", page(s.handleZoneExport))
|
||||
mux.Handle("POST /zones/{id}/import", page(s.handleZoneImport))
|
||||
|
||||
// Records.
|
||||
mux.Handle("GET /records", page(s.handleRecordsAll))
|
||||
mux.Handle("POST /zones/{id}/records", page(s.handleRecordCreate))
|
||||
mux.Handle("POST /zones/{id}/records/bulk", page(s.handleRecordBulk))
|
||||
mux.Handle("POST /records/{id}/edit", page(s.handleRecordUpdate))
|
||||
mux.Handle("POST /records/{id}/delete", page(s.handleRecordDelete))
|
||||
mux.Handle("POST /records/{id}/toggle", page(s.handleRecordToggle))
|
||||
|
||||
// Resolver and cache.
|
||||
mux.Handle("GET /resolver", page(s.handleResolver))
|
||||
mux.Handle("POST /resolver/test", page(s.handleResolverTest))
|
||||
mux.Handle("GET /cache", page(s.handleCache))
|
||||
mux.Handle("POST /cache/flush", page(s.handleCacheFlush))
|
||||
mux.Handle("POST /cache/delete", page(s.handleCacheDelete))
|
||||
|
||||
// Policies.
|
||||
mux.Handle("GET /policies", page(s.handlePolicies))
|
||||
mux.Handle("GET /policies/networks", page(s.handleNetworks))
|
||||
mux.Handle("GET /policies/networks/new", page(s.handleNetworkNew))
|
||||
mux.Handle("POST /policies/networks/new", page(s.handleNetworkCreate))
|
||||
mux.Handle("GET /policies/networks/{id}", page(s.handleNetworkEdit))
|
||||
mux.Handle("POST /policies/networks/{id}", page(s.handleNetworkUpdate))
|
||||
mux.Handle("POST /policies/networks/{id}/delete", page(s.handleNetworkDelete))
|
||||
mux.Handle("POST /policies/networks/{id}/toggle", page(s.handleNetworkToggle))
|
||||
|
||||
mux.Handle("GET /policies/rules/new", page(s.handlePolicyNew))
|
||||
mux.Handle("POST /policies/rules/new", page(s.handlePolicyCreate))
|
||||
mux.Handle("GET /policies/rules/{id}", page(s.handlePolicyEdit))
|
||||
mux.Handle("POST /policies/rules/{id}", page(s.handlePolicyUpdate))
|
||||
mux.Handle("POST /policies/rules/{id}/delete", page(s.handlePolicyDelete))
|
||||
mux.Handle("POST /policies/rules/{id}/toggle", page(s.handlePolicyToggle))
|
||||
|
||||
mux.Handle("GET /policies/blacklists", page(s.handleBlacklists))
|
||||
mux.Handle("GET /policies/allowlists", page(s.handleAllowlists))
|
||||
mux.Handle("POST /policies/lists/new", page(s.handleListCreate))
|
||||
mux.Handle("GET /policies/lists/{id}", page(s.handleListDetail))
|
||||
mux.Handle("POST /policies/lists/{id}", page(s.handleListUpdate))
|
||||
mux.Handle("POST /policies/lists/{id}/delete", page(s.handleListDelete))
|
||||
mux.Handle("POST /policies/lists/{id}/toggle", page(s.handleListToggle))
|
||||
mux.Handle("POST /policies/lists/{id}/clear", page(s.handleListClear))
|
||||
mux.Handle("POST /policies/lists/{id}/import", page(s.handleListImport))
|
||||
mux.Handle("GET /policies/lists/{id}/export", page(s.handleListExport))
|
||||
mux.Handle("POST /policies/lists/{id}/domains", page(s.handleDomainAdd))
|
||||
mux.Handle("POST /policies/domains/{id}/delete", page(s.handleDomainDelete))
|
||||
|
||||
// Logs.
|
||||
mux.Handle("GET /querylog", page(s.handleQueryLog))
|
||||
mux.Handle("POST /querylog/clear", page(s.handleQueryLogClear))
|
||||
mux.Handle("GET /audit", page(s.handleAuditLog))
|
||||
|
||||
// Tools.
|
||||
mux.Handle("GET /tools", page(s.handleTools))
|
||||
mux.Handle("POST /tools/lookup", page(s.handleToolsLookup))
|
||||
|
||||
// Settings.
|
||||
mux.Handle("GET /settings", page(s.redirectTo("/settings/dns")))
|
||||
mux.Handle("GET /settings/dns", page(s.handleSettingsDNS))
|
||||
mux.Handle("POST /settings/dns", page(s.handleSettingsDNSSave))
|
||||
mux.Handle("GET /settings/resolver", page(s.handleSettingsResolver))
|
||||
mux.Handle("POST /settings/resolver", page(s.handleSettingsResolverSave))
|
||||
mux.Handle("GET /settings/cache", page(s.handleSettingsCache))
|
||||
mux.Handle("POST /settings/cache", page(s.handleSettingsCacheSave))
|
||||
mux.Handle("GET /settings/logging", page(s.handleSettingsLogging))
|
||||
mux.Handle("POST /settings/logging", page(s.handleSettingsLoggingSave))
|
||||
mux.Handle("GET /settings/http", page(s.handleSettingsHTTP))
|
||||
mux.Handle("POST /settings/http", page(s.handleSettingsHTTPSave))
|
||||
mux.Handle("GET /settings/database", page(s.handleSettingsDatabase))
|
||||
mux.Handle("POST /settings/database", page(s.handleSettingsDatabaseSave))
|
||||
mux.Handle("POST /settings/database/backup", page(s.handleBackupNow))
|
||||
mux.Handle("GET /settings/database/backup/{name}", page(s.handleBackupDownload))
|
||||
mux.Handle("POST /settings/database/backup/{name}/delete", page(s.handleBackupDelete))
|
||||
mux.Handle("POST /settings/database/backup/{name}/restore", page(s.handleBackupRestore))
|
||||
mux.Handle("POST /settings/database/restore/cancel", page(s.handleRestoreCancel))
|
||||
mux.Handle("GET /settings/api", page(s.handleSettingsAPI))
|
||||
mux.Handle("POST /settings/api/tokens", page(s.handleTokenCreate))
|
||||
mux.Handle("POST /settings/api/tokens/{id}/delete", page(s.handleTokenDelete))
|
||||
mux.Handle("POST /settings/api/tokens/{id}/toggle", page(s.handleTokenToggle))
|
||||
mux.Handle("GET /settings/config/export", page(s.handleConfigExport))
|
||||
mux.Handle("POST /settings/config/import", page(s.handleConfigImport))
|
||||
|
||||
// Account.
|
||||
mux.Handle("GET /account", page(s.handleAccount))
|
||||
mux.Handle("POST /account", page(s.handleAccountSave))
|
||||
|
||||
// Anything unmatched. Registered without a method so it does not conflict
|
||||
// with the "/api/v1/" prefix pattern: ServeMux rejects a method-specific
|
||||
// catch-all that is more general in path than an existing prefix route.
|
||||
mux.Handle("/", page(s.handleNotFound))
|
||||
}
|
||||
|
||||
// handlerFunc is a page handler that may return an error for central handling.
|
||||
type handlerFunc func(http.ResponseWriter, *http.Request) error
|
||||
|
||||
// protect wraps a page handler with authentication and CSRF enforcement.
|
||||
func (s *Server) protect(fn handlerFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.app.Auth.Authenticate(r, false)
|
||||
if err != nil {
|
||||
s.challenge(w, r, err)
|
||||
return
|
||||
}
|
||||
ctx := auth.WithPrincipal(r.Context(), p)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// The CSRF cookie is refreshed on every page view so it never expires
|
||||
// out from under an open tab.
|
||||
token := s.app.Auth.IssueCSRFToken(p.Name)
|
||||
if r.Method == http.MethodGet {
|
||||
auth.SetCSRFCookie(w, r, token)
|
||||
} else if err := s.app.Auth.CheckCSRF(r, p); err != nil {
|
||||
s.log.Warn("rejected a request that failed the CSRF check",
|
||||
"path", r.URL.Path, "client", p.ClientIP)
|
||||
s.renderError(w, r, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := fn(w, r); err != nil {
|
||||
s.handleError(w, r, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// challenge sends the Basic authentication challenge, or a lockout message.
|
||||
func (s *Server) challenge(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrLockedOut):
|
||||
w.Header().Set("Retry-After", "300")
|
||||
s.renderError(w, r, http.StatusTooManyRequests,
|
||||
"Too many failed sign-in attempts from this address. Try again in a few minutes.")
|
||||
case errors.Is(err, auth.ErrNoAdmin):
|
||||
s.renderError(w, r, http.StatusServiceUnavailable,
|
||||
"No administrator account exists yet. Restart the server to create one.")
|
||||
default:
|
||||
w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", auth.Realm))
|
||||
s.renderError(w, r, http.StatusUnauthorized, "Sign in to continue.")
|
||||
}
|
||||
}
|
||||
|
||||
// handleError turns a service error into a flash or an error page.
|
||||
func (s *Server) handleError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if app.IsInternal(err) {
|
||||
s.log.Error("request failed", "path", r.URL.Path, "method", r.Method, "error", err)
|
||||
}
|
||||
status := app.StatusOf(err)
|
||||
message := app.MessageOf(err)
|
||||
|
||||
// A failed form submission returns the operator to the page they were on
|
||||
// with the reason shown, rather than dumping them on an error page.
|
||||
if r.Method == http.MethodPost && status < 500 {
|
||||
setFlash(w, r, "danger", message)
|
||||
s.redirectBack(w, r)
|
||||
return
|
||||
}
|
||||
s.renderError(w, r, status, message)
|
||||
}
|
||||
|
||||
// redirectBack returns to the referring page, or a supplied fallback.
|
||||
func (s *Server) redirectBack(w http.ResponseWriter, r *http.Request) {
|
||||
target := r.FormValue("return_to")
|
||||
if target == "" {
|
||||
target = safeReferer(r)
|
||||
}
|
||||
if target == "" {
|
||||
target = "/"
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// safeReferer only accepts a same-origin path, so a crafted Referer cannot
|
||||
// turn an error into an open redirect.
|
||||
func safeReferer(r *http.Request) string {
|
||||
ref := r.Header.Get("Referer")
|
||||
if ref == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(ref, "/") && !strings.HasPrefix(ref, "//") {
|
||||
return ref
|
||||
}
|
||||
if u, err := parseURL(ref); err == nil && u.Host == r.Host {
|
||||
return u.RequestURI()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) redirectTo(target string) handlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// redirect issues a see-other redirect after a successful mutation.
|
||||
func (s *Server) redirect(w http.ResponseWriter, r *http.Request, target string) error {
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
// actor builds the audit actor for the current request.
|
||||
func (s *Server) actor(r *http.Request) auditlog.Actor {
|
||||
p, _ := auth.PrincipalFrom(r.Context())
|
||||
source := auditlog.SourceWeb
|
||||
if p.Kind == auth.KindToken {
|
||||
source = auditlog.SourceAPI
|
||||
}
|
||||
return auditlog.Actor{Name: p.Name, Source: source, ClientIP: p.ClientIP}
|
||||
}
|
||||
|
||||
// base builds the common page envelope.
|
||||
func (s *Server) base(r *http.Request, title, nav string) PageData {
|
||||
p, _ := auth.PrincipalFrom(r.Context())
|
||||
return PageData{
|
||||
Title: title,
|
||||
Nav: nav,
|
||||
User: p,
|
||||
CSRF: s.app.Auth.IssueCSRFToken(p.Name),
|
||||
Query: r.URL.Query(),
|
||||
}
|
||||
}
|
||||
|
||||
// pathID reads an {id} path parameter.
|
||||
func pathID(r *http.Request, name string) (int64, error) {
|
||||
raw := r.PathValue(name)
|
||||
id, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, app.Invalid("%q is not a valid identifier.", raw)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// --- middleware ---------------------------------------------------------
|
||||
|
||||
func (s *Server) withRecover(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
s.log.Error("panic while handling request",
|
||||
"path", r.URL.Path, "panic", rec, "stack", string(debug.Stack()))
|
||||
// The stack trace goes to the log, never to the browser.
|
||||
s.renderError(w, r, http.StatusInternalServerError,
|
||||
"An unexpected error occurred. Check the server log for details.")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), interest-cohort=()")
|
||||
// Everything is served from this origin: no CDN, no inline event
|
||||
// handlers, no eval. 'unsafe-inline' is allowed for style attributes
|
||||
// only, which Bootstrap components set programmatically.
|
||||
h.Set("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self'; "+
|
||||
"style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' data:; "+
|
||||
"font-src 'self'; "+
|
||||
"connect-src 'self'; "+
|
||||
"form-action 'self'; "+
|
||||
"frame-ancestors 'none'; "+
|
||||
"base-uri 'none'; "+
|
||||
"object-src 'none'")
|
||||
if r.TLS != nil {
|
||||
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// statusRecorder captures the response status for logging.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (w *statusRecorder) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusRecorder) Write(b []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
n, err := w.ResponseWriter.Write(b)
|
||||
w.bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *Server) withRequestLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w}
|
||||
next.ServeHTTP(rec, r)
|
||||
|
||||
if strings.HasPrefix(r.URL.Path, "/static/") {
|
||||
return // asset noise
|
||||
}
|
||||
level := slog.LevelDebug
|
||||
if rec.status >= 500 {
|
||||
level = slog.LevelError
|
||||
} else if rec.status >= 400 {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
s.log.Log(r.Context(), level, "http request",
|
||||
"method", r.Method, "path", r.URL.Path, "status", rec.status,
|
||||
"bytes", rec.bytes, "duration_ms", time.Since(start).Milliseconds(),
|
||||
"client", s.app.Auth.ClientIP(r))
|
||||
})
|
||||
}
|
||||
|
||||
// withBodyLimit caps request bodies so an oversized upload cannot exhaust
|
||||
// memory. The limit follows the configured upload size.
|
||||
func (s *Server) withBodyLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body != nil && r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
limit := int64(s.app.Settings().HTTP.MaxUploadMB) * 1024 * 1024
|
||||
if limit <= 0 {
|
||||
limit = 64 * 1024 * 1024
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// withRateLimit applies a coarse per-address request limit to the management
|
||||
// interface. It is a brute-force and runaway-script guard, not a DoS defence.
|
||||
func (s *Server) withRateLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/healthz" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
perMin := s.app.Settings().HTTP.RateLimitPerMin
|
||||
addr, ok := netutil.AddrFromHostPort(s.app.Auth.ClientIP(r))
|
||||
if ok && !s.limiter.allow(addr.String(), perMin) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, "Too many requests. Slow down and try again shortly.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// cacheStatic marks embedded assets as immutable. They only change when the
|
||||
// binary changes, and the binary is what serves them.
|
||||
func cacheStatic(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user