1016 lines
22 KiB
Go
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
|
|
}
|