Harden explorer AI runtime and API ownership
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -121,10 +122,19 @@ type openAIOutputContent struct {
|
||||
}
|
||||
|
||||
func (s *Server) handleAIContext(w http.ResponseWriter, r *http.Request) {
|
||||
startedAt := time.Now()
|
||||
clientIP := clientIPAddress(r)
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
if allowed, retryAfter := s.allowAIRequest(r, "context"); !allowed {
|
||||
w.Header().Set("Retry-After", fmt.Sprintf("%.0f", retryAfter.Seconds()))
|
||||
s.aiMetrics.Record("context", http.StatusTooManyRequests, time.Since(startedAt), "rate_limited", clientIP)
|
||||
s.logAIRequest("context", http.StatusTooManyRequests, time.Since(startedAt), clientIP, explorerAIModel(), "rate_limited")
|
||||
writeErrorDetailed(w, http.StatusTooManyRequests, "rate_limited", "explorer ai context rate limit exceeded", "please retry shortly")
|
||||
return
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
pageContext := map[string]string{
|
||||
@@ -142,16 +152,28 @@ func (s *Server) handleAIContext(w http.ResponseWriter, r *http.Request) {
|
||||
Warnings: warnings,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
s.aiMetrics.Record("context", http.StatusOK, time.Since(startedAt), "", clientIP)
|
||||
s.logAIRequest("context", http.StatusOK, time.Since(startedAt), clientIP, explorerAIModel(), "")
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
||||
startedAt := time.Now()
|
||||
clientIP := clientIPAddress(r)
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
if allowed, retryAfter := s.allowAIRequest(r, "chat"); !allowed {
|
||||
w.Header().Set("Retry-After", fmt.Sprintf("%.0f", retryAfter.Seconds()))
|
||||
s.aiMetrics.Record("chat", http.StatusTooManyRequests, time.Since(startedAt), "rate_limited", clientIP)
|
||||
s.logAIRequest("chat", http.StatusTooManyRequests, time.Since(startedAt), clientIP, explorerAIModel(), "rate_limited")
|
||||
writeErrorDetailed(w, http.StatusTooManyRequests, "rate_limited", "explorer ai chat rate limit exceeded", "please retry shortly")
|
||||
return
|
||||
}
|
||||
if !explorerAIEnabled() {
|
||||
s.aiMetrics.Record("chat", http.StatusServiceUnavailable, time.Since(startedAt), "service_unavailable", clientIP)
|
||||
s.logAIRequest("chat", http.StatusServiceUnavailable, time.Since(startedAt), clientIP, explorerAIModel(), "service_unavailable")
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer ai is not configured; set OPENAI_API_KEY on the backend")
|
||||
return
|
||||
}
|
||||
@@ -176,7 +198,10 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
reply, model, err := s.callOpenAIResponses(r.Context(), messages, ctxEnvelope)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "bad_gateway", fmt.Sprintf("explorer ai request failed: %v", err))
|
||||
statusCode, code, message, details := mapAIUpstreamError(err)
|
||||
s.aiMetrics.Record("chat", statusCode, time.Since(startedAt), code, clientIP)
|
||||
s.logAIRequest("chat", statusCode, time.Since(startedAt), clientIP, model, code)
|
||||
writeErrorDetailed(w, statusCode, code, message, details)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -188,8 +213,9 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
||||
Warnings: warnings,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
s.aiMetrics.Record("chat", http.StatusOK, time.Since(startedAt), "", clientIP)
|
||||
s.logAIRequest("chat", http.StatusOK, time.Since(startedAt), clientIP, model, "")
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func explorerAIEnabled() bool {
|
||||
@@ -309,6 +335,28 @@ func (s *Server) queryAIStats(ctx context.Context) (map[string]any, error) {
|
||||
stats["latest_block"] = latestBlock
|
||||
}
|
||||
|
||||
if len(stats) == 0 {
|
||||
var totalBlocks int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM blocks`).Scan(&totalBlocks); err == nil {
|
||||
stats["total_blocks"] = totalBlocks
|
||||
}
|
||||
|
||||
var totalTransactions int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions`).Scan(&totalTransactions); err == nil {
|
||||
stats["total_transactions"] = totalTransactions
|
||||
}
|
||||
|
||||
var totalAddresses int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM addresses`).Scan(&totalAddresses); err == nil {
|
||||
stats["total_addresses"] = totalAddresses
|
||||
}
|
||||
|
||||
var latestBlock int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(number), 0) FROM blocks`).Scan(&latestBlock); err == nil {
|
||||
stats["latest_block"] = latestBlock
|
||||
}
|
||||
}
|
||||
|
||||
if len(stats) == 0 {
|
||||
return nil, fmt.Errorf("no indexed stats available")
|
||||
}
|
||||
@@ -337,7 +385,30 @@ func (s *Server) queryAITransaction(ctx context.Context, hash string) (map[strin
|
||||
&txHash, &blockNumber, &fromAddress, &toAddress, &value, &gasUsed, &gasPrice, &status, ×tampISO,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
normalizedHash := normalizeHexString(hash)
|
||||
blockscoutQuery := `
|
||||
SELECT
|
||||
concat('0x', encode(hash, 'hex')) AS hash,
|
||||
block_number,
|
||||
concat('0x', encode(from_address_hash, 'hex')) AS from_address,
|
||||
CASE
|
||||
WHEN to_address_hash IS NULL THEN NULL
|
||||
ELSE concat('0x', encode(to_address_hash, 'hex'))
|
||||
END AS to_address,
|
||||
COALESCE(value::text, '0') AS value,
|
||||
gas_used,
|
||||
gas_price,
|
||||
status,
|
||||
TO_CHAR(block_timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS timestamp_iso
|
||||
FROM transactions
|
||||
WHERE hash = decode($1, 'hex')
|
||||
LIMIT 1
|
||||
`
|
||||
if fallbackErr := s.db.QueryRow(ctx, blockscoutQuery, normalizedHash).Scan(
|
||||
&txHash, &blockNumber, &fromAddress, &toAddress, &value, &gasUsed, &gasPrice, &status, ×tampISO,
|
||||
); fallbackErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
tx := map[string]any{
|
||||
@@ -403,6 +474,63 @@ func (s *Server) queryAIAddress(ctx context.Context, address string) (map[string
|
||||
result["recent_transactions"] = recentHashes
|
||||
}
|
||||
|
||||
if len(result) == 1 {
|
||||
normalizedAddress := normalizeHexString(address)
|
||||
|
||||
var blockscoutTxCount int64
|
||||
var blockscoutTokenCount int64
|
||||
blockscoutAddressQuery := `
|
||||
SELECT
|
||||
COALESCE(transactions_count, 0),
|
||||
COALESCE(token_transfers_count, 0)
|
||||
FROM addresses
|
||||
WHERE hash = decode($1, 'hex')
|
||||
LIMIT 1
|
||||
`
|
||||
if err := s.db.QueryRow(ctx, blockscoutAddressQuery, normalizedAddress).Scan(&blockscoutTxCount, &blockscoutTokenCount); err == nil {
|
||||
result["transaction_count"] = blockscoutTxCount
|
||||
result["token_count"] = blockscoutTokenCount
|
||||
}
|
||||
|
||||
var liveTxCount int64
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM transactions
|
||||
WHERE from_address_hash = decode($1, 'hex') OR to_address_hash = decode($1, 'hex')
|
||||
`, normalizedAddress).Scan(&liveTxCount); err == nil && liveTxCount > 0 {
|
||||
result["transaction_count"] = liveTxCount
|
||||
}
|
||||
|
||||
var liveTokenCount int64
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(DISTINCT token_contract_address_hash)
|
||||
FROM token_transfers
|
||||
WHERE from_address_hash = decode($1, 'hex') OR to_address_hash = decode($1, 'hex')
|
||||
`, normalizedAddress).Scan(&liveTokenCount); err == nil && liveTokenCount > 0 {
|
||||
result["token_count"] = liveTokenCount
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT concat('0x', encode(hash, 'hex'))
|
||||
FROM transactions
|
||||
WHERE from_address_hash = decode($1, 'hex') OR to_address_hash = decode($1, 'hex')
|
||||
ORDER BY block_number DESC, index DESC
|
||||
LIMIT 5
|
||||
`, normalizedAddress)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var hash string
|
||||
if scanErr := rows.Scan(&hash); scanErr == nil {
|
||||
recentHashes = append(recentHashes, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(recentHashes) > 0 {
|
||||
result["recent_transactions"] = recentHashes
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 1 {
|
||||
return nil, fmt.Errorf("address not found")
|
||||
}
|
||||
@@ -428,7 +556,22 @@ func (s *Server) queryAIBlock(ctx context.Context, blockNumber int64) (map[strin
|
||||
|
||||
err := s.db.QueryRow(ctx, query, s.chainID, blockNumber).Scan(&number, &hash, &parentHash, &transactionCount, &gasUsed, &gasLimit, ×tampISO)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
blockscoutQuery := `
|
||||
SELECT
|
||||
number,
|
||||
concat('0x', encode(hash, 'hex')) AS hash,
|
||||
concat('0x', encode(parent_hash, 'hex')) AS parent_hash,
|
||||
(SELECT COUNT(*) FROM transactions WHERE block_number = b.number) AS transaction_count,
|
||||
gas_used,
|
||||
gas_limit,
|
||||
TO_CHAR(timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS timestamp_iso
|
||||
FROM blocks b
|
||||
WHERE number = $1
|
||||
LIMIT 1
|
||||
`
|
||||
if fallbackErr := s.db.QueryRow(ctx, blockscoutQuery, blockNumber).Scan(&number, &hash, &parentHash, &transactionCount, &gasUsed, &gasLimit, ×tampISO); fallbackErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
block := map[string]any{
|
||||
@@ -535,6 +678,11 @@ func filterAIRouteMatches(routes []map[string]any, query string) []map[string]an
|
||||
return matches
|
||||
}
|
||||
|
||||
func normalizeHexString(value string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(value))
|
||||
return strings.TrimPrefix(trimmed, "0x")
|
||||
}
|
||||
|
||||
func routeMatchesQuery(route map[string]any, query string) bool {
|
||||
fields := []string{
|
||||
stringValue(route["routeId"]),
|
||||
@@ -802,21 +950,44 @@ func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessa
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", model, err
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "", model, &AIUpstreamError{
|
||||
StatusCode: http.StatusGatewayTimeout,
|
||||
Code: "upstream_timeout",
|
||||
Message: "explorer ai upstream timed out",
|
||||
Details: "OpenAI request exceeded the configured timeout",
|
||||
}
|
||||
}
|
||||
return "", model, &AIUpstreamError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Code: "upstream_transport_error",
|
||||
Message: "explorer ai upstream transport failed",
|
||||
Details: err.Error(),
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", model, err
|
||||
return "", model, &AIUpstreamError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Code: "upstream_bad_response",
|
||||
Message: "explorer ai upstream body could not be read",
|
||||
Details: err.Error(),
|
||||
}
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", model, fmt.Errorf("openai responses api returned %d: %s", resp.StatusCode, clipString(string(responseBody), 400))
|
||||
return "", model, parseOpenAIError(resp.StatusCode, responseBody)
|
||||
}
|
||||
|
||||
var response openAIResponsesResponse
|
||||
if err := json.Unmarshal(responseBody, &response); err != nil {
|
||||
return "", model, fmt.Errorf("unable to decode openai response: %w", err)
|
||||
return "", model, &AIUpstreamError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Code: "upstream_bad_response",
|
||||
Message: "explorer ai upstream returned invalid JSON",
|
||||
Details: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
reply := strings.TrimSpace(response.OutputText)
|
||||
@@ -824,7 +995,12 @@ func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessa
|
||||
reply = strings.TrimSpace(extractOutputText(response.Output))
|
||||
}
|
||||
if reply == "" {
|
||||
return "", model, fmt.Errorf("openai response did not include output text")
|
||||
return "", model, &AIUpstreamError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Code: "upstream_bad_response",
|
||||
Message: "explorer ai upstream returned no output text",
|
||||
Details: "OpenAI response did not include output_text or content text",
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(response.Model) != "" {
|
||||
model = response.Model
|
||||
@@ -832,6 +1008,53 @@ func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessa
|
||||
return reply, model, nil
|
||||
}
|
||||
|
||||
func parseOpenAIError(statusCode int, responseBody []byte) error {
|
||||
var parsed struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(responseBody, &parsed)
|
||||
|
||||
details := clipString(strings.TrimSpace(parsed.Error.Message), 280)
|
||||
if details == "" {
|
||||
details = clipString(strings.TrimSpace(string(responseBody)), 280)
|
||||
}
|
||||
|
||||
switch statusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return &AIUpstreamError{
|
||||
StatusCode: statusCode,
|
||||
Code: "upstream_auth_failed",
|
||||
Message: "explorer ai upstream authentication failed",
|
||||
Details: details,
|
||||
}
|
||||
case http.StatusTooManyRequests:
|
||||
return &AIUpstreamError{
|
||||
StatusCode: statusCode,
|
||||
Code: "upstream_quota_exhausted",
|
||||
Message: "explorer ai upstream quota exhausted",
|
||||
Details: details,
|
||||
}
|
||||
case http.StatusRequestTimeout, http.StatusGatewayTimeout:
|
||||
return &AIUpstreamError{
|
||||
StatusCode: statusCode,
|
||||
Code: "upstream_timeout",
|
||||
Message: "explorer ai upstream timed out",
|
||||
Details: details,
|
||||
}
|
||||
default:
|
||||
return &AIUpstreamError{
|
||||
StatusCode: statusCode,
|
||||
Code: "upstream_error",
|
||||
Message: "explorer ai upstream request failed",
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractOutputText(items []openAIOutputItem) string {
|
||||
parts := []string{}
|
||||
for _, item := range items {
|
||||
|
||||
Reference in New Issue
Block a user