615 lines
14 KiB
Go
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
|
|
}
|