Files

122 lines
5.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// 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) } };
}