diff --git a/request.go b/request.go index 7ca735d..4e1c2d6 100644 --- a/request.go +++ b/request.go @@ -9,9 +9,11 @@ import ( "net/http" "sort" "strconv" + "strings" "time" "github.com/pkg/errors" + "github.com/ttacon/libphonenumber" "go.opencensus.io/trace" ) @@ -54,6 +56,76 @@ func (r RequestValues) TimestampOrNow() time.Time { return t } +// From returns a Number parsed from the raw From value +func (r RequestValues) From() *ParsedNumber { + return ParseNumber(r["From"]) +} + +// To returns a Number parsed from the raw To value +func (r RequestValues) To() *ParsedNumber { + return ParseNumber(r["To"]) +} + +// ParseNumber parses ether a E164 number or a SIP URI returning a ParsedNumber +func ParseNumber(v string) *ParsedNumber { + number := &ParsedNumber{ + Number: v, + Raw: v, + } + + if err := validPhoneNumber(v, ""); err == nil { + number.Valid = true + return number + } + + u, err := parseSipURI(v) + if err != nil { + return number + } + + parts := strings.Split(u.Hostname(), ".") + l := len(parts) + + if l < 5 || strings.Join(parts[l-2:], ".") != "twilio.com" || parts[l-4] != "sip" { + return number + } + + num, err := FormatNumber(u.User.Username()) + if err != nil { + return number + } + + number.Valid = true + number.Sip = true + number.Number = num + number.SipDomain = strings.Join(parts[:l-4], ".") + number.Region = parts[l-4] + + return number +} + +type ParsedNumber struct { + Valid bool + Number string + Sip bool + SipDomain string + Region string + Raw string +} + +// FormatNumber formates a number to E164 format +func FormatNumber(number string) (string, error) { + num, err := libphonenumber.Parse(number, "US") + if err != nil { + return number, errors.WithMessagef(errors.New("Invalid phone number"), "twiml.FormatNumber(): %s", err) + } + if !libphonenumber.IsValidNumber(num) { + return number, errors.WithMessage(errors.New("Invalid phone number"), "twiml.FormatNumber()") + } + + return libphonenumber.Format(num, libphonenumber.E164), nil +} + // Request is a twillio request expecting a TwiML response type Request struct { host string @@ -75,7 +147,7 @@ func (req *Request) ValidatePost(ctx context.Context, authToken string) error { span.AddAttributes(trace.StringAttribute("url", url)) if req.r.Method != "POST" { - return fmt.Errorf("twiml.Request.ValidatePost(): Expected a POST request, received %s", req.r.Method) + return errors.WithMessage(fmt.Errorf("Expected a POST request, received %s", req.r.Method), "twiml.Request.ValidatePost()") } if err := req.r.ParseForm(); err != nil { @@ -86,7 +158,7 @@ func (req *Request) ValidatePost(ctx context.Context, authToken string) error { for p := range req.r.PostForm { params = append(params, p) } - sort.Sort(sort.StringSlice(params)) + sort.Strings(params) message := url for _, p := range params { @@ -110,7 +182,7 @@ func (req *Request) ValidatePost(ctx context.Context, authToken string) error { if len(xTwilioSigHdr) == 1 { xTwilioSig = xTwilioSigHdr[0] } - return fmt.Errorf("twiml.Request.ValidatePost(): Calculated Signature: %s, failed to match X-Twilio-Signature: %s", sig, xTwilioSig) + return errors.WithMessage(fmt.Errorf("Calculated Signature: %s, failed to match X-Twilio-Signature: %s", sig, xTwilioSig), "twiml.Request.ValidatePost()") } // Validate data @@ -121,7 +193,7 @@ func (req *Request) ValidatePost(ctx context.Context, authToken string) error { } if valParam, ok := fieldValidators[p]; ok { if err := valParam.valFunc(val, valParam.valParam); err != nil { - return fmt.Errorf("twiml.Request.ValidatePost(): Invalid form value: %s=%s, err: %s", p, val, err) + return errors.WithMessage(fmt.Errorf("Invalid form value: %s=%s, err: %s", p, val, err), "twiml.Request.ValidatePost()") } } req.Values[p] = val @@ -138,8 +210,8 @@ type valCfg struct { var fieldValidators = map[string]valCfg{ // "CallSid": "CallSid", // "AccountSid": "AccountSid", - "From": valCfg{valFunc: validPhoneNumber}, - "To": valCfg{valFunc: validPhoneNumber}, + "From": valCfg{valFunc: validFromOrTo}, + "To": valCfg{valFunc: validFromOrTo}, // "CallStatus": "CallStatus", // "ApiVersion": "ApiVersion", // "ForwardedFrom": "ForwardedFrom", diff --git a/request_test.go b/request_test.go index 25bd37a..fb1f34c 100644 --- a/request_test.go +++ b/request_test.go @@ -1,6 +1,9 @@ package twiml -import "testing" +import ( + "reflect" + "testing" +) func TestRequestValues_Duration(t *testing.T) { tests := []struct { @@ -25,3 +28,28 @@ func TestRequestValues_Duration(t *testing.T) { }) } } + +func TestParseNumber(t *testing.T) { + type args struct { + v string + } + tests := []struct { + name string + args args + want *ParsedNumber + }{ + {name: "Valid Number", args: args{v: "+18005642365"}, want: &ParsedNumber{Valid: true, Number: "+18005642365", Raw: "+18005642365"}}, + {name: "Valid SIP us1", args: args{v: "sips:8005642365@domain.sip.us1.twilio.com:5061"}, want: &ParsedNumber{Valid: true, Number: "+18005642365", Sip: true, SipDomain: "domain", Region: "sip", Raw: "sips:8005642365@domain.sip.us1.twilio.com:5061"}}, + {name: "Valid SIP us2", args: args{v: "sips:8005642365@domain.sip.us2.twilio.com:5061"}, want: &ParsedNumber{Valid: true, Number: "+18005642365", Sip: true, SipDomain: "domain", Region: "sip", Raw: "sips:8005642365@domain.sip.us2.twilio.com:5061"}}, + {name: "Invalid SIP sip.domain.com", args: args{v: "sips:8005642365@domain.sip.us1.domain.com:5061"}, want: &ParsedNumber{Number: "sips:8005642365@domain.sip.us1.domain.com:5061", Raw: "sips:8005642365@domain.sip.us1.domain.com:5061"}}, + {name: "Invalid SIP sip2.twilio.com", args: args{v: "sips:8005642365@domain.sip2.us1.twilio.com:5061"}, want: &ParsedNumber{Number: "sips:8005642365@domain.sip2.us1.twilio.com:5061", Raw: "sips:8005642365@domain.sip2.us1.twilio.com:5061"}}, + {name: "Invalid SIP twilio.com", args: args{v: "sips:8005642365@sip.us1.twilio.com:5061"}, want: &ParsedNumber{Number: "sips:8005642365@sip.us1.twilio.com:5061", Raw: "sips:8005642365@sip.us1.twilio.com:5061"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ParseNumber(tt.args.v); !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseNumber() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/validators.go b/validators.go index b675d31..755f4e3 100644 --- a/validators.go +++ b/validators.go @@ -3,11 +3,27 @@ package twiml import ( "errors" "fmt" + "net/url" "strings" "github.com/ttacon/libphonenumber" ) +// validFromOrTo checks that a valid phone number or sip uri is provided +// param of "allowempty" will allow a nil value +func validFromOrTo(v interface{}, param string) error { + err := validPhoneNumber(v, param) + if err == nil { + return nil + } + + if err := validSipURI(v, param); err == nil { + return nil + } + + return err +} + // validPhoneNumber checks that a valid phone number is provided // param of "allowempty" will allow a nil value func validPhoneNumber(v interface{}, param string) error { @@ -29,7 +45,7 @@ func validPhoneNumber(v interface{}, param string) error { } return validatePhoneNumber(*num) default: - return fmt.Errorf("validDatastoreKey: Unexpected type %T", num) + return fmt.Errorf("validPhoneNumber: Unexpected type %T", num) } } @@ -63,7 +79,7 @@ func validateKeyPadEntry(v interface{}, param string) error { } return validateNumericPoundStar(*num) default: - return fmt.Errorf("validDatastoreKey: Unexpected type %T", num) + return fmt.Errorf("validateKeyPadEntry: Unexpected type %T", num) } } @@ -75,9 +91,73 @@ func validateNumericPoundStar(v string) error { // returns an erro if a character is found which is not in charList func characterList(s string, charList string) error { for _, c := range s { - if strings.Index(charList, string(c)) == -1 { + if !strings.Contains(charList, string(c)) { return fmt.Errorf("Invalid: character '%s' is not allowed", string(c)) } } return nil } + +// validSipURI checks that a valid sip uri is provided +// param of "allowempty" will allow a nil value +func validSipURI(v interface{}, param string) error { + switch num := v.(type) { + case string: + if num == "" { + if param == "allowempty" { + return nil + } + return errors.New("Required") + } + _, err := parseSipURI(num) + return err + case *string: + if num == nil { + if param == "allowempty" { + return nil + } + return errors.New("Required") + } + _, err := parseSipURI(*num) + return err + default: + return fmt.Errorf("validPhoneNumber: Unexpected type %T", num) + } +} + +func parseSipURI(uri string) (*url.URL, error) { + uri = strings.ToLower(uri) + + if !strings.HasPrefix(uri, "sip") { + return nil, errors.New("Invalid SIP URI") + } + + // Insert the // after the Schema to enable full parsing and avoid Opaque + if strings.HasPrefix(uri, "sips:") { + uri = strings.Replace(uri, "sips:", "sips://", 1) + } else if strings.HasPrefix(uri, "sip:") { + uri = strings.Replace(uri, "sip:", "sip://", 1) + } + + u, err := url.Parse(uri) + if err != nil { + return nil, errors.New("Invalid SIP URI") + } + + // Schema should be valid + if u.Scheme != "sip" && u.Scheme != "sips" { + return u, errors.New("Invalid SIP URI") + } + + // Path and Opaque should be empty + if u.Path != "" || u.Opaque != "" { + return u, errors.New("Invalid SIP URI") + } + + // Host and User should be provided + if u.Host == "" || u.User.String() == "" { + return u, errors.New("Invalid SIP URI") + } + + return u, nil +} diff --git a/validators_test.go b/validators_test.go new file mode 100644 index 0000000..0122851 --- /dev/null +++ b/validators_test.go @@ -0,0 +1,29 @@ +package twiml + +import "testing" + +func Test_validSipURI(t *testing.T) { + type args struct { + v interface{} + param string + } + tests := []struct { + name string + args args + wantErr bool + }{ + {name: "Valid SIPS URI", args: args{v: "sips:user@yourdomain.sip.us1.twilio.com:5061", param: ""}, wantErr: false}, + {name: "Valid SIP URI", args: args{v: "sip:user@yourdomain.sip.us1.twilio.com:5061", param: ""}, wantErr: false}, + {name: "Valid", args: args{v: "", param: "allowempty"}, wantErr: false}, + {name: "Invaid", args: args{v: "", param: ""}, wantErr: true}, + {name: "Invalid SIP URI", args: args{v: "+18002368945", param: ""}, wantErr: true}, + {name: "Invalid URL", args: args{v: "https://user@yourdomain.sip.us1.twilio.com:5061", param: ""}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validSipURI(tt.args.v, tt.args.param); (err != nil) != tt.wantErr { + t.Errorf("validSipURI() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}