From dcc47472154e6b70858ac980de34a23f6956d0e8 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 31 Mar 2026 09:23:03 +0100 Subject: [PATCH] Harden alert history and tenant storage paths --- internal/ai/memory/incidents.go | 7 ++++-- internal/alerts/history.go | 23 ++++++++++++++--- internal/config/multi_tenant.go | 33 ++++++++++++++++--------- internal/notifications/notifications.go | 15 +++++++++-- internal/notifications/queue.go | 5 +++- 5 files changed, 64 insertions(+), 19 deletions(-) diff --git a/internal/ai/memory/incidents.go b/internal/ai/memory/incidents.go index 4add1b534..b8dc4b6e5 100644 --- a/internal/ai/memory/incidents.go +++ b/internal/ai/memory/incidents.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "sort" "strings" "sync" @@ -126,7 +125,11 @@ func NewIncidentStore(cfg IncidentStoreConfig) *IncidentStore { store.dataDir = "" } else { store.dataDir = normalizedDataDir - store.filePath = filepath.Join(store.dataDir, incidentFileName) + if filePath, pathErr := pathutil.JoinBaseFile(store.dataDir, incidentFileName); pathErr != nil { + log.Warn().Err(pathErr).Str("dataDir", store.dataDir).Msg("Failed to build incident store file path") + } else { + store.filePath = filePath + } } if store.filePath != "" { if err := store.loadFromDisk(); err != nil { diff --git a/internal/alerts/history.go b/internal/alerts/history.go index 455833f11..694b57dce 100644 --- a/internal/alerts/history.go +++ b/internal/alerts/history.go @@ -54,14 +54,31 @@ func NewHistoryManager(dataDir string) *HistoryManager { normalizedDataDir, err := pathutil.NormalizeDir(dataDir) if err != nil { log.Error().Err(err).Str("dir", dataDir).Msg("Invalid alert history data directory") - normalizedDataDir = filepath.Clean(dataDir) + fallbackDir, fallbackErr := pathutil.NormalizeDir(utils.GetDataDir()) + if fallbackErr != nil { + log.Error().Err(fallbackErr).Msg("Failed to normalize fallback alert history data directory") + normalizedDataDir = filepath.Clean(utils.GetDataDir()) + } else { + normalizedDataDir = fallbackDir + } } dataDir = normalizedDataDir + historyFile, err := pathutil.JoinBaseFile(dataDir, HistoryFileName) + if err != nil { + log.Error().Err(err).Str("dir", dataDir).Msg("Invalid alert history file path") + historyFile = filepath.Join(dataDir, HistoryFileName) + } + backupFile, err := pathutil.JoinBaseFile(dataDir, HistoryBackupFileName) + if err != nil { + log.Error().Err(err).Str("dir", dataDir).Msg("Invalid alert history backup path") + backupFile = filepath.Join(dataDir, HistoryBackupFileName) + } + hm := &HistoryManager{ dataDir: dataDir, - historyFile: filepath.Join(dataDir, HistoryFileName), - backupFile: filepath.Join(dataDir, HistoryBackupFileName), + historyFile: historyFile, + backupFile: backupFile, history: make([]HistoryEntry, 0), saveInterval: 5 * time.Minute, stopChan: make(chan struct{}), diff --git a/internal/config/multi_tenant.go b/internal/config/multi_tenant.go index ff1da3b7b..bc4b266a2 100644 --- a/internal/config/multi_tenant.go +++ b/internal/config/multi_tenant.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/pathutil" "github.com/rs/zerolog/log" ) @@ -20,6 +21,9 @@ type MultiTenantPersistence struct { // NewMultiTenantPersistence creates a new multi-tenant persistence manager. func NewMultiTenantPersistence(baseDataDir string) *MultiTenantPersistence { + if normalized, err := pathutil.NormalizeDir(baseDataDir); err == nil { + baseDataDir = normalized + } return &MultiTenantPersistence{ baseDataDir: baseDataDir, tenants: make(map[string]*ConfigPersistence), @@ -50,16 +54,9 @@ func (mtp *MultiTenantPersistence) GetPersistence(orgID string) (*ConfigPersiste return nil, fmt.Errorf("invalid organization ID: %s", orgID) } - // Determine org data directory - // Global/Default org uses the root data dir (legacy compatibility) - // New orgs use /data/orgs/ - var orgDir string - if orgID == "default" { - // IMPORTANT: Default org uses root data dir for backward compatibility - // This ensures existing users' configs (nodes.enc, ai.enc, etc.) continue to work - orgDir = mtp.baseDataDir - } else { - orgDir = filepath.Join(mtp.baseDataDir, "orgs", orgID) + orgDir, err := mtp.orgDir(orgID) + if err != nil { + return nil, err } log.Info().Str("org_id", orgID).Str("dir", orgDir).Msg("Initializing tenant persistence") @@ -89,11 +86,25 @@ func (mtp *MultiTenantPersistence) OrgExists(orgID string) bool { return false } - orgDir := filepath.Join(mtp.baseDataDir, "orgs", orgID) + orgDir, err := mtp.orgDir(orgID) + if err != nil { + return false + } stat, err := os.Stat(orgDir) return err == nil && stat.IsDir() } +func (mtp *MultiTenantPersistence) orgDir(orgID string) (string, error) { + if orgID == "default" { + return mtp.baseDataDir, nil + } + orgsRoot, err := pathutil.JoinBaseFile(mtp.baseDataDir, "orgs") + if err != nil { + return "", fmt.Errorf("failed to resolve org root: %w", err) + } + return pathutil.JoinBaseFile(orgsRoot, orgID) +} + // LoadOrganization loads the organization metadata including members. // Org metadata is stored in /org.json. func (mtp *MultiTenantPersistence) LoadOrganization(orgID string) (*models.Organization, error) { diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index bcd92f8ff..542f1a1aa 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -1536,12 +1536,23 @@ func (n *NotificationManager) sendAppriseViaHTTP(cfg AppriseConfig, title, body, ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.TimeoutSeconds)*time.Second) defer cancel() + validatedBaseURL, err := n.validatedWebhookRequestURL(serverURL) + if err != nil { + return fmt.Errorf("apprise server URL validation failed: %w", err) + } + notifyEndpoint := "/notify" if cfg.ConfigKey != "" { notifyEndpoint = "/notify/" + url.PathEscape(cfg.ConfigKey) } - requestURL := strings.TrimRight(serverURL, "/") + notifyEndpoint + requestURL := *validatedBaseURL + if requestURL.Path == "" || requestURL.Path == "/" { + requestURL.Path = notifyEndpoint + } else { + requestURL.Path = strings.TrimRight(requestURL.Path, "/") + notifyEndpoint + } + requestURL.Fragment = "" payload := map[string]any{ "body": body, @@ -1559,7 +1570,7 @@ func (n *NotificationManager) sendAppriseViaHTTP(cfg AppriseConfig, title, body, return fmt.Errorf("failed to marshal Apprise payload: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(payloadBytes)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payloadBytes)) if err != nil { return fmt.Errorf("failed to create Apprise request: %w", err) } diff --git a/internal/notifications/queue.go b/internal/notifications/queue.go index 679478afc..2a7b454a3 100644 --- a/internal/notifications/queue.go +++ b/internal/notifications/queue.go @@ -79,7 +79,10 @@ func NewNotificationQueue(dataDir string) (*NotificationQueue, error) { return nil, fmt.Errorf("failed to create notification queue directory: %w", err) } - dbPath := filepath.Join(dataDir, "notification_queue.db") + dbPath, err := pathutil.JoinBaseFile(dataDir, "notification_queue.db") + if err != nil { + return nil, fmt.Errorf("failed to build notification queue database path: %w", err) + } // Open database with pragmas in DSN so every pool connection is configured dsn := dbPath + "?" + url.Values{