Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ N8N_PROTOCOL=http
WEBHOOK_URL=http://localhost:5678/
GENERIC_TIMEZONE=UTC

# Shared secret for protected webhooks (workflows 01, 02, 03, 04a, 05, 07).
# Clients must send 'X-Webhook-Secret: <this value>' on every call.
# Public routes (Etsy OAuth callback, mockup-view image proxy) are exempt.
# 32-byte hex
N8N_WEBHOOK_SECRET=replace_with_openssl_rand_hex_32

# ---- ToolJet ----------------------------------------------------------------
# 64-byte hex
TOOLJET_SECRET_KEY_BASE=replace_with_openssl_rand_hex_64
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,28 @@ ngrok http 5678 # n8n webhooks (use this URL as ETSY_REDIRECT_URI)

## Security best practices

### Webhook auth

Workflows 01, 02, 03, 04a, 05, 07 require `X-Webhook-Secret: <N8N_WEBHOOK_SECRET>` on every call. Without it n8n returns **401**. Public exemptions: workflow 04 (Etsy OAuth callback — Etsy can't send a custom header) and workflow 02b (mockup-view image proxy — used by `<img src="…">`).

One-time setup after first boot:
1. Set `N8N_WEBHOOK_SECRET` in `.env` (`openssl rand -hex 32`); `docker compose restart n8n`.
2. In the n8n UI: **Credentials → New → Header Auth**.
- Name: `n8n Webhook Secret` (must match the JSON in workflow imports).
- Header name: `X-Webhook-Secret`.
- Header value: paste the same secret.
3. In the ToolJet UI: **Workspace constants → N8N_WEBHOOK_SECRET → set the same value.**

Direct curl now needs the header:
```bash
curl -X POST http://localhost:5678/webhook/listing-generate \
-H "x-webhook-secret: $N8N_WEBHOOK_SECRET" \
-H 'content-type: application/json' \
-d '{"shop_id":1,"niche":"vintage botanical printables","kind":"printable","cost":0}'
```

### Other practices

- Never commit `.env`. `.gitignore` excludes it.
- `N8N_ENCRYPTION_KEY` encrypts every credential stored in n8n. **Rotating it
invalidates all stored credentials** — only rotate on a fresh install.
Expand All @@ -408,6 +430,9 @@ ngrok http 5678 # n8n webhooks (use this URL as ETSY_REDIRECT_URI)
superuser for simplicity.
- Rotate Etsy / Printify / Printful tokens periodically and revoke them in the
provider UIs when removing a shop.
- `shops.oauth_token` / `refresh_token` are stored in plaintext today.
Encrypting at rest with `pgcrypto` is a recommended follow-on; for now,
restrict Postgres access to the n8n container's network.

---

Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ services:
PINTEREST_WEBHOOK_URL: ${PINTEREST_WEBHOOK_URL}
X_WEBHOOK_URL: ${X_WEBHOOK_URL}
INSTAGRAM_WEBHOOK_URL: ${INSTAGRAM_WEBHOOK_URL}
N8N_WEBHOOK_SECRET: ${N8N_WEBHOOK_SECRET}
volumes:
- n8n_data:/home/node/.n8n
- ./workflows:/workflows:ro
Expand Down
31 changes: 27 additions & 4 deletions tooljet-apps/etsy_dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
"scope": "global",
"_note": "Override per-environment. Use the Docker service name when ToolJet calls n8n inside the compose network."
},
{
"name": "N8N_WEBHOOK_SECRET",
"value": "",
"scope": "global",
"_note": "Operator MUST set this in the ToolJet UI to match the N8N_WEBHOOK_SECRET env var on the n8n container. Sent as X-Webhook-Secret on every n8n_api call."
},
{
"name": "DEFAULT_SHOP_ID",
"value": "1",
Expand All @@ -37,7 +43,10 @@
"kind": "restapi",
"options": {
"base_url": "{{constants.N8N_URL}}",
"headers": [{ "key": "Content-Type", "value": "application/json" }]
"headers": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "X-Webhook-Secret", "value": "{{constants.N8N_WEBHOOK_SECRET}}" }
]
}
}
],
Expand Down Expand Up @@ -199,6 +208,19 @@
},
"run_on_page_load": false
},
{
"name": "start_etsy_connect",
"data_source": "n8n_api",
"kind": "restapi",
"options": {
"method": "get",
"url_path": "/webhook/etsy-connect",
"url_params": [
{ "key": "shop_name", "value": "{{components.newShopName.value || 'My Shop'}}" }
]
},
"run_on_page_load": false
},
{
"name": "save_shop_pod_config",
"data_source": "etsy_app_db",
Expand Down Expand Up @@ -325,10 +347,11 @@
{ "id": "newShopName", "type": "TextInput", "props": { "label": "Shop label (free text)", "placeholder": "My Test Shop" } },
{
"id": "connectShopBtn", "type": "Button",
"props": { "text": "Authorise on Etsy" },
"props": { "text": "Authorise on Etsy", "loading_state": "{{queries.start_etsy_connect.isLoading}}" },
"events": [
{ "event": "onClick", "action": "open-url",
"url": "{{constants.N8N_URL}}/webhook/etsy-connect?shop_name={{encodeURIComponent(components.newShopName.value || 'My Shop')}}",
{ "event": "onClick", "action": "run-query", "query": "start_etsy_connect" },
{ "event": "onSuccess", "action": "open-url",
"url": "{{queries.start_etsy_connect.data?.auth_url}}",
"open_in_new_tab": true }
]
},
Expand Down
9 changes: 8 additions & 1 deletion workflows/01_ai_listing_generator.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@
"httpMethod": "POST",
"path": "listing-generate",
"responseMode": "responseNode",
"authentication": "headerAuth",
"options": {}
},
"id": "wf01-webhook",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300],
"webhookId": "listing-generate"
"webhookId": "listing-generate",
"credentials": {
"httpHeaderAuth": {
"id": "n8n-webhook-secret",
"name": "n8n Webhook Secret"
}
}
},
{
"parameters": {
Expand Down
9 changes: 8 additions & 1 deletion workflows/02_ai_visual_studio.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@
"httpMethod": "POST",
"path": "visual-generate",
"responseMode": "responseNode",
"authentication": "headerAuth",
"options": {}
},
"id": "wf02-webhook",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300],
"webhookId": "visual-generate"
"webhookId": "visual-generate",
"credentials": {
"httpHeaderAuth": {
"id": "n8n-webhook-secret",
"name": "n8n Webhook Secret"
}
}
},
{
"parameters": {
Expand Down
11 changes: 9 additions & 2 deletions workflows/03_ai_ads_studio.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@
"httpMethod": "POST",
"path": "ads-generate",
"responseMode": "responseNode",
"authentication": "headerAuth",
"options": {}
},
"id": "wf03-webhook",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300],
"webhookId": "ads-generate"
"webhookId": "ads-generate",
"credentials": {
"httpHeaderAuth": {
"id": "n8n-webhook-secret",
"name": "n8n Webhook Secret"
}
}
},
{
"parameters": {
Expand Down Expand Up @@ -64,7 +71,7 @@
{
"parameters": {
"language": "javaScript",
"jsCode": "// Parse + validate + optionally call workflow 02 for a shared visual.\nconst ollama = $input.first().json;\nconst input = $('Build Prompt').first().json;\n\nlet parsed;\ntry {\n parsed = typeof ollama.response === 'string' ? JSON.parse(ollama.response) : ollama.response;\n} catch (e) {\n throw new Error(`LLM returned non-JSON: ${e.message}`);\n}\n\nconst channelToCopy = (channel) => {\n const c = parsed[channel];\n if (!c) throw new Error(`Missing copy for channel ${channel}`);\n if (channel === 'pinterest') {\n if (!c.title || c.title.length > 100) throw new Error('Pinterest title invalid');\n if (!c.description || c.description.length > 500) throw new Error('Pinterest description invalid');\n const hashtags = Array.isArray(c.hashtags) ? c.hashtags : [];\n return `${c.title}\\n\\n${c.description}\\n\\n${hashtags.map(h => h.startsWith('#') ? h : '#' + h).join(' ')}`;\n }\n if (channel === 'x') {\n if (!c.post || c.post.length > 280) throw new Error('X post invalid');\n return c.post;\n }\n if (channel === 'instagram') {\n if (!c.caption || c.caption.length > 2200) throw new Error('Instagram caption invalid');\n const hashtags = Array.isArray(c.hashtags) ? c.hashtags : [];\n return `${c.caption}\\n\\n${hashtags.map(h => h.startsWith('#') ? h : '#' + h).join(' ')}`;\n }\n return JSON.stringify(c);\n};\n\nlet imagePath = null;\nif (input.generate_visual) {\n try {\n const visual = await this.helpers.httpRequest({\n method: 'POST',\n url: 'http://localhost:5678/webhook/visual-generate',\n body: { listing_id: input.listing_id, width: 1080, height: 1080 },\n json: true,\n timeout: 360000\n });\n imagePath = visual?.mockup?.file_path || null;\n } catch (e) {\n // Soft-fail: ad copy still goes out, just without a generated visual.\n console.warn(`visual-generate failed: ${e.message}`);\n }\n}\n\nconst scheduledFor = input.schedule_at\n ? new Date(input.schedule_at).toISOString()\n : new Date(Date.now() + 60 * 60 * 1000).toISOString();\n\n// Emit one item per channel so the Postgres node inserts each row independently.\nreturn input.channels.map(channel => ({\n json: {\n listing_id: input.listing_id,\n channel,\n copy: channelToCopy(channel),\n image_path: imagePath,\n scheduled_for: scheduledFor,\n raw: parsed[channel] || null\n }\n}));"
"jsCode": "// Parse + validate + optionally call workflow 02 for a shared visual.\nconst ollama = $input.first().json;\nconst input = $('Build Prompt').first().json;\n\nlet parsed;\ntry {\n parsed = typeof ollama.response === 'string' ? JSON.parse(ollama.response) : ollama.response;\n} catch (e) {\n throw new Error(`LLM returned non-JSON: ${e.message}`);\n}\n\nconst channelToCopy = (channel) => {\n const c = parsed[channel];\n if (!c) throw new Error(`Missing copy for channel ${channel}`);\n if (channel === 'pinterest') {\n if (!c.title || c.title.length > 100) throw new Error('Pinterest title invalid');\n if (!c.description || c.description.length > 500) throw new Error('Pinterest description invalid');\n const hashtags = Array.isArray(c.hashtags) ? c.hashtags : [];\n return `${c.title}\\n\\n${c.description}\\n\\n${hashtags.map(h => h.startsWith('#') ? h : '#' + h).join(' ')}`;\n }\n if (channel === 'x') {\n if (!c.post || c.post.length > 280) throw new Error('X post invalid');\n return c.post;\n }\n if (channel === 'instagram') {\n if (!c.caption || c.caption.length > 2200) throw new Error('Instagram caption invalid');\n const hashtags = Array.isArray(c.hashtags) ? c.hashtags : [];\n return `${c.caption}\\n\\n${hashtags.map(h => h.startsWith('#') ? h : '#' + h).join(' ')}`;\n }\n return JSON.stringify(c);\n};\n\nlet imagePath = null;\nif (input.generate_visual) {\n try {\n const visual = await this.helpers.httpRequest({\n method: 'POST',\n url: 'http://localhost:5678/webhook/visual-generate',\n headers: { 'X-Webhook-Secret': process.env.N8N_WEBHOOK_SECRET },\n body: { listing_id: input.listing_id, width: 1080, height: 1080 },\n json: true,\n timeout: 360000\n });\n imagePath = visual?.mockup?.file_path || null;\n } catch (e) {\n // Soft-fail: ad copy still goes out, just without a generated visual.\n console.warn(`visual-generate failed: ${e.message}`);\n }\n}\n\nconst scheduledFor = input.schedule_at\n ? new Date(input.schedule_at).toISOString()\n : new Date(Date.now() + 60 * 60 * 1000).toISOString();\n\n// Emit one item per channel so the Postgres node inserts each row independently.\nreturn input.channels.map(channel => ({\n json: {\n listing_id: input.listing_id,\n channel,\n copy: channelToCopy(channel),\n image_path: imagePath,\n scheduled_for: scheduledFor,\n raw: parsed[channel] || null\n }\n}));"
},
"id": "wf03-validate",
"name": "Validate + Visual",
Expand Down
18 changes: 10 additions & 8 deletions workflows/04a_etsy_connect.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@
"httpMethod": "GET",
"path": "etsy-connect",
"responseMode": "responseNode",
"authentication": "headerAuth",
"options": {}
},
"id": "wf04a-webhook",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300],
"webhookId": "etsy-connect"
"webhookId": "etsy-connect",
"credentials": {
"httpHeaderAuth": {
"id": "n8n-webhook-secret",
"name": "n8n Webhook Secret"
}
}
},
{
"parameters": {
Expand Down Expand Up @@ -48,13 +55,8 @@
},
{
"parameters": {
"respondWith": "text",
"responseBody": "=<!doctype html><meta charset=\"utf-8\"><title>Connect Etsy shop</title><style>body{font-family:system-ui;max-width:540px;margin:80px auto;padding:0 16px}a.btn{display:inline-block;padding:12px 24px;background:#f56400;color:#fff;text-decoration:none;border-radius:6px;font-weight:600}small{color:#666}</style><h1>Connect <em>{{ $('Generate PKCE').item.json.shop_name }}</em></h1><p>Click below to authorise n8n on Etsy. You'll be redirected back here when it's done.</p><p><a class=\"btn\" href=\"{{ $('Generate PKCE').item.json.auth_url }}\">Authorise on Etsy →</a></p><p><small>State token: <code>{{ $('Generate PKCE').item.json.state }}</code> (valid 10 min)</small></p>",
"options": {
"responseHeaders": {
"entries": [{ "name": "Content-Type", "value": "text/html; charset=utf-8" }]
}
}
"respondWith": "json",
"responseBody": "={{ { ok: true, shop_name: $('Generate PKCE').item.json.shop_name, state: $('Generate PKCE').item.json.state, auth_url: $('Generate PKCE').item.json.auth_url } }}"
},
"id": "wf04a-respond",
"name": "Respond",
Expand Down
9 changes: 8 additions & 1 deletion workflows/05_etsy_listing_publish.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@
"httpMethod": "POST",
"path": "etsy-listing-publish",
"responseMode": "responseNode",
"authentication": "headerAuth",
"options": {}
},
"id": "wf05-webhook",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300],
"webhookId": "etsy-listing-publish"
"webhookId": "etsy-listing-publish",
"credentials": {
"httpHeaderAuth": {
"id": "n8n-webhook-secret",
"name": "n8n Webhook Secret"
}
}
},
{
"parameters": {
Expand Down
6 changes: 6 additions & 0 deletions workflows/06_etsy_order_sync.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@
"parameters": {
"method": "POST",
"url": "http://localhost:5678/webhook/pod-route",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "X-Webhook-Secret", "value": "={{ $env.N8N_WEBHOOK_SECRET }}" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={ \"order_id\": {{ $json.id }} }",
Expand Down
9 changes: 8 additions & 1 deletion workflows/07_pod_route_order.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@
"httpMethod": "POST",
"path": "pod-route",
"responseMode": "responseNode",
"authentication": "headerAuth",
"options": {}
},
"id": "wf07-webhook",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300],
"webhookId": "pod-route"
"webhookId": "pod-route",
"credentials": {
"httpHeaderAuth": {
"id": "n8n-webhook-secret",
"name": "n8n Webhook Secret"
}
}
},
{
"parameters": {
Expand Down