package notices import ( "crypto/sha256" "encoding/hex" "fmt" "log/slog" "os" "path/filepath" "sync" "sync/atomic" "time" "github.com/fsnotify/fsnotify" ) // Store owns one notices file. Reads are lock-free (an atomic snapshot); // writes and the watcher's reloads serialise on mu so a web save and an // external edit can never interleave. type Store struct { path string dir string mu sync.Mutex cur atomic.Pointer[Snapshot] fsw *fsnotify.Watcher done chan struct{} closeOnce sync.Once } // Open loads the file (an absent file is an empty document) and starts // watching its directory. A parse error is fatal at startup so a typo is // visible immediately; later reload errors keep the last good snapshot. func Open(path string) (*Store, error) { abs, err := filepath.Abs(path) if err != nil { return nil, err } dir := filepath.Dir(abs) if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { return nil, fmt.Errorf("notices: directory %s does not exist", dir) } s := &Store{path: abs, dir: dir, done: make(chan struct{})} snap, err := readSnapshot(abs) if err != nil { return nil, err } s.cur.Store(snap) fsw, err := fsnotify.NewWatcher() if err != nil { return nil, fmt.Errorf("notices watch: %w", err) } if err := fsw.Add(dir); err != nil { fsw.Close() return nil, fmt.Errorf("notices watch: %w", err) } s.fsw = fsw go s.watch() return s, nil } // readSnapshot reads and hashes the file. A missing file is an empty // document, not an error -- the admin UI creates it on first save. func readSnapshot(path string) (*Snapshot, error) { b, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { b = nil } else { return nil, fmt.Errorf("notices: %w", err) } } doc, err := parse(b) if err != nil { return nil, err } return &Snapshot{Doc: doc, Rev: hash(b)}, nil } func hash(b []byte) string { sum := sha256.Sum256(b) return hex.EncodeToString(sum[:])[:16] } // Snapshot is the current document. The returned value shares its backing // slices and map with the stored one; callers must not mutate it. func (s *Store) Snapshot() Snapshot { p := s.cur.Load() if p == nil { return Snapshot{} } return *p } // Revision is the content hash. O(1); safe to call on every request. func (s *Store) Revision() string { p := s.cur.Load() if p == nil { return "" } return p.Rev } // Set validates, writes atomically (temp file in the same directory, fsync, // rename, 0600), and publishes the new snapshot immediately, so the next // request sees it without waiting for the watcher. func (s *Store) Set(doc Document) error { if err := validate(&doc); err != nil { return err } b, err := marshal(doc) if err != nil { return err } s.mu.Lock() defer s.mu.Unlock() tmp, err := os.CreateTemp(s.dir, ".notices-*.tmp") if err != nil { return fmt.Errorf("notices: %w", err) } tmpName := tmp.Name() defer os.Remove(tmpName) // no-op once the rename succeeds if err := tmp.Chmod(0o600); err != nil { tmp.Close() return err } if _, err := tmp.Write(b); err != nil { tmp.Close() return err } if err := tmp.Sync(); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Rename(tmpName, s.path); err != nil { return err } s.cur.Store(&Snapshot{Doc: doc, Rev: hash(b)}) return nil } // watch is the only goroutine that touches the debounce timer, so no lock // is needed for it. Events for our own ".notices-*.tmp" files are ignored; // only the real path triggers a reload. func (s *Store) watch() { const debounce = 200 * time.Millisecond var timer *time.Timer var timerC <-chan time.Time for { select { case ev, ok := <-s.fsw.Events: if !ok { return } if filepath.Clean(ev.Name) != s.path { continue } if timer == nil { timer = time.NewTimer(debounce) } else { if !timer.Stop() { select { case <-timer.C: default: } } timer.Reset(debounce) } timerC = timer.C case <-timerC: timerC = nil s.reload() case err, ok := <-s.fsw.Errors: if !ok { return } slog.Warn("notices watch error", "err", err) case <-s.done: if timer != nil { timer.Stop() } return } } } // reload re-reads the file and swaps the snapshot only when the content // hash moved. A parse error keeps the previous snapshot, so a half-written // or mistyped file never blanks the banners. func (s *Store) reload() { s.mu.Lock() defer s.mu.Unlock() snap, err := readSnapshot(s.path) if err != nil { slog.Warn("notices: keeping previous snapshot", "err", err) return } if cur := s.cur.Load(); cur != nil && cur.Rev == snap.Rev { return } s.cur.Store(snap) slog.Info("notices reloaded", "revision", snap.Rev) } func (s *Store) Close() { s.closeOnce.Do(func() { close(s.done) if s.fsw != nil { s.fsw.Close() } }) } // def is the process-wide store the `notices` module reads. A nil default // means the feature is off; the module returns an empty list, not an error. var def atomic.Pointer[Store] func SetDefault(s *Store) { def.Store(s) } func Default() *Store { return def.Load() }