From 56415827090601ab004eb6e04a161175768908e5 Mon Sep 17 00:00:00 2001 From: martbost Date: Wed, 16 Sep 2026 13:56:30 -0500 Subject: [PATCH] Banner creatives must be images: server-side image check (extension or image/* HEAD), form hint, chatbot note Campaign #134 (NAS ad 2806) had the member's join-page link in the image field and served a broken banner 141 times on the network; #102 did the same with an imgbb page link. imageCheck() accepts /uploads/ and instantadpay.com/banners files and image extensions outright, otherwise HEADs the URL (one redirect) and requires image/*. Wired into member and house campaign creation for banners and login-ad creatives. Form placeholder + hint point at Promo tools > Banners > Copy image URL. Co-Authored-By: Claude Fable 5.1 --- chatbot.js | 1 + public/assets/my.js | 1 + public/my.html | 5 +++-- server.js | 26 ++++++++++++++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/chatbot.js b/chatbot.js index da33b1d..a7f0d53 100644 --- a/chatbot.js +++ b/chatbot.js @@ -95,6 +95,7 @@ FACTS: - (SUPERSEDED, kept for history) SPONSOR NUDGE ON A HELD BUY (2026-09-15): when a referral's purchase is held because the sponsor has not switched on payouts, the sponsor is emailed and gets an on-site message the same minute (once an hour at most) telling them who is waiting and to link a wallet / switch on payouts on the Wallet tab; nothing is charged to the buyer, who can simply try again once the sponsor is ready. - DRILL DOWN FROM THE OVERVIEW (2026-09-15): every name chip in 'Your line at a glance' on the Overview is clickable; it jumps to My line with that person's Activity panel already open and the row highlighted. - VISIT PACKS ARE PAID UP FRONT (2026-09-15, Michael's question): a verified-visits campaign charges the whole pack when it is created (3 credits per visit, so 1,000 visits = 3,000 credits) and then delivers over time as members complete verified visits (each member can do at most 20 a day, so packs fill gradually: tens per day at today's size). The Campaigns row shows 'paid up front' and 'N of M visits delivered'; the credits are not being consumed again, they were reserved once. Same for featured-day bookings: a featured link is a flat buy (featuredPerDay credits per day, whole run charged when booked), its row shows 'paid up front', the day count and 'runs through ', and it ends on its last day ('run ended'). FEATURED CANNOT TAKE ADDED CREDITS (2026-09-16, Hugh and Michael both tried): the row offers 'Extend run' which books more days after the current run (or from today if it ended) and charges them right away; there is no 'Buy more views' on a featured row any more. Visit packs offer 'Buy more visits' (charged now, adds to the pack). Credits added to a campaign are set aside immediately (Available drops at once) and whatever is unspent returns to Available when the campaign ends. +- BANNER CREATIVE MUST BE AN IMAGE LINK (2026-09-16): the New campaign image field needs a direct image URL (ends in .png/.jpg/.gif/.webp/.svg) or an upload; a web-page link (a join page, an imgbb/ibb.co page) is refused with a message. Easiest source: Promo tools > Banners > Copy image URL. A campaign created with a page link before this check served a broken banner and was repaired by admin. - PAYMENT TRACE (2026-09-16): Members > Earnings > "Trace a payment": enter a member number or @username (yourself, anyone up to 3 levels below you, or your own 3 uplines) and every purchase they were part of is listed tier by tier — who was paid at level 1/2/3 (50/20/10%), who was skipped and why ("not qualified: had N of 2 qualifying buyers then"), the platform's 20%, and a verify link to the transaction. Use it to answer "why didn't my sponsor / why didn't I get paid on X's purchase" — the usual answers are (a) the sponsor had not unlocked that level when the purchase settled (qualification never applies backwards), (b) the buyer used Qualified Start, so the purchase came from a linked wallet whose on-chain sponsor is the buyer's own main position (level 1 goes to the buyer themselves), (c) the sponsor's payouts were not switched on. Admins see the same trace under the member card in Admin > Members. - CREDIT ACTIVITY LEDGER (2026-09-16): Members > Campaigns has a 'Credit activity' list (last 40 lines, newest first; also linked from the Earn credits pane) showing every real credit movement with a reason: welcome credits, sign-in bonus with streak, daily claim, inbox reads, video watches, verified visits, milestone and leaderboard bonuses, partner codes, admin/team grants, AI Copy Engine charges and refunds, flat buys (featured runs, extensions, visit packs, more visits), login-ad days, and metered delivery rolled up as one line per campaign per day ('Ad views: NAME', 'Inbox deliveries: NAME', 'Video views: NAME'); lines marked '(purchased credits)' spent on-chain purchased credits rather than the earned pool. Reservations (credits set aside for a live campaign) are NOT ledger lines; they show as 'in live campaigns'. Earned credits are spent before purchased ones, so an earn followed by a buy appears as two lines. The ledger starts 2026-09-16; nothing earlier is listed. - PAYMENT + MISSED-PAYMENT NOTICES (2026-09-15): every payout that lands (who bought, level, share in POL and dollars, transaction link) and every missed payout is delivered BOTH by email and as an on-site inbox message from the company account, shown in the login pop-up and the Messages card, so it is waiting when the member signs in. MISSED-PAYOUT detail: when a level-2 or level-3 share passes a member by because that level is not open on their account, the member gets an email the same minute: who bought, the POL and dollar amount they missed, how many qualifying buyers they have versus the 2 or 5 needed, and the two ways to close the gap (bring buyers, or Qualified Start). Qualified members whose wallet rejected a transfer get a different email telling them to link a regular wallet. diff --git a/public/assets/my.js b/public/assets/my.js index 020c1c0..a4b5a6a 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -985,6 +985,7 @@ whereHint(); const t = $('cType').value; $('cImageRow').hidden = t !== 'banner'; // only banners carry a creative; login frames its URL + if ($('cImageHint')) $('cImageHint').hidden = t !== 'banner'; $('cSizeRow').hidden = t !== 'banner'; $('cTitleRow').hidden = t !== 'text' && t !== 'solo'; $('cBodyRow').hidden = t !== 'text'; diff --git a/public/my.html b/public/my.html index 09356bf..9d047a4 100644 --- a/public/my.html +++ b/public/my.html @@ -518,7 +518,8 @@

Banner and text targets are shown full screen in the ad viewer, so those URLs must allow framing — we check them the moment you submit. Login and solo links open in a fresh tab, so any working page is fine.

-

+

+

A link to a web page will not work here. Get one from Promo tools > Banners > Copy image URL, or upload your own.

@@ -1032,7 +1033,7 @@ - + diff --git a/server.js b/server.js index ca4f58c..0f0839d 100644 --- a/server.js +++ b/server.js @@ -342,6 +342,30 @@ function serveJoinPage(res, tok, angle, ang, setCookies) { res.writeHead(200, baseHeaders(headers)); res.end(html); } +// A banner creative must be an IMAGE (2026-09-16: campaign #134 had the member's join-page link in the +// image field and served a broken banner 141 times on the network). Accept /uploads/ files and +// image extensions outright; otherwise HEAD the URL and require an image/* content-type. +async function imageCheck(url) { + const u = String(url || '').trim(); + if (/^\/uploads\//.test(u) || /^https?:\/\/instantadpay\.com\/(uploads|banners)\//i.test(u)) return { ok: true }; + if (/\.(png|jpe?g|gif|webp|svg)([?#].*)?$/i.test(u)) return { ok: true }; + try { + const h = await headUrl(u); + if (h && /^image\//i.test(String(h.contentType || ''))) return { ok: true }; + return { ok: false, reason: 'That link is a web page, not an image. Paste the direct image link (it usually ends in .png or .jpg), or use Promo tools > Banners > Copy image URL.' }; + } catch (e) { return { ok: false, reason: 'Could not load that image link. Paste the direct image link (ends in .png or .jpg), or use Promo tools > Banners > Copy image URL.' }; } +} +function headUrl(url) { + return new Promise((resolve, reject) => { + let u; try { u = new URL(url); } catch (e) { return reject(new Error('bad url')); } + const mod = u.protocol === 'http:' ? require('http') : require('https'); + const req = mod.request({ method: 'HEAD', hostname: u.hostname, port: u.port || undefined, path: u.pathname + u.search, timeout: 6000, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; InstantAdPay-ImageCheck/1.0)' } }, r => { + if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location && !u.searchParams.has('_r')) { try { const nx = new URL(r.headers.location, url); nx.searchParams.set('_r', '1'); return resolve(headUrl(nx.toString())); } catch (e) {} } + resolve({ status: r.statusCode, contentType: r.headers['content-type'] || '' }); r.resume(); + }); + req.on('error', reject); req.on('timeout', () => req.destroy(new Error('timeout'))); req.end(); + }); +} async function frameCheck(url) { const h = await frameFetch(url, 0); if (h.error) return { ok: false, reason: 'We checked your URL and ' + h.error + '. Fix the URL and try again.' }; @@ -2616,6 +2640,7 @@ const server = http.createServer(async (req, res) => { const memberId = await auth.refreshMemberId(s); // 0 is fine: earned credits fund banner/text const b = await readBody(req); if (!['login', 'solo', 'video', 'featured'].includes(String(b.type || ''))) { // banner/text surf views frame the target; login/video/solo/featured open in a new tab or play in our own player + if (b.type === 'banner' || (b.type === 'login' && b.imageUrl)) { const ic = await imageCheck(b.imageUrl); if (!ic.ok) return json(res, 400, { error: ic.reason }); } const fc = await frameCheck(b.targetUrl); if (!fc.ok) return json(res, 400, { error: fc.reason }); } @@ -2744,6 +2769,7 @@ const server = http.createServer(async (req, res) => { if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); const b = await readBody(req); if (!['login', 'solo', 'video', 'featured'].includes(String(b.type || ''))) { + if (b.type === 'banner' || (b.type === 'login' && b.imageUrl)) { const ic = await imageCheck(b.imageUrl); if (!ic.ok) return json(res, 400, { error: ic.reason }); } const fc = await frameCheck(b.targetUrl); if (!fc.ok) return json(res, 400, { error: fc.reason }); }