155 lines
3.8 KiB
Go
155 lines
3.8 KiB
Go
//go:build linux || freebsd
|
|
|
|
// Package storage provides file-based persistence for nuntius.
|
|
//
|
|
// Today it contains only the newsletter subscriber log. It is designed
|
|
// to be zero-dependency (stdlib only) and crash-safe: each Append writes
|
|
// a single JSON line to an append-only file, so partial writes do not
|
|
// corrupt earlier records.
|
|
package storage
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Subscriber is a single newsletter signup, one JSON object per line.
|
|
type Subscriber struct {
|
|
Email string `json:"email"`
|
|
IP string `json:"ip,omitempty"`
|
|
Form string `json:"form"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// NewsletterStore is a file-based append-only log of subscribers.
|
|
// All methods are safe for concurrent use.
|
|
type NewsletterStore struct {
|
|
path string
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewNewsletterStore returns a store that appends to path.
|
|
// The file and its parent directory are created lazily on first Append.
|
|
func NewNewsletterStore(path string) *NewsletterStore {
|
|
return &NewsletterStore{path: path}
|
|
}
|
|
|
|
// Path returns the file path this store writes to.
|
|
func (s *NewsletterStore) Path() string {
|
|
return s.path
|
|
}
|
|
|
|
// Append writes sub as a single JSON line to the log.
|
|
// The file and parent directory are created on first call.
|
|
func (s *NewsletterStore) Append(sub Subscriber) error {
|
|
if sub.CreatedAt.IsZero() {
|
|
sub.CreatedAt = time.Now().UTC()
|
|
}
|
|
line, err := json.Marshal(sub)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal subscriber: %w", err)
|
|
}
|
|
line = append(line, '\n')
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
|
return fmt.Errorf("mkdir %s: %w", filepath.Dir(s.path), err)
|
|
}
|
|
f, err := os.OpenFile(s.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("open %s: %w", s.path, err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.Write(line); err != nil {
|
|
return fmt.Errorf("write %s: %w", s.path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Count returns the number of valid subscriber lines in the log.
|
|
// Malformed lines are silently skipped so a partial write does not
|
|
// brick the entire file.
|
|
func (s *NewsletterStore) Count() (int, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.countLocked()
|
|
}
|
|
|
|
func (s *NewsletterStore) countLocked() (int, error) {
|
|
f, err := os.Open(s.path)
|
|
if os.IsNotExist(err) {
|
|
return 0, nil
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("open %s: %w", s.path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
n := 0
|
|
scanner := bufio.NewScanner(f)
|
|
// Allow up to 1 MB per line in case a single record balloons.
|
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var sub Subscriber
|
|
if err := json.Unmarshal(line, &sub); err != nil {
|
|
// Skip malformed lines rather than failing the whole count.
|
|
continue
|
|
}
|
|
if sub.Email != "" {
|
|
n++
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return n, fmt.Errorf("scan %s: %w", s.path, err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// List returns all valid subscribers in insertion order.
|
|
// Use with care on large files; it reads the whole log into memory.
|
|
func (s *NewsletterStore) List() ([]Subscriber, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
f, err := os.Open(s.path)
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open %s: %w", s.path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
var out []Subscriber
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var sub Subscriber
|
|
if err := json.Unmarshal(line, &sub); err != nil {
|
|
continue
|
|
}
|
|
if sub.Email != "" {
|
|
out = append(out, sub)
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("scan %s: %w", s.path, err)
|
|
}
|
|
return out, nil
|
|
}
|