package archiver import ( "crypto/rand" "encoding/hex" "fmt" "strings" "text/template" "time" "git.unkin.net/unkin/logarchiver/internal/event" ) // ObjectExt is the suffix for logarchiver container objects (zstd + framed // AES-GCM + wrapped OpenPGP DEK). It is deliberately NOT .gz/.pgp because the // object is a logarchiver-specific container, not a bare gpg file. const ObjectExt = ".ndjson.zst.larc" // KeyBuilder renders S3 object keys from a prefix template. Template fields: // {{.Subject}} (sanitized), {{.Year}} {{.Month}} {{.Day}} (UTC, zero-padded). type KeyBuilder struct { tmpl *template.Template } // NewKeyBuilder compiles the prefix template. func NewKeyBuilder(prefixTemplate string) (*KeyBuilder, error) { t, err := template.New("key").Option("missingkey=error").Parse(prefixTemplate) if err != nil { return nil, fmt.Errorf("parse key_prefix template: %w", err) } return &KeyBuilder{tmpl: t}, nil } type keyData struct { Subject string Year string Month string Day string } // Build returns a unique object key for a batch of subject at ts. The filename // is -.ndjson.zst.larc so keys are collision-free and sortable. func (k *KeyBuilder) Build(subject string, ts time.Time) (string, error) { ts = ts.UTC() var sb strings.Builder err := k.tmpl.Execute(&sb, keyData{ Subject: event.SubjectToken(subject), Year: fmt.Sprintf("%04d", ts.Year()), Month: fmt.Sprintf("%02d", int(ts.Month())), Day: fmt.Sprintf("%02d", ts.Day()), }) if err != nil { return "", fmt.Errorf("render key prefix: %w", err) } prefix := sb.String() if prefix != "" && !strings.HasSuffix(prefix, "/") { prefix += "/" } suffix, err := randHex(8) if err != nil { return "", err } name := ts.Format("20060102T150405Z") + "-" + suffix + ObjectExt return prefix + name, nil } func randHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("random: %w", err) } return hex.EncodeToString(b), nil }