Compare commits
No commits in common. "main" and "v1.0.2" have entirely different histories.
11 changed files with 43 additions and 567 deletions
14
README.de.md
14
README.de.md
|
|
@ -3,7 +3,7 @@
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
🇬🇧 [English version → README.md](README.md)
|
🇬🇧 [English version → README.md](README.md)
|
||||||
|
|
||||||
|
|
@ -167,25 +167,17 @@ Jede Aktion ist global in den Einstellungen steuerbar und per Shortcode übersch
|
||||||
|
|
||||||
### Maskierung sensibler Daten
|
### Maskierung sensibler Daten
|
||||||
|
|
||||||
BreznFlow speichert nie das rohe Workflow-JSON. Vor dem Speichern läuft eine Drei-Pass-Sanitierung:
|
BreznFlow speichert nie das rohe Workflow-JSON. Vor dem Speichern läuft eine Zwei-Pass-Sanitierung:
|
||||||
|
|
||||||
**Pass 1 — String-Sanitierung:** Alle Strings durchlaufen `sanitize_text_field()`. Ausnahme: `jsCode`-Felder bleiben erhalten, werden aber mit `esc_html()` ausgegeben (nie ausgeführt).
|
**Pass 1 — String-Sanitierung:** Alle Strings durchlaufen `sanitize_text_field()`. Ausnahme: `jsCode`-Felder bleiben erhalten, werden aber mit `esc_html()` ausgegeben (nie ausgeführt).
|
||||||
|
|
||||||
**Pass 2 — Secret-Erkennung:**
|
**Pass 2 — Secret-Erkennung:**
|
||||||
|
|
||||||
- **URL-Parameter:** `api_key`, `token`, `secret`, `password`, `access_token`, `auth`, `client_secret` in Query-Strings → `[REDACTED]`
|
- **URL-Parameter:** `api_key`, `token`, `secret`, `password`, `access_token`, `auth`, `client_secret` in Query-Strings → `[REDACTED]`
|
||||||
- **Generische `?key=`-URL-Parameter** *(1.0.4)*: werden nur redactet, wenn der gefangene Wert `looks_like_secret()` matcht — schließt die Google-API-Key-Lücke ohne False-Positives auf harmlosen Werten.
|
|
||||||
- **Header-Werte:** Authorization, Bearer, X-API-Key und ähnliche Header-Namen in `{name, value}`-Paaren → Wert maskiert
|
- **Header-Werte:** Authorization, Bearer, X-API-Key und ähnliche Header-Namen in `{name, value}`-Paaren → Wert maskiert
|
||||||
- **Wert-Entropie-Fallback** *(1.0.4)*: `{name, value}`-Paare, deren Name die Allowlist nicht matcht, werden dennoch maskiert, wenn der Wert selbst secret-förmig aussieht — deckt Custom-Header (`X-App-Token`) und n8ns generisches `queryParameters.key`-Pattern ab.
|
|
||||||
- **Bekannte Vendor-Token** *(1.0.4)*: `looks_like_secret()` matcht `AIza…`, `sk-…`, `ghp_…`, `gho_…`, Slack `xox…`, `Bearer …` (JWT) — plus Längen- und Zeichenklassen-Entropie-Fallback sowie Pfad-/Whitespace-Denylist gegen False-Positives.
|
|
||||||
- **Hochentropie-Bedingungen:** Werte in IF/Switch-Conditions, die UUID-Muster, Groß-/Kleinschreibung+Ziffern oder lange Strings ohne Leerzeichen matchen → per Entropie-Heuristik maskiert
|
- **Hochentropie-Bedingungen:** Werte in IF/Switch-Conditions, die UUID-Muster, Groß-/Kleinschreibung+Ziffern oder lange Strings ohne Leerzeichen matchen → per Entropie-Heuristik maskiert
|
||||||
- **Credential-Anzeigenamen** *(1.0.4)*: `credentials[].name` pro Node wird durch `[REDACTED]` ersetzt (die `id` bleibt — sie referenziert die n8n-DB und ist ohne den Server wertlos).
|
|
||||||
|
|
||||||
**Pass 3 — Identifizierende Metadaten** *(1.0.4)*: `meta.instanceId` wird geleert, damit Workflow-Exporte nicht mehr der ausgebenden n8n-Instanz zugeordnet werden können.
|
Ein **Maskierungs-Protokoll** zeichnet jedes maskierte Element mit Grund, Key und Hinweis auf — sichtbar in der Wizard-Vorschau (Schritt 3).
|
||||||
|
|
||||||
**Optional — Tag-Entfernung** *(1.0.4)*: Wizard Schritt 3 bietet eine Opt-in-Checkbox zum Entfernen von Workflow-Tags. Tags sind oft harmlos (`production`, `v2`), manchmal aber identifizierend — der Publisher entscheidet pro Workflow.
|
|
||||||
|
|
||||||
Ein **Maskierungs-Protokoll** zeichnet jedes maskierte Element mit Grund, Key und Hinweis auf. Schritt 3 zeigt es als ausklappbare Grund / Key / Hinweis-Tabelle, damit der Publisher vor Publish genau prüfen kann was veröffentlicht wird.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -3,7 +3,7 @@
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
🇩🇪 [Deutsche Version → README.de.md](README.de.md)
|
🇩🇪 [Deutsche Version → README.de.md](README.de.md)
|
||||||
|
|
||||||
|
|
@ -167,25 +167,17 @@ Each action can be toggled globally in settings and overridden per shortcode.
|
||||||
|
|
||||||
### Sensitive Data Masking
|
### Sensitive Data Masking
|
||||||
|
|
||||||
BreznFlow never stores raw workflow JSON. Before saving, a three-pass sanitization runs:
|
BreznFlow never stores raw workflow JSON. Before saving, a two-pass sanitization runs:
|
||||||
|
|
||||||
**Pass 1 — String sanitization:** All string values pass through `sanitize_text_field()`. Exception: `jsCode` fields are preserved as-is but displayed with `esc_html()` (never executed).
|
**Pass 1 — String sanitization:** All string values pass through `sanitize_text_field()`. Exception: `jsCode` fields are preserved as-is but displayed with `esc_html()` (never executed).
|
||||||
|
|
||||||
**Pass 2 — Secret detection:**
|
**Pass 2 — Secret detection:**
|
||||||
|
|
||||||
- **URL parameters:** `api_key`, `token`, `secret`, `password`, `access_token`, `auth`, `client_secret` in query strings → `[REDACTED]`
|
- **URL parameters:** `api_key`, `token`, `secret`, `password`, `access_token`, `auth`, `client_secret` in query strings → `[REDACTED]`
|
||||||
- **Generic `?key=` URL params** *(1.0.4)*: redacted only when the captured value matches `looks_like_secret()` — closes the Google API key bypass without false-positives on harmless values.
|
|
||||||
- **Header values:** Authorization, Bearer, X-API-Key and similar header names in `{name, value}` pairs → value masked
|
- **Header values:** Authorization, Bearer, X-API-Key and similar header names in `{name, value}` pairs → value masked
|
||||||
- **Value-entropy fallback** *(1.0.4)*: `{name, value}` pairs whose name does not match the allowlist are still masked when the value itself looks secret-shaped — covers custom headers (`X-App-Token`) and n8n's `queryParameters` generic-`key` pattern.
|
|
||||||
- **Known vendor tokens** *(1.0.4)*: `looks_like_secret()` matches `AIza…`, `sk-…`, `ghp_…`, `gho_…`, Slack `xox…`, `Bearer …` (JWT) — with a length+char-class entropy fallback and a path/whitespace denylist for false-positive control.
|
|
||||||
- **High-entropy conditions:** Values in IF/Switch conditions that match UUID patterns, mixed-case+digits, or long strings without spaces → masked via entropy heuristic
|
- **High-entropy conditions:** Values in IF/Switch conditions that match UUID patterns, mixed-case+digits, or long strings without spaces → masked via entropy heuristic
|
||||||
- **Credential display names** *(1.0.4)*: `credentials[].name` is replaced with `[REDACTED]` per node (the credential `id` is retained — it references the n8n DB and is useless without the server).
|
|
||||||
|
|
||||||
**Pass 3 — Identifying metadata** *(1.0.4)*: `meta.instanceId` is cleared so workflow exports cannot be correlated to the originating n8n instance.
|
A **mask log** records every masked item with the reason, key, and note — shown in the wizard's Step 3 preview.
|
||||||
|
|
||||||
**Optional — Tag removal** *(1.0.4)*: Wizard step 3 offers an opt-in checkbox to strip workflow tags. Tags are often innocuous (`production`, `v2`) but sometimes identifying — the publisher decides per workflow.
|
|
||||||
|
|
||||||
A **mask log** records every masked item with reason, key, and note. Step 3 shows it as a collapsible Reason / Key / Note table so the publisher can review exactly what will be published.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,11 +84,6 @@
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
/* Claim all touch gestures on the diagram: single-finger pan, two-finger
|
|
||||||
pinch, double-tap. Trade-off: starting a touch on the SVG means page
|
|
||||||
scroll is blocked until the finger lifts. Page scroll around the
|
|
||||||
diagram (container margins) keeps default behavior. */
|
|
||||||
touch-action: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Canvas (zoom/pan transform applied here) */
|
/* Canvas (zoom/pan transform applied here) */
|
||||||
|
|
@ -412,7 +407,7 @@
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breznflow-minimap svg { display: block; touch-action: none; }
|
.breznflow-minimap svg { display: block; }
|
||||||
|
|
||||||
/* Infobox node badges — interactive highlight */
|
/* Infobox node badges — interactive highlight */
|
||||||
.breznflow-infobox-node {
|
.breznflow-infobox-node {
|
||||||
|
|
|
||||||
|
|
@ -368,16 +368,9 @@
|
||||||
onNavigate(pos.x / fitScale, pos.y / fitScale);
|
onNavigate(pos.x / fitScale, pos.y / fitScale);
|
||||||
}
|
}
|
||||||
|
|
||||||
svg.addEventListener('pointerdown', function(e) {
|
svg.addEventListener('mousedown', function(e) { dragging = true; navigate(e); e.stopPropagation(); });
|
||||||
dragging = true;
|
svg.addEventListener('mousemove', function(e) { if (dragging) navigate(e); });
|
||||||
navigate(e);
|
document.addEventListener('mouseup', function() { dragging = false; });
|
||||||
e.stopPropagation();
|
|
||||||
if (e.cancelable) e.preventDefault();
|
|
||||||
try { svg.setPointerCapture(e.pointerId); } catch (_) { /* iOS quirk */ }
|
|
||||||
}, { passive: false });
|
|
||||||
svg.addEventListener('pointermove', function(e) { if (dragging) navigate(e); }, { passive: false });
|
|
||||||
svg.addEventListener('pointerup', function() { dragging = false; });
|
|
||||||
svg.addEventListener('pointercancel', function() { dragging = false; });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
el: wrap,
|
el: wrap,
|
||||||
|
|
@ -1102,11 +1095,25 @@
|
||||||
self._applyTransform();
|
self._applyTransform();
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
self._initPointerState();
|
svg.addEventListener('pointerdown', function(e) {
|
||||||
svg.addEventListener('pointerdown', function(e) { self._onPointerDown(e); }, { passive: false });
|
const t = e.target;
|
||||||
svg.addEventListener('pointermove', function(e) { self._onPointerMove(e); }, { passive: false });
|
const isBg = t === svg ||
|
||||||
svg.addEventListener('pointerup', function(e) { self._onPointerUp(e); }, { passive: false });
|
(t.getAttribute && (t.getAttribute('class') === 'breznflow-canvas' || t.getAttribute('class') === 'breznflow-connection-path'));
|
||||||
svg.addEventListener('pointercancel', function(e) { self._onPointerUp(e); }, { passive: false });
|
if (!isBg) return;
|
||||||
|
self.isPanning = true;
|
||||||
|
self.panStart = { x: e.clientX - self.tx, y: e.clientY - self.ty };
|
||||||
|
svg.setPointerCapture(e.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.addEventListener('pointermove', function(e) {
|
||||||
|
if (!self.isPanning) return;
|
||||||
|
self.tx = e.clientX - self.panStart.x;
|
||||||
|
self.ty = e.clientY - self.panStart.y;
|
||||||
|
self._applyTransform();
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.addEventListener('pointerup', function() { self.isPanning = false; });
|
||||||
|
svg.addEventListener('pointercancel', function() { self.isPanning = false; });
|
||||||
|
|
||||||
svg.addEventListener('click', function(e) {
|
svg.addEventListener('click', function(e) {
|
||||||
const t = e.target;
|
const t = e.target;
|
||||||
|
|
@ -1125,156 +1132,6 @@
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
BreznFlowRenderer.prototype._initPointerState = function() {
|
|
||||||
this._pointers = new Map();
|
|
||||||
this._lastTap = { t: 0, x: 0, y: 0 };
|
|
||||||
this._pinchStart = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
BreznFlowRenderer.prototype._isBgTarget = function(t) {
|
|
||||||
if (!t) return false;
|
|
||||||
if (t === this._svg) return true;
|
|
||||||
if (!t.getAttribute) return false;
|
|
||||||
const cls = t.getAttribute('class');
|
|
||||||
return cls === 'breznflow-canvas' || cls === 'breznflow-connection-path';
|
|
||||||
};
|
|
||||||
|
|
||||||
BreznFlowRenderer.prototype._onPointerDown = function(e) {
|
|
||||||
const isBg = this._isBgTarget(e.target);
|
|
||||||
const rect = this._svg.getBoundingClientRect();
|
|
||||||
const px = e.clientX - rect.left;
|
|
||||||
const py = e.clientY - rect.top;
|
|
||||||
|
|
||||||
// Double-tap on empty canvas toggles zoom. Must run before pointer is
|
|
||||||
// added to the map so pointers.size===0 check is meaningful.
|
|
||||||
if (isBg && this._pointers.size === 0) {
|
|
||||||
const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
||||||
const dt = now - this._lastTap.t;
|
|
||||||
const dx = px - this._lastTap.x;
|
|
||||||
const dy = py - this._lastTap.y;
|
|
||||||
if (dt < 300 && (dx * dx + dy * dy) < 900) {
|
|
||||||
this._lastTap = { t: 0, x: 0, y: 0 };
|
|
||||||
if (e.cancelable) e.preventDefault();
|
|
||||||
this._handleDoubleTap(px, py);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._lastTap = { t: now, x: px, y: py };
|
|
||||||
}
|
|
||||||
|
|
||||||
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
||||||
|
|
||||||
// Non-bg targets (nodes, badges) fall through to default click handling.
|
|
||||||
if (!isBg) return;
|
|
||||||
if (e.cancelable) e.preventDefault();
|
|
||||||
|
|
||||||
try { this._svg.setPointerCapture(e.pointerId); } catch (_) { /* iOS Safari quirk */ }
|
|
||||||
|
|
||||||
if (this._pointers.size === 1) {
|
|
||||||
this.isPanning = true;
|
|
||||||
this.panStart = { x: e.clientX - this.tx, y: e.clientY - this.ty };
|
|
||||||
} else if (this._pointers.size === 2) {
|
|
||||||
this.isPanning = false;
|
|
||||||
this._pinchStart = this._computePinchState();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
BreznFlowRenderer.prototype._onPointerMove = function(e) {
|
|
||||||
if (!this._pointers.has(e.pointerId)) return;
|
|
||||||
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
||||||
|
|
||||||
if (this._pinchStart && this._pointers.size === 2) {
|
|
||||||
this._applyPinch();
|
|
||||||
} else if (this.isPanning && this._pointers.size === 1) {
|
|
||||||
this.tx = e.clientX - this.panStart.x;
|
|
||||||
this.ty = e.clientY - this.panStart.y;
|
|
||||||
this._applyTransform();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
BreznFlowRenderer.prototype._onPointerUp = function(e) {
|
|
||||||
if (!this._pointers.delete(e.pointerId)) return;
|
|
||||||
|
|
||||||
if (this._pinchStart && this._pointers.size < 2) {
|
|
||||||
this._pinchStart = null;
|
|
||||||
// Transitioning 2→1 fingers: re-seat pan-start on the remaining
|
|
||||||
// pointer so the surviving finger doesn't cause a jump.
|
|
||||||
if (this._pointers.size === 1) {
|
|
||||||
const remaining = this._pointers.values().next().value;
|
|
||||||
this.isPanning = true;
|
|
||||||
this.panStart = { x: remaining.x - this.tx, y: remaining.y - this.ty };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._pointers.size === 0) {
|
|
||||||
this.isPanning = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
BreznFlowRenderer.prototype._computePinchState = function() {
|
|
||||||
const pts = Array.from(this._pointers.values());
|
|
||||||
const p1 = pts[0], p2 = pts[1];
|
|
||||||
const dx = p2.x - p1.x;
|
|
||||||
const dy = p2.y - p1.y;
|
|
||||||
return {
|
|
||||||
d: Math.max(1, Math.sqrt(dx * dx + dy * dy)),
|
|
||||||
cx: (p1.x + p2.x) / 2,
|
|
||||||
cy: (p1.y + p2.y) / 2,
|
|
||||||
tx: this.tx,
|
|
||||||
ty: this.ty,
|
|
||||||
userZoom: this.userZoom
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
BreznFlowRenderer.prototype._applyPinch = function() {
|
|
||||||
if (!this._pinchStart) return;
|
|
||||||
const pts = Array.from(this._pointers.values());
|
|
||||||
if (pts.length !== 2) return;
|
|
||||||
const p1 = pts[0], p2 = pts[1];
|
|
||||||
const dx = p2.x - p1.x;
|
|
||||||
const dy = p2.y - p1.y;
|
|
||||||
const d = Math.max(1, Math.sqrt(dx * dx + dy * dy));
|
|
||||||
const cx = (p1.x + p2.x) / 2;
|
|
||||||
const cy = (p1.y + p2.y) / 2;
|
|
||||||
|
|
||||||
const ratio = d / this._pinchStart.d;
|
|
||||||
const newZoom = Math.max(MIN_SCALE * 100, Math.min(MAX_SCALE * 100, this._pinchStart.userZoom * ratio));
|
|
||||||
const baseScale = this.layout && this.layout.scale ? this.layout.scale : 1;
|
|
||||||
const oldS = baseScale * (this._pinchStart.userZoom / 100);
|
|
||||||
const newS = baseScale * (newZoom / 100);
|
|
||||||
if (oldS === 0) return;
|
|
||||||
|
|
||||||
// Two moves compounded: (1) zoom anchored at the start-center in SVG
|
|
||||||
// coords, (2) pan by how far the current center drifted from the
|
|
||||||
// start-center. Order matters — zoom first, then translate.
|
|
||||||
const rect = this._svg.getBoundingClientRect();
|
|
||||||
const sx = this._pinchStart.cx - rect.left;
|
|
||||||
const sy = this._pinchStart.cy - rect.top;
|
|
||||||
const dxCenter = cx - this._pinchStart.cx;
|
|
||||||
const dyCenter = cy - this._pinchStart.cy;
|
|
||||||
|
|
||||||
this.tx = sx - (sx - this._pinchStart.tx) * (newS / oldS) + dxCenter;
|
|
||||||
this.ty = sy - (sy - this._pinchStart.ty) * (newS / oldS) + dyCenter;
|
|
||||||
this.userZoom = newZoom;
|
|
||||||
if (this.zoomLabel) this.zoomLabel.textContent = Math.round(newZoom) + '%';
|
|
||||||
this._applyTransform();
|
|
||||||
};
|
|
||||||
|
|
||||||
BreznFlowRenderer.prototype._handleDoubleTap = function(px, py) {
|
|
||||||
// Toggle 100↔200 %, zoom-anchored at tap point (identical math to
|
|
||||||
// wheel-zoom above — keeps the world point under the cursor fixed).
|
|
||||||
const newZoom = this.userZoom > 150 ? 100 : 200;
|
|
||||||
const baseScale = this.layout && this.layout.scale ? this.layout.scale : 1;
|
|
||||||
const oldS = baseScale * (this.userZoom / 100);
|
|
||||||
const newS = baseScale * (newZoom / 100);
|
|
||||||
if (oldS === 0) return;
|
|
||||||
|
|
||||||
this.tx = px - (px - this.tx) * (newS / oldS);
|
|
||||||
this.ty = py - (py - this.ty) * (newS / oldS);
|
|
||||||
this.userZoom = newZoom;
|
|
||||||
if (this.zoomLabel) this.zoomLabel.textContent = Math.round(newZoom) + '%';
|
|
||||||
this._applyTransform();
|
|
||||||
};
|
|
||||||
|
|
||||||
BreznFlowRenderer.prototype._toggleFullscreen = function(btn) {
|
BreznFlowRenderer.prototype._toggleFullscreen = function(btn) {
|
||||||
if (this._fsActive) {
|
if (this._fsActive) {
|
||||||
this._exitFullscreen(btn);
|
this._exitFullscreen(btn);
|
||||||
|
|
@ -1663,18 +1520,9 @@
|
||||||
function init() {
|
function init() {
|
||||||
if (typeof breznflowData === 'undefined' || !Array.isArray(breznflowData)) return;
|
if (typeof breznflowData === 'undefined' || !Array.isArray(breznflowData)) return;
|
||||||
|
|
||||||
// Defensive guard: if any filter re-ran the shortcode, breznflowData may
|
|
||||||
// contain duplicate entries pointing at the same container. Mounting twice
|
|
||||||
// would stack two full UIs via appendChild. Track mounted containers and
|
|
||||||
// skip repeats — independent of server-side dedupe.
|
|
||||||
const mounted = new WeakSet();
|
|
||||||
|
|
||||||
for (const data of breznflowData) {
|
for (const data of breznflowData) {
|
||||||
const domId = data.dom_id || ('breznflow-wrap-' + data.id);
|
const container = document.getElementById('breznflow-wrap-' + data.id);
|
||||||
const container = document.getElementById(domId);
|
|
||||||
if (!container) continue;
|
if (!container) continue;
|
||||||
if (mounted.has(container)) continue;
|
|
||||||
mounted.add(container);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const renderer = new BreznFlowRenderer(data);
|
const renderer = new BreznFlowRenderer(data);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
* Plugin Name: BreznFlow
|
* Plugin Name: BreznFlow
|
||||||
* Plugin URI: https://breznflow.com/
|
* Plugin URI: https://breznflow.com/
|
||||||
* Description: Display n8n automation workflows with an interactive SVG diagram, node detail panel, and sensitive data masking.
|
* Description: Display n8n automation workflows with an interactive SVG diagram, node detail panel, and sensitive data masking.
|
||||||
* Version: 1.0.4
|
* Version: 1.0.2
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 8.0
|
* Requires PHP: 8.0
|
||||||
* Author: NoSchmarrn.dev
|
* Author: NoSchmarrn.dev
|
||||||
|
|
@ -23,7 +23,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
define( 'BREZNFLOW_VERSION', '1.0.4' );
|
define( 'BREZNFLOW_VERSION', '1.0.2' );
|
||||||
define( 'BREZNFLOW_FILE', __FILE__ );
|
define( 'BREZNFLOW_FILE', __FILE__ );
|
||||||
define( 'BREZNFLOW_DIR', plugin_dir_path( __FILE__ ) );
|
define( 'BREZNFLOW_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'BREZNFLOW_URL', plugin_dir_url( __FILE__ ) );
|
define( 'BREZNFLOW_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|
|
||||||
|
|
@ -404,24 +404,6 @@ class WizardPage {
|
||||||
wp_die( esc_html__( 'Invalid workflow ID.', 'breznflow' ) );
|
wp_die( esc_html__( 'Invalid workflow ID.', 'breznflow' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opt-in tag removal. Runs against the already-sanitized stored JSON,
|
|
||||||
// so there is no risk of undoing the pass-1/2/3 masking from step 1.
|
|
||||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above
|
|
||||||
if ( ! empty( $_POST['breznflow_strip_tags'] ) ) {
|
|
||||||
$raw_json = get_post_meta( $post_id, '_breznflow_raw_json', true );
|
|
||||||
$decoded = $raw_json ? json_decode( $raw_json, true ) : null;
|
|
||||||
if ( is_array( $decoded ) ) {
|
|
||||||
$mask_log_raw = get_post_meta( $post_id, '_breznflow_mask_log', true );
|
|
||||||
$mask_log = $mask_log_raw ? json_decode( $mask_log_raw, true ) : array();
|
|
||||||
if ( ! is_array( $mask_log ) ) {
|
|
||||||
$mask_log = array();
|
|
||||||
}
|
|
||||||
$stripped = WorkflowSanitizer::strip_workflow_tags( $decoded, $mask_log );
|
|
||||||
update_post_meta( $post_id, '_breznflow_raw_json', wp_slash( wp_json_encode( $stripped ) ) );
|
|
||||||
update_post_meta( $post_id, '_breznflow_mask_log', wp_slash( wp_json_encode( $mask_log ) ) );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wp_update_post(
|
wp_update_post(
|
||||||
array(
|
array(
|
||||||
'ID' => $post_id,
|
'ID' => $post_id,
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,11 @@ $meta_zoom = (int) get_post_meta( $post_id, '_breznflow_default_zoom', true )
|
||||||
$zoom = $meta_zoom ? $meta_zoom : 100;
|
$zoom = $meta_zoom ? $meta_zoom : 100;
|
||||||
$show_infobox = (int) get_post_meta( $post_id, '_breznflow_show_infobox', true );
|
$show_infobox = (int) get_post_meta( $post_id, '_breznflow_show_infobox', true );
|
||||||
|
|
||||||
// Check for code nodes with jsCode and collect workflow tag names.
|
// Check for code nodes with jsCode.
|
||||||
$has_code_nodes = false;
|
$has_code_nodes = false;
|
||||||
$workflow_tags = array();
|
|
||||||
if ( $raw_json ) {
|
if ( $raw_json ) {
|
||||||
$data = json_decode( $raw_json, true );
|
$data = json_decode( $raw_json, true );
|
||||||
if ( is_array( $data ) ) {
|
if ( is_array( $data ) && ! empty( $data['nodes'] ) ) {
|
||||||
if ( ! empty( $data['nodes'] ) ) {
|
|
||||||
foreach ( $data['nodes'] as $node ) {
|
foreach ( $data['nodes'] as $node ) {
|
||||||
if ( isset( $node['parameters']['jsCode'] ) ) {
|
if ( isset( $node['parameters']['jsCode'] ) ) {
|
||||||
$has_code_nodes = true;
|
$has_code_nodes = true;
|
||||||
|
|
@ -44,14 +42,6 @@ if ( $raw_json ) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ( ! empty( $data['tags'] ) && is_array( $data['tags'] ) ) {
|
|
||||||
foreach ( $data['tags'] as $tag ) {
|
|
||||||
if ( is_array( $tag ) && isset( $tag['name'] ) && '' !== $tag['name'] ) {
|
|
||||||
$workflow_tags[] = (string) $tag['name'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$settings = \BreznFlow\Admin\SettingsPage::get_defaults();
|
$settings = \BreznFlow\Admin\SettingsPage::get_defaults();
|
||||||
|
|
@ -152,29 +142,6 @@ $preview_theme = in_array( $saved_theme, $allowed_themes, true ) ? $saved_them
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
</p>
|
</p>
|
||||||
<details class="breznflow-mask-details">
|
|
||||||
<summary><?php esc_html_e( 'Show what was masked', 'breznflow' ); ?></summary>
|
|
||||||
<div class="breznflow-mask-table-wrap" style="max-height:360px;overflow:auto;margin-top:8px;">
|
|
||||||
<table class="widefat striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col"><?php esc_html_e( 'Reason', 'breznflow' ); ?></th>
|
|
||||||
<th scope="col"><?php esc_html_e( 'Key', 'breznflow' ); ?></th>
|
|
||||||
<th scope="col"><?php esc_html_e( 'Note', 'breznflow' ); ?></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ( $mask_log as $entry ) : ?>
|
|
||||||
<tr>
|
|
||||||
<td><code><?php echo esc_html( (string) ( $entry['reason'] ?? '' ) ); ?></code></td>
|
|
||||||
<td><code><?php echo esc_html( (string) ( $entry['key'] ?? '' ) ); ?></code></td>
|
|
||||||
<td><?php echo esc_html( (string) ( $entry['note'] ?? '' ) ); ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|
@ -187,26 +154,6 @@ $preview_theme = in_array( $saved_theme, $allowed_themes, true ) ? $saved_them
|
||||||
<input type="hidden" name="action" value="breznflow_publish_workflow" />
|
<input type="hidden" name="action" value="breznflow_publish_workflow" />
|
||||||
<input type="hidden" name="breznflow_post_id" value="<?php echo esc_attr( (string) $post_id ); ?>" />
|
<input type="hidden" name="breznflow_post_id" value="<?php echo esc_attr( (string) $post_id ); ?>" />
|
||||||
<?php wp_nonce_field( 'breznflow_publish', 'breznflow_nonce' ); ?>
|
<?php wp_nonce_field( 'breznflow_publish', 'breznflow_nonce' ); ?>
|
||||||
|
|
||||||
<?php if ( ! empty( $workflow_tags ) ) : ?>
|
|
||||||
<p>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" name="breznflow_strip_tags" value="1" />
|
|
||||||
<strong><?php esc_html_e( 'Remove workflow tags before publishing', 'breznflow' ); ?></strong>
|
|
||||||
</label>
|
|
||||||
<br />
|
|
||||||
<span class="description">
|
|
||||||
<?php
|
|
||||||
printf(
|
|
||||||
/* translators: %s: comma-separated list of tag names */
|
|
||||||
esc_html__( 'Current tags: %s. Tags are organisational labels in n8n — harmless by default, but sometimes identifying (e.g. an internal project name).', 'breznflow' ),
|
|
||||||
esc_html( implode( ', ', $workflow_tags ) )
|
|
||||||
);
|
|
||||||
?>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="
|
<a href="
|
||||||
<?php
|
<?php
|
||||||
|
|
|
||||||
|
|
@ -21,20 +21,6 @@ class MaskingRules {
|
||||||
/** URL query param pattern for sensitive keys. */
|
/** URL query param pattern for sensitive keys. */
|
||||||
const URL_PARAM_PATTERN = '/([?&](?:api[_-]?key|apikey|token|secret|password|access_token|auth|private_key|client_secret)=)[^&\s#]+/i';
|
const URL_PARAM_PATTERN = '/([?&](?:api[_-]?key|apikey|token|secret|password|access_token|auth|private_key|client_secret)=)[^&\s#]+/i';
|
||||||
|
|
||||||
/**
|
|
||||||
* Known vendor secret formats.
|
|
||||||
*
|
|
||||||
* @since 1.0.4
|
|
||||||
*/
|
|
||||||
const HIGH_ENTROPY_PATTERNS = array(
|
|
||||||
'/^AIza[0-9A-Za-z_-]{35}$/', // Google API key.
|
|
||||||
'/^sk-[A-Za-z0-9_-]{20,}$/', // OpenAI / Anthropic family.
|
|
||||||
'/^ghp_[A-Za-z0-9]{36}$/', // GitHub personal access token.
|
|
||||||
'/^gho_[A-Za-z0-9]{36}$/', // GitHub OAuth token.
|
|
||||||
'/^xox[baprs]-[A-Za-z0-9-]{10,}$/', // Slack token.
|
|
||||||
'/^Bearer\s+[A-Za-z0-9._~+\/=-]{20,}$/', // Bearer auth header.
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Safe-list values that should never be masked (condition rightValue). */
|
/** Safe-list values that should never be masked (condition rightValue). */
|
||||||
const SAFE_CONDITION_VALUES = array(
|
const SAFE_CONDITION_VALUES = array(
|
||||||
'true',
|
'true',
|
||||||
|
|
@ -95,25 +81,6 @@ class MaskingRules {
|
||||||
},
|
},
|
||||||
$value
|
$value
|
||||||
);
|
);
|
||||||
$value = null !== $masked ? $masked : $value;
|
|
||||||
|
|
||||||
// Conditional pass for generic `?key=…` / `&key=…` — only redact when
|
|
||||||
// the captured value itself looks like a secret.
|
|
||||||
$masked = preg_replace_callback(
|
|
||||||
'/([?&]key=)([^&\s#]+)/i',
|
|
||||||
function ( $matches ) use ( &$log ) {
|
|
||||||
if ( ! self::looks_like_secret( $matches[2] ) ) {
|
|
||||||
return $matches[0];
|
|
||||||
}
|
|
||||||
$log[] = array(
|
|
||||||
'reason' => 'url_param_generic_key',
|
|
||||||
'key' => $matches[0],
|
|
||||||
'note' => 'Generic key query-param holds secret-shaped value.',
|
|
||||||
);
|
|
||||||
return $matches[1] . '[REDACTED]';
|
|
||||||
},
|
|
||||||
$value
|
|
||||||
);
|
|
||||||
return null !== $masked ? $masked : $value;
|
return null !== $masked ? $masked : $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,74 +118,9 @@ class MaskingRules {
|
||||||
);
|
);
|
||||||
return '[REDACTED]';
|
return '[REDACTED]';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic `key` field names (common in n8n queryParameters) gated by
|
|
||||||
// entropy check to avoid false positives on harmless values like
|
|
||||||
// `{name:"key", value:"weather_berlin"}`.
|
|
||||||
$generic_key_names = array( 'key', 'x-key', 'access-key', 'x_key' );
|
|
||||||
if ( in_array( strtolower( $field_key ), $generic_key_names, true )
|
|
||||||
&& '' !== $value
|
|
||||||
&& '[REDACTED]' !== $value
|
|
||||||
&& self::looks_like_secret( $value ) ) {
|
|
||||||
$log[] = array(
|
|
||||||
'reason' => 'generic_key_with_entropy',
|
|
||||||
'key' => $field_key,
|
|
||||||
'note' => 'Generic key-named field holds secret-shaped value.',
|
|
||||||
);
|
|
||||||
return '[REDACTED]';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Heuristically detects whether a string value looks like a secret.
|
|
||||||
*
|
|
||||||
* Defense-in-depth: complements the name-based filter in
|
|
||||||
* mask_sensitive_field() by catching generic fields (e.g. n8n's
|
|
||||||
* queryParameters `{name:"key"}`) and custom headers whose names we
|
|
||||||
* cannot enumerate.
|
|
||||||
*
|
|
||||||
* Matches:
|
|
||||||
* - Known vendor tokens: `AIzaSy…`, `sk-proj-…`, `ghp_…`, `Bearer eyJ…`
|
|
||||||
* - Opaque high-entropy strings: ≥30 chars, mixed classes,
|
|
||||||
* no whitespace, no path separators.
|
|
||||||
*
|
|
||||||
* Skips (false-positive control):
|
|
||||||
* - URLs / filesystem paths (contain `/`)
|
|
||||||
* - Strings with whitespace (not vendor-matched)
|
|
||||||
* - Simple slugs like `weather_berlin`, `my-plugin`
|
|
||||||
*
|
|
||||||
* @since 1.0.4
|
|
||||||
* @param string $value Candidate value.
|
|
||||||
* @return bool True if the value has secret-like characteristics.
|
|
||||||
*/
|
|
||||||
public static function looks_like_secret( string $value ): bool {
|
|
||||||
$len = strlen( $value );
|
|
||||||
if ( $len < 20 ) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ( self::HIGH_ENTROPY_PATTERNS as $pattern ) {
|
|
||||||
if ( 1 === preg_match( $pattern, $value ) ) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( $len < 30 ) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ( 1 === preg_match( '/[\s\/]/', $value ) ) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$classes = (int) (bool) preg_match( '/[a-z]/', $value );
|
|
||||||
$classes += (int) (bool) preg_match( '/[A-Z]/', $value );
|
|
||||||
$classes += (int) (bool) preg_match( '/[0-9]/', $value );
|
|
||||||
|
|
||||||
return $classes >= 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies condition rightValue heuristic masking.
|
* Applies condition rightValue heuristic masking.
|
||||||
* Used specifically for condition node parameter values.
|
* Used specifically for condition node parameter values.
|
||||||
|
|
|
||||||
|
|
@ -39,100 +39,16 @@ class WorkflowSanitizer {
|
||||||
if ( isset( $sanitized['nodes'] ) && is_array( $sanitized['nodes'] ) ) {
|
if ( isset( $sanitized['nodes'] ) && is_array( $sanitized['nodes'] ) ) {
|
||||||
foreach ( $sanitized['nodes'] as &$node ) {
|
foreach ( $sanitized['nodes'] as &$node ) {
|
||||||
$node = $this->mask_node( $node );
|
$node = $this->mask_node( $node );
|
||||||
$node = $this->mask_node_credentials( $node );
|
|
||||||
}
|
}
|
||||||
unset( $node );
|
unset( $node );
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 3: Strip identifying metadata that ties a workflow to a
|
|
||||||
// specific n8n instance. The value is not a secret per se, but it
|
|
||||||
// correlates workflows across leaks.
|
|
||||||
if ( isset( $sanitized['meta'] ) && is_array( $sanitized['meta'] )
|
|
||||||
&& array_key_exists( 'instanceId', $sanitized['meta'] ) ) {
|
|
||||||
$this->mask_log[] = array(
|
|
||||||
'reason' => 'meta_instance_id',
|
|
||||||
'key' => 'meta.instanceId',
|
|
||||||
'note' => 'n8n instance identifier cleared.',
|
|
||||||
);
|
|
||||||
$sanitized['meta']['instanceId'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array(
|
return array(
|
||||||
'data' => $sanitized,
|
'data' => $sanitized,
|
||||||
'mask_log' => $this->mask_log,
|
'mask_log' => $this->mask_log,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes workflow tags (opt-in from wizard step 3).
|
|
||||||
*
|
|
||||||
* Tags in n8n are organisational labels (e.g. "production", "Donau") that
|
|
||||||
* can be either innocuous or identifying. Unlike credentials, there is no
|
|
||||||
* reliable heuristic to separate the two, so this is user-controlled and
|
|
||||||
* runs only when the publisher explicitly opts in.
|
|
||||||
*
|
|
||||||
* Static and stateless so callers (WizardPage::handle_publish) can invoke
|
|
||||||
* it without constructing a new Sanitizer instance.
|
|
||||||
*
|
|
||||||
* @since 1.0.4
|
|
||||||
* @param array $data Workflow data.
|
|
||||||
* @param array $mask_log Passed by reference — removal entries appended.
|
|
||||||
* @return array Data with tags cleared.
|
|
||||||
*/
|
|
||||||
public static function strip_workflow_tags( array $data, array &$mask_log ): array {
|
|
||||||
if ( empty( $data['tags'] ) || ! is_array( $data['tags'] ) ) {
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ( $data['tags'] as $tag ) {
|
|
||||||
$name = is_array( $tag ) && isset( $tag['name'] ) ? (string) $tag['name'] : '';
|
|
||||||
$mask_log[] = array(
|
|
||||||
'reason' => 'tags_cleared',
|
|
||||||
'key' => 'tags[].name',
|
|
||||||
'note' => '' !== $name
|
|
||||||
? 'Tag "' . esc_html( $name ) . '" removed.'
|
|
||||||
: 'Tag removed.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data['tags'] = array();
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redacts credential display names on a node.
|
|
||||||
*
|
|
||||||
* n8n stores node credentials as an associative map keyed by credential
|
|
||||||
* type, each with `{ id, name }`. The `id` refers to the n8n DB and is
|
|
||||||
* useless without that server — we keep it so round-tripping stays intact.
|
|
||||||
* The `name` is user-chosen (e.g. "Donau", "Privater OpenAI") and leaks
|
|
||||||
* organisational context. We replace it with `[REDACTED]`.
|
|
||||||
*
|
|
||||||
* @since 1.0.4
|
|
||||||
* @param array $node Single workflow node array.
|
|
||||||
* @return array Node with credential names redacted.
|
|
||||||
*/
|
|
||||||
private function mask_node_credentials( array $node ): array {
|
|
||||||
if ( ! isset( $node['credentials'] ) || ! is_array( $node['credentials'] ) ) {
|
|
||||||
return $node;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ( $node['credentials'] as $cred_type => &$cred ) {
|
|
||||||
if ( is_array( $cred ) && isset( $cred['name'] ) && is_string( $cred['name'] )
|
|
||||||
&& '' !== $cred['name'] && '[REDACTED]' !== $cred['name'] ) {
|
|
||||||
$this->mask_log[] = array(
|
|
||||||
'reason' => 'credential_name',
|
|
||||||
'key' => (string) $cred_type . '.name',
|
|
||||||
'note' => 'Credential display name redacted (id retained).',
|
|
||||||
);
|
|
||||||
$cred['name'] = '[REDACTED]';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset( $cred );
|
|
||||||
|
|
||||||
return $node;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively sanitizes all string values in the data.
|
* Recursively sanitizes all string values in the data.
|
||||||
* jsCode is preserved as-is (displayed with esc_html(), never executed).
|
* jsCode is preserved as-is (displayed with esc_html(), never executed).
|
||||||
|
|
@ -240,16 +156,5 @@ class WorkflowSanitizer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entropy fallback: catches custom-header names we cannot enumerate
|
|
||||||
// (e.g. X-App-Token) when the value itself looks secret-shaped.
|
|
||||||
if ( MaskingRules::looks_like_secret( (string) $item['value'] ) ) {
|
|
||||||
$this->mask_log[] = array(
|
|
||||||
'reason' => 'value_entropy',
|
|
||||||
'key' => 'value',
|
|
||||||
'note' => 'Parameter "' . esc_html( $item['name'] ) . '" has secret-shaped value.',
|
|
||||||
);
|
|
||||||
$item['value'] = '[REDACTED]';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,31 +39,6 @@ class Shortcode {
|
||||||
*/
|
*/
|
||||||
private static bool $assets_enqueued = false;
|
private static bool $assets_enqueued = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* Monotonic counter for unique wrapper DOM IDs within a request.
|
|
||||||
*
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
private static int $instance_counter = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fingerprints of already-rendered shortcode invocations (post_id + resolved settings).
|
|
||||||
* Used to silently skip re-entrant passes triggered by plugins like Easy Table of Contents,
|
|
||||||
* which run the_content filters a second time to scan for headings.
|
|
||||||
*
|
|
||||||
* @var array<string, true>
|
|
||||||
*/
|
|
||||||
private static array $fingerprints = array();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post IDs that have already emitted the share-anchor span in this request.
|
|
||||||
* The anchor id="breznflow-<POSTID>" must be unique in the DOM, so only the
|
|
||||||
* first instance per post emits it; later instances get a wrapper only.
|
|
||||||
*
|
|
||||||
* @var array<int, true>
|
|
||||||
*/
|
|
||||||
private static array $anchored_posts = array();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers the shortcode and footer hook.
|
* Registers the shortcode and footer hook.
|
||||||
*
|
*
|
||||||
|
|
@ -179,27 +154,7 @@ class Shortcode {
|
||||||
|
|
||||||
$max_code_lines = '' !== $atts['max_code_lines'] ? max( 1, (int) $atts['max_code_lines'] ) : (int) $settings['max_code_lines'];
|
$max_code_lines = '' !== $atts['max_code_lines'] ? max( 1, (int) $atts['max_code_lines'] ) : (int) $settings['max_code_lines'];
|
||||||
|
|
||||||
// Re-entry guard: plugins like Easy Table of Contents run the_content filters
|
// Increment view count.
|
||||||
// twice to scan for headings, which re-executes this shortcode. The returned
|
|
||||||
// HTML is often deduplicated by the outer filter, but the static $render_queue
|
|
||||||
// below would still accumulate a duplicate entry and the JS renderer would
|
|
||||||
// mount the workflow twice onto the same container. Hashing post_id + fully
|
|
||||||
// resolved render settings lets us silently skip those re-entrant passes
|
|
||||||
// while still allowing legitimate multi-embed (same post, different atts).
|
|
||||||
$fingerprint = md5(
|
|
||||||
(string) $post_id . '|' .
|
|
||||||
$mode . '|' . (string) $zoom . '|' . (string) $show_title . '|' .
|
|
||||||
(string) $show_infobox . '|' . (string) $allow_download . '|' .
|
|
||||||
(string) $show_minimap . '|' . (string) $max_code_lines . '|' .
|
|
||||||
$theme . '|' . (string) $allow_share . '|' . (string) $allow_embed . '|' .
|
|
||||||
(string) $allow_get_json
|
|
||||||
);
|
|
||||||
if ( isset( self::$fingerprints[ $fingerprint ] ) ) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
self::$fingerprints[ $fingerprint ] = true;
|
|
||||||
|
|
||||||
// Increment view count (after dedupe check, so re-entrant scans don't overcount).
|
|
||||||
ViewCounter::increment( $post_id );
|
ViewCounter::increment( $post_id );
|
||||||
|
|
||||||
// Enqueue assets once.
|
// Enqueue assets once.
|
||||||
|
|
@ -208,15 +163,9 @@ class Shortcode {
|
||||||
self::$assets_enqueued = true;
|
self::$assets_enqueued = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unique per-instance DOM id so multiple embeds of the same workflow coexist.
|
|
||||||
++self::$instance_counter;
|
|
||||||
$instance_id = $post_id . '-' . self::$instance_counter;
|
|
||||||
$dom_id = 'breznflow-wrap-' . $instance_id;
|
|
||||||
|
|
||||||
// Queue workflow data for JS output.
|
// Queue workflow data for JS output.
|
||||||
self::$render_queue[] = array(
|
self::$render_queue[] = array(
|
||||||
'id' => $post_id,
|
'id' => $post_id,
|
||||||
'dom_id' => $dom_id,
|
|
||||||
'workflow' => $workflow,
|
'workflow' => $workflow,
|
||||||
'mode' => $mode,
|
'mode' => $mode,
|
||||||
'zoom' => $zoom ? $zoom : 100,
|
'zoom' => $zoom ? $zoom : 100,
|
||||||
|
|
@ -260,18 +209,10 @@ class Shortcode {
|
||||||
);
|
);
|
||||||
$html .= InfoBoxBuilder::build( $categorized );
|
$html .= InfoBoxBuilder::build( $categorized );
|
||||||
} else {
|
} else {
|
||||||
// Anchor span is only emitted for the first instance per post so the
|
|
||||||
// id="breznflow-<POSTID>" target stays unique; legacy deep-links keep working.
|
|
||||||
$anchor_html = '';
|
|
||||||
if ( ! isset( self::$anchored_posts[ $post_id ] ) ) {
|
|
||||||
$anchor_html = '<span id="breznflow-' . esc_attr( (string) $post_id ) . '" '
|
|
||||||
. 'aria-hidden="true" style="position:absolute;top:-60px;left:0"></span>';
|
|
||||||
self::$anchored_posts[ $post_id ] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '<div style="position:relative">'
|
$html .= '<div style="position:relative">'
|
||||||
. $anchor_html
|
. '<span id="breznflow-' . esc_attr( (string) $post_id ) . '" '
|
||||||
. '<div id="' . esc_attr( $dom_id ) . '" '
|
. 'aria-hidden="true" style="position:absolute;top:-60px;left:0"></span>'
|
||||||
|
. '<div id="breznflow-wrap-' . esc_attr( (string) $post_id ) . '" '
|
||||||
. 'class="breznflow-embed" data-id="' . esc_attr( (string) $post_id ) . '">'
|
. 'class="breznflow-embed" data-id="' . esc_attr( (string) $post_id ) . '">'
|
||||||
. '</div>'
|
. '</div>'
|
||||||
. '</div>';
|
. '</div>';
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ Contributors: mifupadev
|
||||||
Tags: n8n, workflow, automation, diagram, svg
|
Tags: n8n, workflow, automation, diagram, svg
|
||||||
Requires at least: 6.0
|
Requires at least: 6.0
|
||||||
Tested up to: 6.9
|
Tested up to: 6.9
|
||||||
Stable tag: 1.0.4
|
Stable tag: 1.0.2
|
||||||
Requires PHP: 8.0
|
Requires PHP: 8.0
|
||||||
License: GPL-2.0-or-later
|
License: GPL-2.0-or-later
|
||||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
|
@ -16,12 +16,6 @@ BreznFlow turns n8n workflow JSON exports into interactive, zoomable SVG diagram
|
||||||
|
|
||||||
The plugin was built for mifupa.com, a personal blog where n8n automations are documented regularly. Screenshots get outdated. Embedding the n8n editor is impractical. BreznFlow solves this: one shortcode, one interactive diagram, zero external dependencies.
|
The plugin was built for mifupa.com, a personal blog where n8n automations are documented regularly. Screenshots get outdated. Embedding the n8n editor is impractical. BreznFlow solves this: one shortcode, one interactive diagram, zero external dependencies.
|
||||||
|
|
||||||
= Learn more =
|
|
||||||
|
|
||||||
* Website: <a href="https://breznflow.com/">breznflow.com</a>
|
|
||||||
* FAQ: <a href="https://breznflow.com/faq.html">breznflow.com/faq</a>
|
|
||||||
* Live demo: <a href="https://breznflow.com/demo.html">breznflow.com/demo</a>
|
|
||||||
|
|
||||||
= At a glance =
|
= At a glance =
|
||||||
|
|
||||||
* Renders n8n workflow JSON as interactive SVG diagrams with zoom, pan, and click
|
* Renders n8n workflow JSON as interactive SVG diagrams with zoom, pan, and click
|
||||||
|
|
@ -164,22 +158,6 @@ For security, requests to private and internal network addresses are blocked: lo
|
||||||
|
|
||||||
== Changelog ==
|
== Changelog ==
|
||||||
|
|
||||||
= 1.0.4 =
|
|
||||||
* Security: Detect generic `key` fields (n8n Google API pattern `queryParameters.parameters[].name = "key"`) and redact them when the value looks secret-shaped. Closes a gap where API keys bypassed the name-based filter.
|
|
||||||
* Security: Defense-in-depth entropy heuristic (`looks_like_secret()`) with vendor regex for AIza / sk- / ghp_ / Bearer plus a length+char-class fallback — catches custom tokens the name allowlist can't enumerate.
|
|
||||||
* Security: Redact credential display names and `meta.instanceId` so workflow exports can no longer be correlated to the originating n8n instance or team.
|
|
||||||
* Security: Optional tag removal at publish time (opt-in checkbox in wizard step 3). Workflow tags are often harmless but occasionally identifying — publisher decides per workflow.
|
|
||||||
* Security: Wizard step 3 now shows a collapsible Reason / Key / Note table listing exactly what was masked, so publishers can verify before clicking Publish.
|
|
||||||
* Mobile: Rewrote the SVG touch handling. Single-finger pan is now smooth; pinch-to-zoom and double-tap-to-zoom work on iOS and Android. `touch-action: none` on the diagram SVG ends the browser-vs-plugin gesture tug-of-war that caused the "finger loses tracking" stutter.
|
|
||||||
* Mobile: Minimap now responds to touch — tap or drag to navigate.
|
|
||||||
* Note: Starting a touch on the diagram SVG blocks page scroll until the finger lifts. This is intentional so gestures are unambiguous; scroll around the diagram still works.
|
|
||||||
|
|
||||||
= 1.0.3 =
|
|
||||||
* Fixed double rendering when "Easy Table of Contents" (or any plugin that re-runs `the_content` filters) is active. The shortcode now silently deduplicates re-entrant invocations via a fingerprint of post id + resolved render settings.
|
|
||||||
* Wrapper `id` is now unique per instance (`breznflow-wrap-<POSTID>-<COUNTER>`), enabling multiple embeds of the same workflow with different attributes in one post.
|
|
||||||
* Anchor span `id="breznflow-<POSTID>"` is emitted only for the first instance per post to keep the DOM valid and preserve existing share links.
|
|
||||||
* Renderer now guards against mounting twice onto the same container.
|
|
||||||
|
|
||||||
= 1.0.2 =
|
= 1.0.2 =
|
||||||
* Fixed WordPress.org plugin review issues.
|
* Fixed WordPress.org plugin review issues.
|
||||||
* Embed page now uses wp_enqueue_style/wp_enqueue_script with wp_head/wp_footer instead of direct HTML tags.
|
* Embed page now uses wp_enqueue_style/wp_enqueue_script with wp_head/wp_footer instead of direct HTML tags.
|
||||||
|
|
@ -218,12 +196,6 @@ For security, requests to private and internal network addresses are blocked: lo
|
||||||
|
|
||||||
== Upgrade Notice ==
|
== Upgrade Notice ==
|
||||||
|
|
||||||
= 1.0.4 =
|
|
||||||
Closes a secret-masking gap for n8n's generic `key` query-parameter pattern (Google API keys), redacts credential names and instance IDs, and rewrites mobile touch handling so pan / pinch / double-tap work on iOS and Android.
|
|
||||||
|
|
||||||
= 1.0.3 =
|
|
||||||
Fixes duplicate workflow rendering when "Easy Table of Contents" is active, and enables reliable multi-embed of the same workflow in one post.
|
|
||||||
|
|
||||||
= 1.0.2 =
|
= 1.0.2 =
|
||||||
Fixes WordPress.org plugin review issues: proper asset enqueueing, nonce verification, input sanitization, and output escaping.
|
Fixes WordPress.org plugin review issues: proper asset enqueueing, nonce verification, input sanitization, and output escaping.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue