diff --git a/modules/turnstile/turnstile.go b/modules/turnstile/turnstile.go index 381cb71fda5..6bae89caee3 100644 --- a/modules/turnstile/turnstile.go +++ b/modules/turnstile/turnstile.go @@ -12,9 +12,20 @@ import ( "strings" "gitea.dev/modules/json" + "gitea.dev/modules/proxy" "gitea.dev/modules/setting" + "gitea.dev/modules/util" ) +// httpClient returns an HTTP client that honors Gitea's proxy configuration. +var httpClient = util.OnceValue[*http.Client]{ + Func: func() *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = proxy.Proxy() + return &http.Client{Transport: transport} + }, +} + // Response is the structure of JSON returned from API type Response struct { Success bool `json:"success"` @@ -40,7 +51,7 @@ func Verify(ctx context.Context, response string) (bool, error) { } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := http.DefaultClient.Do(req) + resp, err := httpClient.Value().Do(req) if err != nil { return false, fmt.Errorf("Failed to send CAPTCHA response: %w", err) } diff --git a/modules/turnstile/turnstile_test.go b/modules/turnstile/turnstile_test.go new file mode 100644 index 00000000000..a997e4c32a6 --- /dev/null +++ b/modules/turnstile/turnstile_test.go @@ -0,0 +1,38 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package turnstile + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHTTPClientHonorsProxy(t *testing.T) { + proxyURL, err := url.Parse("http://proxy.example.com:3128") + require.NoError(t, err) + + defer test.MockVariableValue(&setting.Proxy.Enabled, true)() + defer test.MockVariableValue(&setting.Proxy.ProxyURL, proxyURL.String())() + defer test.MockVariableValue(&setting.Proxy.ProxyURLFixed, proxyURL)() + defer test.MockVariableValue(&setting.Proxy.ProxyHosts, []string{"**"})() + httpClient.Reset() + transport, ok := httpClient.Value().Transport.(*http.Transport) + require.True(t, ok) + require.NotNil(t, transport.Proxy) + + // The Turnstile verification request must be routed through the configured proxy. + req := httptest.NewRequest(http.MethodPost, "https://any.example.com", nil) + got, err := transport.Proxy(req) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, proxyURL.String(), got.String()) +} diff --git a/modules/util/sync.go b/modules/util/sync.go new file mode 100644 index 00000000000..447bfe10f48 --- /dev/null +++ b/modules/util/sync.go @@ -0,0 +1,51 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package util + +import ( + "sync" + "sync/atomic" +) + +type onceValueResult[T any] struct { + value T + panic any +} + +// OnceValue is similar to Golang's "sync.OnceValue", but can be reset. +type OnceValue[T any] struct { + Func func() T + mu sync.Mutex + res atomic.Pointer[onceValueResult[T]] +} + +func (o *OnceValue[T]) Value() T { + res := o.res.Load() + if res == nil { + o.mu.Lock() + defer o.mu.Unlock() + res = o.res.Load() + if res == nil { + res = &onceValueResult[T]{} + defer func() { + res.panic = recover() + o.res.Store(res) + if res.panic != nil { + panic(res.panic) + } + }() + res.value = o.Func() + } + } + if res.panic != nil { + panic(res.panic) + } + return res.value +} + +func (o *OnceValue[T]) Reset() { + o.mu.Lock() + defer o.mu.Unlock() + o.res.Store(nil) +} diff --git a/modules/util/sync_test.go b/modules/util/sync_test.go new file mode 100644 index 00000000000..297328bd91f --- /dev/null +++ b/modules/util/sync_test.go @@ -0,0 +1,49 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOnceValue(t *testing.T) { + t.Run("RepeatCall", func(t *testing.T) { + callCount := 0 + o := OnceValue[int]{Func: func() int { + callCount++ + return 42 + }} + assert.Equal(t, 42, o.Value()) + assert.Equal(t, 42, o.Value()) + assert.Equal(t, 1, callCount) + o.Reset() + assert.Equal(t, 42, o.Value()) + assert.Equal(t, 2, callCount) + assert.Equal(t, 42, o.Value()) + assert.Equal(t, 2, callCount) + }) + + t.Run("Panic", func(t *testing.T) { + callCount := 0 + doPanic := true + o := OnceValue[int]{Func: func() int { + callCount++ + if doPanic { + panic("some error") + } + return 42 + }} + assert.PanicsWithValue(t, "some error", func() { o.Value() }) + assert.PanicsWithValue(t, "some error", func() { o.Value() }) + assert.Equal(t, 1, callCount) + doPanic = false + o.Reset() + assert.Equal(t, 42, o.Value()) + assert.Equal(t, 2, callCount) + assert.Equal(t, 42, o.Value()) + assert.Equal(t, 2, callCount) + }) +}