mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
631 lines
20 KiB
Go
631 lines
20 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"mime"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"vocat/internal/auth"
|
|
"vocat/internal/exportproxy"
|
|
"vocat/internal/extensions"
|
|
"vocat/internal/httpsmode"
|
|
"vocat/internal/loghub"
|
|
"vocat/internal/store"
|
|
"vocat/internal/update"
|
|
"vocat/internal/vowifi"
|
|
)
|
|
|
|
const (
|
|
sessionCookieName = "vocat_session"
|
|
csrfCookieName = "vocat_csrf"
|
|
csrfHeaderName = "X-CSRF-Token"
|
|
)
|
|
|
|
type Options struct {
|
|
Store *store.Store
|
|
Auth *auth.Service
|
|
Devices DeviceController
|
|
VoWiFi VoWiFiController
|
|
Logs *loghub.Hub
|
|
Assets fs.FS
|
|
Logger *slog.Logger
|
|
SecureCookies bool
|
|
MaxRequestBodyBytes int64
|
|
Extensions *extensions.Manager
|
|
ExportProxy *exportproxy.Manager
|
|
DeveloperEnabled bool
|
|
UpdateRepository string
|
|
UpdateToken string
|
|
HTTPS *httpsmode.Manager
|
|
}
|
|
|
|
// Server is the single HTTP handler for the JSON API and embedded SPA.
|
|
type Server struct {
|
|
store *store.Store
|
|
auth *auth.Service
|
|
devices DeviceController
|
|
vowifi VoWiFiController
|
|
logs *loghub.Hub
|
|
assets fs.FS
|
|
indexHTML []byte
|
|
fileServer http.Handler
|
|
logger *slog.Logger
|
|
secureCookies bool
|
|
maxRequestBodyBytes int64
|
|
startedAt time.Time
|
|
handler http.Handler
|
|
websheets *websheetManager
|
|
accessMu sync.RWMutex
|
|
access parsedAccessConfig
|
|
loginLimiter *loginRateLimiter
|
|
extensions *extensions.Manager
|
|
exportProxy *exportproxy.Manager
|
|
developerEnabled bool
|
|
updateRepository string
|
|
updateToken string
|
|
updateCheck func(context.Context, string, string, string) (update.CheckResult, error)
|
|
updateApply func(context.Context, *slog.Logger, update.Options, bool) (update.CheckResult, error)
|
|
updateRestart func(*slog.Logger) error
|
|
updateMu sync.Mutex
|
|
updateApplying bool
|
|
https *httpsmode.Manager
|
|
netTraffic *liveNetTracker
|
|
publicIPMu sync.RWMutex
|
|
publicIPs map[string]cachedPublicIP
|
|
}
|
|
|
|
func New(options Options) (*Server, error) {
|
|
if options.Store == nil {
|
|
return nil, errors.New("server: store is required")
|
|
}
|
|
if options.Auth == nil {
|
|
return nil, errors.New("server: auth service is required")
|
|
}
|
|
if options.Assets == nil {
|
|
return nil, errors.New("server: SPA assets are required")
|
|
}
|
|
indexHTML, err := fs.ReadFile(options.Assets, "index.html")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("server: read embedded index.html: %w", err)
|
|
}
|
|
if options.Logger == nil {
|
|
options.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
if options.MaxRequestBodyBytes <= 0 {
|
|
options.MaxRequestBodyBytes = 1 << 20
|
|
}
|
|
if strings.TrimSpace(options.UpdateRepository) == "" {
|
|
options.UpdateRepository = update.DefaultRepository
|
|
}
|
|
|
|
server := &Server{
|
|
store: options.Store,
|
|
auth: options.Auth,
|
|
devices: options.Devices,
|
|
vowifi: options.VoWiFi,
|
|
logs: options.Logs,
|
|
assets: options.Assets,
|
|
indexHTML: indexHTML,
|
|
fileServer: http.FileServer(http.FS(options.Assets)),
|
|
logger: options.Logger,
|
|
secureCookies: options.SecureCookies,
|
|
maxRequestBodyBytes: options.MaxRequestBodyBytes,
|
|
startedAt: time.Now().UTC(),
|
|
websheets: newWebsheetManager(),
|
|
loginLimiter: newLoginRateLimiter(),
|
|
extensions: options.Extensions,
|
|
exportProxy: options.ExportProxy,
|
|
developerEnabled: options.DeveloperEnabled,
|
|
updateRepository: strings.TrimSpace(options.UpdateRepository),
|
|
updateToken: strings.TrimSpace(options.UpdateToken),
|
|
https: options.HTTPS,
|
|
netTraffic: newLiveNetTracker(),
|
|
publicIPs: make(map[string]cachedPublicIP),
|
|
updateCheck: update.CheckLatest,
|
|
updateApply: update.ApplyLatest,
|
|
updateRestart: update.RestartService,
|
|
}
|
|
server.loadAccessConfig(context.Background())
|
|
server.loadUILanguage(context.Background())
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/health", server.handleHealth)
|
|
mux.HandleFunc("/api/auth/login", server.handleLogin)
|
|
mux.HandleFunc("/api/auth/session", server.handleSession)
|
|
mux.HandleFunc("/api/auth/logout", server.handleLogout)
|
|
mux.HandleFunc("/api", server.handleAPI)
|
|
mux.HandleFunc("/api/", server.handleAPI)
|
|
mux.HandleFunc("/websheets/", server.handleWebsheet)
|
|
mux.HandleFunc("/plugin-assets/", server.handlePluginAsset)
|
|
mux.HandleFunc("/", server.handleSPA)
|
|
|
|
server.handler = server.recoverPanics(
|
|
server.securityHeaders(server.accessControl(server.logRequests(mux))),
|
|
)
|
|
return server, nil
|
|
}
|
|
|
|
// VoWiFiController is the asynchronous runtime boundary used by the HTTP
|
|
// layer. State transitions continue after the request completes and are
|
|
// surfaced by the normal device status endpoints.
|
|
type VoWiFiController interface {
|
|
State(string) (vowifi.State, error)
|
|
RequestEnabled(string, bool) (vowifi.State, error)
|
|
RequestReconnect(string) (vowifi.State, error)
|
|
}
|
|
|
|
type VoWiFiCallController interface {
|
|
Calls(string) ([]vowifi.Call, error)
|
|
DialCall(context.Context, string, string) (vowifi.Call, error)
|
|
AnswerCall(context.Context, string, string) (vowifi.Call, error)
|
|
HangupCall(context.Context, string, string) error
|
|
}
|
|
|
|
type VoWiFiCallMediaController interface {
|
|
CallMedia(context.Context, string, string) (vowifi.CallMedia, error)
|
|
}
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.handler.ServeHTTP(w, r)
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
if !requireMethod(w, r, http.MethodGet) {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
|
defer cancel()
|
|
if err := s.store.Ready(ctx); err != nil {
|
|
s.logger.Error("health check failed", "error", err)
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "service is not ready")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"data": map[string]any{
|
|
"status": "ok",
|
|
"database": "ok",
|
|
"time": time.Now().UTC().Format(time.RFC3339),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if !requireMethod(w, r, http.MethodPost) {
|
|
return
|
|
}
|
|
var request struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := s.decodeJSON(w, r, &request); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
|
|
limiterKey := s.loginKey(r, request.Username)
|
|
if retryAfter, locked := s.loginLimiter.checkLocked(limiterKey); locked {
|
|
s.auditAuth(r, request.Username, "locked")
|
|
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())+1))
|
|
writeError(w, http.StatusTooManyRequests, "too_many_attempts", "too many failed login attempts; please try again later")
|
|
return
|
|
}
|
|
|
|
credentials, err := s.auth.Login(r.Context(), request.Username, request.Password)
|
|
if errors.Is(err, auth.ErrInvalidCredentials) {
|
|
lockout, newlyLocked := s.loginLimiter.recordFailure(limiterKey)
|
|
s.auditAuth(r, request.Username, "failure")
|
|
if newlyLocked {
|
|
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(lockout.Seconds())))
|
|
writeError(w, http.StatusTooManyRequests, "too_many_attempts", "too many failed login attempts; please try again later")
|
|
return
|
|
}
|
|
writeError(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.logger.Error("login failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
|
|
return
|
|
}
|
|
|
|
s.loginLimiter.recordSuccess(limiterKey)
|
|
s.auditAuth(r, credentials.Principal.Username, "success")
|
|
s.setAuthCookies(w, credentials.SessionToken, credentials.CSRFToken, credentials.ExpiresAt)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"data": map[string]any{
|
|
"user": credentials.Principal,
|
|
"csrf_token": credentials.CSRFToken,
|
|
"expires_at": credentials.ExpiresAt.Format(time.RFC3339),
|
|
"authenticated": true,
|
|
"status": "ok",
|
|
},
|
|
})
|
|
}
|
|
|
|
// loginKey builds the rate-limit key from the client address and username so
|
|
// brute-force attempts against one account from one source are throttled.
|
|
func (s *Server) loginKey(r *http.Request, username string) string {
|
|
address := s.currentAccessConfig().clientIP(r)
|
|
return address.String() + "|" + strings.ToLower(strings.TrimSpace(username))
|
|
}
|
|
|
|
func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if !requireMethod(w, r, http.MethodGet) {
|
|
return
|
|
}
|
|
sessionToken, ok := s.sessionToken(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
existingCSRF := ""
|
|
if cookie, cookieErr := r.Cookie(csrfCookieName); cookieErr == nil {
|
|
existingCSRF = cookie.Value
|
|
}
|
|
session, csrfToken, err := s.auth.CSRFToken(r.Context(), sessionToken, existingCSRF)
|
|
if errors.Is(err, auth.ErrUnauthorized) {
|
|
s.authenticationRequired(w, r)
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.logger.Error("load session failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
|
|
return
|
|
}
|
|
s.setCSRFCookie(w, csrfToken, session.ExpiresAt)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"data": map[string]any{
|
|
"user": session.Principal,
|
|
"csrf_token": csrfToken,
|
|
"expires_at": session.ExpiresAt.Format(time.RFC3339),
|
|
"authenticated": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if !requireMethod(w, r, http.MethodPost) {
|
|
return
|
|
}
|
|
sessionToken, ok := s.sessionToken(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
csrfToken, ok := s.validateDoubleSubmitCSRF(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := s.auth.ValidateCSRF(r.Context(), sessionToken, csrfToken); err != nil {
|
|
switch {
|
|
case errors.Is(err, auth.ErrUnauthorized):
|
|
s.authenticationRequired(w, r)
|
|
case errors.Is(err, auth.ErrInvalidCSRF):
|
|
writeError(w, http.StatusForbidden, "invalid_csrf", "CSRF validation failed")
|
|
default:
|
|
s.logger.Error("logout validation failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
|
|
}
|
|
return
|
|
}
|
|
if err := s.auth.Logout(r.Context(), sessionToken); err != nil {
|
|
s.logger.Error("logout failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
|
|
return
|
|
}
|
|
s.clearAuthCookies(w)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"data": map[string]bool{"logged_out": true},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAPINotFound(w http.ResponseWriter, r *http.Request) {
|
|
writeError(w, http.StatusNotFound, "not_found", "API endpoint not found")
|
|
}
|
|
|
|
func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
|
|
// The UI language preference is not sensitive; exposing the read side lets
|
|
// the login page render in the persisted language before authentication.
|
|
if r.Method == http.MethodGet &&
|
|
strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/") == "settings/preferences" {
|
|
s.writeUIPreferences(w, r)
|
|
return
|
|
}
|
|
if !s.requireAuthenticated(w, r) {
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet &&
|
|
r.Method != http.MethodHead &&
|
|
r.Method != http.MethodOptions {
|
|
sessionToken, ok := s.sessionToken(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
csrfToken, ok := s.validateDoubleSubmitCSRF(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := s.auth.ValidateCSRF(r.Context(), sessionToken, csrfToken); err != nil {
|
|
switch {
|
|
case errors.Is(err, auth.ErrUnauthorized):
|
|
s.authenticationRequired(w, r)
|
|
case errors.Is(err, auth.ErrInvalidCSRF):
|
|
writeError(w, http.StatusForbidden, "invalid_csrf", "CSRF validation failed")
|
|
default:
|
|
s.logger.Error("API CSRF validation failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
if s.routeDeviceAPI(w, r) {
|
|
return
|
|
}
|
|
if s.routeGeneralAPI(w, r) {
|
|
return
|
|
}
|
|
s.handleAPINotFound(w, r)
|
|
}
|
|
|
|
func (s *Server) handleSPA(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
w.Header().Set("Allow", "GET, HEAD")
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
|
return
|
|
}
|
|
|
|
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
|
if name != "." && fs.ValidPath(name) {
|
|
if info, err := fs.Stat(s.assets, name); err == nil && info.Mode().IsRegular() {
|
|
if strings.HasPrefix(name, "assets/") {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
}
|
|
s.fileServer.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Content-Type", mime.TypeByExtension(".html"))
|
|
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(s.indexHTML))
|
|
}
|
|
|
|
func (s *Server) decodeJSON(w http.ResponseWriter, r *http.Request, destination any) error {
|
|
contentType := r.Header.Get("Content-Type")
|
|
if contentType != "" {
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
if err != nil ||
|
|
(mediaType != "application/json" && !strings.HasSuffix(mediaType, "+json")) {
|
|
return errors.New("Content-Type must be application/json")
|
|
}
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, s.maxRequestBodyBytes)
|
|
decoder := json.NewDecoder(r.Body)
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(destination); err != nil {
|
|
var maxBytesError *http.MaxBytesError
|
|
if errors.As(err, &maxBytesError) {
|
|
return fmt.Errorf("request body exceeds %d bytes", s.maxRequestBodyBytes)
|
|
}
|
|
return errors.New("request body must contain one valid JSON object")
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
return errors.New("request body must contain one valid JSON object")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) sessionToken(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
cookie, err := r.Cookie(sessionCookieName)
|
|
if err != nil || cookie.Value == "" {
|
|
s.authenticationRequired(w, r)
|
|
return "", false
|
|
}
|
|
return cookie.Value, true
|
|
}
|
|
|
|
func (s *Server) requireAuthenticated(w http.ResponseWriter, r *http.Request) bool {
|
|
sessionToken, ok := s.sessionToken(w, r)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if _, err := s.auth.Authenticate(r.Context(), sessionToken); err != nil {
|
|
if errors.Is(err, auth.ErrUnauthorized) {
|
|
s.authenticationRequired(w, r)
|
|
} else {
|
|
s.logger.Error("request authentication failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// authenticationRequired preserves JSON semantics for API clients while
|
|
// making a direct browser navigation land on the login screen instead of a
|
|
// raw {"error":...} document. Frontend fetches explicitly request JSON and
|
|
// are handled by the shared vocat:unauthorized event.
|
|
func (s *Server) authenticationRequired(w http.ResponseWriter, r *http.Request) {
|
|
s.clearAuthCookies(w)
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
|
strings.Contains(strings.ToLower(r.Header.Get("Accept")), "text/html") {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required")
|
|
}
|
|
|
|
func (s *Server) validateDoubleSubmitCSRF(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
headerToken := r.Header.Get(csrfHeaderName)
|
|
cookie, err := r.Cookie(csrfCookieName)
|
|
if err != nil || headerToken == "" || cookie.Value == "" ||
|
|
subtle.ConstantTimeCompare([]byte(headerToken), []byte(cookie.Value)) != 1 {
|
|
writeError(w, http.StatusForbidden, "invalid_csrf", "CSRF validation failed")
|
|
return "", false
|
|
}
|
|
return headerToken, true
|
|
}
|
|
|
|
func (s *Server) setAuthCookies(w http.ResponseWriter, sessionToken string, csrfToken string, expiresAt time.Time) {
|
|
maxAge := int(time.Until(expiresAt).Seconds())
|
|
if maxAge < 1 {
|
|
maxAge = 1
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: sessionToken,
|
|
Path: "/",
|
|
Expires: expiresAt,
|
|
MaxAge: maxAge,
|
|
HttpOnly: true,
|
|
Secure: s.secureCookies,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
s.setCSRFCookie(w, csrfToken, expiresAt)
|
|
}
|
|
|
|
func (s *Server) setCSRFCookie(w http.ResponseWriter, csrfToken string, expiresAt time.Time) {
|
|
maxAge := int(time.Until(expiresAt).Seconds())
|
|
if maxAge < 1 {
|
|
maxAge = 1
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: csrfCookieName,
|
|
Value: csrfToken,
|
|
Path: "/",
|
|
Expires: expiresAt,
|
|
MaxAge: maxAge,
|
|
HttpOnly: false,
|
|
Secure: s.secureCookies,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
func (s *Server) clearAuthCookies(w http.ResponseWriter) {
|
|
for _, name := range []string{sessionCookieName, csrfCookieName} {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name,
|
|
Value: "",
|
|
Path: "/",
|
|
Expires: time.Unix(1, 0),
|
|
MaxAge: -1,
|
|
HttpOnly: name == sessionCookieName,
|
|
Secure: s.secureCookies,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
}
|
|
|
|
func requireMethod(w http.ResponseWriter, r *http.Request, allowed string) bool {
|
|
if r.Method == allowed {
|
|
return true
|
|
}
|
|
w.Header().Set("Allow", allowed)
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
|
return false
|
|
}
|
|
|
|
type statusWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (w *statusWriter) Unwrap() http.ResponseWriter {
|
|
return w.ResponseWriter
|
|
}
|
|
|
|
func (w *statusWriter) WriteHeader(status int) {
|
|
if w.status != 0 {
|
|
return
|
|
}
|
|
w.status = status
|
|
w.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (w *statusWriter) Write(data []byte) (int, error) {
|
|
if w.status == 0 {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
return w.ResponseWriter.Write(data)
|
|
}
|
|
|
|
func (s *Server) logRequests(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
startedAt := time.Now()
|
|
writer := &statusWriter{ResponseWriter: w}
|
|
next.ServeHTTP(writer, r)
|
|
status := writer.status
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
s.logger.Info(
|
|
"http request",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", status,
|
|
"duration", time.Since(startedAt),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Referrer-Policy", "same-origin")
|
|
w.Header().Set("Permissions-Policy", "camera=(), microphone=(self), geolocation=()")
|
|
if strings.HasPrefix(r.URL.Path, "/websheets/") || strings.HasPrefix(r.URL.Path, "/plugin-assets/") {
|
|
// The self-hosted E911 websheet is embedded in an iframe by the SPA, so
|
|
// it must be frameable same-origin. Every other route stays DENY.
|
|
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
|
w.Header().Set(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; base-uri 'self'; frame-ancestors 'self'; "+
|
|
"object-src 'none'; form-action 'self'; "+
|
|
"script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; "+
|
|
"img-src 'self' data:; connect-src 'self'",
|
|
)
|
|
} else {
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; base-uri 'self'; frame-ancestors 'none'; "+
|
|
"object-src 'none'; form-action 'self'; "+
|
|
"script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; "+
|
|
"img-src 'self' data:; connect-src 'self'",
|
|
)
|
|
}
|
|
if s.secureCookies && s.https == nil {
|
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) recoverPanics(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
s.logger.Error("panic while serving request", "panic", recovered)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|