All checks were successful
Build and Release / release (push) Successful in 1m28s
171 lines
4.9 KiB
Go
171 lines
4.9 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"history-api/pkg/cache"
|
|
"history-api/pkg/config"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type GoongService interface {
|
|
ProxyRequest(ctx context.Context, method string, targetURL string, headers map[string]string, reqBody []byte) (int, map[string]string, []byte, error)
|
|
}
|
|
|
|
type CacheEntry struct {
|
|
StatusCode int `json:"status_code"`
|
|
Headers map[string]string `json:"headers"`
|
|
Body []byte `json:"body"`
|
|
}
|
|
|
|
type goongService struct {
|
|
redis cache.Cache
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewGoongService(redis cache.Cache) GoongService {
|
|
return &goongService{
|
|
redis: redis,
|
|
httpClient: &http.Client{
|
|
Timeout: 15 * time.Second,
|
|
Transport: &http.Transport{
|
|
MaxIdleConns: 100,
|
|
MaxIdleConnsPerHost: 100,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *goongService) ProxyRequest(ctx context.Context, method string, targetURL string, headers map[string]string, reqBody []byte) (int, map[string]string, []byte, error) {
|
|
apiKey, err := config.GetConfig("GOONG_API_KEY")
|
|
if err != nil {
|
|
return 500, nil, nil, fmt.Errorf("GOONG_API_KEY is not configured")
|
|
}
|
|
|
|
fullTargetURL := targetURL
|
|
if !strings.HasPrefix(fullTargetURL, "http://") && !strings.HasPrefix(fullTargetURL, "https://") {
|
|
fullTargetURL = "https://" + fullTargetURL
|
|
}
|
|
|
|
parsedUrl, err := url.Parse(fullTargetURL)
|
|
if err != nil || parsedUrl.Host == "" {
|
|
return 400, nil, nil, fmt.Errorf("invalid target URL")
|
|
}
|
|
|
|
if !strings.HasSuffix(parsedUrl.Host, "goong.io") {
|
|
return 403, nil, nil, fmt.Errorf("only goong.io domains are allowed")
|
|
}
|
|
|
|
q := parsedUrl.Query()
|
|
q.Set("api_key", apiKey)
|
|
parsedUrl.RawQuery = q.Encode()
|
|
finalURL := parsedUrl.String()
|
|
|
|
cacheKey := fmt.Sprintf("goong_proxy_v2:%s", finalURL)
|
|
|
|
if method == http.MethodGet {
|
|
var entry CacheEntry
|
|
if err := s.redis.Get(ctx, cacheKey, &entry); err == nil && len(entry.Body) > 0 {
|
|
return entry.StatusCode, entry.Headers, entry.Body, nil
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, finalURL, nil)
|
|
if err != nil {
|
|
return 500, nil, nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
if len(reqBody) > 0 {
|
|
req.Body = io.NopCloser(bytes.NewReader(reqBody))
|
|
}
|
|
|
|
for k, v := range headers {
|
|
lowerK := strings.ToLower(k)
|
|
if lowerK == "host" || lowerK == "connection" || lowerK == "accept-encoding" || lowerK == "keep-alive" || lowerK == "te" || lowerK == "transfer-encoding" || lowerK == "upgrade" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(lowerK, "cf-") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(lowerK, "x-forwarded-") || lowerK == "x-real-ip" || lowerK == "true-client-ip" || lowerK == "cdn-loop" {
|
|
continue
|
|
}
|
|
if lowerK == "cookie" || lowerK == "authorization" || lowerK == "x-api-key" || lowerK == "x-csrf-token" {
|
|
continue
|
|
}
|
|
req.Header.Set(k, v)
|
|
}
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return 500, nil, nil, fmt.Errorf("failed to fetch from target: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return 500, nil, nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
respHeaders := make(map[string]string)
|
|
for k, v := range resp.Header {
|
|
lowerK := strings.ToLower(k)
|
|
// Skip hop-by-hop/transport headers in response
|
|
if lowerK == "connection" || lowerK == "keep-alive" || lowerK == "transfer-encoding" || lowerK == "upgrade" {
|
|
continue
|
|
}
|
|
// Skip Cloudflare headers from Goong to prevent clashing with our own server
|
|
if strings.HasPrefix(lowerK, "cf-") || lowerK == "server" || lowerK == "date" || lowerK == "alt-svc" {
|
|
continue
|
|
}
|
|
// Skip CORS headers (handled by our own Fiber CORS middleware)
|
|
if strings.HasPrefix(lowerK, "access-control-") {
|
|
continue
|
|
}
|
|
// Skip content encoding/length (handled by Fiber / Go client automatically)
|
|
if lowerK == "content-encoding" || lowerK == "content-length" {
|
|
continue
|
|
}
|
|
respHeaders[k] = v[0]
|
|
}
|
|
|
|
contentType := resp.Header.Get("Content-Type")
|
|
isText := strings.Contains(contentType, "json") ||
|
|
strings.Contains(contentType, "text") ||
|
|
strings.Contains(contentType, "javascript") ||
|
|
strings.Contains(contentType, "xml")
|
|
|
|
if isText && len(respBody) > 0 {
|
|
pattern := fmt.Sprintf(`(?i)([?&])api_key=%s(&?)`, regexp.QuoteMeta(apiKey))
|
|
if re, err := regexp.Compile(pattern); err == nil {
|
|
respBodyStr := string(respBody)
|
|
cleanedStr := re.ReplaceAllStringFunc(respBodyStr, func(match string) string {
|
|
groups := re.FindStringSubmatch(match)
|
|
if len(groups) > 2 && groups[2] == "&" {
|
|
return groups[1]
|
|
}
|
|
return ""
|
|
})
|
|
respBody = []byte(cleanedStr)
|
|
}
|
|
}
|
|
|
|
if method == http.MethodGet && resp.StatusCode == http.StatusOK {
|
|
entry := CacheEntry{
|
|
StatusCode: resp.StatusCode,
|
|
Headers: respHeaders,
|
|
Body: respBody,
|
|
}
|
|
_ = s.redis.Set(ctx, cacheKey, entry, 24*time.Hour)
|
|
}
|
|
|
|
return resp.StatusCode, respHeaders, respBody, nil
|
|
}
|