122 lines
5.4 KiB
JavaScript
122 lines
5.4 KiB
JavaScript
// ============================================================================
|
||
// 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, paths:[...]}] }
|
||
//
|
||
// v30 (2026-08-28): adds `paths` per purchase — each product's resources' alist_path
|
||
// (via Products→Resources mm link NOCODB_LINK_PROD_RESOURCES), so the footer modal
|
||
// can show a collapsible resource-path list. Products with no linked resources
|
||
// return paths: [].
|
||
//
|
||
// 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 + public folder paths only
|
||
// (no internal NocoDB Ids, no customer PII).
|
||
// - Server-side enumeration is mitigated by a Cloudflare WAF rate-limit +
|
||
// Origin/Referer rule (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 (same pattern as W1–W4).
|
||
const ctx = { $env, helpers: this.helpers };
|
||
|
||
// 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();
|
||
|
||
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;
|
||
}
|
||
|
||
// 内网直连 NocoDB:n8n 与 nocodb 同在 bridge_hoelee,NocoDB 监听 :10380。
|
||
// 原 NOCODB_URL=https://nocodb.hoelee.com 走公网 CF tunnel,慢且抖(曾致 >60s → nginx 504
|
||
// → footer 8s 超时每 1s 重试 → 连接堆积 → 503)。内网 30ms vs 公网 300ms+,且无 tunnel 抖动。
|
||
const NOCODB_URL = 'http://nocodb:10380';
|
||
|
||
async function ncGet(path) {
|
||
return await ctx.helpers.httpRequest({
|
||
method: 'GET',
|
||
url: NOCODB_URL + path,
|
||
headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },
|
||
json: true,
|
||
});
|
||
}
|
||
|
||
const empty = () => ({ username, known: false, purchases: [] });
|
||
|
||
if (!username) return { json: empty() };
|
||
|
||
try {
|
||
const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;
|
||
const T_CP = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;
|
||
const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS;
|
||
const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES;
|
||
const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg
|
||
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 || [];
|
||
|
||
// 3. resolve paths per product (Products→Resources mm link → alist_path).
|
||
// Fetch all Resources once, index Id → alist_path (link API only returns
|
||
// {Id, name}, not alist_path — see effective-grants.js pattern).
|
||
const allRes = (await ncGet(
|
||
`/api/v2/tables/${T_RES}/records?limit=10000&fields=Id,alist_path`
|
||
)).list || [];
|
||
const resPath = {};
|
||
allRes.forEach((r) => { resPath[r.Id] = r.alist_path; });
|
||
|
||
const prodIds = [...new Set(cps.map((c) => linkId(c.product)).filter(Boolean))];
|
||
|
||
const pathsByProduct = {}; // pid -> [alist_path, ...]
|
||
for (const pid of prodIds) {
|
||
let paths = [];
|
||
try {
|
||
const res = (await ncGet(
|
||
`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`
|
||
)).list || [];
|
||
paths = res.map((r) => resPath[r.Id]).filter((p) => p != null && String(p).length > 0);
|
||
} catch (e) {
|
||
paths = []; // graceful: no paths rather than failing the whole lookup
|
||
}
|
||
pathsByProduct[pid] = paths.sort();
|
||
}
|
||
|
||
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 pid = linkId(c.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';
|
||
const paths = pid ? (pathsByProduct[pid] || []) : [];
|
||
return { product: name, expires_at: expires, display_state: state, paths };
|
||
})
|
||
.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) } };
|
||
}
|