diff --git a/model/validate.go b/model/validate.go index b303627..c993237 100644 --- a/model/validate.go +++ b/model/validate.go @@ -82,68 +82,85 @@ var ( ErrWrongEventParams = errors.New("wrong event params") ErrUnsupportedTag = errors.New("unsupported tag") ErrUnsupportedJob = errors.New("unsupported job") - - KindSupportedTags = map[Kind][]string{ - nostr.KindProfileMetadata: {"e", "p", "a", "alt"}, - nostr.KindTextNote: {"e", "p", "q", "l", "L", "imeta"}, - nostr.KindFollowList: {"p"}, - nostr.KindDeletion: {"a", "e", "k"}, - nostr.KindRepost: {"e", "p"}, - nostr.KindReaction: {"e", "p", "a", "k"}, - nostr.KindBadgeAward: {"a", "p"}, - nostr.KindGenericRepost: {"k", "e", "p"}, - nostr.KindReactionToWebsite: {"r"}, - nostr.KindMuteList: {"p", "t", "word", "e"}, - nostr.KindPinList: {"e"}, - nostr.KindBookmarkList: {"e", "a", "t", "r"}, - nostr.KindCommunityList: {"a"}, - nostr.KindPublicChatList: {"e"}, - nostr.KindBlockedRelayList: {"relay"}, - nostr.KindSearchRelayList: {"relay"}, - nostr.KindSimpleGroupList: {"group"}, - nostr.KindInterestList: {"t", "a"}, - nostr.KindEmojiList: {"emoji", "a"}, - nostr.KindDMRelayList: {"relay"}, - nostr.KindGoodWikiAuthorList: {"p"}, - nostr.KindGoodWikiRelayList: {"relay"}, - nostr.KindCategorizedPeopleList: {"p", "d", "title", "image", "description"}, - nostr.KindRelaySets: {"relay", "d", "title", "image", "description"}, - nostr.KindBookmarkSets: {"e", "a", "t", "r", "d", "title", "image", "description"}, - nostr.KindCuratedSets: {"a", "e", "d", "title", "image", "description"}, - nostr.KindCuratedVideoSets: {"a", "d", "title", "image", "description"}, - nostr.KindMuteSets: {"p", "d", "title", "image", "description"}, - nostr.KindInterestSets: {"t", "d", "title", "image", "description"}, - nostr.KindEmojiSets: {"emoji", "d", "title", "image", "description"}, - nostr.KindReleaseArtifactSets: {"e", "i", "version", "d", "title", "image", "description"}, - nostr.KindLabel: {"L", "l", "e", "p", "a", "r", "t"}, - nostr.KindRelayListMetadata: {"r"}, - nostr.KindProfileBadges: {"d", "a", "e"}, - nostr.KindBadgeDefinition: {"d", "name", "image", "description", "thumb"}, - nostr.KindArticle: {"a", "d", "e", "t", "title", "image", "summary", "published_at", "imeta"}, - nostr.KindDraftArticle: {"a", "d", "e", "t", "title", "image", "summary", "published_at", "imeta"}, + CommongTags = tagsTable("nonce", "expiration", "imeta", CustomIONTagOnBehalfOf) + KindSupportedTags = map[Kind]map[string]struct{}{ + nostr.KindProfileMetadata: tagsTable("e", "p", "a", "alt"), + nostr.KindTextNote: tagsTable("e", "p", "q", "l", "L"), + nostr.KindFollowList: tagsTable("p"), + nostr.KindDeletion: tagsTable("a", "e", "k"), + nostr.KindRepost: tagsTable("e", "p"), + nostr.KindReaction: tagsTable("e", "p", "a", "k"), + nostr.KindBadgeAward: tagsTable("a", "p"), + nostr.KindGenericRepost: tagsTable("k", "e", "p"), + nostr.KindReactionToWebsite: tagsTable("r"), + nostr.KindMuteList: tagsTable("p", "t", "word", "e"), + nostr.KindPinList: tagsTable("e"), + nostr.KindBookmarkList: tagsTable("e", "a", "t", "r"), + nostr.KindCommunityList: tagsTable("a"), + nostr.KindPublicChatList: tagsTable("e"), + nostr.KindBlockedRelayList: tagsTable("relay"), + nostr.KindSearchRelayList: tagsTable("relay"), + nostr.KindSimpleGroupList: tagsTable("group"), + nostr.KindInterestList: tagsTable("t", "a"), + nostr.KindEmojiList: tagsTable("emoji", "a"), + nostr.KindDMRelayList: tagsTable("relay"), + nostr.KindGoodWikiAuthorList: tagsTable("p"), + nostr.KindGoodWikiRelayList: tagsTable("relay"), + nostr.KindCategorizedPeopleList: tagsTable("p", "d", "title", "image", "description"), + nostr.KindRelaySets: tagsTable("relay", "d", "title", "image", "description"), + nostr.KindBookmarkSets: tagsTable("e", "a", "t", "r", "d", "title", "image", "description"), + nostr.KindCuratedSets: tagsTable("a", "e", "d", "title", "image", "description"), + nostr.KindCuratedVideoSets: tagsTable("a", "d", "title", "image", "description"), + nostr.KindMuteSets: tagsTable("p", "d", "title", "image", "description"), + nostr.KindInterestSets: tagsTable("t", "d", "title", "image", "description"), + nostr.KindEmojiSets: tagsTable("emoji", "d", "title", "image", "description"), + nostr.KindReleaseArtifactSets: tagsTable("e", "i", "version", "d", "title", "image", "description"), + nostr.KindLabel: tagsTable("L", "l", "e", "p", "a", "r", "t"), + nostr.KindRelayListMetadata: tagsTable("r"), + nostr.KindProfileBadges: tagsTable("d", "a", "e"), + nostr.KindBadgeDefinition: tagsTable("d", "name", "image", "description", "thumb"), + nostr.KindArticle: tagsTable("a", "d", "e", "t", "title", "image", "summary", "published_at"), + nostr.KindDraftArticle: tagsTable("a", "d", "e", "t", "title", "image", "summary", "published_at"), // --- Jobs - KindJobTextExtraction: {"i", "output", "param", "bid", "relays", "p"}, - KindJobSummarization: {"i", "output", "param", "bid", "relays", "p"}, - KindJobTranslation: {"i", "output", "param", "bid", "relays", "p"}, - KindJobTextGeneration: {"i", "output", "param", "bid", "relays", "p"}, - KindJobImageGeneration: {"i", "output", "param", "bid", "relays", "p"}, - KindJobVideoConversion: {"i", "output", "param", "bid", "relays", "p"}, - KindJobVideoTranslation: {"i", "output", "param", "bid", "relays", "p"}, - KindJobImageToVideoConversion: {"i", "output", "param", "bid", "relays", "p"}, - KindJobTextToSpeechGeneration: {"i", "output", "param", "bid", "relays", "p"}, - KindJobNostrContentDiscovery: {"i", "output", "param", "bid", "relays", "p"}, - KindJobNostrPeopleDiscovery: {"i", "output", "param", "bid", "relays", "p"}, - KindJobNostrContentSearch: {"i", "output", "param", "bid", "relays", "p"}, - KindJobNostrPeopleSearch: {"i", "output", "param", "bid", "relays", "p"}, - KindJobNostrEventCount: {"i", "output", "param", "bid", "relays", "p"}, - KindJobMalwareScanning: {"i", "output", "param", "bid", "relays", "p"}, - KindJobNostrEventTimeStamping: {"i", "output", "param", "bid", "relays", "p"}, - KindJobOpReturnCreation: {"i", "output", "param", "bid", "relays", "p"}, - KindJobNostrEventPublishSchedule: {"i", "output", "param", "bid", "relays", "p", "encrypted"}, - nostr.KindJobFeedback: {"status", "amount", "e", "p"}, - } - SupportedIMetaKeys = []string{"url", "m", "x", "ox", "size", "dim", "magnet", "i", "blurhash", "thumb", "image", "summary", "alt", "fallback"} + KindJobTextExtraction: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobSummarization: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobTranslation: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobTextGeneration: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobImageGeneration: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobVideoConversion: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobVideoTranslation: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobImageToVideoConversion: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobTextToSpeechGeneration: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobNostrContentDiscovery: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobNostrPeopleDiscovery: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobNostrContentSearch: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobNostrPeopleSearch: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobNostrEventCount: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobMalwareScanning: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobNostrEventTimeStamping: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobOpReturnCreation: tagsTable("i", "output", "param", "bid", "relays", "p"), + KindJobNostrEventPublishSchedule: tagsTable("i", "output", "param", "bid", "relays", "p", "encrypted"), + nostr.KindJobFeedback: tagsTable("status", "amount", "e", "p"), + } + + // Tag name -> required. + SupportedIMetaKeys = map[string]bool{ + "url": true, + "m": true, + "x": false, + "ox": false, + "size": false, + "dim": false, + "magnet": false, + "i": true, + "blurhash": false, + "thumb": false, + "image": false, + "summary": false, + "alt": true, + "fallback": false, + } JobFeedbackStatusValues = map[string]struct{}{ JobFeedbackStatusPaymentRequired: {}, @@ -158,8 +175,8 @@ func (e *Event) Validate() error { if e.Kind < 0 || e.Kind > 65535 { return errors.New("wrong kind value") } - if !areTagsSupported(e) { - return errors.Wrapf(ErrUnsupportedTag, "unsupported tag for this event kind: %+v", e) + if err := validateEventTags(e); err != nil { + return errors.Wrapf(err, "event: %+v", e) } e.normalizeTags() switch e.Kind { @@ -284,9 +301,6 @@ func (e *Event) Validate() error { if e.Content == "" { return errors.Wrapf(ErrWrongEventParams, "nip-23: this kind should have text markdown content: %+v", e) } - if err := validateIMetaTag(e.Tags); err != nil { - return errors.Wrapf(ErrWrongEventParams, "nip-92: %w", err) - } default: if e.Kind >= 6000 && e.Kind <= 6999 { return validateKindJobResult(e) @@ -549,9 +563,6 @@ func validateKindTextNoteEvent(e *Event) error { } } } - if err := validateIMetaTag(e.Tags); err != nil { - return errors.Wrapf(err, "wrong imeta tag: %+v", e) - } return nil } @@ -646,82 +657,88 @@ func validateKindFeedbackJob(e *Event) error { return nil } -func validateIMetaTag(tags nostr.Tags) error { - if tags == nil { +func validateIMetaTag(tag nostr.Tag) error { + if tag == nil { return nil } - var iMetaTagCount int - for _, tag := range tags { - if tag.Key() != "imeta" { - continue + + values := make(map[string]string) + // Parse tag values and check for all unsupported values. + for _, val := range tag[1:] { + parts := strings.Split(val, " ") + if len(parts) < 2 { + return errors.Wrapf(ErrWrongEventParams, "wrong imeta tag: %+v", tag) + } else if _, ok := SupportedIMetaKeys[parts[0]]; !ok { + return errors.Wrapf(ErrWrongEventParams, "not supported imeta value: %s", parts[0]) + } else if _, ok := values[parts[0]]; ok { + return errors.Wrapf(ErrWrongEventParams, "duplicate imeta value: %s", parts[0]) } - iMetaTagCount++ - if len(tag) < 3 { - return errors.Wrapf(ErrWrongEventParams, "imeta tag should have at least 2 values: %+v", tag) + values[parts[0]] = parts[1] + } + + // Check for all required values. + for key, required := range SupportedIMetaKeys { + if required && values[key] == "" { + return errors.Wrapf(ErrWrongEventParams, "missing required imeta value: %s", key) } - for ix, val := range tag { - if ix == 0 { - continue + } + + // Either x or ox should be present and they should be hex. + if values["x"] == "" && values["ox"] == "" { + return errors.Wrapf(ErrWrongEventParams, "missing required imeta value: x or ox") + } + + // Check for values correctness. + for key, value := range values { + switch key { + case "x", "ox": + if _, err := hex.DecodeString(value); err != nil { + return errors.Wrapf(ErrWrongEventParams, "wrong imeta value: %s, should be hex", key) } - parts := strings.Split(val, " ") - if len(parts) < 2 { - return errors.Wrapf(ErrWrongEventParams, "wrong imeta tag: %+v", tag) + case "url": + if !strings.HasPrefix(value, "http") { + return errors.Wrapf(ErrWrongEventParams, "wrong imeta value: %s, should be url", key) } - var ( - key = parts[0] - val = parts[1] - isKeySupported = false - ) - for _, supportedIMetaKey := range SupportedIMetaKeys { - if key == supportedIMetaKey { - isKeySupported = true - - break + case "m": + if strings.ToLower(value) != value { + return errors.Wrapf(ErrWrongEventParams, "wrong imeta value: %s, should be lowercase", key) + } else if strings.HasPrefix(value, "video") { + for _, videoKey := range []string{"thumb", "image", "dim"} { + if values[videoKey] == "" { + return errors.Wrapf(ErrWrongEventParams, "missing required imeta value: %s for video content", videoKey) + } } } - if !isKeySupported { - return errors.Wrapf(ErrWrongEventParams, "wrong imeta tag: %+v", tag) - } - if key == "url" && !strings.HasPrefix(val, "http") { - return errors.Wrapf(ErrWrongEventParams, "wrong url value in imeta tag: %+v", tag) - } else if key == "m" && strings.ToLower(val) != val { - return errors.Wrapf(ErrWrongEventParams, "wrong m value in imeta tag: %+v", tag) - } else if key == "x" || key == "ox" { - if _, err := hex.DecodeString(val); err != nil { - return errors.Wrapf(ErrWrongEventParams, "wrong x value in imeta tag: %+v, should be hex", tag) - } - } else if key == "dim" && len(strings.Split(val, "x")) != 2 { - return errors.Wrapf(ErrWrongEventParams, "wrong dim value in imeta tag: %+v", tag) + case "dim": + if len(strings.Split(value, "x")) != 2 { + return errors.Wrapf(ErrWrongEventParams, "wrong imeta value: %s, should be in format: 123x123", key) } } } - if iMetaTagCount > 1 { - return errors.Wrapf(ErrWrongEventParams, "wrong count of imeta tag: %+v", tags) - } return nil } -func areTagsSupported(e *Event) bool { +func validateEventTags(e *Event) error { supportedTags, ok := KindSupportedTags[e.Kind] if !ok { - return true + return nil } -next: + for _, tag := range e.Tags { - if tag.Key() == "nonce" || tag.Key() == "expiration" || tag.Key() == CustomIONTagOnBehalfOf { - continue next + _, isCommon := CommongTags[tag.Key()] + if _, ok := supportedTags[tag.Key()]; !ok && !isCommon { + return errors.Wrapf(ErrUnsupportedTag, "tag: %v", tag) } - for _, supportedTag := range supportedTags { - if tag.Key() == supportedTag { - continue next + + if tag.Key() == "imeta" { + if err := validateIMetaTag(tag); err != nil { + return errors.Join(ErrUnsupportedTag, err) } } - - return false } - return true + return nil } func (e *Event) normalizeTags() { @@ -734,3 +751,11 @@ func (e *Event) normalizeTags() { } } } + +func tagsTable(tags ...string) map[string]struct{} { + table := make(map[string]struct{}, len(tags)) + for _, tag := range tags { + table[tag] = struct{}{} + } + return table +} diff --git a/server/ws/subscriptions_test.go b/server/ws/subscriptions_test.go index ba0b86c..9aaff19 100644 --- a/server/ws/subscriptions_test.go +++ b/server/ws/subscriptions_test.go @@ -2336,6 +2336,7 @@ func TestPublishingNIP92IMetaTag(t *testing.T) { "url https://alicerelay.example.com", "m image/jpg", "dim 3024x4032", + "i foobar", "alt A scenic photo overlooking the coast of Costa Rica", fmt.Sprintf("x %x", []byte("https://alicerelay.example.com")), fmt.Sprintf("ox %x", []byte("https://alicerelay.example.com")), @@ -2356,6 +2357,7 @@ func TestPublishingNIP92IMetaTag(t *testing.T) { "imeta", "url https://alicerelay.example.com", "m image/jpg", + "i foobar", "dim 3024x4032", "alt A scenic photo overlooking the coast of Costa Rica", fmt.Sprintf("x %x", []byte("https://alicerelay.example.com")), @@ -2406,6 +2408,7 @@ func TestPublishingNIP92IMetaTag(t *testing.T) { "imeta", "url https://alicerelay.example.com", "m image/jpg", + "i foobar", "dim 3024", "alt A scenic photo overlooking the coast of Costa Rica", fmt.Sprintf("x %x", []byte("https://alicerelay.example.com")), @@ -2427,6 +2430,7 @@ func TestPublishingNIP92IMetaTag(t *testing.T) { "url https://alicerelay.example.com", "m image/jpg", "dim 3024x4032", + "i foobar", "alt A scenic photo overlooking the coast of Costa Rica", "x a", fmt.Sprintf("ox %v", hex.EncodeToString([]byte("https://alicerelay.example.com"))), @@ -2447,9 +2451,9 @@ func TestPublishingNIP92IMetaTag(t *testing.T) { "url https://alicerelay.example.com", "m image/jpg", "dim 3024x4032", + "i foobar", "alt A scenic photo overlooking the coast of Costa Rica", "ox a", - fmt.Sprintf("ox %v", hex.EncodeToString([]byte("https://alicerelay.example.com"))), }) ev := &model.Event{Event: nostr.Event{ CreatedAt: nostr.Timestamp(time.Now().Unix()),