86 lines
2.3 KiB
Go
86 lines
2.3 KiB
Go
// 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
|
||
|
|
}
|