Files
nostrlib/schema/schema.go
2025-11-23 17:52:29 -03:00

408 lines
10 KiB
Go

package schema
import (
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"slices"
"strconv"
"strings"
"unsafe"
"fiatjaf.com/nostr"
"gopkg.in/yaml.v3"
)
const DefaultSchemaURL = "https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/daaa3a2a5573606aa0a4c4a98c25460ea9a1e388/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 struct {
GenericTags map[string]nextSpec `yaml:"generic_tags"`
Kinds map[string]KindSchema `yaml:"kinds"`
}
type KindSchema struct {
Content nextSpec `yaml:"content"`
Required []string `yaml:"required"`
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
FailOnUnknownKind bool
FailOnUnknownType bool
TypeValidators map[string]func(value string, spec *nextSpec) error
UnknownTypes []string
}
func NewValidatorFromBytes(schemaData []byte) (Validator, error) {
schema := Schema{
GenericTags: make(map[string]nextSpec),
Kinds: make(map[string]KindSchema),
}
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(value string, spec *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
},
"json": func(value string, spec *nextSpec) error {
if !json.Valid(unsafe.Slice(unsafe.StringData(value), len(value))) {
return ErrInvalidJson
}
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
},
"numeric": func(value string, spec *nextSpec) error {
_, err := strconv.ParseUint(value, 10, 64)
return err
},
"imeta": func(value string, spec *nextSpec) error {
if len(strings.SplitN(value, " ", 2)) == 2 {
return nil
}
return fmt.Errorf("not a space-separated keyval")
},
"empty": func(value string, spec *nextSpec) error {
if len(value) > 0 {
return fmt.Errorf("not empty")
}
return nil
},
"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")
ErrDTagInNonAddressable = fmt.Errorf("non-addressable event can't have a 'd' 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)
}
type RequiredTagError struct {
Missing []string
}
func (rte RequiredTagError) Error() string {
return fmt.Sprintf("missing tags: %v", rte.Missing)
}
func (v *Validator) ValidateEvent(evt nostr.Event) error {
if !isTrimmed(evt.Content) {
return ContentError{ErrDanglingSpace}
}
if sch, ok := v.Schema.Kinds[strconv.FormatUint(uint64(evt.Kind), 10)]; ok {
if validator, ok := v.TypeValidators[sch.Content.Type]; ok {
if err := validator(evt.Content, &sch.Content); err != nil {
return ContentError{err}
}
} else {
if v.FailOnUnknownType {
return ContentError{ErrUnknownContent}
}
}
var requiredTags []string
if len(sch.Required) > 0 {
requiredTags = append(requiredTags, sch.Required...)
}
needsD := evt.Kind.IsAddressable()
tags:
for ti, tag := range evt.Tags {
if len(tag) == 0 {
return ErrEmptyTag
}
// if this is a "d" tag in an addressable event, just handle it here
if tag[0] == "d" {
if evt.Kind.IsAddressable() {
needsD = false
} else {
return ErrDTagInNonAddressable
}
}
if requiredTags != nil {
// swap-delete this from the list of requirements
if idx := slices.Index(requiredTags, tag[0]); idx != -1 {
requiredTags[idx] = requiredTags[len(requiredTags)-1]
requiredTags = requiredTags[0 : len(requiredTags)-1]
}
}
var lastErr error
var tagWasValidated bool
specs:
for _, tagspec := range sch.Tags {
if tagspec.Name == tag[0] || (tagspec.Prefix != "" && strings.HasPrefix(tag[0], tagspec.Prefix)) {
if tagspec.Next != nil {
tagWasValidated = true
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
}
// when we don't find a specific tag validator for this kind, try a generic one
if !tagWasValidated {
if tagSpecNext, ok := v.Schema.GenericTags[tag[0]]; ok {
if ii, err := v.validateNext(tag, 1, &tagSpecNext); err != nil {
return TagError{ti, ii, err}
}
}
}
}
if len(requiredTags) > 0 {
return RequiredTagError{requiredTags}
}
if needsD {
return RequiredTagError{Missing: []string{"d"}}
}
}
if v.FailOnUnknownKind {
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.Kinds {
for _, tagSpec := range kindSchema.Tags {
collectTypes(tagSpec.Next, visitedTypes, func(typeName string) {
if _, ok := v.TypeValidators[typeName]; !ok {
unknown = append(unknown, typeName)
}
})
}
}
for _, tagSpec := range schema.GenericTags {
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.FailOnUnknownType {
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
}