diff --git a/badge.js b/badge.js
new file mode 100644
index 0000000..7ecb1fc
--- /dev/null
+++ b/badge.js
@@ -0,0 +1,42 @@
+// Achievement badge image, rendered on the server with ffmpeg (Marty, 2026-09-16).
+// The browser used to compose it on a canvas and upload the JPEG; a phone that ran short of canvas
+// memory (cryptomonk's Spark post) sent a mostly blank card to the group. Now the server draws the
+// member's name onto the badge art itself, the same way the Video Maker draws end cards, so every
+// badge looks the same no matter what device unlocked it. The browser upload stays as a fallback.
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+
+let PUBLIC_DIR = null;
+const ART = { payouts: { file: 'badge-spark.jpg', ribbonY: 0.728 }, firstBuyer: { file: 'badge-surge.jpg', ribbonY: 0.76 }, level2: { file: 'badge-circuit.jpg', ribbonY: 0.72 }, level3: { file: 'badge-nexus.jpg', ribbonY: 0.73 } };
+let FONT;
+function font() {
+ if (FONT !== undefined) return FONT;
+ FONT = process.env.BADGE_FONT || null;
+ if (FONT) return FONT;
+ const walk = d => { let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch (e) { return null; } for (const e of ents) { const f = path.join(d, e.name); if (e.isDirectory()) { const r = walk(f); if (r) return r; } else if (/DejaVuSans-Bold\.ttf$/i.test(f)) return f; } return null; };
+ for (const d of ['/usr/share/fonts', '/usr/local/share/fonts']) { const r = walk(d); if (r) { FONT = r; break; } }
+ if (!FONT && process.platform === 'win32' && fs.existsSync('C:/Windows/Fonts/arialbd.ttf')) FONT = 'C:/Windows/Fonts/arialbd.ttf';
+ return FONT;
+}
+const ffText = t => String(t).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/:/g, '\\:').replace(/%/g, '%%');
+function init(opts) { PUBLIC_DIR = opts.publicDir; }
+// returns a JPEG buffer, or null when ffmpeg or the font is missing (caller falls back to the upload)
+function render(key, who) {
+ return new Promise(resolve => {
+ const a = ART[key]; const F = font(); if (!a || !F) return resolve(null);
+ const src = path.resolve(PUBLIC_DIR, 'badges', a.file); if (!fs.existsSync(src)) return resolve(null);
+ const name = String(who || '').slice(0, 28);
+ // a Windows font path carries a drive colon that the filter parser trips on: run from the font's folder instead
+ const winPath = /:/.test(F);
+ const fontFile = winPath ? path.basename(F) : F;
+ // 1080-wide art: 59px bold gold text with a dark outline, centred on the ribbon (matches the old canvas layout)
+ const vf = name ? 'drawtext=fontfile=' + fontFile + ":text='" + ffText(name) + "':fontcolor=#ffd15c:fontsize=59:borderw=7:bordercolor=0x04140f@0.9:x=(w-text_w)/2:y=" + a.ribbonY + '*h-text_h/2' : 'null';
+ const p = spawn('ffmpeg', ['-v', 'error', '-i', src, '-vf', vf, '-frames:v', '1', '-q:v', '3', '-f', 'image2pipe', '-vcodec', 'mjpeg', 'pipe:1'], { stdio: ['ignore', 'pipe', 'pipe'], cwd: winPath ? path.dirname(F) : undefined });
+ const chunks = []; let err = '';
+ p.stdout.on('data', c => chunks.push(c)); p.stderr.on('data', c => { err += c; });
+ p.on('error', () => resolve(null));
+ p.on('close', code => { const buf = Buffer.concat(chunks); if (code === 0 && buf.length > 2000 && buf[0] === 0xff && buf[1] === 0xd8) resolve(buf); else { console.error('badge render failed', code, err.slice(0, 200)); resolve(null); } });
+ });
+}
+module.exports = { init, render, font, ART };
diff --git a/server.js b/server.js
index ac7cad3..bbddb7e 100644
--- a/server.js
+++ b/server.js
@@ -36,6 +36,7 @@ const updates = require('./updates');
const audit = require('./audit'); // counter audit: views vs delivery logs, charges vs shows (Marty, 2026-09-15) // member update emails from Admin > Releases (Marty, 2026-09-14)
const leaderboard = require('./leaderboard');
const toolkit = require('./toolkit');
+const badge = require('./badge'); // achievement badge image drawn on the server with ffmpeg (2026-09-16)
const snapshot = require('./snapshot'); // daily growth snapshot -> Telegram payments feed (Marty, 2026-09-15)
const pipeline = require('./pipeline'); // sponsor follow-up board (coming soon until site setting pipelineMode = on) (Marty, 2026-09-15)
const videomaker = require('./videomaker'); // Circuit tool: promo videos with the member's own end card (ffmpeg in the image) // badge-gated promo toolkit + AI Copy Engine (Surge and up) (Marty, 2026-09-14) // referral contest: /leaderboard, Overview card, weekly + monthly winners (Marty, 2026-09-14) // release notes + roadmap: /whats-new, Overview card, Admin > Releases (Marty, 2026-09-14) // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
@@ -372,6 +373,7 @@ async function boot() {
// follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot)
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer, ads, tank, lb: () => leaderboard, siteConfig });
pipeline.init({ dataDir: DATA_DIR, accounts, coach, chain });
+ badge.init({ publicDir: PUBLIC_DIR });
snapshot.init({ dataDir: DATA_DIR, db, chain, siteConfig, send: async (c, t, th) => { await telegramSend(c, t, th); return true; } });
setInterval(() => snapshot.tick().catch(e => console.error('snapshot', e.message)), 10 * 60 * 1000);
tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' });
@@ -1548,6 +1550,8 @@ const server = http.createServer(async (req, res) => {
const link = a && a.username ? 'instantadpay.com/join/' + a.username : 'instantadpay.com';
const [label, sub] = BADGE_META[key];
const caption = '\u{1F3C6} InstantAdPay \u00b7 ' + who.replace(/[<>&]/g, '') + ' unlocked ' + label + ': ' + sub + '\n' + link;
+ // the server draws the card (a phone's canvas can come out blank: cryptomonk, 2026-09-16); the upload is the fallback
+ try { const srv = await badge.render(key, a && a.username ? a.username : (a && a.memberId ? 'member #' + a.memberId : '')); if (srv) jpeg = srv; } catch (e) {}
const sc = siteConfig(); let sent = 0;
if (sc.telegramBotToken && sc.telegramEchoChatId) {
if (await telegramSendPhoto(sc.telegramEchoChatId, jpeg, caption, sc.telegramEchoTopicId)) sent++; // payments topic
@@ -1569,9 +1573,27 @@ const server = http.createServer(async (req, res) => {
if (!(await ads.milestonesOf(s.email)).includes(key)) return json(res, 400, { error: 'You have not unlocked that badge yet.' });
const a = await accounts.byEmail(s.email);
if (!a || !a.username) return json(res, 400, { error: 'Pick a username first; the share page carries it.' });
+ try { const srv = await badge.render(key, a.username); if (srv) jpeg = srv; } catch (e) {}
fs.writeFileSync(path.join(UPLOADS_DIR, 'badge-' + a.username + '-' + key + '.jpg'), jpeg);
return json(res, 200, { ok: true, page: 'https://instantadpay.com/b/' + a.username + '/' + key, image: 'https://instantadpay.com/badge-img/' + a.username + '/' + key + '.jpg' });
}
+ if (p === '/api/admin/badge-repost' && req.method === 'POST') {
+ if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
+ const b = await readBody(req); const key = String(b.key || ''); const a = await accounts.byEmail(String(b.email || '').toLowerCase());
+ if (!a || !BADGE_META[key]) return json(res, 400, { error: 'Unknown member or badge.' });
+ if (!(await ads.milestonesOf(a.email)).includes(key)) return json(res, 400, { error: 'That member has not unlocked that badge.' });
+ const jpeg = await badge.render(key, a.username || ('member #' + a.memberId)); if (!jpeg) return json(res, 500, { error: 'Render failed.' });
+ const who = a.username ? '@' + a.username : 'member #' + a.memberId; const link = a.username ? 'instantadpay.com/join/' + a.username : 'instantadpay.com';
+ const [label, sub] = BADGE_META[key];
+ const caption = '\u{1F3C6} InstantAdPay \u00b7 ' + who.replace(/[<>&]/g, '') + ' unlocked ' + label + ': ' + sub + '\n' + link;
+ const sc = siteConfig(); let sent = 0;
+ if (sc.telegramBotToken && sc.telegramEchoChatId) {
+ if (await telegramSendPhoto(sc.telegramEchoChatId, jpeg, caption, sc.telegramEchoTopicId)) sent++;
+ if (String(sc.telegramBadgeGeneral || '1') !== '0' && b.general !== false && await telegramSendPhoto(sc.telegramEchoChatId, jpeg, caption, null)) sent++;
+ }
+ const log = badgeLog(); const mine = log[a.email] || {}; mine[key] = { ts: Date.now(), sent, repost: true }; log[a.email] = mine; try { fs.writeFileSync(BADGE_LOG(), JSON.stringify(log)); } catch (e) {}
+ return json(res, 200, { ok: true, sent, bytes: jpeg.length });
+ }
if (p === '/api/my/badge-posted' && req.method === 'GET') { // which of my badges are already on Telegram
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });