├── .gitignore ├── LICENSE ├── README.md ├── content.go ├── events.go ├── example └── main.go ├── go.mod ├── go.sum ├── handler ├── LICENSE ├── handler.go └── slab.go ├── hub.go ├── model ├── hub.go ├── modes.go ├── requests.go └── subscription.go ├── publish.go ├── store ├── bolt │ └── bolt.go ├── database │ ├── database.go │ └── mysql.sql ├── events.go ├── memory │ └── memory.go └── store.go └── worker.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Tyler Stuyfzand 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Go WebSub Server 2 | ================ 3 | 4 | A Go implementation of a [WebSub](https://www.w3.org/TR/websub/) server. It has been tested to pass every [WebSub Rocks! Hub test](https://websub.rocks/hub). 5 | 6 | See `examples/main.go` for a basic example which uses boltdb and a simple publisher. 7 | 8 | Importing: 9 | 10 | ``` 11 | go get meow.tf/websub 12 | ``` 13 | 14 | Features 15 | -------- 16 | 17 | * Acts as a package to allow implementation using your favorite HTTP router (See Hub.ServeHTTP, as well as each Handle method for implementation in other routers that aren't stdlib compliant) 18 | * Allows publishing of events directly from the hub, for use with custom implementations (such as a bridge for services that don't support hubs) 19 | * Supports secrets and sha1, sha256, sha384, sha512 validation 20 | * Supports `Content-Type` forwarding. 21 | * Supports external workers to scale past what a single server can do (See [Workers](#Workers)) 22 | 23 | Stores 24 | ------ 25 | 26 | Specific stores can be implemented/used to store subscriptions and call them. 27 | 28 | If you'd like to implement your own store, the following interface can be implemented: 29 | 30 | ```go 31 | // Store defines an interface for stores to implement for data storage. 32 | type Store interface { 33 | // All returns all subscriptions for the specified topic. 34 | All(topic string) ([]model.Subscription, error) 35 | 36 | // For returns the subscriptions for the specified callback 37 | For(callback string) ([]model.Subscription, error) 38 | 39 | // Add saves/adds a subscription to the store. 40 | Add(sub model.Subscription) error 41 | 42 | // Get retrieves a subscription given a topic and callback. 43 | Get(topic, callback string) (*model.Subscription, error) 44 | 45 | // Remove removes a subscription from the store. 46 | Remove(sub model.Subscription) error 47 | } 48 | ``` 49 | 50 | ### Memory 51 | 52 | A memory-backed store. This store is cleared when the application is restarted. 53 | 54 | ### Bolt 55 | 56 | A [boltdb/bbolt](https://github.com/etcd-io/bbolt) backed store which persists to disk. 57 | 58 | Workers 59 | ------- 60 | 61 | This hub system uses Workers to implement a system that can be infinitely scaled by adding other nodes/servers and workers which can pull off a queue. 62 | 63 | By default, the worker pool is a basic channel + goroutine handler that goes through each request. 64 | 65 | ```go 66 | type Worker interface { 67 | Add(f PublishJob) 68 | Start() 69 | Stop() 70 | } 71 | ``` 72 | 73 | When implementing workers, pay attention to the fields. `ContentType` is used to say what the body content type is (required by the specification), and if subscription.secret is set it MUST be used to generate an `X-Hub-Signature` header. 74 | 75 | Using it with your own Publisher 76 | -------------------------------- 77 | 78 | If you wish to bypass the included `hub.mode=publish` handler, you can use the `Publish` function to publish your own data. 79 | 80 | For example, if you're taking an event off some kind of queue/event subscriber: 81 | 82 | ```go 83 | hub.Publish("https://example.com", "application/json", []byte("{}")) 84 | ``` 85 | -------------------------------------------------------------------------------- /content.go: -------------------------------------------------------------------------------- 1 | package websub 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | var ( 10 | contentClient = &http.Client{ 11 | Timeout: 30 * time.Second, 12 | } 13 | ) 14 | 15 | func HttpContent(topic string) ([]byte, string, error) { 16 | req, err := http.NewRequest(http.MethodGet, topic, nil) 17 | 18 | if err != nil { 19 | return nil, "", err 20 | } 21 | 22 | res, err := contentClient.Do(req) 23 | 24 | if err != nil { 25 | return nil, "", err 26 | } 27 | 28 | defer res.Body.Close() 29 | 30 | data, err := io.ReadAll(res.Body) 31 | 32 | if err != nil { 33 | return nil, "", err 34 | } 35 | 36 | contentType := res.Header.Get("Content-Type") 37 | 38 | if contentType == "" { 39 | contentType = "text/xml" 40 | } 41 | 42 | return data, contentType, nil 43 | } 44 | -------------------------------------------------------------------------------- /events.go: -------------------------------------------------------------------------------- 1 | package websub 2 | 3 | import "meow.tf/websub/model" 4 | 5 | // Verified is an event called when a subscription is successfully verified. 6 | type Verified struct { 7 | Subscription model.Subscription 8 | } 9 | 10 | // VerificationFailed is an event called when a subscription fails to verify. 11 | type VerificationFailed struct { 12 | Subscription model.Subscription 13 | Error error 14 | } 15 | 16 | // Publish is called when items are published 17 | type Publish struct { 18 | Topic string 19 | ContentType string 20 | Data []byte 21 | } 22 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "meow.tf/websub" 6 | "meow.tf/websub/store/bolt" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | ) 11 | 12 | func main() { 13 | store, err := bolt.New("hub.db") 14 | 15 | if err != nil { 16 | log.Fatal(err) 17 | } 18 | 19 | h := websub.New(store) 20 | 21 | r := http.NewServeMux() 22 | 23 | r.HandleFunc("/", h.ServeHTTP) 24 | 25 | log.Println("Starting server on :8080") 26 | 27 | go http.ListenAndServe(":8080", r) 28 | 29 | interrupt := make(chan os.Signal, 1) 30 | 31 | signal.Notify(interrupt, os.Interrupt) 32 | 33 | <-interrupt 34 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module meow.tf/websub 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/go-playground/validator/v10 v10.9.0 7 | github.com/google/uuid v1.1.4 8 | github.com/jpillora/backoff v1.0.0 9 | github.com/mitchellh/mapstructure v1.4.2 10 | github.com/pkg/errors v0.9.1 11 | go.etcd.io/bbolt v1.3.6 12 | ) 13 | 14 | require ( 15 | github.com/go-playground/locales v0.14.0 // indirect 16 | github.com/go-playground/universal-translator v0.18.0 // indirect 17 | github.com/leodido/go-urn v1.2.1 // indirect 18 | golang.org/x/crypto v0.1.0 // indirect 19 | golang.org/x/sys v0.1.0 // indirect 20 | golang.org/x/text v0.4.0 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= 6 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 7 | github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= 8 | github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= 9 | github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= 10 | github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= 11 | github.com/go-playground/validator/v10 v10.9.0 h1:NgTtmN58D0m8+UuxtYmGztBJB7VnPgjj221I1QHci2A= 12 | github.com/go-playground/validator/v10 v10.9.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= 13 | github.com/google/uuid v1.1.4 h1:0ecGp3skIrHWPNGPJDaBIghfA6Sp7Ruo2Io8eLKzWm0= 14 | github.com/google/uuid v1.1.4/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 15 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 16 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 17 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 18 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 19 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 20 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 21 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 22 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 23 | github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= 24 | github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= 25 | github.com/mitchellh/mapstructure v1.4.2 h1:6h7AQ0yhTcIsmFmnAwQls75jp2Gzs4iB8W7pjMO+rqo= 26 | github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 27 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 28 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 29 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 33 | github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= 34 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 35 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 36 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 37 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 38 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 39 | go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= 40 | go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= 41 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 42 | golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 43 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 44 | golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= 45 | golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 46 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 47 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 48 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 49 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 50 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 51 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 52 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 53 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 54 | golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 55 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 56 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= 61 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 63 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 64 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 65 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 66 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 67 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 68 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 69 | golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= 70 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 71 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 72 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 73 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 74 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 75 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 76 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 77 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 78 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 79 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 80 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 81 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 82 | -------------------------------------------------------------------------------- /handler/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 diamondburned 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose 4 | with or without fee is hereby granted, provided that the above copyright notice 5 | and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 9 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 11 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 12 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 13 | THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /handler/handler.go: -------------------------------------------------------------------------------- 1 | // Package handler handles incoming Gateway events. It reflects the function's 2 | // first argument and caches that for use in each event. 3 | // 4 | // This is from Arikawa (https://github.com/diamondburned/arikawa) 5 | // 6 | // Performance 7 | // 8 | // Each call to the event would take 167 ns/op for roughly each handler. Scaling 9 | // that up to 100 handlers is roughly the same as multiplying 167 ns by 100, 10 | // which gives 16700 ns or 0.0167 ms. 11 | // 12 | // BenchmarkReflect-8 7260909 167 ns/op 13 | // 14 | // Usage 15 | // 16 | // Handler's usage is mostly similar to Discordgo, in that AddHandler expects a 17 | // function with only one argument or an event channel. For more information, 18 | // refer to AddHandler. 19 | package handler 20 | 21 | import ( 22 | "context" 23 | "fmt" 24 | "reflect" 25 | "sync" 26 | 27 | "github.com/pkg/errors" 28 | ) 29 | 30 | // Handler is a container for command handlers. A zero-value instance is a valid 31 | // instance. 32 | type Handler struct { 33 | // Synchronous controls whether to spawn each event handler in its own 34 | // goroutine. Default false (meaning goroutines are spawned). 35 | Synchronous bool 36 | 37 | mutex sync.RWMutex 38 | slab slab 39 | } 40 | 41 | func New() *Handler { 42 | return &Handler{} 43 | } 44 | 45 | // Call calls all handlers with the given event. This is an internal method; use 46 | // with care. 47 | func (h *Handler) Call(ev interface{}) { 48 | var evV = reflect.ValueOf(ev) 49 | var evT = evV.Type() 50 | 51 | h.mutex.RLock() 52 | defer h.mutex.RUnlock() 53 | 54 | for _, entry := range h.slab.Entries { 55 | if entry.isInvalid() || entry.not(evT) { 56 | continue 57 | } 58 | 59 | if h.Synchronous { 60 | entry.call(evV) 61 | } else { 62 | go entry.call(evV) 63 | } 64 | } 65 | } 66 | 67 | // WaitFor blocks until there's an event. It's advised to use ChanFor instead, 68 | // as WaitFor may skip some events if it's not ran fast enough after the event 69 | // arrived. 70 | func (h *Handler) WaitFor(ctx context.Context, fn func(interface{}) bool) interface{} { 71 | var result = make(chan interface{}) 72 | 73 | cancel := h.AddHandler(func(v interface{}) { 74 | if fn(v) { 75 | result <- v 76 | } 77 | }) 78 | defer cancel() 79 | 80 | select { 81 | case r := <-result: 82 | return r 83 | case <-ctx.Done(): 84 | return nil 85 | } 86 | } 87 | 88 | // ChanFor returns a channel that would receive all incoming events that match 89 | // the callback given. The cancel() function removes the handler and drops all 90 | // hanging goroutines. 91 | // 92 | // This method is more intended to be used as a filter. For a persistent event 93 | // channel, consider adding it directly as a handler with AddHandler. 94 | func (h *Handler) ChanFor(fn func(interface{}) bool) (out <-chan interface{}, cancel func()) { 95 | result := make(chan interface{}) 96 | closer := make(chan struct{}) 97 | 98 | removeHandler := h.AddHandler(func(v interface{}) { 99 | if fn(v) { 100 | select { 101 | case result <- v: 102 | case <-closer: 103 | } 104 | } 105 | }) 106 | 107 | // Only allow cancel to be called once. 108 | var once sync.Once 109 | cancel = func() { 110 | once.Do(func() { 111 | removeHandler() 112 | close(closer) 113 | }) 114 | } 115 | out = result 116 | 117 | return 118 | } 119 | 120 | // AddHandler adds the handler, returning a function that would remove this 121 | // handler when called. A handler type is either a single-argument no-return 122 | // function or a channel. 123 | // 124 | // Function 125 | // 126 | // A handler can be a function with a single argument that is the expected event 127 | // type. It must not have any returns or any other number of arguments. 128 | // 129 | // // An example of a valid function handler. 130 | // h.AddHandler(func(*gateway.MessageCreateEvent) {}) 131 | // 132 | // Channel 133 | // 134 | // A handler can also be a channel. The underlying type that the channel wraps 135 | // around will be the event type. As such, the type rules are the same as 136 | // function handlers. 137 | // 138 | // Keep in mind that the user must NOT close the channel. In fact, the channel 139 | // should not be closed at all. The caller function WILL PANIC if the channel is 140 | // closed! 141 | // 142 | // When the rm callback that is returned is called, it will also guarantee that 143 | // all blocking sends will be cancelled. This helps prevent dangling goroutines. 144 | // 145 | // // An example of a valid channel handler. 146 | // ch := make(chan *gateway.MessageCreateEvent) 147 | // h.AddHandler(ch) 148 | // 149 | func (h *Handler) AddHandler(handler interface{}) (rm func()) { 150 | rm, err := h.addHandler(handler) 151 | if err != nil { 152 | panic(err) 153 | } 154 | return rm 155 | } 156 | 157 | // AddHandlerCheck adds the handler, but safe-guards reflect panics with a 158 | // recoverer, returning the error. Refer to AddHandler for more information. 159 | func (h *Handler) AddHandlerCheck(handler interface{}) (rm func(), err error) { 160 | // Reflect would actually panic if anything goes wrong, so this is just in 161 | // case. 162 | defer func() { 163 | if rec := recover(); rec != nil { 164 | if recErr, ok := rec.(error); ok { 165 | err = recErr 166 | } else { 167 | err = fmt.Errorf("%v", rec) 168 | } 169 | } 170 | }() 171 | 172 | return h.addHandler(handler) 173 | } 174 | 175 | func (h *Handler) addHandler(fn interface{}) (rm func(), err error) { 176 | // Reflect the handler 177 | r, err := newHandler(fn) 178 | 179 | if err != nil { 180 | return nil, errors.Wrap(err, "handler reflect failed") 181 | } 182 | 183 | h.mutex.Lock() 184 | id := h.slab.Put(r) 185 | h.mutex.Unlock() 186 | 187 | return func() { 188 | h.mutex.Lock() 189 | popped := h.slab.Pop(id) 190 | h.mutex.Unlock() 191 | 192 | popped.cleanup() 193 | }, nil 194 | } 195 | 196 | type handler struct { 197 | event reflect.Type // underlying type; arg0 or chan underlying type 198 | callback reflect.Value 199 | isIface bool 200 | chanclose reflect.Value // IsValid() if chan 201 | } 202 | 203 | // newHandler reflects either a channel or a function into a handler. A function 204 | // must only have a single argument being the event and no return, and a channel 205 | // must have the event type as the underlying type. 206 | func newHandler(unknown interface{}) (handler, error) { 207 | fnV := reflect.ValueOf(unknown) 208 | fnT := fnV.Type() 209 | 210 | // underlying event type 211 | var handler = handler{ 212 | callback: fnV, 213 | } 214 | 215 | switch fnT.Kind() { 216 | case reflect.Func: 217 | if fnT.NumIn() != 1 { 218 | return handler, errors.New("function can only accept 1 event as argument") 219 | } 220 | 221 | if fnT.NumOut() > 0 { 222 | return handler, errors.New("function can't accept returns") 223 | } 224 | 225 | handler.event = fnT.In(0) 226 | 227 | case reflect.Chan: 228 | handler.event = fnT.Elem() 229 | handler.chanclose = reflect.ValueOf(make(chan struct{})) 230 | 231 | default: 232 | return handler, errors.New("given interface is not a function or channel") 233 | } 234 | 235 | var kind = handler.event.Kind() 236 | 237 | // Accept either pointer type or interface{} type 238 | if kind != reflect.Ptr && kind != reflect.Interface { 239 | return handler, errors.New("first argument is not pointer") 240 | } 241 | 242 | handler.isIface = kind == reflect.Interface 243 | 244 | return handler, nil 245 | } 246 | 247 | func (h handler) not(event reflect.Type) bool { 248 | if h.isIface { 249 | return !event.Implements(h.event) 250 | } 251 | 252 | return h.event != event 253 | } 254 | 255 | func (h handler) call(event reflect.Value) { 256 | if h.chanclose.IsValid() { 257 | reflect.Select([]reflect.SelectCase{ 258 | {Dir: reflect.SelectSend, Chan: h.callback, Send: event}, 259 | {Dir: reflect.SelectRecv, Chan: h.chanclose}, 260 | }) 261 | } else { 262 | h.callback.Call([]reflect.Value{event}) 263 | } 264 | } 265 | 266 | func (h handler) cleanup() { 267 | if h.chanclose.IsValid() { 268 | // Closing this channel will force all ongoing selects to return 269 | // immediately. 270 | h.chanclose.Close() 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /handler/slab.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | type slabEntry struct { 4 | handler 5 | index int 6 | } 7 | 8 | func (entry slabEntry) isInvalid() bool { 9 | return entry.index != -1 10 | } 11 | 12 | // slab is an implementation of the internal handler free list. 13 | type slab struct { 14 | Entries []slabEntry 15 | free int 16 | } 17 | 18 | func (s *slab) Put(entry handler) int { 19 | if s.free == len(s.Entries) { 20 | index := len(s.Entries) 21 | s.Entries = append(s.Entries, slabEntry{entry, -1}) 22 | s.free++ 23 | return index 24 | } 25 | 26 | next := s.Entries[s.free].index 27 | s.Entries[s.free] = slabEntry{entry, -1} 28 | 29 | i := s.free 30 | s.free = next 31 | 32 | return i 33 | } 34 | 35 | func (s *slab) Get(i int) handler { 36 | return s.Entries[i].handler 37 | } 38 | 39 | func (s *slab) Pop(i int) handler { 40 | popped := s.Entries[i].handler 41 | s.Entries[i] = slabEntry{handler{}, s.free} 42 | s.free = i 43 | return popped 44 | } 45 | -------------------------------------------------------------------------------- /hub.go: -------------------------------------------------------------------------------- 1 | package websub 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/sha1" 7 | "crypto/sha256" 8 | "crypto/sha512" 9 | "encoding/hex" 10 | "errors" 11 | "fmt" 12 | "github.com/go-playground/validator/v10" 13 | "github.com/google/uuid" 14 | "github.com/jpillora/backoff" 15 | "github.com/mitchellh/mapstructure" 16 | "hash" 17 | "io" 18 | "log" 19 | "meow.tf/websub/handler" 20 | "meow.tf/websub/model" 21 | "meow.tf/websub/store" 22 | "net/http" 23 | "net/url" 24 | "reflect" 25 | "runtime" 26 | "strconv" 27 | "strings" 28 | "time" 29 | ) 30 | 31 | // Validator is a function to validate a subscription request. 32 | // If error is not nil, hub.mode=verify will be called with the error. 33 | type Validator func(model.Subscription) error 34 | 35 | // ContentProvider is a function to extract content out of the specific content topic. 36 | type ContentProvider func(topic string) ([]byte, string, error) 37 | 38 | // Option represents a Hub option. 39 | type Option func(h *Hub) 40 | 41 | // Hub represents a WebSub hub. 42 | type Hub struct { 43 | *handler.Handler 44 | 45 | client *http.Client 46 | store store.Store 47 | validator Validator 48 | contentProvider ContentProvider 49 | worker Worker 50 | hasher string 51 | url string 52 | maxLease time.Duration 53 | } 54 | 55 | var ( 56 | v = validator.New() 57 | ) 58 | 59 | // WithValidator sets the subscription validator. 60 | func WithValidator(validator Validator) Option { 61 | return func(h *Hub) { 62 | h.validator = validator 63 | } 64 | } 65 | 66 | // WithContentProvider sets the content provider for external hub.mode=publish requests. 67 | func WithContentProvider(provider ContentProvider) Option { 68 | return func(h *Hub) { 69 | h.contentProvider = provider 70 | } 71 | } 72 | 73 | // WithHasher lets you set other hmac hashers/types (like sha256, sha384, sha512, etc) 74 | func WithHasher(hasher string) Option { 75 | return func(h *Hub) { 76 | h.hasher = hasher 77 | } 78 | } 79 | 80 | // WithWorker lets you set the worker used to distribute subscription responses. 81 | // This can be done with any number of systems, such as Amazon SQS, Beanstalk, etc. 82 | func WithWorker(worker Worker) Option { 83 | return func(h *Hub) { 84 | h.worker = worker 85 | } 86 | } 87 | 88 | // WithURL lets you set the hub url. 89 | // By default, this is auto detected on first request for ease of use. 90 | func WithURL(url string) Option { 91 | return func(h *Hub) { 92 | h.url = url 93 | } 94 | } 95 | 96 | // WithMaxLease lets you set the hub's max lease time. 97 | // By default, this is 24 hours. 98 | func WithMaxLease(maxLease time.Duration) Option { 99 | return func(h *Hub) { 100 | h.maxLease = maxLease 101 | } 102 | } 103 | 104 | // New creates a new WebSub Hub instance. 105 | // store is required to store all of the subscriptions. 106 | func New(store store.Store, opts ...Option) *Hub { 107 | h := &Hub{ 108 | Handler: handler.New(), 109 | client: &http.Client{ 110 | Timeout: 30 * time.Second, 111 | }, 112 | store: store, 113 | contentProvider: HttpContent, 114 | hasher: "sha256", 115 | maxLease: 24 * time.Hour, 116 | } 117 | 118 | for _, opt := range opts { 119 | opt(h) 120 | } 121 | 122 | if h.worker == nil { 123 | h.worker = NewGoWorker(h, runtime.NumCPU()) 124 | h.worker.Start() 125 | } 126 | 127 | return h 128 | } 129 | 130 | // ServeHTTP is a generic webserver handler for websub. 131 | // It takes in "hub.mode" from the form, and passes it to the appropriate handlers. 132 | func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { 133 | if err := r.ParseForm(); err != nil { 134 | http.Error(w, err.Error(), http.StatusBadRequest) 135 | return 136 | } 137 | 138 | hubMode := r.FormValue("hub.mode") 139 | 140 | if hubMode == "" { 141 | http.Error(w, "missing hub.mode parameter", http.StatusBadRequest) 142 | return 143 | } 144 | 145 | // If url is not set, set to something we can "guess" 146 | if h.url == "" { 147 | proto := "http" 148 | 149 | // Usually X-Forwarded cannot be trusted, but in this case it's the first request that defines it. 150 | // For our case, this simply sets the hub url via "auto detection". 151 | // it is STRONGLY advised to set the url using WithURL beforehand. 152 | if r.Header.Get("X-Forwarded-Proto") == "https" { 153 | proto = r.Header.Get("X-Forwarded-Proto") 154 | } 155 | 156 | u := &url.URL{ 157 | Scheme: proto, 158 | Host: r.Host, 159 | Path: r.RequestURI, 160 | } 161 | 162 | h.url = strings.TrimRight(u.String(), "/") 163 | } 164 | 165 | switch hubMode { 166 | case model.ModeSubscribe: 167 | var req model.SubscribeRequest 168 | 169 | if err := DecodeForm(r, &req); err != nil { 170 | http.Error(w, err.Error(), http.StatusBadRequest) 171 | return 172 | } 173 | 174 | err := h.HandleSubscribe(req) 175 | 176 | if err != nil { 177 | http.Error(w, err.Error(), http.StatusBadRequest) 178 | return 179 | } 180 | 181 | w.WriteHeader(http.StatusAccepted) 182 | case model.ModeUnsubscribe: 183 | var req model.UnsubscribeRequest 184 | 185 | if err := DecodeForm(r, &req); err != nil { 186 | http.Error(w, err.Error(), http.StatusBadRequest) 187 | return 188 | } 189 | 190 | err := h.HandleUnsubscribe(req) 191 | 192 | if err != nil { 193 | http.Error(w, err.Error(), http.StatusBadRequest) 194 | return 195 | } 196 | 197 | w.WriteHeader(http.StatusAccepted) 198 | case model.ModePublish: 199 | var req model.PublishRequest 200 | 201 | if err := DecodeForm(r, &req); err != nil { 202 | http.Error(w, err.Error(), http.StatusBadRequest) 203 | return 204 | } 205 | 206 | err := h.HandlePublish(req) 207 | 208 | if err != nil { 209 | http.Error(w, err.Error(), http.StatusBadRequest) 210 | return 211 | } 212 | 213 | w.WriteHeader(http.StatusAccepted) 214 | default: 215 | http.Error(w, "hub.mode not recognized", http.StatusBadRequest) 216 | } 217 | } 218 | 219 | // HandleSubscribe handles a hub.mode=subscribe request. 220 | func (h *Hub) HandleSubscribe(req model.SubscribeRequest) error { 221 | // validate for required fields 222 | if err := v.Struct(req); err != nil { 223 | return err 224 | } 225 | 226 | // Default lease 227 | leaseDuration := 240 * time.Hour 228 | 229 | if req.LeaseSeconds > 0 { 230 | if req.LeaseSeconds < 60 || time.Duration(req.LeaseSeconds)*time.Second > h.maxLease { 231 | return errors.New("invalid hub.lease_seconds value") 232 | } else { 233 | leaseDuration = time.Duration(req.LeaseSeconds) * time.Second 234 | } 235 | } 236 | 237 | sub := model.Subscription{ 238 | Topic: req.Topic, 239 | Callback: req.Callback, 240 | Secret: req.Secret, 241 | Expires: time.Now().Add(leaseDuration), 242 | } 243 | 244 | if h.validator != nil { 245 | err := h.validator(sub) 246 | 247 | if err != nil { 248 | sub.Reason = err 249 | 250 | return h.Verify(model.ModeDenied, sub) 251 | } 252 | } 253 | 254 | existingSub, err := h.store.Get(req.Topic, req.Callback) 255 | 256 | if existingSub != nil && err == nil { 257 | // Update existingSub instead. 258 | // TODO: Can Secret be updated? 259 | sub = *existingSub 260 | sub.Expires = time.Now().Add(leaseDuration) 261 | } 262 | 263 | go func(hubMode string, sub model.Subscription) { 264 | err := h.Verify(hubMode, sub) 265 | 266 | if err != nil { 267 | h.Call(&VerificationFailed{ 268 | Subscription: sub, 269 | Error: err, 270 | }) 271 | } else { 272 | h.Call(&Verified{ 273 | Subscription: sub, 274 | }) 275 | } 276 | }(req.Mode, sub) 277 | 278 | return nil 279 | } 280 | 281 | // HandleUnsubscribe handles a hub.mode=unsubscribe 282 | func (h *Hub) HandleUnsubscribe(req model.UnsubscribeRequest) error { 283 | // validate for required fields 284 | if err := v.Struct(req); err != nil { 285 | return err 286 | } 287 | 288 | sub := model.Subscription{ 289 | Topic: req.Topic, 290 | Callback: req.Callback, 291 | } 292 | 293 | if h.validator != nil { 294 | err := h.validator(sub) 295 | 296 | if err != nil { 297 | sub.Reason = err 298 | 299 | return h.Verify(model.ModeDenied, sub) 300 | } 301 | } 302 | 303 | go func(hubMode string, sub model.Subscription) { 304 | err := h.Verify(hubMode, sub) 305 | 306 | if err != nil { 307 | log.Println("Error:", err) 308 | } 309 | }(req.Mode, sub) 310 | 311 | return nil 312 | } 313 | 314 | // Verify sends a response to a subscription model with the specified data. 315 | // If the subscription failed, Reason can be set to send hub.reason in the callback. 316 | func (h *Hub) Verify(mode string, sub model.Subscription) error { 317 | u, err := url.Parse(sub.Callback) 318 | 319 | if err != nil { 320 | return err 321 | } 322 | 323 | challenge := uuid.New().String() 324 | 325 | q := u.Query() 326 | q.Set("hub.mode", mode) 327 | q.Set("hub.topic", sub.Topic) 328 | 329 | if mode != model.ModeDenied { 330 | q.Set("hub.challenge", challenge) 331 | q.Set("hub.lease_seconds", strconv.Itoa(int(sub.LeaseTime/time.Second))) 332 | } else if sub.Reason != nil { 333 | q.Set("hub.reason", sub.Reason.Error()) 334 | } 335 | 336 | u.RawQuery = q.Encode() 337 | 338 | req, err := http.NewRequest("GET", u.String(), nil) 339 | 340 | if err != nil { 341 | return err 342 | } 343 | 344 | req.Header.Set("User-Agent", "Go WebSub 1.0 ("+runtime.Version()+")") 345 | 346 | res, err := h.client.Do(req) 347 | 348 | if err != nil { 349 | return err 350 | } 351 | 352 | if res.StatusCode != 200 { 353 | // Uh oh! 354 | return errors.New("unexpected status code") 355 | } 356 | 357 | defer res.Body.Close() 358 | 359 | if mode == model.ModeDenied { 360 | io.Copy(io.Discard, res.Body) 361 | return nil 362 | } 363 | 364 | // Read max of challenge size bytes 365 | data := make([]byte, len(challenge)) 366 | 367 | read, err := io.ReadFull(res.Body, data) 368 | 369 | if err != nil && err != io.ErrUnexpectedEOF { 370 | return err 371 | } 372 | 373 | data = data[0:read] 374 | 375 | if string(data) != challenge { 376 | // Nope. 377 | return errors.New(fmt.Sprint("verification: challenge did not match for "+u.Host+", expected: ", challenge, " actual: ", string(data))) 378 | } 379 | 380 | if mode == model.ModeSubscribe { 381 | // Update the subscription and set it as verified 382 | // time.Now().Add(time.Duration(leaseSeconds) * time.Second), topic, callback 383 | err = h.store.Add(sub) 384 | } else if mode == model.ModeUnsubscribe { 385 | // Delete the subscription 386 | err = h.store.Remove(sub) 387 | } 388 | 389 | return err 390 | } 391 | 392 | // HandlePublish handles a request to publish from a publisher. 393 | func (h *Hub) HandlePublish(req model.PublishRequest) error { 394 | if err := v.Struct(req); err != nil { 395 | return err 396 | } 397 | 398 | data, contentType, err := h.contentProvider(req.Topic) 399 | 400 | if err != nil { 401 | return err 402 | } 403 | 404 | return h.Publish(req.Topic, contentType, data) 405 | } 406 | 407 | // Publish queues responses to the worker for a publish. 408 | func (h *Hub) Publish(topic, contentType string, data []byte) error { 409 | subs, err := h.store.All(topic) 410 | 411 | if err != nil { 412 | return err 413 | } 414 | 415 | h.Call(&Publish{ 416 | Topic: topic, 417 | ContentType: contentType, 418 | Data: data, 419 | }) 420 | 421 | hub := model.Hub{ 422 | Hasher: h.hasher, 423 | URL: h.url, 424 | } 425 | 426 | for _, sub := range subs { 427 | h.worker.Add(PublishJob{ 428 | Hub: hub, 429 | Subscription: sub, 430 | ContentType: contentType, 431 | Data: data, 432 | }) 433 | } 434 | 435 | return nil 436 | } 437 | 438 | // callCallback sends a request to the specified URL with the publish data. 439 | func (h *Hub) callCallback(job PublishJob) bool { 440 | req, err := http.NewRequest("POST", job.Subscription.Callback, bytes.NewReader(job.Data)) 441 | 442 | if err != nil { 443 | return false 444 | } 445 | 446 | if job.Subscription.Secret != "" { 447 | mac := hmac.New(NewHasher(h.hasher), []byte(job.Subscription.Secret)) 448 | mac.Write(job.Data) 449 | req.Header.Set("X-Hub-Signature", h.hasher+"="+hex.EncodeToString(mac.Sum(nil))) 450 | } 451 | 452 | req.Header.Set("Content-Type", job.ContentType) 453 | req.Header.Set("Link", fmt.Sprintf("<%s>; rel=\"hub\", <%s>; rel=\"self\"", h.url, job.Subscription.Topic)) 454 | 455 | b := &backoff.Backoff{ 456 | Min: 100 * time.Millisecond, 457 | Max: 10 * time.Minute, 458 | Factor: 2, 459 | Jitter: false, 460 | } 461 | 462 | var attempts int 463 | 464 | for { 465 | res, err := h.client.Do(req) 466 | 467 | if err == nil { 468 | res.Body.Close() 469 | 470 | if res.StatusCode >= 200 && res.StatusCode <= 299 { 471 | return true 472 | } else if res.StatusCode == http.StatusGone { 473 | h.store.Remove(job.Subscription) 474 | return false 475 | } 476 | } 477 | 478 | attempts++ 479 | 480 | if attempts >= 3 { 481 | break 482 | } 483 | 484 | <-time.After(b.Duration()) 485 | } 486 | 487 | return false 488 | } 489 | 490 | // NewHasher takes a string and returns a hash.Hash based on type. 491 | func NewHasher(hasher string) func() hash.Hash { 492 | switch hasher { 493 | case "sha1": 494 | return sha1.New 495 | case "sha256": 496 | return sha256.New 497 | case "sha384": 498 | return sha512.New384 499 | case "sha512": 500 | return sha512.New 501 | } 502 | 503 | panic("Invalid hasher type supplied") 504 | } 505 | 506 | // DecodeForm decodes a request form into a struct using the mapstructure package. 507 | func DecodeForm(r *http.Request, dest interface{}) error { 508 | decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ 509 | TagName: "form", 510 | Result: dest, 511 | // This hook is a trick to allow us to map from []string -> string in the case of elements. 512 | // This is only required because we're mapping from r.Form -> struct. 513 | DecodeHook: func(from reflect.Kind, to reflect.Kind, v interface{}) (interface{}, error) { 514 | if from == reflect.Slice && (to == reflect.String || to == reflect.Int) { 515 | switch s := v.(type) { 516 | case []string: 517 | if len(s) < 1 { 518 | return "", nil 519 | } 520 | 521 | // Switch statement seems wasteful here, but if we want to add uint/etc we can easily. 522 | switch to { 523 | case reflect.Int: 524 | return strconv.Atoi(s[0]) 525 | } 526 | 527 | return s[0], nil 528 | } 529 | 530 | return v, nil 531 | } 532 | 533 | return v, nil 534 | }, 535 | }) 536 | 537 | if err != nil { 538 | return err 539 | } 540 | 541 | return decoder.Decode(r.Form) 542 | } 543 | -------------------------------------------------------------------------------- /model/hub.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Hub struct { 4 | Hasher string `json:"hasher"` 5 | URL string `json:"url"` 6 | } 7 | -------------------------------------------------------------------------------- /model/modes.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | const ( 4 | ModeSubscribe = "subscribe" 5 | ModeUnsubscribe = "unsubscribe" 6 | ModeDenied = "denied" 7 | ModePublish = "publish" 8 | ) 9 | -------------------------------------------------------------------------------- /model/requests.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // SubscribeRequest represents a form request for a subscribe. 4 | type SubscribeRequest struct { 5 | Mode string `form:"hub.mode" validate:"required"` 6 | Callback string `form:"hub.callback" validate:"required"` 7 | Topic string `form:"hub.topic" validate:"required"` 8 | Secret string `form:"hub.secret" validate:"max=200"` 9 | LeaseSeconds int `form:"hub.lease_seconds" validate:""` 10 | } 11 | 12 | // UnsubscribeRequest represents a form request for an unsubscribe. 13 | type UnsubscribeRequest struct { 14 | Mode string `form:"hub.mode" validate:"required"` 15 | Callback string `form:"hub.callback" validate:"required,url"` 16 | Topic string `form:"hub.topic" validate:"required"` 17 | } 18 | 19 | // PublishRequest represents a form request for a publish. 20 | type PublishRequest struct { 21 | Topic string `form:"hub.topic" validation:"required"` 22 | } 23 | -------------------------------------------------------------------------------- /model/subscription.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | type Subscription struct { 6 | ID int64 `json:"id"` 7 | Topic string `json:"topic"` 8 | Callback string `json:"callback"` 9 | Secret string `json:"secret"` 10 | LeaseTime time.Duration `json:"lease"` 11 | Expires time.Time `json:"expires"` 12 | Reason error `json:"-"` 13 | } 14 | -------------------------------------------------------------------------------- /publish.go: -------------------------------------------------------------------------------- 1 | package websub 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | "github.com/jpillora/backoff" 10 | "net/http" 11 | "time" 12 | ) 13 | 14 | func Notify(client *http.Client, job PublishJob) (bool, error) { 15 | req, err := http.NewRequest(http.MethodPost, job.Subscription.Callback, bytes.NewReader(job.Data)) 16 | 17 | if err != nil { 18 | return false, err 19 | } 20 | 21 | if job.Subscription.Secret != "" { 22 | mac := hmac.New(NewHasher(job.Hub.Hasher), []byte(job.Subscription.Secret)) 23 | mac.Write(job.Data) 24 | req.Header.Set("X-Hub-Signature", job.Hub.Hasher+"="+hex.EncodeToString(mac.Sum(nil))) 25 | } 26 | 27 | req.Header.Set("Content-Type", job.ContentType) 28 | req.Header.Set("Link", fmt.Sprintf("<%s>; rel=\"hub\", <%s>; rel=\"self\"", job.Hub.URL, job.Subscription.Topic)) 29 | 30 | b := &backoff.Backoff{ 31 | Min: 100 * time.Millisecond, 32 | Max: 10 * time.Minute, 33 | Factor: 2, 34 | Jitter: false, 35 | } 36 | 37 | var attempts int 38 | 39 | for { 40 | res, err := client.Do(req) 41 | 42 | if err == nil { 43 | res.Body.Close() 44 | 45 | if res.StatusCode >= 200 && res.StatusCode <= 299 { 46 | return true, nil 47 | } else if res.StatusCode == http.StatusGone { 48 | return false, nil 49 | } 50 | } 51 | 52 | attempts++ 53 | 54 | if attempts >= 3 { 55 | break 56 | } 57 | 58 | <-time.After(b.Duration()) 59 | } 60 | 61 | return false, errors.New("failed to publish after 3 attempts") 62 | } 63 | -------------------------------------------------------------------------------- /store/bolt/bolt.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | "encoding/json" 5 | bolt "go.etcd.io/bbolt" 6 | "meow.tf/websub/handler" 7 | "meow.tf/websub/model" 8 | "meow.tf/websub/store" 9 | "time" 10 | ) 11 | 12 | // New creates a new boltdb store. 13 | // Bolt is fine for low throughput applications, though a full database should be used for performance. 14 | func New(file string) (*Store, error) { 15 | db, err := bolt.Open(file, 0600, nil) 16 | 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | s := &Store{ 22 | Handler: handler.New(), 23 | db: db, 24 | } 25 | 26 | go func() { 27 | t := time.NewTicker(60 * time.Second) 28 | for { 29 | <-t.C 30 | 31 | s.Cleanup() 32 | } 33 | }() 34 | 35 | return s, nil 36 | } 37 | 38 | // Store represents a boltdb backed store. 39 | type Store struct { 40 | *handler.Handler 41 | db *bolt.DB 42 | } 43 | 44 | // Cleanup will loop all buckets and keys, expiring subscriptions that are old. 45 | func (s *Store) Cleanup() { 46 | now := time.Now() 47 | 48 | s.db.Update(func(tx *bolt.Tx) error { 49 | return tx.ForEach(func(topic []byte, b *bolt.Bucket) error { 50 | return b.ForEach(func(k, v []byte) error { 51 | var s model.Subscription 52 | err := json.Unmarshal(v, &s) 53 | 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if s.Expires.Before(now) { 59 | return b.Delete(k) 60 | } 61 | 62 | return nil 63 | }) 64 | }) 65 | }) 66 | } 67 | 68 | // All retrieves all active subscriptions for a topic. 69 | func (s *Store) All(topic string) ([]model.Subscription, error) { 70 | subscriptions := make([]model.Subscription, 0) 71 | 72 | now := time.Now() 73 | 74 | err := s.db.View(func(tx *bolt.Tx) error { 75 | b := tx.Bucket([]byte(topic)) 76 | 77 | if b == nil { 78 | return store.ErrNotFound 79 | } 80 | 81 | return b.ForEach(func(k, v []byte) error { 82 | var s model.Subscription 83 | 84 | err := json.Unmarshal(v, &s) 85 | 86 | if err != nil { 87 | return err 88 | } 89 | 90 | if now.Before(s.Expires) { 91 | subscriptions = append(subscriptions, s) 92 | } 93 | 94 | return err 95 | }) 96 | }) 97 | 98 | return subscriptions, err 99 | } 100 | 101 | // For returns the subscriptions for the specified callback 102 | func (s *Store) For(callback string) ([]model.Subscription, error) { 103 | ret := make([]model.Subscription, 0) 104 | 105 | err := s.db.Update(func(tx *bolt.Tx) error { 106 | return tx.ForEach(func(topic []byte, b *bolt.Bucket) error { 107 | return b.ForEach(func(k, v []byte) error { 108 | var s model.Subscription 109 | err := json.Unmarshal(v, &s) 110 | 111 | if err != nil { 112 | return err 113 | } 114 | 115 | if s.Callback != callback { 116 | return nil 117 | } 118 | 119 | ret = append(ret, s) 120 | 121 | return nil 122 | }) 123 | }) 124 | }) 125 | 126 | return ret, err 127 | } 128 | 129 | // Add stores a subscription in the bucket for the specified topic. 130 | func (s *Store) Add(sub model.Subscription) error { 131 | err := s.db.Update(func(tx *bolt.Tx) error { 132 | b, err := tx.CreateBucketIfNotExists([]byte(sub.Topic)) 133 | 134 | if err != nil { 135 | return err 136 | } 137 | 138 | jsonB, err := json.Marshal(sub) 139 | 140 | if err != nil { 141 | return err 142 | } 143 | 144 | return b.Put([]byte(sub.Callback), jsonB) 145 | }) 146 | 147 | if err == nil { 148 | s.Call(&store.Added{ 149 | Subscription: sub, 150 | }) 151 | } 152 | 153 | return err 154 | } 155 | 156 | // Get retrieves a subscription for the specified topic and callback. 157 | func (s *Store) Get(topic, callback string) (*model.Subscription, error) { 158 | var sub *model.Subscription 159 | 160 | err := s.db.View(func(tx *bolt.Tx) error { 161 | b := tx.Bucket([]byte(topic)) 162 | 163 | if b == nil { 164 | return nil 165 | } 166 | 167 | data := b.Get([]byte(callback)) 168 | 169 | if data == nil { 170 | return store.ErrNotFound 171 | } 172 | 173 | return json.Unmarshal(data, &sub) 174 | }) 175 | 176 | return sub, err 177 | } 178 | 179 | // Remove removes a subscription from the bucket for the specified topic. 180 | func (s *Store) Remove(sub model.Subscription) error { 181 | err := s.db.Update(func(tx *bolt.Tx) error { 182 | b := tx.Bucket([]byte(sub.Topic)) 183 | 184 | if b == nil { 185 | return store.ErrNotFound 186 | } 187 | 188 | return b.Delete([]byte(sub.Callback)) 189 | }) 190 | 191 | if err == nil { 192 | s.Call(&store.Removed{ 193 | Subscription: sub, 194 | }) 195 | } 196 | 197 | return err 198 | } 199 | -------------------------------------------------------------------------------- /store/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "meow.tf/websub/handler" 6 | "meow.tf/websub/model" 7 | "meow.tf/websub/store" 8 | "time" 9 | ) 10 | 11 | // New creates a new database store. 12 | func New(db *sql.DB) *Store { 13 | s := &Store{ 14 | Handler: handler.New(), 15 | db: db, 16 | } 17 | 18 | go func() { 19 | t := time.NewTicker(60 * time.Second) 20 | for { 21 | <-t.C 22 | 23 | s.Cleanup() 24 | } 25 | }() 26 | 27 | return s 28 | } 29 | 30 | // Store represents a database backed store. 31 | type Store struct { 32 | *handler.Handler 33 | db *sql.DB 34 | } 35 | 36 | // Cleanup will run a query to remove expired subscriptions, as well as clean up topics. 37 | func (s *Store) Cleanup() { 38 | // Cleanup expired subscriptions which were not renewed 39 | _, err := s.db.Exec("DELETE FROM subscriptions WHERE expires_at <= NOW()") 40 | 41 | if err != nil { 42 | return 43 | } 44 | 45 | // Cleanup topics with no subscriptions 46 | _, err = s.db.Exec("DELETE FROM topics WHERE not exists (select 1 from subscriptions where subscriptions.topic_id = topics.id)") 47 | 48 | if err != nil { 49 | return 50 | } 51 | } 52 | 53 | // All retrieves all active subscriptions for a topic. 54 | func (s *Store) All(topic string) ([]model.Subscription, error) { 55 | topicRow := s.db.QueryRow("SELECT id FROM topics WHERE topic = ?", topic) 56 | 57 | var topicID int64 58 | 59 | if err := topicRow.Scan(&topicID); err != nil { 60 | return nil, store.ErrNotFound 61 | } 62 | 63 | rows, err := s.db.Query("SELECT id, callback, secret, lease, expires_at FROM subscriptions WHERE topic_id = ?", topicID) 64 | 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | defer rows.Close() 70 | 71 | subscriptions := make([]model.Subscription, 0) 72 | 73 | for rows.Next() { 74 | sub := model.Subscription{ 75 | Topic: topic, 76 | } 77 | 78 | var leaseSeconds int 79 | 80 | if err := rows.Scan(&sub.ID, &sub.Callback, &sub.Secret, &leaseSeconds, &sub.Expires); err != nil { 81 | return nil, err 82 | } 83 | 84 | sub.LeaseTime = time.Duration(leaseSeconds) * time.Second 85 | 86 | subscriptions = append(subscriptions, sub) 87 | } 88 | 89 | return subscriptions, nil 90 | } 91 | 92 | // For returns the subscriptions for the specified callback 93 | func (s *Store) For(callback string) ([]model.Subscription, error) { 94 | rows, err := s.db.Query("SELECT subscriptions.id, topics.topic, callback, secret, lease, expires_at FROM subscriptions JOIN topics ON topics.id = subscriptions.topic_id WHERE callback = ?", callback) 95 | 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | defer rows.Close() 101 | 102 | ret := make([]model.Subscription, 0) 103 | 104 | for rows.Next() { 105 | var sub model.Subscription 106 | 107 | var leaseSeconds int 108 | 109 | if err := rows.Scan(&sub.ID, &sub.Topic, &sub.Callback, &sub.Secret, &leaseSeconds, &sub.Expires); err != nil { 110 | return nil, err 111 | } 112 | 113 | sub.LeaseTime = time.Duration(leaseSeconds) * time.Second 114 | 115 | ret = append(ret, sub) 116 | } 117 | 118 | return ret, nil 119 | } 120 | 121 | // findTopic will find an existing topic and return the id. 122 | func (s *Store) findTopic(topic string) (int64, error) { 123 | topicRow := s.db.QueryRow("SELECT id FROM topics WHERE topic = ?", topic) 124 | 125 | var topicID int64 126 | 127 | if err := topicRow.Scan(&topicID); err != nil { 128 | if err == sql.ErrNoRows { 129 | return -1, store.ErrNotFound 130 | } 131 | 132 | return -1, err 133 | } 134 | 135 | return topicID, nil 136 | } 137 | 138 | // findOrCreateTopic will find an existing topic, or create a new topic and return the id. 139 | func (s *Store) findOrCreateTopic(topic string) (int64, error) { 140 | topicID, err := s.findTopic(topic) 141 | 142 | if err == nil { 143 | return topicID, nil 144 | } 145 | 146 | topicRes, err := s.db.Exec("INSERT INTO topics (`topic`) VALUES (?)", topic) 147 | 148 | if err != nil { 149 | return -1, err 150 | } 151 | 152 | topicID, err = topicRes.LastInsertId() 153 | 154 | if err != nil { 155 | return -1, err 156 | } 157 | 158 | return topicID, nil 159 | } 160 | 161 | // Add stores a subscription in the bucket for the specified topic. 162 | func (s *Store) Add(sub model.Subscription) error { 163 | topicID, err := s.findOrCreateTopic(sub.Topic) 164 | 165 | if err != nil { 166 | return err 167 | } 168 | 169 | res, err := s.db.Exec("INSERT INTO subscriptions(`topic_id`, `callback`, `secret`, `lease`, `expires_at`) VALUES (?, ?, ?, ?, ?)", 170 | topicID, sub.Callback, sub.Secret, sub.LeaseTime/time.Second, sub.Expires) 171 | 172 | if err != nil { 173 | return err 174 | } 175 | 176 | sub.ID, err = res.LastInsertId() 177 | 178 | if err != nil { 179 | return err 180 | } 181 | 182 | s.Call(&store.Added{Subscription: sub}) 183 | return nil 184 | } 185 | 186 | // Get retrieves a subscription for the specified topic and callback. 187 | func (s *Store) Get(topic, callback string) (*model.Subscription, error) { 188 | topicID, err := s.findTopic(topic) 189 | 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | row := s.db.QueryRow("SELECT id, callback, secret, lease, expires_at FROM subscriptions WHERE topic_id = ? AND callback = ?", topicID, callback) 195 | 196 | sub := model.Subscription{ 197 | Topic: topic, 198 | } 199 | 200 | var leaseSeconds int 201 | 202 | if err := row.Scan(&sub.ID, &sub.Callback, &sub.Secret, &leaseSeconds, &sub.Expires); err != nil { 203 | return nil, err 204 | } 205 | 206 | sub.LeaseTime = time.Duration(leaseSeconds) * time.Second 207 | 208 | return &sub, nil 209 | } 210 | 211 | // Remove removes a subscription from the database for the specified topic and callback. 212 | func (s *Store) Remove(sub model.Subscription) error { 213 | if sub.ID > 0 { 214 | _, err := s.db.Exec("DELETE FROM subscriptions WHERE id = ?", sub.ID) 215 | 216 | return err 217 | } 218 | 219 | topicID, err := s.findTopic(sub.Topic) 220 | 221 | if err != nil { 222 | return err 223 | } 224 | 225 | _, err = s.db.Exec("DELETE FROM subscriptions WHERE topic_id = ? AND callback = ?", topicID, sub.Callback) 226 | 227 | if err != nil { 228 | return err 229 | } 230 | 231 | s.Call(&store.Removed{Subscription: sub}) 232 | return nil 233 | } 234 | -------------------------------------------------------------------------------- /store/database/mysql.sql: -------------------------------------------------------------------------------- 1 | create table subscriptions 2 | ( 3 | id bigint unsigned auto_increment null, 4 | topic_id bigint unsigned not null, 5 | callback varchar(1024) not null, 6 | secret varchar(200) null, 7 | lease int default 0 null, 8 | created_at timestamp default current_timestamp() not null, 9 | expires_at timestamp null, 10 | constraint subscriptions_pk 11 | primary key (id) 12 | ); 13 | 14 | create index subscriptions_topic_index 15 | on subscriptions (topic_id); 16 | 17 | create index subscriptions_topic_callback_index 18 | on subscriptions (topic_id, callback); 19 | 20 | create table topics 21 | ( 22 | id bigint unsigned auto_increment primary key, 23 | topic varchar(512) not null 24 | ); 25 | 26 | create index topics_topic_index on topics (topic); -------------------------------------------------------------------------------- /store/events.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import "meow.tf/websub/model" 4 | 5 | // Added represents an event when a subscription is added to the store. 6 | type Added struct { 7 | Subscription model.Subscription 8 | } 9 | 10 | // Removed represents an event when a subscription is removed from the store. 11 | type Removed struct { 12 | Subscription model.Subscription 13 | } 14 | -------------------------------------------------------------------------------- /store/memory/memory.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "meow.tf/websub/handler" 5 | "meow.tf/websub/model" 6 | "meow.tf/websub/store" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | // New creates a new memory store. 12 | func New() *Store { 13 | s := &Store{ 14 | Handler: handler.New(), 15 | topicLock: &sync.RWMutex{}, 16 | topics: make(map[string][]model.Subscription), 17 | } 18 | 19 | go func() { 20 | t := time.NewTicker(60 * time.Second) 21 | for { 22 | <-t.C 23 | 24 | s.Cleanup() 25 | } 26 | }() 27 | 28 | return s 29 | } 30 | 31 | // Store represents a memory backed store. 32 | type Store struct { 33 | *handler.Handler 34 | topicLock *sync.RWMutex 35 | topics map[string][]model.Subscription 36 | } 37 | 38 | // Cleanup will loop all buckets and keys, expiring subscriptions that are old. 39 | func (s *Store) Cleanup() { 40 | now := time.Now() 41 | 42 | s.topicLock.RLock() 43 | 44 | remove := make([]model.Subscription, 0) 45 | 46 | for _, subscriptions := range s.topics { 47 | for _, sub := range subscriptions { 48 | if sub.Expires.Before(now) { 49 | remove = append(remove, sub) 50 | } 51 | } 52 | } 53 | 54 | s.topicLock.RUnlock() 55 | 56 | for _, sub := range remove { 57 | s.Remove(sub) 58 | } 59 | } 60 | 61 | // All retrieves all active subscriptions for a topic. 62 | func (s *Store) All(topic string) ([]model.Subscription, error) { 63 | subscriptions := make([]model.Subscription, 0) 64 | 65 | now := time.Now() 66 | 67 | s.topicLock.RLock() 68 | defer s.topicLock.RUnlock() 69 | 70 | if subs, ok := s.topics[topic]; ok { 71 | for _, sub := range subs { 72 | if now.Before(sub.Expires) { 73 | continue 74 | } 75 | 76 | subscriptions = append(subscriptions, sub) 77 | } 78 | } else { 79 | return nil, store.ErrNotFound 80 | } 81 | 82 | return subscriptions, nil 83 | } 84 | 85 | // For returns the subscriptions for the specified callback 86 | func (s *Store) For(callback string) ([]model.Subscription, error) { 87 | s.topicLock.RLock() 88 | defer s.topicLock.RUnlock() 89 | 90 | ret := make([]model.Subscription, 0) 91 | 92 | for _, subs := range s.topics { 93 | for _, sub := range subs { 94 | if sub.Callback == callback { 95 | ret = append(ret, sub) 96 | } 97 | } 98 | } 99 | 100 | return ret, nil 101 | } 102 | 103 | // Add stores a subscription in the bucket for the specified topic. 104 | func (s *Store) Add(sub model.Subscription) error { 105 | s.topicLock.Lock() 106 | 107 | if list, ok := s.topics[sub.Topic]; ok { 108 | list = append(list, sub) 109 | 110 | s.topics[sub.Topic] = list 111 | s.topicLock.Unlock() 112 | 113 | s.Call(&store.Added{Subscription: sub}) 114 | return nil 115 | } 116 | 117 | s.topics[sub.Topic] = []model.Subscription{sub} 118 | s.topicLock.Unlock() 119 | 120 | s.Call(&store.Added{Subscription: sub}) 121 | return nil 122 | } 123 | 124 | // Get retrieves a subscription for the specified topic and callback. 125 | func (s *Store) Get(topic, callback string) (*model.Subscription, error) { 126 | s.topicLock.RLock() 127 | defer s.topicLock.RUnlock() 128 | 129 | subs := s.topics[topic] 130 | 131 | if subs == nil { 132 | return nil, store.ErrNotFound 133 | } 134 | 135 | for _, sub := range subs { 136 | if sub.Callback == callback { 137 | return &sub, nil 138 | } 139 | } 140 | 141 | return nil, store.ErrNotFound 142 | } 143 | 144 | // Remove removes a subscription from the bucket for the specified topic. 145 | func (s *Store) Remove(sub model.Subscription) error { 146 | // Lock topics since we're doing a modification to a specific topic. 147 | s.topicLock.Lock() 148 | 149 | subs := s.topics[sub.Topic] 150 | 151 | var found bool 152 | 153 | for i, s := range subs { 154 | if s.Topic == sub.Topic && s.Callback == s.Callback { 155 | subs = append(subs[:i], subs[i+1:]...) 156 | found = true 157 | break 158 | } 159 | } 160 | 161 | if !found { 162 | s.topicLock.Unlock() 163 | return store.ErrNotFound 164 | } 165 | 166 | s.topics[sub.Topic] = subs 167 | s.topicLock.Unlock() 168 | 169 | s.Call(&store.Removed{Subscription: sub}) 170 | return nil 171 | } 172 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "errors" 5 | "meow.tf/websub/model" 6 | ) 7 | 8 | var ( 9 | ErrNotFound = errors.New("subscription not found") 10 | ) 11 | 12 | // Store defines an interface for stores to implement for data storage. 13 | type Store interface { 14 | // All returns all subscriptions for the specified topic. 15 | All(topic string) ([]model.Subscription, error) 16 | 17 | // For returns the subscriptions for the specified callback 18 | For(callback string) ([]model.Subscription, error) 19 | 20 | // Add saves/adds a subscription to the store. 21 | Add(sub model.Subscription) error 22 | 23 | // Get retrieves a subscription given a topic and callback. 24 | Get(topic, callback string) (*model.Subscription, error) 25 | 26 | // Remove removes a subscription from the store. 27 | Remove(sub model.Subscription) error 28 | } 29 | -------------------------------------------------------------------------------- /worker.go: -------------------------------------------------------------------------------- 1 | package websub 2 | 3 | import "meow.tf/websub/model" 4 | 5 | // PublishJob represents a job to publish data to a subscription. 6 | type PublishJob struct { 7 | Hub model.Hub `json:"hub"` 8 | Subscription model.Subscription `json:"subscription"` 9 | ContentType string `json:"contentType"` 10 | Data []byte `json:"data"` 11 | } 12 | 13 | // Worker is an interface to allow other types of workers to be created. 14 | type Worker interface { 15 | Add(f PublishJob) 16 | Start() 17 | Stop() 18 | } 19 | 20 | // NewGoWorker creates a new worker from the specified hub and worker count. 21 | func NewGoWorker(h *Hub, workerCount int) *GoWorker { 22 | return &GoWorker{ 23 | hub: h, 24 | workerCount: workerCount, 25 | jobCh: make(chan PublishJob), 26 | } 27 | } 28 | 29 | // GoWorker is a basic Goroutine-based worker. 30 | // It will start workerCount workers and process jobs from a channel. 31 | type GoWorker struct { 32 | hub *Hub 33 | workerCount int 34 | jobCh chan PublishJob 35 | } 36 | 37 | // Add will add a job to the queue. 38 | func (w *GoWorker) Add(job PublishJob) { 39 | w.jobCh <- job 40 | } 41 | 42 | // Start will start the worker routines. 43 | func (w *GoWorker) Start() { 44 | for i := 0; i < w.workerCount; i++ { 45 | go w.run() 46 | } 47 | } 48 | 49 | // Stop will close the job channel, causing each worker routine to exit. 50 | func (w *GoWorker) Stop() { 51 | close(w.jobCh) 52 | } 53 | 54 | // run pulls jobs off the job channel and processes them. 55 | func (w *GoWorker) run() { 56 | for { 57 | job, ok := <-w.jobCh 58 | 59 | if !ok { 60 | return 61 | } 62 | 63 | sent, err := Notify(w.hub.client, job) 64 | 65 | // TODO: Log errors 66 | if err != nil { 67 | continue 68 | } 69 | 70 | // Remove failed subscriptions 71 | if !sent { 72 | w.hub.store.Remove(job.Subscription) 73 | } 74 | } 75 | } 76 | --------------------------------------------------------------------------------