// 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() }