- 引入 CustomFunctionType 枚举,支持 Script 和 Http 两种函数类型 - 在自定义函数中添加 Http 相关字段(HttpMethod、HttpUrl、HttpHeaders、HttpBodyTemplate) - 实现 HTTP 函数执行器,支持变量替换和 SSRF 防护 - 将按钮动作链拆分为 before、main、after 三个阶段 - 新增前置和后置动作 JSON 字段存储 - 实现批量执行多个函数的 executeFunctions 动作类型 - 更新数据库迁移以支持新的字段结构 - 调整相关 DTO、命令和处理器以兼容新功能
190 lines
7.4 KiB
C#
190 lines
7.4 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// HTTP 自定义函数执行器实现。
|
||
/// 复用 Webhook 的 IHttpClientFactory("Webhook" 命名客户端)和 SSRF 白名单防护。
|
||
/// 对 URL/Header/Body 中的 $input.xxx 占位符做变量替换后发起请求,
|
||
/// 非 2xx 响应、SSRF 拦截、超时均抛 BusinessException。
|
||
/// </summary>
|
||
public class HttpFunctionExecutor(
|
||
IHttpClientFactory httpClientFactory,
|
||
IOptions<NotificationOptions> options,
|
||
ILogger<HttpFunctionExecutor> logger) : IHttpFunctionExecutor
|
||
{
|
||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||
{
|
||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||
};
|
||
|
||
private readonly NotificationOptions _options = options.Value;
|
||
|
||
public async Task<string> 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<Dictionary<string, string>>(headersJson)
|
||
?? new Dictionary<string, string>();
|
||
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: "<body>" }
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(responseBody);
|
||
return JsonSerializer.Serialize(doc.RootElement, JsonOptions);
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
return JsonSerializer.Serialize(new { result = responseBody }, JsonOptions);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 替换模板中的 $input.xxx 占位符为 inputJson 中对应路径的值。
|
||
/// 占位符出现在字符串中会被原地替换为 JSON 字面量(去掉外层引号的 JSON token)。
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>SSRF 防护:校验 URL 主机在白名单内,拒绝 localhost 与私有/内网 IP。</summary>
|
||
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]}...";
|
||
}
|