Some initial experimentation with SCPD reading.
This commit is contained in:
parent
cc75a26e13
commit
4dd2213715
124
v2alpha/cmd/goupnp2dcpgen/main.go
Normal file
124
v2alpha/cmd/goupnp2dcpgen/main.go
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/huin/goupnp/v2alpha/cmd/goupnp2dcpgen/zipread"
|
||||||
|
"github.com/huin/goupnp/v2alpha/description/scpd"
|
||||||
|
"github.com/huin/goupnp/v2alpha/description/xmlscpd"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
upnpresourcesZip = flag.String("upnpresources_zip", "", "Path to upnpresources.zip.")
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
if len(flag.Args()) > 0 {
|
||||||
|
return fmt.Errorf("unused arguments: %s", strings.Join(flag.Args(), " "))
|
||||||
|
}
|
||||||
|
if *upnpresourcesZip == "" {
|
||||||
|
return errors.New("-upnpresources_zip is a required flag.")
|
||||||
|
}
|
||||||
|
f, err := os.Open(*upnpresourcesZip)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
upnpresources, err := zipread.FromOsFile(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, m := range manifests {
|
||||||
|
if err := processDCP(upnpresources, m); err != nil {
|
||||||
|
return fmt.Errorf("processing DCP %s: %w", m.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifests = []*DCPSpecManifest{
|
||||||
|
{
|
||||||
|
Path: "standardizeddcps/Internet Gateway_2/UPnP-gw-IGD-TestFiles-20101210.zip",
|
||||||
|
Services: map[string]string{
|
||||||
|
"LANHostConfigManagement:1": "xml data files/service/LANHostConfigManagement1.xml",
|
||||||
|
"WANPPPConnection:1": "xml data files/service/WANPPPConnection1.xml",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func processDCP(
|
||||||
|
upnpresources *zipread.ZipRead,
|
||||||
|
manifest *DCPSpecManifest,
|
||||||
|
) error {
|
||||||
|
dcpSpecData, err := upnpresources.OpenZip(manifest.Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for name, path := range manifest.Services {
|
||||||
|
if err := processService(dcpSpecData, name, path); err != nil {
|
||||||
|
return fmt.Errorf("processing service %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processService(
|
||||||
|
dcpSpecData *zipread.ZipRead,
|
||||||
|
name string,
|
||||||
|
path string,
|
||||||
|
) error {
|
||||||
|
fmt.Printf("%s\n", name)
|
||||||
|
f, err := dcpSpecData.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
d := xml.NewDecoder(f)
|
||||||
|
|
||||||
|
xmlSCPD := &xmlscpd.SCPD{}
|
||||||
|
if err := d.Decode(xmlSCPD); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
xmlSCPD.Clean()
|
||||||
|
|
||||||
|
for _, action := range xmlSCPD.Actions {
|
||||||
|
fmt.Printf("* %s()\n", action.Name)
|
||||||
|
for _, arg := range action.Arguments {
|
||||||
|
direction := "?"
|
||||||
|
if arg.Direction == "in" {
|
||||||
|
direction = "<-"
|
||||||
|
} else if arg.Direction == "out" {
|
||||||
|
direction = "->"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s %s %s\n", direction, arg.Name, arg.RelatedStateVariable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := scpd.FromXML(xmlSCPD)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DCPSpecManifest struct {
|
||||||
|
// Path is the file path within upnpresources.zip to the DCP spec ZIP file.
|
||||||
|
Path string
|
||||||
|
// Services maps from a service name (e.g. "FooBar:1") to a path within the DCP spec ZIP file
|
||||||
|
// (e.g. "xml data files/service/FooBar1.xml").
|
||||||
|
Services map[string]string
|
||||||
|
}
|
68
v2alpha/cmd/goupnp2dcpgen/zipread/zipread.go
Normal file
68
v2alpha/cmd/goupnp2dcpgen/zipread/zipread.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
package zipread
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SizedReaderAt struct {
|
||||||
|
R io.ReaderAt
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSizedReaderFromOsFile(f *os.File) (*SizedReaderAt, error) {
|
||||||
|
stat, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &SizedReaderAt{R: f, Size: stat.Size()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSizedReaderFromReader(r io.Reader) (*SizedReaderAt, error) {
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
contents := bytes.NewReader(data)
|
||||||
|
return &SizedReaderAt{R: contents, Size: int64(len(data))}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ZipRead struct {
|
||||||
|
*zip.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(r *SizedReaderAt) (*ZipRead, error) {
|
||||||
|
zr, err := zip.NewReader(r.R, r.Size)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ZipRead{zr}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromOsFile(f *os.File) (*ZipRead, error) {
|
||||||
|
r, err := NewSizedReaderFromOsFile(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return New(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromFsFile(f fs.File) (*ZipRead, error) {
|
||||||
|
r, err := NewSizedReaderFromReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return New(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (zr *ZipRead) OpenZip(path string) (*ZipRead, error) {
|
||||||
|
f, err := zr.Reader.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return FromFsFile(f)
|
||||||
|
}
|
167
v2alpha/description/scpd/scpd.go
Normal file
167
v2alpha/description/scpd/scpd.go
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
// Package scpd contains data structures that represent an SCPD at a higher level than XML.
|
||||||
|
package scpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/huin/goupnp/v2alpha/description/xmlscpd"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
BadDescriptionError = errors.New("bad XML description")
|
||||||
|
UnsupportedDescriptionError = errors.New("unsupported XML description")
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCPD is the top level service description.
|
||||||
|
type SCPD struct {
|
||||||
|
actionByName map[string]*Action
|
||||||
|
variableByName map[string]*StateVariable
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromXML creates an SCPD from XML data.
|
||||||
|
//
|
||||||
|
// It assumes that xmlDesc.Clean() has been called.
|
||||||
|
func FromXML(xmlDesc *xmlscpd.SCPD) (*SCPD, error) {
|
||||||
|
stateVariables := make(map[string]*StateVariable, len(xmlDesc.StateVariables))
|
||||||
|
for _, xmlSV := range xmlDesc.StateVariables {
|
||||||
|
sv, err := stateVariableFromXML(xmlSV)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("processing state variable %q: %w", xmlSV.Name, err)
|
||||||
|
}
|
||||||
|
if _, exists := stateVariables[sv.name]; exists {
|
||||||
|
return nil, fmt.Errorf("%w: multiple state variables with name %q",
|
||||||
|
BadDescriptionError, sv.name)
|
||||||
|
}
|
||||||
|
stateVariables[sv.name] = sv
|
||||||
|
}
|
||||||
|
actions := make(map[string]*Action, len(xmlDesc.Actions))
|
||||||
|
for _, xmlAction := range xmlDesc.Actions {
|
||||||
|
action, err := actionFromXML(xmlAction)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("processing action %q: %w", xmlAction.Name, err)
|
||||||
|
}
|
||||||
|
if _, exists := actions[action.name]; exists {
|
||||||
|
return nil, fmt.Errorf("%w: multiple actions with name %q",
|
||||||
|
BadDescriptionError, action.name)
|
||||||
|
}
|
||||||
|
actions[action.name] = action
|
||||||
|
}
|
||||||
|
return &SCPD{
|
||||||
|
actionByName: actions,
|
||||||
|
variableByName: stateVariables,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionNames returns the ordered names of each action.
|
||||||
|
func (scpd *SCPD) ActionNames() []string {
|
||||||
|
names := make([]string, 0, len(scpd.actionByName))
|
||||||
|
for name := range scpd.actionByName {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action returns an action with the given name.
|
||||||
|
func (scpd *SCPD) Action(name string) *Action {
|
||||||
|
return scpd.actionByName[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable returns a state variable with the given name.
|
||||||
|
func (scpd *SCPD) Variable(name string) *StateVariable {
|
||||||
|
return scpd.variableByName[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action describes a single UPnP SOAP action.
|
||||||
|
type Action struct {
|
||||||
|
name string
|
||||||
|
inArgs []*Argument
|
||||||
|
outArgs []*Argument
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionFromXML creates an Action from the given XML description.
|
||||||
|
func actionFromXML(xmlAction *xmlscpd.Action) (*Action, error) {
|
||||||
|
if xmlAction.Name == "" {
|
||||||
|
return nil, fmt.Errorf("%w: empty action name", BadDescriptionError)
|
||||||
|
}
|
||||||
|
var inArgs []*Argument
|
||||||
|
var outArgs []*Argument
|
||||||
|
for _, xmlArg := range xmlAction.Arguments {
|
||||||
|
arg, err := argumentFromXML(xmlArg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("processing argument %q: %w", xmlArg.Name, err)
|
||||||
|
}
|
||||||
|
switch xmlArg.Direction {
|
||||||
|
case "in":
|
||||||
|
inArgs = append(inArgs, arg)
|
||||||
|
case "out":
|
||||||
|
outArgs = append(outArgs, arg)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: argument %q has invalid direction %q",
|
||||||
|
BadDescriptionError, xmlArg.Name, xmlArg.Direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Action{
|
||||||
|
name: xmlAction.Name,
|
||||||
|
inArgs: inArgs,
|
||||||
|
outArgs: outArgs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Argument description data.
|
||||||
|
type Argument struct {
|
||||||
|
name string
|
||||||
|
relatedStateVariable string
|
||||||
|
}
|
||||||
|
|
||||||
|
// argumentFromXML creates an Argument from the XML description.
|
||||||
|
func argumentFromXML(xmlArg *xmlscpd.Argument) (*Argument, error) {
|
||||||
|
if xmlArg.Name == "" {
|
||||||
|
return nil, fmt.Errorf("%w: empty argument name", BadDescriptionError)
|
||||||
|
}
|
||||||
|
if xmlArg.RelatedStateVariable == "" {
|
||||||
|
return nil, fmt.Errorf("%w: empty related state variable", BadDescriptionError)
|
||||||
|
}
|
||||||
|
return &Argument{
|
||||||
|
name: xmlArg.Name,
|
||||||
|
relatedStateVariable: xmlArg.RelatedStateVariable,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (arg *Argument) Name() string {
|
||||||
|
return arg.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (arg *Argument) RelatedStateVariableName() string {
|
||||||
|
return arg.relatedStateVariable
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateVariable description data.
|
||||||
|
type StateVariable struct {
|
||||||
|
name string
|
||||||
|
dataType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func stateVariableFromXML(xmlSV *xmlscpd.StateVariable) (*StateVariable, error) {
|
||||||
|
if xmlSV.Name == "" {
|
||||||
|
return nil, fmt.Errorf("%w: empty state variable name", BadDescriptionError)
|
||||||
|
}
|
||||||
|
if xmlSV.DataType.Type != "" {
|
||||||
|
return nil, fmt.Errorf("%w: unsupported data type %q",
|
||||||
|
UnsupportedDescriptionError, xmlSV.DataType.Type)
|
||||||
|
}
|
||||||
|
return &StateVariable{
|
||||||
|
name: xmlSV.Name,
|
||||||
|
dataType: xmlSV.DataType.Name,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sv *StateVariable) Name() string {
|
||||||
|
return sv.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sv *StateVariable) DataTypeName() string {
|
||||||
|
return sv.dataType
|
||||||
|
}
|
161
v2alpha/description/xmlscpd/xmlscpd.go
Normal file
161
v2alpha/description/xmlscpd/xmlscpd.go
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
// Package xmlscpd contains the XML data structures used in SCPD (Service Control Protocol Description).
|
||||||
|
//
|
||||||
|
// Described in section 2.5 of
|
||||||
|
// https://openconnectivity.org/upnp-specs/UPnP-arch-DeviceArchitecture-v2.0-20200417.pdf.
|
||||||
|
package xmlscpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cleanWhitespace(s *string) {
|
||||||
|
*s = strings.TrimSpace(*s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCPD is the top level XML service description.
|
||||||
|
type SCPD struct {
|
||||||
|
XMLName xml.Name `xml:"scpd"`
|
||||||
|
ConfigId string `xml:"configId,attr"`
|
||||||
|
SpecVersion SpecVersion `xml:"specVersion"`
|
||||||
|
Actions []*Action `xml:"actionList>action"`
|
||||||
|
StateVariables []*StateVariable `xml:"serviceStateTable>stateVariable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean removes stray whitespace in the structure.
|
||||||
|
//
|
||||||
|
// It's common for stray whitespace to be present in SCPD documents, this method removes them
|
||||||
|
// in-place.
|
||||||
|
func (scpd *SCPD) Clean() {
|
||||||
|
cleanWhitespace(&scpd.ConfigId)
|
||||||
|
for i := range scpd.Actions {
|
||||||
|
scpd.Actions[i].Clean()
|
||||||
|
}
|
||||||
|
for i := range scpd.StateVariables {
|
||||||
|
scpd.StateVariables[i].Clean()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpecVersion is part of a SCPD document, describes the version of the
|
||||||
|
// specification that the data adheres to.
|
||||||
|
type SpecVersion struct {
|
||||||
|
Major int32 `xml:"major"`
|
||||||
|
Minor int32 `xml:"minor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action XML description data.
|
||||||
|
type Action struct {
|
||||||
|
Name string `xml:"name"`
|
||||||
|
Arguments []*Argument `xml:"argumentList>argument"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean removes stray whitespace in the structure.
|
||||||
|
func (action *Action) Clean() {
|
||||||
|
cleanWhitespace(&action.Name)
|
||||||
|
for i := range action.Arguments {
|
||||||
|
action.Arguments[i].Clean()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Argument XML data.
|
||||||
|
type Argument struct {
|
||||||
|
Name string `xml:"name"`
|
||||||
|
Direction string `xml:"direction"` // in|out
|
||||||
|
RelatedStateVariable string `xml:"relatedStateVariable"` // ?
|
||||||
|
Retval string `xml:"retval"` // ?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean removes stray whitespace in the structure.
|
||||||
|
func (arg *Argument) Clean() {
|
||||||
|
cleanWhitespace(&arg.Name)
|
||||||
|
cleanWhitespace(&arg.Direction)
|
||||||
|
cleanWhitespace(&arg.RelatedStateVariable)
|
||||||
|
cleanWhitespace(&arg.Retval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateVariable XML data.
|
||||||
|
type StateVariable struct {
|
||||||
|
Optional PresenceBool
|
||||||
|
Name string `xml:"name"`
|
||||||
|
SendEvents string `xml:"sendEvents,attr"` // yes|no
|
||||||
|
Multicast string `xml:"multicast,attr"` // yes|no
|
||||||
|
DataType DataType `xml:"dataType"`
|
||||||
|
DefaultValue string `xml:"defaultValue"`
|
||||||
|
AllowedValueRange *AllowedValueRange `xml:"allowedValueRange"`
|
||||||
|
AllowedValues []string `xml:"allowedValueList>allowedValue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean removes stray whitespace in the structure.
|
||||||
|
func (v *StateVariable) Clean() {
|
||||||
|
cleanWhitespace(&v.Name)
|
||||||
|
cleanWhitespace(&v.SendEvents)
|
||||||
|
cleanWhitespace(&v.Multicast)
|
||||||
|
v.DataType.Clean()
|
||||||
|
cleanWhitespace(&v.DefaultValue)
|
||||||
|
if v.AllowedValueRange != nil {
|
||||||
|
v.AllowedValueRange.Clean()
|
||||||
|
}
|
||||||
|
for i := range v.AllowedValues {
|
||||||
|
cleanWhitespace(&v.AllowedValues[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedValueRange XML data.
|
||||||
|
type AllowedValueRange struct {
|
||||||
|
Minimum string `xml:"minimum"`
|
||||||
|
Maximum string `xml:"maximum"`
|
||||||
|
Step string `xml:"step"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean removes stray whitespace in the structure.
|
||||||
|
func (r *AllowedValueRange) Clean() {
|
||||||
|
cleanWhitespace(&r.Minimum)
|
||||||
|
cleanWhitespace(&r.Maximum)
|
||||||
|
cleanWhitespace(&r.Step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataType XML data.
|
||||||
|
type DataType struct {
|
||||||
|
Name string `xml:",chardata"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean removes stray whitespace in the structure.
|
||||||
|
func (dt *DataType) Clean() {
|
||||||
|
cleanWhitespace(&dt.Name)
|
||||||
|
cleanWhitespace(&dt.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresenceBool represents an empty XML element that is true if present.
|
||||||
|
//
|
||||||
|
// Is an error if it contains any attributes or contents.
|
||||||
|
type PresenceBool bool
|
||||||
|
|
||||||
|
func (pb *PresenceBool) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||||
|
*pb = true
|
||||||
|
if len(start.Attr) > 0 {
|
||||||
|
return fmt.Errorf("unexpected attributes on element %s:%s",
|
||||||
|
start.Name.Space, start.Name.Local)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
tok, err := d.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch tok := tok.(type) {
|
||||||
|
case xml.CharData:
|
||||||
|
if len(bytes.TrimSpace([]byte(tok))) > 0 {
|
||||||
|
return fmt.Errorf("unexpected char data on element %s:%s",
|
||||||
|
start.Name.Space, start.Name.Local)
|
||||||
|
}
|
||||||
|
case xml.EndElement:
|
||||||
|
return nil
|
||||||
|
case xml.Comment:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unexpected %T token on element %s:%s",
|
||||||
|
tok, start.Name.Space, start.Name.Local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user