// backend/camera_firmware.go package main import ( "context" "database/sql" "encoding/json" "errors" "html" "io" "net/http" "regexp" "strconv" "strings" "time" "unicode" "github.com/jackc/pgx/v5" ) const cameraFirmwareCacheMaxAge = 24 * time.Hour const cameraFirmwareManualCheckCooldown = time.Hour type cameraFirmwareProvider interface { Supports(manufacturer string, model string) bool CheckLatestFirmware(ctx context.Context, manufacturer string, model string) (cameraFirmwareLatest, error) } type cameraFirmwareLatest struct { LatestVersion string SupportURL string Source string SupportBadges []DeviceSupportBadge } type cameraFirmwareCacheEntry struct { Manufacturer string NormalizedModel string LatestVersion string SupportURL string Source string CheckError string SupportBadges []DeviceSupportBadge CheckedAt time.Time } type cameraFirmwareManualCheckState struct { LastManualCheckedAt *time.Time NextAllowedAt *time.Time CanCheck bool RemainingSeconds int64 } type firmwareLookupKey struct { Manufacturer string NormalizedModel string Model string } func cameraFirmwareProviders() []cameraFirmwareProvider { return []cameraFirmwareProvider{ axisFirmwareProvider{}, } } func (s *Server) enrichDevicesWithFirmwareUpdates(ctx context.Context, devices []Device) { if len(devices) == 0 { return } lookupKeys := map[firmwareLookupKey]bool{} for _, device := range devices { manufacturer := strings.TrimSpace(device.Manufacturer) model := strings.TrimSpace(device.Model) if manufacturer == "" || model == "" { continue } provider := findCameraFirmwareProvider(manufacturer, model) if provider == nil { continue } lookupKeys[firmwareLookupKey{ Manufacturer: normalizeCameraFirmwareKey(manufacturer), NormalizedModel: normalizeCameraFirmwareKey(model), Model: model, }] = true } if len(lookupKeys) == 0 { return } results := map[firmwareLookupKey]cameraFirmwareCacheEntry{} for lookupKey := range lookupKeys { provider := findCameraFirmwareProvider(lookupKey.Manufacturer, lookupKey.Model) if provider == nil { continue } cachedEntry, cached, err := s.getCameraFirmwareCache( ctx, lookupKey.Manufacturer, lookupKey.NormalizedModel, ) if err != nil { logError("Firmware-Cache konnte nicht geladen werden", err, LogFields{ "manufacturer": lookupKey.Manufacturer, "model": lookupKey.Model, }) continue } if cached && time.Since(cachedEntry.CheckedAt) <= cameraFirmwareCacheMaxAge { results[lookupKey] = cachedEntry continue } latest, err := provider.CheckLatestFirmware( ctx, lookupKey.Manufacturer, lookupKey.Model, ) if err != nil { logError("Firmware-Version konnte nicht geprüft werden", err, LogFields{ "manufacturer": lookupKey.Manufacturer, "model": lookupKey.Model, }) if cached { results[lookupKey] = cachedEntry } _ = s.recordCameraFirmwareCheckError( ctx, lookupKey.Manufacturer, lookupKey.NormalizedModel, err.Error(), ) continue } cacheEntry, err := s.upsertCameraFirmwareCache( ctx, lookupKey.Manufacturer, lookupKey.NormalizedModel, latest, ) if err != nil { logError("Firmware-Cache konnte nicht gespeichert werden", err, LogFields{ "manufacturer": lookupKey.Manufacturer, "model": lookupKey.Model, "latestVersion": latest.LatestVersion, }) continue } results[lookupKey] = cacheEntry } for index := range devices { device := &devices[index] lookupKey := firmwareLookupKey{ Manufacturer: normalizeCameraFirmwareKey(device.Manufacturer), NormalizedModel: normalizeCameraFirmwareKey(device.Model), Model: device.Model, } cacheEntry, exists := results[lookupKey] if !exists { continue } if len(cacheEntry.SupportBadges) > 0 { device.SupportBadges = cacheEntry.SupportBadges } if strings.TrimSpace(cacheEntry.LatestVersion) == "" || strings.TrimSpace(device.FirmwareVersion) == "" { continue } available := isFirmwareVersionNewer( cacheEntry.LatestVersion, device.FirmwareVersion, ) device.FirmwareUpdate = &DeviceFirmwareUpdate{ Available: available, CurrentVersion: device.FirmwareVersion, LatestVersion: cacheEntry.LatestVersion, SupportURL: cacheEntry.SupportURL, Source: cacheEntry.Source, CheckedAt: &cacheEntry.CheckedAt, } } } func (s *Server) handleGetCameraFirmwareCheckState(w http.ResponseWriter, r *http.Request) { state, err := s.getCameraFirmwareManualCheckState(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Firmware-Prüfstatus konnte nicht geladen werden") return } lastCacheCheckedAt, err := s.getLastCameraFirmwareCacheCheckedAt(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Letzte Firmware-Prüfung konnte nicht geladen werden") return } writeJSON(w, http.StatusOK, map[string]any{ "canCheck": state.CanCheck, "lastCheckedAt": lastCacheCheckedAt, "lastManualCheckedAt": state.LastManualCheckedAt, "nextAllowedAt": state.NextAllowedAt, "remainingSeconds": state.RemainingSeconds, }) } func (s *Server) handleCheckCameraFirmware(w http.ResponseWriter, r *http.Request) { user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } state, err := s.getCameraFirmwareManualCheckState(r.Context()) if err != nil { s.logAndWriteUserError( w, r, user, http.StatusInternalServerError, "camera_firmware", "load_manual_check_state", "Firmware-Prüfstatus konnte nicht geladen werden", err, nil, ) return } if !state.CanCheck { writeJSON(w, http.StatusTooManyRequests, map[string]any{ "error": "Firmware kann nur einmal pro Stunde manuell geprüft werden", "nextAllowedAt": state.NextAllowedAt, "remainingSeconds": state.RemainingSeconds, }) return } devices, err := s.listDevicesForFirmwareCheck(r.Context()) if err != nil { s.logAndWriteUserError( w, r, user, http.StatusInternalServerError, "camera_firmware", "list_devices", "Geräte konnten für die Firmware-Prüfung nicht geladen werden", err, nil, ) return } checkedModels, failedModels, err := s.refreshCameraFirmwareForDevices( r.Context(), devices, ) if err != nil { s.logAndWriteUserError( w, r, user, http.StatusInternalServerError, "camera_firmware", "manual_check", "Firmware-Prüfung konnte nicht ausgeführt werden", err, LogFields{ "checkedModels": checkedModels, "failedModels": failedModels, }, ) return } checkedAt := time.Now().UTC() if err := s.saveCameraFirmwareManualCheckState(r.Context(), user.ID, checkedAt); err != nil { s.logAndWriteUserError( w, r, user, http.StatusInternalServerError, "camera_firmware", "save_manual_check_state", "Firmware-Prüfstatus konnte nicht gespeichert werden", err, nil, ) return } s.logUserEvent(r.Context(), UserLogEntry{ User: &user, Request: r, Level: UserLogLevelInfo, Category: "camera_firmware", Action: "manual_check", Message: "Firmware wurde manuell geprüft", Details: LogFields{ "checkedModels": checkedModels, "failedModels": failedModels, }, }) writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "checkedModels": checkedModels, "failedModels": failedModels, "checkedAt": checkedAt, "nextAllowedAt": checkedAt.Add(cameraFirmwareManualCheckCooldown), "remainingSeconds": int64(cameraFirmwareManualCheckCooldown.Seconds()), }) } func (s *Server) getCameraFirmwareManualCheckState( ctx context.Context, ) (cameraFirmwareManualCheckState, error) { state := cameraFirmwareManualCheckState{ CanCheck: true, } var lastCheckedAt sql.NullTime err := s.db.QueryRow( ctx, ` SELECT last_checked_at FROM camera_firmware_refresh_state WHERE id = 1 LIMIT 1 `, ).Scan(&lastCheckedAt) if errors.Is(err, pgx.ErrNoRows) { return state, nil } if err != nil { return state, err } if !lastCheckedAt.Valid { return state, nil } lastManualCheckedAt := lastCheckedAt.Time nextAllowedAt := lastManualCheckedAt.Add(cameraFirmwareManualCheckCooldown) now := time.Now() state.LastManualCheckedAt = &lastManualCheckedAt state.NextAllowedAt = &nextAllowedAt state.CanCheck = !now.Before(nextAllowedAt) if !state.CanCheck { state.RemainingSeconds = int64(time.Until(nextAllowedAt).Seconds()) if state.RemainingSeconds < 1 { state.RemainingSeconds = 1 } } return state, nil } func (s *Server) saveCameraFirmwareManualCheckState( ctx context.Context, userID string, checkedAt time.Time, ) error { _, err := s.db.Exec( ctx, ` INSERT INTO camera_firmware_refresh_state ( id, last_checked_at, last_checked_by, updated_at ) VALUES (1, $1, $2, now()) ON CONFLICT (id) DO UPDATE SET last_checked_at = EXCLUDED.last_checked_at, last_checked_by = EXCLUDED.last_checked_by, updated_at = now() `, checkedAt, userID, ) return err } func (s *Server) getLastCameraFirmwareCacheCheckedAt( ctx context.Context, ) (*time.Time, error) { var checkedAt sql.NullTime err := s.db.QueryRow( ctx, ` SELECT MAX(checked_at) FROM camera_firmware_cache `, ).Scan(&checkedAt) if err != nil { return nil, err } if !checkedAt.Valid { return nil, nil } return &checkedAt.Time, nil } func (s *Server) listDevicesForFirmwareCheck(ctx context.Context) ([]Device, error) { rows, err := s.db.Query( ctx, ` SELECT id::TEXT, manufacturer, model, COALESCE(firmware_version, '') FROM devices WHERE manufacturer <> '' AND model <> '' ORDER BY manufacturer ASC, model ASC `, ) if err != nil { return nil, err } defer rows.Close() devices := []Device{} for rows.Next() { var device Device if err := rows.Scan( &device.ID, &device.Manufacturer, &device.Model, &device.FirmwareVersion, ); err != nil { return nil, err } devices = append(devices, device) } if err := rows.Err(); err != nil { return nil, err } return devices, nil } func (s *Server) refreshCameraFirmwareForDevices( ctx context.Context, devices []Device, ) (int, int, error) { lookupKeys := map[firmwareLookupKey]bool{} for _, device := range devices { manufacturer := strings.TrimSpace(device.Manufacturer) model := strings.TrimSpace(device.Model) if manufacturer == "" || model == "" { continue } provider := findCameraFirmwareProvider(manufacturer, model) if provider == nil { continue } lookupKeys[firmwareLookupKey{ Manufacturer: normalizeCameraFirmwareKey(manufacturer), NormalizedModel: normalizeCameraFirmwareKey(model), Model: model, }] = true } checkedModels := 0 failedModels := 0 for lookupKey := range lookupKeys { provider := findCameraFirmwareProvider(lookupKey.Manufacturer, lookupKey.Model) if provider == nil { continue } latest, err := provider.CheckLatestFirmware( ctx, lookupKey.Manufacturer, lookupKey.Model, ) if err != nil { failedModels++ logError("Firmware-Version konnte nicht manuell geprüft werden", err, LogFields{ "manufacturer": lookupKey.Manufacturer, "model": lookupKey.Model, }) _ = s.recordCameraFirmwareCheckError( ctx, lookupKey.Manufacturer, lookupKey.NormalizedModel, err.Error(), ) continue } if _, err := s.upsertCameraFirmwareCache( ctx, lookupKey.Manufacturer, lookupKey.NormalizedModel, latest, ); err != nil { return checkedModels, failedModels, err } checkedModels++ } return checkedModels, failedModels, nil } func findCameraFirmwareProvider(manufacturer string, model string) cameraFirmwareProvider { for _, provider := range cameraFirmwareProviders() { if provider.Supports(manufacturer, model) { return provider } } return nil } func (s *Server) getCameraFirmwareCache( ctx context.Context, manufacturer string, normalizedModel string, ) (cameraFirmwareCacheEntry, bool, error) { var entry cameraFirmwareCacheEntry var supportBadgesJSON []byte err := s.db.QueryRow( ctx, ` SELECT manufacturer, normalized_model, latest_version, support_url, source, check_error, support_badges, checked_at FROM camera_firmware_cache WHERE manufacturer = $1 AND normalized_model = $2 LIMIT 1 `, manufacturer, normalizedModel, ).Scan( &entry.Manufacturer, &entry.NormalizedModel, &entry.LatestVersion, &entry.SupportURL, &entry.Source, &entry.CheckError, &supportBadgesJSON, &entry.CheckedAt, ) if err == nil { entry.SupportBadges = decodeDeviceSupportBadges(supportBadgesJSON) return entry, true, nil } if err == pgx.ErrNoRows { return entry, false, nil } return entry, false, err } func (s *Server) upsertCameraFirmwareCache( ctx context.Context, manufacturer string, normalizedModel string, latest cameraFirmwareLatest, ) (cameraFirmwareCacheEntry, error) { var entry cameraFirmwareCacheEntry var supportBadgesJSON []byte nextSupportBadgesJSON, err := json.Marshal(latest.SupportBadges) if err != nil { return entry, err } err = s.db.QueryRow( ctx, ` INSERT INTO camera_firmware_cache ( manufacturer, normalized_model, latest_version, support_url, source, check_error, support_badges, checked_at, updated_at ) VALUES ($1, $2, $3, $4, $5, '', $6, now(), now()) ON CONFLICT (manufacturer, normalized_model) DO UPDATE SET latest_version = EXCLUDED.latest_version, support_url = EXCLUDED.support_url, source = EXCLUDED.source, check_error = '', support_badges = EXCLUDED.support_badges, checked_at = now(), updated_at = now() RETURNING manufacturer, normalized_model, latest_version, support_url, source, check_error, support_badges, checked_at `, manufacturer, normalizedModel, latest.LatestVersion, latest.SupportURL, latest.Source, nextSupportBadgesJSON, ).Scan( &entry.Manufacturer, &entry.NormalizedModel, &entry.LatestVersion, &entry.SupportURL, &entry.Source, &entry.CheckError, &supportBadgesJSON, &entry.CheckedAt, ) if err != nil { return entry, err } entry.SupportBadges = decodeDeviceSupportBadges(supportBadgesJSON) return entry, nil } func (s *Server) recordCameraFirmwareCheckError( ctx context.Context, manufacturer string, normalizedModel string, checkError string, ) error { _, err := s.db.Exec( ctx, ` INSERT INTO camera_firmware_cache ( manufacturer, normalized_model, check_error, checked_at, updated_at ) VALUES ($1, $2, $3, now(), now()) ON CONFLICT (manufacturer, normalized_model) DO UPDATE SET check_error = EXCLUDED.check_error, checked_at = now(), updated_at = now() `, manufacturer, normalizedModel, checkError, ) return err } func decodeDeviceSupportBadges(value []byte) []DeviceSupportBadge { if len(value) == 0 { return nil } var badges []DeviceSupportBadge if err := json.Unmarshal(value, &badges); err != nil { return nil } return badges } func normalizeCameraFirmwareKey(value string) string { value = strings.TrimSpace(strings.ToLower(value)) value = strings.Join(strings.Fields(value), " ") return value } type axisFirmwareProvider struct{} func (axisFirmwareProvider) Supports(manufacturer string, model string) bool { manufacturer = strings.ToLower(strings.TrimSpace(manufacturer)) model = strings.ToLower(strings.TrimSpace(model)) return strings.Contains(manufacturer, "axis") || strings.HasPrefix(model, "axis ") } func (axisFirmwareProvider) CheckLatestFirmware( ctx context.Context, manufacturer string, model string, ) (cameraFirmwareLatest, error) { supportURL := buildAxisSupportURL(model) requestCtx, cancel := context.WithTimeout(ctx, 12*time.Second) defer cancel() request, err := http.NewRequestWithContext( requestCtx, http.MethodGet, supportURL, nil, ) if err != nil { return cameraFirmwareLatest{}, err } request.Header.Set("Accept", "text/html,application/xhtml+xml") request.Header.Set("User-Agent", "TEG-Firmware-Checker/1.0") response, err := http.DefaultClient.Do(request) if err != nil { return cameraFirmwareLatest{}, err } defer response.Body.Close() body, err := io.ReadAll(io.LimitReader(response.Body, 3*1024*1024)) if err != nil { return cameraFirmwareLatest{}, err } if response.StatusCode < 200 || response.StatusCode >= 300 { return cameraFirmwareLatest{}, newAppErrorf( "axis support page request failed", LogFields{ "statusCode": response.StatusCode, "supportUrl": supportURL, }, "axis support page request failed: status=%d", response.StatusCode, ) } latestVersion := extractAxisLatestFirmwareVersion(string(body)) supportBadges := extractAxisSupportBadges(string(body), supportURL) if latestVersion == "" { return cameraFirmwareLatest{}, newAppErrorf( "axis firmware version not found", LogFields{ "supportUrl": supportURL, "model": model, }, "axis firmware version not found for model %q", model, ) } return cameraFirmwareLatest{ LatestVersion: latestVersion, SupportURL: supportURL, Source: "axis-support-page", SupportBadges: supportBadges, }, nil } var axisSupportSlugOverrides = map[string]string{ "f44": "axis-f44-main-unit", "axis f44": "axis-f44-main-unit", "axis f44 main unit": "axis-f44-main-unit", "f44 main unit": "axis-f44-main-unit", } func buildAxisSupportURL(model string) string { model = strings.TrimSpace(model) modelKey := normalizeCameraFirmwareKey(model) if slug, exists := axisSupportSlugOverrides[modelKey]; exists { return "https://www.axis.com/products/" + slug + "/support" } if !strings.HasPrefix(strings.ToLower(model), "axis ") { model = "AXIS " + model } modelKey = normalizeCameraFirmwareKey(model) if slug, exists := axisSupportSlugOverrides[modelKey]; exists { return "https://www.axis.com/products/" + slug + "/support" } slug := strings.ToLower(model) var builder strings.Builder previousDash := false for _, r := range slug { if unicode.IsLetter(r) || unicode.IsDigit(r) { builder.WriteRune(r) previousDash = false continue } if !previousDash { builder.WriteRune('-') previousDash = true } } slug = strings.Trim(builder.String(), "-") return "https://www.axis.com/products/" + slug + "/support" } func extractAxisSupportBadges(body string, supportURL string) []DeviceSupportBadge { body = html.UnescapeString(body) body = regexp.MustCompile(`\s+`).ReplaceAllString(body, " ") badges := []DeviceSupportBadge{} hardwareSupportExpiredPattern := regexp.MustCompile( `(?i)(Hardware support and RMA service expired on ([0-9]{4}-[0-9]{2}-[0-9]{2})\.?)`, ) if matches := hardwareSupportExpiredPattern.FindStringSubmatch(body); len(matches) >= 3 { message := strings.TrimSpace(matches[1]) if !strings.HasSuffix(message, ".") { message += "." } badges = append(badges, DeviceSupportBadge{ Type: "hardware_support_expired", Label: "Hardware/RMA abgelaufen", Message: message, Severity: "warning", SupportURL: supportURL, }) } softwareSupportExpiredPattern := regexp.MustCompile( `(?i)(Software support expired on ([0-9]{4}-[0-9]{2}-[0-9]{2})\.?)`, ) if matches := softwareSupportExpiredPattern.FindStringSubmatch(body); len(matches) >= 3 { message := strings.TrimSpace(matches[1]) if !strings.HasSuffix(message, ".") { message += "." } badges = append(badges, DeviceSupportBadge{ Type: "software_support_expired", Label: "Software-Support abgelaufen", Message: message, Severity: "danger", SupportURL: supportURL, }) } axisOSSupportExpiredPattern := regexp.MustCompile( `(?i)(AXIS OS support expired on ([0-9]{4}-[0-9]{2}-[0-9]{2})\.?)`, ) if matches := axisOSSupportExpiredPattern.FindStringSubmatch(body); len(matches) >= 3 { message := strings.TrimSpace(matches[1]) if !strings.HasSuffix(message, ".") { message += "." } badges = append(badges, DeviceSupportBadge{ Type: "axis_os_support_expired", Label: "AXIS OS abgelaufen", Message: message, Severity: "danger", SupportURL: supportURL, }) } return badges } func extractAxisLatestFirmwareVersion(body string) string { body = html.UnescapeString(body) patterns := []*regexp.Regexp{ regexp.MustCompile(`(?i)Version\s+([0-9]+(?:\.[0-9]+){1,5})`), regexp.MustCompile(`(?i)AXIS\s+OS[^0-9]{0,120}([0-9]+(?:\.[0-9]+){1,5})`), regexp.MustCompile(`(?i)firmware[^0-9]{0,120}([0-9]+(?:\.[0-9]+){1,5})`), } for _, pattern := range patterns { matches := pattern.FindStringSubmatch(body) if len(matches) >= 2 { return strings.TrimSpace(matches[1]) } } return "" } func isFirmwareVersionNewer(latestVersion string, currentVersion string) bool { latestParts := parseFirmwareVersionParts(latestVersion) currentParts := parseFirmwareVersionParts(currentVersion) if len(latestParts) == 0 || len(currentParts) == 0 { return false } maxLength := len(latestParts) if len(currentParts) > maxLength { maxLength = len(currentParts) } for index := 0; index < maxLength; index++ { latestPart := 0 currentPart := 0 if index < len(latestParts) { latestPart = latestParts[index] } if index < len(currentParts) { currentPart = currentParts[index] } if latestPart > currentPart { return true } if latestPart < currentPart { return false } } return false } func parseFirmwareVersionParts(value string) []int { versionPattern := regexp.MustCompile(`[0-9]+(?:\.[0-9]+)+`) version := versionPattern.FindString(value) if version == "" { return nil } rawParts := strings.Split(version, ".") parts := make([]int, 0, len(rawParts)) for _, rawPart := range rawParts { part, err := strconv.Atoi(rawPart) if err != nil { return nil } parts = append(parts, part) } return parts } func nullStringToString(value sql.NullString) string { if !value.Valid { return "" } return value.String }