Files
nostrlib/schema/schema.go
2025-08-21 16:17:53 -03:00

176 lines
4.2 KiB
Go

package schema
import (
_ "embed"
"encoding/hex"
"fmt"
"net/url"
"slices"
"strconv"
"strings"
"unsafe"
"fiatjaf.com/nostr"
"github.com/segmentio/encoding/json"
"gopkg.in/yaml.v3"
)
//go:embed schema.yaml
var schemaFile []byte
type Schema map[string]KindSchema
type KindSchema struct {
Content string `yaml:"content"`
Tags []tagSpec `yaml:"tags"`
}
type tagSpec struct {
Name string `yaml:"name"`
Prefix string `yaml:"prefix"`
Next *nextSpec `yaml:"next"`
}
type nextSpec struct {
Type string `yaml:"type"`
Required bool `yaml:"required"`
Either []string `yaml:"either"`
Next *nextSpec `yaml:"next"`
Variadic bool `yaml:"variadic"`
}
type Validator struct {
Schema Schema
FailOnUnknown bool
}
func NewValidator(schemaData string) Validator {
schema := make(Schema)
if err := yaml.Unmarshal([]byte(schemaData), &schema); err != nil {
panic(fmt.Errorf("failed to parse schema.yaml: %w", err))
}
return Validator{Schema: schema}
}
var (
ErrUnknownContent = fmt.Errorf("unknown content")
ErrUnknownKind = fmt.Errorf("unknown kind")
ErrInvalidContentJson = fmt.Errorf("invalid content json")
ErrEmptyTag = fmt.Errorf("empty tag")
ErrUnknownTagType = fmt.Errorf("unknown tag type")
)
func (v *Validator) ValidateEvent(evt nostr.Event) error {
if sch, ok := v.Schema[strconv.FormatUint(uint64(evt.Kind), 10)]; ok {
switch sch.Content {
case "json":
if !json.Valid(unsafe.Slice(unsafe.StringData(evt.Content), len(evt.Content))) {
return ErrInvalidContentJson
}
case "free":
default:
if v.FailOnUnknown {
return ErrInvalidContentJson
}
}
tags:
for _, tag := range evt.Tags {
if len(tag) == 0 {
return ErrEmptyTag
}
var lastErr error
specs:
for _, tagspec := range sch.Tags {
if tagspec.Name == tag[0] || (tagspec.Prefix != "" && strings.HasPrefix(tag[0], tagspec.Prefix)) {
if tagspec.Next != nil {
if err := v.validateNext(tag, 1, tagspec.Next); err != nil {
lastErr = err
continue specs // see if there is another tagspec that matches this
} else {
continue tags
}
}
} else {
continue specs
}
}
if lastErr != nil {
// there was at least one failure for this tag and no further successes
return lastErr
}
}
}
if v.FailOnUnknown {
return ErrUnknownKind
}
return nil
}
var gitcommitdummydecoder = make([]byte, 20)
func (v *Validator) validateNext(tag nostr.Tag, index int, this *nextSpec) error {
if len(tag) <= index {
if this.Required {
return fmt.Errorf("invalid tag '%s', missing index %d", tag[0], index)
}
return nil
}
switch this.Type {
case "id":
if _, err := nostr.IDFromHex(tag[index]); err != nil {
return fmt.Errorf("invalid id at tag '%s', index %d", tag[0], index)
}
case "pubkey":
if _, err := nostr.PubKeyFromHex(tag[index]); err != nil {
return fmt.Errorf("invalid pubkey at tag '%s', index %d", tag[0], index)
}
case "addr":
if _, err := nostr.ParseAddrString(tag[index]); err != nil {
return fmt.Errorf("invalid addr at tag '%s', index %d", tag[0], index)
}
case "kind":
if _, err := strconv.ParseUint(tag[index], 10, 16); err != nil {
return fmt.Errorf("invalid kind at tag '%s', index %d", tag[0], index)
}
case "relay":
if url, err := url.Parse(tag[index]); err != nil || (url.Scheme != "ws" && url.Scheme != "wss") {
return fmt.Errorf("invalid relay at tag '%s', index %d", tag[0], index)
}
case "constrained":
if !slices.Contains(this.Either, tag[index]) {
return fmt.Errorf("invalid constrained at tag '%s', index %d", tag[0], index)
}
case "gitcommit":
if len(tag[index]) != 40 {
return fmt.Errorf("invalid gitcommit at tag '%s', index %d", tag[0], index)
}
if _, err := hex.Decode(gitcommitdummydecoder, unsafe.Slice(unsafe.StringData(tag[index]), 40)); err != nil {
return fmt.Errorf("invalid gitcommit at tag '%s', index %d", tag[0], index)
}
case "free":
default:
if v.FailOnUnknown {
return ErrUnknownTagType
}
}
if this.Variadic {
// apply this same validation to all further items
if len(tag) >= index {
return v.validateNext(tag, index+1, this)
}
}
if this.Next != nil {
return v.validateNext(tag, index+1, this.Next)
}
return nil
}