|
|
package executor |
|
|
|
|
|
import ( |
|
|
"bytes" |
|
|
"context" |
|
|
"fmt" |
|
|
"html" |
|
|
"net/http" |
|
|
"sort" |
|
|
"strings" |
|
|
"time" |
|
|
|
|
|
"github.com/gin-gonic/gin" |
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config" |
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util" |
|
|
) |
|
|
|
|
|
const ( |
|
|
apiAttemptsKey = "API_UPSTREAM_ATTEMPTS" |
|
|
apiRequestKey = "API_REQUEST" |
|
|
apiResponseKey = "API_RESPONSE" |
|
|
) |
|
|
|
|
|
|
|
|
type upstreamRequestLog struct { |
|
|
URL string |
|
|
Method string |
|
|
Headers http.Header |
|
|
Body []byte |
|
|
Provider string |
|
|
AuthID string |
|
|
AuthLabel string |
|
|
AuthType string |
|
|
AuthValue string |
|
|
} |
|
|
|
|
|
type upstreamAttempt struct { |
|
|
index int |
|
|
request string |
|
|
response *strings.Builder |
|
|
responseIntroWritten bool |
|
|
statusWritten bool |
|
|
headersWritten bool |
|
|
bodyStarted bool |
|
|
bodyHasContent bool |
|
|
errorWritten bool |
|
|
} |
|
|
|
|
|
|
|
|
func recordAPIRequest(ctx context.Context, cfg *config.Config, info upstreamRequestLog) { |
|
|
if cfg == nil || !cfg.RequestLog { |
|
|
return |
|
|
} |
|
|
ginCtx := ginContextFrom(ctx) |
|
|
if ginCtx == nil { |
|
|
return |
|
|
} |
|
|
|
|
|
attempts := getAttempts(ginCtx) |
|
|
index := len(attempts) + 1 |
|
|
|
|
|
builder := &strings.Builder{} |
|
|
builder.WriteString(fmt.Sprintf("=== API REQUEST %d ===\n", index)) |
|
|
builder.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano))) |
|
|
if info.URL != "" { |
|
|
builder.WriteString(fmt.Sprintf("Upstream URL: %s\n", info.URL)) |
|
|
} else { |
|
|
builder.WriteString("Upstream URL: <unknown>\n") |
|
|
} |
|
|
if info.Method != "" { |
|
|
builder.WriteString(fmt.Sprintf("HTTP Method: %s\n", info.Method)) |
|
|
} |
|
|
if auth := formatAuthInfo(info); auth != "" { |
|
|
builder.WriteString(fmt.Sprintf("Auth: %s\n", auth)) |
|
|
} |
|
|
builder.WriteString("\nHeaders:\n") |
|
|
writeHeaders(builder, info.Headers) |
|
|
builder.WriteString("\nBody:\n") |
|
|
if len(info.Body) > 0 { |
|
|
builder.WriteString(string(bytes.Clone(info.Body))) |
|
|
} else { |
|
|
builder.WriteString("<empty>") |
|
|
} |
|
|
builder.WriteString("\n\n") |
|
|
|
|
|
attempt := &upstreamAttempt{ |
|
|
index: index, |
|
|
request: builder.String(), |
|
|
response: &strings.Builder{}, |
|
|
} |
|
|
attempts = append(attempts, attempt) |
|
|
ginCtx.Set(apiAttemptsKey, attempts) |
|
|
updateAggregatedRequest(ginCtx, attempts) |
|
|
} |
|
|
|
|
|
|
|
|
func recordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status int, headers http.Header) { |
|
|
if cfg == nil || !cfg.RequestLog { |
|
|
return |
|
|
} |
|
|
ginCtx := ginContextFrom(ctx) |
|
|
if ginCtx == nil { |
|
|
return |
|
|
} |
|
|
attempts, attempt := ensureAttempt(ginCtx) |
|
|
ensureResponseIntro(attempt) |
|
|
|
|
|
if status > 0 && !attempt.statusWritten { |
|
|
attempt.response.WriteString(fmt.Sprintf("Status: %d\n", status)) |
|
|
attempt.statusWritten = true |
|
|
} |
|
|
if !attempt.headersWritten { |
|
|
attempt.response.WriteString("Headers:\n") |
|
|
writeHeaders(attempt.response, headers) |
|
|
attempt.headersWritten = true |
|
|
attempt.response.WriteString("\n") |
|
|
} |
|
|
|
|
|
updateAggregatedResponse(ginCtx, attempts) |
|
|
} |
|
|
|
|
|
|
|
|
func recordAPIResponseError(ctx context.Context, cfg *config.Config, err error) { |
|
|
if cfg == nil || !cfg.RequestLog || err == nil { |
|
|
return |
|
|
} |
|
|
ginCtx := ginContextFrom(ctx) |
|
|
if ginCtx == nil { |
|
|
return |
|
|
} |
|
|
attempts, attempt := ensureAttempt(ginCtx) |
|
|
ensureResponseIntro(attempt) |
|
|
|
|
|
if attempt.bodyStarted && !attempt.bodyHasContent { |
|
|
|
|
|
attempt.bodyStarted = false |
|
|
} |
|
|
if attempt.errorWritten { |
|
|
attempt.response.WriteString("\n") |
|
|
} |
|
|
attempt.response.WriteString(fmt.Sprintf("Error: %s\n", err.Error())) |
|
|
attempt.errorWritten = true |
|
|
|
|
|
updateAggregatedResponse(ginCtx, attempts) |
|
|
} |
|
|
|
|
|
|
|
|
func appendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byte) { |
|
|
if cfg == nil || !cfg.RequestLog { |
|
|
return |
|
|
} |
|
|
data := bytes.TrimSpace(bytes.Clone(chunk)) |
|
|
if len(data) == 0 { |
|
|
return |
|
|
} |
|
|
ginCtx := ginContextFrom(ctx) |
|
|
if ginCtx == nil { |
|
|
return |
|
|
} |
|
|
attempts, attempt := ensureAttempt(ginCtx) |
|
|
ensureResponseIntro(attempt) |
|
|
|
|
|
if !attempt.headersWritten { |
|
|
attempt.response.WriteString("Headers:\n") |
|
|
writeHeaders(attempt.response, nil) |
|
|
attempt.headersWritten = true |
|
|
attempt.response.WriteString("\n") |
|
|
} |
|
|
if !attempt.bodyStarted { |
|
|
attempt.response.WriteString("Body:\n") |
|
|
attempt.bodyStarted = true |
|
|
} |
|
|
if attempt.bodyHasContent { |
|
|
attempt.response.WriteString("\n\n") |
|
|
} |
|
|
attempt.response.WriteString(string(data)) |
|
|
attempt.bodyHasContent = true |
|
|
|
|
|
updateAggregatedResponse(ginCtx, attempts) |
|
|
} |
|
|
|
|
|
func ginContextFrom(ctx context.Context) *gin.Context { |
|
|
ginCtx, _ := ctx.Value("gin").(*gin.Context) |
|
|
return ginCtx |
|
|
} |
|
|
|
|
|
func getAttempts(ginCtx *gin.Context) []*upstreamAttempt { |
|
|
if ginCtx == nil { |
|
|
return nil |
|
|
} |
|
|
if value, exists := ginCtx.Get(apiAttemptsKey); exists { |
|
|
if attempts, ok := value.([]*upstreamAttempt); ok { |
|
|
return attempts |
|
|
} |
|
|
} |
|
|
return nil |
|
|
} |
|
|
|
|
|
func ensureAttempt(ginCtx *gin.Context) ([]*upstreamAttempt, *upstreamAttempt) { |
|
|
attempts := getAttempts(ginCtx) |
|
|
if len(attempts) == 0 { |
|
|
attempt := &upstreamAttempt{ |
|
|
index: 1, |
|
|
request: "=== API REQUEST 1 ===\n<missing>\n\n", |
|
|
response: &strings.Builder{}, |
|
|
} |
|
|
attempts = []*upstreamAttempt{attempt} |
|
|
ginCtx.Set(apiAttemptsKey, attempts) |
|
|
updateAggregatedRequest(ginCtx, attempts) |
|
|
} |
|
|
return attempts, attempts[len(attempts)-1] |
|
|
} |
|
|
|
|
|
func ensureResponseIntro(attempt *upstreamAttempt) { |
|
|
if attempt == nil || attempt.response == nil || attempt.responseIntroWritten { |
|
|
return |
|
|
} |
|
|
attempt.response.WriteString(fmt.Sprintf("=== API RESPONSE %d ===\n", attempt.index)) |
|
|
attempt.response.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano))) |
|
|
attempt.response.WriteString("\n") |
|
|
attempt.responseIntroWritten = true |
|
|
} |
|
|
|
|
|
func updateAggregatedRequest(ginCtx *gin.Context, attempts []*upstreamAttempt) { |
|
|
if ginCtx == nil { |
|
|
return |
|
|
} |
|
|
var builder strings.Builder |
|
|
for _, attempt := range attempts { |
|
|
builder.WriteString(attempt.request) |
|
|
} |
|
|
ginCtx.Set(apiRequestKey, []byte(builder.String())) |
|
|
} |
|
|
|
|
|
func updateAggregatedResponse(ginCtx *gin.Context, attempts []*upstreamAttempt) { |
|
|
if ginCtx == nil { |
|
|
return |
|
|
} |
|
|
var builder strings.Builder |
|
|
for idx, attempt := range attempts { |
|
|
if attempt == nil || attempt.response == nil { |
|
|
continue |
|
|
} |
|
|
responseText := attempt.response.String() |
|
|
if responseText == "" { |
|
|
continue |
|
|
} |
|
|
builder.WriteString(responseText) |
|
|
if !strings.HasSuffix(responseText, "\n") { |
|
|
builder.WriteString("\n") |
|
|
} |
|
|
if idx < len(attempts)-1 { |
|
|
builder.WriteString("\n") |
|
|
} |
|
|
} |
|
|
ginCtx.Set(apiResponseKey, []byte(builder.String())) |
|
|
} |
|
|
|
|
|
func writeHeaders(builder *strings.Builder, headers http.Header) { |
|
|
if builder == nil { |
|
|
return |
|
|
} |
|
|
if len(headers) == 0 { |
|
|
builder.WriteString("<none>\n") |
|
|
return |
|
|
} |
|
|
keys := make([]string, 0, len(headers)) |
|
|
for key := range headers { |
|
|
keys = append(keys, key) |
|
|
} |
|
|
sort.Strings(keys) |
|
|
for _, key := range keys { |
|
|
values := headers[key] |
|
|
if len(values) == 0 { |
|
|
builder.WriteString(fmt.Sprintf("%s:\n", key)) |
|
|
continue |
|
|
} |
|
|
for _, value := range values { |
|
|
masked := util.MaskSensitiveHeaderValue(key, value) |
|
|
builder.WriteString(fmt.Sprintf("%s: %s\n", key, masked)) |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
func formatAuthInfo(info upstreamRequestLog) string { |
|
|
var parts []string |
|
|
if trimmed := strings.TrimSpace(info.Provider); trimmed != "" { |
|
|
parts = append(parts, fmt.Sprintf("provider=%s", trimmed)) |
|
|
} |
|
|
if trimmed := strings.TrimSpace(info.AuthID); trimmed != "" { |
|
|
parts = append(parts, fmt.Sprintf("auth_id=%s", trimmed)) |
|
|
} |
|
|
if trimmed := strings.TrimSpace(info.AuthLabel); trimmed != "" { |
|
|
parts = append(parts, fmt.Sprintf("label=%s", trimmed)) |
|
|
} |
|
|
|
|
|
authType := strings.ToLower(strings.TrimSpace(info.AuthType)) |
|
|
authValue := strings.TrimSpace(info.AuthValue) |
|
|
switch authType { |
|
|
case "api_key": |
|
|
if authValue != "" { |
|
|
parts = append(parts, fmt.Sprintf("type=api_key value=%s", util.HideAPIKey(authValue))) |
|
|
} else { |
|
|
parts = append(parts, "type=api_key") |
|
|
} |
|
|
case "oauth": |
|
|
if authValue != "" { |
|
|
parts = append(parts, fmt.Sprintf("type=oauth account=%s", authValue)) |
|
|
} else { |
|
|
parts = append(parts, "type=oauth") |
|
|
} |
|
|
default: |
|
|
if authType != "" { |
|
|
if authValue != "" { |
|
|
parts = append(parts, fmt.Sprintf("type=%s value=%s", authType, authValue)) |
|
|
} else { |
|
|
parts = append(parts, fmt.Sprintf("type=%s", authType)) |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
return strings.Join(parts, ", ") |
|
|
} |
|
|
|
|
|
func summarizeErrorBody(contentType string, body []byte) string { |
|
|
isHTML := strings.Contains(strings.ToLower(contentType), "text/html") |
|
|
if !isHTML { |
|
|
trimmed := bytes.TrimSpace(bytes.ToLower(body)) |
|
|
if bytes.HasPrefix(trimmed, []byte("<!doctype html")) || bytes.HasPrefix(trimmed, []byte("<html")) { |
|
|
isHTML = true |
|
|
} |
|
|
} |
|
|
if isHTML { |
|
|
if title := extractHTMLTitle(body); title != "" { |
|
|
return title |
|
|
} |
|
|
return "[html body omitted]" |
|
|
} |
|
|
return string(body) |
|
|
} |
|
|
|
|
|
func extractHTMLTitle(body []byte) string { |
|
|
lower := bytes.ToLower(body) |
|
|
start := bytes.Index(lower, []byte("<title")) |
|
|
if start == -1 { |
|
|
return "" |
|
|
} |
|
|
gt := bytes.IndexByte(lower[start:], '>') |
|
|
if gt == -1 { |
|
|
return "" |
|
|
} |
|
|
start += gt + 1 |
|
|
end := bytes.Index(lower[start:], []byte("</title>")) |
|
|
if end == -1 { |
|
|
return "" |
|
|
} |
|
|
title := string(body[start : start+end]) |
|
|
title = html.UnescapeString(title) |
|
|
title = strings.TrimSpace(title) |
|
|
if title == "" { |
|
|
return "" |
|
|
} |
|
|
return strings.Join(strings.Fields(title), " ") |
|
|
} |
|
|
|