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
|
||||
}
|
||||
Reference in New Issue
Block a user