Video ads (watch-to-earn): duration-tiered pricing, escape-proof player, per-view charge
- New 'video' campaign type; MP4/WebM upload or direct https link + required-watch tier (10/30/60s → 3/7/12 cr per view; viewer earns 1/2/4) - Composer: video fields, tier dropdown, live price hint - Earn credits > Watch videos sub-tab: no-seek player, server-clock watch floor, daily cap, self-exclusion, single-use tokens - media-src CSP for direct-link/hosted video; video skips frame-check Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); // solo-ad media lives on th
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
const uploadCounts = new Map(); // email:day -> uploads today
|
||||
const gauntletTokens = new Map(); // email -> welcome-tour token (server-clock dwell floor)
|
||||
const videoTokens = new Map(); // email -> watch-to-earn video token (server-clock watch floor)
|
||||
// walk the referral chain upward via sponsorRef (code/username/member id)
|
||||
async function uplineSlides(email, depth = 3) {
|
||||
const out = [];
|
||||
@@ -139,7 +140,7 @@ const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': '
|
||||
'.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' };
|
||||
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://fonts.gstatic.com; form-action 'self'; frame-src https: http:";
|
||||
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',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin' }, extra || {});
|
||||
@@ -615,6 +616,34 @@ const server = http.createServer(async (req, res) => {
|
||||
return json(res, 200, { name: a.username ? '@' + a.username : 'member #' + (a.memberId || 0),
|
||||
joinUrl: '/join/' + (a.username || a.code), ladder });
|
||||
}
|
||||
// -- watch-to-earn video ads: serve one, then reward a server-clock-verified watch
|
||||
if (p === '/api/my/videos' && req.method === 'GET') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const status = await ads.videoStatus(s.email);
|
||||
if (status.left <= 0) return json(res, 200, { ad: null, status });
|
||||
const ad = await ads.serveVideo({ excludeEmail: s.email }); // never your own video
|
||||
if (!ad) return json(res, 200, { ad: null, status });
|
||||
const token = crypto.randomBytes(16).toString('hex');
|
||||
videoTokens.set(s.email, { token, ts: Date.now(), id: ad.id, secs: ad.watchSecs });
|
||||
return json(res, 200, { ad, token, status });
|
||||
}
|
||||
if (p === '/api/my/videowatch' && req.method === 'POST') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const b = await readBody(req);
|
||||
const t = videoTokens.get(s.email);
|
||||
if (!t || t.token !== String(b.token || '')) return json(res, 400, { error: 'That video is no longer open. Load the next one.' });
|
||||
const age = Date.now() - t.ts;
|
||||
if (age < t.secs * 1000 - 600) return json(res, 400, { error: 'Watch the full video first.' });
|
||||
if (age > t.secs * 1000 + 10 * 60 * 1000) { videoTokens.delete(s.email); return json(res, 400, { error: 'That watch went stale. Load a fresh video.' }); }
|
||||
videoTokens.delete(s.email); // single use
|
||||
const tier = await ads.chargeVideoView(t.id); // charge advertiser; null if it ran dry
|
||||
if (!tier) return json(res, 200, { ok: true, credited: 0, status: await ads.videoStatus(s.email), gone: true });
|
||||
await ads.addEarned(s.email, tier.reward);
|
||||
const status = await ads.recordVideoWatch(s.email);
|
||||
return json(res, 200, { ok: true, credited: tier.reward, status });
|
||||
}
|
||||
// -- onsite solo ads: member inbox with read rewards
|
||||
if (p === '/api/my/inbox' && req.method === 'GET') {
|
||||
const s = await auth.fromRequest(req);
|
||||
@@ -723,7 +752,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const memberId = await auth.refreshMemberId(s); // 0 is fine: earned credits fund banner/text
|
||||
const b = await readBody(req);
|
||||
if (!['login', 'solo'].includes(String(b.type || ''))) { // banner/text surf views frame the target: catch frame-breakers at the door (login opens a new tab, solo links are click-through)
|
||||
if (!['login', 'solo', 'video'].includes(String(b.type || ''))) { // banner/text surf views frame the target: catch frame-breakers (login/video/solo open in a new tab or play in our own player)
|
||||
const fc = await frameCheck(b.targetUrl);
|
||||
if (!fc.ok) return json(res, 400, { error: fc.reason });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user