[object Object]

← back to Wallco Ai

wallco.ai downloads route: /downloads/:slug/:token.zip token-gated bundle delivery (wallco_download_tokens PG table) + mint CLI + tokens minted + Shopify metafields set on 3 drafts

e1b59d851986b421936a53378d9ccb011c4d4c7d · 2026-05-28 13:21:05 -0700 · Steve Abrams

Files touched

Diff

commit e1b59d851986b421936a53378d9ccb011c4d4c7d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 13:21:05 2026 -0700

    wallco.ai downloads route: /downloads/:slug/:token.zip token-gated bundle delivery (wallco_download_tokens PG table) + mint CLI + tokens minted + Shopify metafields set on 3 drafts
---
 scripts/mint-shopify-download-token.js | 85 ++++++++++++++++++++++++++++++++++
 server.js                              | 30 ++++++++++++
 2 files changed, 115 insertions(+)

diff --git a/scripts/mint-shopify-download-token.js b/scripts/mint-shopify-download-token.js
new file mode 100644
index 0000000..61dc4f3
--- /dev/null
+++ b/scripts/mint-shopify-download-token.js
@@ -0,0 +1,85 @@
+#!/usr/bin/env node
+/**
+ * mint-shopify-download-token — generates a token-gated URL for a bundle ZIP
+ * hosted at /downloads/:slug/:token.zip on wallco.ai.
+ *
+ * Steve 2026-05-28: Shopify can't host the ZIPs (admin token missing write_files
+ * scope), so wallco.ai serves the downloads gated by tokens stored in PG. This
+ * CLI mints a token, INSERTs it into wallco_download_tokens, and prints the
+ * full URL for pasting into the Shopify product metafield (or for testing).
+ *
+ * Usage:
+ *   node scripts/mint-shopify-download-token.js --slug cactus --date 2026-05-28 --product 7843826270259
+ *   node scripts/mint-shopify-download-token.js --slug cactus --date 2026-05-28 --product 7843826270259 --email customer@example.com --order 12345
+ *   node scripts/mint-shopify-download-token.js --slug cactus --date 2026-05-28 --product 7843826270259 --days 90 --max 10
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { execSync } = require('child_process');
+const ROOT = path.resolve(__dirname, '..');
+
+function argv(name, dflt) {
+  const i = process.argv.indexOf('--' + name);
+  if (i === -1) return dflt;
+  if (i === process.argv.length - 1 || process.argv[i + 1].startsWith('--')) return true;
+  return process.argv[i + 1];
+}
+
+const slug      = argv('slug',    null);
+const date      = argv('date',    null);
+const productId = argv('product', null);
+const email     = argv('email',   null);
+const orderId   = argv('order',   null);
+const days      = parseInt(argv('days', '30'), 10);
+const maxDl     = parseInt(argv('max',  '5'),  10);
+const baseUrl   = argv('base',    process.env.WALLCO_BASE_URL || 'https://wallco.ai');
+
+if (!slug || !date || !productId) {
+  console.error('usage: --slug <bundle-slug> --date YYYY-MM-DD --product <shopify-product-id> [--email <e>] [--order <id>] [--days 30] [--max 5]');
+  process.exit(2);
+}
+
+// Resolve SKU from the bundle listing.json
+const listingPath = path.join(ROOT, 'data', 'etsy-exports', date, 'bundles', slug, 'listing.json');
+if (!fs.existsSync(listingPath)) { console.error(`listing not found: ${listingPath}`); process.exit(3); }
+const listing = JSON.parse(fs.readFileSync(listingPath, 'utf8'));
+const sku = listing.sku || `DW-BUNDLE-${slug}`;
+
+// Mint a 32-char hex token (16 bytes of randomness)
+const token = crypto.randomBytes(16).toString('hex');
+
+function pgEsc(s) { return s == null ? 'NULL' : `'${String(s).replace(/'/g, "''")}'`; }
+
+const sql = `INSERT INTO wallco_download_tokens
+  (token, bundle_sku, bundle_slug, bundle_date, shopify_order_id, shopify_product_id, customer_email, max_downloads, expires_at)
+  VALUES (${pgEsc(token)}, ${pgEsc(sku)}, ${pgEsc(slug)}, ${pgEsc(date)},
+          ${orderId ? parseInt(orderId, 10) : 'NULL'},
+          ${productId ? parseInt(productId, 10) : 'NULL'},
+          ${pgEsc(email)},
+          ${maxDl},
+          now() + interval '${days} days')
+  RETURNING token, max_downloads, expires_at::timestamp(0);`;
+
+try {
+  // psql -c with embedded newlines breaks (JSON.stringify escapes \n as backslash-n,
+  // psql then sees a literal backslash). Use stdin instead so newlines stay newlines.
+  const { spawnSync } = require('child_process');
+  const r = spawnSync('psql', ['dw_unified', '-At', '-F|'], { input: sql, encoding: 'utf8' });
+  if (r.status !== 0) throw new Error(r.stderr || `psql exit ${r.status}`);
+  const out = (r.stdout || '').trim();
+  if (!out) { console.error('INSERT returned no row'); process.exit(4); }
+  const [tk, max, exp] = out.split('|');
+  const url = `${baseUrl}/downloads/${slug}/${tk}.zip`;
+  console.log(`✓ minted token for ${sku}`);
+  console.log(`  url:           ${url}`);
+  console.log(`  max_downloads: ${max}`);
+  console.log(`  expires:       ${exp}`);
+  if (email) console.log(`  customer:      ${email}`);
+  console.log();
+  console.log(`Paste into Shopify product metafield (wallco_ai.bundle_download_url, type=url) for product ${productId}.`);
+} catch (e) {
+  console.error('mint failed:', e.message.slice(0, 400));
+  process.exit(5);
+}
diff --git a/server.js b/server.js
index c7a08a7..d55e522 100644
--- a/server.js
+++ b/server.js
@@ -3812,6 +3812,36 @@ app.post('/api/saved', (req, res) => {
   }
 });
 
+// ── GET /downloads/:slug/:token.zip — token-gated digital-bundle download.
+//
+// Steve 2026-05-28: Shopify Admin token lacks write_files scope, so we can't
+// host bundle ZIPs on Shopify's CDN. Instead, the Shopify product metafield
+// (wallco_ai.bundle_download_url) points to this route — token gates access,
+// download_count caps password-sharing, expires_at retires old links.
+//
+// Path lookup: ZIPs live at data/etsy-exports/<bundle_date>/bundles/<slug>/<slug>.zip
+// Failures emit opaque 404 so we don't leak token validity to scrapers.
+app.get('/downloads/:slug/:token.zip', (req, res) => {
+  const slug  = String(req.params.slug || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80);
+  const token = String(req.params.token || '').replace(/[^a-f0-9]/g, '').slice(0, 64);
+  if (!slug || !token) return res.status(404).type('html').send('<h1>Not Found</h1>');
+  let row;
+  try {
+    const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT token, bundle_slug, bundle_date, download_count, max_downloads, expires_at FROM wallco_download_tokens WHERE token=${pgEsc(token)} LIMIT 1) t;`);
+    if (!raw || raw.length < 3) return res.status(404).type('html').send('<h1>Not Found</h1>');
+    row = JSON.parse(raw);
+  } catch (e) { return res.status(404).type('html').send('<h1>Not Found</h1>'); }
+  if (row.bundle_slug !== slug) return res.status(404).type('html').send('<h1>Not Found</h1>');
+  if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) return res.status(404).type('html').send('<h1>Link expired</h1>');
+  if (row.max_downloads != null && row.download_count >= row.max_downloads) return res.status(404).type('html').send('<h1>Download limit reached</h1>');
+  const zipPath = path.join(__dirname, 'data', 'etsy-exports', row.bundle_date, 'bundles', slug, `${slug}.zip`);
+  if (!fs.existsSync(zipPath)) return res.status(404).type('html').send('<h1>Not Found</h1>');
+  // Increment + stamp BEFORE sending so a concurrent download still counts.
+  try { psqlExecLocal(`UPDATE wallco_download_tokens SET download_count=download_count+1, last_downloaded_at=now() WHERE token=${pgEsc(token)};`); } catch {}
+  try { psqlExecLocal(`INSERT INTO wallco_audit_log (user_id, is_admin, actor_label, action, payload, ip, user_agent) VALUES (NULL, FALSE, 'download', 'bundle_download', ${pgEsc(JSON.stringify({ token: token.slice(0,6)+'…', slug, count: (row.download_count||0)+1 }))}::jsonb, ${pgEsc(((req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '').toString().split(',')[0].trim()) || '0.0.0.0')}::inet, ${pgEsc((req.headers['user-agent'] || '').toString().slice(0, 500))});`); } catch {}
+  return res.download(zipPath, `${slug}-wallco-ai.zip`);
+});
+
 // GET /me/saved — auth-gated page listing all the user's saved designs.
 // Uses the same .design-grid CSS class as /designs so it inherits the same
 // layout + density-slider system.

← c4b01d5 add tile-period-detect: validate mural-mode room renders  ·  back to Wallco Ai  ·  luxe-curator: add canonical .when chip + flow created_at thr 7e34229 →