Files
ddns-updater/internal/persistence/json/database.go

149 lines
3.6 KiB
Go

package json
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sync"
"time"
"github.com/qdm12/ddns-updater/internal/models"
)
type Database struct {
data dataModel
filepath string
sync.RWMutex
}
func (db *Database) Close() error {
db.Lock() // ensure a write operation finishes
defer db.Unlock()
return nil
}
// NewDatabase opens or creates the JSON file database.
func NewDatabase(dataDir string) (*Database, error) {
filePath := filepath.Join(dataDir, "updates.json")
file, err := os.Open(filePath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("reading file: %w", err)
}
const perm fs.FileMode = 0700
err = os.MkdirAll(filepath.Dir(filePath), perm)
if err != nil {
return nil, fmt.Errorf("creating data directory: %w", err)
}
return &Database{filepath: filePath}, nil
}
stat, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("stating file: %w", err)
} else if stat.Size() == 0 { // empty file
_ = file.Close()
return &Database{filepath: filePath}, nil
}
decoder := json.NewDecoder(file)
var data dataModel
err = decoder.Decode(&data)
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("decoding data from file: %w", err)
}
err = file.Close()
if err != nil {
return nil, fmt.Errorf("closing database file: %w", err)
}
err = checkData(data)
if err != nil {
return nil, fmt.Errorf("%s validation error: %w", filePath, err)
}
return &Database{
data: data,
filepath: filePath,
}, nil
}
var (
ErrDomainEmpty = errors.New("domain is empty")
ErrHostIsEmpty = errors.New("host is empty")
ErrIPRecordsMisordered = errors.New("IP records are not ordered correctly by time")
ErrIPEmpty = errors.New("IP is empty")
ErrIPTimeEmpty = errors.New("time of IP is empty")
)
func checkData(data dataModel) (err error) {
for i, record := range data.Records {
switch {
case record.Domain == "":
return fmt.Errorf("%w: for record %d of %d", ErrDomainEmpty,
i+1, len(data.Records))
case record.Host == "":
return fmt.Errorf("%w: for record %d of %d with domain %s",
ErrHostIsEmpty, i+1, len(data.Records), record.Domain)
}
err = checkHistoryEvents(record.Events)
if err != nil {
return fmt.Errorf("for record %d of %d with domain %s and host %s: "+
"history events: %w", i+1, len(data.Records),
record.Domain, record.Host, err)
}
}
return nil
}
func checkHistoryEvents(events []models.HistoryEvent) (err error) {
var previousEventTime time.Time
for i, event := range events {
switch {
case event.Time.IsZero():
return fmt.Errorf("%w: for event %d of %d (IP %s)",
ErrIPTimeEmpty, i+1, len(events), event.IP)
case event.Time.Before(previousEventTime):
return fmt.Errorf("%w: event %d of %d (IP %s and time %s) "+
" is before event %d of %d (IP %s and time %s)",
ErrIPRecordsMisordered, i+1, len(events), event.IP, event.Time,
i, len(events), events[i-1].IP, events[i-1].Time)
case !event.IP.IsValid():
return fmt.Errorf("%w: for event %d of %d",
ErrIPEmpty, i+1, len(events))
}
previousEventTime = event.Time
}
return nil
}
func (db *Database) write() error {
const createPerms fs.FileMode = 0600
file, err := os.OpenFile(db.filepath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, createPerms)
if err != nil {
return fmt.Errorf("opening file: %w", err)
}
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
err = encoder.Encode(db.data)
if err != nil {
_ = file.Close()
return fmt.Errorf("encoding data to file: %w", err)
}
err = file.Close()
if err != nil {
return fmt.Errorf("closing database file: %w", err)
}
return nil
}