Skip to content

Commit

Permalink
Add support for validating a sip uri in the From and To fields
Browse files Browse the repository at this point in the history
Add ParsedNumber to provide details about different types of To and From values including SIP URI's
  • Loading branch information
jtwatson committed May 3, 2020
1 parent f06f9c7 commit 31613a0
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 10 deletions.
84 changes: 78 additions & 6 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import (
"net/http"
"sort"
"strconv"
"strings"
"time"

"github.com/pkg/errors"
"github.com/ttacon/libphonenumber"
"go.opencensus.io/trace"
)

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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",
Expand Down
30 changes: 29 additions & 1 deletion request_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package twiml

import "testing"
import (
"reflect"
"testing"
)

func TestRequestValues_Duration(t *testing.T) {
tests := []struct {
Expand All @@ -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)
}
})
}
}
86 changes: 83 additions & 3 deletions validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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
}
29 changes: 29 additions & 0 deletions validators_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}

0 comments on commit 31613a0

Please sign in to comment.