Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

internal/notices/store.go

554bcfc63721b22faf8bca78d5518d76e24a4d20 · 5.1 KB · 230 lines raw

1 package notices
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "sync"
11 "sync/atomic"
12 "time"
13
14 "github.com/fsnotify/fsnotify"
15 )
16
17 // Store owns one notices file. Reads are lock-free (an atomic snapshot);
18 // writes and the watcher's reloads serialise on mu so a web save and an
19 // external edit can never interleave.
20 type Store struct {
21 path string
22 dir string
23
24 mu sync.Mutex
25 cur atomic.Pointer[Snapshot]
26
27 fsw *fsnotify.Watcher
28 done chan struct{}
29 closeOnce sync.Once
30 }
31
32 // Open loads the file (an absent file is an empty document) and starts
33 // watching its directory. A parse error is fatal at startup so a typo is
34 // visible immediately; later reload errors keep the last good snapshot.
35 func Open(path string) (*Store, error) {
36 abs, err := filepath.Abs(path)
37 if err != nil {
38 return nil, err
39 }
40 dir := filepath.Dir(abs)
41 if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
42 return nil, fmt.Errorf("notices: directory %s does not exist", dir)
43 }
44
45 s := &Store{path: abs, dir: dir, done: make(chan struct{})}
46 snap, err := readSnapshot(abs)
47 if err != nil {
48 return nil, err
49 }
50 s.cur.Store(snap)
51
52 fsw, err := fsnotify.NewWatcher()
53 if err != nil {
54 return nil, fmt.Errorf("notices watch: %w", err)
55 }
56 if err := fsw.Add(dir); err != nil {
57 fsw.Close()
58 return nil, fmt.Errorf("notices watch: %w", err)
59 }
60 s.fsw = fsw
61 go s.watch()
62 return s, nil
63 }
64
65 // readSnapshot reads and hashes the file. A missing file is an empty
66 // document, not an error -- the admin UI creates it on first save.
67 func readSnapshot(path string) (*Snapshot, error) {
68 b, err := os.ReadFile(path)
69 if err != nil {
70 if os.IsNotExist(err) {
71 b = nil
72 } else {
73 return nil, fmt.Errorf("notices: %w", err)
74 }
75 }
76 doc, err := parse(b)
77 if err != nil {
78 return nil, err
79 }
80 return &Snapshot{Doc: doc, Rev: hash(b)}, nil
81 }
82
83 func hash(b []byte) string {
84 sum := sha256.Sum256(b)
85 return hex.EncodeToString(sum[:])[:16]
86 }
87
88 // Snapshot is the current document. The returned value shares its backing
89 // slices and map with the stored one; callers must not mutate it.
90 func (s *Store) Snapshot() Snapshot {
91 p := s.cur.Load()
92 if p == nil {
93 return Snapshot{}
94 }
95 return *p
96 }
97
98 // Revision is the content hash. O(1); safe to call on every request.
99 func (s *Store) Revision() string {
100 p := s.cur.Load()
101 if p == nil {
102 return ""
103 }
104 return p.Rev
105 }
106
107 // Set validates, writes atomically (temp file in the same directory, fsync,
108 // rename, 0600), and publishes the new snapshot immediately, so the next
109 // request sees it without waiting for the watcher.
110 func (s *Store) Set(doc Document) error {
111 if err := validate(&doc); err != nil {
112 return err
113 }
114 b, err := marshal(doc)
115 if err != nil {
116 return err
117 }
118
119 s.mu.Lock()
120 defer s.mu.Unlock()
121
122 tmp, err := os.CreateTemp(s.dir, ".notices-*.tmp")
123 if err != nil {
124 return fmt.Errorf("notices: %w", err)
125 }
126 tmpName := tmp.Name()
127 defer os.Remove(tmpName) // no-op once the rename succeeds
128 if err := tmp.Chmod(0o600); err != nil {
129 tmp.Close()
130 return err
131 }
132 if _, err := tmp.Write(b); err != nil {
133 tmp.Close()
134 return err
135 }
136 if err := tmp.Sync(); err != nil {
137 tmp.Close()
138 return err
139 }
140 if err := tmp.Close(); err != nil {
141 return err
142 }
143 if err := os.Rename(tmpName, s.path); err != nil {
144 return err
145 }
146
147 s.cur.Store(&Snapshot{Doc: doc, Rev: hash(b)})
148 return nil
149 }
150
151 // watch is the only goroutine that touches the debounce timer, so no lock
152 // is needed for it. Events for our own ".notices-*.tmp" files are ignored;
153 // only the real path triggers a reload.
154 func (s *Store) watch() {
155 const debounce = 200 * time.Millisecond
156 var timer *time.Timer
157 var timerC <-chan time.Time
158
159 for {
160 select {
161 case ev, ok := <-s.fsw.Events:
162 if !ok {
163 return
164 }
165 if filepath.Clean(ev.Name) != s.path {
166 continue
167 }
168 if timer == nil {
169 timer = time.NewTimer(debounce)
170 } else {
171 if !timer.Stop() {
172 select {
173 case <-timer.C:
174 default:
175 }
176 }
177 timer.Reset(debounce)
178 }
179 timerC = timer.C
180 case <-timerC:
181 timerC = nil
182 s.reload()
183 case err, ok := <-s.fsw.Errors:
184 if !ok {
185 return
186 }
187 slog.Warn("notices watch error", "err", err)
188 case <-s.done:
189 if timer != nil {
190 timer.Stop()
191 }
192 return
193 }
194 }
195 }
196
197 // reload re-reads the file and swaps the snapshot only when the content
198 // hash moved. A parse error keeps the previous snapshot, so a half-written
199 // or mistyped file never blanks the banners.
200 func (s *Store) reload() {
201 s.mu.Lock()
202 defer s.mu.Unlock()
203
204 snap, err := readSnapshot(s.path)
205 if err != nil {
206 slog.Warn("notices: keeping previous snapshot", "err", err)
207 return
208 }
209 if cur := s.cur.Load(); cur != nil && cur.Rev == snap.Rev {
210 return
211 }
212 s.cur.Store(snap)
213 slog.Info("notices reloaded", "revision", snap.Rev)
214 }
215
216 func (s *Store) Close() {
217 s.closeOnce.Do(func() {
218 close(s.done)
219 if s.fsw != nil {
220 s.fsw.Close()
221 }
222 })
223 }
224
225 // def is the process-wide store the `notices` module reads. A nil default
226 // means the feature is off; the module returns an empty list, not an error.
227 var def atomic.Pointer[Store]
228
229 func SetDefault(s *Store) { def.Store(s) }
230 func Default() *Store { return def.Load() }