diff --git a/n8n/gen_w5.js b/n8n/gen_w5.js new file mode 100644 index 0000000..d43f7e3 --- /dev/null +++ b/n8n/gen_w5.js @@ -0,0 +1,81 @@ +// Builds n8n/W5-purchase-check.json — public footer purchase/expiry webhook. +// Query: GET /dk-purchase-check?u=&s= +// 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)'); diff --git a/n8n/w5-body.js b/n8n/w5-body.js new file mode 100644 index 0000000..1f6abec --- /dev/null +++ b/n8n/w5-body.js @@ -0,0 +1,80 @@ +// ============================================================================ +// W5 runtime body — footer purchase/expiry check (public, read-only). +// Query: GET /dk-purchase-check?u=&k= +// 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, + }, +};