package api import ( "bytes" "crypto/subtle" "encoding/json" "fmt" "io" "log" "net/http" "strings" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/assets" "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" "gitea.dooplex.hu/admin/felhom-hub/internal/notify" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // ConfigTemplateProvider returns the controller.yaml template for config generation. type ConfigTemplateProvider interface { Template() string } // Handler handles API endpoints for report ingest and customer queries. type Handler struct { store *store.Store apiKey string resendAPIKey string fromEmail string logger *log.Logger httpClient *http.Client templateProvider ConfigTemplateProvider dispatcher *notify.Dispatcher assetsMgr *assets.Manager } // New creates a new API handler. func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, templateProvider ConfigTemplateProvider, logger *log.Logger) *Handler { return &Handler{ store: store, apiKey: apiKey, resendAPIKey: resendAPIKey, fromEmail: fromEmail, logger: logger, httpClient: &http.Client{Timeout: 10 * time.Second}, templateProvider: templateProvider, } } // SetDispatcher sets the notification dispatcher for event-triggered emails. func (h *Handler) SetDispatcher(d *notify.Dispatcher) { h.dispatcher = d } // SetAssetManager sets the asset manager for serving app assets to controllers. func (h *Handler) SetAssetManager(am *assets.Manager) { h.assetsMgr = am } // checkAuth verifies the Bearer token against the global API key or a per-customer API key. // Returns true if authorized. func (h *Handler) checkAuth(r *http.Request) bool { _, _, ok := h.checkAuthCustomer(r) return ok } // checkAuthCustomer verifies the Bearer token and returns the authenticated customer identity. // For per-customer keys: returns (customerID, false, true). // For global key: returns ("", true, true) — caller must allow any customer_id. // On failure: returns ("", false, false). func (h *Handler) checkAuthCustomer(r *http.Request) (customerID string, isGlobal bool, ok bool) { auth := r.Header.Get("Authorization") if !strings.HasPrefix(auth, "Bearer ") { return "", false, false } token := strings.TrimPrefix(auth, "Bearer ") // Check global key first if h.apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(h.apiKey)) == 1 { return "", true, true } // Check per-customer key cfg, err := h.store.GetCustomerConfigByAPIKey(token) if err != nil || cfg == nil { return "", false, false } return cfg.CustomerID, false, true } // ServeHTTP routes API requests. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/v1") switch { case r.Method == http.MethodPost && path == "/report": h.handleReport(w, r) case r.Method == http.MethodPost && path == "/event": h.handleEvent(w, r) case r.Method == http.MethodPost && path == "/notify": h.handleNotify(w, r) case r.Method == http.MethodPost && path == "/infra-backup": h.handleInfraBackupPush(w, r) case r.Method == http.MethodGet && strings.HasPrefix(path, "/infra-backup/"): h.handleInfraBackupGet(w, r, strings.TrimPrefix(path, "/infra-backup/")) case r.Method == http.MethodPost && path == "/preferences": h.handleSavePreferences(w, r) case r.Method == http.MethodGet && path == "/customers": h.handleCustomers(w, r) case r.Method == http.MethodGet && strings.HasPrefix(path, "/customers/"): parts := strings.Split(strings.TrimPrefix(path, "/customers/"), "/") customerID := parts[0] if len(parts) > 1 && parts[1] == "history" { h.handleCustomerHistory(w, r, customerID) } else { h.handleCustomer(w, r, customerID) } case r.Method == http.MethodGet && strings.HasPrefix(path, "/recovery/"): customerID := strings.TrimPrefix(path, "/recovery/") h.handleRecovery(w, r, customerID) case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"): customerID := strings.TrimPrefix(path, "/config/") h.handleConfigRetrieve(w, r, customerID) case r.Method == http.MethodGet && path == "/assets/manifest": h.handleAssetsManifest(w, r) case r.Method == http.MethodGet && strings.HasPrefix(path, "/assets/file/"): filename := strings.TrimPrefix(path, "/assets/file/") h.handleAssetFile(w, r, filename) default: http.NotFound(w, r) } } func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) { authCustomerID, isGlobal, ok := h.checkAuthCustomer(r) if !ok { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit if err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } // Extract customer_id from JSON var payload struct { CustomerID string `json:"customer_id"` } if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" { http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest) return } // Validate customer_id matches authenticated customer (unless global key) if !isGlobal && authCustomerID != payload.CustomerID { http.Error(w, "Forbidden: customer_id mismatch", http.StatusForbidden) return } if err := h.store.SaveReport(payload.CustomerID, body); err != nil { h.logger.Printf("[ERROR] Failed to save report from %s: %v", payload.CustomerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } h.logger.Printf("[INFO] Received report from %s (%d bytes)", payload.CustomerID, len(body)) // Build response with optional customer_blocked flag resp := map[string]interface{}{"status": "ok"} if custCfg, err := h.store.GetCustomerConfig(payload.CustomerID); err == nil && custCfg != nil { if custCfg.Status == "blocked" { resp["customer_blocked"] = true } } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(resp) } // allowedEventTypes lists all valid event_type values the Hub accepts. var allowedEventTypes = map[string]bool{ // Controller-pushed events "controller_started": true, "controller_updated": true, "backup_completed": true, "backup_failed": true, "db_dump_completed": true, "db_dump_failed": true, "backup_integrity_ok": true, "backup_integrity_failed": true, "crossdrive_completed": true, "crossdrive_failed": true, "storage_disconnected": true, "storage_reconnected": true, "disk_warning": true, "disk_critical": true, "health_degraded": true, "health_critical": true, "health_recovered": true, "app_deployed": true, "app_removed": true, "disaster_recovery_started": true, "disaster_recovery_completed": true, // Hub-generated events "node_stale": true, "node_down": true, "node_recovered": true, "expected_backup_missed": true, "expected_dbdump_missed": true, // Special "test": true, } // handleEvent processes structured events from controllers (new endpoint, replaces /notify for updated controllers). func (h *Handler) handleEvent(w http.ResponseWriter, r *http.Request) { authCustomerID, isGlobal, ok := h.checkAuthCustomer(r) if !ok { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } var payload struct { CustomerID string `json:"customer_id"` EventType string `json:"event_type"` Severity string `json:"severity"` Message string `json:"message"` Details json.RawMessage `json:"details"` } if err := json.Unmarshal(body, &payload); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } if payload.CustomerID == "" || payload.EventType == "" { http.Error(w, "customer_id and event_type are required", http.StatusBadRequest) return } // Validate customer_id matches authenticated customer (unless global key) if !isGlobal && authCustomerID != payload.CustomerID { http.Error(w, "Forbidden: customer_id mismatch", http.StatusForbidden) return } // Validate event_type if !allowedEventTypes[payload.EventType] { http.Error(w, fmt.Sprintf("Invalid event_type: %s", payload.EventType), http.StatusBadRequest) return } // Validate/default severity switch payload.Severity { case "info", "warning", "error": default: payload.Severity = "info" } // Store details as JSON string detailsStr := "{}" if len(payload.Details) > 0 && string(payload.Details) != "null" { detailsStr = string(payload.Details) } _, err = h.store.SaveEvent(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, detailsStr, "controller") if err != nil { h.logger.Printf("[ERROR] Failed to save event from %s: %v", payload.CustomerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } h.logger.Printf("[INFO] Event from %s: %s (%s) — %s", payload.CustomerID, payload.EventType, payload.Severity, payload.Message) // Dispatch notifications (non-blocking) if h.dispatcher != nil { go h.dispatcher.ProcessEvent(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, detailsStr, "controller") } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"ok":true}`)) } func (h *Handler) handleCustomers(w http.ResponseWriter, r *http.Request) { customers, err := h.store.GetCustomers() if err != nil { h.logger.Printf("[ERROR] Failed to get customers: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } type customerJSON struct { ID string `json:"id"` Name string `json:"name"` ControllerVersion string `json:"controller_version"` ControllerURL string `json:"controller_url,omitempty"` HealthStatus string `json:"health_status"` LastSeen time.Time `json:"last_seen"` CPUPercent float64 `json:"cpu_percent"` MemoryPercent float64 `json:"memory_percent"` ContainerTotal int `json:"container_total"` ContainerRunning int `json:"container_running"` BackupLastSnapshot *time.Time `json:"backup_last_snapshot"` } result := make([]customerJSON, 0, len(customers)) for _, c := range customers { result = append(result, customerJSON{ ID: c.CustomerID, Name: c.CustomerName, ControllerVersion: c.ControllerVersion, ControllerURL: c.ControllerURL, HealthStatus: c.HealthStatus, LastSeen: c.ReceivedAt, CPUPercent: c.CPUPercent, MemoryPercent: c.MemoryPercent, ContainerTotal: c.ContainerTotal, ContainerRunning: c.ContainerRunning, BackupLastSnapshot: c.BackupLastSnapshot, }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } func (h *Handler) handleCustomer(w http.ResponseWriter, r *http.Request, customerID string) { customer, err := h.store.GetCustomer(customerID) if err != nil { h.logger.Printf("[ERROR] Failed to get customer %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if customer == nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") // Return the full report JSON directly w.Write([]byte(customer.ReportJSON)) } func (h *Handler) handleCustomerHistory(w http.ResponseWriter, r *http.Request, customerID string) { period := r.URL.Query().Get("period") var since time.Duration switch period { case "7d": since = 7 * 24 * time.Hour case "30d": since = 30 * 24 * time.Hour default: since = 24 * time.Hour } history, err := h.store.GetCustomerHistory(customerID, since) if err != nil { h.logger.Printf("[ERROR] Failed to get history for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } type historyEntry struct { ReceivedAt time.Time `json:"received_at"` HealthStatus string `json:"health_status"` CPUPercent float64 `json:"cpu_percent"` MemoryPercent float64 `json:"memory_percent"` } result := make([]historyEntry, 0, len(history)) for _, h := range history { result = append(result, historyEntry{ ReceivedAt: h.ReceivedAt, HealthStatus: h.HealthStatus, CPUPercent: h.CPUPercent, MemoryPercent: h.MemoryPercent, }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } // handleNotify processes notification events from customer controllers. func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) { if !h.checkAuth(r) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } var payload struct { CustomerID string `json:"customer_id"` EventType string `json:"event_type"` Severity string `json:"severity"` Message string `json:"message"` Details string `json:"details"` } if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" || payload.EventType == "" { http.Error(w, "Invalid payload: customer_id and event_type required", http.StatusBadRequest) return } h.logger.Printf("[INFO] Notification from %s: %s (%s) — %s", payload.CustomerID, payload.EventType, payload.Severity, payload.Message) // Check if customer is blocked if h.store.IsCustomerBlocked(payload.CustomerID) { h.logger.Printf("[INFO] Notification suppressed for blocked customer %s", payload.CustomerID) h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "customer blocked", "customer") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok","sent":false,"reason":"blocked"}`)) return } // Look up customer notification preferences prefs, err := h.store.GetNotificationPrefs(payload.CustomerID) if err != nil { h.logger.Printf("[ERROR] Failed to get notification prefs for %s: %v", payload.CustomerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } // Check if customer has email configured and event type is enabled if prefs == nil || prefs.Email == "" { h.logger.Printf("[INFO] No email configured for %s, skipping notification", payload.CustomerID) h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "no email configured", "customer") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok","sent":false,"reason":"no_email"}`)) return } // Check if event type is in the enabled list (test events always pass) eventEnabled := payload.EventType == "test" for _, e := range prefs.EnabledEvents { if e == payload.EventType { eventEnabled = true break } } if !eventEnabled { h.logger.Printf("[INFO] Event %s not enabled for %s, skipping", payload.EventType, payload.CustomerID) h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "event not enabled", "customer") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok","sent":false,"reason":"event_disabled"}`)) return } // Send email via Resend API if h.resendAPIKey == "" { h.logger.Printf("[WARN] Resend API key not configured, cannot send notification email") h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "resend api key not configured", "customer") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok","sent":false,"reason":"no_api_key"}`)) return } subject, emailBody := formatNotificationEmail(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, payload.Details) sendErr := h.sendResendEmail(prefs.Email, subject, emailBody) if sendErr != nil { h.logger.Printf("[ERROR] Failed to send notification email to %s: %v", prefs.Email, sendErr) h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "failed", sendErr.Error(), "customer") http.Error(w, "Failed to send email", http.StatusInternalServerError) return } h.logger.Printf("[INFO] Notification email sent to %s for %s/%s", prefs.Email, payload.CustomerID, payload.EventType) h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "sent", "", "customer") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok","sent":true}`)) } // handleSavePreferences stores notification preferences pushed from a customer controller. func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request) { if !h.checkAuth(r) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } var payload struct { CustomerID string `json:"customer_id"` Email string `json:"email"` EnabledEvents []string `json:"enabled_events"` CooldownHours int `json:"cooldown_hours"` } if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" { http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest) return } if err := h.store.SaveNotificationPrefs(payload.CustomerID, payload.Email, payload.EnabledEvents, payload.CooldownHours); err != nil { h.logger.Printf("[ERROR] Failed to save notification prefs for %s: %v", payload.CustomerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } h.logger.Printf("[INFO] Notification preferences updated for %s: email=%s, events=%v", payload.CustomerID, payload.Email, payload.EnabledEvents) w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok"}`)) } // handleInfraBackupPush stores an infrastructure snapshot from a controller. func (h *Handler) handleInfraBackupPush(w http.ResponseWriter, r *http.Request) { if !h.checkAuth(r) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit if err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } var payload struct { CustomerID string `json:"customer_id"` } if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" { http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest) return } if err := h.store.SaveInfraBackup(payload.CustomerID, body); err != nil { h.logger.Printf("[ERROR] Failed to save infra backup for %s: %v", payload.CustomerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } h.logger.Printf("[INFO] Infra backup saved for %s (%d bytes)", payload.CustomerID, len(body)) w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok"}`)) } // handleInfraBackupGet returns the infrastructure backup for a customer. func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, customerID string) { if !h.checkAuth(r) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } if customerID == "" { http.Error(w, "Missing customer_id", http.StatusBadRequest) return } data, err := h.store.GetInfraBackup(customerID) if err != nil { h.logger.Printf("[ERROR] Failed to get infra backup for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if data == nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") w.Write(data) } // handleRecovery returns both the generated controller.yaml and the infra backup for disaster recovery. // Auth: X-Retrieval-Password header (same as config retrieval). func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, customerID string) { if customerID == "" { http.Error(w, "Missing customer_id", http.StatusBadRequest) return } password := r.Header.Get("X-Retrieval-Password") if password == "" { http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized) return } cfg, err := h.store.GetCustomerConfig(customerID) if err != nil { h.logger.Printf("[ERROR] Recovery: failed to get customer config for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if cfg == nil { http.Error(w, "Not found", http.StatusNotFound) return } if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 { http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized) return } // Generate controller.yaml var configYAML string if h.templateProvider != nil { yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg) if err != nil { h.logger.Printf("[ERROR] Recovery: failed to generate config for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } configYAML = yamlOutput } // Fetch infra backup (optional — may not exist for new customers) var infraBackup json.RawMessage hasInfraBackup := false if data, err := h.store.GetInfraBackup(customerID); err == nil && data != nil { infraBackup = data hasInfraBackup = true } resp := struct { CustomerID string `json:"customer_id"` ConfigYAML string `json:"config_yaml"` InfraBackup json.RawMessage `json:"infra_backup"` HasInfraBackup bool `json:"has_infra_backup"` }{ CustomerID: customerID, ConfigYAML: configYAML, InfraBackup: infraBackup, HasInfraBackup: hasInfraBackup, } h.logger.Printf("[INFO] Recovery data downloaded for customer %s (has_infra_backup=%v)", customerID, hasInfraBackup) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // handleConfigRetrieve returns a generated controller.yaml for a customer. // Auth: X-Retrieval-Password header (not Bearer token). func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, customerID string) { if customerID == "" { http.Error(w, "Missing customer_id", http.StatusBadRequest) return } password := r.Header.Get("X-Retrieval-Password") if password == "" { http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized) return } cfg, err := h.store.GetCustomerConfig(customerID) if err != nil { h.logger.Printf("[ERROR] Failed to get customer config for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if cfg == nil { http.Error(w, "Not found", http.StatusNotFound) return } // Constant-time comparison to prevent timing attacks if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 { http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized) return } if h.templateProvider == nil { http.Error(w, "Config generation not available", http.StatusServiceUnavailable) return } yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg) if err != nil { h.logger.Printf("[ERROR] Failed to generate config for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } h.logger.Printf("[INFO] Config downloaded for customer %s", customerID) w.Header().Set("Content-Type", "text/yaml; charset=utf-8") w.Write([]byte(yamlOutput)) } // sendResendEmail sends an email via the Resend HTTP API. func (h *Handler) sendResendEmail(to, subject, textBody string) error { payload := map[string]interface{}{ "from": h.fromEmail, "to": []string{to}, "subject": subject, "text": textBody, } jsonData, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshaling email payload: %w", err) } req, err := http.NewRequest("POST", "https://api.resend.com/emails", bytes.NewReader(jsonData)) if err != nil { return fmt.Errorf("creating request: %w", err) } req.Header.Set("Authorization", "Bearer "+h.resendAPIKey) req.Header.Set("Content-Type", "application/json") resp, err := h.httpClient.Do(req) if err != nil { return fmt.Errorf("sending request: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return fmt.Errorf("resend API returned %d: %s", resp.StatusCode, string(respBody)) } return nil } // formatNotificationEmail creates a Hungarian email subject and body. func formatNotificationEmail(customerID, eventType, severity, message, details string) (string, string) { severityLabel := map[string]string{ "info": "Információ", "warning": "Figyelmeztetés", "error": "Hiba", "critical": "Kritikus", } label := severityLabel[severity] if label == "" { label = severity } subject := fmt.Sprintf("[Felhom] %s: %s", label, message) now := time.Now().Format("2006-01-02 15:04") emailText := fmt.Sprintf(`Kedves Ügyfél! A Felhom rendszered a következő figyelmeztetést jelezte: %s Részletek: - Szerver: %s - Időpont: %s - Szint: %s - Típus: %s`, message, customerID, now, label, eventType) if details != "" { emailText += fmt.Sprintf("\n- Megjegyzés: %s", details) } emailText += ` Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. Üdvözlettel, Felhom.eu monitoring` return subject, emailText } // --- Asset endpoints --- func (h *Handler) handleAssetsManifest(w http.ResponseWriter, r *http.Request) { if !h.checkAuth(r) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } if h.assetsMgr == nil { http.Error(w, "Assets not configured", http.StatusServiceUnavailable) return } data, err := h.assetsMgr.MarshalManifestJSON() if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Write(data) } func (h *Handler) handleAssetFile(w http.ResponseWriter, r *http.Request, filename string) { if !h.checkAuth(r) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } if h.assetsMgr == nil { http.Error(w, "Assets not configured", http.StatusServiceUnavailable) return } h.assetsMgr.ServeFile(w, r, filename) }