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.' });