Skip to content

Commit 38a93f7

Browse files
committed
fix: handle SEE contract
1 parent d21449c commit 38a93f7

4 files changed

Lines changed: 53 additions & 21 deletions

File tree

internal/app/backend.go

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,21 +77,26 @@ func NewBackend(ctx context.Context, rawCallbackURL string, signer Signer, deplo
7777
}
7878

7979
type CloudEvent[Data any] struct {
80-
Specversion string
81-
Id string
82-
Source string
83-
Time time.Time
84-
Type string
85-
Data Data
80+
Specversion string `json:"specversion"`
81+
Id string `json:"id"`
82+
Source string `json:"source"`
83+
Type string `json:"type"`
84+
Time time.Time `json:"time"`
85+
Data Data `json:"data"`
8686
}
8787

8888
type ConnectBody struct {
8989
ClientToken string `json:"clientToken"`
9090
SendToken string `json:"sendToken"`
9191
ConnectionId string `json:"connectionId"`
92+
Origin string `json:"origin"`
9293
}
9394

94-
func (b *Backend) Connect(ctx context.Context, token string, signals chan<- Signal, maxLifeTime time.Duration) (string, error) {
95+
type DisconnectBody struct {
96+
ConnectionId string `json:"connectionId"`
97+
}
98+
99+
func (b *Backend) Connect(ctx context.Context, token string, origin string, signals chan<- Signal, maxLifeTime time.Duration) (string, error) {
95100
connectionId := uuid.New().String()
96101
sendToken, err := b.signer.CreateSendToken(SendTokenData{
97102
ConnectionId: connectionId,
@@ -111,6 +116,7 @@ func (b *Backend) Connect(ctx context.Context, token string, signals chan<- Sign
111116
ClientToken: token,
112117
SendToken: string(sendToken),
113118
ConnectionId: connectionId,
119+
Origin: origin,
114120
},
115121
}
116122

@@ -153,13 +159,13 @@ func (b *Backend) Connect(ctx context.Context, token string, signals chan<- Sign
153159
func (b *Backend) Disconnect(ctx context.Context, connectionId string) error {
154160
b.board.Unregister(connectionId)
155161

156-
event := CloudEvent[ConnectBody]{
162+
event := CloudEvent[DisconnectBody]{
157163
Specversion: "1.0",
158164
Id: uuid.New().String(),
159165
Source: b.deploymentURL,
160166
Time: time.Now(),
161167
Type: "charge.disconnected.v1",
162-
Data: ConnectBody{
168+
Data: DisconnectBody{
163169
ConnectionId: connectionId,
164170
},
165171
}
@@ -181,10 +187,15 @@ func (b *Backend) Disconnect(ctx context.Context, connectionId string) error {
181187
req.Header.Add("Content-Type", "application/cloudevents+json")
182188
req.Header.Add("Webhook-Signature", string(signature))
183189

184-
_, err = http.DefaultClient.Do(req)
190+
resp, err := http.DefaultClient.Do(req)
185191
if err != nil {
186192
return fmt.Errorf("post disconnected: %w", err)
187193
}
194+
defer resp.Body.Close()
195+
196+
if resp.StatusCode != http.StatusOK {
197+
return fmt.Errorf("disconnect callback returned %d", resp.StatusCode)
198+
}
188199

189200
return nil
190201
}

internal/app/bouncer.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"net/http"
7+
"net/url"
78
"slices"
89
"time"
910

@@ -24,6 +25,7 @@ type Bouncer struct {
2425
m *util.SyncMap[string, BounceStatus]
2526
deploymentURL string
2627
allowAll bool
28+
allowInsecure bool
2729
}
2830

2931
type BounceStatus struct {
@@ -51,10 +53,11 @@ func (e NotAllowedError) Error() string {
5153
return fmt.Sprintf("not allowed: %s (may try again after %s)", e.Reason, e.MayTryAgainAfter.Format(time.RFC3339))
5254
}
5355

54-
func (b *Bouncer) Allowed(domain string) error {
56+
func (b *Bouncer) Allowed(callbackUrl *url.URL) error {
5557
if b.allowAll {
5658
return nil
5759
}
60+
domain := callbackUrl.Hostname()
5861
if status, ok := b.m.Load(domain); ok && time.Now().Before(status.validBefore) {
5962
return status.Allowed()
6063
}
@@ -65,7 +68,7 @@ func (b *Bouncer) Allowed(domain string) error {
6568
return st, nil
6669
}
6770

68-
newStatus := b.fetchStatus(domain)
71+
newStatus := b.fetchStatus(callbackUrl)
6972
b.m.Store(domain, newStatus)
7073
return newStatus, nil
7174
})
@@ -76,12 +79,17 @@ func (b *Bouncer) Allowed(domain string) error {
7679
return status.Allowed()
7780
}
7881

79-
func (b *Bouncer) fetchStatus(domain string) BounceStatus {
82+
func (b *Bouncer) fetchStatus(callbackUrl *url.URL) BounceStatus {
8083
status := BounceStatus{
8184
validBefore: time.Now().Add(defaultNotAllowedCacheDuration),
8285
}
8386

84-
allowedURL := fmt.Sprintf("https://%s/%s", domain, allowedInfoPath)
87+
if !b.allowInsecure && callbackUrl.Scheme != "https" {
88+
status.reason = "insecure scheme"
89+
return status
90+
}
91+
92+
allowedURL := fmt.Sprintf("%s://%s/%s", callbackUrl.Scheme, callbackUrl.Host, allowedInfoPath)
8593
req, err := http.NewRequest(http.MethodGet, allowedURL, nil)
8694
if err != nil {
8795
status.reason = fmt.Sprintf("new request: %s", err)

internal/app/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type Config struct {
1818
RawSigningKeys []byte `envconfig:"SIGNING_KEYS" required:"true"`
1919
MaxConnectionDuration time.Duration `envconfig:"MAX_CONNECTION_DURATION" default:"4h"`
2020
AllowAllOrigins bool `envconfig:"ALLOW_ALL_ORIGINS"`
21+
AllowInsecureOrigins bool `envconfig:"ALLOW_INSECURE_ORIGINS"`
2122
}
2223

2324
type SigningKey struct {

internal/app/run.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func Run(ctx context.Context, log *slog.Logger, cfg Config) error {
3434
m: &util.SyncMap[string, BounceStatus]{},
3535
deploymentURL: cfg.DeploymentURL,
3636
allowAll: cfg.AllowAllOrigins,
37+
allowInsecure: cfg.AllowInsecureOrigins,
3738
}
3839

3940
bi := BackendIndex{
@@ -46,6 +47,11 @@ func Run(ctx context.Context, log *slog.Logger, cfg Config) error {
4647
mux.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) {
4748
ctx = r.Context()
4849

50+
origin := r.Header.Get("Origin")
51+
if origin != "" {
52+
w.Header().Set("Access-Control-Allow-Origin", origin)
53+
}
54+
4955
callback := r.URL.Query().Get("callback_url")
5056
if callback == "" {
5157
w.WriteHeader(http.StatusBadRequest)
@@ -58,7 +64,7 @@ func Run(ctx context.Context, log *slog.Logger, cfg Config) error {
5864
return
5965
}
6066

61-
bounceErr := bouncer.Allowed(callbackURL.Hostname())
67+
bounceErr := bouncer.Allowed(callbackURL)
6268
if bounceErr != nil {
6369
if nbErr, ok := bounceErr.(NotAllowedError); ok {
6470
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(time.Until(nbErr.MayTryAgainAfter).Seconds())))
@@ -92,6 +98,14 @@ func Run(ctx context.Context, log *slog.Logger, cfg Config) error {
9298
return
9399
}
94100

101+
// Set SSE headers
102+
w.Header().Set("Content-Type", "text/event-stream")
103+
w.Header().Set("Cache-Control", "no-cache")
104+
w.Header().Set("Connection", "keep-alive")
105+
// Write an initial comment to establish the SSE stream
106+
fmt.Fprint(w, ":\n\n")
107+
flusher.Flush()
108+
95109
maxDurationReached := time.After(cfg.MaxConnectionDuration)
96110

97111
wg := &sync.WaitGroup{}
@@ -106,7 +120,8 @@ func Run(ctx context.Context, log *slog.Logger, cfg Config) error {
106120
if !ok {
107121
return
108122
}
109-
_, err := w.Write(s.Message)
123+
// Format SSE event: "data: <payload>\n\n"
124+
_, err := fmt.Fprintf(w, "data: %s\n\n", s.Message)
110125
if err != nil {
111126
select {
112127
case s.Result <- fmt.Errorf("write: %w", err):
@@ -126,8 +141,9 @@ func Run(ctx context.Context, log *slog.Logger, cfg Config) error {
126141
}
127142
})
128143

129-
id, err := be.Connect(ctx, token, signals, cfg.MaxConnectionDuration)
144+
id, err := be.Connect(ctx, token, origin, signals, cfg.MaxConnectionDuration)
130145
if err != nil {
146+
// TODO: this error path is invalid as the headers have already been sent.
131147
slog.Error("Failed to connect to backend", "backend_callback_url", be.callbackUrl.String(), "error", err)
132148
w.WriteHeader(http.StatusServiceUnavailable)
133149
return
@@ -138,10 +154,6 @@ func Run(ctx context.Context, log *slog.Logger, cfg Config) error {
138154
}
139155
}()
140156

141-
w.Header().Set("Content-Type", "text/event-stream")
142-
w.Header().Set("Cache-Control", "no-cache")
143-
w.Header().Set("Connection", "keep-alive")
144-
145157
wg.Wait()
146158
})
147159

0 commit comments

Comments
 (0)