using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using BuildFlow.Shared.Domain.Common;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Workflow.Application.BuildFlow.CustomFunctions.Services;
using Workflow.Application.Notifications;
using Workflow.Domain.Entities;
namespace BuildFlow.Host.Services.Integration;
///
/// HTTP 自定义函数执行器实现。
/// 复用 Webhook 的 IHttpClientFactory("Webhook" 命名客户端)和 SSRF 白名单防护。
/// 对 URL/Header/Body 中的 $input.xxx 占位符做变量替换后发起请求,
/// 非 2xx 响应、SSRF 拦截、超时均抛 BusinessException。
///
public class HttpFunctionExecutor(
IHttpClientFactory httpClientFactory,
IOptions options,
ILogger logger) : IHttpFunctionExecutor
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
private readonly NotificationOptions _options = options.Value;
public async Task ExecuteAsync(CustomFunction function, string inputJson, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(function.HttpUrl))
{
throw new BusinessException($"HTTP 函数 {function.DisplayName} 未配置目标 URL");
}
var url = ReplaceVariables(function.HttpUrl, inputJson);
if (!IsHostAllowed(url))
{
logger.LogWarning("HTTP 函数 {FunctionId} URL {Url} 被拒绝(SSRF 白名单拦截)", function.Id, url);
throw new BusinessException($"HTTP 函数目标地址不在允许的白名单内:{new Uri(url).Host}");
}
var client = httpClientFactory.CreateClient("Webhook");
using var request = new HttpRequestMessage(
new HttpMethod(string.IsNullOrWhiteSpace(function.HttpMethod) ? "POST" : function.HttpMethod.ToUpperInvariant()),
url);
// 请求头
if (!string.IsNullOrWhiteSpace(function.HttpHeaders))
{
var headersJson = ReplaceVariables(function.HttpHeaders, inputJson);
var headers = JsonSerializer.Deserialize>(headersJson)
?? new Dictionary();
foreach (var (key, value) in headers)
{
request.Headers.TryAddWithoutValidation(key, value);
}
}
// 请求体
if (!string.IsNullOrWhiteSpace(function.HttpBodyTemplate))
{
var body = ReplaceVariables(function.HttpBodyTemplate, inputJson);
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
}
// 执行请求
var response = await client.SendAsync(request, ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("HTTP 函数 {FunctionId} 调用 {Url} 返回非成功状态 {Status}", function.Id, url, (int)response.StatusCode);
throw new BusinessException(
$"HTTP 函数调用失败:{(int)response.StatusCode} {response.StatusCode}" +
(string.IsNullOrEmpty(responseBody) ? "" : $"\n{Truncate(responseBody, 500)}"));
}
// 尝试将响应体解析为 JSON;若不是合法 JSON 则包装为 { result: "" }
try
{
using var doc = JsonDocument.Parse(responseBody);
return JsonSerializer.Serialize(doc.RootElement, JsonOptions);
}
catch (JsonException)
{
return JsonSerializer.Serialize(new { result = responseBody }, JsonOptions);
}
}
///
/// 替换模板中的 $input.xxx 占位符为 inputJson 中对应路径的值。
/// 占位符出现在字符串中会被原地替换为 JSON 字面量(去掉外层引号的 JSON token)。
///
private static string ReplaceVariables(string template, string inputJson)
{
if (string.IsNullOrEmpty(template)) return template;
using var input = string.IsNullOrWhiteSpace(inputJson)
? JsonDocument.Parse("{}")
: JsonDocument.Parse(inputJson);
var result = new StringBuilder(template);
var i = 0;
while ((i = result.ToString().IndexOf("$input.", i, StringComparison.Ordinal)) >= 0)
{
// 提取 $input. 后的路径(支持字母数字下划线和点)
var end = i + "$input.".Length;
while (end < result.Length && (char.IsLetterOrDigit(result[end]) || result[end] == '_' || result[end] == '.'))
{
end++;
}
var path = result.ToString(i + "$input.".Length, end - i - "$input.".Length);
var value = ReadPath(input.RootElement, path);
var literal = value switch
{
{ } v when v.ValueKind == JsonValueKind.String => v.GetString() ?? "",
{ } v when v.ValueKind == JsonValueKind.Number => v.GetRawText(),
{ } v when v.ValueKind == JsonValueKind.True => "true",
{ } v when v.ValueKind == JsonValueKind.False => "false",
{ } v when v.ValueKind == JsonValueKind.Null => "null",
{ } v when v.ValueKind == JsonValueKind.Object => v.GetRawText(),
{ } v when v.ValueKind == JsonValueKind.Array => v.GetRawText(),
_ => "",
};
result.Remove(i, end - i);
result.Insert(i, literal);
i += literal.Length;
}
return result.ToString();
}
private static JsonElement? ReadPath(JsonElement root, string path)
{
var current = root;
foreach (var segment in path.Split('.', StringSplitOptions.RemoveEmptyEntries))
{
if (current.ValueKind != JsonValueKind.Object ||
!current.TryGetProperty(segment, out current))
{
return null;
}
}
return current;
}
/// SSRF 防护:校验 URL 主机在白名单内,拒绝 localhost 与私有/内网 IP。
internal bool IsHostAllowed(string url)
{
if (_options.Webhook.AllowedHosts is null || _options.Webhook.AllowedHosts.Length == 0)
{
return false;
}
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return false;
}
var host = uri.Host;
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (IPAddress.TryParse(host, out var ip))
{
if (IPAddress.IsLoopback(ip)) return false;
if (ip.AddressFamily == AddressFamily.InterNetwork)
{
var bytes = ip.GetAddressBytes();
if (bytes[0] == 10) return false;
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return false;
if (bytes[0] == 192 && bytes[1] == 168) return false;
}
}
return _options.Webhook.AllowedHosts
.Any(allowed => string.Equals(allowed, host, StringComparison.OrdinalIgnoreCase));
}
private static string Truncate(string text, int maxLength) =>
text.Length <= maxLength ? text : $"{text[..maxLength]}...";
}