feat: contact form backend for linux and freebsd servers
Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s
Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//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"
|
||||
"strings"
|
||||
"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()
|
||||
return appendLine(s.path, line)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// DedupeNewsletterStore wraps a NewsletterStore with an in-memory index of
|
||||
// recorded addresses so callers can detect a repeat subscription before
|
||||
// doing any user-visible work. The index is built lazily from the log on
|
||||
// first use and kept in sync on successful appends.
|
||||
//
|
||||
// Addresses are compared case-insensitively. Strictly speaking the local
|
||||
// part of an address may be case-sensitive, but every major provider treats
|
||||
// mailbox names that way in practice, and bot submissions exploit
|
||||
// exact-case variants to multiply signups.
|
||||
type DedupeNewsletterStore struct {
|
||||
store *NewsletterStore
|
||||
mu sync.Mutex
|
||||
seen map[string]struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewDedupeNewsletterStore wraps store.
|
||||
func NewDedupeNewsletterStore(store *NewsletterStore) *DedupeNewsletterStore {
|
||||
return &DedupeNewsletterStore{store: store}
|
||||
}
|
||||
|
||||
// Path returns the wrapped store's file path.
|
||||
func (d *DedupeNewsletterStore) Path() string {
|
||||
return d.store.Path()
|
||||
}
|
||||
|
||||
// Count returns the number of valid records in the log.
|
||||
func (d *DedupeNewsletterStore) Count() (int, error) {
|
||||
return d.store.Count()
|
||||
}
|
||||
|
||||
// List returns all valid subscribers in insertion order.
|
||||
func (d *DedupeNewsletterStore) List() ([]Subscriber, error) {
|
||||
return d.store.List()
|
||||
}
|
||||
|
||||
// seenKey normalises an address for comparison.
|
||||
func seenKey(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
// load populates the index once per process lifetime. An unreadable log
|
||||
// behaves like an empty index: dedupe then only covers this run, which
|
||||
// matches how the process would behave after a hard crash anyway.
|
||||
func (d *DedupeNewsletterStore) load() {
|
||||
d.once.Do(func() {
|
||||
d.seen = make(map[string]struct{})
|
||||
subs, err := d.store.List()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, sub := range subs {
|
||||
d.seen[seenKey(sub.Email)] = struct{}{}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Has reports whether the address was already recorded.
|
||||
func (d *DedupeNewsletterStore) Has(email string) bool {
|
||||
d.load()
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
_, ok := d.seen[seenKey(email)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Remember records an address after its append succeeded.
|
||||
func (d *DedupeNewsletterStore) Remember(sub Subscriber) {
|
||||
d.load()
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.seen[seenKey(sub.Email)] = struct{}{}
|
||||
}
|
||||
|
||||
// Append persists sub and records the address on success.
|
||||
func (d *DedupeNewsletterStore) Append(sub Subscriber) error {
|
||||
if err := d.store.Append(sub); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Remember(sub)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewsletterStore_AppendCreatesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "subs", "subscribers.jsonl")
|
||||
s := NewNewsletterStore(path)
|
||||
|
||||
if err := s.Append(Subscriber{Email: "a@example.com", Form: "newsletter"}); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected file to exist, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterStore_AppendAndCount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "subs.jsonl")
|
||||
s := NewNewsletterStore(path)
|
||||
|
||||
for i, e := range []string{"a@x.com", "b@x.com", "c@x.com"} {
|
||||
if err := s.Append(Subscriber{Email: e, Form: "newsletter"}); err != nil {
|
||||
t.Fatalf("Append #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
n, err := s.Count()
|
||||
if err != nil {
|
||||
t.Fatalf("Count: %v", err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Errorf("expected 3 subscribers, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterStore_AppendIsOneLinePerRecord(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "subs.jsonl")
|
||||
s := NewNewsletterStore(path)
|
||||
|
||||
for _, e := range []string{"a@x.com", "b@x.com"} {
|
||||
if err := s.Append(Subscriber{Email: e, Form: "n"}); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Each line should be a self-contained JSON object.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
count := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var sub Subscriber
|
||||
if err := json.Unmarshal(line, &sub); err != nil {
|
||||
t.Fatalf("Unmarshal line: %v", err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("scanner.Err: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 records, decoded %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterStore_CountOnMissingFileIsZero(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "does-not-exist.jsonl")
|
||||
s := NewNewsletterStore(path)
|
||||
|
||||
n, err := s.Count()
|
||||
if err != nil {
|
||||
t.Fatalf("Count on missing file: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("expected 0 on missing file, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterStore_ListReturnsInsertionOrder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "subs.jsonl")
|
||||
s := NewNewsletterStore(path)
|
||||
|
||||
want := []string{"a@x.com", "b@x.com", "c@x.com"}
|
||||
for _, e := range want {
|
||||
_ = s.Append(Subscriber{Email: e, Form: "n"})
|
||||
}
|
||||
|
||||
subs, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(subs) != len(want) {
|
||||
t.Fatalf("expected %d, got %d", len(want), len(subs))
|
||||
}
|
||||
for i, sub := range subs {
|
||||
if sub.Email != want[i] {
|
||||
t.Errorf("position %d: want %q, got %q", i, want[i], sub.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterStore_SkipsMalformedLines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "subs.jsonl")
|
||||
|
||||
// Write a mix of valid and garbage lines.
|
||||
content := `{"email":"good1@x.com","form":"n","created_at":"2026-01-01T00:00:00Z"}
|
||||
this is not valid json
|
||||
{"email":"good2@x.com","form":"n","created_at":"2026-01-02T00:00:00Z"}
|
||||
{not even close to json
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
s := NewNewsletterStore(path)
|
||||
n, err := s.Count()
|
||||
if err != nil {
|
||||
t.Fatalf("Count: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Errorf("expected 2 valid records (skipping malformed), got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterStore_PathReturnsConfiguredPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "subs.jsonl")
|
||||
s := NewNewsletterStore(path)
|
||||
if got := s.Path(); got != path {
|
||||
t.Errorf("Path: want %q, got %q", path, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The dedupe wrapper records delivered addresses and reports repeats
|
||||
// case-insensitively; underlying Append semantics stay untouched.
|
||||
func TestDedupeNewsletterStore(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "subs.jsonl")
|
||||
store := NewNewsletterStore(path)
|
||||
dedupe := NewDedupeNewsletterStore(store)
|
||||
|
||||
if dedupe.Has("Jane@Example.com") {
|
||||
t.Fatal("fresh log should not contain the address")
|
||||
}
|
||||
|
||||
if err := dedupe.Append(Subscriber{Email: "jane@example.com"}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
if !dedupe.Has(" Jane@Example.COM ") {
|
||||
t.Error("address should be found after append, ignoring case and spaces")
|
||||
}
|
||||
n, err := store.Count()
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("log lines = %d (err %v), want exactly one record", n, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
// Double opt-in support: addresses wait in a pending file until their
|
||||
// confirmation token is redeemed, then move into the main subscriber log.
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PendingTTL is how long an unconfirmed subscription stays actionable.
|
||||
// After that it is purged and the address must be signed up again.
|
||||
const PendingTTL = 72 * time.Hour
|
||||
|
||||
// RandomToken returns a 32-byte cryptographically random value hex-encoded
|
||||
// for use inside URLs. Only its SHA-256 hash is persisted; the raw value
|
||||
// lives exclusively in the confirmation link.
|
||||
func RandomToken() (string, error) {
|
||||
var buf [32]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", fmt.Errorf("generate confirmation token: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(buf[:]), nil
|
||||
}
|
||||
|
||||
type PendingSubscription struct {
|
||||
Email string `json:"email"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type pendingFile struct {
|
||||
Schema int `json:"schema"`
|
||||
Entries map[string]PendingSubscription `json:"entries"`
|
||||
}
|
||||
|
||||
const pendingSchema = 1
|
||||
|
||||
// PendingStore keeps unconfirmed newsletter subscriptions keyed by the hash
|
||||
// of their confirmation token. The whole set is rewritten atomically on
|
||||
// every change: pending files stay tiny (only signups within the TTL), so
|
||||
// the append-only trick is not needed here.
|
||||
type PendingStore struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewPendingStore wraps path with the given entry lifetime.
|
||||
func NewPendingStore(path string, ttl time.Duration) *PendingStore {
|
||||
if ttl <= 0 {
|
||||
ttl = PendingTTL
|
||||
}
|
||||
return &PendingStore{path: path, ttl: ttl}
|
||||
}
|
||||
|
||||
// Path returns the backing file location.
|
||||
func (p *PendingStore) Path() string { return p.path }
|
||||
|
||||
// TTL returns the entry lifetime the store enforces.
|
||||
func (p *PendingStore) TTL() time.Duration { return p.ttl }
|
||||
|
||||
func hashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// load reads the file, prunes expired entries and persists the pruned set.
|
||||
// A missing or corrupt file behaves like an empty store.
|
||||
func (p *PendingStore) load(now time.Time) (map[string]PendingSubscription, error) {
|
||||
f := pendingFile{Schema: pendingSchema, Entries: map[string]PendingSubscription{}}
|
||||
raw, err := os.ReadFile(p.path)
|
||||
switch {
|
||||
case err == nil:
|
||||
if err := json.Unmarshal(raw, &f); err != nil || f.Schema != pendingSchema {
|
||||
return f.Entries, fmt.Errorf("unreadable pending store %s", p.path)
|
||||
}
|
||||
case os.IsNotExist(err):
|
||||
default:
|
||||
return f.Entries, fmt.Errorf("read pending store %s: %w", p.path, err)
|
||||
}
|
||||
dirty := false
|
||||
for k, e := range f.Entries {
|
||||
if now.Sub(e.CreatedAt) > p.ttl || e.CreatedAt.After(now.Add(time.Hour)) {
|
||||
delete(f.Entries, k)
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
if dirty {
|
||||
if werr := p.save(f); werr != nil {
|
||||
return f.Entries, werr
|
||||
}
|
||||
}
|
||||
return f.Entries, nil
|
||||
}
|
||||
|
||||
func (p *PendingStore) save(f pendingFile) error {
|
||||
line, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal pending store: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p.path), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", filepath.Dir(p.path), err)
|
||||
}
|
||||
tmp := p.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, line, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", tmp, err)
|
||||
}
|
||||
return os.Rename(tmp, p.path)
|
||||
}
|
||||
|
||||
// Issue stores a new pending subscription keyed by the token hash,
|
||||
// superseding any earlier entry for the same address. A load warning
|
||||
// (unreadable file) is non-fatal: the new entry is still written.
|
||||
func (p *PendingStore) Issue(rawToken string, sub PendingSubscription) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
now := time.Now()
|
||||
entries, _ := p.load(now)
|
||||
for k, e := range entries {
|
||||
if seenKey(e.Email) == seenKey(sub.Email) && k != hashToken(rawToken) {
|
||||
delete(entries, k) // one live token per address
|
||||
}
|
||||
}
|
||||
sub.CreatedAt = now.UTC()
|
||||
entries[hashToken(rawToken)] = sub
|
||||
return p.save(pendingFile{Schema: pendingSchema, Entries: entries})
|
||||
}
|
||||
|
||||
// Consume redeems a token: a valid, unexpired entry is removed from the
|
||||
// file and returned. Unknown tokens, already-redeemed tokens and expired
|
||||
// entries all report false.
|
||||
func (p *PendingStore) Consume(rawToken string) (PendingSubscription, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
key := hashToken(rawToken)
|
||||
now := time.Now()
|
||||
entries, _ := p.load(now)
|
||||
sub, ok := entries[key]
|
||||
if !ok {
|
||||
return PendingSubscription{}, false
|
||||
}
|
||||
delete(entries, key)
|
||||
_ = p.save(pendingFile{Schema: pendingSchema, Entries: entries})
|
||||
if now.Sub(sub.CreatedAt) > p.ttl {
|
||||
return PendingSubscription{}, false
|
||||
}
|
||||
return sub, true
|
||||
}
|
||||
|
||||
// Peek returns the subscription behind rawToken without consuming it,
|
||||
// reporting false when the token is unknown or expired.
|
||||
func (p *PendingStore) Peek(rawToken string) (PendingSubscription, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
now := time.Now()
|
||||
entries, _ := p.load(now)
|
||||
sub, ok := entries[hashToken(rawToken)]
|
||||
if !ok || now.Sub(sub.CreatedAt) > p.ttl {
|
||||
return PendingSubscription{}, false
|
||||
}
|
||||
return sub, true
|
||||
}
|
||||
|
||||
// HasToken reports whether raw is still a live, redeemable token.
|
||||
func (p *PendingStore) HasToken(rawToken string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
now := time.Now()
|
||||
entries, _ := p.load(now)
|
||||
sub, ok := entries[hashToken(rawToken)]
|
||||
return ok && now.Sub(sub.CreatedAt) <= p.ttl
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPendingStoreRoundTrip(t *testing.T) {
|
||||
p := NewPendingStore(filepath.Join(t.TempDir(), "pending.json"), 72*time.Hour)
|
||||
|
||||
if _, ok := p.Consume("unknown-token"); ok {
|
||||
t.Fatal("unknown token must not consume")
|
||||
}
|
||||
|
||||
if err := p.Issue("token-a", PendingSubscription{Email: "Jane@Example.COM", IP: "203.0.113.9"}); err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
sub, ok := p.Consume("token-a")
|
||||
if !ok {
|
||||
t.Fatal("valid token must consume")
|
||||
}
|
||||
if sub.Email != "Jane@Example.COM" || sub.IP != "203.0.113.9" {
|
||||
t.Errorf("consumed entry = %+v", sub)
|
||||
}
|
||||
if _, ok := p.Consume("token-a"); ok {
|
||||
t.Error("token must be single-use")
|
||||
}
|
||||
}
|
||||
|
||||
// An expired entry is never returned and is purged from the file.
|
||||
func TestPendingStoreExpiry(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pending.json")
|
||||
stale, _ := json.Marshal(pendingFile{Schema: pendingSchema, Entries: map[string]PendingSubscription{
|
||||
hashToken("old"): {Email: "a@b.c", CreatedAt: time.Now().Add(-96 * time.Hour)},
|
||||
}})
|
||||
if err := os.WriteFile(path, stale, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := NewPendingStore(path, 72*time.Hour).Consume("old"); ok {
|
||||
t.Fatal("expired token must not consume")
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(raw), `"a@b.c"`) {
|
||||
t.Error("expired entry was not purged from the file")
|
||||
}
|
||||
}
|
||||
|
||||
// Issue replaces the previous live token for the same address.
|
||||
func TestPendingStoreRotation(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pending.json")
|
||||
p := NewPendingStore(path, 72*time.Hour)
|
||||
if err := p.Issue("first", PendingSubscription{Email: "jane@example.com"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.Issue("second", PendingSubscription{Email: "jane@example.com"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if strings.Count(string(raw), "@") != 1 {
|
||||
t.Errorf("expected a single live entry after rotation, got %s", raw)
|
||||
}
|
||||
if _, ok := p.Consume("first"); ok {
|
||||
t.Error("superseded token must no longer work")
|
||||
}
|
||||
if _, ok := p.Consume("second"); !ok {
|
||||
t.Error("current token must still work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomTokenIsHexAndUnique(t *testing.T) {
|
||||
a, err := RandomToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := RandomToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a) != 64 || a == b {
|
||||
t.Errorf("tokens = %q %q, want unique 64-char hex", a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Submission is one archived form submission, one JSON object per line.
|
||||
// Newsletter forms do not write here: they persist through the double
|
||||
// opt-in subscriber log instead.
|
||||
type Submission struct {
|
||||
Form string `json:"form"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Message string `json:"message"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ArchiveStore is a file-based append-only log of submissions. All methods
|
||||
// are safe for concurrent use. The file and its parent directory are
|
||||
// created lazily on first Append, so a form that archives nothing writes
|
||||
// nothing.
|
||||
type ArchiveStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewArchiveStore returns a store that appends to path.
|
||||
func NewArchiveStore(path string) *ArchiveStore {
|
||||
return &ArchiveStore{path: path}
|
||||
}
|
||||
|
||||
// Path returns the file path this store writes to.
|
||||
func (s *ArchiveStore) Path() string {
|
||||
return s.path
|
||||
}
|
||||
|
||||
// Append writes sub as a single JSON line to the log.
|
||||
func (s *ArchiveStore) Append(sub Submission) error {
|
||||
if sub.CreatedAt.IsZero() {
|
||||
sub.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
line, err := json.Marshal(sub)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal submission: %w", err)
|
||||
}
|
||||
line = append(line, '\n')
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return appendLine(s.path, line)
|
||||
}
|
||||
|
||||
// appendLine writes one marshalled JSON line to an append-only log,
|
||||
// creating the file and its parent directory on first use. The caller
|
||||
// holds the store's lock.
|
||||
func appendLine(path string, line []byte) (err error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := f.Close(); cerr != nil && err == nil {
|
||||
err = fmt.Errorf("close %s: %w", path, cerr)
|
||||
}
|
||||
}()
|
||||
if _, err := f.Write(line); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArchiveStoreRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "archive-test.jsonl")
|
||||
store := NewArchiveStore(path)
|
||||
|
||||
first := Submission{
|
||||
Form: "contact",
|
||||
Name: "Jane Doe",
|
||||
Email: "jane@example.com",
|
||||
Service: "architecture",
|
||||
Message: "Hello, I would like to discuss an engagement.",
|
||||
IP: "192.0.2.1",
|
||||
}
|
||||
if err := store.Append(first); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
second := Submission{Form: "feedback", Email: "other@example.com", Message: "Short"}
|
||||
if err := store.Append(second); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var got []Submission
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
var sub Submission
|
||||
if err := json.Unmarshal(scanner.Bytes(), &sub); err != nil {
|
||||
t.Fatalf("unmarshal line: %v", err)
|
||||
}
|
||||
got = append(got, sub)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("lines = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].CreatedAt.IsZero() {
|
||||
t.Errorf("first created_at is zero, want a stamped time")
|
||||
}
|
||||
first.CreatedAt = got[0].CreatedAt
|
||||
if got[0] != first {
|
||||
t.Errorf("first = %+v, want %+v", got[0], first)
|
||||
}
|
||||
if got[1].CreatedAt.IsZero() {
|
||||
t.Errorf("second created_at is zero, want a stamped time")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user