feat(n8n): W5 footer purchase-check webhook (source draft) — GET dk-purchase-check, HMAC-signed username lookup, returns customer purchases+expiry

This commit is contained in:
2026-08-28 18:04:13 +08:00
parent 0c2f60e922
commit 7dd14f9f25
2 changed files with 161 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
// 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.
// Response (JSON, CORS: https://drive.digikedai.com):
// { username, known, purchases: [{product, product_id, expires_at, status, display_state}] }
// Run: node n8n/gen_w5.js
const fs = require('fs');
const path = require('path');
const body = fs.readFileSync(path.join(__dirname, 'w5-body.js'), 'utf8');
const workflow = {
name: 'W5 - Footer Purchase Check (Public)',
nodes: [
{
parameters: {
httpMethod: 'GET',
path: 'dk-purchase-check',
responseMode: 'responseNode',
options: {}
},
id: 'trigger-webhook',
name: 'Footer Purchase Check (Webhook)',
type: 'n8n-nodes-base.webhook',
typeVersion: 2,
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',
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]
}
],
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: [] }
},
settings: { executionOrder: 'v1' },
pinData: {}
};
fs.writeFileSync(path.join(__dirname, 'W5-purchase-check.json'), JSON.stringify(workflow, null, 2) + '\n');
console.log('wrote W5-purchase-check.json (' + body.length + ' chars body)');
+80
View File
@@ -0,0 +1,80 @@
// ============================================================================
// 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.
// ============================================================================
// n8n Code node context: ctx = { $env, helpers }
const ctx = { $env, helpers: this.helpers };
// --- input from the "Extract Query" node (already validated the shared key) ---
const username = String($json.username || '').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;
if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;
return v;
}
async function ncGet(path) {
return await ctx.helpers.httpRequest({
method: 'GET',
url: ctx.$env.NOCODB_URL + path,
headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },
json: true,
});
}
const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;
const T_CP = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;
// 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 (!customer) {
return { json: { username, purchases: [], known: false } };
}
// 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,
},
};