package delivery import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "slices" "strings" "time" "sneak.berlin/go/webhooker/internal/database" ) // The archive rotations: how often a database target starts a new // archive file. Every rotation but none puts the period of an event's // receive time, in UTC, in the name of the file the event goes to, // written in the layout beside it. const ( archiveRotationNone = "none" archiveRotationMonthly = "monthly" archiveRotationDaily = "daily" archiveRotationHourly = "hourly" archiveMonthLayout = "2006-01" archiveDayLayout = "2006-01-02" archiveHourLayout = "2006-01-02-15" ) // errArchiveRotationUnknown is returned for a rotation that is not one // of the four. var errArchiveRotationUnknown = errors.New( "rotation must be none, monthly, daily or hourly", ) // archiveFile is one of a database target's archive files, and the // period in its name: "" for the file named without one. type archiveFile struct { path string period string } // ValidateArchiveRotation checks a user-supplied archive rotation for // a database target: empty or none (both meaning one file), monthly, // daily or hourly. func ValidateArchiveRotation(rotation string) error { switch rotation { case "", archiveRotationNone, archiveRotationMonthly, archiveRotationDaily, archiveRotationHourly: return nil default: return fmt.Errorf("%w: %q", errArchiveRotationUnknown, rotation) } } // parseArchiveRotation reads the rotation from a database target's // config JSON. An empty config or an empty rotation is none. func parseArchiveRotation(configJSON string) (string, error) { if configJSON == "" { return archiveRotationNone, nil } var cfg databaseTargetConfig err := json.Unmarshal([]byte(configJSON), &cfg) if err != nil { return "", fmt.Errorf("parsing database target config: %w", err) } err = ValidateArchiveRotation(cfg.Rotation) if err != nil { return "", err } if cfg.Rotation == "" { return archiveRotationNone, nil } return cfg.Rotation, nil } // archivePeriod returns the period, in UTC, that a rotation puts an // event received at receivedAt in: "2026-10" for monthly, // "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none. func archivePeriod(rotation string, receivedAt time.Time) string { switch rotation { case archiveRotationMonthly: return receivedAt.UTC().Format(archiveMonthLayout) case archiveRotationDaily: return receivedAt.UTC().Format(archiveDayLayout) case archiveRotationHourly: return receivedAt.UTC().Format(archiveHourLayout) default: return "" } } // archivePeriodPath returns the path of a database target's archive // file for a period: path, as ArchivePath gives it, with "-" and the // period put before its ".db". The period "" gives path itself. func archivePeriodPath(path, period string) string { if period == "" { return path } return strings.TrimSuffix(path, ".db") + "-" + period + ".db" } // ArchivePathAt returns the archive file a database target writes an // event received at receivedAt to: ArchivePath's file, with the // period in its name when the target rotates. func ArchivePathAt( dbMgr *database.WebhookDBManager, webhook *database.Webhook, target *database.Target, receivedAt time.Time, ) (string, error) { rotation, err := parseArchiveRotation(target.Config) if err != nil { return "", err } return archivePeriodPath( ArchivePath(dbMgr, webhook, target), archivePeriod(rotation, receivedAt), ), nil } // archiveFiles lists the database target's archive files that exist, // given the path ArchivePath gives it: the file at path, then each // file archivePeriodPath names for path and a period, oldest period // first. Which rotation wrote a file does not matter, so the files of // an earlier rotation setting are listed too. func archiveFiles(path string) ([]archiveFile, error) { dir := filepath.Dir(path) entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("listing archive files: %w", err) } stem := strings.TrimSuffix(filepath.Base(path), ".db") var files []archiveFile for _, entry := range entries { period, ok := archiveFilePeriod(stem, entry.Name()) if ok { files = append(files, archiveFile{ path: filepath.Join(dir, entry.Name()), period: period, }) } } // A month sorts before the days and hours in it. slices.SortFunc(files, func(a, b archiveFile) int { return strings.Compare(a.period, b.period) }) return files, nil } // archiveFilePeriod reports whether name is the name of an archive // file of the target whose file name without a period is stem+".db", // and the period in it. func archiveFilePeriod(stem, name string) (string, bool) { rest, ok := strings.CutPrefix(name, stem) if !ok { return "", false } rest, ok = strings.CutSuffix(rest, ".db") if !ok { return "", false } if rest == "" { return "", true } period, ok := strings.CutPrefix(rest, "-") if !ok { return "", false } for _, layout := range []string{ archiveMonthLayout, archiveDayLayout, archiveHourLayout, } { _, err := time.Parse(layout, period) if err == nil { return period, true } } return "", false }