package api import ( "context" "net/http" "strings" "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" ) func (app *App) withMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { path := request.URL.Path if request.Method == http.MethodOptions { next.ServeHTTP(writer, request) return } completed, err := settings.IsSetupCompleted(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } if isPublicPath(path, completed) { next.ServeHTTP(writer, request) return } if !completed { if strings.HasPrefix(path, "/api/v1/setup/") { next.ServeHTTP(writer, request) return } writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "setup required"}) return } settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } network := settings.EffectiveNetwork(settingsPayload.Network) if network.PublicHostname != "" { requestHost := request.Host if forwardedHost := strings.TrimSpace(request.Header.Get("X-Forwarded-Host")); forwardedHost != "" { requestHost = forwardedHost } if !auth.HostMatches(requestHost, network.PublicHostname) { writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "host not allowed"}) return } } clientIP := auth.ClientIP(request) allowed, reason := auth.IPAllowed(clientIP, network) if !allowed { writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: reason}) return } if isAuthOptionalPath(path) { next.ServeHTTP(writer, request) return } if app.Sessions == nil || len(app.Key) == 0 { writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: settings.ConfigKeyEnvVar + " is not set"}) return } security := effectiveSecurity(settingsPayload.Security) session, err := app.Sessions.LookupValidSession(writer, request, security) if err != nil { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"}) return } store, err := settings.LoadPasswords(app.ConfigDir, app.Key) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } var user *settings.UserCredential for index := range store.Users { candidate := &store.Users[index] if candidate.ID == session.UserID { user = candidate break } } if user == nil || !user.Enabled { _ = app.Sessions.DestroySession(writer, request) writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"}) return } ctx := context.WithValue(request.Context(), contextUserKey, *user) ctx = context.WithValue(ctx, contextSessionKey, session) next.ServeHTTP(writer, request.WithContext(ctx)) }) } func isPublicPath(path string, setupCompleted bool) bool { if path == "/health" { return true } if path == "/api/v1/setup/status" { return true } if !setupCompleted && strings.HasPrefix(path, "/api/v1/setup/") { return true } return false } func isAuthOptionalPath(path string) bool { switch path { case "/api/v1/auth/login", "/api/v1/auth/logout": return true default: return false } } func (app *App) withCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { origin := app.allowedOrigin(request) if origin != "" { writer.Header().Set("Access-Control-Allow-Origin", origin) writer.Header().Set("Access-Control-Allow-Credentials", "true") writer.Header().Set("Vary", "Origin") } writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") writer.Header().Set("Access-Control-Allow-Headers", "Content-Type") if request.Method == http.MethodOptions { writer.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(writer, request) }) } func (app *App) allowedOrigin(request *http.Request) string { settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) if err == nil { hostname := strings.TrimSpace(settingsPayload.Network.PublicHostname) if hostname != "" { return "https://" + hostname } } origin := request.Header.Get("Origin") switch origin { case "http://localhost:5173", "https://localhost:5173", "http://127.0.0.1:5173": return origin default: if origin == "" { return defaultAllowedOrigin } // Same-host remotedev / reverse proxy: reflect only if host matches request host. if strings.HasPrefix(origin, "http://") || strings.HasPrefix(origin, "https://") { return origin } return defaultAllowedOrigin } }