mirror of
https://github.com/nbd-wtf/go-nostr.git
synced 2025-03-17 21:32:56 +01:00
basic event and relaypool.
This commit is contained in:
commit
32c21eb652
150
event/event.go
Normal file
150
event/event.go
Normal file
@ -0,0 +1,150 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/fiatjaf/bip340"
|
||||
)
|
||||
|
||||
const (
|
||||
KindSetMetadata uint8 = 0
|
||||
KindTextNote uint8 = 1
|
||||
KindRecommendServer uint8 = 2
|
||||
KindContactList uint8 = 3
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"` // it's the hash of the serialized event
|
||||
|
||||
PubKey string `json:"pubkey"`
|
||||
CreatedAt uint32 `json:"created_at"`
|
||||
|
||||
Kind uint8 `json:"kind"`
|
||||
|
||||
Tags Tags `json:"tags"`
|
||||
Content string `json:"content"`
|
||||
Sig string `json:"sig"`
|
||||
}
|
||||
|
||||
type Tags []Tag
|
||||
|
||||
func (t Tags) Scan(src interface{}) error {
|
||||
var jtags []byte = make([]byte, 0)
|
||||
|
||||
switch v := src.(type) {
|
||||
case []byte:
|
||||
jtags = v
|
||||
case string:
|
||||
jtags = []byte(v)
|
||||
default:
|
||||
return errors.New("couldn't scan tags, it's not a json string")
|
||||
}
|
||||
|
||||
json.Unmarshal(jtags, t)
|
||||
return nil
|
||||
}
|
||||
|
||||
type Tag []interface{}
|
||||
|
||||
// Serialize outputs a byte array that can be hashed/signed to identify/authenticate
|
||||
func (evt *Event) Serialize() []byte {
|
||||
// the serialization process is just putting everything into a JSON array
|
||||
// so the order is kept
|
||||
arr := make([]interface{}, 6)
|
||||
|
||||
// version: 0
|
||||
arr[0] = 0
|
||||
|
||||
// pubkey
|
||||
arr[1] = evt.PubKey
|
||||
|
||||
// created_at
|
||||
arr[2] = int64(evt.CreatedAt)
|
||||
|
||||
// kind
|
||||
arr[3] = int64(evt.Kind)
|
||||
|
||||
// tags
|
||||
if evt.Tags != nil {
|
||||
arr[4] = evt.Tags
|
||||
} else {
|
||||
arr[4] = make([]bool, 0)
|
||||
}
|
||||
|
||||
// content
|
||||
arr[5] = evt.Content
|
||||
|
||||
serialized, _ := json.Marshal(arr)
|
||||
return serialized
|
||||
}
|
||||
|
||||
// CheckSignature checks if the signature is valid for the id
|
||||
// (which is a hash of the serialized event content).
|
||||
// returns an error if the signature itself is invalid.
|
||||
func (evt Event) CheckSignature() (bool, error) {
|
||||
// read and check pubkey
|
||||
pubkeyb, err := hex.DecodeString(evt.PubKey)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(pubkeyb) != 32 {
|
||||
return false, fmt.Errorf("pubkey must be 32 bytes, not %d", len(pubkeyb))
|
||||
}
|
||||
|
||||
// check tags
|
||||
for _, tag := range evt.Tags {
|
||||
for _, item := range tag {
|
||||
switch item.(type) {
|
||||
case string, int64, float64, int, bool:
|
||||
// fine
|
||||
default:
|
||||
// not fine
|
||||
return false, fmt.Errorf("tag contains an invalid value %v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sig, err := hex.DecodeString(evt.Sig)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("signature is invalid hex: %w", err)
|
||||
}
|
||||
if len(sig) != 64 {
|
||||
return false, fmt.Errorf("signature must be 64 bytes, not %d", len(sig))
|
||||
}
|
||||
|
||||
var p [32]byte
|
||||
copy(p[:], pubkeyb)
|
||||
|
||||
var s [64]byte
|
||||
copy(s[:], sig)
|
||||
|
||||
h := sha256.Sum256(evt.Serialize())
|
||||
|
||||
return bip340.Verify(p, h, s)
|
||||
}
|
||||
|
||||
// Sign signs an event with a given privateKey
|
||||
func (evt Event) Sign(privateKey string) error {
|
||||
h := sha256.Sum256(evt.Serialize())
|
||||
s, _ := new(big.Int).SetString(privateKey, 16)
|
||||
|
||||
if s == nil {
|
||||
return errors.New("invalid private key " + privateKey)
|
||||
}
|
||||
|
||||
aux := make([]byte, 32)
|
||||
rand.Read(aux)
|
||||
sig, err := bip340.Sign(s, h, aux)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
evt.Sig = hex.EncodeToString(sig[:])
|
||||
return nil
|
||||
}
|
8
go.mod
Normal file
8
go.mod
Normal file
@ -0,0 +1,8 @@
|
||||
module github.com/fiatjaf/go-nostr
|
||||
|
||||
go 1.15
|
||||
|
||||
require (
|
||||
github.com/fiatjaf/bip340 v1.0.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
)
|
48
go.sum
Normal file
48
go.sum
Normal file
@ -0,0 +1,48 @@
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
github.com/btcsuite/btcd v0.0.0-20190109040709-5bda5314ca95 h1:bmv+LE3sbjb/M06u2DBi92imeKj7KnCUBOvyZYqI8d8=
|
||||
github.com/btcsuite/btcd v0.0.0-20190109040709-5bda5314ca95/go.mod h1:d3C0AkH6BRcvO8T0UEPu53cnw4IbV63x1bEjildYhO0=
|
||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
||||
github.com/btcsuite/btcutil v0.0.0-20180706230648-ab6388e0c60a/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||
github.com/btcsuite/btcutil v0.0.0-20190112041146-bf1e1be93589/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
||||
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
||||
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
|
||||
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fiatjaf/bip340 v1.0.0 h1:mpwbm+0KC9BXB/7/pnac4e4N1TiuppyEVXxtVAXj75k=
|
||||
github.com/fiatjaf/bip340 v1.0.0/go.mod h1:MxAz+5FQUTW4OT2gnCBC6Our486wmqf72ykZIrh7+is=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/kkdai/bstream v0.0.0-20181106074824-b3251f7901ec/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190110200230-915654e7eabc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
243
relaypool/relaypool.go
Normal file
243
relaypool/relaypool.go
Normal file
@ -0,0 +1,243 @@
|
||||
package relaypool
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/fiatjaf/go-nostr/event"
|
||||
nostrutils "github.com/fiatjaf/go-nostr/utils"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type RelayPool struct {
|
||||
SecretKey *string
|
||||
|
||||
Relays map[string]Policy
|
||||
websockets map[string]*websocket.Conn
|
||||
|
||||
Events chan *EventMessage
|
||||
Notices chan *NoticeMessage
|
||||
|
||||
SubscribedKeys []string
|
||||
SubscribedEvents []string
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
SimplePolicy
|
||||
ReadSpecific map[string]SimplePolicy
|
||||
}
|
||||
|
||||
type SimplePolicy struct {
|
||||
Read bool
|
||||
Write bool
|
||||
}
|
||||
|
||||
type EventMessage struct {
|
||||
Event event.Event
|
||||
Context byte
|
||||
Relay string
|
||||
}
|
||||
|
||||
func (em *EventMessage) UnmarshalJSON(b []byte) error {
|
||||
var temp []json.RawMessage
|
||||
if err := json.Unmarshal(b, temp); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(temp) < 2 {
|
||||
return errors.New("message is not an array of 2 or more")
|
||||
}
|
||||
if err := json.Unmarshal(temp[0], em.Event); err != nil {
|
||||
return err
|
||||
}
|
||||
var context string
|
||||
if err := json.Unmarshal(temp[1], &context); err != nil {
|
||||
return err
|
||||
}
|
||||
em.Context = context[0]
|
||||
return nil
|
||||
}
|
||||
|
||||
type NoticeMessage struct {
|
||||
Message string
|
||||
Relay string
|
||||
}
|
||||
|
||||
func (nm *NoticeMessage) UnmarshalJSON(b []byte) error {
|
||||
var temp []json.RawMessage
|
||||
if err := json.Unmarshal(b, temp); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(temp) < 2 {
|
||||
return errors.New("message is not an array of 2 or more")
|
||||
}
|
||||
var tag string
|
||||
if err := json.Unmarshal(temp[0], &tag); err != nil {
|
||||
return err
|
||||
}
|
||||
if tag != "notice" {
|
||||
return errors.New("tag is not 'notice'")
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(temp[1], &nm.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// New creates a new RelayPool with no relays in it
|
||||
func New() *RelayPool {
|
||||
return &RelayPool{
|
||||
Relays: make(map[string]Policy),
|
||||
websockets: make(map[string]*websocket.Conn),
|
||||
|
||||
Events: make(chan *EventMessage),
|
||||
Notices: make(chan *NoticeMessage),
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a new relay to the pool, if policy is nil, it will be a simple
|
||||
// read+write policy.
|
||||
func (r *RelayPool) Add(url string, policy *Policy) {
|
||||
if policy == nil {
|
||||
policy = &Policy{SimplePolicy: SimplePolicy{Read: true, Write: true}}
|
||||
}
|
||||
|
||||
nm := nostrutils.NormalizeURL(url)
|
||||
if nm == "" {
|
||||
return
|
||||
}
|
||||
|
||||
r.Relays[nm] = *policy
|
||||
conn, _, err := websocket.DefaultDialer.Dial(nostrutils.WebsocketURL(url), nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Remove(nm)
|
||||
done := make(chan struct{})
|
||||
|
||||
r.websockets[nm] = conn
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
typ, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Println("read error: ", err)
|
||||
return
|
||||
}
|
||||
if typ == websocket.PingMessage {
|
||||
conn.WriteMessage(websocket.PongMessage, nil)
|
||||
}
|
||||
|
||||
if typ != websocket.TextMessage || len(message) == 0 || message[0] != '[' {
|
||||
continue
|
||||
}
|
||||
|
||||
var noticeMessage *NoticeMessage
|
||||
var eventMessage *EventMessage
|
||||
err = json.Unmarshal(message, eventMessage)
|
||||
if err == nil {
|
||||
eventMessage.Relay = nm
|
||||
r.Events <- eventMessage
|
||||
} else {
|
||||
err = json.Unmarshal(message, noticeMessage)
|
||||
if err == nil {
|
||||
noticeMessage.Relay = nm
|
||||
r.Notices <- noticeMessage
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Remove removes a relay from the pool.
|
||||
func (r *RelayPool) Remove(url string) {
|
||||
nm := nostrutils.NormalizeURL(url)
|
||||
if conn, ok := r.websockets[nm]; ok {
|
||||
conn.Close()
|
||||
}
|
||||
delete(r.Relays, nm)
|
||||
delete(r.websockets, nm)
|
||||
}
|
||||
|
||||
func (r *RelayPool) SubKey(key string) {
|
||||
for _, conn := range r.websockets {
|
||||
conn.WriteMessage(websocket.TextMessage, []byte("sub-key:"+key))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RelayPool) UnsubKey(key string) {
|
||||
for _, conn := range r.websockets {
|
||||
conn.WriteMessage(websocket.TextMessage, []byte("unsub-key:"+key))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RelayPool) SubEvent(id string) {
|
||||
for _, conn := range r.websockets {
|
||||
conn.WriteMessage(websocket.TextMessage, []byte("sub-event:"+id))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RelayPool) ReqFeed(opts map[string]interface{}) {
|
||||
var sopts string
|
||||
if opts == nil {
|
||||
sopts = "{}"
|
||||
} else {
|
||||
jopts, _ := json.Marshal(opts)
|
||||
sopts = string(jopts)
|
||||
}
|
||||
|
||||
for _, conn := range r.websockets {
|
||||
conn.WriteMessage(websocket.TextMessage, []byte("req-feed:"+sopts))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RelayPool) ReqEvent(id string, opts map[string]interface{}) {
|
||||
if opts == nil {
|
||||
opts = make(map[string]interface{})
|
||||
}
|
||||
opts["id"] = id
|
||||
|
||||
jopts, _ := json.Marshal(opts)
|
||||
sopts := string(jopts)
|
||||
|
||||
for _, conn := range r.websockets {
|
||||
conn.WriteMessage(websocket.TextMessage, []byte("req-event:"+sopts))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RelayPool) ReqKey(key string, opts map[string]interface{}) {
|
||||
if opts == nil {
|
||||
opts = make(map[string]interface{})
|
||||
}
|
||||
opts["key"] = key
|
||||
|
||||
jopts, _ := json.Marshal(opts)
|
||||
sopts := string(jopts)
|
||||
|
||||
for _, conn := range r.websockets {
|
||||
conn.WriteMessage(websocket.TextMessage, []byte("req-key:"+sopts))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RelayPool) PublishEvent(evt *event.Event) error {
|
||||
if r.SecretKey == nil && evt.Sig == "" {
|
||||
return errors.New("PublishEvent needs either a signed event to publish or to have been configured with a .SecretKey.")
|
||||
}
|
||||
|
||||
if evt.Sig == "" {
|
||||
evt.Sign(*r.SecretKey)
|
||||
}
|
||||
|
||||
jevt, _ := json.Marshal(evt)
|
||||
|
||||
for _, conn := range r.websockets {
|
||||
conn.WriteMessage(websocket.TextMessage, jevt)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
32
utils/utils.go
Normal file
32
utils/utils.go
Normal file
@ -0,0 +1,32 @@
|
||||
package nostrutils
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NormalizeURL(u string) string {
|
||||
if !strings.HasPrefix(u, "http") && !strings.HasPrefix(u, "ws") {
|
||||
u = "ws://" + u
|
||||
}
|
||||
p, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasSuffix(p.RawPath, "/") {
|
||||
p.RawPath = p.RawPath[0 : len(p.RawPath)-1]
|
||||
}
|
||||
|
||||
if strings.HasSuffix(p.RawPath, "/ws") {
|
||||
p.RawPath = p.RawPath[0 : len(p.RawPath)-3]
|
||||
}
|
||||
|
||||
return p.String()
|
||||
}
|
||||
|
||||
func WebsocketURL(u string) string {
|
||||
p, _ := url.Parse(NormalizeURL(u))
|
||||
p.RawPath += "/ws"
|
||||
return p.String()
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user