From 8add32bb78d732cb03ad6e673ff89218225a3433 Mon Sep 17 00:00:00 2001 From: mleku Date: Thu, 21 Aug 2025 15:22:17 +0100 Subject: [PATCH] Add relayinfo package and utility modules for NIP-11 support Introduce the `relayinfo` package with `NIP-11` utilities, including `Fees`, `Limits`, and `NIPs` structures. Add utility modules for handling numbers, timestamps, and kinds. Integrate functionality for fetching and managing relay information. --- app/handleRelayinfo.go | 63 ++++ app/listener.go | 29 ++ app/main.go | 9 +- pkg/encoders/kind/kind.go | 424 ++++++++++++++++++++++++++ pkg/encoders/kind/kind_test.go | 37 +++ pkg/encoders/kind/kinds.go | 150 +++++++++ pkg/encoders/kind/kinds_test.go | 36 +++ pkg/encoders/timestamp/timestamp.go | 119 ++++++++ pkg/protocol/relayinfo/fetch.go | 48 +++ pkg/protocol/relayinfo/nip11_test.go | 15 + pkg/protocol/relayinfo/relayinfo.go | 39 +++ pkg/protocol/relayinfo/types.go | 358 ++++++++++++++++++++++ pkg/utils/normalize/normalize.go | 143 +++++++++ pkg/utils/normalize/normalize_test.go | 35 +++ pkg/utils/number/list.go | 36 +++ pkg/version/version.go | 2 +- 16 files changed, 1541 insertions(+), 2 deletions(-) create mode 100644 app/handleRelayinfo.go create mode 100644 app/listener.go create mode 100644 pkg/encoders/kind/kind.go create mode 100644 pkg/encoders/kind/kind_test.go create mode 100644 pkg/encoders/kind/kinds.go create mode 100644 pkg/encoders/kind/kinds_test.go create mode 100644 pkg/encoders/timestamp/timestamp.go create mode 100644 pkg/protocol/relayinfo/fetch.go create mode 100644 pkg/protocol/relayinfo/nip11_test.go create mode 100644 pkg/protocol/relayinfo/relayinfo.go create mode 100644 pkg/protocol/relayinfo/types.go create mode 100644 pkg/utils/normalize/normalize.go create mode 100644 pkg/utils/normalize/normalize_test.go create mode 100644 pkg/utils/number/list.go diff --git a/app/handleRelayinfo.go b/app/handleRelayinfo.go new file mode 100644 index 0000000..a341a1f --- /dev/null +++ b/app/handleRelayinfo.go @@ -0,0 +1,63 @@ +package app + +import ( + "encoding/json" + "net/http" + "sort" + + "lol.mleku.dev/chk" + "lol.mleku.dev/log" + "next.orly.dev/pkg/protocol/relayinfo" + "next.orly.dev/pkg/version" +) + +// HandleRelayInfo generates and returns a relay information document in JSON +// format based on the server's configuration and supported NIPs. +// +// # Parameters +// +// - w: HTTP response writer used to send the generated document. +// +// - r: HTTP request object containing incoming client request data. +// +// # Expected Behaviour +// +// The function constructs a relay information document using either the +// Informer interface implementation or predefined server configuration. It +// returns this document as a JSON response to the client. +func (l *Listener) HandleRelayInfo(w http.ResponseWriter, r *http.Request) { + r.Header.Set("Content-Type", "application/json") + log.I.Ln("handling relay information document") + var info *relayinfo.T + supportedNIPs := relayinfo.GetList( + relayinfo.BasicProtocol, + // relayinfo.Authentication, + // relayinfo.EncryptedDirectMessage, + // relayinfo.EventDeletion, + relayinfo.RelayInformationDocument, + // relayinfo.GenericTagQueries, + // relayinfo.NostrMarketplace, + // relayinfo.EventTreatment, + // relayinfo.CommandResults, + // relayinfo.ParameterizedReplaceableEvents, + // relayinfo.ExpirationTimestamp, + // relayinfo.ProtectedEvents, + // relayinfo.RelayListMetadata, + ) + sort.Sort(supportedNIPs) + log.T.Ln("supported NIPs", supportedNIPs) + info = &relayinfo.T{ + Name: l.Config.AppName, + Description: version.Description, + Nips: supportedNIPs, + Software: version.URL, + Version: version.V, + Limitation: relayinfo.Limits{ + // AuthRequired: l.C.AuthRequired, + // RestrictedWrites: l.C.AuthRequired, + }, + Icon: "https://cdn.satellite.earth/ac9778868fbf23b63c47c769a74e163377e6ea94d3f0f31711931663d035c4f6.png", + } + if err := json.NewEncoder(w).Encode(info); chk.E(err) { + } +} diff --git a/app/listener.go b/app/listener.go new file mode 100644 index 0000000..07a0d8a --- /dev/null +++ b/app/listener.go @@ -0,0 +1,29 @@ +package app + +import ( + "net/http" + + "lol.mleku.dev/log" + "next.orly.dev/app/config" +) + +type Listener struct { + mux *http.ServeMux + Config *config.C +} + +func (l *Listener) ServeHTTP(w http.ResponseWriter, r *http.Request) { + log.I.F("path %v header %v", r.URL, r.Header) + if r.Header.Get("Upgrade") == "websocket" { + l.HandleWebsocket(w, r) + } else if r.Header.Get("Accept") == "application/nostr+json" { + l.HandleRelayInfo(w, r) + } else { + http.Error(w, "Upgrade required", http.StatusUpgradeRequired) + } +} + +func (l *Listener) HandleWebsocket(w http.ResponseWriter, r *http.Request) { + log.I.F("websocket") + return +} diff --git a/app/main.go b/app/main.go index f25b5b3..8ec30c7 100644 --- a/app/main.go +++ b/app/main.go @@ -2,6 +2,8 @@ package app import ( "context" + "fmt" + "net/http" "lol.mleku.dev/log" "next.orly.dev/app/config" @@ -17,7 +19,12 @@ func Run(ctx context.Context, cfg *config.C) (quit chan struct{}) { } }() // start listener - + l := &Listener{ + Config: cfg, + } + addr := fmt.Sprintf("%s:%d", cfg.Listen, cfg.Port) + log.I.F("starting listener on %s", addr) + go http.ListenAndServe(addr, l) quit = make(chan struct{}) return } diff --git a/pkg/encoders/kind/kind.go b/pkg/encoders/kind/kind.go new file mode 100644 index 0000000..8c23313 --- /dev/null +++ b/pkg/encoders/kind/kind.go @@ -0,0 +1,424 @@ +// Package kind includes a type for convenient handling of event kinds, and a +// kind database with reverse lookup for human-readable information about event +// kinds. +package kind + +import ( + "sync" + + "lol.mleku.dev/chk" + "next.orly.dev/pkg/encoders/ints" + + "golang.org/x/exp/constraints" +) + +// K - which will be externally referenced as kind.K is the event type in the +// nostr protocol, the use of the capital K signifying type, consistent with Go +// idiom, the Go standard library, and much, conformant, existing code. +type K struct { + K uint16 +} + +// New creates a new kind.K with a provided integer value. Note that anything larger than 2^16 +// will be truncated. +func New[V constraints.Integer](k V) (ki *K) { return &K{uint16(k)} } + +// ToInt returns the value of the kind.K as an int. +func (k *K) ToInt() int { + if k == nil { + return 0 + } + return int(k.K) +} + +// ToU16 returns the value of the kind.K as an uint16 (the native form). +func (k *K) ToU16() uint16 { + if k == nil { + return 0 + } + return k.K +} + +// ToI32 returns the value of the kind.K as an int32. +func (k *K) ToI32() int32 { + if k == nil { + return 0 + } + return int32(k.K) +} + +// ToU64 returns the value of the kind.K as an uint64. +func (k *K) ToU64() uint64 { + if k == nil { + return 0 + } + return uint64(k.K) +} + +// Name returns the human readable string describing the semantics of the kind.K. +func (k *K) Name() string { return GetString(k) } + +// Equal checks if +func (k *K) Equal(k2 *K) bool { + if k == nil || k2 == nil { + return false + } + return k.K == k2.K +} + +var Privileged = []*K{ + EncryptedDirectMessage, + GiftWrap, + GiftWrapWithKind4, + JWTBinding, + ApplicationSpecificData, + Seal, + PrivateDirectMessage, +} + +// IsPrivileged returns true if the type is the kind of message nobody else than the pubkeys in +// the event and p tags of the event are party to. +func (k *K) IsPrivileged() (is bool) { + for i := range Privileged { + if k.Equal(Privileged[i]) { + return true + } + } + return +} + +// Marshal renders the kind.K into bytes containing the ASCII string form of the kind number. +func (k *K) Marshal(dst []byte) (b []byte) { return ints.New(k.ToU64()).Marshal(dst) } + +// Unmarshal decodes a byte string into a kind.K. +func (k *K) Unmarshal(b []byte) (r []byte, err error) { + n := ints.New(0) + if r, err = n.Unmarshal(b); chk.T(err) { + return + } + k.K = n.Uint16() + return +} + +// GetString returns a human-readable identifier for a kind.K. +func GetString(t *K) string { + if t == nil { + return "" + } + MapMx.Lock() + defer MapMx.Unlock() + return Map[t.K] +} + +// IsEphemeral returns true if the event kind is an ephemeral event. (not to be +// stored) +func (k *K) IsEphemeral() bool { + return k.K >= EphemeralStart.K && k.K < EphemeralEnd.K +} + +// IsReplaceable returns true if the event kind is a replaceable kind - that is, +// if the newest version is the one that is in force (eg follow lists, relay +// lists, etc. +func (k *K) IsReplaceable() bool { + return k.K == ProfileMetadata.K || k.K == FollowList.K || + (k.K >= ReplaceableStart.K && k.K < ReplaceableEnd.K) +} + +// IsParameterizedReplaceable is a kind of event that is one of a group of +// events that replaces based on matching criteria. +func (k *K) IsParameterizedReplaceable() bool { + return k.K >= ParameterizedReplaceableStart.K && + k.K < ParameterizedReplaceableEnd.K +} + +// Directory events are events that necessarily need to be readable by anyone in +// order to interact with users who have access to the relay, in order to +// facilitate other users to find and interact with users on an auth-required +// relay. +var Directory = []*K{ + ProfileMetadata, + FollowList, + EventDeletion, + Reporting, + RelayListMetadata, + MuteList, + DMRelaysList, +} + +// IsDirectoryEvent returns whether an event kind is a Directory event, which +// should grant permission to read such events without requiring authentication. +func (k *K) IsDirectoryEvent() bool { + for i := range Directory { + if k.Equal(Directory[i]) { + return true + } + } + return false +} + +var ( + // ProfileMetadata is an event type that stores user profile data, pet + // names, bio, lightning address, etc. + ProfileMetadata = &K{0} + // SetMetadata is a synonym for ProfileMetadata. + SetMetadata = &K{0} + // TextNote is a standard short text note of plain text a la twitter + TextNote = &K{1} + // RecommendServer is an event type that... + RecommendServer = &K{2} + RecommendRelay = &K{2} + // FollowList an event containing a list of pubkeys of users that should be + // shown as follows in a timeline. + FollowList = &K{3} + Follows = &K{3} + // EncryptedDirectMessage is an event type that... + EncryptedDirectMessage = &K{4} + // Deletion is an event type that... + Deletion = &K{5} + EventDeletion = &K{5} + // Repost is an event type that... + Repost = &K{6} + // Reaction is an event type that... + Reaction = &K{7} + // BadgeAward is an event type + BadgeAward = &K{8} + // Seal is an event that wraps a PrivateDirectMessage and is placed inside a + // GiftWrap or GiftWrapWithKind4 + Seal = &K{13} + // PrivateDirectMessage is a nip-17 direct message with a different + // construction. It doesn't actually appear as an event a relay might receive + // but only as the stringified content of a GiftWrap or GiftWrapWithKind4 inside + // a + PrivateDirectMessage = &K{14} + // ReadReceipt is a type of event that marks a list of tagged events (e + // tags) as being seen by the client, its distinctive feature is the + // "expiration" tag which indicates a time after which the marking expires + ReadReceipt = &K{15} + // GenericRepost is an event type that... + GenericRepost = &K{16} + // ChannelCreation is an event type that... + ChannelCreation = &K{40} + // ChannelMetadata is an event type that... + ChannelMetadata = &K{41} + // ChannelMessage is an event type that... + ChannelMessage = &K{42} + // ChannelHideMessage is an event type that... + ChannelHideMessage = &K{43} + // ChannelMuteUser is an event type that... + ChannelMuteUser = &K{44} + // Bid is an event type that... + Bid = &K{1021} + // BidConfirmation is an event type that... + BidConfirmation = &K{1022} + // OpenTimestamps is an event type that... + OpenTimestamps = &K{1040} + GiftWrap = &K{1059} + GiftWrapWithKind4 = &K{1060} + // FileMetadata is an event type that... + FileMetadata = &K{1063} + // LiveChatMessage is an event type that... + LiveChatMessage = &K{1311} + // BitcoinBlock is an event type created for the Nostrocket + BitcoinBlock = &K{1517} + // LiveStream from zap.stream + LiveStream = &K{1808} + // ProblemTracker is an event type used by Nostrocket + ProblemTracker = &K{1971} + // MemoryHole is an event type contains a report about an event (usually + // text note or other human readable) + MemoryHole = &K{1984} + Reporting = &K{1984} + // Label is an event type has L and l tags, namespace and type - NIP-32 + Label = &K{1985} + // CommunityPostApproval is an event type that... + CommunityPostApproval = &K{4550} + JobRequestStart = &K{5000} + JobRequestEnd = &K{5999} + JobResultStart = &K{6000} + JobResultEnd = &K{6999} + JobFeedback = &K{7000} + ZapGoal = &K{9041} + // ZapRequest is an event type that... + ZapRequest = &K{9734} + // Zap is an event type that... + Zap = &K{9735} + Highlights = &K{9882} + // ReplaceableStart is an event type that... + ReplaceableStart = &K{10000} + // MuteList is an event type that... + MuteList = &K{10000} + BlockList = &K{10000} + // PinList is an event type that... + PinList = &K{10001} + // RelayListMetadata is an event type that... + RelayListMetadata = &K{10002} + BookmarkList = &K{10003} + CommunitiesList = &K{10004} + PublicChatsList = &K{10005} + BlockedRelaysList = &K{10006} + SearchRelaysList = &K{10007} + InterestsList = &K{10015} + UserEmojiList = &K{10030} + DMRelaysList = &K{10050} + FileStorageServerList = &K{10096} + // JWTBinding is an event kind that creates a link between a JWT certificate and a pubkey + JWTBinding = &K{13004} + // NWCWalletServiceInfo is an event type that... + NWCWalletServiceInfo = &K{13194} + WalletServiceInfo = &K{13194} + // ReplaceableEnd is an event type that... + ReplaceableEnd = &K{19999} + // EphemeralStart is an event type that... + EphemeralStart = &K{20000} + LightningPubRPC = &K{21000} + // ClientAuthentication is an event type that... + ClientAuthentication = &K{22242} + // NWCWalletRequest is an event type that... + NWCWalletRequest = &K{23194} + WalletRequest = &K{23194} + // NWCWalletResponse is an event type that... + NWCWalletResponse = &K{23195} + WalletResponse = &K{23195} + NWCNotification = &K{23196} + WalletNotificationNip4 = &K{23196} + WalletNotification = &K{23197} + // NostrConnect is an event type that... + NostrConnect = &K{24133} + HTTPAuth = &K{27235} + // EphemeralEnd is an event type that... + EphemeralEnd = &K{29999} + // ParameterizedReplaceableStart is an event type that... + ParameterizedReplaceableStart = &K{30000} + // CategorizedPeopleList is an event type that... + CategorizedPeopleList = &K{30000} + FollowSets = &K{30000} + // CategorizedBookmarksList is an event type that... + CategorizedBookmarksList = &K{30001} + GenericLists = &K{30001} + RelaySets = &K{30002} + BookmarkSets = &K{30003} + CurationSets = &K{30004} + // ProfileBadges is an event type that... + ProfileBadges = &K{30008} + // BadgeDefinition is an event type that... + BadgeDefinition = &K{30009} + InterestSets = &K{30015} + // StallDefinition creates or updates a stall + StallDefinition = &K{30017} + // ProductDefinition creates or updates a product + ProductDefinition = &K{30018} + MarketplaceUIUX = &K{30019} + ProductSoldAsAuction = &K{30020} + // Article is an event type that... + Article = &K{30023} + LongFormContent = &K{30023} + DraftLongFormContent = &K{30024} + EmojiSets = &K{30030} + // ApplicationSpecificData is an event type stores data about application + // configuration, this, like DMs and giftwraps must be protected by user + // auth. + ApplicationSpecificData = &K{30078} + LiveEvent = &K{30311} + UserStatuses = &K{30315} + ClassifiedListing = &K{30402} + DraftClassifiedListing = &K{30403} + DateBasedCalendarEvent = &K{31922} + TimeBasedCalendarEvent = &K{31923} + Calendar = &K{31924} + CalendarEventRSVP = &K{31925} + HandlerRecommendation = &K{31989} + HandlerInformation = &K{31990} + // WaveLakeTrack which has no spec and uses malformed tags + WaveLakeTrack = &K{32123} + CommunityDefinition = &K{34550} + ACLEvent = &K{39998} + // ParameterizedReplaceableEnd is an event type that... + ParameterizedReplaceableEnd = &K{39999} +) + +var MapMx sync.Mutex +var Map = map[uint16]string{ + ProfileMetadata.K: "ProfileMetadata", + TextNote.K: "TextNote", + RecommendRelay.K: "RecommendRelay", + FollowList.K: "FollowList", + EncryptedDirectMessage.K: "EncryptedDirectMessage", + EventDeletion.K: "EventDeletion", + Repost.K: "Repost", + Reaction.K: "Reaction", + BadgeAward.K: "BadgeAward", + ReadReceipt.K: "ReadReceipt", + GenericRepost.K: "GenericRepost", + ChannelCreation.K: "ChannelCreation", + ChannelMetadata.K: "ChannelMetadata", + ChannelMessage.K: "ChannelMessage", + ChannelHideMessage.K: "ChannelHideMessage", + ChannelMuteUser.K: "ChannelMuteUser", + Bid.K: "Bid", + BidConfirmation.K: "BidConfirmation", + OpenTimestamps.K: "OpenTimestamps", + FileMetadata.K: "FileMetadata", + LiveChatMessage.K: "LiveChatMessage", + ProblemTracker.K: "ProblemTracker", + Reporting.K: "Reporting", + Label.K: "Label", + CommunityPostApproval.K: "CommunityPostApproval", + JobRequestStart.K: "JobRequestStart", + JobRequestEnd.K: "JobRequestEnd", + JobResultStart.K: "JobResultStart", + JobResultEnd.K: "JobResultEnd", + JobFeedback.K: "JobFeedback", + ZapGoal.K: "ZapGoal", + ZapRequest.K: "ZapRequest", + Zap.K: "Zap", + Highlights.K: "Highlights", + BlockList.K: "BlockList", + PinList.K: "PinList", + RelayListMetadata.K: "RelayListMetadata", + BookmarkList.K: "BookmarkList", + CommunitiesList.K: "CommunitiesList", + PublicChatsList.K: "PublicChatsList", + BlockedRelaysList.K: "BlockedRelaysList", + SearchRelaysList.K: "SearchRelaysList", + InterestsList.K: "InterestsList", + UserEmojiList.K: "UserEmojiList", + DMRelaysList.K: "DMRelaysList", + FileStorageServerList.K: "FileStorageServerList", + NWCWalletServiceInfo.K: "NWCWalletServiceInfo", + LightningPubRPC.K: "LightningPubRPC", + ClientAuthentication.K: "ClientAuthentication", + WalletRequest.K: "WalletRequest", + WalletResponse.K: "WalletResponse", + WalletNotificationNip4.K: "WalletNotificationNip4", + WalletNotification.K: "WalletNotification", + NostrConnect.K: "NostrConnect", + HTTPAuth.K: "HTTPAuth", + FollowSets.K: "FollowSets", + GenericLists.K: "GenericLists", + RelaySets.K: "RelaySets", + BookmarkSets.K: "BookmarkSets", + CurationSets.K: "CurationSets", + ProfileBadges.K: "ProfileBadges", + BadgeDefinition.K: "BadgeDefinition", + InterestSets.K: "InterestSets", + StallDefinition.K: "StallDefinition", + ProductDefinition.K: "ProductDefinition", + MarketplaceUIUX.K: "MarketplaceUIUX", + ProductSoldAsAuction.K: "ProductSoldAsAuction", + LongFormContent.K: "LongFormContent", + DraftLongFormContent.K: "DraftLongFormContent", + EmojiSets.K: "EmojiSets", + ApplicationSpecificData.K: "ApplicationSpecificData", + ParameterizedReplaceableEnd.K: "ParameterizedReplaceableEnd", + LiveEvent.K: "LiveEvent", + UserStatuses.K: "UserStatuses", + ClassifiedListing.K: "ClassifiedListing", + DraftClassifiedListing.K: "DraftClassifiedListing", + DateBasedCalendarEvent.K: "DateBasedCalendarEvent", + TimeBasedCalendarEvent.K: "TimeBasedCalendarEvent", + Calendar.K: "Calendar", + CalendarEventRSVP.K: "CalendarEventRSVP", + HandlerRecommendation.K: "HandlerRecommendation", + HandlerInformation.K: "HandlerInformation", + CommunityDefinition.K: "CommunityDefinition", +} diff --git a/pkg/encoders/kind/kind_test.go b/pkg/encoders/kind/kind_test.go new file mode 100644 index 0000000..b08b070 --- /dev/null +++ b/pkg/encoders/kind/kind_test.go @@ -0,0 +1,37 @@ +package kind + +import ( + "testing" + + "lol.mleku.dev/chk" + + "lukechampine.com/frand" +) + +func TestMarshalUnmarshal(t *testing.T) { + var err error + k := make([]*K, 1000000) + for i := range k { + k[i] = New(uint16(frand.Intn(65535))) + } + mk := make([][]byte, len(k)) + for i := range mk { + mk[i] = make([]byte, 0, 5) // 16 bits max 65535 = 5 characters + } + for i := range k { + mk[i] = k[i].Marshal(mk[i]) + } + k2 := make([]*K, len(k)) + for i := range k2 { + k2[i] = New(0) + } + for i := range k2 { + var r []byte + if r, err = k2[i].Unmarshal(mk[i]); chk.E(err) { + t.Fatal(err) + } + if len(r) != 0 { + t.Fatalf("remainder after unmarshal: '%s'", r) + } + } +} diff --git a/pkg/encoders/kind/kinds.go b/pkg/encoders/kind/kinds.go new file mode 100644 index 0000000..fa71603 --- /dev/null +++ b/pkg/encoders/kind/kinds.go @@ -0,0 +1,150 @@ +// Package kinds is a set of helpers for dealing with lists of kind numbers +// including comparisons and encoding. +package kind + +import ( + "lol.mleku.dev/chk" + "lol.mleku.dev/errorf" + "next.orly.dev/pkg/encoders/ints" +) + +// S is an array of kind.K, used in filter.K and filter.S for searches. +type S struct { + K []*K +} + +// NewS creates a new kinds.S, if no parameter is given it just creates an empty zero kinds.S. +func NewS(k ...*K) *S { return &S{k} } + +// NewWithCap creates a new empty kinds.S with a given slice capacity. +func NewWithCap(c int) *S { return &S{make([]*K, 0, c)} } + +// FromIntSlice converts a []int into a kinds.S. +func FromIntSlice(is []int) (k *S) { + k = &S{} + for i := range is { + k.K = append(k.K, New(uint16(is[i]))) + } + return +} + +// Len returns the number of elements in a kinds.S. +func (k *S) Len() (l int) { + if k == nil { + return + } + return len(k.K) +} + +// Less returns which of two elements of a kinds.S is lower. +func (k *S) Less(i, j int) bool { return k.K[i].K < k.K[j].K } + +// Swap switches the position of two kinds.S elements. +func (k *S) Swap(i, j int) { + k.K[i].K, k.K[j].K = k.K[j].K, k.K[i].K +} + +// ToUint16 returns a []uint16 version of the kinds.S. +func (k *S) ToUint16() (o []uint16) { + for i := range k.K { + o = append(o, k.K[i].ToU16()) + } + return +} + +// Clone makes a new kind.K with the same members. +func (k *S) Clone() (c *S) { + c = &S{K: make([]*K, len(k.K))} + for i := range k.K { + c.K[i] = k.K[i] + } + return +} + +// Contains returns true if the provided element is found in the kinds.S. +// +// Note that the request must use the typed kind.K or convert the number thus. +// Even if a custom number is found, this codebase does not have the logic to +// deal with the kind so such a search is pointless and for which reason static +// typing always wins. No mistakes possible with known quantities. +func (k *S) Contains(s *K) bool { + for i := range k.K { + if k.K[i].Equal(s) { + return true + } + } + return false +} + +// Equals checks that the provided kind.K matches. +func (k *S) Equals(t1 *S) bool { + if len(k.K) != len(t1.K) { + return false + } + for i := range k.K { + if k.K[i] != t1.K[i] { + return false + } + } + return true +} + +// Marshal renders the kinds.S into a JSON array of integers. +func (k *S) Marshal(dst []byte) (b []byte) { + b = dst + b = append(b, '[') + for i := range k.K { + b = k.K[i].Marshal(b) + if i != len(k.K)-1 { + b = append(b, ',') + } + } + b = append(b, ']') + return +} + +// Unmarshal decodes a provided JSON array of integers into a kinds.S. +func (k *S) Unmarshal(b []byte) (r []byte, err error) { + r = b + var openedBracket bool + for ; len(r) > 0; r = r[1:] { + if !openedBracket && r[0] == '[' { + openedBracket = true + continue + } else if openedBracket { + if r[0] == ']' { + // done + return + } else if r[0] == ',' { + continue + } + kk := ints.New(0) + if r, err = kk.Unmarshal(r); chk.E(err) { + return + } + k.K = append(k.K, New(kk.Uint16())) + if r[0] == ']' { + r = r[1:] + return + } + } + } + if !openedBracket { + return nil, errorf.E( + "kinds: failed to unmarshal\n%s\n%s\n%s", k, + b, r, + ) + } + return +} + +// IsPrivileged returns true if any of the elements of a kinds.S are privileged (ie, they should +// be privacy protected). +func (k *S) IsPrivileged() (priv bool) { + for i := range k.K { + if k.K[i].IsPrivileged() { + return true + } + } + return +} diff --git a/pkg/encoders/kind/kinds_test.go b/pkg/encoders/kind/kinds_test.go new file mode 100644 index 0000000..5cf11ee --- /dev/null +++ b/pkg/encoders/kind/kinds_test.go @@ -0,0 +1,36 @@ +package kind + +import ( + "testing" + + "lol.mleku.dev/chk" + "lukechampine.com/frand" +) + +func TestUnmarshalKindsArray(t *testing.T) { + k := &S{make([]*K, 100)} + for i := range k.K { + k.K[i] = New(uint16(frand.Intn(65535))) + } + var dst []byte + var err error + if dst = k.Marshal(dst); chk.E(err) { + t.Fatal(err) + } + k2 := &S{} + var rem []byte + if rem, err = k2.Unmarshal(dst); chk.E(err) { + return + } + if len(rem) > 0 { + t.Fatalf("failed to unmarshal, remnant afterwards '%s'", rem) + } + for i := range k.K { + if *k.K[i] != *k2.K[i] { + t.Fatalf( + "failed to unmarshal at element %d; got %x, expected %x", + i, k.K[i], k2.K[i], + ) + } + } +} diff --git a/pkg/encoders/timestamp/timestamp.go b/pkg/encoders/timestamp/timestamp.go new file mode 100644 index 0000000..81fb0d6 --- /dev/null +++ b/pkg/encoders/timestamp/timestamp.go @@ -0,0 +1,119 @@ +// Package timestamp is a set of helpers for working with timestamps including +// encoding and conversion to various integer forms, from time.Time and varints. +package timestamp + +import ( + "encoding/binary" + "time" + "unsafe" + + "lol.mleku.dev/chk" + "lol.mleku.dev/errorf" + "next.orly.dev/pkg/encoders/ints" +) + +// T is a convenience type for UNIX 64 bit timestamps of 1 second +// precision. +type T struct{ V int64 } + +// New creates a new timestamp.T, as zero or optionally from the first variadic +// parameter as int64. +func New(x ...int64) (t *T) { + t = &T{} + if len(x) > 0 { + t.V = x[0] + } + return +} + +// Now returns the current UNIX timestamp of the current second. +func Now() *T { + tt := T{time.Now().Unix()} + return &tt +} + +// U64 returns the current UNIX timestamp of the current second as uint64. +func (t *T) U64() uint64 { + if t == nil { + return 0 + } + return uint64(t.V) +} + +// I64 returns the current UNIX timestamp of the current second as int64. +func (t *T) I64() int64 { + if t == nil { + return 0 + } + return t.V +} + +// Time converts a timestamp.Time value into a canonical UNIX 64 bit 1 second +// precision timestamp. +func (t *T) Time() time.Time { return time.Unix(t.V, 0) } + +// Int returns the timestamp as an int. +func (t *T) Int() int { + if t == nil { + return 0 + } + return int(t.V) +} + +// Bytes returns a timestamp as an 8 byte thing. +func (t *T) Bytes() (b []byte) { + b = make([]byte, 8) + binary.BigEndian.PutUint64(b, uint64(t.V)) + return +} + +// FromTime returns a T from a time.Time +func FromTime(t time.Time) *T { return &T{t.Unix()} } + +// FromUnix converts from a standard int64 unix timestamp. +func FromUnix(t int64) *T { return &T{t} } + +func (t *T) FromInt(i int) { *t = T{int64(i)} } + +// FromBytes converts from a string of raw bytes. +func FromBytes(b []byte) *T { return &T{int64(binary.BigEndian.Uint64(b))} } + +// FromVarint decodes a varint and returns the remainder of the bytes and the encoded +// timestamp.T. +func FromVarint(b []byte) (t *T, rem []byte, err error) { + n, read := binary.Varint(b) + if read < 1 { + err = errorf.E("failed to decode varint timestamp %v", b) + return + } + t = &T{n} + rem = b[:read] + return +} + +// String renders a timestamp.T as a string. +func (t *T) String() (s string) { + b := make([]byte, 0, 20) + tt := ints.New(t.U64()) + b = tt.Marshal(b) + return unsafe.String(&b[0], len(b)) +} + +// Marshal a timestamp.T into bytes and append to a provided byte slice. +func (t *T) Marshal(dst []byte) (b []byte) { return ints.New(t.U64()).Marshal(dst) } + +// Unmarshal a byte slice with an encoded timestamp.T value and append it to a provided byte +// slice. +func (t *T) Unmarshal(b []byte) (r []byte, err error) { + n := ints.New(0) + if r, err = n.Unmarshal(b); chk.E(err) { + return + } + *t = T{n.Int64()} + return +} + +// MarshalJSON marshals a timestamp.T using the json MarshalJSON interface. +func (t *T) MarshalJSON() ([]byte, error) { + return ints.New(t.U64()).Marshal(nil), nil +} diff --git a/pkg/protocol/relayinfo/fetch.go b/pkg/protocol/relayinfo/fetch.go new file mode 100644 index 0000000..7af9d3b --- /dev/null +++ b/pkg/protocol/relayinfo/fetch.go @@ -0,0 +1,48 @@ +package relayinfo + +import ( + "context" + "encoding/json" + "io" + "net/http" + "time" + + "lol.mleku.dev/chk" + "lol.mleku.dev/errorf" + "next.orly.dev/pkg/utils/normalize" +) + +// Fetch fetches the NIP-11 Info. +func Fetch(c context.Context, u []byte) (info *T, err error) { + if _, ok := c.Deadline(); !ok { + // if no timeout is set, force it to 7 seconds + var cancel context.CancelFunc + c, cancel = context.WithTimeout(c, 7*time.Second) + defer cancel() + } + u = normalize.URL(u) + var req *http.Request + if req, err = http.NewRequestWithContext( + c, http.MethodGet, string(u), nil, + ); chk.E(err) { + return + } + // add the NIP-11 header + req.Header.Add("Accept", "application/nostr+json") + // send the response + var resp *http.Response + if resp, err = http.DefaultClient.Do(req); chk.E(err) { + err = errorf.E("request failed: %w", err) + return + } + defer chk.E(resp.Body.Close()) + var b []byte + if b, err = io.ReadAll(resp.Body); chk.E(err) { + return + } + info = &T{} + if err = json.Unmarshal(b, info); chk.E(err) { + return + } + return +} diff --git a/pkg/protocol/relayinfo/nip11_test.go b/pkg/protocol/relayinfo/nip11_test.go new file mode 100644 index 0000000..a1f2c75 --- /dev/null +++ b/pkg/protocol/relayinfo/nip11_test.go @@ -0,0 +1,15 @@ +package relayinfo + +import "testing" + +func TestAddSupportedNIP(t *testing.T) { + info := NewInfo(nil) + info.AddNIPs(12, 12, 13, 1, 12, 44, 2, 13, 2, 13, 0, 17, 19, 1, 18) + for i, v := range []int{0, 1, 2, 12, 13, 17, 18, 19, 44} { + if !info.HasNIP(v) { + t.Errorf("expected info.nips[%d] to equal %v, got %v", + i, v, info.Nips) + return + } + } +} diff --git a/pkg/protocol/relayinfo/relayinfo.go b/pkg/protocol/relayinfo/relayinfo.go new file mode 100644 index 0000000..f212591 --- /dev/null +++ b/pkg/protocol/relayinfo/relayinfo.go @@ -0,0 +1,39 @@ +package relayinfo + +// AddSupportedNIP appends a supported NIP number to a RelayInfo. +func (ri *T) AddSupportedNIP(n int) { + idx, exists := ri.Nips.HasNumber(n) + if exists { + return + } + ri.Nips = append(ri.Nips, -1) + copy(ri.Nips[idx+1:], ri.Nips[idx:]) + ri.Nips[idx] = n +} + +// Admission is the cost of opening an account with a relay. +type Admission struct { + Amount int `json:"amount"` + Unit string `json:"unit"` +} + +// Subscription is the cost of keeping an account open for a specified period of time. +type Subscription struct { + Amount int `json:"amount"` + Unit string `json:"unit"` + Period int `json:"period"` +} + +// Publication is the cost and restrictions on storing events on a relay. +type Publication []struct { + Kinds []int `json:"kinds"` + Amount int `json:"amount"` + Unit string `json:"unit"` +} + +// Fees defines the fee structure used for a paid relay. +type Fees struct { + Admission []Admission `json:"admission,omitempty"` + Subscription []Subscription `json:"subscription,omitempty"` + Publication []Publication `json:"publication,omitempty"` +} diff --git a/pkg/protocol/relayinfo/types.go b/pkg/protocol/relayinfo/types.go new file mode 100644 index 0000000..a50991b --- /dev/null +++ b/pkg/protocol/relayinfo/types.go @@ -0,0 +1,358 @@ +package relayinfo + +import ( + "encoding/json" + "errors" + "os" + "sort" + "sync" + + "lol.mleku.dev/chk" + "lol.mleku.dev/log" + "next.orly.dev/pkg/encoders/kind" + "next.orly.dev/pkg/encoders/timestamp" + "next.orly.dev/pkg/utils/number" +) + +// NIP is a number and description of a nostr "improvement" possibility. +type NIP struct { + Description string + Number int +} + +// N returns the number of a nostr "improvement" possibility. +func (n NIP) N() int { return n.Number } + +// GetList converts a NIP into a number.List of simple numbers, sorted in +// ascending order. +func GetList(items ...NIP) (n number.List) { + for _, item := range items { + n = append(n, item.N()) + } + sort.Sort(n) + return +} + +// this is the list of all nips and their titles for use in the supported_nips +// field +var ( + BasicProtocol = NIP{"Basic protocol flow description", 1} + NIP1 = BasicProtocol + FollowList = NIP{"Follow List", 2} + NIP2 = FollowList + OpenTimestampsAttestations = NIP{ + "OpenTimestamps Attestations for Events", 3, + } + NIP3 = OpenTimestampsAttestations + EncryptedDirectMessage = NIP{ + "Direct Message deprecated in favor of NIP-44", 4, + } + NIP4 = EncryptedDirectMessage + MappingNostrKeysToDNS = NIP{ + "Mapping Nostr keys to DNS-based identifiers", 5, + } + NIP5 = MappingNostrKeysToDNS + HandlingMentions = NIP{ + "Handling Mentions deprecated in favor of NIP-27", 8, + } + NIP8 = HandlingMentions + EventDeletion = NIP{"Event Deletion", 9} + NIP9 = EventDeletion + RelayInformationDocument = NIP{"Client Information Document", 11} + NIP11 = RelayInformationDocument + GenericTagQueries = NIP{"Generic Tag Queries", 12} + NIP12 = GenericTagQueries + SubjectTag = NIP{"Subject tag in text events", 14} + NIP14 = SubjectTag + NostrMarketplace = NIP{ + "Nostr Marketplace (for resilient marketplaces)", 15, + } + NIP15 = NostrMarketplace + EventTreatment = NIP{"EVent Treatment", 16} + NIP16 = EventTreatment + Reposts = NIP{"Reposts", 18} + NIP18 = Reposts + Bech32EncodedEntities = NIP{"bech32-encoded entities", 19} + NIP19 = Bech32EncodedEntities + CommandResults = NIP{"Command Results", 20} + NIP20 = CommandResults + NostrURIScheme = NIP{"nostr: URI scheme", 21} + NIP21 = NostrURIScheme + Comment = NIP{"Comment", 22} + NIP22 = Comment + LongFormContent = NIP{"Long-form Content", 23} + NIP23 = LongFormContent + ExtraMetadata = NIP{"Extra metadata fields and tags", 24} + NIP24 = ExtraMetadata + Reactions = NIP{"Reactions", 25} + NIP25 = Reactions + DelegatedEventSigning = NIP{"Delegated Event Signing", 26} + NIP26 = DelegatedEventSigning + TextNoteReferences = NIP{"Text Note References", 27} + NIP27 = TextNoteReferences + PublicChat = NIP{"Public Chat", 28} + NIP28 = PublicChat + CustomEmoji = NIP{"Custom Emoji", 30} + NIP30 = CustomEmoji + Labeling = NIP{"Labeling", 32} + NIP32 = Labeling + ParameterizedReplaceableEvents = NIP{"Parameterized Replaceable Events", 33} + NIP33 = ParameterizedReplaceableEvents + SensitiveContent = NIP{"Sensitive Content", 36} + NIP36 = SensitiveContent + UserStatuses = NIP{"User Statuses", 38} + NIP38 = UserStatuses + ExternalIdentitiesInProfiles = NIP{"External Identities in Profiles", 39} + NIP39 = ExternalIdentitiesInProfiles + ExpirationTimestamp = NIP{"Expiration Timestamp", 40} + NIP40 = ExpirationTimestamp + Authentication = NIP{ + "Authentication of clients to relays", 42, + } + NIP42 = Authentication + VersionedEncryption = NIP{"Versioned Encryption", 44} + NIP44 = VersionedEncryption + CountingResults = NIP{"Counting results", 45} + NIP45 = CountingResults + NostrConnect = NIP{"Nostr Connect", 46} + NIP46 = NostrConnect + WalletConnect = NIP{"Wallet Connect", 47} + NIP47 = WalletConnect + ProxyTags = NIP{"Proxy Tags", 48} + NIP48 = ProxyTags + SearchCapability = NIP{"Search Capability", 50} + NIP50 = SearchCapability + Lists = NIP{"Lists", 51} + NIP51 = Lists + CalendarEvents = NIP{"Calendar Events", 52} + NIP52 = CalendarEvents + LiveActivities = NIP{"Live Activities", 53} + NIP53 = LiveActivities + Reporting = NIP{"Reporting", 56} + NIP56 = Reporting + LightningZaps = NIP{"Lightning Zaps", 57} + NIP57 = LightningZaps + Badges = NIP{"Badges", 58} + NIP58 = Badges + RelayListMetadata = NIP{"Client List Metadata", 65} + NIP65 = RelayListMetadata + ProtectedEvents = NIP{"Protected Events", 70} + NIP70 = ProtectedEvents + ModeratedCommunities = NIP{"Moderated Communities", 72} + NIP72 = ModeratedCommunities + ZapGoals = NIP{"Zap Goals", 75} + NIP75 = ZapGoals + ApplicationSpecificData = NIP{"Application-specific data", 78} + NIP78 = ApplicationSpecificData + Highlights = NIP{"Highlights", 84} + NIP84 = Highlights + RecommendedApplicationHandlers = NIP{"Recommended Application Handlers", 89} + NIP89 = RecommendedApplicationHandlers + DataVendingMachines = NIP{"Data Vending Machines", 90} + NIP90 = DataVendingMachines + FileMetadata = NIP{"File Metadata", 94} + NIP94 = FileMetadata + HTTPFileStorageIntegration = NIP{"HTTP File Storage Integration", 96} + NIP96 = HTTPFileStorageIntegration + HTTPAuth = NIP{"HTTP IsAuthed", 98} + NIP98 = HTTPAuth + ClassifiedListings = NIP{"Classified Listings", 99} + NIP99 = ClassifiedListings +) + +var NIPMap = map[int]NIP{ + 1: NIP1, 2: NIP2, 3: NIP3, 4: NIP4, 5: NIP5, 8: NIP8, 9: NIP9, + 11: NIP11, 12: NIP12, 14: NIP14, 15: NIP15, 16: NIP16, 18: NIP18, 19: NIP19, + 20: NIP20, + 21: NIP21, 22: NIP22, 23: NIP23, 24: NIP24, 25: NIP25, 26: NIP26, 27: NIP27, + 28: NIP28, + 30: NIP30, 32: NIP32, 33: NIP33, 36: NIP36, 38: NIP38, 39: NIP39, 40: NIP40, + 42: NIP42, + 44: NIP44, 45: NIP45, 46: NIP46, 47: NIP47, 48: NIP48, 50: NIP50, 51: NIP51, + 52: NIP52, + 53: NIP53, 56: NIP56, 57: NIP57, 58: NIP58, 65: NIP65, 72: NIP72, 75: NIP75, + 78: NIP78, + 84: NIP84, 89: NIP89, 90: NIP90, 94: NIP94, 96: NIP96, 98: NIP98, 99: NIP99, +} + +// Limits are rules about what is acceptable for events and filters on a relay. +type Limits struct { + // MaxMessageLength is the maximum number of bytes for incoming JSON that + // the relay will attempt to decode and act upon. When you send large + // subscriptions, you will be limited by this value. It also effectively + // limits the maximum size of any event. Value is calculated from [ to ] and + // is after UTF-8 serialization (so some Unicode characters will cost 2-3 + // bytes). It is equal to the maximum size of the WebSocket message frame. + MaxMessageLength int `json:"max_message_length,omitempty"` + // MaxSubscriptions is the total number of subscriptions that may be active + // on a single websocket connection to this relay. It's possible that + // authenticated clients with a (paid) relationship to the relay may have + // higher limits. + MaxSubscriptions int `json:"max_subscriptions,omitempty"` + // MaxFilter is the maximum number of filter values in each subscription. + // Must be one or higher. + MaxFilters int `json:"max_filters,omitempty"` + // MaxLimit is the relay server will clamp each filter's limit value to this + // number. This means the client won't be able to get more than this number + // of events from a single subscription filter. This clamping is typically + // done silently by the relay, but with this number, you can know that there + // are additional results if you narrowed your filter's time range or other + // parameters. + MaxLimit int `json:"max_limit,omitempty"` + // MaxSubidLength is the maximum length of subscription id as a string. + MaxSubidLength int `json:"max_subid_length,omitempty"` + // MaxEventTags in any event, this is the maximum number of elements in the + // tags list. + MaxEventTags int `json:"max_event_tags,omitempty"` + // MaxContentLength maximum number of characters in the content field of any + // event. This is a count of Unicode characters. After serializing into JSON + // it may be larger (in bytes), and is still subject to the + // max_message_length, if defined. + MaxContentLength int `json:"max_content_length,omitempty"` + // MinPowDifficulty new events will require at least this difficulty of PoW, + // based on NIP-13, or they will be rejected by this server. + MinPowDifficulty int `json:"min_pow_difficulty,omitempty"` + // AuthRequired means the relay requires NIP-42 authentication to happen + // before a new connection may perform any other action. Even if set to + // False, authentication may be required for specific actions. + AuthRequired bool `json:"auth_required"` + // PaymentRequired this relay requires payment before a new connection may + // perform any action. + PaymentRequired bool `json:"payment_required"` + // RestrictedWrites means this relay requires some kind of condition to be + // fulfilled to accept events (not necessarily, but including + // payment_required and min_pow_difficulty). This should only be set to true + // when users are expected to know the relay policy before trying to write + // to it -- like belonging to a special pubkey-based whitelist or writing + // only events of a specific niche kind or content. Normal anti-spam + // heuristics, for example, do not qualify.q + RestrictedWrites bool `json:"restricted_writes"` + Oldest *timestamp.T `json:"created_at_lower_limit,omitempty"` + Newest *timestamp.T `json:"created_at_upper_limit,omitempty"` +} + +// Payment is an amount and currency unit name. +type Payment struct { + Amount int `json:"amount"` + Unit string `json:"unit"` +} + +// Sub is a subscription, with the Payment and the period it yields. +type Sub struct { + Payment + Period int `json:"period"` +} + +// Pub is a limitation for what you can store on the relay as a kinds.S and the +// cost (for???). +type Pub struct { + Kinds kind.S `json:"kinds"` + Payment +} + +// T is the relay information document. +type T struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + PubKey string `json:"pubkey,omitempty"` + Contact string `json:"contact,omitempty"` + Nips number.List `json:"supported_nips"` + Software string `json:"software"` + Version string `json:"version"` + Limitation Limits `json:"limitation,omitempty"` + Retention any `json:"retention,omitempty"` + RelayCountries []string `json:"relay_countries,omitempty"` + LanguageTags []string `json:"language_tags,omitempty"` + Tags []string `json:"tags,omitempty"` + PostingPolicy string `json:"posting_policy,omitempty"` + PaymentsURL string `json:"payments_url,omitempty"` + Fees *Fees `json:"fees,omitempty"` + Icon string `json:"icon"` + sync.Mutex +} + +// NewInfo populates the nips map, and if an Info structure is provided, it is +// used and its nips map is populated if it isn't already. +func NewInfo(inf *T) (info *T) { + if inf != nil { + info = inf + } else { + info = &T{ + Limitation: Limits{ + MaxLimit: 500, + }, + } + } + return +} + +// Clone replicates a relayinfo.T. +// todo: this could be done better, but i don't think it's in use. +func (ri *T) Clone() (r2 *T, err error) { + r2 = new(T) + var b []byte + if b, err = json.Marshal(ri); chk.E(err) { + return + } + if err = json.Unmarshal(b, r2); chk.E(err) { + return + } + return +} + +// AddNIPs adds one or more numbers to the list of NIPs. +func (ri *T) AddNIPs(n ...int) { + ri.Lock() + for _, num := range n { + ri.Nips = append(ri.Nips, num) + } + ri.Unlock() +} + +// HasNIP returns true if the given number is found in the list. +func (ri *T) HasNIP(n int) (ok bool) { + ri.Lock() + for i := range ri.Nips { + if ri.Nips[i] == n { + ok = true + break + } + } + ri.Unlock() + return +} + +// Save the relayinfo.T to a given file as JSON. +func (ri *T) Save(filename string) (err error) { + if ri == nil { + err = errors.New("cannot save nil relay info document") + log.E.Ln(err) + return + } + var b []byte + if b, err = json.MarshalIndent(ri, "", " "); chk.E(err) { + return + } + if err = os.WriteFile(filename, b, 0600); chk.E(err) { + return + } + return +} + +// Load a given file and decode the JSON relayinfo.T encoded in it. +func (ri *T) Load(filename string) (err error) { + if ri == nil { + err = errors.New("cannot load into nil config") + log.E.Ln(err) + return + } + var b []byte + if b, err = os.ReadFile(filename); chk.E(err) { + return + } + // log.S.ToSliceOfBytes("realy information document\n%s", string(b)) + if err = json.Unmarshal(b, ri); chk.E(err) { + return + } + return +} diff --git a/pkg/utils/normalize/normalize.go b/pkg/utils/normalize/normalize.go new file mode 100644 index 0000000..9fdd912 --- /dev/null +++ b/pkg/utils/normalize/normalize.go @@ -0,0 +1,143 @@ +// Package normalize is a set of tools for cleaning up URL s and formatting +// nostr OK and CLOSED messages. +package normalize + +import ( + "bytes" + "fmt" + "net/url" + + "lol.mleku.dev/chk" + "lol.mleku.dev/log" + "next.orly.dev/pkg/encoders/ints" +) + +var ( + hp = bytes.HasPrefix + WS = []byte("ws://") + WSS = []byte("wss://") + HTTP = []byte("http://") + HTTPS = []byte("https://") +) + +// URL normalizes the URL +// +// - Adds wss:// to addresses without a port, or with 443 that have no protocol +// prefix +// +// - Adds ws:// to addresses with any other port +// +// - Converts http/s to ws/s +func URL[V string | []byte](v V) (b []byte) { + u := []byte(v) + if len(u) == 0 { + return nil + } + u = bytes.TrimSpace(u) + u = bytes.ToLower(u) + // if the address has a port number, we can probably assume it is insecure + // websocket as most public or production relays have a domain name and a + // well-known port 80 or 443 and thus no port number. + // + // if a protocol prefix is present, we assume it is already complete. + // Converting http/s to websocket-equivalent will be done later anyway. + if bytes.Contains(u, []byte(":")) && + !(hp(u, HTTP) || hp(u, HTTPS) || hp(u, WS) || hp(u, WSS)) { + + split := bytes.Split(u, []byte(":")) + if len(split) != 2 { + log.D.F("Error: more than one ':' in URL: '%s'", u) + // this is a malformed URL if it has more than one ":", return empty + // since this function does not return an error explicitly. + return + } + p := ints.New(0) + _, err := p.Unmarshal(split[1]) + if chk.E(err) { + log.D.F("Error normalizing URL '%s': %s", u, err) + // again, without an error, we must return nil + return + } + if p.Uint64() > 65535 { + log.D.F( + "Port on address %d: greater than maximum 65535", + p.Uint64(), + ) + return + } + // if the port is explicitly set to 443 we assume it is wss:// and drop + // the port. + if p.Uint16() == 443 { + u = append(WSS, split[0]...) + } else { + u = append(WSS, u...) + } + } + + // if the prefix isn't specified as http/s or websocket, assume secure + // websocket and add wss prefix (this is the most common). + if !(hp(u, HTTP) || hp(u, HTTPS) || hp(u, WS) || hp(u, WSS)) { + u = append(WSS, u...) + } + var err error + var p *url.URL + if p, err = url.Parse(string(u)); chk.E(err) { + return + } + // convert http/s to ws/s + switch p.Scheme { + case "https": + p.Scheme = "wss" + case "http": + p.Scheme = "ws" + } + // remove trailing path slash + p.Path = string(bytes.TrimRight([]byte(p.Path), "/")) + return []byte(p.String()) +} + +// Msg constructs a properly formatted message with a machine-readable prefix +// for OK and CLOSED envelopes. +func Msg(prefix Reason, format string, params ...any) []byte { + if len(prefix) < 1 { + prefix = Error + } + return []byte(fmt.Sprintf(prefix.S()+": "+format, params...)) +} + +// Reason is the machine-readable prefix before the colon in an OK or CLOSED +// envelope message. Below are the most common kinds that are mentioned in +// NIP-01. +type Reason []byte + +var ( + AuthRequired = Reason("auth-required") + PoW = Reason("pow") + Duplicate = Reason("duplicate") + Blocked = Reason("blocked") + RateLimited = Reason("rate-limited") + Invalid = Reason("invalid") + Error = Reason("error") + Unsupported = Reason("unsupported") + Restricted = Reason("restricted") +) + +// S returns the Reason as a string +func (r Reason) S() string { return string(r) } + +// B returns the Reason as a byte slice. +func (r Reason) B() []byte { return r } + +// IsPrefix returns whether a text contains the same Reason prefix. +func (r Reason) IsPrefix(reason []byte) bool { + return bytes.HasPrefix( + reason, r.B(), + ) +} + +// F allows creation of a full Reason text with a printf style format. +func (r Reason) F(format string, params ...any) []byte { + return Msg( + r, format, params..., + ) +} diff --git a/pkg/utils/normalize/normalize_test.go b/pkg/utils/normalize/normalize_test.go new file mode 100644 index 0000000..8a22bab --- /dev/null +++ b/pkg/utils/normalize/normalize_test.go @@ -0,0 +1,35 @@ +package normalize + +import ( + "fmt" + "testing" +) + +func TestURL(t *testing.T) { + fmt.Println(URL([]byte(""))) + fmt.Println(URL([]byte("wss://x.com/y"))) + fmt.Println(URL([]byte("wss://x.com/y/"))) + fmt.Println(URL([]byte("http://x.com/y"))) + fmt.Println(URL(URL([]byte("http://x.com/y")))) + fmt.Println(URL([]byte("wss://x.com"))) + fmt.Println(URL([]byte("wss://x.com/"))) + fmt.Println(URL(URL(URL([]byte("wss://x.com/"))))) + fmt.Println(URL([]byte("x.com"))) + fmt.Println(URL([]byte("x.com/"))) + fmt.Println(URL([]byte("x.com////"))) + fmt.Println(URL([]byte("x.com/?x=23"))) + + // Output: + // + // wss://x.com/y + // wss://x.com/y + // ws://x.com/y + // ws://x.com/y + // wss://x.com + // wss://x.com + // wss://x.com + // wss://x.com + // wss://x.com + // wss://x.com + // wss://x.com?x=23 +} diff --git a/pkg/utils/number/list.go b/pkg/utils/number/list.go new file mode 100644 index 0000000..ff15033 --- /dev/null +++ b/pkg/utils/number/list.go @@ -0,0 +1,36 @@ +// Package number implements a simple number list, used with relayinfo package +// for NIP support lists. +package number + +import "fmt" + +// List is a simple list of numbers with a sort implementation and number match. +type List []int + +func (l List) Len() int { return len(l) } +func (l List) Less(i, j int) bool { return l[i] < l[j] } +func (l List) Swap(i, j int) { l[i], l[j] = l[j], l[i] } + +// HasNumber returns true if the list contains a given number +func (l List) HasNumber(n int) (idx int, has bool) { + for idx = range l { + if l[idx] == n { + has = true + return + } + } + return +} + +// String outputs a number.List as a minified JSON array. +func (l List) String() (s string) { + s += "[" + for i := range l { + if i > 0 { + s += "," + } + s += fmt.Sprint(l[i]) + } + s += "]" + return +} diff --git a/pkg/version/version.go b/pkg/version/version.go index 22729c3..94e4fc2 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -9,4 +9,4 @@ var V string var Description = "relay powered by the orly framework https://next.orly.dev" -var URL = "https://nextorly.dev" +var URL = "https://next.orly.dev"