udp: Implement draft of BEP45; Endpoint type added
Because of the requirement of storing multiple ports, Announce.Port has been abolished and Announce.IPv4/IPv6 have been replaced with the Endpoint type. HTTP has been updated to support this model. UDP has been updated to support the latest draft of BEP45 and most of the optional-types described in BEP41.
This commit is contained in:
parent
f25464a02b
commit
222415f467
3 changed files with 150 additions and 76 deletions
|
@ -38,17 +38,34 @@ func (s *Server) newAnnounce(r *http.Request, p httprouter.Params) (*models.Anno
|
||||||
return nil, models.ErrMalformedRequest
|
return nil, models.ErrMalformedRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
ipv4, ipv6, err := requestedIP(q, r, &s.config.NetConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil, models.ErrMalformedRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
port, err := q.Uint64("port")
|
port, err := q.Uint64("port")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, models.ErrMalformedRequest
|
return nil, models.ErrMalformedRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
left, err := q.Uint64("left")
|
left, err := q.Uint64("left")
|
||||||
|
|
||||||
|
ipv4, ipv6, err := requestedEndpoint(q, r, &s.config.NetConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, models.ErrMalformedRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
var ipv4Endpoint, ipv6Endpoint models.Endpoint
|
||||||
|
if ipv4 != nil {
|
||||||
|
ipv4Endpoint = *ipv4
|
||||||
|
// If the port we couldn't get the port before, fallback to the port param.
|
||||||
|
if ipv4Endpoint.Port == uint16(0) {
|
||||||
|
ipv4Endpoint.Port = uint16(port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ipv6 != nil {
|
||||||
|
ipv6Endpoint = *ipv6
|
||||||
|
// If the port we couldn't get the port before, fallback to the port param.
|
||||||
|
if ipv6Endpoint.Port == uint16(0) {
|
||||||
|
ipv6Endpoint.Port = uint16(port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, models.ErrMalformedRequest
|
return nil, models.ErrMalformedRequest
|
||||||
}
|
}
|
||||||
|
@ -68,14 +85,13 @@ func (s *Server) newAnnounce(r *http.Request, p httprouter.Params) (*models.Anno
|
||||||
Compact: compact,
|
Compact: compact,
|
||||||
Downloaded: downloaded,
|
Downloaded: downloaded,
|
||||||
Event: event,
|
Event: event,
|
||||||
IPv4: ipv4,
|
IPv4: ipv4Endpoint,
|
||||||
IPv6: ipv6,
|
IPv6: ipv6Endpoint,
|
||||||
Infohash: infohash,
|
Infohash: infohash,
|
||||||
Left: left,
|
Left: left,
|
||||||
NumWant: numWant,
|
NumWant: numWant,
|
||||||
Passkey: p.ByName("passkey"),
|
Passkey: p.ByName("passkey"),
|
||||||
PeerID: peerID,
|
PeerID: peerID,
|
||||||
Port: uint16(port),
|
|
||||||
Uploaded: uploaded,
|
Uploaded: uploaded,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
@ -116,26 +132,26 @@ func requestedPeerCount(q *query.Query, fallback int) int {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestedIP returns the IP addresses for a request. If there are multiple
|
// requestedEndpoint returns the IP address and port pairs for a request. If
|
||||||
// IP addresses in the request, one IPv4 and one IPv6 will be returned.
|
// there are multiple in the request, one IPv4 and one IPv6 will be returned.
|
||||||
func requestedIP(q *query.Query, r *http.Request, cfg *config.NetConfig) (v4, v6 net.IP, err error) {
|
func requestedEndpoint(q *query.Query, r *http.Request, cfg *config.NetConfig) (v4, v6 *models.Endpoint, err error) {
|
||||||
var done bool
|
var done bool
|
||||||
|
|
||||||
if cfg.AllowIPSpoofing {
|
if cfg.AllowIPSpoofing {
|
||||||
if str, ok := q.Params["ip"]; ok {
|
if str, ok := q.Params["ip"]; ok {
|
||||||
if v4, v6, done = getIPs(str, v4, v6, cfg); done {
|
if v4, v6, done = getEndpoints(str, v4, v6, cfg); done {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if str, ok := q.Params["ipv4"]; ok {
|
if str, ok := q.Params["ipv4"]; ok {
|
||||||
if v4, v6, done = getIPs(str, v4, v6, cfg); done {
|
if v4, v6, done = getEndpoints(str, v4, v6, cfg); done {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if str, ok := q.Params["ipv6"]; ok {
|
if str, ok := q.Params["ipv6"]; ok {
|
||||||
if v4, v6, done = getIPs(str, v4, v6, cfg); done {
|
if v4, v6, done = getEndpoints(str, v4, v6, cfg); done {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -143,47 +159,49 @@ func requestedIP(q *query.Query, r *http.Request, cfg *config.NetConfig) (v4, v6
|
||||||
|
|
||||||
if cfg.RealIPHeader != "" {
|
if cfg.RealIPHeader != "" {
|
||||||
if xRealIPs, ok := r.Header[cfg.RealIPHeader]; ok {
|
if xRealIPs, ok := r.Header[cfg.RealIPHeader]; ok {
|
||||||
if v4, v6, done = getIPs(string(xRealIPs[0]), v4, v6, cfg); done {
|
if v4, v6, done = getEndpoints(string(xRealIPs[0]), v4, v6, cfg); done {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if r.RemoteAddr == "" {
|
if r.RemoteAddr == "" && v4 == nil {
|
||||||
if v4 == nil {
|
if v4, v6, done = getEndpoints("127.0.0.1", v4, v6, cfg); done {
|
||||||
v4 = net.ParseIP("127.0.0.1")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var host string
|
|
||||||
host, _, err = net.SplitHostPort(r.RemoteAddr)
|
|
||||||
|
|
||||||
if err == nil && host != "" {
|
|
||||||
if v4, v6, done = getIPs(host, v4, v6, cfg); done {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if v4, v6, done = getEndpoints(r.RemoteAddr, v4, v6, cfg); done {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if v4 == nil && v6 == nil {
|
if v4 == nil && v6 == nil {
|
||||||
err = errors.New("failed to parse IP address")
|
err = errors.New("failed to parse IP address")
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func getIPs(ipstr string, ipv4, ipv6 net.IP, cfg *config.NetConfig) (net.IP, net.IP, bool) {
|
func getEndpoints(ipstr string, ipv4, ipv6 *models.Endpoint, cfg *config.NetConfig) (*models.Endpoint, *models.Endpoint, bool) {
|
||||||
var done bool
|
host, port, err := net.SplitHostPort(ipstr)
|
||||||
|
if err != nil {
|
||||||
|
host = ipstr
|
||||||
|
}
|
||||||
|
|
||||||
if ip := net.ParseIP(ipstr); ip != nil {
|
// We can ignore this error, because ports that are 0 are assumed to be the
|
||||||
newIPv4 := ip.To4()
|
// port parameter provided in the "port" param of the announce request.
|
||||||
|
parsedPort, _ := strconv.ParseUint(port, 10, 16)
|
||||||
|
|
||||||
if ipv4 == nil && newIPv4 != nil {
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
ipv4 = newIPv4
|
ipTo4 := ip.To4()
|
||||||
} else if ipv6 == nil && newIPv4 == nil {
|
if ipv4 == nil && ipTo4 != nil {
|
||||||
ipv6 = ip
|
ipv4 = &models.Endpoint{ipTo4, uint16(parsedPort)}
|
||||||
|
} else if ipv6 == nil && ipTo4 == nil {
|
||||||
|
ipv6 = &models.Endpoint{ip, uint16(parsedPort)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var done bool
|
||||||
if cfg.DualStackedPeers {
|
if cfg.DualStackedPeers {
|
||||||
done = ipv4 != nil && ipv6 != nil
|
done = ipv4 != nil && ipv6 != nil
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -8,7 +8,6 @@ package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -46,6 +45,7 @@ func (e ClientError) Error() string { return string(e) }
|
||||||
func (e NotFoundError) Error() string { return string(e) }
|
func (e NotFoundError) Error() string { return string(e) }
|
||||||
func (e ProtocolError) Error() string { return string(e) }
|
func (e ProtocolError) Error() string { return string(e) }
|
||||||
|
|
||||||
|
// IsPublicError determines whether an error should be propogated to the client.
|
||||||
func IsPublicError(err error) bool {
|
func IsPublicError(err error) bool {
|
||||||
_, cl := err.(ClientError)
|
_, cl := err.(ClientError)
|
||||||
_, nf := err.(NotFoundError)
|
_, nf := err.(NotFoundError)
|
||||||
|
@ -60,8 +60,8 @@ type PeerList []Peer
|
||||||
type PeerKey string
|
type PeerKey string
|
||||||
|
|
||||||
// NewPeerKey creates a properly formatted PeerKey.
|
// NewPeerKey creates a properly formatted PeerKey.
|
||||||
func NewPeerKey(peerID string, ip net.IP, port string) PeerKey {
|
func NewPeerKey(peerID string, ip net.IP) PeerKey {
|
||||||
return PeerKey(peerID + "//" + ip.String() + ":" + port)
|
return PeerKey(peerID + "//" + ip.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// IP parses and returns the IP address for a given PeerKey.
|
// IP parses and returns the IP address for a given PeerKey.
|
||||||
|
@ -78,26 +78,23 @@ func (pk PeerKey) PeerID() string {
|
||||||
return strings.Split(string(pk), "//")[0]
|
return strings.Split(string(pk), "//")[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port returns the port section of the PeerKey.
|
// Endpoint is an IP and port pair.
|
||||||
func (pk PeerKey) Port() string {
|
type Endpoint struct {
|
||||||
return strings.Split(string(pk), "//")[2]
|
// Always has length net.IPv4len if IPv4, and net.IPv6len if IPv6
|
||||||
|
IP net.IP `json:"ip"`
|
||||||
|
Port uint16 `json:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peer is a participant in a swarm.
|
// Peer is a participant in a swarm.
|
||||||
type Peer struct {
|
type Peer struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID uint64 `json:"user_id"`
|
UserID uint64 `json:"user_id"`
|
||||||
TorrentID uint64 `json:"torrent_id"`
|
TorrentID uint64 `json:"torrent_id"`
|
||||||
|
|
||||||
// Always has length net.IPv4len if IPv4, and net.IPv6len if IPv6
|
|
||||||
IP net.IP `json:"ip,omitempty"`
|
|
||||||
|
|
||||||
Port uint16 `json:"port"`
|
|
||||||
|
|
||||||
Uploaded uint64 `json:"uploaded"`
|
Uploaded uint64 `json:"uploaded"`
|
||||||
Downloaded uint64 `json:"downloaded"`
|
Downloaded uint64 `json:"downloaded"`
|
||||||
Left uint64 `json:"left"`
|
Left uint64 `json:"left"`
|
||||||
LastAnnounce int64 `json:"last_announce"`
|
LastAnnounce int64 `json:"last_announce"`
|
||||||
|
Endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasIPv4 determines if a peer's IP address can be represented as an IPv4
|
// HasIPv4 determines if a peer's IP address can be represented as an IPv4
|
||||||
|
@ -114,7 +111,7 @@ func (p *Peer) HasIPv6() bool {
|
||||||
|
|
||||||
// Key returns a PeerKey for the given peer.
|
// Key returns a PeerKey for the given peer.
|
||||||
func (p *Peer) Key() PeerKey {
|
func (p *Peer) Key() PeerKey {
|
||||||
return NewPeerKey(p.ID, p.IP, strconv.FormatUint(p.Port, 10))
|
return NewPeerKey(p.ID, p.IP)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Torrent is a swarm for a given torrent file.
|
// Torrent is a swarm for a given torrent file.
|
||||||
|
@ -149,18 +146,17 @@ type User struct {
|
||||||
type Announce struct {
|
type Announce struct {
|
||||||
Config *config.Config `json:"config"`
|
Config *config.Config `json:"config"`
|
||||||
|
|
||||||
Compact bool `json:"compact"`
|
Compact bool `json:"compact"`
|
||||||
Downloaded uint64 `json:"downloaded"`
|
Downloaded uint64 `json:"downloaded"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
IPv4 net.IP `json:"ipv4"`
|
IPv4 Endpoint `json:"ipv4"`
|
||||||
IPv6 net.IP `json:"ipv6"`
|
IPv6 Endpoint `json:"ipv6"`
|
||||||
Infohash string `json:"infohash"`
|
Infohash string `json:"infohash"`
|
||||||
Left uint64 `json:"left"`
|
Left uint64 `json:"left"`
|
||||||
NumWant int `json:"numwant"`
|
NumWant int `json:"numwant"`
|
||||||
Passkey string `json:"passkey"`
|
Passkey string `json:"passkey"`
|
||||||
PeerID string `json:"peer_id"`
|
PeerID string `json:"peer_id"`
|
||||||
Port uint16 `json:"port"`
|
Uploaded uint64 `json:"uploaded"`
|
||||||
Uploaded uint64 `json:"uploaded"`
|
|
||||||
|
|
||||||
Torrent *Torrent `json:"-"`
|
Torrent *Torrent `json:"-"`
|
||||||
User *User `json:"-"`
|
User *User `json:"-"`
|
||||||
|
@ -186,12 +182,14 @@ func (a *Announce) ClientID() (clientID string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasIPv4 determines whether or not an announce has an IPv4 endpoint.
|
||||||
func (a *Announce) HasIPv4() bool {
|
func (a *Announce) HasIPv4() bool {
|
||||||
return a.IPv4 != nil
|
return a.IPv4.IP != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasIPv6 determines whether or not an announce has an IPv6 endpoint.
|
||||||
func (a *Announce) HasIPv6() bool {
|
func (a *Announce) HasIPv6() bool {
|
||||||
return a.IPv6 != nil
|
return a.IPv6.IP != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildPeer creates the Peer representation of an Announce. When provided nil
|
// BuildPeer creates the Peer representation of an Announce. When provided nil
|
||||||
|
@ -201,7 +199,6 @@ func (a *Announce) HasIPv6() bool {
|
||||||
func (a *Announce) BuildPeer(u *User, t *Torrent) {
|
func (a *Announce) BuildPeer(u *User, t *Torrent) {
|
||||||
a.Peer = &Peer{
|
a.Peer = &Peer{
|
||||||
ID: a.PeerID,
|
ID: a.PeerID,
|
||||||
Port: a.Port,
|
|
||||||
Uploaded: a.Uploaded,
|
Uploaded: a.Uploaded,
|
||||||
Downloaded: a.Downloaded,
|
Downloaded: a.Downloaded,
|
||||||
Left: a.Left,
|
Left: a.Left,
|
||||||
|
@ -220,15 +217,15 @@ func (a *Announce) BuildPeer(u *User, t *Torrent) {
|
||||||
|
|
||||||
if a.HasIPv4() && a.HasIPv6() {
|
if a.HasIPv4() && a.HasIPv6() {
|
||||||
a.PeerV4 = a.Peer
|
a.PeerV4 = a.Peer
|
||||||
a.PeerV4.IP = a.IPv4
|
a.PeerV4.Endpoint = a.IPv4
|
||||||
a.PeerV6 = &*a.Peer
|
a.PeerV6 = &*a.Peer
|
||||||
a.PeerV6.IP = a.IPv6
|
a.PeerV6.Endpoint = a.IPv6
|
||||||
} else if a.HasIPv4() {
|
} else if a.HasIPv4() {
|
||||||
a.PeerV4 = a.Peer
|
a.PeerV4 = a.Peer
|
||||||
a.PeerV4.IP = a.IPv4
|
a.PeerV4.Endpoint = a.IPv4
|
||||||
} else if a.HasIPv6() {
|
} else if a.HasIPv6() {
|
||||||
a.PeerV6 = a.Peer
|
a.PeerV6 = a.Peer
|
||||||
a.PeerV6.IP = a.IPv6
|
a.PeerV6.Endpoint = a.IPv6
|
||||||
} else {
|
} else {
|
||||||
panic("models: announce must have an IP")
|
panic("models: announce must have an IP")
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,17 @@ var (
|
||||||
// initialConnectionID is the magic initial connection ID specified by BEP 15.
|
// initialConnectionID is the magic initial connection ID specified by BEP 15.
|
||||||
initialConnectionID = []byte{0, 0, 0x04, 0x17, 0x27, 0x10, 0x19, 0x80}
|
initialConnectionID = []byte{0, 0, 0x04, 0x17, 0x27, 0x10, 0x19, 0x80}
|
||||||
|
|
||||||
// eventIDs maps IDs to event names.
|
// emptyIPs are the value of an IP field that has been left blank.
|
||||||
|
emptyIPv4 = []byte{0, 0, 0, 0}
|
||||||
|
emptyIPv6 = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||||
|
|
||||||
|
// Option-Types described in BEP41 and BEP45.
|
||||||
|
optionEndOfOptions = byte(0x0)
|
||||||
|
optionNOP = byte(0x1)
|
||||||
|
optionURLData = byte(0x2)
|
||||||
|
optionIPv6 = byte(0x3)
|
||||||
|
|
||||||
|
// eventIDs map IDs to event names.
|
||||||
eventIDs = []string{
|
eventIDs = []string{
|
||||||
"",
|
"",
|
||||||
"completed",
|
"completed",
|
||||||
|
@ -125,9 +135,9 @@ func (s *Server) newAnnounce(packet []byte, ip net.IP) (*models.Announce, error)
|
||||||
return nil, errMalformedEvent
|
return nil, errMalformedEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
ipbytes := packet[84:88]
|
ipv4bytes := packet[84:88]
|
||||||
if s.config.AllowIPSpoofing && !bytes.Equal(ipbytes, []byte{0, 0, 0, 0}) {
|
if s.config.AllowIPSpoofing && !bytes.Equal(ipv4bytes, emptyIPv4) {
|
||||||
ip = net.ParseIP(string(ipbytes))
|
ip = net.ParseIP(string(ipv4bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
if ip == nil {
|
if ip == nil {
|
||||||
|
@ -139,16 +149,65 @@ func (s *Server) newAnnounce(packet []byte, ip net.IP) (*models.Announce, error)
|
||||||
numWant := binary.BigEndian.Uint32(packet[92:96])
|
numWant := binary.BigEndian.Uint32(packet[92:96])
|
||||||
port := binary.BigEndian.Uint16(packet[96:98])
|
port := binary.BigEndian.Uint16(packet[96:98])
|
||||||
|
|
||||||
|
// Optionally, parse the optional parameteres as described in BEP41.
|
||||||
|
var IPv6Endpoint models.Endpoint
|
||||||
|
if len(packet) > 98 {
|
||||||
|
optionStartIndex := 98
|
||||||
|
for optionStartIndex < len(packet)-1 {
|
||||||
|
option := packet[optionStartIndex]
|
||||||
|
switch option {
|
||||||
|
case optionEndOfOptions:
|
||||||
|
break
|
||||||
|
|
||||||
|
case optionNOP:
|
||||||
|
optionStartIndex++
|
||||||
|
|
||||||
|
case optionURLData:
|
||||||
|
if optionStartIndex+1 > len(packet)-1 {
|
||||||
|
return nil, errMalformedPacket
|
||||||
|
}
|
||||||
|
|
||||||
|
length := int(packet[optionStartIndex+1])
|
||||||
|
if optionStartIndex+1+length > len(packet)-1 {
|
||||||
|
return nil, errMalformedPacket
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Actually parse the URL Data as described in BEP41.
|
||||||
|
|
||||||
|
optionStartIndex += 1 + length
|
||||||
|
|
||||||
|
case optionIPv6:
|
||||||
|
if optionStartIndex+19 > len(packet)-1 {
|
||||||
|
return nil, errMalformedPacket
|
||||||
|
}
|
||||||
|
|
||||||
|
ipv6bytes := packet[optionStartIndex+1 : optionStartIndex+17]
|
||||||
|
if s.config.AllowIPSpoofing && !bytes.Equal(ipv6bytes, emptyIPv6) {
|
||||||
|
IPv6Endpoint.IP = net.ParseIP(string(ipv6bytes)).To16()
|
||||||
|
IPv6Endpoint.Port = binary.BigEndian.Uint16(packet[optionStartIndex+17 : optionStartIndex+19])
|
||||||
|
if IPv6Endpoint.IP == nil {
|
||||||
|
return nil, errMalformedIP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optionStartIndex += 19
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &models.Announce{
|
return &models.Announce{
|
||||||
Config: s.config,
|
Config: s.config,
|
||||||
Downloaded: downloaded,
|
Downloaded: downloaded,
|
||||||
Event: eventIDs[eventID],
|
Event: eventIDs[eventID],
|
||||||
IPv4: ip,
|
IPv4: models.Endpoint{ip, port},
|
||||||
|
IPv6: IPv6Endpoint,
|
||||||
Infohash: string(infohash),
|
Infohash: string(infohash),
|
||||||
Left: left,
|
Left: left,
|
||||||
NumWant: int(numWant),
|
NumWant: int(numWant),
|
||||||
PeerID: string(peerID),
|
PeerID: string(peerID),
|
||||||
Port: port,
|
|
||||||
Uploaded: uploaded,
|
Uploaded: uploaded,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue