619 lines
18 KiB
PHP
619 lines
18 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
require_once WD . '/modules/n8n/n8n_c.php';
|
|
|
|
use Exception;
|
|
use interfaces\n8n_i;
|
|
use n8n\n8n_c;
|
|
use stdClass;
|
|
|
|
class n8n implements n8n_i
|
|
{
|
|
private const WORKFLOW_READ_ONLY_FIELDS = [
|
|
'id',
|
|
'active',
|
|
'createdAt',
|
|
'updatedAt',
|
|
'tags',
|
|
'shared',
|
|
'activeVersion',
|
|
];
|
|
|
|
public n8n_c $config;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->config = new n8n_c();
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function requireModuleEnabled(): void
|
|
{
|
|
if (!$this->config->enabled->isTrue()) {
|
|
throw new Exception('The n8n module is not enabled.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function listWorkflows(array $filters = []): object
|
|
{
|
|
return $this->sendApiRequest('GET', '/workflows', $this->filterAllowed($filters, [
|
|
'active',
|
|
'tags',
|
|
'name',
|
|
'projectId',
|
|
'excludePinnedData',
|
|
'limit',
|
|
'cursor',
|
|
]));
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function getWorkflow(string $id, bool $excludePinnedData = false): object
|
|
{
|
|
$this->requireValidIdentifier($id, 'workflow id');
|
|
|
|
$query = [];
|
|
if ($excludePinnedData) {
|
|
$query['excludePinnedData'] = true;
|
|
}
|
|
|
|
return $this->sendApiRequest('GET', '/workflows/' . rawurlencode($id), $query);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function createWorkflow(object $workflow): object
|
|
{
|
|
return $this->sendApiRequest('POST', '/workflows', [], $this->sanitizeWorkflowPayload($workflow));
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function updateWorkflow(string $id, object $changes): object
|
|
{
|
|
$this->requireValidIdentifier($id, 'workflow id');
|
|
|
|
$existing = $this->getWorkflow($id);
|
|
$merged = $this->mergeWorkflowPayload($existing, $changes);
|
|
|
|
return $this->sendApiRequest('PUT', '/workflows/' . rawurlencode($id), [], $merged);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function publishWorkflow(string $id, ?object $options = null): object
|
|
{
|
|
$this->requireValidIdentifier($id, 'workflow id');
|
|
|
|
return $this->sendApiRequest(
|
|
'POST',
|
|
'/workflows/' . rawurlencode($id) . '/activate',
|
|
[],
|
|
$options !== null ? $this->filterPublishOptions($options) : null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function deactivateWorkflow(string $id): object
|
|
{
|
|
$this->requireValidIdentifier($id, 'workflow id');
|
|
|
|
return $this->sendApiRequest('POST', '/workflows/' . rawurlencode($id) . '/deactivate');
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function runWebhook(string $webhookTarget, mixed $payload = null, string $method = 'POST', array $query = []): object
|
|
{
|
|
$this->requireModuleEnabled();
|
|
|
|
$url = $this->resolveWebhookUrl($webhookTarget);
|
|
$normalizedMethod = $this->normalizeMethod($method);
|
|
|
|
return $this->sendWebhookRequest($normalizedMethod, $url, $query, $payload);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function listExecutions(array $filters = []): object
|
|
{
|
|
return $this->sendApiRequest('GET', '/executions', $this->filterAllowed($filters, [
|
|
'includeData',
|
|
'status',
|
|
'workflowId',
|
|
'projectId',
|
|
'limit',
|
|
'cursor',
|
|
]));
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function getExecution(int $id, bool $includeData = false): object
|
|
{
|
|
$this->requirePositiveInteger($id, 'execution id');
|
|
|
|
$query = [];
|
|
if ($includeData) {
|
|
$query['includeData'] = true;
|
|
}
|
|
|
|
return $this->sendApiRequest('GET', '/executions/' . $id, $query);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function retryExecution(int $id, bool $loadWorkflow = false): object
|
|
{
|
|
$this->requirePositiveInteger($id, 'execution id');
|
|
|
|
$payload = null;
|
|
if ($loadWorkflow) {
|
|
$payload = (object)['loadWorkflow' => true];
|
|
}
|
|
|
|
return $this->sendApiRequest('POST', '/executions/' . $id . '/retry', [], $payload);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function stopExecution(int $id): object
|
|
{
|
|
$this->requirePositiveInteger($id, 'execution id');
|
|
|
|
return $this->sendApiRequest('POST', '/executions/' . $id . '/stop');
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function sendApiRequest(string $method, string $path, array $query = [], ?object $body = null): object
|
|
{
|
|
$this->requireModuleEnabled();
|
|
$this->requireConfiguredApiUrl();
|
|
$this->requireConfiguredApiKey();
|
|
|
|
$url = $this->buildUrl($this->config->api_url->getVariableValue(), $path, $query);
|
|
$headers = [
|
|
'Accept: application/json',
|
|
'X-N8N-API-KEY: ' . trim((string)$this->config->api_key->getVariableValue()),
|
|
];
|
|
|
|
return $this->executeJsonRequest($method, $url, $headers, $body);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function sendWebhookRequest(string $method, string $url, array $query = [], mixed $body = null): object
|
|
{
|
|
$headers = ['Accept: application/json'];
|
|
$payload = null;
|
|
|
|
if ($body !== null) {
|
|
$headers[] = 'Content-Type: application/json';
|
|
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
|
|
if ($payload === false) {
|
|
throw new Exception('Unable to encode n8n webhook payload as JSON.');
|
|
}
|
|
}
|
|
|
|
$response = $this->executeRequest($method, $this->buildUrl($url, '', $query), $headers, $payload);
|
|
if ($response['status'] >= 400) {
|
|
throw new Exception($this->extractErrorMessage($response['body'], $response['status'], 'Webhook request failed'));
|
|
}
|
|
|
|
$decoded = json_decode($response['body']);
|
|
if (json_last_error() === JSON_ERROR_NONE) {
|
|
if (is_object($decoded)) {
|
|
$decoded->status_code = $response['status'];
|
|
return $decoded;
|
|
}
|
|
|
|
return (object)[
|
|
'status_code' => $response['status'],
|
|
'data' => $decoded,
|
|
];
|
|
}
|
|
|
|
return (object)[
|
|
'status_code' => $response['status'],
|
|
'body' => $response['body'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function executeJsonRequest(string $method, string $url, array $headers, ?object $body = null): object
|
|
{
|
|
$payload = null;
|
|
if ($body !== null) {
|
|
$headers[] = 'Content-Type: application/json';
|
|
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
|
|
if ($payload === false) {
|
|
throw new Exception('Unable to encode n8n request body as JSON.');
|
|
}
|
|
}
|
|
|
|
$response = $this->executeRequest($method, $url, $headers, $payload);
|
|
$decoded = json_decode($response['body']);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
throw new Exception('Invalid JSON response from n8n (HTTP ' . $response['status'] . ').');
|
|
}
|
|
|
|
if ($response['status'] >= 400) {
|
|
throw new Exception($this->extractErrorMessage($decoded, $response['status'], 'n8n API request failed'));
|
|
}
|
|
|
|
if (is_object($decoded)) {
|
|
return $decoded;
|
|
}
|
|
|
|
return (object)[
|
|
'data' => $decoded,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function executeRequest(string $method, string $url, array $headers, ?string $body = null): array
|
|
{
|
|
$curl = curl_init();
|
|
curl_setopt_array($curl, [
|
|
CURLOPT_URL => $url,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_CUSTOMREQUEST => $method,
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
]);
|
|
|
|
if ($body !== null) {
|
|
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
|
|
}
|
|
|
|
$responseBody = curl_exec($curl);
|
|
$statusCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
|
$error = curl_error($curl);
|
|
curl_close($curl);
|
|
|
|
if ($error !== '') {
|
|
throw new Exception('cURL request to n8n failed: ' . $error);
|
|
}
|
|
|
|
if ($responseBody === false) {
|
|
throw new Exception('n8n request returned an empty response.');
|
|
}
|
|
|
|
return [
|
|
'status' => $statusCode,
|
|
'body' => (string)$responseBody,
|
|
];
|
|
}
|
|
|
|
private function buildUrl(string $baseUrl, string $path = '', array $query = []): string
|
|
{
|
|
$url = rtrim(trim($baseUrl), '/');
|
|
if ($path !== '') {
|
|
$url .= '/' . ltrim($path, '/');
|
|
}
|
|
|
|
$query = array_filter($query, static function (mixed $value): bool {
|
|
return $value !== null && $value !== '';
|
|
});
|
|
|
|
if ($query !== []) {
|
|
$url .= '?' . http_build_query($query);
|
|
}
|
|
|
|
return $url;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function resolveWebhookUrl(string $target): string
|
|
{
|
|
$target = trim($target);
|
|
if ($target === '') {
|
|
throw new Exception('Webhook target must not be empty.');
|
|
}
|
|
|
|
$baseUrl = trim((string)$this->config->webhook_base_url->getVariableValue());
|
|
if ($baseUrl === '') {
|
|
throw new Exception('n8n webhook base URL is not configured.');
|
|
}
|
|
|
|
if (filter_var($target, FILTER_VALIDATE_URL) !== false) {
|
|
if (!$this->isAllowedWebhookAbsoluteUrl($target, $baseUrl)) {
|
|
throw new Exception('Webhook URL must use the configured n8n webhook host.');
|
|
}
|
|
|
|
return $target;
|
|
}
|
|
|
|
return rtrim($baseUrl, '/') . '/' . ltrim($target, '/');
|
|
}
|
|
|
|
private function isAllowedWebhookAbsoluteUrl(string $targetUrl, string $baseUrl): bool
|
|
{
|
|
$targetParts = parse_url($targetUrl);
|
|
$baseParts = parse_url($baseUrl);
|
|
|
|
if (!is_array($targetParts) || !is_array($baseParts)) {
|
|
return false;
|
|
}
|
|
|
|
$targetHost = strtolower((string)($targetParts['host'] ?? ''));
|
|
$baseHost = strtolower((string)($baseParts['host'] ?? ''));
|
|
if ($targetHost === '' || $baseHost === '' || $targetHost !== $baseHost) {
|
|
return false;
|
|
}
|
|
|
|
$targetScheme = strtolower((string)($targetParts['scheme'] ?? ''));
|
|
$baseScheme = strtolower((string)($baseParts['scheme'] ?? ''));
|
|
if ($targetScheme === '' || $baseScheme === '' || $targetScheme !== $baseScheme) {
|
|
return false;
|
|
}
|
|
|
|
$targetPort = (int)($targetParts['port'] ?? ($targetScheme === 'https' ? 443 : 80));
|
|
$basePort = (int)($baseParts['port'] ?? ($baseScheme === 'https' ? 443 : 80));
|
|
|
|
return $targetPort === $basePort;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function sanitizeWorkflowPayload(object $workflow): object
|
|
{
|
|
$payload = $this->cloneObject($workflow);
|
|
|
|
foreach (self::WORKFLOW_READ_ONLY_FIELDS as $field) {
|
|
if (property_exists($payload, $field)) {
|
|
unset($payload->{$field});
|
|
}
|
|
}
|
|
|
|
if (!property_exists($payload, 'name') || !is_string($payload->name) || trim($payload->name) === '') {
|
|
throw new Exception('Workflow name is required.');
|
|
}
|
|
|
|
if (!property_exists($payload, 'nodes') || !is_array($payload->nodes)) {
|
|
throw new Exception('Workflow nodes are required and must be an array.');
|
|
}
|
|
|
|
if (!property_exists($payload, 'connections')) {
|
|
throw new Exception('Workflow connections are required.');
|
|
}
|
|
|
|
if (!property_exists($payload, 'settings') || $payload->settings === null) {
|
|
$payload->settings = new stdClass();
|
|
}
|
|
|
|
$payload->connections = $this->normalizeObjectValue($payload->connections, 'connections');
|
|
$payload->settings = $this->normalizeObjectValue($payload->settings, 'settings');
|
|
|
|
if (property_exists($payload, 'staticData') && is_array($payload->staticData) && !array_is_list($payload->staticData)) {
|
|
$payload->staticData = $this->arrayToObject($payload->staticData);
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
private function mergeWorkflowPayload(object $existing, object $changes): object
|
|
{
|
|
$merged = $this->cloneObject($existing);
|
|
|
|
foreach (get_object_vars($changes) as $key => $value) {
|
|
if (in_array($key, self::WORKFLOW_READ_ONLY_FIELDS, true)) {
|
|
continue;
|
|
}
|
|
|
|
if (property_exists($merged, $key) && is_object($merged->{$key}) && is_object($value)) {
|
|
$merged->{$key} = $this->mergeObjects($merged->{$key}, $value);
|
|
continue;
|
|
}
|
|
|
|
$merged->{$key} = $value;
|
|
}
|
|
|
|
return $this->sanitizeWorkflowPayload($merged);
|
|
}
|
|
|
|
private function mergeObjects(object $base, object $changes): object
|
|
{
|
|
foreach (get_object_vars($changes) as $key => $value) {
|
|
if (property_exists($base, $key) && is_object($base->{$key}) && is_object($value)) {
|
|
$base->{$key} = $this->mergeObjects($base->{$key}, $value);
|
|
continue;
|
|
}
|
|
|
|
$base->{$key} = $value;
|
|
}
|
|
|
|
return $base;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function normalizeObjectValue(mixed $value, string $field): object
|
|
{
|
|
if ($value instanceof stdClass || is_object($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (is_array($value) && !array_is_list($value)) {
|
|
return $this->arrayToObject($value);
|
|
}
|
|
|
|
if ($value === [] && $field === 'settings') {
|
|
return new stdClass();
|
|
}
|
|
|
|
throw new Exception('Workflow ' . $field . ' must be an object.');
|
|
}
|
|
|
|
private function arrayToObject(array $value): object
|
|
{
|
|
$object = new stdClass();
|
|
|
|
foreach ($value as $key => $item) {
|
|
$object->{$key} = $this->normalizeMixedValue($item);
|
|
}
|
|
|
|
return $object;
|
|
}
|
|
|
|
private function normalizeMixedValue(mixed $value): mixed
|
|
{
|
|
if (!is_array($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (array_is_list($value)) {
|
|
return array_map(fn (mixed $item): mixed => $this->normalizeMixedValue($item), $value);
|
|
}
|
|
|
|
return $this->arrayToObject($value);
|
|
}
|
|
|
|
private function cloneObject(object $value): object
|
|
{
|
|
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE);
|
|
if ($encoded === false) {
|
|
return clone $value;
|
|
}
|
|
|
|
$decoded = json_decode($encoded);
|
|
return is_object($decoded) ? $decoded : clone $value;
|
|
}
|
|
|
|
private function filterPublishOptions(object $options): object
|
|
{
|
|
$filtered = new stdClass();
|
|
|
|
foreach (['versionId', 'name', 'description'] as $field) {
|
|
if (property_exists($options, $field) && $options->{$field} !== null && $options->{$field} !== '') {
|
|
$filtered->{$field} = $options->{$field};
|
|
}
|
|
}
|
|
|
|
return $filtered;
|
|
}
|
|
|
|
private function filterAllowed(array $filters, array $allowedKeys): array
|
|
{
|
|
$allowed = array_flip($allowedKeys);
|
|
$filtered = [];
|
|
|
|
foreach ($filters as $key => $value) {
|
|
if (isset($allowed[$key])) {
|
|
$filtered[$key] = $value;
|
|
}
|
|
}
|
|
|
|
return $filtered;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function requireConfiguredApiUrl(): void
|
|
{
|
|
$url = trim((string)$this->config->api_url->getVariableValue());
|
|
if ($url === '' || filter_var($this->normalizeUrlForValidation($url), FILTER_VALIDATE_URL) === false) {
|
|
throw new Exception('Invalid n8n API URL configured.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function requireConfiguredApiKey(): void
|
|
{
|
|
if (trim((string)$this->config->api_key->getVariableValue()) === '') {
|
|
throw new Exception('Invalid n8n API key configured.');
|
|
}
|
|
}
|
|
|
|
private function normalizeUrlForValidation(string $url): string
|
|
{
|
|
if (preg_match('#^https?://#i', $url)) {
|
|
return $url;
|
|
}
|
|
|
|
return 'http://' . ltrim($url, '/');
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function requireValidIdentifier(string $value, string $label): void
|
|
{
|
|
if (trim($value) === '') {
|
|
throw new Exception('Invalid ' . $label . '.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function requirePositiveInteger(int $value, string $label): void
|
|
{
|
|
if ($value <= 0) {
|
|
throw new Exception('Invalid ' . $label . '.');
|
|
}
|
|
}
|
|
|
|
private function normalizeMethod(string $method): string
|
|
{
|
|
$normalized = strtoupper(trim($method));
|
|
if (!in_array($normalized, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], true)) {
|
|
return 'POST';
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
private function extractErrorMessage(mixed $decoded, int $statusCode, string $fallback): string
|
|
{
|
|
if (is_object($decoded)) {
|
|
if (isset($decoded->message) && is_string($decoded->message)) {
|
|
return $decoded->message;
|
|
}
|
|
if (isset($decoded->error) && is_string($decoded->error)) {
|
|
return $decoded->error;
|
|
}
|
|
}
|
|
|
|
if (is_string($decoded) && trim($decoded) !== '') {
|
|
return $decoded;
|
|
}
|
|
|
|
return $fallback . ' (HTTP ' . $statusCode . ').';
|
|
}
|
|
}
|