Harden alert history and tenant storage paths

This commit is contained in:
rcourtman 2026-03-31 09:23:03 +01:00
parent a7326d7047
commit dcc4747215
5 changed files with 64 additions and 19 deletions

View file

@ -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 {

View file

@ -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{}),

View file

@ -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/<org-id>
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 <orgDir>/org.json.
func (mtp *MultiTenantPersistence) LoadOrganization(orgID string) (*models.Organization, error) {

View file

@ -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)
}

View file

@ -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{