331 lines
8.0 KiB
Go
331 lines
8.0 KiB
Go
package schema
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"unsafe"
|
|
|
|
"fiatjaf.com/nostr"
|
|
"github.com/segmentio/encoding/json"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const DefaultSchemaURL = "https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/refs/heads/master/schema.yaml"
|
|
|
|
// this is used by hex.Decode in the "hex" validator -- we don't care about data races
|
|
var hexdummydecoder = make([]byte, 128)
|
|
|
|
func fetchSchemaFromURL(schemaURL string) (string, error) {
|
|
resp, err := http.Get(schemaURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to fetch schema from URL: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("failed to fetch schema: HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read schema response: %w", err)
|
|
}
|
|
|
|
return string(body), nil
|
|
}
|
|
|
|
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"`
|
|
Min int `yaml:"min"`
|
|
Max int `yaml:"max"`
|
|
Either []string `yaml:"either"`
|
|
Next *nextSpec `yaml:"next"`
|
|
Variadic bool `yaml:"variadic"`
|
|
}
|
|
|
|
type Validator struct {
|
|
Schema Schema
|
|
FailOnUnknown bool
|
|
TypeValidators map[string]func(string, *nextSpec) error
|
|
UnknownTypes []string
|
|
}
|
|
|
|
func NewValidatorFromBytes(schemaData []byte) (Validator, error) {
|
|
schema := make(Schema)
|
|
if err := yaml.Unmarshal(schemaData, &schema); err != nil {
|
|
return Validator{}, fmt.Errorf("failed to parse schema: %w", err)
|
|
}
|
|
|
|
return NewValidatorFromSchema(schema), nil
|
|
}
|
|
|
|
func NewValidatorFromSchema(sch Schema) Validator {
|
|
validator := Validator{
|
|
Schema: sch,
|
|
TypeValidators: map[string]func(string, *nextSpec) error{
|
|
"id": func(value string, spec *nextSpec) error {
|
|
if len(value) != 64 {
|
|
return fmt.Errorf("needed 64 hex chars")
|
|
}
|
|
|
|
_, err := hex.Decode(hexdummydecoder, unsafe.Slice(unsafe.StringData(value), len(value)))
|
|
return err
|
|
},
|
|
"pubkey": func(value string, spec *nextSpec) error {
|
|
_, err := nostr.PubKeyFromHex(value)
|
|
return err
|
|
},
|
|
"addr": func(value string, spec *nextSpec) error {
|
|
_, err := nostr.ParseAddrString(value)
|
|
return err
|
|
},
|
|
"kind": func(value string, spec *nextSpec) error {
|
|
if _, err := strconv.ParseUint(value, 10, 16); err != nil {
|
|
return fmt.Errorf("not an unsigned integer: %w", err)
|
|
}
|
|
return nil
|
|
},
|
|
"relay": func(value string, spec *nextSpec) error {
|
|
if url, err := url.Parse(value); err != nil || (url.Scheme != "ws" && url.Scheme != "wss") {
|
|
return fmt.Errorf("must be ws or wss URL")
|
|
}
|
|
return nil
|
|
},
|
|
"constrained": func(value string, spec *nextSpec) error {
|
|
if !slices.Contains(spec.Either, value) {
|
|
return fmt.Errorf("not in allowed list")
|
|
}
|
|
return nil
|
|
},
|
|
"hex": func(value string, spec *nextSpec) error {
|
|
if spec.Min > 0 && len(value) < spec.Min {
|
|
return fmt.Errorf("hex value too short: %d < %d", len(value), spec.Min)
|
|
}
|
|
if spec.Max > 0 && len(value) > spec.Max {
|
|
return fmt.Errorf("hex value too long: %d > %d", len(value), spec.Max)
|
|
}
|
|
_, err := hex.Decode(hexdummydecoder, unsafe.Slice(unsafe.StringData(value), len(value)))
|
|
return err
|
|
},
|
|
"lowercase": func(value string, spec *nextSpec) error {
|
|
if strings.ToLower(value) != value {
|
|
return fmt.Errorf("not lowercase")
|
|
}
|
|
return nil
|
|
},
|
|
"imeta": func(value string, spec *nextSpec) error {
|
|
if len(strings.SplitN(value, " ", 2)) == 2 {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("not a space-separated keyval")
|
|
},
|
|
"free": func(value string, spec *nextSpec) error {
|
|
return nil // accepts anything
|
|
},
|
|
},
|
|
}
|
|
|
|
validator.UnknownTypes = validator.findUnknownTypes(sch)
|
|
|
|
return validator
|
|
}
|
|
|
|
func NewValidatorFromFile(filename string) (Validator, error) {
|
|
data, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
return Validator{}, fmt.Errorf("failed to read schema file: %w", err)
|
|
}
|
|
return NewValidatorFromBytes(data)
|
|
}
|
|
|
|
func NewValidatorFromURL(schemaURL string) (Validator, error) {
|
|
schemaData, err := fetchSchemaFromURL(schemaURL)
|
|
if err != nil {
|
|
return Validator{}, err
|
|
}
|
|
return NewValidatorFromBytes([]byte(schemaData))
|
|
}
|
|
|
|
var (
|
|
ErrUnknownContent = fmt.Errorf("unknown content")
|
|
ErrUnknownKind = fmt.Errorf("unknown kind")
|
|
ErrInvalidJson = fmt.Errorf("invalid json")
|
|
ErrEmptyValue = fmt.Errorf("can't be empty")
|
|
ErrEmptyTag = fmt.Errorf("empty tag")
|
|
ErrUnknownTagType = fmt.Errorf("unknown tag type")
|
|
ErrDanglingSpace = fmt.Errorf("value has dangling space")
|
|
)
|
|
|
|
type UnknownTypes struct {
|
|
Types []string
|
|
}
|
|
|
|
func (ut UnknownTypes) Error() string {
|
|
return fmt.Sprintf("unknown types: %v", ut.Types)
|
|
}
|
|
|
|
type ContentError struct {
|
|
Err error
|
|
}
|
|
|
|
func (ce ContentError) Error() string {
|
|
return fmt.Sprintf("content: %s", ce.Err)
|
|
}
|
|
|
|
type TagError struct {
|
|
Tag int
|
|
Item int
|
|
Err error
|
|
}
|
|
|
|
func (te TagError) Error() string {
|
|
return fmt.Sprintf("tag[%d][%d]: %s", te.Tag, te.Item, te.Err)
|
|
}
|
|
|
|
func (v *Validator) ValidateEvent(evt nostr.Event) error {
|
|
if !isTrimmed(evt.Content) {
|
|
return ContentError{ErrDanglingSpace}
|
|
}
|
|
|
|
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 ContentError{ErrInvalidJson}
|
|
}
|
|
case "free":
|
|
if evt.Content == "" {
|
|
return ContentError{ErrEmptyValue}
|
|
}
|
|
default:
|
|
if v.FailOnUnknown {
|
|
return ContentError{ErrUnknownContent}
|
|
}
|
|
}
|
|
|
|
tags:
|
|
for ti, 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 ii, err := v.validateNext(tag, 1, tagspec.Next); err != nil {
|
|
lastErr = TagError{ti, ii, 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
|
|
}
|
|
|
|
func collectTypes(spec *nextSpec, visitedTypes []string, cb func(string)) {
|
|
if spec == nil {
|
|
return
|
|
}
|
|
if !slices.Contains(visitedTypes, spec.Type) {
|
|
visitedTypes = append(visitedTypes, spec.Type)
|
|
cb(spec.Type)
|
|
}
|
|
|
|
collectTypes(spec.Next, visitedTypes, cb)
|
|
}
|
|
|
|
func (v *Validator) findUnknownTypes(schema Schema) []string {
|
|
var unknown []string
|
|
|
|
visitedTypes := make([]string, 0, 10)
|
|
for _, kindSchema := range schema {
|
|
for _, tagSpec := range kindSchema.Tags {
|
|
collectTypes(tagSpec.Next, visitedTypes, func(typeName string) {
|
|
if _, ok := v.TypeValidators[typeName]; !ok {
|
|
unknown = append(unknown, typeName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
return unknown
|
|
}
|
|
|
|
func (v *Validator) validateNext(tag nostr.Tag, index int, this *nextSpec) (failedIndex int, err error) {
|
|
if len(tag) <= index {
|
|
if this.Required {
|
|
return index, fmt.Errorf("invalid tag '%s', missing index %d", tag[0], index)
|
|
}
|
|
return -1, nil
|
|
}
|
|
|
|
if !isTrimmed(tag[index]) {
|
|
return index, ErrDanglingSpace
|
|
}
|
|
|
|
if validator, ok := v.TypeValidators[this.Type]; ok {
|
|
if err := validator(tag[index], this); err != nil {
|
|
return index, fmt.Errorf("invalid %s value '%s' at tag '%s', index %d: %w",
|
|
this.Type, tag[index], tag[0], index, err)
|
|
}
|
|
} else {
|
|
if v.FailOnUnknown {
|
|
return index, 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 -1, nil
|
|
}
|