feat(n8n): W5 footer purchase-check webhook (公开只读) + docs
- W5 gets username ?u= → Customers→CustomerProducts → {username,known,purchases}
- 无 HMAC(密钥会下发客户端): 改用 CORS 限 drive.digikedai.com + 最小数据 + CF WAF 建议
- 复用现有 NOCODB_* env, 零新增 env
- 2 节点: webhook(GET, lastNode, CORS 头) + Code(w5-body.js)
- footer v2.6 动态行数据源; 待手动导入 n8n
- docs: N8N_WORKFLOWS + PROJECT_STATE 记录 W5 + 导入步骤
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "W5 - Footer Purchase Check (Public)",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "GET",
|
||||
"path": "dk-purchase-check",
|
||||
"responseMode": "lastNode",
|
||||
"options": {
|
||||
"responseCode": 200,
|
||||
"responseHeaders": {
|
||||
"entries": [
|
||||
{
|
||||
"name": "Access-Control-Allow-Origin",
|
||||
"value": "https://drive.digikedai.com"
|
||||
},
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "trigger-webhook",
|
||||
"name": "Footer Purchase Check (Webhook)",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"webhookId": "dk-purchase-check"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// ============================================================================\n// W5 runtime body — footer purchase/expiry lookup (PUBLIC, READ-ONLY).\n// Trigger: GET webhook /dk-purchase-check?u=<username>\n// Returns: { username, known, purchases: [{product, expires_at, display_state}] }\n//\n// Security model (no HMAC — key would ship to the client, so it is theatre):\n// - CORS: response header Access-Control-Allow-Origin: https://drive.digikedai.com\n// → only the AList portal origin may READ the response from a browser.\n// - Minimal data: product name + expiry + display_state only (no internal Ids).\n// - Server-side enumeration is mitigated by a Cloudflare WAF rate-limit +\n// Origin/Referer rule on auto.hoelee.com (see delivery guide).\n// - No AList auth requests of any kind (username comes from the caller's own\n// JWT, decoded client-side in the footer) → no empty-client device drift.\n// ============================================================================\n\n// n8n Code node context (same pattern as W1–W4).\nconst ctx = { $env, helpers: this.helpers };\n\n// GET webhook: n8n puts query-string params under $json.query (fallback: body).\nconst item = $json ?? ($input && $input.item ? $input.item.json : {});\nconst q = item.query ?? item.body ?? {};\nconst username = String(q.u ?? '').trim();\n\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\nasync function ncGet(path) {\n return await ctx.helpers.httpRequest({\n method: 'GET',\n url: ctx.$env.NOCODB_URL + path,\n headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },\n json: true,\n });\n}\n\nconst empty = () => ({ username, known: false, purchases: [] });\n\nif (!username) return { json: empty() };\n\ntry {\n const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;\n const T_CP = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;\n const today = new Date().toISOString().slice(0, 10);\n\n // 1. resolve username -> customer (username is unique in Customers)\n const custRows = (await ncGet(\n `/api/v2/tables/${T_CUST}/records?where=(username,eq,${encodeURIComponent(username)})&limit=1`\n )).list || [];\n const customer = custRows[0];\n if (!customer) return { json: empty() };\n\n // 2. this customer's products (link query by Id, per NocoDB v2 gotchas)\n const cps = (await ncGet(\n `/api/v2/tables/${T_CP}/records?where=(customer.Id,eq,${customer.Id})&limit=500`\n )).list || [];\n\n const purchases = cps\n .map((c) => {\n const productObj = (typeof c.product === 'object' && c.product) ? c.product : null;\n const name = productObj && productObj.name ? String(productObj.name) : 'Product';\n const expires = c.expires_at ? String(c.expires_at).slice(0, 10) : null;\n let state = (c.status && c.status !== 'active') ? c.status : 'active';\n if (state === 'active' && expires && expires <= today) state = 'expired';\n return { product: name, expires_at: expires, display_state: state };\n })\n .sort((a, b) => {\n const rank = { active: 0, suspended: 1, revoked: 2, expired: 3 };\n if (rank[a.display_state] !== rank[b.display_state]) return rank[a.display_state] - rank[b.display_state];\n return String(a.expires_at || '').localeCompare(String(b.expires_at || ''));\n });\n\n return { json: { username, known: true, purchases } };\n} catch (err) {\n // graceful: footer renders static rows only on any error\n return { json: { ...empty(), error: String((err && err.message) || err) } };\n}\n"
|
||||
},
|
||||
"id": "lookup",
|
||||
"name": "Lookup Purchases",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Footer Purchase Check (Webhook)": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Lookup Purchases",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"pinData": {}
|
||||
}
|
||||
+19
-43
@@ -1,9 +1,9 @@
|
||||
// Builds n8n/W5-purchase-check.json — public footer purchase/expiry webhook.
|
||||
// Query: GET /dk-purchase-check?u=<username>&s=<hmac-sha256(username,dkey)>
|
||||
// dkey = DK_HOST_KEY ($env.DK_HOST_KEY) — a per-instance shared key; the
|
||||
// signature prevents username enumeration + forged requests from off-domain.
|
||||
// Trigger: GET /webhook/dk-purchase-check?u=<username>
|
||||
// Response (JSON, CORS: https://drive.digikedai.com):
|
||||
// { username, known, purchases: [{product, product_id, expires_at, status, display_state}] }
|
||||
// { username, known, purchases: [{product, expires_at, display_state}] }
|
||||
// No HMAC (key would ship client-side = theatre). Access control = CORS +
|
||||
// Cloudflare WAF rate-limit/Origin rule (documented in delivery guide).
|
||||
// Run: node n8n/gen_w5.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -17,8 +17,16 @@ const workflow = {
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'dk-purchase-check',
|
||||
responseMode: 'responseNode',
|
||||
options: {}
|
||||
responseMode: 'lastNode',
|
||||
options: {
|
||||
responseCode: 200,
|
||||
responseHeaders: {
|
||||
entries: [
|
||||
{ name: 'Access-Control-Allow-Origin', value: 'https://drive.digikedai.com' },
|
||||
{ name: 'Content-Type', value: 'application/json' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
id: 'trigger-webhook',
|
||||
name: 'Footer Purchase Check (Webhook)',
|
||||
@@ -27,51 +35,19 @@ const workflow = {
|
||||
position: [0, 0],
|
||||
webhookId: 'dk-purchase-check'
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
jsCode: "// Extract + validate query params; verify HMAC signature.\n// u = username, s = HMAC-SHA256(username, $env.DK_HOST_KEY) hex.\nconst crypto = require('crypto');\n\nfunction sigOf(u) {\n return crypto.createHmac('sha256', $env.DK_HOST_KEY).update(u).digest('hex');\n}\n\nconst q = $input.item.json.query ?? {};\nconst username = String(q.u || '').trim();\nconst supplied = String(q.s || '').toLowerCase();\n\n// username must be non-empty; signature must match (timing-safe-ish)\nconst expected = sigOf(username);\nconst ok = username.length > 0 && supplied.length === 64 &&\n crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(expected));\n\nif (!ok) {\n return { json: { status: 403, body: { error: 'forbidden', username: '' } } };\n}\n\nreturn { json: { username, _ok: true } };\n"
|
||||
},
|
||||
id: 'extract-validate',
|
||||
name: 'Extract & Validate Query',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [220, 0]
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
jsCode: "// load env (Code node reads $env reliably only here) and pass through\nconst prev = $input.item.json;\nreturn { json: {\n username: prev.username,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n NOCODB_TABLE_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,\n NOCODB_TABLE_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n} };\n"
|
||||
},
|
||||
id: 'load-env',
|
||||
name: 'Load Env',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [440, 0]
|
||||
},
|
||||
{
|
||||
parameters: { jsCode: body },
|
||||
id: 'compute',
|
||||
id: 'lookup',
|
||||
name: 'Lookup Purchases',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [660, 0]
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
jsCode: "// Wrap in CORS + always-200 JSON envelope. On error return empty purchases.\nconst d = $input.item.json;\nreturn { json: {\n body: {\n username: d.username ?? '',\n known: d.known ?? false,\n purchases: d.purchases ?? [],\n }\n} };\n"
|
||||
},
|
||||
id: 'respond',
|
||||
name: 'Respond JSON',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [880, 0]
|
||||
position: [220, 0]
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Footer Purchase Check (Webhook)': { main: [[{ node: 'Extract & Validate Query', type: 'main', index: 0 }]] },
|
||||
'Extract & Validate Query': { main: [[{ node: 'Load Env', type: 'main', index: 0 }]] },
|
||||
'Load Env': { main: [[{ node: 'Lookup Purchases', type: 'main', index: 0 }]] },
|
||||
'Lookup Purchases': { main: [[{ node: 'Respond JSON', type: 'main', index: 0 }]] },
|
||||
'Respond JSON': { main: [] }
|
||||
'Footer Purchase Check (Webhook)': {
|
||||
main: [[{ node: 'Lookup Purchases', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: { executionOrder: 'v1' },
|
||||
pinData: {}
|
||||
|
||||
+55
-55
@@ -1,17 +1,26 @@
|
||||
// ============================================================================
|
||||
// W5 runtime body — footer purchase/expiry check (public, read-only).
|
||||
// Query: GET /dk-purchase-check?u=<username>&k=<shared-key>
|
||||
// Returns: [{ product, expires_at, status }] for the customer's products,
|
||||
// or { error } when username unknown / key wrong.
|
||||
// W5 runtime body — footer purchase/expiry lookup (PUBLIC, READ-ONLY).
|
||||
// Trigger: GET webhook /dk-purchase-check?u=<username>
|
||||
// Returns: { username, known, purchases: [{product, expires_at, display_state}] }
|
||||
//
|
||||
// Security model (no HMAC — key would ship to the client, so it is theatre):
|
||||
// - CORS: response header Access-Control-Allow-Origin: https://drive.digikedai.com
|
||||
// → only the AList portal origin may READ the response from a browser.
|
||||
// - Minimal data: product name + expiry + display_state only (no internal Ids).
|
||||
// - Server-side enumeration is mitigated by a Cloudflare WAF rate-limit +
|
||||
// Origin/Referer rule on auto.hoelee.com (see delivery guide).
|
||||
// - No AList auth requests of any kind (username comes from the caller's own
|
||||
// JWT, decoded client-side in the footer) → no empty-client device drift.
|
||||
// ============================================================================
|
||||
|
||||
// n8n Code node context: ctx = { $env, helpers }
|
||||
// n8n Code node context (same pattern as W1–W4).
|
||||
const ctx = { $env, helpers: this.helpers };
|
||||
|
||||
// --- input from the "Extract Query" node (already validated the shared key) ---
|
||||
const username = String($json.username || '').trim();
|
||||
// GET webhook: n8n puts query-string params under $json.query (fallback: body).
|
||||
const item = $json ?? ($input && $input.item ? $input.item.json : {});
|
||||
const q = item.query ?? item.body ?? {};
|
||||
const username = String(q.u ?? '').trim();
|
||||
|
||||
// --- helpers (self-contained; no shared lib needed for pure reads) ---
|
||||
function linkId(v) {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;
|
||||
@@ -28,53 +37,44 @@ async function ncGet(path) {
|
||||
});
|
||||
}
|
||||
|
||||
const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;
|
||||
const T_CP = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;
|
||||
const empty = () => ({ username, known: false, purchases: [] });
|
||||
|
||||
// 1. resolve username -> customer id (username is unique in Customers)
|
||||
const custRows = (await ncGet(
|
||||
`/api/v2/tables/${T_CUST}/records?where=(username,eq,${encodeURIComponent(username)})&limit=1`
|
||||
)).list || [];
|
||||
const customer = custRows[0];
|
||||
if (!username) return { json: empty() };
|
||||
|
||||
if (!customer) {
|
||||
return { json: { username, purchases: [], known: false } };
|
||||
try {
|
||||
const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;
|
||||
const T_CP = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// 1. resolve username -> customer (username is unique in Customers)
|
||||
const custRows = (await ncGet(
|
||||
`/api/v2/tables/${T_CUST}/records?where=(username,eq,${encodeURIComponent(username)})&limit=1`
|
||||
)).list || [];
|
||||
const customer = custRows[0];
|
||||
if (!customer) return { json: empty() };
|
||||
|
||||
// 2. this customer's products (link query by Id, per NocoDB v2 gotchas)
|
||||
const cps = (await ncGet(
|
||||
`/api/v2/tables/${T_CP}/records?where=(customer.Id,eq,${customer.Id})&limit=500`
|
||||
)).list || [];
|
||||
|
||||
const purchases = cps
|
||||
.map((c) => {
|
||||
const productObj = (typeof c.product === 'object' && c.product) ? c.product : null;
|
||||
const name = productObj && productObj.name ? String(productObj.name) : 'Product';
|
||||
const expires = c.expires_at ? String(c.expires_at).slice(0, 10) : null;
|
||||
let state = (c.status && c.status !== 'active') ? c.status : 'active';
|
||||
if (state === 'active' && expires && expires <= today) state = 'expired';
|
||||
return { product: name, expires_at: expires, display_state: state };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const rank = { active: 0, suspended: 1, revoked: 2, expired: 3 };
|
||||
if (rank[a.display_state] !== rank[b.display_state]) return rank[a.display_state] - rank[b.display_state];
|
||||
return String(a.expires_at || '').localeCompare(String(b.expires_at || ''));
|
||||
});
|
||||
|
||||
return { json: { username, known: true, purchases } };
|
||||
} catch (err) {
|
||||
// graceful: footer renders static rows only on any error
|
||||
return { json: { ...empty(), error: String((err && err.message) || err) } };
|
||||
}
|
||||
|
||||
// 2. fetch this customer's products (active + not-yet-expired) OR all for display
|
||||
const cps = (await ncGet(
|
||||
`/api/v2/tables/${T_CP}/records?where=(customer.Id,eq,${customer.Id})&limit=1000`
|
||||
)).list || [];
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const purchases = cps
|
||||
.map(c => {
|
||||
const productObj = (typeof c.product === 'object' && c.product) ? c.product : null;
|
||||
const name = productObj?.name ?? 'Product';
|
||||
const expires = c.expires_at ? String(c.expires_at).slice(0, 10) : null;
|
||||
let state = 'active';
|
||||
if (c.status === 'active' && expires && expires <= today) state = 'expired';
|
||||
else if (c.status && c.status !== 'active') state = c.status;
|
||||
|
||||
return {
|
||||
product: name,
|
||||
product_id: linkId(c.product),
|
||||
expires_at: expires,
|
||||
status: c.status || null,
|
||||
display_state: state, // active / expired / revoked / suspended
|
||||
};
|
||||
})
|
||||
// sort: active (expiring soonest first), then expired (most recent expiry first)
|
||||
.sort((a, b) => {
|
||||
const rank = { active: 0, suspended: 1, revoked: 2, expired: 3 };
|
||||
if (rank[a.display_state] !== rank[b.display_state]) return rank[a.display_state] - rank[b.display_state];
|
||||
return String(a.expires_at || '').localeCompare(String(b.expires_at || ''));
|
||||
});
|
||||
|
||||
return {
|
||||
json: {
|
||||
username,
|
||||
known: true,
|
||||
purchases,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user