Files
bd2/go/internal/server/mail/grant_spool.go
T

203 lines
6.3 KiB
Go

package mail
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"bd2server/internal/server/gamedata"
)
// GrantSpool is a local operator input, independent of the versioned starter
// mailbox and player state. Writers atomically replace the whole JSON file.
// Version 1 uses Unix milliseconds; a stable identity is never issued twice,
// including after the player opens the mail or the server restarts.
type GrantSpool struct {
Version uint64 `json:"version"`
Grants []Grant `json:"grants"`
}
type Grant struct {
Identity string `json:"identity"`
Title string `json:"title"`
Body string `json:"body"`
SentAt int64 `json:"sent_at"`
Rewards []GrantReward `json:"rewards"`
}
type GrantReward struct {
Type uint64 `json:"type"`
ID uint64 `json:"id"`
Count uint64 `json:"count"`
}
type compensation struct {
identity, title, body string
sentAt time.Time
rewards []gamedata.Reward
}
func (g compensation) validate() error {
if strings.TrimSpace(g.identity) == "" || strings.TrimSpace(g.identity) != g.identity || utf8.RuneCountInString(g.identity) > 500 ||
strings.TrimSpace(g.title) == "" || utf8.RuneCountInString(g.title) > 500 ||
strings.TrimSpace(g.body) == "" || utf8.RuneCountInString(g.body) > 5000 ||
g.sentAt.IsZero() || g.sentAt.UnixMilli() <= 0 || g.sentAt.UnixMilli() > math.MaxInt64-int64(30*24*time.Hour/time.Millisecond) || len(g.rewards) == 0 {
return errors.New("mail: invalid compensation identity, content, time or rewards")
}
for _, reward := range g.rewards {
costumeReward := reward.Type == 11 && reward.ID != 0 && reward.Count <= 6
if reward.Count == 0 || reward.Count > math.MaxInt32 || reward.ID > math.MaxInt32 || (!currencyRewardTypes[reward.Type] && !supportedItemDBInfoReward(reward) && !costumeReward) ||
(currencyRewardTypes[reward.Type] && reward.ID != 0) {
return fmt.Errorf("mail: compensation %q has unsupported reward type=%d id=%d count=%d", g.identity, reward.Type, reward.ID, reward.Count)
}
}
return nil
}
// AttachGrantSpoolPath enables reads before each /MailInfo. An absent file is
// an empty queue, allowing an operator to create it after server startup.
// Existing malformed input is rejected immediately without issuing mail.
func (s *Service) AttachGrantSpoolPath(path string) error {
if s == nil || strings.TrimSpace(path) == "" {
return errors.New("mail: invalid grant spool path")
}
abs, err := filepath.Abs(filepath.Clean(path))
if err != nil {
return fmt.Errorf("mail: resolve grant spool path: %w", err)
}
if _, err := readGrantSpool(abs); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.grantSpoolPath = abs
return nil
}
func readGrantSpool(path string) ([]compensation, error) {
if path == "" {
return nil, nil
}
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("mail: read grant spool: %w", err)
}
grants, err := decodeGrantSpool(data)
if err != nil {
return nil, fmt.Errorf("mail: reject grant spool: %w", err)
}
return grants, nil
}
// Decode the entire file before returning any grants. The exact object reader
// rejects missing, unknown, duplicate and null fields at every schema level.
func decodeGrantSpool(data []byte) ([]compensation, error) {
if !utf8.Valid(data) {
return nil, errors.New("invalid UTF-8")
}
root, err := exactSpoolObject(data, "version", "grants")
if err != nil {
return nil, err
}
var version uint64
if err := json.Unmarshal(root["version"], &version); err != nil || version != 1 {
return nil, errors.New("unsupported grant spool version")
}
var rawGrants []json.RawMessage
if err := json.Unmarshal(root["grants"], &rawGrants); err != nil {
return nil, err
}
seen := make(map[string]bool, len(rawGrants))
grants := make([]compensation, 0, len(rawGrants))
for i, raw := range rawGrants {
fields, err := exactSpoolObject(raw, "identity", "title", "body", "sent_at", "rewards")
if err != nil {
return nil, fmt.Errorf("grant %d: %w", i, err)
}
var entry Grant
if err := json.Unmarshal(raw, &entry); err != nil {
return nil, fmt.Errorf("grant %d: %w", i, err)
}
if seen[entry.Identity] {
return nil, fmt.Errorf("duplicate grant identity %q", entry.Identity)
}
seen[entry.Identity] = true
var rawRewards []json.RawMessage
if err := json.Unmarshal(fields["rewards"], &rawRewards); err != nil {
return nil, err
}
grant := compensation{identity: entry.Identity, title: entry.Title, body: entry.Body, sentAt: time.UnixMilli(entry.SentAt)}
for j, reward := range rawRewards {
if _, err := exactSpoolObject(reward, "type", "id", "count"); err != nil {
return nil, fmt.Errorf("grant %d reward %d: %w", i, j, err)
}
grant.rewards = append(grant.rewards, gamedata.Reward{Type: entry.Rewards[j].Type, ID: entry.Rewards[j].ID, Count: entry.Rewards[j].Count})
}
if err := grant.validate(); err != nil {
return nil, fmt.Errorf("grant %d: %w", i, err)
}
grants = append(grants, grant)
}
return grants, nil
}
func exactSpoolObject(data []byte, names ...string) (map[string]json.RawMessage, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
token, err := decoder.Token()
if err != nil {
return nil, err
}
if token != json.Delim('{') {
return nil, errors.New("expected JSON object")
}
wanted := make(map[string]bool, len(names))
for _, name := range names {
wanted[name] = true
}
fields := make(map[string]json.RawMessage, len(names))
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return nil, err
}
name, ok := token.(string)
if !ok || !wanted[name] {
return nil, fmt.Errorf("unknown field %q", token)
}
if _, exists := fields[name]; exists {
return nil, fmt.Errorf("duplicate field %q", name)
}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return nil, err
}
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return nil, fmt.Errorf("null field %q", name)
}
fields[name] = value
}
if _, err := decoder.Token(); err != nil {
return nil, err
}
if _, err := decoder.Token(); err != io.EOF {
return nil, errors.New("trailing JSON content")
}
for _, name := range names {
if _, exists := fields[name]; !exists {
return nil, fmt.Errorf("missing field %q", name)
}
}
return fields, nil
}