mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
feat(api): security and metrics history improvements
- Require admin + settings:write scope for setup-script-url endpoint - Add license enforcement for long-term metrics (30d/90d require Pro) - Add downsampling step calculation for metrics history queries - Add isContainerSSHRestricted helper for SSH restriction checks - Clean up temperature proxy references from config handlers - Minor OIDC and rate limit improvements
This commit is contained in:
@@ -4737,17 +4737,12 @@ func (h *ConfigHandlers) HandleSetupScriptURL(w http.ResponseWriter, r *http.Req
|
||||
backupPerms = "&backup_perms=true"
|
||||
}
|
||||
|
||||
authParam := ""
|
||||
if token != "" {
|
||||
authParam = "&auth_token=" + url.QueryEscape(token)
|
||||
}
|
||||
// Build script URL (setup token is passed via environment variable).
|
||||
scriptURL := fmt.Sprintf("%s/api/setup-script?type=%s%s&pulse_url=%s%s",
|
||||
pulseURL, req.Type, encodedHost, pulseURL, backupPerms)
|
||||
|
||||
// Build script URL and include the one-time auth token for automatic registration
|
||||
scriptURL := fmt.Sprintf("%s/api/setup-script?type=%s%s&pulse_url=%s%s%s",
|
||||
pulseURL, req.Type, encodedHost, pulseURL, backupPerms, authParam)
|
||||
|
||||
// Return a simple curl command - no environment variables needed
|
||||
// The setup token is returned separately so the script can prompt the user
|
||||
// Return a curl command; the setup token is passed via environment variable.
|
||||
// The setup token is returned separately so the script can prompt the user.
|
||||
tokenHint := token
|
||||
if len(token) > 6 {
|
||||
tokenHint = fmt.Sprintf("%s…%s", token[:3], token[len(token)-3:])
|
||||
@@ -5062,16 +5057,25 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
fingerprint := ""
|
||||
if fp, err := tlsutil.FetchFingerprint(host); err != nil {
|
||||
log.Warn().Err(err).Str("host", host).Msg("Failed to fetch TLS fingerprint for auto-register")
|
||||
} else {
|
||||
fingerprint = fp
|
||||
}
|
||||
|
||||
// Create a node configuration
|
||||
boolFalse := false
|
||||
boolTrue := true
|
||||
verifySSL := true
|
||||
nodeConfig := NodeConfigRequest{
|
||||
Type: req.Type,
|
||||
Name: req.ServerName,
|
||||
Host: host, // Use normalized host
|
||||
TokenName: req.TokenID,
|
||||
TokenValue: req.TokenValue,
|
||||
VerifySSL: &boolFalse, // Default to not verifying SSL for auto-registration
|
||||
Fingerprint: fingerprint,
|
||||
VerifySSL: &verifySSL,
|
||||
MonitorVMs: &boolTrue,
|
||||
MonitorContainers: &boolTrue,
|
||||
MonitorStorage: &boolTrue,
|
||||
@@ -5539,6 +5543,14 @@ func (h *ConfigHandlers) handleSecureAutoRegister(w http.ResponseWriter, _ *http
|
||||
return
|
||||
}
|
||||
|
||||
fingerprint := ""
|
||||
if fp, err := tlsutil.FetchFingerprint(host); err != nil {
|
||||
log.Warn().Err(err).Str("host", host).Msg("Failed to fetch TLS fingerprint for auto-register")
|
||||
} else {
|
||||
fingerprint = fp
|
||||
}
|
||||
verifySSL := true
|
||||
|
||||
// Create the token on the remote server
|
||||
var fullTokenID string
|
||||
var createErr error
|
||||
@@ -5557,10 +5569,11 @@ func (h *ConfigHandlers) handleSecureAutoRegister(w http.ResponseWriter, _ *http
|
||||
Msg("Creating PBS token via API")
|
||||
|
||||
pbsClient, err := pbs.NewClient(pbs.ClientConfig{
|
||||
Host: host,
|
||||
User: req.Username,
|
||||
Password: req.Password,
|
||||
VerifySSL: false, // Self-signed certs common
|
||||
Host: host,
|
||||
User: req.Username,
|
||||
Password: req.Password,
|
||||
Fingerprint: fingerprint,
|
||||
VerifySSL: verifySSL,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("host", host).Msg("Failed to create PBS client")
|
||||
@@ -5609,7 +5622,8 @@ func (h *ConfigHandlers) handleSecureAutoRegister(w http.ResponseWriter, _ *http
|
||||
Host: host,
|
||||
TokenName: fullTokenID,
|
||||
TokenValue: tokenValue,
|
||||
VerifySSL: false,
|
||||
Fingerprint: fingerprint,
|
||||
VerifySSL: verifySSL,
|
||||
MonitorVMs: true,
|
||||
MonitorContainers: true,
|
||||
MonitorStorage: true,
|
||||
@@ -5622,7 +5636,8 @@ func (h *ConfigHandlers) handleSecureAutoRegister(w http.ResponseWriter, _ *http
|
||||
Host: host,
|
||||
TokenName: fullTokenID,
|
||||
TokenValue: tokenValue,
|
||||
VerifySSL: false,
|
||||
Fingerprint: fingerprint,
|
||||
VerifySSL: verifySSL,
|
||||
MonitorBackups: true,
|
||||
MonitorDatastores: true,
|
||||
MonitorSyncJobs: true,
|
||||
@@ -5836,7 +5851,6 @@ func (h *ConfigHandlers) HandleAgentInstallCommand(w http.ResponseWriter, r *htt
|
||||
config.Mu.Lock()
|
||||
h.config.APITokens = append(h.config.APITokens, *record)
|
||||
h.config.SortAPITokens()
|
||||
h.config.APITokenEnabled = true
|
||||
|
||||
if h.persistence != nil {
|
||||
if err := h.persistence.SaveAPITokens(h.config.APITokens); err != nil {
|
||||
|
||||
@@ -491,8 +491,8 @@ func (r *Router) ensureOIDCConfig() *config.OIDCConfig {
|
||||
// respecting X-Forwarded-* headers when behind a reverse proxy
|
||||
func buildRedirectURL(req *http.Request, configuredURL string) string {
|
||||
// If explicitly configured, use that
|
||||
if strings.TrimSpace(configuredURL) != "" {
|
||||
return configuredURL
|
||||
if configured := strings.TrimSpace(configuredURL); configured != "" {
|
||||
return configured
|
||||
}
|
||||
|
||||
// Build from request headers (respects reverse proxy headers)
|
||||
@@ -500,15 +500,38 @@ func buildRedirectURL(req *http.Request, configuredURL string) string {
|
||||
if req.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
// Check X-Forwarded-Proto header (set by reverse proxies)
|
||||
if proto := req.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
|
||||
peerIP := extractRemoteIP(req.RemoteAddr)
|
||||
trustedProxy := isTrustedProxyIP(peerIP)
|
||||
|
||||
if trustedProxy {
|
||||
if proto := firstForwardedValue(req.Header.Get("X-Forwarded-Proto")); proto != "" {
|
||||
scheme = proto
|
||||
} else if proto := firstForwardedValue(req.Header.Get("X-Forwarded-Scheme")); proto != "" {
|
||||
scheme = proto
|
||||
}
|
||||
}
|
||||
scheme = strings.ToLower(strings.TrimSpace(scheme))
|
||||
switch scheme {
|
||||
case "https", "http":
|
||||
default:
|
||||
if req.TLS != nil {
|
||||
scheme = "https"
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
}
|
||||
|
||||
host := req.Host
|
||||
// Check X-Forwarded-Host header (set by reverse proxies)
|
||||
if fwdHost := req.Header.Get("X-Forwarded-Host"); fwdHost != "" {
|
||||
host = fwdHost
|
||||
rawHost := ""
|
||||
if trustedProxy {
|
||||
rawHost = firstForwardedValue(req.Header.Get("X-Forwarded-Host"))
|
||||
}
|
||||
if rawHost == "" {
|
||||
rawHost = req.Host
|
||||
}
|
||||
host, _ := sanitizeForwardedHost(rawHost)
|
||||
if host == "" {
|
||||
host = req.Host
|
||||
}
|
||||
|
||||
redirectURL := fmt.Sprintf("%s://%s%s", scheme, host, config.DefaultOIDCCallbackPath)
|
||||
@@ -518,6 +541,8 @@ func buildRedirectURL(req *http.Request, configuredURL string) string {
|
||||
Str("host", host).
|
||||
Str("x_forwarded_proto", req.Header.Get("X-Forwarded-Proto")).
|
||||
Str("x_forwarded_host", req.Header.Get("X-Forwarded-Host")).
|
||||
Bool("trusted_proxy", trustedProxy).
|
||||
Str("peer_ip", peerIP).
|
||||
Str("redirect_url", redirectURL).
|
||||
Bool("has_tls", req.TLS != nil).
|
||||
Msg("Built OIDC redirect URL from request")
|
||||
|
||||
@@ -747,7 +747,8 @@ func TestIntersects(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildRedirectURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "127.0.0.1/32")
|
||||
resetTrustedProxyConfig()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -820,10 +821,9 @@ func TestBuildRedirectURL(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req, _ := http.NewRequest("GET", "http://"+tc.host+"/", nil)
|
||||
req.Host = tc.host
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
if tc.tls {
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
}
|
||||
|
||||
@@ -105,9 +105,12 @@ func (rl *RateLimiter) cleanup() {
|
||||
// Middleware for rate limiting
|
||||
func (rl *RateLimiter) Middleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.RemoteAddr
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
ip = forwarded
|
||||
ip := GetClientIP(r)
|
||||
if ip == "" {
|
||||
ip = extractRemoteIP(r.RemoteAddr)
|
||||
}
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
|
||||
if !rl.Allow(ip) {
|
||||
|
||||
@@ -368,6 +368,9 @@ func TestRateLimiter_Middleware_Denied(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRateLimiter_Middleware_XForwardedFor(t *testing.T) {
|
||||
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "127.0.0.1/32")
|
||||
resetTrustedProxyConfig()
|
||||
|
||||
rl := NewRateLimiter(1, time.Minute)
|
||||
defer rl.Stop()
|
||||
|
||||
|
||||
@@ -1109,7 +1109,7 @@ func (r *Router) setupRoutes() {
|
||||
r.mux.HandleFunc("/api/setup-script", r.configHandlers.HandleSetupScript)
|
||||
|
||||
// Generate setup script URL with temporary token (for authenticated users)
|
||||
r.mux.HandleFunc("/api/setup-script-url", r.configHandlers.HandleSetupScriptURL)
|
||||
r.mux.HandleFunc("/api/setup-script-url", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleSetupScriptURL)))
|
||||
|
||||
// Generate agent install command with API token (for authenticated users)
|
||||
r.mux.HandleFunc("/api/agent-install-command", RequireAuth(r.config, r.configHandlers.HandleAgentInstallCommand))
|
||||
@@ -4287,30 +4287,62 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request)
|
||||
|
||||
// Parse time range
|
||||
var duration time.Duration
|
||||
var stepSecs int64 = 0 // Default to no downsampling (raw)
|
||||
|
||||
switch timeRange {
|
||||
case "1h":
|
||||
duration = time.Hour
|
||||
stepSecs = 0 // Raw for 1h
|
||||
case "6h":
|
||||
duration = 6 * time.Hour
|
||||
stepSecs = 60 // 1m for 6h (~360 points)
|
||||
case "12h":
|
||||
duration = 12 * time.Hour
|
||||
stepSecs = 120 // 2m for 12h (~360 points)
|
||||
case "24h", "1d", "":
|
||||
duration = 24 * time.Hour
|
||||
stepSecs = 600 // 10m for 24h (~144 points)
|
||||
case "7d":
|
||||
duration = 7 * 24 * time.Hour
|
||||
stepSecs = 3600 // 1h for 7d (~168 points)
|
||||
case "30d":
|
||||
duration = 30 * 24 * time.Hour
|
||||
stepSecs = 14400 // 4h for 30d (~180 points)
|
||||
case "90d":
|
||||
duration = 90 * 24 * time.Hour
|
||||
stepSecs = 43200 // 12h for 90d (~180 points)
|
||||
default:
|
||||
// Try parsing as duration
|
||||
var err error
|
||||
duration, err = time.ParseDuration(timeRange)
|
||||
if err != nil {
|
||||
duration = 24 * time.Hour // Default to 24 hours
|
||||
stepSecs = 600
|
||||
} else {
|
||||
// Dynamic step for custom durations: target ~200 points
|
||||
stepSecs = int64(duration.Seconds()) / 200
|
||||
if stepSecs < 60 {
|
||||
stepSecs = 0 // No downsampling if very short
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce license limits: 7d free, 30d/90d require Pro
|
||||
// Returns 402 Payment Required for unlicensed long-term requests
|
||||
maxFreeDuration := 7 * 24 * time.Hour
|
||||
if duration > maxFreeDuration && !r.licenseHandlers.Service().HasFeature(license.FeatureLongTermMetrics) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusPaymentRequired)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "license_required",
|
||||
"message": "Long-term metrics history (30d/90d) requires a Pulse Pro license",
|
||||
"feature": license.FeatureLongTermMetrics,
|
||||
"upgrade_url": "https://pulserelay.pro/",
|
||||
"max_free": "7d",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
end := time.Now()
|
||||
start := end.Add(-duration)
|
||||
|
||||
@@ -4318,7 +4350,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request)
|
||||
|
||||
if metricType != "" {
|
||||
// Query single metric type
|
||||
points, err := store.Query(resourceType, resourceID, metricType, start, end)
|
||||
points, err := store.Query(resourceType, resourceID, metricType, start, end, stepSecs)
|
||||
if err != nil {
|
||||
log.Error().Err(err).
|
||||
Str("resourceType", resourceType).
|
||||
@@ -4351,7 +4383,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request)
|
||||
}
|
||||
} else {
|
||||
// Query all metrics for this resource
|
||||
metricsMap, err := store.QueryAll(resourceType, resourceID, start, end)
|
||||
metricsMap, err := store.QueryAll(resourceType, resourceID, start, end, stepSecs)
|
||||
if err != nil {
|
||||
log.Error().Err(err).
|
||||
Str("resourceType", resourceType).
|
||||
@@ -5312,7 +5344,6 @@ func (r *Router) handleDiagnosticsDockerPrepareToken(w http.ResponseWriter, req
|
||||
|
||||
r.config.APITokens = append(r.config.APITokens, *record)
|
||||
r.config.SortAPITokens()
|
||||
r.config.APITokenEnabled = true
|
||||
|
||||
if r.persistence != nil {
|
||||
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
||||
|
||||
@@ -44,7 +44,6 @@ func newIntegrationServerWithConfig(t *testing.T, customize func(*config.Config)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
BackendHost: "127.0.0.1",
|
||||
BackendPort: 7655,
|
||||
ConfigPath: tmpDir,
|
||||
DataPath: tmpDir,
|
||||
@@ -283,7 +282,6 @@ func TestAPIOnlyModeRequiresToken(t *testing.T) {
|
||||
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.AuthUser = ""
|
||||
cfg.AuthPass = ""
|
||||
cfg.APITokenEnabled = true
|
||||
cfg.APITokens = []config.APITokenRecord{*tokenRecord}
|
||||
})
|
||||
|
||||
@@ -492,7 +490,6 @@ func TestAuthenticatedEndpointsRequireToken(t *testing.T) {
|
||||
const apiToken = "test-token"
|
||||
|
||||
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.APITokenEnabled = true
|
||||
record, err := config.NewAPITokenRecord(apiToken, "Integration test token", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create API token record: %v", err)
|
||||
@@ -589,7 +586,6 @@ func TestAPITokenQueryParameterRejected(t *testing.T) {
|
||||
const apiToken = "query-token-1234567890"
|
||||
|
||||
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.APITokenEnabled = true
|
||||
record, err := config.NewAPITokenRecord(apiToken, "Query token test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create API token record: %v", err)
|
||||
@@ -740,7 +736,6 @@ func TestWebSocketSendsInitialState(t *testing.T) {
|
||||
|
||||
func TestSessionCookieAllowsAuthenticatedAccess(t *testing.T) {
|
||||
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.APITokenEnabled = false
|
||||
hashedPass, err := internalauth.HashPassword("super-secure-pass")
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
@@ -817,7 +812,6 @@ func TestPublicURLDetectionUsesForwardedHeaders(t *testing.T) {
|
||||
api.ResetTrustedProxyConfigForTests()
|
||||
|
||||
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.APITokenEnabled = true
|
||||
record, err := config.NewAPITokenRecord(apiToken, "Public URL detection test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create API token record: %v", err)
|
||||
|
||||
@@ -280,7 +280,6 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
|
||||
r.config.AuthPass = hashedPassword
|
||||
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
|
||||
r.config.SortAPITokens()
|
||||
r.config.APITokenEnabled = true
|
||||
config.Mu.Unlock()
|
||||
|
||||
if r.persistence != nil {
|
||||
@@ -556,7 +555,6 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
||||
config.Mu.Lock()
|
||||
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
|
||||
r.config.SortAPITokens()
|
||||
r.config.APITokenEnabled = true
|
||||
config.Mu.Unlock()
|
||||
log.Info().Msg("Runtime config updated with new API token - active immediately")
|
||||
|
||||
@@ -678,7 +676,7 @@ func (r *Router) HandleValidateAPIToken(w http.ResponseWriter, rq *http.Request)
|
||||
}
|
||||
|
||||
// Check if API token auth is enabled
|
||||
if !r.config.APITokenEnabled || !r.config.HasAPITokens() {
|
||||
if !r.config.HasAPITokens() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"valid": false,
|
||||
|
||||
@@ -262,12 +262,11 @@ func TestQuickSecuritySetupRequiresSettingsScopeForTokens(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
AuthUser: "admin",
|
||||
AuthPass: hashed,
|
||||
DataPath: dataDir,
|
||||
ConfigPath: dataDir,
|
||||
APITokens: []config.APITokenRecord{*record},
|
||||
APITokenEnabled: true,
|
||||
AuthUser: "admin",
|
||||
AuthPass: hashed,
|
||||
DataPath: dataDir,
|
||||
ConfigPath: dataDir,
|
||||
APITokens: []config.APITokenRecord{*record},
|
||||
}
|
||||
cfg.SortAPITokens()
|
||||
|
||||
@@ -306,12 +305,11 @@ func TestRegenerateAPITokenRequiresSettingsScope(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
AuthUser: "admin",
|
||||
AuthPass: hashed,
|
||||
DataPath: dataDir,
|
||||
ConfigPath: dataDir,
|
||||
APITokens: []config.APITokenRecord{*record},
|
||||
APITokenEnabled: true,
|
||||
AuthUser: "admin",
|
||||
AuthPass: hashed,
|
||||
DataPath: dataDir,
|
||||
ConfigPath: dataDir,
|
||||
APITokens: []config.APITokenRecord{*record},
|
||||
}
|
||||
cfg.SortAPITokens()
|
||||
|
||||
@@ -347,12 +345,11 @@ func TestValidateAPITokenRequiresSettingsScope(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
AuthUser: "admin",
|
||||
AuthPass: hashed,
|
||||
DataPath: dataDir,
|
||||
ConfigPath: dataDir,
|
||||
APITokens: []config.APITokenRecord{*record},
|
||||
APITokenEnabled: true,
|
||||
AuthUser: "admin",
|
||||
AuthPass: hashed,
|
||||
DataPath: dataDir,
|
||||
ConfigPath: dataDir,
|
||||
APITokens: []config.APITokenRecord{*record},
|
||||
}
|
||||
cfg.SortAPITokens()
|
||||
|
||||
@@ -390,12 +387,11 @@ func TestResetLockoutRequiresSettingsScope(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
AuthUser: "admin",
|
||||
AuthPass: hashed,
|
||||
DataPath: dataDir,
|
||||
ConfigPath: dataDir,
|
||||
APITokens: []config.APITokenRecord{*record},
|
||||
APITokenEnabled: true,
|
||||
AuthUser: "admin",
|
||||
AuthPass: hashed,
|
||||
DataPath: dataDir,
|
||||
ConfigPath: dataDir,
|
||||
APITokens: []config.APITokenRecord{*record},
|
||||
}
|
||||
cfg.SortAPITokens()
|
||||
|
||||
@@ -428,10 +424,9 @@ func TestEnsureSettingsWriteScopeWithValidScope(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
DataPath: dataDir,
|
||||
ConfigPath: dataDir,
|
||||
APITokens: []config.APITokenRecord{*record},
|
||||
APITokenEnabled: true,
|
||||
DataPath: dataDir,
|
||||
ConfigPath: dataDir,
|
||||
APITokens: []config.APITokenRecord{*record},
|
||||
}
|
||||
cfg.SortAPITokens()
|
||||
|
||||
|
||||
@@ -151,7 +151,6 @@ func (r *Router) handleCreateAPIToken(w http.ResponseWriter, req *http.Request)
|
||||
|
||||
r.config.APITokens = append(r.config.APITokens, *record)
|
||||
r.config.SortAPITokens()
|
||||
r.config.APITokenEnabled = true
|
||||
|
||||
if r.persistence != nil {
|
||||
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
||||
@@ -203,7 +202,6 @@ func (r *Router) handleDeleteAPIToken(w http.ResponseWriter, req *http.Request)
|
||||
}
|
||||
|
||||
r.config.SortAPITokens()
|
||||
r.config.APITokenEnabled = r.config.HasAPITokens()
|
||||
|
||||
if r.persistence != nil {
|
||||
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user