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

615 lines
14 KiB
Go

// backend\milestone_api.go
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
type milestoneRuntimeConfig struct {
Host string
AccessToken string
TokenType string
SkipTLSVerify bool
TokenExpiresAt *time.Time
}
type milestoneHardwarePatchDevice struct {
ID string
InventoryNumber string
MilestoneHardwareID string
MilestoneDisplayName string
IPAddress string
MilestonePort string
MilestoneEnabled bool
}
type updateMilestoneHardwareRequest struct {
Name *string `json:"name"`
DisplayName *string `json:"displayName"`
MilestoneDisplayName *string `json:"milestoneDisplayName"`
IPAddress *string `json:"ipAddress"`
Address *string `json:"address"`
Port *string `json:"port"`
MilestonePort *string `json:"milestonePort"`
Enabled *bool `json:"enabled"`
}
func (s *Server) getMilestoneHardwarePatchDevice(
ctx context.Context,
deviceID string,
) (milestoneHardwarePatchDevice, error) {
var device milestoneHardwarePatchDevice
err := s.db.QueryRow(
ctx,
`
SELECT
id::TEXT,
inventory_number,
COALESCE(milestone_hardware_id, ''),
COALESCE(milestone_display_name, ''),
COALESCE(ip_address, ''),
COALESCE(milestone_port, ''),
milestone_enabled
FROM devices
WHERE id = $1
LIMIT 1
`,
deviceID,
).Scan(
&device.ID,
&device.InventoryNumber,
&device.MilestoneHardwareID,
&device.MilestoneDisplayName,
&device.IPAddress,
&device.MilestonePort,
&device.MilestoneEnabled,
)
return device, err
}
func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
deviceID := strings.TrimSpace(r.PathValue("id"))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
return
}
var input updateMilestoneHardwareRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if input.Name == nil &&
input.DisplayName == nil &&
input.MilestoneDisplayName == nil &&
input.IPAddress == nil &&
input.Address == nil &&
input.Port == nil &&
input.MilestonePort == nil &&
input.Enabled == nil {
writeError(w, http.StatusBadRequest, "Keine Milestone-Felder zum Aktualisieren übergeben")
return
}
device, err := s.getMilestoneHardwarePatchDevice(r.Context(), deviceID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht geladen werden")
return
}
if strings.TrimSpace(device.MilestoneHardwareID) == "" {
writeError(w, http.StatusBadRequest, "Dieses Gerät ist kein Milestone-Gerät")
return
}
nextDisplayName := strings.TrimSpace(device.MilestoneDisplayName)
nextIPAddress := strings.TrimSpace(device.IPAddress)
nextPort := strings.TrimSpace(device.MilestonePort)
nextEnabled := device.MilestoneEnabled
payload := map[string]any{}
changes := []DeviceHistoryChange{}
requestedDisplayName := input.Name
if requestedDisplayName == nil {
requestedDisplayName = input.DisplayName
}
if requestedDisplayName == nil {
requestedDisplayName = input.MilestoneDisplayName
}
if requestedDisplayName != nil {
nextDisplayName = strings.TrimSpace(*requestedDisplayName)
// Wenn der Client einen Namen sendet, immer an Milestone weitergeben.
payload["name"] = nextDisplayName
if nextDisplayName != strings.TrimSpace(device.MilestoneDisplayName) {
changes = appendHistoryChange(
changes,
"milestoneDisplayName",
"Milestone-Anzeigename",
device.MilestoneDisplayName,
nextDisplayName,
)
}
}
requestedAddress := input.Address
if requestedAddress == nil {
requestedAddress = input.IPAddress
}
requestedPort := input.Port
if requestedPort == nil {
requestedPort = input.MilestonePort
}
if requestedAddress != nil || requestedPort != nil {
addressValue := device.IPAddress
if requestedAddress != nil {
addressValue = *requestedAddress
}
portValue := device.MilestonePort
if requestedPort != nil {
portValue = *requestedPort
}
nextAddress := normalizeMilestoneHardwareAddress(addressValue, portValue)
nextIPAddress, parsedPort := extractMilestoneHardwareAddressParts(nextAddress)
nextPort = normalizeMilestonePort(portValue)
if nextPort == "" {
nextPort = parsedPort
}
if nextIPAddress == "" {
nextIPAddress = strings.TrimSpace(addressValue)
}
// Wenn der Client Adresse oder Port sendet, immer an Milestone weitergeben.
payload["address"] = nextAddress
if nextIPAddress != strings.TrimSpace(device.IPAddress) {
changes = appendHistoryChange(
changes,
"ipAddress",
"IP-Adresse",
device.IPAddress,
nextIPAddress,
)
}
if nextPort != strings.TrimSpace(device.MilestonePort) {
changes = appendHistoryChange(
changes,
"milestonePort",
"Milestone-Port",
device.MilestonePort,
nextPort,
)
}
}
if input.Enabled != nil {
nextEnabled = *input.Enabled
// Wenn der Client den Status sendet, immer an Milestone weitergeben.
payload["enabled"] = nextEnabled
if nextEnabled != device.MilestoneEnabled {
changes = appendHistoryChange(
changes,
"milestoneEnabled",
"Milestone-Status",
formatHistoryBool(device.MilestoneEnabled),
formatHistoryBool(nextEnabled),
)
}
}
if len(payload) == 0 {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"id": device.ID,
"milestoneDisplayName": device.MilestoneDisplayName,
"ipAddress": device.IPAddress,
"milestonePort": device.MilestonePort,
"milestoneEnabled": device.MilestoneEnabled,
})
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt")
return
}
if err != nil {
s.logAndWriteUserError(
w,
r,
user,
http.StatusBadGateway,
"milestone_hardware",
"load_or_refresh_token",
"Milestone-Token konnte nicht geladen oder erneuert werden",
err,
LogFields{
"deviceId": device.ID,
"inventoryNumber": device.InventoryNumber,
"milestoneHardwareId": device.MilestoneHardwareID,
},
)
return
}
if strings.TrimSpace(config.Host) == "" {
writeError(w, http.StatusBadRequest, "Milestone Host fehlt")
return
}
if strings.TrimSpace(config.AccessToken) == "" {
writeError(w, http.StatusBadRequest, "Milestone-Token fehlt und konnte nicht automatisch erneuert werden")
return
}
if err := patchMilestoneHardware(
r.Context(),
config,
device.MilestoneHardwareID,
payload,
); err != nil {
s.logAndWriteUserError(
w,
r,
user,
http.StatusBadGateway,
"milestone_hardware",
"patch_hardware",
"Milestone-Gerät konnte nicht aktualisiert werden",
err,
LogFields{
"deviceId": device.ID,
"inventoryNumber": device.InventoryNumber,
"milestoneHardwareId": device.MilestoneHardwareID,
"payload": payload,
},
)
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden")
return
}
defer tx.Rollback(r.Context())
_, err = tx.Exec(
r.Context(),
`
UPDATE devices
SET
milestone_display_name = $2,
ip_address = $3,
milestone_port = $4,
milestone_enabled = $5,
milestone_synced_at = now(),
updated_at = now()
WHERE id = $1
`,
device.ID,
nextDisplayName,
nextIPAddress,
nextPort,
nextEnabled,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden")
return
}
if len(changes) > 0 {
if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "updated", changes); err != nil {
writeError(w, http.StatusInternalServerError, "Geräteverlauf konnte nicht gespeichert werden")
return
}
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden")
return
}
if err := s.publishDeviceChangeEvent(
r.Context(),
user.ID,
device.ID,
"device.milestone.hardware",
"Milestone-Gerät aktualisiert",
formatText("Gerät %s wurde in Milestone aktualisiert.", device.InventoryNumber),
LogFields{
"inventoryNumber": device.InventoryNumber,
"milestoneHardwareId": device.MilestoneHardwareID,
"payload": payload,
},
); err != nil {
logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
"deviceId": device.ID,
"type": "device.milestone.hardware",
})
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"id": device.ID,
"milestoneDisplayName": nextDisplayName,
"ipAddress": nextIPAddress,
"milestonePort": nextPort,
"milestoneEnabled": nextEnabled,
})
}
func normalizeMilestoneHardwareAddress(value string, port string) string {
value = strings.TrimSpace(value)
port = normalizeMilestonePort(port)
if value == "" || value == "-" {
return ""
}
if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") {
value = "http://" + strings.TrimRight(value, "/")
}
parsedURL, err := url.Parse(value)
if err != nil || parsedURL.Hostname() == "" {
value = strings.TrimRight(value, "/")
if port != "" && !strings.Contains(value, ":") {
value += ":" + port
}
return value + "/"
}
scheme := strings.TrimSpace(parsedURL.Scheme)
if scheme == "" {
scheme = "http"
}
host := parsedURL.Hostname()
if port == "" {
port = parsedURL.Port()
}
hostPort := host
if port != "" {
hostPort += ":" + port
}
return scheme + "://" + hostPort + "/"
}
func normalizeMilestonePort(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, ":")
if value == "-" {
return ""
}
return value
}
const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute
func (s *Server) getMilestoneRuntimeConfig(
ctx context.Context,
) (milestoneRuntimeConfig, error) {
config, err := s.loadMilestoneRuntimeConfig(ctx)
if err != nil {
return config, err
}
if shouldRefreshMilestoneToken(config) {
if err := s.refreshMilestoneRuntimeToken(ctx); err != nil {
return config, err
}
return s.loadMilestoneRuntimeConfig(ctx)
}
return config, nil
}
func shouldRefreshMilestoneToken(config milestoneRuntimeConfig) bool {
if strings.TrimSpace(config.AccessToken) == "" {
return true
}
if config.TokenExpiresAt == nil {
return true
}
return time.Now().After(config.TokenExpiresAt.Add(-milestoneTokenRefreshBeforeExpiry))
}
func (s *Server) loadMilestoneRuntimeConfig(
ctx context.Context,
) (milestoneRuntimeConfig, error) {
var config milestoneRuntimeConfig
var tokenExpiresAt sql.NullTime
err := s.db.QueryRow(
ctx,
`
SELECT
host,
COALESCE(pgp_sym_decrypt(access_token_encrypted, $1), ''),
COALESCE(token_type, ''),
skip_tls_verify,
token_expires_at
FROM milestone_settings
WHERE id = 1
LIMIT 1
`,
s.milestoneEncryptionKey(),
).Scan(
&config.Host,
&config.AccessToken,
&config.TokenType,
&config.SkipTLSVerify,
&tokenExpiresAt,
)
if err != nil {
return config, err
}
if tokenExpiresAt.Valid {
config.TokenExpiresAt = &tokenExpiresAt.Time
}
return config, nil
}
func (s *Server) refreshMilestoneRuntimeToken(ctx context.Context) error {
credentials, err := s.getMilestoneCredentials(ctx)
if err != nil {
return err
}
if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" {
return errors.New("Milestone-Zugangsdaten sind unvollständig")
}
tokenResponse, err := s.requestMilestoneToken(ctx, credentials)
if err != nil {
return err
}
accessToken := strings.TrimSpace(tokenResponse.AccessToken)
if accessToken == "" {
return errors.New("Milestone hat keinen Access-Token zurückgegeben")
}
tokenType := strings.TrimSpace(tokenResponse.TokenType)
if tokenType == "" {
tokenType = "Bearer"
}
expiresIn := tokenResponse.ExpiresIn
if expiresIn <= 0 {
expiresIn = 3600
}
tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
_, err = s.db.Exec(
ctx,
`
UPDATE milestone_settings
SET
access_token_encrypted = pgp_sym_encrypt($1, $2),
token_type = $3,
token_expires_at = $4,
token_updated_at = now(),
updated_at = now()
WHERE id = 1
`,
accessToken,
s.milestoneEncryptionKey(),
tokenType,
tokenExpiresAt,
)
return err
}
func patchMilestoneHardware(
ctx context.Context,
config milestoneRuntimeConfig,
hardwareID string,
payload any,
) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/hardware/" +
url.PathEscape(hardwareID)
request, err := http.NewRequestWithContext(
ctx,
http.MethodPatch,
requestURL,
bytes.NewReader(body),
)
if err != nil {
return err
}
tokenType := strings.TrimSpace(config.TokenType)
if tokenType == "" {
tokenType = "Bearer"
}
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", "application/json")
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(response.Body)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return newAppErrorf(
"milestone hardware patch failed",
LogFields{
"statusCode": response.StatusCode,
"responseBody": string(responseBody),
"hardwareId": hardwareID,
},
"milestone hardware patch failed: status=%d body=%s",
response.StatusCode,
string(responseBody),
)
}
return nil
}