openvibers/openvibe.media Commits

Views owned by Media: visit-based view_count with per-visitor cooldown + unique_views (hashed visitors), owner/bot/rate-limit exclusions, playback counts once per session, app API /api/v1/:app/views, paste API counts app-rendered page views, legacy unique-IP import

OP OpenVibers committed 27 Aug 2026 · 718c63c4cb · 7 files, +241 −19


Changes

ddiff --git a/server/index.js b/server/index.js
iindex 90c39ff..ac86d27 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -64,6 +64,9 @@ app.get('/api/v1/:app/stats', auth.tenantAuth(), (req, res) => {
}
});
++try { require('./views/service').ensureSchema(); } catch (e) { console.warn('[Views] schema:', e.message); }
++setInterval(() => { try { const n = require('./views/service').prune(30); if (n) console.log(`[Views] pruned ${n} stale visit row(s)`); } catch { /* */ } }, 12 * 3600 * 1000);
++app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
app.use('/api/v1/:app/pastes', require('./pastes/routes'));
ddiff --git a/server/pastes/routes.js b/server/pastes/routes.js
iindex 9337a0e..1dcf411 100644
---- a/server/pastes/routes.js
++++ b/server/pastes/routes.js
@@@ -154,6 +154,8 @@ function screenshotUrl(paste) {
function pastePublic(paste) {
if (!paste) return null;
return {
++ unique_views: paste.unique_views || 0,
++
id: paste.id,
app_id: paste.app_id,
slug: paste.slug,
@@@ -435,6 +437,12 @@ router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
try {
const paste = _getPasteScoped(req, res);
if (!paste) return;
++ // The owning app renders the paste page itself — count the view here (real client
++ // IP via X-Forwarded-For, viewer via the user JWT / X-OV-User-Id) unless it says
++ // this fetch is not a page view (?no_view=1 for edit forms, embeds, bots).
++ if (String(req.query.no_view || '') !== '1' && !paste.burn_after_read) {
++ try { const r = require('../views/service').recordView('paste', paste.id, { req, ownerUserId: paste.user_id }); if (r.view_count != null) { paste.views = r.view_count; paste.unique_views = r.unique_views; } } catch { /* */ }
++ }
// Private pastes: owner (or the app itself) only.
if (paste.visibility === 'private') {
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex c18b433..078aa67 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -34,19 +34,12 @@ function getRequesterIp(req) {
return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.socket?.remoteAddress || 'unknown';
}
--function trackUniqueView(kind, id, req) {
-- try {
-- const ip = getRequesterIp(req);
-- const inserted = db.run(
-- 'INSERT OR IGNORE INTO content_views (content_type, content_id, ip) VALUES (?, ?, ?)',
-- [kind, id, ip]
-- );
-- if (inserted.changes > 0) {
-- const count = db.get('SELECT COUNT(*) as c FROM content_views WHERE content_type = ? AND content_id = ?', [kind, id]);
-- const table = kind === 'vod' ? 'vods' : 'clips';
-- db.run(`UPDATE ${table} SET view_count = ? WHERE id = ?`, [count.c, id]);
-- }
-- } catch { /* non-critical */ }
++const views = require('../views/service');
++// Views + unique views live in ../views/service (visit-based with a cooldown, hashed
++// visitors, owner/bot/rate-limit exclusions). A playback session counts once: only the
++// request for the first bytes, not every seek/range request.
++function trackUniqueView(kind, id, req, ownerUserId = null) {
++ try { if (views.isInitialPlaybackRequest(req)) views.recordView(kind, id, { req, ownerUserId }); } catch { /* non-critical */ }
}
function canAccessPrivate(record, req) {
@@@ -103,7 +96,7 @@ async function serveMediaRecord(kind, record, req, res) {
return res.status(403).json({ error: 'This media is private' });
}
-- trackUniqueView(kind, record.id, req);
++ trackUniqueView(kind, record.id, req, record.user_id);
// Track last access time for storage tier decisions
if (kind === 'vod') {
@@@ -452,7 +445,7 @@ function renderPastePage(paste) {
</header>
<main>
<h1>${title}</h1>
-- <p class="meta">${escapeHtml(paste.language || 'text')} · ${paste.views || 0} views · ${created}${isScreenshot ? '' : ` · <a href="/p/${escapeHtml(paste.slug)}/raw">raw</a>`}</p>
++ <p class="meta">${escapeHtml(paste.language || 'text')} · ${paste.views || 0} views · ${paste.unique_views || 0} unique · ${created}${isScreenshot ? '' : ` · <a href="/p/${escapeHtml(paste.slug)}/raw">raw</a>`}</p>
${body}
</main>
<footer>Shared via OpenVibe.Media — <a href="https://openvibe.network">One Account. All of OpenVibe.</a></footer>
@@@ -471,11 +464,12 @@ router.get('/p/:slug', optionalIdentity, (req, res) => {
return res.status(404).send('Paste not found');
}
-- // Increment view count (don't count the owner's views)
++ // Count the view (visit-based, cooldown, owner excluded). Burn-after-read pastes
++ // keep the literal every-read counter — one read is the whole point of them.
const isOwner = req.userId != null && paste.user_id === req.userId;
if (!isOwner) {
-- db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]);
-- paste.views += 1;
++ if (paste.burn_after_read) { db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]); paste.views += 1; }
++ else { const r = views.recordView('paste', paste.id, { req, ownerUserId: paste.user_id }); if (r.view_count != null) { paste.views = r.view_count; paste.unique_views = r.unique_views; } }
}
// Burn-after-read: allow one non-owner read, then delete.
@@@ -510,7 +504,8 @@ router.get('/p/:slug/raw', (req, res) => {
return res.status(410).send('This paste has been burned after reading.');
}
-- db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]);
++ if (paste.burn_after_read) db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]);
++ else views.recordView('paste', paste.id, { req, ownerUserId: paste.user_id });
res.type('text/plain').send(paste.content);
} catch {
res.status(500).send('Error');
ddiff --git a/server/views/routes.js b/server/views/routes.js
nnew file mode 100644
iindex 0000000..214ab10
---- /dev/null
++++ b/server/views/routes.js
@@@ -0,0 +1,46 @@
++/**
++ * OpenVibe.Media — app-facing view API (mounted at /api/v1/:app/views, app key).
++ *
++ * POST / { type: 'vod'|'clip'|'paste'|'file', id, ip?, user_id?, user_agent? }
++ * Apps that render their own watch/paste pages call this when the page
++ * opens. The real client IP travels in X-Forwarded-For (Live's proxy sets
++ * it) or `ip`; the viewer's user id in X-OV-User-Id or `user_id`.
++ * → { counted, unique, reason?, view_count, unique_views }
++ * GET /?type=vod&ids=1,2,3 → { counts: { "1": {view_count, unique_views}, ... } }
++ */
++'use strict';
++const express = require('express');
++const db = require('../db/database');
++const { tenantAuth } = require('../auth');
++const views = require('./service');
++
++const router = express.Router({ mergeParams: true });
++
++function ownerOf(type, id) {
++ const t = views.TABLES[type]; if (!t) return null;
++ try { const r = db.get(`SELECT user_id FROM ${t.table} WHERE id = ?`, [id]); return r ? r.user_id : null; } catch { return null; }
++}
++
++router.post('/', tenantAuth(), (req, res) => {
++ const body = req.body || {};
++ const type = String(body.type || '');
++ const id = parseInt(body.id, 10);
++ if (!views.TABLES[type] || !id) return res.status(400).json({ error: 'type (vod|clip|paste|file) and id required' });
++ // Deletion/tenancy guard: only the owning app may count views on its content.
++ const t = views.TABLES[type];
++ const row = db.get(`SELECT app_id, user_id FROM ${t.table} WHERE id = ?`, [id]);
++ if (!row) return res.status(404).json({ error: 'Not found' });
++ if (row.app_id && row.app_id !== req.appId) return res.status(403).json({ error: 'Not your content' });
++ const userId = body.user_id != null ? body.user_id : (req.userId != null ? req.userId : null);
++ const out = views.recordView(type, id, { req, ip: body.ip || undefined, userId, ownerUserId: row.user_id, userAgent: body.user_agent || undefined });
++ res.json(out);
++});
++
++router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
++ const type = String(req.query.type || '');
++ if (!views.TABLES[type]) return res.status(400).json({ error: 'type required' });
++ const ids = String(req.query.ids || '').split(',').map(x => parseInt(x, 10)).filter(n => Number.isInteger(n) && n > 0).slice(0, 200);
++ res.json({ type, counts: views.countsMany(type, ids) });
++});
++
++module.exports = router;
ddiff --git a/server/views/service.js b/server/views/service.js
nnew file mode 100644
iindex 0000000..e332984
---- /dev/null
++++ b/server/views/service.js
@@@ -0,0 +1,166 @@
++/**
++ * OpenVibe.Media — view counting (owned here for every app and every content type).
++ *
++ * Two numbers per item, both maintained by this module:
++ * view_count "views": one per VISIT. A visitor (logged-in user id, else a keyed hash of
++ * the client IP — raw IPs are never stored) counts again only after the
++ * cooldown window (media_settings.view_cooldown_sec, default 6h). Refresh
++ * spam, seeking (range requests) and reloads inside the window count once.
++ * unique_views distinct visitors, ever.
++ *
++ * Also: the owner's own views never count; obvious bots/crawlers are ignored; a single IP
++ * firing view events at an absurd rate (any content) is dropped after a per-minute budget.
++ *
++ * Entry points: recordView() from Media's own public routes (/v /c /p) and from the
++ * app-facing API (POST /api/v1/:app/views) that Live/Tools call for pages they render.
++ */
++'use strict';
++const crypto = require('crypto');
++const db = require('../db/database');
++
++const TABLES = { vod: { table: 'vods', raw: 'view_count' }, clip: { table: 'clips', raw: 'view_count' }, paste: { table: 'pastes', raw: 'views' }, file: { table: 'files', raw: 'view_count' } };
++const BOT_UA = /bot|crawl|spider|slurp|facebookexternalhit|preview|discordbot|twitterbot|whatsapp|telegram|curl\/|wget\/|python-requests|go-http-client|headless/i;
++
++let _secret = null;
++function secret() {
++ if (_secret) return _secret;
++ _secret = process.env.VIEW_HASH_SECRET || process.env.MEDIA_SECRET || process.env.INTERNAL_API_KEY || '';
++ if (!_secret) {
++ // Persist a random secret so visitor hashes stay stable across restarts.
++ try {
++ const row = db.get("SELECT value FROM media_settings WHERE key = 'view_hash_secret'");
++ if (row && row.value) _secret = row.value;
++ else { _secret = crypto.randomBytes(24).toString('hex'); db.run("INSERT OR REPLACE INTO media_settings (key, value, description, type) VALUES ('view_hash_secret', ?, 'Internal: salt for hashed visitor ids (do not share)', 'string')", [_secret]); }
++ } catch { _secret = 'openvibe-media'; }
++ }
++ return _secret;
++}
++function cooldownSec() { const v = parseInt(db.getSetting('view_cooldown_sec'), 10); return Number.isFinite(v) && v >= 0 ? v : 6 * 3600; }
++function rateLimitPerMin() { const v = parseInt(db.getSetting('view_ip_events_per_min'), 10); return Number.isFinite(v) && v > 0 ? v : 60; }
++
++function ensureSchema() {
++ db.getDb().exec(`
++ CREATE TABLE IF NOT EXISTS content_visits (
++ content_type TEXT NOT NULL, -- vod | clip | paste | file
++ content_id INTEGER NOT NULL,
++ visitor TEXT NOT NULL, -- 'u:<userId>' or 'ip:<hmac>'
++ first_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ last_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ visits INTEGER DEFAULT 1,
++ PRIMARY KEY (content_type, content_id, visitor)
++ );
++ CREATE INDEX IF NOT EXISTS idx_content_visits_item ON content_visits(content_type, content_id);
++ CREATE INDEX IF NOT EXISTS idx_content_visits_last ON content_visits(last_at);
++ `);
++ for (const [, t] of Object.entries(TABLES)) {
++ try {
++ const cols = db.getDb().prepare(`PRAGMA table_info(${t.table})`).all().map(c => c.name);
++ if (!cols.length) continue; // table absent in this deployment
++ if (!cols.includes('unique_views')) db.getDb().exec(`ALTER TABLE ${t.table} ADD COLUMN unique_views INTEGER DEFAULT 0`);
++ if (!cols.includes(t.raw)) db.getDb().exec(`ALTER TABLE ${t.table} ADD COLUMN ${t.raw} INTEGER DEFAULT 0`);
++ } catch (e) { console.warn(`[Views] ${t.table} columns:`, e.message); }
++ }
++ // One-time import of the legacy unique-IP table (raw IPs → hashed visitors) so existing
++ // counts survive; view_count on vods/clips already equals the unique count, so seed
++ // unique_views from it and leave view_count as the (identical) starting point.
++ try {
++ const done = db.get("SELECT value FROM media_settings WHERE key = 'views_migrated_v2'");
++ if (!done) {
++ const rows = db.all('SELECT content_type, content_id, ip, created_at FROM content_views');
++ const ins = db.getDb().prepare('INSERT OR IGNORE INTO content_visits (content_type, content_id, visitor, first_at, last_at, visits) VALUES (?, ?, ?, ?, ?, 1)');
++ const tx = db.getDb().transaction(() => { for (const r of rows) ins.run(r.content_type, r.content_id, visitorForIp(r.ip), r.created_at, r.created_at); });
++ tx();
++ db.run('UPDATE vods SET unique_views = (SELECT COUNT(*) FROM content_visits v WHERE v.content_type = \'vod\' AND v.content_id = vods.id)');
++ db.run('UPDATE clips SET unique_views = (SELECT COUNT(*) FROM content_visits v WHERE v.content_type = \'clip\' AND v.content_id = clips.id)');
++ db.run('UPDATE vods SET view_count = MAX(COALESCE(view_count,0), unique_views)');
++ db.run('UPDATE clips SET view_count = MAX(COALESCE(view_count,0), unique_views)');
++ db.run("INSERT OR REPLACE INTO media_settings (key, value, description, type) VALUES ('views_migrated_v2', '1', 'Internal: legacy content_views imported', 'boolean')");
++ console.log(`[Views] imported ${rows.length} legacy unique-view row(s)`);
++ }
++ } catch (e) { console.warn('[Views] legacy import:', e.message); }
++ try {
++ const seed = db.getDb().prepare('INSERT OR IGNORE INTO media_settings (key, value, description, type) VALUES (?, ?, ?, ?)');
++ seed.run('view_cooldown_sec', '21600', 'A visitor counts as a new view of the same item only after this many seconds (anti refresh-spam). 0 = every visit', 'number');
++ seed.run('view_ip_events_per_min', '60', 'Drop view events from an IP that fires more than this many per minute (across all content)', 'number');
++ } catch { /* */ }
++}
++
++function clientIp(req) {
++ return (req.headers['cf-connecting-ip'] || (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.ip || (req.socket && req.socket.remoteAddress) || 'unknown').toString();
++}
++function visitorForIp(ip) { return 'ip:' + crypto.createHmac('sha256', secret()).update(String(ip || 'unknown')).digest('hex').slice(0, 24); }
++
++// Per-IP event budget (sliding minute), in memory — protects the DB from a spammer, not a metric.
++const _ipEvents = new Map(); // ip → [timestamps]
++function _overBudget(ip) {
++ const now = Date.now(); const cut = now - 60000;
++ let arr = _ipEvents.get(ip) || [];
++ arr = arr.filter(t => t > cut);
++ if (arr.length >= rateLimitPerMin()) { _ipEvents.set(ip, arr); return true; }
++ arr.push(now); _ipEvents.set(ip, arr);
++ if (_ipEvents.size > 5000) for (const [k, v] of _ipEvents) if (!v.some(t => t > cut)) _ipEvents.delete(k);
++ return false;
++}
++
++/**
++ * Record a view.
++ * @param {'vod'|'clip'|'paste'|'file'} type
++ * @param {number} id
++ * @param {object} o { req?, ip?, userId?, ownerUserId?, userAgent? }
++ * @returns {{ counted:boolean, unique:boolean, reason?:string, view_count?:number, unique_views?:number }}
++ */
++function recordView(type, id, o = {}) {
++ const t = TABLES[type];
++ if (!t || !id) return { counted: false, unique: false, reason: 'bad-type' };
++ const req = o.req || null;
++ const ip = o.ip || (req ? clientIp(req) : 'unknown');
++ const ua = o.userAgent || (req && req.headers['user-agent']) || '';
++ const userId = o.userId != null ? o.userId : (req && req.userId != null ? req.userId : null);
++ if (userId != null && o.ownerUserId != null && String(userId) === String(o.ownerUserId)) return { counted: false, unique: false, reason: 'owner' };
++ if (BOT_UA.test(ua)) return { counted: false, unique: false, reason: 'bot' };
++ if (_overBudget(ip)) return { counted: false, unique: false, reason: 'rate-limited' };
++ const visitor = userId != null ? `u:${userId}` : visitorForIp(ip);
++ const cd = cooldownSec();
++ try {
++ const row = db.get('SELECT last_at, visits FROM content_visits WHERE content_type = ? AND content_id = ? AND visitor = ?', [type, id, visitor]);
++ if (!row) {
++ db.run('INSERT OR IGNORE INTO content_visits (content_type, content_id, visitor) VALUES (?, ?, ?)', [type, id, visitor]);
++ db.run(`UPDATE ${t.table} SET ${t.raw} = COALESCE(${t.raw},0) + 1, unique_views = COALESCE(unique_views,0) + 1 WHERE id = ?`, [id]);
++ return { counted: true, unique: true, ...counts(type, id) };
++ }
++ const lastMs = new Date(String(row.last_at).replace(' ', 'T') + 'Z').getTime();
++ if (cd > 0 && Date.now() - lastMs < cd * 1000) return { counted: false, unique: false, reason: 'cooldown', ...counts(type, id) };
++ db.run('UPDATE content_visits SET last_at = CURRENT_TIMESTAMP, visits = visits + 1 WHERE content_type = ? AND content_id = ? AND visitor = ?', [type, id, visitor]);
++ db.run(`UPDATE ${t.table} SET ${t.raw} = COALESCE(${t.raw},0) + 1 WHERE id = ?`, [id]);
++ return { counted: true, unique: false, ...counts(type, id) };
++ } catch (e) {
++ console.warn('[Views] record failed:', e.message);
++ return { counted: false, unique: false, reason: 'error' };
++ }
++}
++
++function counts(type, id) {
++ const t = TABLES[type]; if (!t) return {};
++ const r = db.get(`SELECT COALESCE(${t.raw},0) AS view_count, COALESCE(unique_views,0) AS unique_views FROM ${t.table} WHERE id = ?`, [id]);
++ return r || { view_count: 0, unique_views: 0 };
++}
++function countsMany(type, ids) {
++ const t = TABLES[type]; if (!t || !ids.length) return {};
++ const rows = db.all(`SELECT id, COALESCE(${t.raw},0) AS view_count, COALESCE(unique_views,0) AS unique_views FROM ${t.table} WHERE id IN (${ids.map(() => '?').join(',')})`, ids);
++ const out = {}; for (const r of rows) out[r.id] = { view_count: r.view_count, unique_views: r.unique_views };
++ return out;
++}
++
++/** Playback requests: only the first byte-range of a session should count. */
++function isInitialPlaybackRequest(req) {
++ if (req.method !== 'GET') return false;
++ const range = req.headers.range;
++ return !range || /^bytes=0-/.test(String(range));
++}
++
++/** Old visit rows are only needed for the cooldown; keep the table small (uniques stay in the counters). */
++function prune(days = 30) {
++ try { return db.run("DELETE FROM content_visits WHERE last_at < datetime('now', ?)", [`-${days} days`]).changes; } catch { return 0; }
++}
++
++module.exports = { ensureSchema, recordView, counts, countsMany, isInitialPlaybackRequest, clientIp, prune, TABLES };
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex 4aac181..728a74a 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -56,6 +56,8 @@ const clipUpload = multer({
function clipPublic(clip) {
if (!clip) return null;
return {
++ unique_views: clip.unique_views || 0,
++
id: clip.id,
app_id: clip.app_id,
vod_id: clip.vod_id,
ddiff --git a/server/vod/finalize.js b/server/vod/finalize.js
iindex 9c1c53e..20869d0 100644
---- a/server/vod/finalize.js
++++ b/server/vod/finalize.js
@@@ -37,6 +37,8 @@ function isFinalizing(vodId) {
function vodPublic(vod) {
if (!vod) return null;
return {
++ unique_views: vod.unique_views || 0,
++
id: vod.id,
app_id: vod.app_id,
stream_id: vod.stream_id,