Badge emblems, profile social links, and SEO/OG social previews

- Achievement badge share-image now draws a milestone emblem (⚡🎯⭐🏆) in the medal
- Profile: Facebook/X/YouTube/Instagram/TikTok/Telegram/LinkedIn/Website links, shown on the public bio page
- OG + Twitter Card tags on homepage/ledger/contract (hero banner as share image)
- Per-member OG tags server-injected into /wall/<username> so shared bio links preview with name/bio/avatar
- robots.txt + sitemap.xml; .txt/.xml MIME types

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-06 12:21:53 -05:00
parent b001df61b1
commit 3c0e94f20f
16 changed files with 178 additions and 52 deletions
+48 -4
View File
@@ -151,7 +151,7 @@ function siteConfig() {
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
'.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.webp': 'image/webp',
'.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2',
'.gif': 'image/gif', '.webm': 'video/webm' };
'.gif': 'image/gif', '.webm': 'video/webm', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml; charset=utf-8' };
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; media-src 'self' https: blob:; connect-src 'self'; font-src 'self' data: https://fonts.gstatic.com; form-action 'self'; frame-src https: http:";
function baseHeaders(extra) {
return Object.assign({ 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff',
@@ -684,7 +684,18 @@ const server = http.createServer(async (req, res) => {
if (avatar && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(avatar))
return json(res, 400, { error: 'Avatar must be an uploaded image or an https image URL.' });
const bio = b.bio === undefined ? undefined : String(b.bio || '').trim().slice(0, 600);
const r = await accounts.setProfile(s.email, avatar, bio);
// socials: {platform: url}; keep only known platforms with valid https urls
let socials;
if (b.socials !== undefined) {
const PLAT = ['facebook', 'twitter', 'youtube', 'instagram', 'tiktok', 'telegram', 'linkedin', 'website'];
const clean = {};
for (const p of PLAT) {
const v = String((b.socials && b.socials[p]) || '').trim();
if (v && /^https:\/\/[^\s]+$/i.test(v) && v.length <= 200) clean[p] = v;
}
socials = Object.keys(clean).length ? JSON.stringify(clean) : null;
}
const r = await accounts.setProfile(s.email, avatar, bio, socials);
return json(res, r.error ? 400 : 200, r);
}
// -- line banner: the member's viral slot on welcome tours + their wall
@@ -737,8 +748,9 @@ const server = http.createServer(async (req, res) => {
.map(x => ({ name: x.username ? '@' + x.username : x.memberId ? 'member #' + x.memberId : 'a member',
bannerUrl: x.lineBannerUrl || null, targetUrl: x.lineTargetUrl || null }));
const joinPath = '/join/' + (a.username || a.code);
let socials = null; try { socials = a.socials ? JSON.parse(a.socials) : null; } catch (e) {}
return json(res, 200, { name: a.username ? '@' + a.username : 'member #' + (a.memberId || 0),
avatarUrl: a.avatarUrl || null, bio: a.bio || null,
avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials,
joinUrl: joinPath, qrUrl: '/api/qr?d=' + encodeURIComponent('https://instantadpay.com' + joinPath), ladder });
}
// -- watch-to-earn video ads: serve one, then reward a server-clock-verified watch
@@ -996,7 +1008,39 @@ const server = http.createServer(async (req, res) => {
m = /^\/uploads\/([a-z0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm))$/.exec(p);
if (m) return sendFile(res, path.join(UPLOADS_DIR, m[1]));
if (/^\/tx\/0x[0-9a-fA-F]{64}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'tx.html'));
if (/^\/wall\/[A-Za-z0-9_]{1,20}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'wall.html'));
m = /^\/wall\/([A-Za-z0-9_]{1,20})$/.exec(p);
if (m) { // server-inject per-member OG tags so shared bio links preview correctly (crawlers don't run JS)
try {
const tok = m[1].toLowerCase();
let a = await accounts.byUsername(tok); if (!a) a = await accounts.byCode(tok);
let html = fs.readFileSync(path.join(PUBLIC_DIR, 'wall.html'), 'utf8');
if (a) {
const nm = a.username ? '@' + a.username : 'member #' + (a.memberId || 0);
const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const title = nm + ' on InstantAdPay';
const desc = a.bio ? esc(a.bio).slice(0, 200) : 'Join ' + nm + '’s line on InstantAdPay — instant on-chain ad payouts, free to join.';
const img = a.avatarUrl && /^https:/.test(a.avatarUrl) ? a.avatarUrl : 'https://instantadpay.com/banners/iap-hero-1200x630.png';
const url = 'https://instantadpay.com/wall/' + (a.username || a.code);
const og = [
'<meta property="og:type" content="profile">',
'<meta property="og:site_name" content="InstantAdPay">',
'<meta property="og:url" content="' + url + '">',
'<meta property="og:title" content="' + esc(title) + '">',
'<meta property="og:description" content="' + desc + '">',
'<meta property="og:image" content="' + esc(img) + '">',
'<meta name="twitter:card" content="summary_large_image">',
'<meta name="twitter:title" content="' + esc(title) + '">',
'<meta name="twitter:description" content="' + desc + '">',
'<meta name="twitter:image" content="' + esc(img) + '">',
'<meta name="description" content="' + desc + '">',
'<link rel="canonical" href="' + url + '">'
].join('\n');
html = html.replace('<title>Banner wall | InstantAdPay</title>', '<title>' + esc(title) + '</title>').replace('<!--OG-->', og);
}
res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' }));
return res.end(html);
} catch (e) { return sendFile(res, path.join(PUBLIC_DIR, 'wall.html')); }
}
const safe = path.normalize(p).replace(/^([.\\/])+/, '');
const file = path.join(PUBLIC_DIR, safe);
if (file.startsWith(PUBLIC_DIR) && fs.existsSync(file) && fs.statSync(file).isFile()) return sendFile(res, file);