diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-08-22 20:36:56 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-08-22 20:36:56 +0200 |
| commit | c1decadb590c4d79d92bcc4df7772119a5c91244 (patch) | |
| tree | 468f9fa37535dbb90cc5d4061eb20de83912131e /internal/store/store.go | |
| download | aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.gz aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.xz aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.zip | |
Initial Aegis Usenet client release
Diffstat (limited to 'internal/store/store.go')
| -rw-r--r-- | internal/store/store.go | 232 |
1 files changed, 232 insertions, 0 deletions
diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..f851dce --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,232 @@ +// Package store contains the local, non-secret Aegis cache. +package store + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "aegis/internal/filter" +) + +const ( + stateVersion = 1 + maxArticleBytes = 16 << 20 + maxDraftBytes = 2 << 20 +) + +type Article struct { + Key string `json:"key"` + Group string `json:"group"` + Number int64 `json:"number"` + Subject string `json:"subject,omitempty"` + From string `json:"from,omitempty"` + MessageID string `json:"message_id,omitempty"` + Raw string `json:"raw,omitempty"` + Read bool `json:"read"` + Bookmarked bool `json:"bookmarked"` + Tags []string `json:"tags,omitempty"` + FetchedAt time.Time `json:"fetched_at"` +} + +type Draft struct { + ID string `json:"id"` + Groups string `json:"groups,omitempty"` + From string `json:"from,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body,omitempty"` + References string `json:"references,omitempty"` + FollowupTo string `json:"followup_to,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type State struct { + Version int `json:"version"` + Articles map[string]Article `json:"articles"` + Drafts map[string]Draft `json:"drafts"` + Filters []filter.Rule `json:"filters,omitempty"` +} + +type Store struct { + mu sync.RWMutex + path string + state State +} + +func DefaultPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("locate user configuration directory: %w", err) + } + return filepath.Join(dir, "aegis", "state.json"), nil +} + +func Open(path string) (*Store, error) { + if path == "" { + return nil, errors.New("state path is required") + } + s := &Store{path: path, state: emptyState()} + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, fmt.Errorf("read local state: %w", err) + } + if len(data) > 32<<20 { + return nil, errors.New("local state is too large") + } + if err := json.Unmarshal(data, &s.state); err != nil { + return nil, fmt.Errorf("decode local state: %w", err) + } + if s.state.Version == 0 { + s.state.Version = stateVersion + } + if s.state.Version != stateVersion { + return nil, fmt.Errorf("unsupported local state version %d", s.state.Version) + } + if s.state.Articles == nil { + s.state.Articles = make(map[string]Article) + } + if s.state.Drafts == nil { + s.state.Drafts = make(map[string]Draft) + } + return s, nil +} + +func emptyState() State { + return State{Version: stateVersion, Articles: make(map[string]Article), Drafts: make(map[string]Draft)} +} + +func (s *Store) Save() error { + s.mu.RLock() + data, err := json.MarshalIndent(s.state, "", " ") + s.mu.RUnlock() + if err != nil { + return fmt.Errorf("encode local state: %w", err) + } + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create local state directory: %w", err) + } + if err := os.Chmod(dir, 0o700); err != nil { + return fmt.Errorf("protect local state directory: %w", err) + } + tmp, err := os.CreateTemp(dir, ".state-*.tmp") + if err != nil { + return fmt.Errorf("create local state temporary file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(append(data, '\n')); err != nil { + _ = tmp.Close() + return fmt.Errorf("write local state: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync local state: %w", err) + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, s.path); err != nil { + return fmt.Errorf("commit local state: %w", err) + } + return nil +} + +func (s *Store) Snapshot() State { + s.mu.RLock() + defer s.mu.RUnlock() + result := emptyState() + result.Filters = append(result.Filters, s.state.Filters...) + for key, article := range s.state.Articles { + article.Tags = append([]string(nil), article.Tags...) + result.Articles[key] = article + } + for key, draft := range s.state.Drafts { + result.Drafts[key] = draft + } + return result +} + +func (s *Store) UpsertArticle(article Article) error { + if article.Key == "" { + return errors.New("article key is required") + } + if len(article.Raw) > maxArticleBytes { + return errors.New("article exceeds local cache limit") + } + s.mu.Lock() + if old, ok := s.state.Articles[article.Key]; ok { + article.Read = old.Read + article.Bookmarked = old.Bookmarked + if len(article.Tags) == 0 { + article.Tags = append([]string(nil), old.Tags...) + } + } + if article.FetchedAt.IsZero() { + article.FetchedAt = time.Now().UTC() + } + s.state.Articles[article.Key] = article + s.mu.Unlock() + return nil +} + +func (s *Store) SetRead(key string, read bool) error { + s.mu.Lock() + defer s.mu.Unlock() + article, ok := s.state.Articles[key] + if !ok { + return errors.New("article not found") + } + article.Read = read + s.state.Articles[key] = article + return nil +} + +func (s *Store) SetBookmarked(key string, bookmarked bool) error { + s.mu.Lock() + defer s.mu.Unlock() + article, ok := s.state.Articles[key] + if !ok { + return errors.New("article not found") + } + article.Bookmarked = bookmarked + s.state.Articles[key] = article + return nil +} + +func (s *Store) SaveDraft(draft Draft) error { + if draft.ID == "" { + return errors.New("draft ID is required") + } + if len(draft.Body) > maxDraftBytes { + return errors.New("draft exceeds local size limit") + } + draft.UpdatedAt = time.Now().UTC() + s.mu.Lock() + s.state.Drafts[draft.ID] = draft + s.mu.Unlock() + return nil +} + +func (s *Store) DeleteDraft(id string) { + s.mu.Lock() + delete(s.state.Drafts, id) + s.mu.Unlock() +} + +func (s *Store) SetFilters(rules []filter.Rule) { + s.mu.Lock() + s.state.Filters = append([]filter.Rule(nil), rules...) + s.mu.Unlock() +} |
