teg/backend/camera_firmware.go
2026-05-28 15:06:37 +02:00

1016 lines
22 KiB
Go

// 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
}