Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f83b2870f2 | |||
| de02dfb0ee | |||
| bd7f9fad15 | |||
| 5a138b9ac8 | |||
| 29c17440a5 | |||
| 6ec3353b12 | |||
| 0d56f6adf1 | |||
| f597fa8b52 | |||
| fe5d11754a | |||
| 3967fa955a | |||
| 7c7bceb9d7 | |||
| c1f8fe8a2c | |||
| 7e9265c6a7 | |||
| 28b50128d9 | |||
| d5b158e0bf | |||
| 324a2e6fe0 |
4
block.go
4
block.go
@ -5,8 +5,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/blocks"
|
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
|
||||||
"git.dev.alexdunmow.com/block/core/settings"
|
"git.dev.alexdunmow.com/block/pluginsdk/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// renderSettings is the SettingsManager available to CalcomBookingFunc, which
|
// renderSettings is the SettingsManager available to CalcomBookingFunc, which
|
||||||
|
|||||||
64
block_resolver.go
Normal file
64
block_resolver.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// httpBase is the prefix the host mounts the plugin HTTP handler at; the wasm
|
||||||
|
// host forwards full paths, so every route registers under it.
|
||||||
|
const httpBase = "/api/plugins/calcomblock"
|
||||||
|
|
||||||
|
// publishedConfigsSource is the slice of content.Content the resolver needs
|
||||||
|
// (satisfied by deps.Content).
|
||||||
|
type publishedConfigsSource interface {
|
||||||
|
PublishedBlockConfigs(ctx context.Context, blockKey string) ([]map[string]any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishedBlockResolver answers CalcomBookingRequiresCaptcha from published
|
||||||
|
// block content served by the host (content.published_block_configs) — the
|
||||||
|
// server-authoritative source, never the POST body/blockId. A config with an
|
||||||
|
// empty username applies to any posted username (it falls back to the
|
||||||
|
// plugin's default account), erring toward requiring captcha.
|
||||||
|
type publishedBlockResolver struct {
|
||||||
|
source publishedConfigsSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r publishedBlockResolver) CalcomBookingRequiresCaptcha(ctx context.Context, username, eventTypeSlug string) (bool, error) {
|
||||||
|
configs, err := r.source.PublishedBlockConfigs(ctx, "calcom:booking")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if !configBool(cfg["captchaEnabled"]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !configFieldMatches(cfg["eventTypeSlug"], eventTypeSlug, false) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if configFieldMatches(cfg["username"], username, true) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configBool(v any) bool {
|
||||||
|
switch b := v.(type) {
|
||||||
|
case bool:
|
||||||
|
return b
|
||||||
|
case string:
|
||||||
|
return strings.EqualFold(strings.TrimSpace(b), "true")
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configFieldMatches(cfgValue any, posted string, emptyMatchesAny bool) bool {
|
||||||
|
s, _ := cfgValue.(string)
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return emptyMatchesAny
|
||||||
|
}
|
||||||
|
return strings.EqualFold(s, strings.TrimSpace(posted))
|
||||||
|
}
|
||||||
69
block_resolver_test.go
Normal file
69
block_resolver_test.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeConfigsSource struct {
|
||||||
|
configs []map[string]any
|
||||||
|
err error
|
||||||
|
gotKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeConfigsSource) PublishedBlockConfigs(_ context.Context, blockKey string) ([]map[string]any, error) {
|
||||||
|
f.gotKey = blockKey
|
||||||
|
return f.configs, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishedBlockResolverRequiresCaptchaOnMatch(t *testing.T) {
|
||||||
|
src := &fakeConfigsSource{configs: []map[string]any{
|
||||||
|
{"username": "other", "eventTypeSlug": "30min", "captchaEnabled": true},
|
||||||
|
{"username": "Alice", "eventTypeSlug": "30min", "captchaEnabled": true},
|
||||||
|
}}
|
||||||
|
r := publishedBlockResolver{source: src}
|
||||||
|
|
||||||
|
got, err := r.CalcomBookingRequiresCaptcha(context.Background(), "alice", "30min")
|
||||||
|
if err != nil || !got {
|
||||||
|
t.Fatalf("RequiresCaptcha = %v, %v; want true (case-insensitive username match)", got, err)
|
||||||
|
}
|
||||||
|
if src.gotKey != "calcom:booking" {
|
||||||
|
t.Fatalf("queried block key %q, want calcom:booking", src.gotKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishedBlockResolverNoRequirementWhenDisabledOrUnmatched(t *testing.T) {
|
||||||
|
src := &fakeConfigsSource{configs: []map[string]any{
|
||||||
|
{"username": "alice", "eventTypeSlug": "30min", "captchaEnabled": false},
|
||||||
|
{"username": "alice", "eventTypeSlug": "60min", "captchaEnabled": true},
|
||||||
|
}}
|
||||||
|
r := publishedBlockResolver{source: src}
|
||||||
|
|
||||||
|
if got, _ := r.CalcomBookingRequiresCaptcha(context.Background(), "alice", "30min"); got {
|
||||||
|
t.Fatal("RequiresCaptcha = true; want false (disabled for 30min, 60min doesn't match)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishedBlockResolverEmptyConfigUsernameMatchesAny(t *testing.T) {
|
||||||
|
// A block may omit username (falls back to the plugin's default account);
|
||||||
|
// the requirement then applies to the event type for any posted username —
|
||||||
|
// erring toward requiring captcha, never stripping it.
|
||||||
|
src := &fakeConfigsSource{configs: []map[string]any{
|
||||||
|
{"eventTypeSlug": "30min", "captchaEnabled": true},
|
||||||
|
}}
|
||||||
|
r := publishedBlockResolver{source: src}
|
||||||
|
|
||||||
|
if got, _ := r.CalcomBookingRequiresCaptcha(context.Background(), "whoever", "30min"); !got {
|
||||||
|
t.Fatal("RequiresCaptcha = false; want true (config without username applies to any)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishedBlockResolverPropagatesError(t *testing.T) {
|
||||||
|
src := &fakeConfigsSource{err: errors.New("hostcall failed")}
|
||||||
|
r := publishedBlockResolver{source: src}
|
||||||
|
|
||||||
|
if _, err := r.CalcomBookingRequiresCaptcha(context.Background(), "alice", "30min"); err == nil {
|
||||||
|
t.Fatal("expected error to propagate (caller decides fail-open policy)")
|
||||||
|
}
|
||||||
|
}
|
||||||
10
client.go
10
client.go
@ -38,10 +38,14 @@ type CalcomClient struct {
|
|||||||
baseURL string
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCalcomClient builds a client with the standard 30s HTTP timeout.
|
// NewCalcomClient builds a client with the standard 30s HTTP timeout. rt is the
|
||||||
func NewCalcomClient(apiKey string) *CalcomClient {
|
// host-mediated egress transport (deps.OutboundHTTP, ADR 0023) — under wasm the
|
||||||
|
// guest has no network, so every Cal.com request must ride it. A nil rt falls
|
||||||
|
// back to http.DefaultTransport, keeping native/DESCRIBE and httptest paths
|
||||||
|
// working (tests reach their fake server by overriding calcomBaseURL).
|
||||||
|
func NewCalcomClient(apiKey string, rt http.RoundTripper) *CalcomClient {
|
||||||
return &CalcomClient{
|
return &CalcomClient{
|
||||||
http: &http.Client{Timeout: 30 * time.Second},
|
http: &http.Client{Transport: rt, Timeout: 30 * time.Second},
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
baseURL: calcomBaseURL,
|
baseURL: calcomBaseURL,
|
||||||
}
|
}
|
||||||
|
|||||||
9
go.mod
9
go.mod
@ -3,11 +3,11 @@ module git.dev.alexdunmow.com/block/calcomblock
|
|||||||
go 1.26.4
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.dev.alexdunmow.com/block/core v0.20.2
|
git.dev.alexdunmow.com/block/pluginsdk v0.2.5
|
||||||
github.com/a-h/templ v0.3.1020
|
github.com/a-h/templ v0.3.1020
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/nyaruka/phonenumbers v1.8.0
|
github.com/nyaruka/phonenumbers v1.8.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -16,7 +16,8 @@ require (
|
|||||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
golang.org/x/mod v0.34.0 // indirect
|
golang.org/x/mod v0.37.0 // indirect
|
||||||
golang.org/x/text v0.36.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
|
golang.org/x/text v0.38.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
26
go.sum
26
go.sum
@ -1,7 +1,7 @@
|
|||||||
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
||||||
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
||||||
git.dev.alexdunmow.com/block/core v0.20.2 h1:OVh5J7sYsjMDq6UBUpm/6FoUcfeuTr+YUPp0rQiYDuo=
|
git.dev.alexdunmow.com/block/pluginsdk v0.2.5 h1:01d+5stAywSENScafnNKyMc2ZM5GGFt+hCBKuKTFJW4=
|
||||||
git.dev.alexdunmow.com/block/core v0.20.2/go.mod h1:n/0y+8g/zabjwRNseNjme3w5J8thKqdjkIE9YJCzGow=
|
git.dev.alexdunmow.com/block/pluginsdk v0.2.5/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM=
|
||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
@ -19,8 +19,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
|||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/nyaruka/phonenumbers v1.8.0 h1:TrXNJmbwcAHajzDqin3mLWw57vqLUA6ZjVdeNds0heQ=
|
github.com/nyaruka/phonenumbers v1.8.0 h1:TrXNJmbwcAHajzDqin3mLWw57vqLUA6ZjVdeNds0heQ=
|
||||||
@ -34,14 +34,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
120
handler.go
120
handler.go
@ -15,30 +15,14 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/auth"
|
"git.dev.alexdunmow.com/block/pluginsdk/auth"
|
||||||
"git.dev.alexdunmow.com/block/core/captcha"
|
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||||
"git.dev.alexdunmow.com/block/core/plugin"
|
"git.dev.alexdunmow.com/block/pluginsdk/rbac"
|
||||||
"git.dev.alexdunmow.com/block/core/rbac"
|
|
||||||
"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
|
"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/nyaruka/phonenumbers"
|
"github.com/nyaruka/phonenumbers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// captchaVerifier is the shared Cap (trycap.dev) proof-of-work verifier, built
|
|
||||||
// once from the per-instance secret and injected at server startup via
|
|
||||||
// SetCaptchaServer — mirroring SetNotifyFn. It is nil when the instance captcha
|
|
||||||
// secret failed to load: an enabled booking block then FAILS CLOSED (rejects
|
|
||||||
// rather than allowing an unverified booking through). Read directly by
|
|
||||||
// HandleCreateBooking; the mailing-list / block-form handlers hold the same
|
|
||||||
// server on their struct, but the plugin HTTP mount is generic so a package
|
|
||||||
// seam is the established injection point here.
|
|
||||||
var captchaVerifier *captcha.Server
|
|
||||||
|
|
||||||
// SetCaptchaServer wires the CMS's shared captcha server into the Cal.com
|
|
||||||
// booking handler. Called once at server startup from server.go. Safe to pass
|
|
||||||
// nil (captcha then unavailable → enabled blocks fail closed).
|
|
||||||
func SetCaptchaServer(s *captcha.Server) { captchaVerifier = s }
|
|
||||||
|
|
||||||
// blockContentResolver answers the server-authoritative question "does a
|
// blockContentResolver answers the server-authoritative question "does a
|
||||||
// booking for this Cal.com username + event-type slug require a captcha token?"
|
// booking for this Cal.com username + event-type slug require a captcha token?"
|
||||||
// by scanning currently-published block content — NEVER a client-supplied
|
// by scanning currently-published block content — NEVER a client-supplied
|
||||||
@ -267,6 +251,10 @@ type CalcomHandler struct {
|
|||||||
// trusting the POST body. nil in editor-preview / direct-handler unit tests,
|
// trusting the POST body. nil in editor-preview / direct-handler unit tests,
|
||||||
// where blockCaptchaEnabled reports false.
|
// where blockCaptchaEnabled reports false.
|
||||||
blockConfig blockContentResolver
|
blockConfig blockContentResolver
|
||||||
|
// rt is the host-mediated egress transport (deps.OutboundHTTP, ADR 0023)
|
||||||
|
// every Cal.com client is built with. nil in native/DESCRIBE/test builds,
|
||||||
|
// where NewCalcomClient falls back to the default transport.
|
||||||
|
rt http.RoundTripper
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCalcomHandler creates a new Cal.com handler. appURL is the externally
|
// NewCalcomHandler creates a new Cal.com handler. appURL is the externally
|
||||||
@ -281,6 +269,11 @@ func NewCalcomHandler(settings *SettingsManager, limiter *RateLimiter, appURL st
|
|||||||
// and encrypt the Cal.com API key + webhook secret.
|
// and encrypt the Cal.com API key + webhook secret.
|
||||||
func NewCalcomRouter(deps plugin.CoreServices) http.Handler {
|
func NewCalcomRouter(deps plugin.CoreServices) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
// Context does not cross the wasm ABI: requireAdmin's claims exist only if
|
||||||
|
// the guest rebuilds them from the host-stamped X-Bn-Verified-* headers.
|
||||||
|
// Without this middleware every admin endpoint 401s for real admins (the
|
||||||
|
// settings panel could not save the API key — 2.0.3 regression).
|
||||||
|
r.Use(auth.TrustedHeaderMiddleware)
|
||||||
// Settings persistence goes through the SDK settings capabilities: under the
|
// Settings persistence goes through the SDK settings capabilities: under the
|
||||||
// wasm sandbox the plugin's Postgres role cannot touch the CMS `settings`
|
// wasm sandbox the plugin's Postgres role cannot touch the CMS `settings`
|
||||||
// table (public schema) directly, so the host performs the DB work.
|
// table (public schema) directly, so the host performs the DB work.
|
||||||
@ -293,38 +286,48 @@ func NewCalcomRouter(deps plugin.CoreServices) http.Handler {
|
|||||||
limiter := NewRateLimiter(bookingsPerHourPerIP)
|
limiter := NewRateLimiter(bookingsPerHourPerIP)
|
||||||
helpers.StartCleanupLoop(context.Background(), 15*time.Minute, limiter.SweepStale)
|
helpers.StartCleanupLoop(context.Background(), 15*time.Minute, limiter.SweepStale)
|
||||||
h := NewCalcomHandler(settings, limiter, deps.AppURL)
|
h := NewCalcomHandler(settings, limiter, deps.AppURL)
|
||||||
// blockConfig scans published block content for the server-authoritative
|
// Route every Cal.com call through the host-mediated egress transport. nil
|
||||||
// captcha requirement (CalcomBookingRequiresCaptcha over page_block_snapshots
|
// under native/DESCRIBE builds (deps has no host); the client then uses the
|
||||||
// in the public schema). No wasm-ABI capability exposes that today, and the
|
// default transport, which only the non-wasm paths can reach.
|
||||||
// sandboxed plugin role cannot read it via SQL, so it is left nil:
|
h.rt = deps.OutboundHTTP
|
||||||
// bookingRequiresCaptcha then reports false and the baseline honeypot +
|
// blockConfig resolves the server-authoritative captcha requirement from
|
||||||
// per-IP rate limit still apply. Restoring server-authoritative captcha
|
// published block content via the content.published_block_configs
|
||||||
// enforcement needs a future core capability that exposes published-block
|
// capability (pluginsdk >= v0.2.4) — the sandboxed plugin role cannot read
|
||||||
// content across the wasm ABI (tracked as a follow-up).
|
// page_block_snapshots itself. deps.Content is nil in native/DESCRIBE
|
||||||
h.blockConfig = nil
|
// builds; blockConfig then stays nil and bookingRequiresCaptcha reports
|
||||||
|
// false (honeypot + per-IP rate limit remain the baseline).
|
||||||
|
if deps.Content != nil {
|
||||||
|
h.blockConfig = publishedBlockResolver{source: deps.Content}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes are registered ABSOLUTE on a flat chi router: the wasm host forwards
|
||||||
|
// the FULL original path (r.URL.Path, no prefix strip), so the guest matches
|
||||||
|
// on the complete path. A nested r.Route(httpBase, …) instead relies on chi
|
||||||
|
// Mount's RoutePath rebasing, which the raw-path forwarder never populates —
|
||||||
|
// mirror testplugin's httpBase convention (flat + absolute) so the surface
|
||||||
|
// resolves regardless of how the host mounts the forwarder.
|
||||||
// Public booking endpoints — reachable by anonymous visitors.
|
// Public booking endpoints — reachable by anonymous visitors.
|
||||||
r.Get("/slots", h.HandleGetSlots)
|
r.Get(httpBase+"/slots", h.HandleGetSlots)
|
||||||
r.Get("/form", h.HandleGetForm)
|
r.Get(httpBase+"/form", h.HandleGetForm)
|
||||||
r.Post("/book", h.HandleCreateBooking)
|
r.Post(httpBase+"/book", h.HandleCreateBooking)
|
||||||
r.Post("/cancel", h.HandleCancelBooking)
|
r.Post(httpBase+"/cancel", h.HandleCancelBooking)
|
||||||
r.Get("/reset", h.HandleReset)
|
r.Get(httpBase+"/reset", h.HandleReset)
|
||||||
r.Get("/date-grid", h.HandleGetDateGrid)
|
r.Get(httpBase+"/date-grid", h.HandleGetDateGrid)
|
||||||
|
|
||||||
// Webhook receiver — HMAC-verified inside the handler, so no admin gate.
|
// Webhook receiver — HMAC-verified inside the handler, so no admin gate.
|
||||||
wh := NewWebhookHandler(settings, slog.Default())
|
wh := NewWebhookHandler(settings, slog.Default())
|
||||||
r.Post("/webhook", wh.Handle)
|
r.Post(httpBase+"/webhook", wh.Handle)
|
||||||
|
|
||||||
// Admin endpoints — gated by requireAdmin. The plugin HTTP mount has no
|
// Admin endpoints — gated by requireAdmin. The plugin HTTP mount has no
|
||||||
// outer admin middleware, so this is the only line keeping the API key
|
// outer admin middleware, so this is the only line keeping the API key
|
||||||
// + webhook secret + Cal.com proxy out of attackers' hands.
|
// + webhook secret + Cal.com proxy out of attackers' hands.
|
||||||
r.Group(func(admin chi.Router) {
|
r.Group(func(admin chi.Router) {
|
||||||
admin.Use(requireAdmin)
|
admin.Use(requireAdmin)
|
||||||
admin.Get("/settings", h.HandleGetSettings)
|
admin.Get(httpBase+"/settings", h.HandleGetSettings)
|
||||||
admin.Post("/settings", h.HandleSaveSettings)
|
admin.Post(httpBase+"/settings", h.HandleSaveSettings)
|
||||||
admin.Post("/settings/rotate-webhook-secret", h.HandleRotateWebhookSecret)
|
admin.Post(httpBase+"/settings/rotate-webhook-secret", h.HandleRotateWebhookSecret)
|
||||||
admin.Post("/test", h.HandleTestConnection)
|
admin.Post(httpBase+"/test", h.HandleTestConnection)
|
||||||
admin.Get("/event-types", h.HandleListEventTypes)
|
admin.Get(httpBase+"/event-types", h.HandleListEventTypes)
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
@ -380,8 +383,8 @@ func resetEventTZCache() {
|
|||||||
// type's pinned availability schedule when one is set, else the account's
|
// type's pinned availability schedule when one is set, else the account's
|
||||||
// default schedule. Returns "" when any hop fails — callers then fall down
|
// default schedule. Returns "" when any hop fails — callers then fall down
|
||||||
// the site-setting ladder rather than guessing.
|
// the site-setting ladder rather than guessing.
|
||||||
func fetchEventTimezone(ctx context.Context, apiKey, username, eventType string) string {
|
func fetchEventTimezone(ctx context.Context, rt http.RoundTripper, apiKey, username, eventType string) string {
|
||||||
client := NewCalcomClient(apiKey)
|
client := NewCalcomClient(apiKey, rt)
|
||||||
schedules, err := client.ListSchedules(ctx)
|
schedules, err := client.ListSchedules(ctx)
|
||||||
if err != nil || len(schedules) == 0 {
|
if err != nil || len(schedules) == 0 {
|
||||||
log.Printf("calcom: list schedules for %s/%s: %v", username, eventType, err)
|
log.Printf("calcom: list schedules for %s/%s: %v", username, eventType, err)
|
||||||
@ -416,7 +419,7 @@ func fetchEventTimezone(ctx context.Context, apiKey, username, eventType string)
|
|||||||
func (h *CalcomHandler) eventLocation(ctx context.Context, apiKey, username, eventType string) (*time.Location, string) {
|
func (h *CalcomHandler) eventLocation(ctx context.Context, apiKey, username, eventType string) (*time.Location, string) {
|
||||||
tz := peekEventTZ(username, eventType)
|
tz := peekEventTZ(username, eventType)
|
||||||
if tz == "" && apiKey != "" && username != "" && eventType != "" {
|
if tz == "" && apiKey != "" && username != "" && eventType != "" {
|
||||||
if tz = fetchEventTimezone(ctx, apiKey, username, eventType); tz != "" {
|
if tz = fetchEventTimezone(ctx, h.rt, apiKey, username, eventType); tz != "" {
|
||||||
storeEventTZ(username, eventType, tz)
|
storeEventTZ(username, eventType, tz)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -463,7 +466,7 @@ func (h *CalcomHandler) HandleGetSlots(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||||
|
|
||||||
client := NewCalcomClient(apiKey)
|
client := NewCalcomClient(apiKey, h.rt)
|
||||||
slotsResp, err := client.GetSlots(
|
slotsResp, err := client.GetSlots(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
username,
|
username,
|
||||||
@ -559,7 +562,7 @@ func (h *CalcomHandler) HandleGetForm(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var bookingFields []BookingField
|
var bookingFields []BookingField
|
||||||
if apiKeyErr == nil && apiKey != "" && username != "" && eventType != "" {
|
if apiKeyErr == nil && apiKey != "" && username != "" && eventType != "" {
|
||||||
client := NewCalcomClient(apiKey)
|
client := NewCalcomClient(apiKey, h.rt)
|
||||||
et, etErr := client.GetEventType(r.Context(), username, eventType)
|
et, etErr := client.GetEventType(r.Context(), username, eventType)
|
||||||
if etErr != nil {
|
if etErr != nil {
|
||||||
// Fall back to default fields so a transient Cal.com error doesn't
|
// Fall back to default fields so a transient Cal.com error doesn't
|
||||||
@ -697,13 +700,16 @@ func (h *CalcomHandler) HandleCreateBooking(w http.ResponseWriter, r *http.Reque
|
|||||||
// authoritative source is whether any currently-published calcom:booking
|
// authoritative source is whether any currently-published calcom:booking
|
||||||
// block for this username+eventType has captchaEnabled=true — resolved from
|
// block for this username+eventType has captchaEnabled=true — resolved from
|
||||||
// published content, never the POST body/blockId — so a bot cannot strip the
|
// published content, never the POST body/blockId — so a bot cannot strip the
|
||||||
// requirement by omitting or forging blockId. When captcha IS required we
|
// requirement by omitting or forging blockId. Verification is HOST-side: a
|
||||||
// fail CLOSED: a nil shared captcha server (instance secret failed at
|
// guest cannot hold the host's stateful captcha server, so the host consumes
|
||||||
// startup) rejects rather than allows. On failure we render the same error
|
// the cap-token and stamps the unforgeable X-Bn-Verified-Captcha trusted
|
||||||
// partial as other validation failures (not a 500), so the visitor can
|
// header (pluginsdk/auth). When captcha IS required we fail CLOSED: no
|
||||||
// re-solve and retry.
|
// stamp (invalid token, or the instance captcha server failed at startup)
|
||||||
|
// rejects rather than allows. On failure we render the same error partial
|
||||||
|
// as other validation failures (not a 500), so the visitor can re-solve
|
||||||
|
// and retry.
|
||||||
if h.bookingRequiresCaptcha(r.Context(), username, eventType) {
|
if h.bookingRequiresCaptcha(r.Context(), username, eventType) {
|
||||||
if captchaVerifier == nil || !captchaVerifier.VerifyRequest(r) {
|
if !auth.CaptchaVerified(r.Header) {
|
||||||
h.renderError(w, "Captcha check failed, please try again.", blockID, false)
|
h.renderError(w, "Captcha check failed, please try again.", blockID, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -721,7 +727,7 @@ func (h *CalcomHandler) HandleCreateBooking(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
payload := routeBookingForm(r.Form, region)
|
payload := routeBookingForm(r.Form, region)
|
||||||
|
|
||||||
client := NewCalcomClient(apiKey)
|
client := NewCalcomClient(apiKey, h.rt)
|
||||||
br := BookingRequest{
|
br := BookingRequest{
|
||||||
EventTypeSlug: eventType,
|
EventTypeSlug: eventType,
|
||||||
Username: username,
|
Username: username,
|
||||||
@ -834,7 +840,7 @@ func (h *CalcomHandler) HandleCancelBooking(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client := NewCalcomClient(apiKey)
|
client := NewCalcomClient(apiKey, h.rt)
|
||||||
if err := client.CancelBooking(r.Context(), uid, "Cancelled by attendee"); err != nil {
|
if err := client.CancelBooking(r.Context(), uid, "Cancelled by attendee"); err != nil {
|
||||||
// Full technical cause stays server-side; the visitor sees curated copy.
|
// Full technical cause stays server-side; the visitor sees curated copy.
|
||||||
log.Printf("calcom: cancel booking %q: %v", uid, err)
|
log.Printf("calcom: cancel booking %q: %v", uid, err)
|
||||||
@ -950,7 +956,7 @@ func (h *CalcomHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request
|
|||||||
// have no cached username. Fire /me once on the first settings load and
|
// have no cached username. Fire /me once on the first settings load and
|
||||||
// persist the result so subsequent loads are cache-hits.
|
// persist the result so subsequent loads are cache-hits.
|
||||||
if username == "" && apiKey != "" {
|
if username == "" && apiKey != "" {
|
||||||
client := NewCalcomClient(apiKey)
|
client := NewCalcomClient(apiKey, h.rt)
|
||||||
if me, err := client.GetMe(ctx); err != nil {
|
if me, err := client.GetMe(ctx); err != nil {
|
||||||
log.Printf("calcom: backfill /me in HandleGetSettings: %v", err)
|
log.Printf("calcom: backfill /me in HandleGetSettings: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@ -1012,7 +1018,7 @@ func (h *CalcomHandler) HandleSaveSettings(w http.ResponseWriter, r *http.Reques
|
|||||||
// ask the admin to type their own username. Best-effort: if /me is
|
// ask the admin to type their own username. Best-effort: if /me is
|
||||||
// down or rate-limited, the save still succeeds and the admin can
|
// down or rate-limited, the save still succeeds and the admin can
|
||||||
// refresh later via Test Connection.
|
// refresh later via Test Connection.
|
||||||
client := NewCalcomClient(req.APIKey)
|
client := NewCalcomClient(req.APIKey, h.rt)
|
||||||
if me, err := client.GetMe(ctx); err != nil {
|
if me, err := client.GetMe(ctx); err != nil {
|
||||||
log.Printf("calcom: /me lookup after save: %v", err)
|
log.Printf("calcom: /me lookup after save: %v", err)
|
||||||
} else if err := h.settings.SetUsername(ctx, me.Username); err != nil {
|
} else if err := h.settings.SetUsername(ctx, me.Username); err != nil {
|
||||||
@ -1076,7 +1082,7 @@ func (h *CalcomHandler) HandleListEventTypes(w http.ResponseWriter, r *http.Requ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
username := r.URL.Query().Get("username")
|
username := r.URL.Query().Get("username")
|
||||||
client := NewCalcomClient(apiKey)
|
client := NewCalcomClient(apiKey, h.rt)
|
||||||
ets, err := client.ListEventTypes(r.Context(), username)
|
ets, err := client.ListEventTypes(r.Context(), username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("calcom: list event types: %v", err)
|
log.Printf("calcom: list event types: %v", err)
|
||||||
@ -1099,7 +1105,7 @@ func (h *CalcomHandler) HandleTestConnection(w http.ResponseWriter, r *http.Requ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client := NewCalcomClient(apiKey)
|
client := NewCalcomClient(apiKey, h.rt)
|
||||||
ets, err := client.ListEventTypes(r.Context(), "")
|
ets, err := client.ListEventTypes(r.Context(), "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("calcom: test connection: %v", err)
|
log.Printf("calcom: test connection: %v", err)
|
||||||
|
|||||||
@ -2,22 +2,18 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/captcha"
|
"git.dev.alexdunmow.com/block/pluginsdk/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeBlockResolver is an in-memory blockContentResolver so the booking handler
|
// fakeBlockResolver is an in-memory blockContentResolver so the booking handler
|
||||||
@ -46,7 +42,7 @@ func (f fakeBlockResolver) CalcomBookingRequiresCaptcha(_ context.Context, usern
|
|||||||
// block for username "alice" / eventType "30min" carries the given
|
// block for username "alice" / eventType "30min" carries the given
|
||||||
// captchaEnabled flag, plus a persisted API key so the booking flow reaches the
|
// captchaEnabled flag, plus a persisted API key so the booking flow reaches the
|
||||||
// Cal.com call. The requirement is keyed on username+eventType, NOT a blockId —
|
// Cal.com call. The requirement is keyed on username+eventType, NOT a blockId —
|
||||||
// so the booking form may post any blockId (or none) without affecting it.
|
// see fakeBlockResolver.
|
||||||
func newCalcomCaptchaHandler(t *testing.T, captchaEnabled bool) *CalcomHandler {
|
func newCalcomCaptchaHandler(t *testing.T, captchaEnabled bool) *CalcomHandler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
h, _ := newTestHandler()
|
h, _ := newTestHandler()
|
||||||
@ -59,12 +55,6 @@ func newCalcomCaptchaHandler(t *testing.T, captchaEnabled bool) *CalcomHandler {
|
|||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
// newCalcomTestCaptchaServer builds a captcha.Server with a low PoW difficulty
|
|
||||||
// so the challenge solves quickly in tests.
|
|
||||||
func newCalcomTestCaptchaServer() *captcha.Server {
|
|
||||||
return captcha.New([]byte("calcom-captcha-test-secret"), captcha.WithChallenge(4, 8, 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
// captchaBookingForm builds a booking POST body that passes every cheap
|
// captchaBookingForm builds a booking POST body that passes every cheap
|
||||||
// validation (name+email present, valid email) so the only thing standing
|
// validation (name+email present, valid email) so the only thing standing
|
||||||
// between the request and the Cal.com call is the captcha check. blockID is
|
// between the request and the Cal.com call is the captcha check. blockID is
|
||||||
@ -84,10 +74,18 @@ func captchaBookingForm(blockID string) url.Values {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func postCaptchaBooking(h *CalcomHandler, form url.Values) *httptest.ResponseRecorder {
|
// postCaptchaBooking submits the form. captchaHeader is the raw value of the
|
||||||
|
// host-stamped X-Bn-Verified-Captcha trusted header ("" = host stamped
|
||||||
|
// nothing: no valid cap-token crossed the host, or the instance captcha
|
||||||
|
// server was unavailable). Guests never see raw tokens — the host verifies
|
||||||
|
// and consumes them before dispatch (pluginsdk/auth/trustedheaders.go).
|
||||||
|
func postCaptchaBooking(h *CalcomHandler, form url.Values, captchaHeader string) *httptest.ResponseRecorder {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
|
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("X-Forwarded-For", "198.51.100.7") // TEST-NET-2, isolated bucket
|
req.Header.Set("X-Forwarded-For", "198.51.100.7") // TEST-NET-2, isolated bucket
|
||||||
|
if captchaHeader != "" {
|
||||||
|
req.Header.Set(auth.HeaderVerifiedCaptcha, captchaHeader)
|
||||||
|
}
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.HandleCreateBooking(rec, req)
|
h.HandleCreateBooking(rec, req)
|
||||||
return rec
|
return rec
|
||||||
@ -108,60 +106,55 @@ func fakeCalcomServer(t *testing.T) (*httptest.Server, *atomic.Int32) {
|
|||||||
return srv, &bookings
|
return srv, &bookings
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCalcomBooking_CaptchaEnabled_RejectsWithoutToken(t *testing.T) {
|
func TestCalcomBooking_CaptchaEnabled_RejectsWithoutVerifiedHeader(t *testing.T) {
|
||||||
fake, bookings := fakeCalcomServer(t)
|
fake, bookings := fakeCalcomServer(t)
|
||||||
withCalcomBaseURL(t, fake.URL)
|
withCalcomBaseURL(t, fake.URL)
|
||||||
|
|
||||||
h := newCalcomCaptchaHandler(t, true)
|
h := newCalcomCaptchaHandler(t, true)
|
||||||
SetCaptchaServer(newCalcomTestCaptchaServer())
|
|
||||||
t.Cleanup(func() { SetCaptchaServer(nil) })
|
|
||||||
|
|
||||||
rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString())) // no cap-token
|
rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString()), "") // host stamped nothing
|
||||||
|
|
||||||
if got := bookings.Load(); got != 0 {
|
if got := bookings.Load(); got != 0 {
|
||||||
t.Fatalf("Cal.com booking created %d times; want 0 (missing captcha token must be rejected)", got)
|
t.Fatalf("Cal.com booking created %d times; want 0 (missing verified-captcha header must be rejected)", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(rec.Body.String(), "aptcha") {
|
if !strings.Contains(rec.Body.String(), "aptcha") {
|
||||||
t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
|
t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCalcomBooking_CaptchaEnabled_RejectsInvalidToken(t *testing.T) {
|
func TestCalcomBooking_CaptchaEnabled_RejectsNonCanonicalHeaderValue(t *testing.T) {
|
||||||
fake, bookings := fakeCalcomServer(t)
|
fake, bookings := fakeCalcomServer(t)
|
||||||
withCalcomBaseURL(t, fake.URL)
|
withCalcomBaseURL(t, fake.URL)
|
||||||
|
|
||||||
h := newCalcomCaptchaHandler(t, true)
|
h := newCalcomCaptchaHandler(t, true)
|
||||||
SetCaptchaServer(newCalcomTestCaptchaServer())
|
|
||||||
t.Cleanup(func() { SetCaptchaServer(nil) })
|
|
||||||
|
|
||||||
form := captchaBookingForm(uuid.NewString())
|
// Only the exact host-stamped value "1" counts; anything else reads as
|
||||||
form.Set("cap-token", "not-a-real-token")
|
// unverified (auth.CaptchaVerified).
|
||||||
rec := postCaptchaBooking(h, form)
|
rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString()), "true")
|
||||||
|
|
||||||
if got := bookings.Load(); got != 0 {
|
if got := bookings.Load(); got != 0 {
|
||||||
t.Fatalf("Cal.com booking created %d times; want 0 (invalid token rejected)", got)
|
t.Fatalf("Cal.com booking created %d times; want 0 (non-canonical header value rejected)", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(rec.Body.String(), "aptcha") {
|
if !strings.Contains(rec.Body.String(), "aptcha") {
|
||||||
t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
|
t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCalcomBooking_CaptchaEnabled_ProceedsWithValidToken(t *testing.T) {
|
func TestCalcomBooking_CaptchaEnabled_ProceedsWithVerifiedHeader(t *testing.T) {
|
||||||
fake, bookings := fakeCalcomServer(t)
|
fake, bookings := fakeCalcomServer(t)
|
||||||
withCalcomBaseURL(t, fake.URL)
|
withCalcomBaseURL(t, fake.URL)
|
||||||
|
|
||||||
srv := newCalcomTestCaptchaServer()
|
|
||||||
h := newCalcomCaptchaHandler(t, true)
|
h := newCalcomCaptchaHandler(t, true)
|
||||||
SetCaptchaServer(srv)
|
|
||||||
t.Cleanup(func() { SetCaptchaServer(nil) })
|
|
||||||
|
|
||||||
token := mintCalcomCaptchaToken(t, srv)
|
// A real widget still posts the cap-token field; the HOST consumes it and
|
||||||
|
// stamps the header. The guest must trust the header and never forward the
|
||||||
|
// raw token to Cal.com.
|
||||||
form := captchaBookingForm(uuid.NewString())
|
form := captchaBookingForm(uuid.NewString())
|
||||||
form.Set("cap-token", token)
|
form.Set("cap-token", "host-already-consumed-this")
|
||||||
rec := postCaptchaBooking(h, form)
|
rec := postCaptchaBooking(h, form, "1")
|
||||||
|
|
||||||
if got := bookings.Load(); got != 1 {
|
if got := bookings.Load(); got != 1 {
|
||||||
t.Fatalf("Cal.com booking created %d times; want 1 (valid token must pass captcha)", got)
|
t.Fatalf("Cal.com booking created %d times; want 1 (host-verified captcha must pass)", got)
|
||||||
}
|
}
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String())
|
||||||
@ -172,16 +165,13 @@ func TestCalcomBooking_CaptchaEnabled_ProceedsWithValidToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCalcomBooking_CaptchaDisabled_TokenNotRequired(t *testing.T) {
|
func TestCalcomBooking_CaptchaDisabled_HeaderNotRequired(t *testing.T) {
|
||||||
fake, bookings := fakeCalcomServer(t)
|
fake, bookings := fakeCalcomServer(t)
|
||||||
withCalcomBaseURL(t, fake.URL)
|
withCalcomBaseURL(t, fake.URL)
|
||||||
|
|
||||||
h := newCalcomCaptchaHandler(t, false)
|
h := newCalcomCaptchaHandler(t, false)
|
||||||
// A non-nil server would still reject if enforcement wrongly kicked in; nil
|
|
||||||
// proves the disabled path never touches the verifier.
|
|
||||||
SetCaptchaServer(nil)
|
|
||||||
|
|
||||||
rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString())) // no cap-token
|
rec := postCaptchaBooking(h, captchaBookingForm(uuid.NewString()), "") // no header
|
||||||
|
|
||||||
if got := bookings.Load(); got != 1 {
|
if got := bookings.Load(); got != 1 {
|
||||||
t.Fatalf("Cal.com booking created %d times; want 1 (captcha disabled)", got)
|
t.Fatalf("Cal.com booking created %d times; want 1 (captcha disabled)", got)
|
||||||
@ -194,21 +184,22 @@ func TestCalcomBooking_CaptchaDisabled_TokenNotRequired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCalcomBooking_CaptchaEnabled_FailsClosedWhenServerNil(t *testing.T) {
|
// TestCalcomBooking_CaptchaEnabled_FailsClosedWithoutHostStamp pins the
|
||||||
|
// fail-closed contract: a request that carries a cap-token but NO host stamp
|
||||||
|
// (instance captcha server down, or the token was invalid/replayed host-side)
|
||||||
|
// must be rejected — the guest never verifies tokens itself.
|
||||||
|
func TestCalcomBooking_CaptchaEnabled_FailsClosedWithoutHostStamp(t *testing.T) {
|
||||||
fake, bookings := fakeCalcomServer(t)
|
fake, bookings := fakeCalcomServer(t)
|
||||||
withCalcomBaseURL(t, fake.URL)
|
withCalcomBaseURL(t, fake.URL)
|
||||||
|
|
||||||
h := newCalcomCaptchaHandler(t, true)
|
h := newCalcomCaptchaHandler(t, true)
|
||||||
// nil captcha server: the instance secret failed at startup. An enabled
|
|
||||||
// block must reject rather than allow the booking through unverified.
|
|
||||||
SetCaptchaServer(nil)
|
|
||||||
|
|
||||||
form := captchaBookingForm(uuid.NewString())
|
form := captchaBookingForm(uuid.NewString())
|
||||||
form.Set("cap-token", "anything")
|
form.Set("cap-token", "anything")
|
||||||
rec := postCaptchaBooking(h, form)
|
rec := postCaptchaBooking(h, form, "")
|
||||||
|
|
||||||
if got := bookings.Load(); got != 0 {
|
if got := bookings.Load(); got != 0 {
|
||||||
t.Fatalf("Cal.com booking created %d times; want 0 (fail closed on nil server)", got)
|
t.Fatalf("Cal.com booking created %d times; want 0 (fail closed without host stamp)", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(rec.Body.String(), "aptcha") {
|
if !strings.Contains(rec.Body.String(), "aptcha") {
|
||||||
t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
|
t.Fatalf("expected captcha error fragment, got: %s", rec.Body.String())
|
||||||
@ -232,11 +223,9 @@ func TestCalcomBooking_CaptchaBypass_BlockIdCannotStripRequirement(t *testing.T)
|
|||||||
withCalcomBaseURL(t, fake.URL)
|
withCalcomBaseURL(t, fake.URL)
|
||||||
|
|
||||||
h := newCalcomCaptchaHandler(t, true) // published block requires captcha
|
h := newCalcomCaptchaHandler(t, true) // published block requires captcha
|
||||||
SetCaptchaServer(newCalcomTestCaptchaServer())
|
|
||||||
t.Cleanup(func() { SetCaptchaServer(nil) })
|
|
||||||
|
|
||||||
// No valid cap-token; the requirement must not depend on blockId.
|
// No host stamp; the requirement must not depend on blockId.
|
||||||
rec := postCaptchaBooking(h, captchaBookingForm(tc.blockID))
|
rec := postCaptchaBooking(h, captchaBookingForm(tc.blockID), "")
|
||||||
|
|
||||||
if got := bookings.Load(); got != 0 {
|
if got := bookings.Load(); got != 0 {
|
||||||
t.Fatalf("Cal.com booking created %d times; want 0 (captcha requirement must not depend on blockId)", got)
|
t.Fatalf("Cal.com booking created %d times; want 0 (captcha requirement must not depend on blockId)", got)
|
||||||
@ -252,7 +241,7 @@ func TestCalcomBooking_CaptchaBypass_BlockIdCannotStripRequirement(t *testing.T)
|
|||||||
// deliberate choice for the requirement lookup: a resolver (DB) error is logged
|
// deliberate choice for the requirement lookup: a resolver (DB) error is logged
|
||||||
// and treated as "not required", so a transient DB blip cannot block every
|
// and treated as "not required", so a transient DB blip cannot block every
|
||||||
// booking site-wide. The honeypot + per-IP rate limit remain the baseline
|
// booking site-wide. The honeypot + per-IP rate limit remain the baseline
|
||||||
// protection. (This is distinct from the nil-captcha-server path, which fails
|
// protection. (This is distinct from the missing-host-stamp path, which fails
|
||||||
// CLOSED when a block IS known to require captcha.)
|
// CLOSED when a block IS known to require captcha.)
|
||||||
func TestCalcomBooking_CaptchaRequirement_ResolverErrorFailsOpen(t *testing.T) {
|
func TestCalcomBooking_CaptchaRequirement_ResolverErrorFailsOpen(t *testing.T) {
|
||||||
fake, bookings := fakeCalcomServer(t)
|
fake, bookings := fakeCalcomServer(t)
|
||||||
@ -263,10 +252,8 @@ func TestCalcomBooking_CaptchaRequirement_ResolverErrorFailsOpen(t *testing.T) {
|
|||||||
t.Fatalf("SetAPIKey: %v", err)
|
t.Fatalf("SetAPIKey: %v", err)
|
||||||
}
|
}
|
||||||
h.blockConfig = fakeBlockResolver{err: errors.New("db unavailable")}
|
h.blockConfig = fakeBlockResolver{err: errors.New("db unavailable")}
|
||||||
SetCaptchaServer(newCalcomTestCaptchaServer())
|
|
||||||
t.Cleanup(func() { SetCaptchaServer(nil) })
|
|
||||||
|
|
||||||
rec := postCaptchaBooking(h, captchaBookingForm("")) // no cap-token
|
rec := postCaptchaBooking(h, captchaBookingForm(""), "") // no host stamp
|
||||||
if got := bookings.Load(); got != 1 {
|
if got := bookings.Load(); got != 1 {
|
||||||
t.Fatalf("Cal.com booking created %d times; want 1 (resolver error → captcha not enforced; honeypot + rate limit still apply)", got)
|
t.Fatalf("Cal.com booking created %d times; want 1 (resolver error → captcha not enforced; honeypot + rate limit still apply)", got)
|
||||||
}
|
}
|
||||||
@ -274,67 +261,3 @@ func TestCalcomBooking_CaptchaRequirement_ResolverErrorFailsOpen(t *testing.T) {
|
|||||||
t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d; want 200 (body=%s)", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// captcha PoW helpers — local copies of the (unexported) core/captcha PRNG so
|
|
||||||
// the test can solve a challenge and mint a real verification token. Mirrors
|
|
||||||
// the helpers in internal/handlers/captcha_challenge_test.go.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func mintCalcomCaptchaToken(t *testing.T, srv *captcha.Server) string {
|
|
||||||
t.Helper()
|
|
||||||
ch, err := srv.CreateChallenge()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateChallenge: %v", err)
|
|
||||||
}
|
|
||||||
intSols := solveCalcomChallenge(ch.Token, ch.Challenge.C, ch.Challenge.S, ch.Challenge.D)
|
|
||||||
sols := make([]string, len(intSols))
|
|
||||||
for i, n := range intSols {
|
|
||||||
sols[i] = strconv.Itoa(n)
|
|
||||||
}
|
|
||||||
rd := srv.Redeem(ch.Token, sols)
|
|
||||||
if !rd.Success || rd.Token == "" {
|
|
||||||
t.Fatalf("Redeem failed: %+v", rd)
|
|
||||||
}
|
|
||||||
return rd.Token
|
|
||||||
}
|
|
||||||
|
|
||||||
func solveCalcomChallenge(token string, c, s, d int) []int {
|
|
||||||
sols := make([]int, c)
|
|
||||||
for i := 1; i <= c; i++ {
|
|
||||||
salt := calcomPRNG(token+strconv.Itoa(i), s)
|
|
||||||
target := calcomPRNG(token+strconv.Itoa(i)+"d", d)
|
|
||||||
for n := 0; ; n++ {
|
|
||||||
sum := sha256.Sum256([]byte(salt + strconv.Itoa(n)))
|
|
||||||
h := hex.EncodeToString(sum[:])
|
|
||||||
if len(h) >= len(target) && h[:len(target)] == target {
|
|
||||||
sols[i-1] = n
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sols
|
|
||||||
}
|
|
||||||
|
|
||||||
func calcomPRNG(seed string, length int) string {
|
|
||||||
const mask = 0xFFFFFFFF
|
|
||||||
state := calcomFNV1a(seed)
|
|
||||||
var b strings.Builder
|
|
||||||
for b.Len() < length {
|
|
||||||
state ^= (state << 13) & mask
|
|
||||||
state ^= state >> 17
|
|
||||||
state ^= (state << 5) & mask
|
|
||||||
state &= mask
|
|
||||||
fmt.Fprintf(&b, "%08x", state)
|
|
||||||
}
|
|
||||||
return b.String()[:length]
|
|
||||||
}
|
|
||||||
|
|
||||||
func calcomFNV1a(s string) uint32 {
|
|
||||||
var h uint32 = 2166136261
|
|
||||||
for _, ch := range s {
|
|
||||||
h ^= uint32(ch)
|
|
||||||
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) & 0xFFFFFFFF
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/auth"
|
"git.dev.alexdunmow.com/block/pluginsdk/auth"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|||||||
2
main.go
2
main.go
@ -6,7 +6,7 @@
|
|||||||
// init (hence wasmguest.Serve) before any ABI hook call. main is never called.
|
// init (hence wasmguest.Serve) before any ABI hook call. main is never called.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "git.dev.alexdunmow.com/block/core/plugin/wasmguest"
|
import "git.dev.alexdunmow.com/block/pluginsdk/plugin/wasmguest"
|
||||||
|
|
||||||
func init() { wasmguest.Serve(Registration) }
|
func init() { wasmguest.Serve(Registration) }
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
[plugin]
|
[plugin]
|
||||||
name = "calcomblock"
|
name = "calcomblock"
|
||||||
display_name = "Cal.com Booking"
|
display_name = "Cal.com Booking"
|
||||||
scope = "@ninja"
|
scope = "ninja"
|
||||||
version = "2.0.1"
|
version = "2.0.9"
|
||||||
description = "Embeddable Cal.com booking calendar block with custom styling, timezone-aware slot windowing, honeypot + captcha + rate-limited public booking endpoints, and webhook receiver."
|
description = "Embeddable Cal.com booking calendar block with custom styling, timezone-aware slot windowing, honeypot + captcha + rate-limited public booking endpoints, and webhook receiver."
|
||||||
kind = "plugin"
|
kind = "plugin"
|
||||||
categories = ["forms"]
|
categories = ["forms"]
|
||||||
tags = ["calcom", "booking", "calendar", "scheduling", "appointments"]
|
tags = ["calcom", "booking", "calendar", "scheduling", "appointments"]
|
||||||
|
allowed_hosts = ["api.cal.com"]
|
||||||
|
|||||||
@ -5,9 +5,9 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/blocks"
|
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
|
||||||
"git.dev.alexdunmow.com/block/core/plugin"
|
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||||
"git.dev.alexdunmow.com/block/core/templates"
|
"git.dev.alexdunmow.com/block/pluginsdk/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:web/dist/assets
|
//go:embed all:web/dist/assets
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/blocks"
|
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
|
||||||
"git.dev.alexdunmow.com/block/core/plugin"
|
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||||
"git.dev.alexdunmow.com/block/core/templates"
|
"git.dev.alexdunmow.com/block/pluginsdk/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Registration is the compile-time plugin registration for the Cal.com Booking block.
|
// Registration is the compile-time plugin registration for the Cal.com Booking block.
|
||||||
|
|||||||
63
router_paths_test.go
Normal file
63
router_paths_test.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.dev.alexdunmow.com/block/pluginsdk/auth"
|
||||||
|
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRouterMatchesAbsolutePluginPaths pins the wasm routing contract: the
|
||||||
|
// host forwards the FULL request path (/api/plugins/calcomblock/...) to the
|
||||||
|
// guest with no prefix strip, so every route must be registered absolute.
|
||||||
|
// Relative registrations 404 the plugin's entire HTTP surface at runtime —
|
||||||
|
// exactly the regression that shipped in 2.0.0–2.0.2.
|
||||||
|
func TestRouterMatchesAbsolutePluginPaths(t *testing.T) {
|
||||||
|
router, ok := NewCalcomRouter(plugin.CoreServices{}).(chi.Routes)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("NewCalcomRouter no longer returns a chi router")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct{ method, path string }{
|
||||||
|
{http.MethodGet, "/api/plugins/calcomblock/reset"},
|
||||||
|
{http.MethodGet, "/api/plugins/calcomblock/slots"},
|
||||||
|
{http.MethodGet, "/api/plugins/calcomblock/date-grid"},
|
||||||
|
{http.MethodPost, "/api/plugins/calcomblock/book"},
|
||||||
|
{http.MethodPost, "/api/plugins/calcomblock/cancel"},
|
||||||
|
{http.MethodGet, "/api/plugins/calcomblock/event-types"},
|
||||||
|
} {
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
if !router.Match(rctx, tc.method, tc.path) {
|
||||||
|
t.Errorf("%s %s does not match any route — must be registered at the absolute plugin path", tc.method, tc.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRouterRebuildsClaimsFromTrustedHeaders pins the wasm identity contract:
|
||||||
|
// context does not cross the ABI, so the ONLY way requireAdmin can see the
|
||||||
|
// caller is for the router to rebuild claims from the host-stamped
|
||||||
|
// X-Bn-Verified-* headers (auth.TrustedHeaderMiddleware). 2.0.3 shipped
|
||||||
|
// without it — every admin endpoint 401'd for real admins. A viewer-role
|
||||||
|
// header must reach requireAdmin and be rejected 403 (claims seen), never
|
||||||
|
// 401 (claims lost); no handler runs, so empty CoreServices is safe.
|
||||||
|
func TestRouterRebuildsClaimsFromTrustedHeaders(t *testing.T) {
|
||||||
|
router := NewCalcomRouter(plugin.CoreServices{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/plugins/calcomblock/settings", nil)
|
||||||
|
req.Header.Set(auth.HeaderVerifiedUserID, uuid.NewString())
|
||||||
|
req.Header.Set(auth.HeaderVerifiedEmail, "viewer@example.test")
|
||||||
|
req.Header.Set(auth.HeaderVerifiedRole, "viewer")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code == http.StatusUnauthorized {
|
||||||
|
t.Fatalf("viewer trusted headers yielded 401 — TrustedHeaderMiddleware is not mounted, claims never reach requireAdmin")
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("viewer trusted headers: expected 403 from requireAdmin, got %d (body %q)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,8 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/crypto"
|
"git.dev.alexdunmow.com/block/pluginsdk/crypto"
|
||||||
"git.dev.alexdunmow.com/block/core/settings"
|
"git.dev.alexdunmow.com/block/pluginsdk/settings"
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/calcomblock/internal/db"
|
"git.dev.alexdunmow.com/block/calcomblock/internal/db"
|
||||||
"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
|
"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
|
||||||
|
|||||||
@ -383,6 +383,29 @@ function CalcomBlockEditor({ content, onChange }) {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
|
||||||
|
className: "flex items-center justify-between rounded-lg border p-3",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
|
||||||
|
className: "space-y-0.5",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsxRuntimeExports.jsx(Label, { children: t("calcom.editor.captchaEnabled.label", "Enable captcha") }),
|
||||||
|
/* @__PURE__ */ jsxRuntimeExports.jsx("p", {
|
||||||
|
className: "text-xs text-muted-foreground",
|
||||||
|
children: t(
|
||||||
|
"calcom.editor.captchaEnabled.help",
|
||||||
|
"Require visitors to solve a privacy-friendly proof-of-work captcha before booking (spam protection).",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsxRuntimeExports.jsx(Switch, {
|
||||||
|
"checked": getBool("captchaEnabled", false),
|
||||||
|
"onCheckedChange": (v) => handleFieldChange("captchaEnabled", v),
|
||||||
|
"aria-label": t("calcom.editor.captchaEnabled.label", "Enable captcha"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
2
web/dist/assets/remoteEntry.js
vendored
2
web/dist/assets/remoteEntry.js
vendored
@ -3,7 +3,7 @@ const exportSet = new Set(["Module", "__esModule", "default", "_export_sfc"]);
|
|||||||
let moduleMap = {
|
let moduleMap = {
|
||||||
"./editor": () => {
|
"./editor": () => {
|
||||||
dynamicLoadingCss(["style-Bs2zy9jQ.css"], false, "./editor");
|
dynamicLoadingCss(["style-Bs2zy9jQ.css"], false, "./editor");
|
||||||
return __federation_import("./__federation_expose_Editor-BDCHVRx7.js").then((module) => (Object.keys(module).every((item) => exportSet.has(item)) ? () => module.default : () => module));
|
return __federation_import("./__federation_expose_Editor-DbTTOPh9.js").then((module) => (Object.keys(module).every((item) => exportSet.has(item)) ? () => module.default : () => module));
|
||||||
},
|
},
|
||||||
"./settings": () => {
|
"./settings": () => {
|
||||||
dynamicLoadingCss(["style-Bs2zy9jQ.css"], false, "./settings");
|
dynamicLoadingCss(["style-Bs2zy9jQ.css"], false, "./settings");
|
||||||
|
|||||||
@ -318,6 +318,20 @@ function CalcomBlockEditor({ content, onChange }: BlockEditorProps) {
|
|||||||
aria-label={t("calcom.editor.showTimezone.label", "Show Timezone Selector")}
|
aria-label={t("calcom.editor.showTimezone.label", "Show Timezone Selector")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>{t("calcom.editor.captchaEnabled.label", "Enable captcha")}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("calcom.editor.captchaEnabled.help", "Require visitors to solve a privacy-friendly proof-of-work captcha before booking (spam protection).")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={getBool("captchaEnabled", false)}
|
||||||
|
onCheckedChange={(v) => handleFieldChange("captchaEnabled", v)}
|
||||||
|
aria-label={t("calcom.editor.captchaEnabled.label", "Enable captcha")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -18,5 +18,10 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^6.4.1"
|
"vite": "^6.4.1"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user