Files
petrbalvin 3a38f00dc0
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
feat: contact form backend for linux and freebsd servers
Assisted-by: GLM 5.3 Flash
2026-09-29 00:32:56 +02:00

186 lines
5.5 KiB
Go

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