mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Add n8n connection preset and workflow step operations (#7447)
# Description of Changes Add n8n, simple reuse of webhook call etc and custom API etc etc <img width="2104" height="1090" alt="image" src="https://github.com/user-attachments/assets/0d0df39a-31b3-4e1b-ac63-847aefa06ffe" /> <img width="2104" height="1592" alt="image" src="https://github.com/user-attachments/assets/4074cce2-0f4b-4300-813c-d49576b33940" /> <img width="1021" height="797" alt="image" src="https://github.com/user-attachments/assets/370f54f7-3066-4454-9618-9ddd2cb91b83" /> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details.
This commit is contained in:
@@ -6934,6 +6934,32 @@ label = "Jira"
|
||||
description = "Email a document or a notification when a policy runs."
|
||||
label = "Mailgun"
|
||||
|
||||
[portal.connections.types.n8n]
|
||||
baseUrlPlaceholder = "https://your-n8n/webhook/9f2c-..."
|
||||
description = "Hand documents to an n8n workflow, and take back whatever it produces."
|
||||
label = "n8n"
|
||||
|
||||
[portal.connections.types.n8n.fields.authType]
|
||||
helperText = "Match the Authentication set on the n8n Webhook node."
|
||||
label = "Webhook authentication"
|
||||
|
||||
[portal.connections.types.n8n.fields.authType.options.basic]
|
||||
label = "Basic auth"
|
||||
|
||||
[portal.connections.types.n8n.fields.authType.options.header]
|
||||
label = "Header auth"
|
||||
|
||||
[portal.connections.types.n8n.fields.authType.options.none]
|
||||
label = "None"
|
||||
|
||||
[portal.connections.types.n8n.fields.headerName]
|
||||
helperText = "The header name from the n8n credential, not its value."
|
||||
label = "Header name"
|
||||
placeholder = "X-N8N-Key"
|
||||
|
||||
[portal.connections.types.n8n.fields.token]
|
||||
label = "Header value"
|
||||
|
||||
[portal.connections.types.nextcloud]
|
||||
baseUrlPlaceholder = "https://your-server/remote.php/dav"
|
||||
description = "File processed documents into Nextcloud."
|
||||
@@ -8118,6 +8144,24 @@ label = "Attach to a Jira issue"
|
||||
description = "Sends the processed document as an attachment."
|
||||
label = "Email the document (Mailgun)"
|
||||
|
||||
[portal.policies.operations.n8nGate]
|
||||
description = "Your workflow decides whether the run carries on."
|
||||
label = "Ask an n8n workflow to approve"
|
||||
note = "The workflow must reply with {\"approved\": true}. Anything else — including a timeout — stops the run and parks the document."
|
||||
|
||||
[portal.policies.operations.n8nNotify]
|
||||
description = "Sends the run's details, not the document."
|
||||
label = "Tell an n8n workflow"
|
||||
|
||||
[portal.policies.operations.n8nSend]
|
||||
description = "Sends the document and the run's details to your workflow."
|
||||
label = "Send the document to n8n"
|
||||
|
||||
[portal.policies.operations.n8nTransform]
|
||||
description = "Your workflow processes the document and the file it returns replaces it."
|
||||
label = "Process the document in n8n"
|
||||
note = "Set the Webhook node to respond \"Using Respond to Webhook Node\", and return a file. On the default \"Immediately\" setting n8n replies with an acknowledgement, and that would replace the document."
|
||||
|
||||
[portal.policies.operations.nextcloudUpload]
|
||||
description = "Writes the processed document into a folder."
|
||||
label = "Upload to Nextcloud"
|
||||
|
||||
@@ -188,6 +188,21 @@ const BRAND: Record<string, (p: MarkProps) => ReactNode> = {
|
||||
<circle cx="12" cy="12" r="2.6" fill="#FF4F00" />
|
||||
</Fill>
|
||||
),
|
||||
n8n: (p) => (
|
||||
<Fill {...p}>
|
||||
<g stroke="#EA4B71" strokeWidth="1.6" fill="none" strokeLinecap="round">
|
||||
<path d="M3.6 12h5.2" />
|
||||
<path d="M13.2 12h1.2c1.1 0 1.5-.8 2-1.7s.9-1.7 2-1.7h1" />
|
||||
<path d="M13.2 12h1.2c1.1 0 1.5.8 2 1.7s.9 1.7 2 1.7h1" />
|
||||
</g>
|
||||
<g fill="#EA4B71">
|
||||
<circle cx="2.6" cy="12" r="1.7" />
|
||||
<circle cx="11" cy="12" r="2.4" />
|
||||
<circle cx="20.6" cy="8.6" r="1.7" />
|
||||
<circle cx="20.6" cy="15.4" r="1.7" />
|
||||
</g>
|
||||
</Fill>
|
||||
),
|
||||
jira: (p) => (
|
||||
<Fill {...p}>
|
||||
<path
|
||||
|
||||
@@ -229,6 +229,90 @@ describe("the Cloudmersive scan gate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the n8n operations", () => {
|
||||
it("covers each of the engine's response behaviours exactly once", () => {
|
||||
// A Webhook node can reply, so n8n reaches replace and requireTrue as well as report;
|
||||
// collapsed onto notify() the extra three would silently become fire-and-forget.
|
||||
const shape = (id: string) => {
|
||||
const params = buildStepParameters(operationById(id)!, "1", {
|
||||
message: "x",
|
||||
});
|
||||
return {
|
||||
includeFile: params.includeFile,
|
||||
responseMode: params.responseMode,
|
||||
requireTrue: params.requireTrue,
|
||||
};
|
||||
};
|
||||
|
||||
expect(shape("n8nNotify")).toEqual({
|
||||
includeFile: "false",
|
||||
responseMode: "report",
|
||||
requireTrue: "",
|
||||
});
|
||||
expect(shape("n8nSend")).toEqual({
|
||||
includeFile: "true",
|
||||
responseMode: "report",
|
||||
requireTrue: "",
|
||||
});
|
||||
expect(shape("n8nTransform")).toEqual({
|
||||
includeFile: "true",
|
||||
responseMode: "replace",
|
||||
requireTrue: "",
|
||||
});
|
||||
expect(shape("n8nGate")).toEqual({
|
||||
includeFile: "true",
|
||||
responseMode: "report",
|
||||
requireTrue: "approved",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the facts as fields rather than one sentence to parse", () => {
|
||||
const params = buildStepParameters(operationById("n8nNotify")!, "1", {
|
||||
message: "done",
|
||||
});
|
||||
const body = JSON.parse(params.bodyTemplate) as Record<string, unknown>;
|
||||
|
||||
expect(body.message).toBe("done");
|
||||
expect(body.document).toEqual({
|
||||
filename: "{{document.filename}}",
|
||||
extension: "{{document.extension}}",
|
||||
contentType: "{{document.contentType}}",
|
||||
sizeBytes: "{{document.sizeBytes}}",
|
||||
sha256: "{{document.sha256}}",
|
||||
});
|
||||
expect(body.run).toMatchObject({ policy: "{{run.policyName}}" });
|
||||
});
|
||||
|
||||
it("uses no PDF-only placeholder in a fixed body", () => {
|
||||
// document.pageCount is added only for PDFs, and the backend throws on a placeholder it cannot
|
||||
// resolve - baking it in would fail the step on the first non-PDF that reached it.
|
||||
for (const id of ["n8nNotify", "n8nSend", "n8nTransform", "n8nGate"]) {
|
||||
const params = buildStepParameters(operationById(id)!, "1", {
|
||||
message: "x",
|
||||
});
|
||||
expect(params.bodyTemplate, id).not.toContain("pageCount");
|
||||
}
|
||||
});
|
||||
|
||||
it("gives the workflow the run's context beside the file", () => {
|
||||
for (const id of ["n8nSend", "n8nTransform", "n8nGate"]) {
|
||||
const params = buildStepParameters(operationById(id)!, "1", {});
|
||||
expect(params.includeContext, id).toBe("true");
|
||||
expect(params.bodyMode, id).toBe("multipart");
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves every other vendor's context off", () => {
|
||||
// includeContext used to be hardcoded false; now that it is per-operation, a vendor with a
|
||||
// fixed API would reject the extra part.
|
||||
const optedIn = STEP_OPERATIONS.filter(
|
||||
(op) => buildStepParameters(op, "1", {}).includeContext === "true",
|
||||
).map((op) => op.id);
|
||||
|
||||
expect(optedIn.sort()).toEqual(["n8nGate", "n8nSend", "n8nTransform"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("substituting an answer into the URL path", () => {
|
||||
it("percent-encodes the answer so a slash stays a value, not a new segment", () => {
|
||||
// A Jira key like "OPS/1" (or a space) must not add a path segment or reach another endpoint.
|
||||
|
||||
@@ -83,6 +83,9 @@ export interface OperationCall {
|
||||
bodyTemplate?: string;
|
||||
/** False for notify-style calls that send facts rather than the document. */
|
||||
includeFile?: boolean;
|
||||
/** Send the fact context beside the document so the receiver can branch on the run.
|
||||
* Off by default: a vendor with a fixed API has no field for it. */
|
||||
includeContext?: boolean;
|
||||
}
|
||||
|
||||
export interface StepOperation {
|
||||
@@ -153,6 +156,138 @@ function notify(
|
||||
};
|
||||
}
|
||||
|
||||
/** Facts every document has, whatever its type — `document.pageCount` is PDF-only and would
|
||||
* fail the step on anything else, so it is offered in the editable message rather than baked in. */
|
||||
const N8N_FACTS = {
|
||||
event: "policy.document.processed",
|
||||
run: {
|
||||
policy: "{{run.policyName}}",
|
||||
id: "{{run.runId}}",
|
||||
at: "{{run.timestamp}}",
|
||||
},
|
||||
document: {
|
||||
filename: "{{document.filename}}",
|
||||
extension: "{{document.extension}}",
|
||||
contentType: "{{document.contentType}}",
|
||||
sizeBytes: "{{document.sizeBytes}}",
|
||||
sha256: "{{document.sha256}}",
|
||||
},
|
||||
};
|
||||
|
||||
/** The four shapes an n8n workflow can take part in; see the catalogue entry for why n8n gets more
|
||||
* than one. All four post to the Webhook node's URL, which is the whole endpoint. */
|
||||
function n8nOperations(): StepOperation[] {
|
||||
const base = {
|
||||
connectionTypeId: "n8n",
|
||||
integrationType: "API" as const,
|
||||
category: "notify" as const,
|
||||
};
|
||||
const entry = (
|
||||
id: string,
|
||||
call: OperationCall,
|
||||
extra: Partial<StepOperation> = {},
|
||||
) => ({
|
||||
...base,
|
||||
id,
|
||||
labelKey: `${PREFIX}.${id}.label`,
|
||||
descriptionKey: `${PREFIX}.${id}.description`,
|
||||
call,
|
||||
...extra,
|
||||
});
|
||||
|
||||
return [
|
||||
// Facts only, structured rather than one text blob: a workflow branches on
|
||||
// fields instead of parsing a sentence back apart.
|
||||
entry(
|
||||
"n8nNotify",
|
||||
{
|
||||
path: "",
|
||||
bodyMode: "json",
|
||||
includeFile: false,
|
||||
bodyTemplate: JSON.stringify({ ...N8N_FACTS, message: "{{message}}" }),
|
||||
},
|
||||
{
|
||||
searchTerms: ["n8n", "notify", "trigger", "workflow", "automation"],
|
||||
fields: [
|
||||
{
|
||||
key: "message",
|
||||
labelKey: `${PREFIX}.fields.message.label`,
|
||||
control: "textarea",
|
||||
required: true,
|
||||
helperTextKey: `${PREFIX}.fields.message.helperText`,
|
||||
defaultValue: "{{run.policyName}} processed {{document.filename}}",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
|
||||
// The document itself, plus the facts as a `stirlingContext` part so the workflow knows which
|
||||
// policy sent it. Fire-and-forget: whatever n8n replies is recorded, the document is unchanged.
|
||||
entry(
|
||||
"n8nSend",
|
||||
{
|
||||
path: "",
|
||||
bodyMode: "multipart",
|
||||
fileFieldName: "file",
|
||||
includeContext: true,
|
||||
responseMode: "report",
|
||||
},
|
||||
{
|
||||
searchTerms: ["n8n", "send", "file", "upload", "workflow", "binary"],
|
||||
},
|
||||
),
|
||||
|
||||
// The round trip: the returned file replaces the one in the pipeline. Needs the Webhook node
|
||||
// set to "Respond to Webhook" - on "Immediately" its acknowledgement would replace the document.
|
||||
entry(
|
||||
"n8nTransform",
|
||||
{
|
||||
path: "",
|
||||
bodyMode: "multipart",
|
||||
fileFieldName: "file",
|
||||
includeContext: true,
|
||||
responseMode: "replace",
|
||||
},
|
||||
{
|
||||
searchTerms: [
|
||||
"n8n",
|
||||
"transform",
|
||||
"process",
|
||||
"convert",
|
||||
"workflow",
|
||||
"replace",
|
||||
],
|
||||
noteKey: `${PREFIX}.n8nTransform.note`,
|
||||
},
|
||||
),
|
||||
|
||||
// The workflow decides whether the run continues. `requireTrue` is fail-closed, so an n8n
|
||||
// outage or a malformed reply parks the document rather than waving it through.
|
||||
entry(
|
||||
"n8nGate",
|
||||
{
|
||||
path: "",
|
||||
bodyMode: "multipart",
|
||||
fileFieldName: "file",
|
||||
includeContext: true,
|
||||
responseMode: "report",
|
||||
requireTrue: "approved",
|
||||
},
|
||||
{
|
||||
searchTerms: [
|
||||
"n8n",
|
||||
"approve",
|
||||
"gate",
|
||||
"review",
|
||||
"decision",
|
||||
"workflow",
|
||||
],
|
||||
noteKey: `${PREFIX}.n8nGate.note`,
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const STEP_OPERATIONS: StepOperation[] = [
|
||||
// ---- scan & classify: the verdict gates the run ------------------------------------------
|
||||
{
|
||||
@@ -501,6 +636,11 @@ export const STEP_OPERATIONS: StepOperation[] = [
|
||||
"trigger",
|
||||
]),
|
||||
|
||||
// ---- n8n: the one vendor here that can answer back ---------------------------------------------
|
||||
// A Webhook node takes binary and can return what the workflow produces, so n8n reaches all three
|
||||
// engine modes - report, replace and a `requireTrue` verdict - and gets an operation for each.
|
||||
...n8nOperations(),
|
||||
|
||||
{
|
||||
id: "webhookPost",
|
||||
connectionTypeId: "webhook",
|
||||
@@ -660,7 +800,7 @@ export function buildStepParameters(
|
||||
headers: call.headers ? substituteJson(JSON.stringify(call.headers)) : "",
|
||||
fields: call.fields ? substituteJson(JSON.stringify(call.fields)) : "",
|
||||
bodyTemplate: call.bodyTemplate ? substituteJson(call.bodyTemplate) : "",
|
||||
includeContext: "false",
|
||||
includeContext: String(call.includeContext ?? false),
|
||||
includeFile: String(call.includeFile ?? true),
|
||||
operationId: op.id,
|
||||
operationValues: JSON.stringify(values),
|
||||
|
||||
@@ -136,6 +136,74 @@ describe("isFieldVisible", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the n8n preset", () => {
|
||||
const n8n = byId("n8n");
|
||||
const field = (key: string) => {
|
||||
const found = n8n.fields.find((f) => f.key === key);
|
||||
if (!found) throw new Error(`no field ${key}`);
|
||||
return found;
|
||||
};
|
||||
|
||||
it("offers only the authentication an n8n Webhook node can be set to", () => {
|
||||
// The node does None, Basic and Header. Offering bearer or the token-login dance would be
|
||||
// auth the operator cannot configure on the other end.
|
||||
expect(field("authType").options?.map((o) => o.value)).toEqual([
|
||||
"NONE",
|
||||
"HEADER",
|
||||
"BASIC",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows each credential only for the auth it belongs to", () => {
|
||||
expect(isFieldVisible(field("headerName"), { authType: "HEADER" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isFieldVisible(field("headerName"), { authType: "NONE" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isFieldVisible(field("username"), { authType: "BASIC" })).toBe(true);
|
||||
expect(isFieldVisible(field("username"), { authType: "HEADER" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the operator's answer override the preset's default of no auth", () => {
|
||||
// buildConnectionConfig spreads presetConfig first, so an unanswered form still yields NONE
|
||||
// but a chosen HEADER must win - otherwise the credential is stored and never sent.
|
||||
const open = buildConnectionConfig(n8n, {
|
||||
baseUrl: "https://n8n.acme.test/webhook/abc",
|
||||
});
|
||||
expect(open.authType).toBe("NONE");
|
||||
|
||||
const secured = buildConnectionConfig(n8n, {
|
||||
baseUrl: "https://n8n.acme.test/webhook/abc",
|
||||
authType: "HEADER",
|
||||
headerName: "X-N8N-Key",
|
||||
token: "s3cr3t",
|
||||
});
|
||||
expect(secured).toMatchObject({
|
||||
authType: "HEADER",
|
||||
headerName: "X-N8N-Key",
|
||||
token: "s3cr3t",
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers an n8n Cloud connection from its host", () => {
|
||||
expect(
|
||||
connectionTypeOf("API", {
|
||||
baseUrl: "https://acme.app.n8n.cloud/webhook/abc",
|
||||
})?.id,
|
||||
).toBe("n8n");
|
||||
});
|
||||
|
||||
it("recovers a self-hosted n8n from its marker, since the host names nothing", () => {
|
||||
const config = buildConnectionConfig(n8n, {
|
||||
baseUrl: "https://automation.acme.internal/webhook/abc",
|
||||
});
|
||||
expect(connectionTypeOf("API", config)?.id).toBe("n8n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolving a stored connection back to its preset", () => {
|
||||
it("keeps a Discord connection as Discord, not the first API preset", () => {
|
||||
// Seventeen presets share integrationType "API". Matching on that alone returned whichever
|
||||
|
||||
@@ -496,6 +496,57 @@ const field = {
|
||||
}),
|
||||
};
|
||||
|
||||
/** The three modes an n8n Webhook node can be set to, asked for rather than baked into the preset
|
||||
* because it is the operator's choice; field values apply over `presetConfig`, so the answer wins. */
|
||||
const N8N_AUTH_FIELDS: ConnectionFieldDef[] = [
|
||||
{
|
||||
key: "authType",
|
||||
labelKey: `${PREFIX}.n8n.fields.authType.label`,
|
||||
control: "select",
|
||||
required: true,
|
||||
defaultValue: "NONE",
|
||||
helperTextKey: `${PREFIX}.n8n.fields.authType.helperText`,
|
||||
options: [
|
||||
{
|
||||
value: "NONE",
|
||||
labelKey: `${PREFIX}.n8n.fields.authType.options.none.label`,
|
||||
},
|
||||
{
|
||||
value: "HEADER",
|
||||
labelKey: `${PREFIX}.n8n.fields.authType.options.header.label`,
|
||||
},
|
||||
{
|
||||
value: "BASIC",
|
||||
labelKey: `${PREFIX}.n8n.fields.authType.options.basic.label`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "headerName",
|
||||
labelKey: `${PREFIX}.n8n.fields.headerName.label`,
|
||||
control: "text",
|
||||
required: true,
|
||||
placeholderKey: `${PREFIX}.n8n.fields.headerName.placeholder`,
|
||||
helperTextKey: `${PREFIX}.n8n.fields.headerName.helperText`,
|
||||
visibleWhen: { key: "authType", oneOf: ["HEADER"] },
|
||||
},
|
||||
{
|
||||
key: "token",
|
||||
labelKey: `${PREFIX}.n8n.fields.token.label`,
|
||||
control: "password",
|
||||
required: true,
|
||||
visibleWhen: { key: "authType", oneOf: ["HEADER"] },
|
||||
},
|
||||
{
|
||||
...field.username(),
|
||||
visibleWhen: { key: "authType", oneOf: ["BASIC"] },
|
||||
},
|
||||
{
|
||||
...field.password(),
|
||||
visibleWhen: { key: "authType", oneOf: ["BASIC"] },
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* A vendor whose integration is "an HTTP API with known mechanics". The auth shape, and the base
|
||||
* URL when the vendor has a single global one, are baked into `preset`; the operator supplies only
|
||||
@@ -692,6 +743,26 @@ const API_PRESETS: CreatableConnectionType[] = [
|
||||
searchTerms: ["make", "automation", "workflow", "trigger", "no-code"],
|
||||
identifyHosts: ["zapier.com"],
|
||||
}),
|
||||
// Usually self-hosted, so there is no global host to bake in. The node's three auth options are
|
||||
// mirrored exactly; BEARER or TOKEN_LOGIN would be auth it cannot be configured for.
|
||||
apiPreset({
|
||||
id: "n8n",
|
||||
category: "notify",
|
||||
preset: { authType: "NONE" },
|
||||
fields: [field.webhookUrl("n8n"), ...N8N_AUTH_FIELDS],
|
||||
searchTerms: [
|
||||
"n8n",
|
||||
"automation",
|
||||
"workflow",
|
||||
"trigger",
|
||||
"self-hosted",
|
||||
"no-code",
|
||||
"low-code",
|
||||
],
|
||||
// n8n Cloud instances live at <tenant>.app.n8n.cloud; a self-hosted one is recovered from
|
||||
// its presetId marker instead, since its host is whatever the operator chose.
|
||||
identifyHosts: ["n8n.cloud"],
|
||||
}),
|
||||
apiPreset({
|
||||
id: "webhook",
|
||||
category: "notify",
|
||||
|
||||
Reference in New Issue
Block a user