@ads-repo/meta-creative-buckets 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Shared configuration for the creative-buckets dashboard.
5
+ *
6
+ * Everything client-specific lives here, so the same scripts run against any Meta ad
7
+ * account without editing code. Resolution order, first match wins:
8
+ *
9
+ * 1. CLI flag --client acme
10
+ * 2. Environment BUCKETS_CLIENT=acme
11
+ * 3. buckets.config.json in the project root
12
+ * 4. Defaults below
13
+ *
14
+ * The client slug does two jobs: it names the output folder (data/meta-ads/<slug>/) and,
15
+ * when the per-client env convention is used, it picks the credentials. Both token forms
16
+ * are accepted so a single-account setup does not need a suffix:
17
+ *
18
+ * META_ACCESS_TOKEN_ACME + META_AD_ACCOUNT_ID_ACME (per-client, slug upper-cased)
19
+ * META_ACCESS_TOKEN + META_AD_ACCOUNT_ID (plain fallback)
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ // .env loading, dependency-free. `dotenv` is used when the project already has it; otherwise
26
+ // a small parser covers the same ground, so the skill drops into any repo without an install.
27
+ // Real environment variables always win over the file, and .env.local shadows .env.
28
+ (function loadEnv() {
29
+ try {
30
+ require('dotenv').config();
31
+ } catch (e) {
32
+ for (const file of ['.env', '.env.local']) {
33
+ const p = path.join(process.cwd(), file);
34
+ if (!fs.existsSync(p)) continue;
35
+ for (const raw of fs.readFileSync(p, 'utf-8').split('\n')) {
36
+ const line = raw.trim();
37
+ if (!line || line.startsWith('#')) continue;
38
+ const eq = line.indexOf('=');
39
+ if (eq === -1) continue;
40
+ const key = line.slice(0, eq).trim().replace(/^export\s+/, '');
41
+ if (!key || process.env[key] !== undefined) continue;
42
+ let val = line.slice(eq + 1).trim();
43
+ // strip matching surrounding quotes, keeping any inside the value
44
+ if (val.length > 1 && (val[0] === '"' || val[0] === "'") && val[val.length - 1] === val[0]) {
45
+ val = val.slice(1, -1);
46
+ }
47
+ process.env[key] = val;
48
+ }
49
+ }
50
+ }
51
+ })();
52
+
53
+ const DEFAULTS = {
54
+ client: 'default',
55
+ accountName: null, // shown in the dashboard header; falls back to the account id
56
+ logo: null, // path to a client logo (png/jpg/svg), relative to project root
57
+ agencyLogo: null, // second logo, drawn to the right of the first
58
+ };
59
+
60
+ function readFlag(name) {
61
+ const i = process.argv.indexOf(`--${name}`);
62
+ if (i !== -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) {
63
+ return process.argv[i + 1];
64
+ }
65
+ const inline = process.argv.find(a => a.startsWith(`--${name}=`));
66
+ return inline ? inline.slice(name.length + 3) : null;
67
+ }
68
+
69
+ function readConfigFile() {
70
+ const p = path.join(process.cwd(), 'buckets.config.json');
71
+ if (!fs.existsSync(p)) return {};
72
+ try {
73
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
74
+ } catch (e) {
75
+ console.warn(` ⚠ buckets.config.json is not valid JSON (${e.message}) — using defaults`);
76
+ return {};
77
+ }
78
+ }
79
+
80
+ const fileCfg = readConfigFile();
81
+
82
+ const client = readFlag('client') || process.env.BUCKETS_CLIENT || fileCfg.client || DEFAULTS.client;
83
+
84
+ // Slug -> ENV suffix. "acme-cz" becomes ACME_CZ, matching the per-client token convention.
85
+ const SUFFIX = client.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_|_$/g, '');
86
+
87
+ function credentials() {
88
+ const token = process.env[`META_ACCESS_TOKEN_${SUFFIX}`] || process.env.META_ACCESS_TOKEN;
89
+ let accountId = process.env[`META_AD_ACCOUNT_ID_${SUFFIX}`] || process.env.META_AD_ACCOUNT_ID;
90
+ if (accountId && !String(accountId).startsWith('act_')) accountId = 'act_' + accountId;
91
+ return { token, accountId };
92
+ }
93
+
94
+ // A single place to fail, so every script gives the same actionable message.
95
+ function requireCredentials() {
96
+ const { token, accountId } = credentials();
97
+ if (!token || !accountId) {
98
+ // On the default client the plain form is the one to show; a named client means the
99
+ // user is juggling accounts, so lead with the suffixed pair that keeps them apart.
100
+ const named = client !== DEFAULTS.client;
101
+ console.error(
102
+ `\nError: Meta credentials missing${named ? ` for client "${client}"` : ''}.\n\n` +
103
+ `Add these to .env in your project root (see env.example in the skill folder):\n` +
104
+ (named
105
+ ? ` META_ACCESS_TOKEN_${SUFFIX}=your-access-token\n` +
106
+ ` META_AD_ACCOUNT_ID_${SUFFIX}=act_1234567890\n`
107
+ : ` META_ACCESS_TOKEN=your-access-token\n` +
108
+ ` META_AD_ACCOUNT_ID=act_1234567890\n\n` +
109
+ `Reading several accounts? Suffix each pair with the client slug\n` +
110
+ `(META_ACCESS_TOKEN_ACME_CZ=…) and pass --client acme-cz.\n`)
111
+ );
112
+ process.exit(1);
113
+ }
114
+ return { token, accountId };
115
+ }
116
+
117
+ const dataDir = path.join(process.cwd(), 'data', 'meta-ads', client);
118
+
119
+ module.exports = {
120
+ client,
121
+ envSuffix: SUFFIX,
122
+ accountName: fileCfg.accountName || DEFAULTS.accountName,
123
+ logo: fileCfg.logo || DEFAULTS.logo,
124
+ agencyLogo: fileCfg.agencyLogo || DEFAULTS.agencyLogo,
125
+ dataDir,
126
+ historyDir: path.join(dataDir, 'history'),
127
+ latestFile: path.join(dataDir, 'buckets-latest.json'),
128
+ credentials,
129
+ requireCredentials,
130
+ apiVersion: process.env.META_API_VERSION || 'v25.0',
131
+ pageSize: parseInt(process.env.META_API_PAGE_SIZE) || 500,
132
+ rateLimitDelay: parseInt(process.env.META_API_RATE_LIMIT_DELAY) || 400,
133
+ };
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wrap the built dashboard in a password gate, for hosting on a Vercel Hobby plan where
5
+ * Vercel's own Password Protection is not available.
6
+ *
7
+ * The whole page is encrypted with AES-256-GCM under a key derived from the password with
8
+ * PBKDF2-SHA256 (600k iterations). The output is a small login page that decrypts the real
9
+ * dashboard in the browser via Web Crypto and writes it into the document. No library, no
10
+ * server, no build step — it stays a single static file.
11
+ *
12
+ * buckets-matrix.html -> buckets-matrix-locked.html
13
+ *
14
+ * READ THIS BEFORE RELYING ON IT
15
+ * ------------------------------
16
+ * This is a fence, not a vault. The ciphertext ships to anyone who loads the page, so an
17
+ * attacker can brute-force the password offline at their own pace. 600k PBKDF2 iterations
18
+ * make that slow but not impossible — a weak password will fall. It stops casual visitors,
19
+ * search engines and link-forwarding; it does not stop a determined one. For real protection
20
+ * use Vercel Pro's Password Protection or Cloudflare Access.
21
+ *
22
+ * The source file is never modified: this reads it and writes a NEW file alongside.
23
+ *
24
+ * Thumbnails: by default the referenced thumbs/ images are inlined into the encrypted
25
+ * payload, so they cannot be fetched directly from the host. That is the point of the
26
+ * exercise — leaving them as loose files would expose every creative to anyone guessing
27
+ * the URL, defeating the gate. It makes the file much larger (~14 MB here). Pass
28
+ * --keep-thumbs to skip inlining and accept that exposure.
29
+ *
30
+ * Usage:
31
+ * node encrypt-dashboard.cjs <password> [--in <file>] [--out <file>] [--keep-thumbs]
32
+ */
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const crypto = require('crypto');
37
+ const cfg = require('./config.cjs');
38
+
39
+ // Label for the unlock screen. The dashboard JSON knows the real account name; fall back to
40
+ // the client slug when it has not been fetched yet.
41
+ function accountLabel() {
42
+ if (cfg.accountName) return cfg.accountName;
43
+ try {
44
+ return JSON.parse(fs.readFileSync(cfg.latestFile, 'utf-8')).accountName || cfg.client;
45
+ } catch (e) {
46
+ return cfg.client;
47
+ }
48
+ }
49
+ const LABEL = accountLabel();
50
+ const escHtml = (s) => String(s == null ? '' : s)
51
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
52
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
53
+
54
+ const args = process.argv.slice(2);
55
+ const flag = (name) => {
56
+ const i = args.indexOf(name);
57
+ return i >= 0 ? args[i + 1] : null;
58
+ };
59
+ const has = (name) => args.includes(name);
60
+
61
+ const password = args.find((a) => !a.startsWith('--') && args[args.indexOf(a) - 1] !== '--in' && args[args.indexOf(a) - 1] !== '--out');
62
+
63
+ if (!password) {
64
+ console.error('Usage: node encrypt-dashboard.cjs <password> [--in <file>] [--out <file>] [--keep-thumbs]');
65
+ process.exit(1);
66
+ }
67
+ if (password.length < 6) {
68
+ console.error(`Error: password is ${password.length} characters — too short to be worth encrypting at all.`);
69
+ process.exit(1);
70
+ }
71
+ // Warn but proceed: the length floor is the caller's call, not this script's.
72
+ // Measured on an M-series Mac at 600k iterations: ~10 guesses/s locally, so a GPU rig lands
73
+ // around 3k/s. A brand-name-plus-year password sits in the first few thousand guesses.
74
+ if (password.length < 12 || /^[A-Za-z]+\d{0,4}$/.test(password)) {
75
+ console.warn(`\n ⚠ Weak password (${password.length} chars). A brand-name-plus-year pattern falls to an\n` +
76
+ ` offline guess in seconds, because the ciphertext is public. This will stop a passer-by\n` +
77
+ ` following a shared link, and nothing more. Do not treat the page as confidential.`);
78
+ }
79
+
80
+ const IN = path.resolve(flag('--in') || path.join(cfg.dataDir, 'buckets-matrix.html'));
81
+ const OUT = path.resolve(flag('--out') || IN.replace(/\.html$/, '-locked.html'));
82
+ const KEEP_THUMBS = has('--keep-thumbs');
83
+
84
+ if (!fs.existsSync(IN)) {
85
+ console.error(`Error: ${IN} not found — build the dashboard first.`);
86
+ process.exit(1);
87
+ }
88
+ if (path.resolve(IN) === path.resolve(OUT)) {
89
+ console.error('Error: --out must differ from --in. This script never overwrites its source.');
90
+ process.exit(1);
91
+ }
92
+
93
+ let html = fs.readFileSync(IN, 'utf-8');
94
+ const originalBytes = Buffer.byteLength(html);
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Inline the thumbnails, so the gate actually covers the creatives too.
98
+ // ---------------------------------------------------------------------------
99
+ let inlined = 0, missingThumbs = 0;
100
+ if (!KEEP_THUMBS) {
101
+ const baseDir = path.dirname(IN);
102
+ const cache = new Map();
103
+ const toDataUri = (rel) => {
104
+ if (cache.has(rel)) return cache.get(rel);
105
+ const p = path.join(baseDir, rel);
106
+ let uri = null;
107
+ if (fs.existsSync(p)) {
108
+ uri = `data:image/jpeg;base64,${fs.readFileSync(p).toString('base64')}`;
109
+ inlined++;
110
+ } else {
111
+ missingThumbs++;
112
+ }
113
+ cache.set(rel, uri);
114
+ return uri;
115
+ };
116
+
117
+ // The page references thumbs/<id>.jpg in the ADS array, and derives thumbs/<id>@full.jpg
118
+ // from it at runtime. Replace both with data URIs, and neutralise the runtime derivation
119
+ // by mapping ad id -> full-size data URI in a lookup the page prefers when present.
120
+ const fullMap = {};
121
+ html = html.replace(/"thumbs\/([^"]+?)\.jpg"/g, (m, id) => {
122
+ const small = toDataUri(`thumbs/${id}.jpg`);
123
+ const full = toDataUri(`thumbs/${id}@full.jpg`);
124
+ if (full) fullMap[id] = full;
125
+ return small ? JSON.stringify(small) : m;
126
+ });
127
+
128
+ // fullSrc() turns a thumb path into its @full variant by string replace; with data URIs
129
+ // that no longer works, so give it an explicit lookup keyed by ad id.
130
+ const lookup = `\nconst FULL_BY_ID = ${JSON.stringify(fullMap)};\n`;
131
+ html = html.replace(
132
+ /function fullSrc\(a\)\{[^\n]*\n/,
133
+ `${lookup}function fullSrc(a){ return FULL_BY_ID[a.id] || a.thumb || ''; }\n`
134
+ );
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Encrypt: PBKDF2-SHA256 -> AES-256-GCM
139
+ // ---------------------------------------------------------------------------
140
+ const ITER = 600000;
141
+ const salt = crypto.randomBytes(16);
142
+ const iv = crypto.randomBytes(12);
143
+ const key = crypto.pbkdf2Sync(password, salt, ITER, 32, 'sha256');
144
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
145
+ const ct = Buffer.concat([cipher.update(Buffer.from(html, 'utf-8')), cipher.final()]);
146
+ const tag = cipher.getAuthTag();
147
+ // Web Crypto expects the auth tag appended to the ciphertext
148
+ const payload = Buffer.concat([ct, tag]).toString('base64');
149
+
150
+ const page = `<!DOCTYPE html>
151
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
152
+ <title>Creative Matrix - ${escHtml(LABEL)}</title>
153
+ <meta name="robots" content="noindex,nofollow">
154
+ <link href="https://fonts.googleapis.com/css2?family=Raleway:wght@400;500;600;700&display=swap" rel="stylesheet">
155
+ <style>
156
+ *{box-sizing:border-box}
157
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
158
+ font-family:'Raleway',-apple-system,sans-serif;background:#F3F4F6;color:#111;padding:24px}
159
+ .box{background:#fff;border:1px solid #E5E7EB;border-radius:12px;padding:32px;width:100%;max-width:380px;
160
+ box-shadow:0 8px 28px rgba(16,24,40,.08);text-align:center}
161
+ h1{margin:0 0 6px;font-size:19px;font-weight:600}
162
+ .sub{color:#6b7280;font-size:13px;margin-bottom:22px}
163
+ input{width:100%;font-family:inherit;font-size:15px;padding:11px 14px;border:1px solid #E5E7EB;
164
+ border-radius:8px;outline:none;text-align:center}
165
+ input:focus{border-color:#ff5722}
166
+ button{width:100%;margin-top:10px;font-family:inherit;font-size:14px;font-weight:600;padding:11px;
167
+ border:none;border-radius:8px;background:#ff5722;color:#fff;cursor:pointer}
168
+ button:hover{filter:brightness(1.05)}
169
+ button:disabled{opacity:.6;cursor:default}
170
+ .err{color:#dc2626;font-size:12.5px;font-weight:600;margin-top:12px;min-height:18px}
171
+ .foot{color:#9ca3af;font-size:11px;margin-top:18px;line-height:1.5}
172
+ </style></head>
173
+ <body>
174
+ <div class="box" id="box">
175
+ <h1>Creative Matrix</h1>
176
+ <div class="sub">${escHtml(LABEL)}</div>
177
+ <form id="f">
178
+ <input type="password" id="pw" placeholder="Password" autocomplete="current-password" autofocus>
179
+ <button type="submit" id="go">Unlock</button>
180
+ </form>
181
+ <div class="err" id="err"></div>
182
+ <div class="foot">This page is access-limited, not secured.<br>Do not share the link outside the client.</div>
183
+ </div>
184
+ <script>
185
+ const DATA={salt:"${salt.toString('base64')}",iv:"${iv.toString('base64')}",ct:"${payload}",iter:${ITER}};
186
+ const b64=s=>Uint8Array.from(atob(s),c=>c.charCodeAt(0));
187
+ const KEY='creativeBucketsPw';
188
+
189
+ async function unlock(pw,quiet){
190
+ const err=document.getElementById('err'), go=document.getElementById('go');
191
+ if(!quiet){ go.disabled=true; go.textContent='Decrypting…'; err.textContent=''; }
192
+ try{
193
+ const base=await crypto.subtle.importKey('raw',new TextEncoder().encode(pw),'PBKDF2',false,['deriveKey']);
194
+ const key=await crypto.subtle.deriveKey(
195
+ {name:'PBKDF2',salt:b64(DATA.salt),iterations:DATA.iter,hash:'SHA-256'},
196
+ base,{name:'AES-GCM',length:256},false,['decrypt']);
197
+ const plain=await crypto.subtle.decrypt({name:'AES-GCM',iv:b64(DATA.iv)},key,b64(DATA.ct));
198
+ // remember the password so a reload does not ask again (sessionStorage = this tab only)
199
+ try{ sessionStorage.setItem(KEY,pw); }catch(e){}
200
+ const doc=new TextDecoder().decode(plain);
201
+ document.open(); document.write(doc); document.close();
202
+ return true;
203
+ }catch(e){
204
+ if(!quiet){
205
+ err.textContent='Wrong password';
206
+ go.disabled=false; go.textContent='Unlock';
207
+ document.getElementById('pw').select();
208
+ }
209
+ try{ sessionStorage.removeItem(KEY); }catch(e2){}
210
+ return false;
211
+ }
212
+ }
213
+
214
+ document.getElementById('f').addEventListener('submit',e=>{
215
+ e.preventDefault();
216
+ unlock(document.getElementById('pw').value,false);
217
+ });
218
+
219
+ // already unlocked in this tab? go straight in
220
+ (async()=>{
221
+ let saved=null;
222
+ try{ saved=sessionStorage.getItem(KEY); }catch(e){}
223
+ if(saved) await unlock(saved,true);
224
+ })();
225
+ </script>
226
+ </body></html>`;
227
+
228
+ fs.writeFileSync(OUT, page);
229
+
230
+ const outBytes = Buffer.byteLength(page);
231
+ console.log(`\nPassword-gated dashboard`);
232
+ console.log(` in : ${path.relative(process.cwd(), IN)} (${(originalBytes / 1024).toFixed(0)} kB, unchanged)`);
233
+ console.log(` out : ${path.relative(process.cwd(), OUT)} (${(outBytes / 1024 / 1024).toFixed(1)} MB)`);
234
+ if (!KEEP_THUMBS) {
235
+ console.log(` thumbnails inlined : ${inlined}${missingThumbs ? ` (${missingThumbs} missing)` : ''}`);
236
+ } else {
237
+ console.log(` thumbnails NOT inlined — they stay fetchable by direct URL`);
238
+ }
239
+ console.log(` PBKDF2 iterations : ${ITER.toLocaleString()}`);
240
+ console.log(`\n Reminder: the ciphertext is public, so this resists casual access, not a determined`);
241
+ console.log(` attacker. Use a long password and do not reuse one that protects anything else.`);