A => LICENSE +21 -0
@@ 1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2022 Simon Ser
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
A => README.md +9 -0
@@ 1,9 @@
+# go-smee
+
+A Go library and client for [smee].
+
+## License
+
+MIT
+
+[smee]: https://smee.io/
A => cmd/smee/main.go +59 -0
@@ 1,59 @@
+package main
+
+import (
+ "bytes"
+ "flag"
+ "log"
+ "net/http"
+ "net/url"
+
+ "git.sr.ht/~emersion/go-smee"
+)
+
+func main() {
+ var proxy, target string
+ flag.StringVar(&proxy, "proxy", "https://smee.io", "Proxy URL")
+ flag.StringVar(&target, "target", "http://localhost:8080", "Target URL")
+ flag.Parse()
+
+ ch, err := smee.CreateChannel(proxy)
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Printf("Opened channel: %v", ch.URL)
+
+ targetURL, err := url.Parse(target)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for {
+ wh, err := ch.ReadWebHook()
+ if err != nil {
+ log.Fatalf("failed to read webhook: %v", err)
+ }
+
+ log.Println(wh)
+
+ u := *targetURL
+ q := u.Query()
+ for k, v := range wh.Query {
+ q.Set(k, v)
+ }
+ u.RawQuery = q.Encode()
+ req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(wh.Body))
+ if err != nil {
+ log.Fatalf("failed to create HTTP request: %v", err)
+ }
+ for k, v := range wh.Header {
+ req.Header.Set(k, v)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ log.Printf("failed to deliver webhook: %v", err)
+ continue
+ }
+ resp.Body.Close()
+ }
+}
A => go.mod +3 -0
@@ 1,3 @@
+module git.sr.ht/~emersion/go-smee
+
+go 1.16
A => smee.go +156 -0
@@ 1,156 @@
+package smee
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+)
+
+type message struct {
+ Event string
+ ID string
+ Data string
+}
+
+type webHook struct {
+ Body json.RawMessage `json:"body"`
+ Query map[string]string `json:"query"`
+}
+
+type WebHook struct {
+ Body []byte
+ Query map[string]string
+ Header map[string]string
+}
+
+type Channel struct {
+ URL string
+
+ resp *http.Response
+ scanner *bufio.Scanner
+}
+
+func CreateChannel(url string) (*Channel, error) {
+ url, err := createChannel(url)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create channel: %v", err)
+ }
+
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create HTTP request: %v", err)
+ }
+
+ req.Header.Set("Cache-Control", "no-store")
+ req.Header.Set("Accept", "text/event-stream")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to SSE endpoint: %v", err)
+ }
+
+ return &Channel{
+ URL: url,
+ resp: resp,
+ scanner: bufio.NewScanner(resp.Body),
+ }, nil
+}
+
+func createChannel(url string) (string, error) {
+ if url == "" {
+ url = "https://smee.io"
+ }
+ req, err := http.NewRequest(http.MethodHead, url+"/new", nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create HTTP request: %v", err)
+ }
+ c := http.Client{
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ Timeout: 30 * time.Second,
+ }
+ resp, err := c.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("HTTP request failed: %v", err)
+ }
+ resp.Body.Close()
+ return resp.Header.Get("Location"), nil
+}
+
+func (ch *Channel) Close() error {
+ return ch.resp.Body.Close()
+}
+
+func (ch *Channel) readMessage() (*message, error) {
+ msg := &message{Event: "message"}
+ for ch.scanner.Scan() {
+ line := ch.scanner.Text()
+ if line == "" {
+ return msg, nil
+ } else if line[0] == ':' {
+ continue
+ }
+
+ k, v, _ := strings.Cut(line, ":")
+ v = strings.TrimPrefix(v, " ")
+
+ switch k {
+ case "event":
+ msg.Event = v
+ case "data":
+ msg.Data = v
+ case "id":
+ msg.ID = v
+ // TODO: "retry"
+ }
+ }
+ return nil, ch.scanner.Err()
+}
+
+func (ch *Channel) ReadWebHook() (*WebHook, error) {
+ var msg *message
+ for {
+ var err error
+ msg, err = ch.readMessage()
+ if err != nil {
+ return nil, err
+ } else if msg.Event == "message" {
+ break
+ }
+ }
+
+ var data webHook
+ if err := json.Unmarshal([]byte(msg.Data), &data); err != nil {
+ return nil, fmt.Errorf("failed to decode webhook data: %v", err)
+ }
+
+ var fields map[string]interface{}
+ if err := json.Unmarshal([]byte(msg.Data), &fields); err != nil {
+ return nil, fmt.Errorf("failed to decode webhook fields: %v", err)
+ }
+
+ header := make(map[string]string, len(fields))
+ for k, v := range fields {
+ s, ok := v.(string)
+ if !ok {
+ continue
+ }
+
+ switch k {
+ case "query", "body":
+ // Ignore
+ default:
+ header[k] = s
+ }
+ }
+
+ return &WebHook{
+ Body: []byte(data.Body),
+ Query: data.Query,
+ Header: header,
+ }, nil
+}