openvibers/openvibe.media Commits
Zero-VOD prevention, chat-asset registry, AI fields in responses, navbar on index
Changes
nnew file mode 100644@@@ -0,0 +1,97 @@++/**++ * OpenVibe.Media — app asset API (emotes / channel sounds).++ * Mounted at /api/v1/:app/assets, app-key auth only: the owning app syncs its++ * chat assets here so Media is their canonical public home (served at /a/:id,++ * browsable on the media index with uploader + channel attribution).++ *++ * POST / multipart `file` + { kind, name, username?, user_id?,++ * channel_username?, duration_seconds?, meta? } → upsert++ * GET /?kind= list++ * DELETE /:id++ */++'use strict';++++const express = require('express');++const path = require('path');++const fs = require('fs');++const crypto = require('crypto');++const multer = require('multer');++const config = require('../config');++const db = require('../db/database');++const { tenantAuth } = require('../auth');++++const ASSET_DIR = config.assets.path;++if (!fs.existsSync(ASSET_DIR)) fs.mkdirSync(ASSET_DIR, { recursive: true });++++const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });++const router = express.Router({ mergeParams: true });++++const assetUrl = (a) => `${config.publicUrl}/a/${a.id}`;++function assetPublic(a) {++ return {++ id: a.id, app_id: a.app_id, kind: a.kind, name: a.name,++ url: assetUrl(a), mime: a.mime,++ user_id: a.user_id, username: a.username, channel_username: a.channel_username,++ duration_seconds: a.duration_seconds || 0, created_at: a.created_at,++ };++}++++router.post('/', tenantAuth(), upload.single('file'), (req, res) => {++ try {++ const { kind, name } = req.body || {};++ if (!['emote', 'sound'].includes(kind)) return res.status(400).json({ error: "kind must be 'emote' or 'sound'" });++ if (!name || !req.file) return res.status(400).json({ error: 'name and file are required' });++ const ext = (path.extname(req.file.originalname || '') || '').toLowerCase().slice(0, 8) || '.bin';++ const filename = `${kind}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;++ const filePath = path.join(ASSET_DIR, filename);++ fs.writeFileSync(filePath, req.file.buffer);++ let meta = {};++ try { meta = req.body.meta ? JSON.parse(req.body.meta) : {}; } catch { /* */ }++ const prev = db.get('SELECT file_path FROM assets WHERE app_id = ? AND kind = ? AND name = ? AND channel_username = ?',++ [req.appId, kind, String(name), String(req.body.channel_username || '')]);++ const row = db.upsertAsset({++ app_id: req.appId, kind, name: String(name).slice(0, 100),++ file_path: filePath, mime: req.file.mimetype || 'application/octet-stream',++ user_id: req.body.user_id != null ? parseInt(req.body.user_id, 10) || null : null,++ username: String(req.body.username || '').slice(0, 60),++ channel_username: String(req.body.channel_username || '').slice(0, 60),++ duration_seconds: parseFloat(req.body.duration_seconds) || 0,++ meta,++ });++ // Replaced an earlier upload for the same identity → drop the old file.++ if (prev && prev.file_path && prev.file_path !== filePath && fs.existsSync(prev.file_path)) {++ try { fs.unlinkSync(prev.file_path); } catch { /* */ }++ }++ res.status(201).json({ asset: assetPublic(row) });++ } catch (err) {++ console.error('[Assets] upload error:', err.message);++ res.status(500).json({ error: 'Failed to store asset' });++ }++});++++router.get('/', tenantAuth(), (req, res) => {++ try {++ const limit = Math.min(Math.max(parseInt(req.query.limit || '200', 10), 1), 1000);++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);++ const kind = ['emote', 'sound'].includes(req.query.kind) ? req.query.kind : null;++ const assets = db.listAssets(req.appId, { kind, limit, offset }).map(assetPublic);++ res.json({ assets, total: db.countAssets(req.appId, { kind }), limit, offset });++ } catch (err) {++ res.status(500).json({ error: 'Failed to list assets' });++ }++});++++router.delete('/:id', tenantAuth(), (req, res) => {++ try {++ const a = db.getAssetById(parseInt(req.params.id, 10));++ if (!a || a.app_id !== req.appId) return res.status(404).json({ error: 'Not found' });++ try { if (a.file_path && fs.existsSync(a.file_path)) fs.unlinkSync(a.file_path); } catch { /* */ }++ db.deleteAsset(a.id);++ res.json({ success: true });++ } catch (err) {++ res.status(500).json({ error: 'Failed to delete asset' });++ }++});++++module.exports = router;++module.exports.assetPublic = assetPublic;@@@ -32,6 +32,9 @@ const config = {pastes: {path: process.env.PASTES_PATH || './data/pastes',},++ assets: {++ path: process.env.ASSETS_PATH || './data/assets',++ },thumbnails: {path: process.env.THUMBNAILS_PATH || './data/thumbnails',},@@@ -65,6 +68,7 @@ for (const p of ['path']) {}config.vod.clipsPath = path.resolve(config.vod.clipsPath);config.pastes.path = path.resolve(config.pastes.path);++config.assets.path = path.resolve(config.assets.path);config.thumbnails.path = path.resolve(config.thumbnails.path);config.files.path = path.resolve(config.files.path);config.db.path = path.resolve(config.db.path);@@@ -532,6 +532,34 @@ function getFileByKey(key, appId = null) {return get(`SELECT * FROM files WHERE key = ?${clause}`, params);}++// ── App assets (emotes / channel sounds) ─────────────────────++function upsertAsset({ app_id, kind, name, file_path, mime, user_id, username, channel_username, duration_seconds, meta }) {++ const info = run(`++ INSERT INTO assets (app_id, kind, name, file_path, mime, user_id, username, channel_username, duration_seconds, meta_json)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT(app_id, kind, name, channel_username) DO UPDATE SET++ file_path = excluded.file_path, mime = excluded.mime,++ user_id = excluded.user_id, username = excluded.username,++ duration_seconds = excluded.duration_seconds, meta_json = excluded.meta_json`,++ [app_id, kind, name, file_path, mime || 'application/octet-stream', user_id ?? null,++ username || '', channel_username || '', duration_seconds || 0, JSON.stringify(meta || {})]);++ return get('SELECT * FROM assets WHERE app_id = ? AND kind = ? AND name = ? AND channel_username = ?',++ [app_id, kind, name, channel_username || '']);++}++function getAssetById(id) { return get('SELECT * FROM assets WHERE id = ?', [id]); }++function listAssets(appId, { kind = null, limit = 100, offset = 0 } = {}) {++ const conds = ['app_id = ?']; const params = [appId];++ if (kind) { conds.push('kind = ?'); params.push(kind); }++ params.push(limit, offset);++ return all(`SELECT * FROM assets WHERE ${conds.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, params);++}++function countAssets(appId, { kind = null } = {}) {++ const conds = ['app_id = ?']; const params = [appId];++ if (kind) { conds.push('kind = ?'); params.push(kind); }++ return get(`SELECT COUNT(*) c FROM assets WHERE ${conds.join(' AND ')}`, params).c;++}++function deleteAsset(id) { return run('DELETE FROM assets WHERE id = ?', [id]); }++function listFiles(appId, { limit = 100, offset = 0 } = {}) {return all('SELECT * FROM files WHERE app_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', [appId, limit, offset]);}@@@ -585,6 +613,7 @@ module.exports = {// vodscreateVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,latestVodThumbsByManagedStreams, getAppStats,++ upsertAsset, getAssetById, listAssets, countAssets, deleteAsset,updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,// clipscreateClip, getClipById, getClipByFileBasename, listClips, countClips, setClipVisibility, findDuplicateClip,@@@ -182,3 +182,23 @@ CREATE TABLE IF NOT EXISTS media_settings (type TEXT DEFAULT 'string',updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);++++-- ── App assets (emotes / channel sounds) ─────────────────────++-- Canonical public home for per-channel chat assets the apps upload; browse++-- index shows uploader + channel. Unique per (app, kind, name, channel).++CREATE TABLE IF NOT EXISTS assets (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ app_id TEXT NOT NULL DEFAULT 'live',++ kind TEXT NOT NULL, -- 'emote' | 'sound'++ name TEXT NOT NULL, -- emote code or !command++ file_path TEXT NOT NULL,++ mime TEXT DEFAULT 'application/octet-stream',++ user_id INTEGER, -- uploader (app-local id)++ username TEXT DEFAULT '', -- uploader name snapshot++ channel_username TEXT DEFAULT '', -- channel it belongs to++ duration_seconds REAL DEFAULT 0,++ meta_json TEXT DEFAULT '{}',++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP++);++CREATE INDEX IF NOT EXISTS idx_assets_app_kind ON assets(app_id, kind, created_at DESC);++CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_identity ON assets(app_id, kind, name, channel_username);@@@ -69,6 +69,7 @@ app.use('/api/v1/:app/clips', require('./vod/clips-routes'));app.use('/api/v1/:app/pastes', require('./pastes/routes'));app.use('/api/v1/:app/files', require('./files/routes'));app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));++app.use('/api/v1/:app/assets', require('./assets/routes'));app.use('/api/v1/:app/admin/storage', require('./admin/routes'));app.use('/', require('./public/routes')); // /v /c /p /t /f@@@ -173,6 +173,9 @@ function pastePublic(paste) {copies: paste.copies || 0,likes: paste.likes || 0,is_nsfw: !!paste.is_nsfw,++ ai_summary: paste.ai_summary || null,++ ai_tags: paste.ai_tags || null,++ ai_analyzed_at: paste.ai_analyzed_at || null,url: `/p/${paste.slug}`,raw_url: `/p/${paste.slug}/raw`,created_at: paste.created_at,@@@ -73,6 +73,20 @@ function fileCard(f) {source: null,};}++function assetCard(a) {++ const isEmote = a.kind === 'emote';++ const channel = a.channel_username || a.username || '';++ return {++ kind: a.kind, title: isEmote ? a.name : `!${a.name}`, date: a.created_at, app: a.app_id,++ thumb: isEmote ? `${config.publicUrl}/a/${a.id}` : null,++ duration: !isEmote ? a.duration_seconds : null,++ text: `uploaded by ${a.username || 'unknown'}${channel ? ` · channel @${channel}` : ''}`,++ view: `${config.publicUrl}/a/${a.id}`,++ source: channel ? `${appUrl(a.app_id)}/@${encodeURIComponent(channel)}` : null,++ sourceLabel: channel ? `@${channel}'s channel` : null,++ };++}++function thumbCard(name, mtimeMs) {const m = /^(vod|clip|stream)-(\d+)/.exec(name);let source = null, sourceLabel = null;@@@ -133,6 +147,14 @@ function fetchTab(tab, page) {total: db.get('SELECT COUNT(*) c FROM files').c,cards: db.all(`SELECT * FROM files ${L}`).map(fileCard),};++ case 'emotes': return {++ total: db.get("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'").c,++ cards: db.all(`SELECT * FROM assets WHERE kind = 'emote' ${L}`).map(assetCard),++ };++ case 'sounds': return {++ total: db.get("SELECT COUNT(*) c FROM assets WHERE kind = 'sound'").c,++ cards: db.all(`SELECT * FROM assets WHERE kind = 'sound' ${L}`).map(assetCard),++ };case 'thumbnails': {const list = thumbList();return { total: list.length, cards: list.slice(off, off + PAGE_SIZE).map(([f, t]) => thumbCard(f, t)) };@@@ -144,13 +166,17 @@ function fetchTab(tab, page) {SELECT 'clip', id, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM clips WHERE ${C_WHERE}UNION ALLSELECT 'paste', id, title, NULL, NULL, NULL, NULL, created_at, app_id, slug, type, substr(COALESCE(content,''),1,300), language FROM pastes WHERE ${P_WHERE}++ UNION ALL++ SELECT 'asset', id, name, username, channel_username, kind, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM assetsORDER BY created_at DESC LIMIT ${PAGE_SIZE} OFFSET ${off}`);const total = db.get(`SELECT(SELECT COUNT(*) FROM vods WHERE ${V_WHERE}) +(SELECT COUNT(*) FROM clips WHERE ${C_WHERE}) +-- (SELECT COUNT(*) FROM pastes WHERE ${P_WHERE}) c`).c;++ (SELECT COUNT(*) FROM pastes WHERE ${P_WHERE}) +++ (SELECT COUNT(*) FROM assets) c`).c;const cards = rows.map(r => r.k === 'vod' ? vodCard({ ...r, id: r.ref }): r.k === 'clip' ? clipCard({ ...r, id: r.ref })++ : r.k === 'asset' ? assetCard({ id: r.ref, name: r.title, username: r.description, channel_username: r.ai_overview, kind: r.thumbnail_url, duration_seconds: r.duration_seconds, created_at: r.created_at, app_id: r.app_id }): pasteCard({ ...r, id: r.ref }));return { total, cards };}@@@ -167,9 +193,11 @@ function tabCounts() {images: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`),text: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`),files: c('SELECT COUNT(*) c FROM files'),++ emotes: c("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'"),++ sounds: c("SELECT COUNT(*) c FROM assets WHERE kind = 'sound'"),thumbnails: thumbList().length,};-- counts.all = counts.videos + counts.clips + counts.images + counts.text;++ counts.all = counts.videos + counts.clips + counts.images + counts.text + counts.emotes + counts.sounds;_countCache = { at: Date.now(), counts };return counts;}@@@ -196,7 +224,7 @@ function renderCard(c) {</div>`;}--const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['images', 'Images'], ['text', 'Text'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];++const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['images', 'Images'], ['text', 'Text'], ['emotes', 'Emotes'], ['sounds', 'Sounds'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];function renderPage(tab, page, data, counts) {const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));@@@ -208,7 +236,7 @@ function renderPage(tab, page, data, counts) {<style>:root{--bg:#0f1420;--panel:#161c2c;--border:#232b40;--text:#e6e9f2;--muted:#9aa3b8;--accent:#8b5cf6}*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:15px/1.5 system-ui,'Segoe UI',Arial,sans-serif;padding-bottom:3rem}--header{display:flex;align-items:center;gap:.9rem;padding:1rem 1.4rem;border-bottom:1px solid var(--border);position:sticky;top:0;background:rgba(15,20,32,.92);backdrop-filter:blur(8px);z-index:2}++header{display:flex;align-items:center;gap:.9rem;padding:1rem 1.4rem;border-bottom:1px solid var(--border);background:rgba(15,20,32,.92)}header .logo{width:30px;height:30px}header h1{font-size:1.15rem}header h1 b{color:var(--accent)}header .sub{color:var(--muted);font-size:.82rem;margin-left:auto}@@@ -232,7 +260,11 @@ nav a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}.pg{color:var(--accent);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--border);border-radius:8px}.pg.dis{color:var(--muted);opacity:.4}.empty{padding:3rem;text-align:center;color:var(--muted)}--</style></head><body>++</style>++<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">++</head><body>++<script src="https://openvibe.network/shared/navbar.js"></script>++<script>try { OpenVibeNavbar.init({ service: 'media' }); } catch (e) { /* navbar optional */ }</script><header><svg class="logo" viewBox="0 0 100 100" fill="none" stroke="#8b5cf6" stroke-width="7"><circle cx="28" cy="66" r="11"/><circle cx="72" cy="66" r="11"/><circle cx="50" cy="30" r="11"/><line x1="34" y1="58" x2="45" y2="40"/><line x1="66" y1="58" x2="55" y2="40"/><line x1="39" y1="66" x2="61" y2="66"/></svg><h1>OpenVibe<b>.Media</b></h1>@@@ -249,6 +249,24 @@ router.get('/live/:sel/frame.jpg', async (req, res) => {}});++// ── App assets (emotes / sounds) by id ───────────────────────++router.get('/a/:id', (req, res) => {++ try {++ const a = db.getAssetById(parseInt(req.params.id, 10));++ if (!a || !a.file_path || !fs.existsSync(a.file_path)) return res.status(404).json({ error: 'Not found' });++ // Content is replaced under the same URL on re-upload — cache a day, not immutable.++ streamFileWithRange(req, res, a.file_path, {++ 'Content-Type': a.mime || 'application/octet-stream',++ 'Cache-Control': 'public, max-age=86400',++ 'Access-Control-Allow-Origin': '*',++ 'X-Robots-Tag': 'noindex',++ });++ } catch (err) {++ console.error('[Public] /a error:', err.message);++ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve asset' });++ }++});++// ── Media index home page ────────────────────────────────────// GET / — server-rendered mass index of all public media (tabs: All / Videos /// Clips / Images / Text / Thumbnails / Files), each card linking back to its@@@ -78,6 +78,8 @@ function clipPublic(clip) {is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',storage_provider: clip.storage_provider || 'local',auto_generated: !!clip.auto_generated,++ ai_overview: clip.ai_overview || null,++ ai_analyzed_at: clip.ai_analyzed_at || null,view_count: clip.view_count || 0,created_at: clip.created_at,};@@@ -300,7 +302,8 @@ router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {const clip = _getClipScoped(req, res);if (!clip) return;// Bare object, mirroring GET /vods/:id per CONTRACTS.md.-- res.json(clipPublic(clip));++ // Detail responses carry the transcript too (lists stay light).++ res.json({ ...clipPublic(clip), ai_transcript: clip.ai_transcript || null });} catch (err) {res.status(500).json({ error: 'Failed to get clip' });}@@@ -64,6 +64,8 @@ function vodPublic(vod) {health_status: vod.health_status,clips_only: !!vod.clips_only,is_recording: !!vod.is_recording,++ ai_overview: vod.ai_overview || null,++ ai_analyzed_at: vod.ai_analyzed_at || null,view_count: vod.view_count || 0,created_at: vod.created_at,meta: (() => { try { return JSON.parse(vod.meta_json || '{}'); } catch { return {}; } })(),@@@ -154,10 +156,13 @@ async function _doFinalize(vodId, opts) {}if (!filePath || !fs.existsSync(filePath)) {-- db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);-- const failed = db.getVodById(vodId);-- _webhookForVod(failed, 'vod.failed');-- return failed;++ // No media was ever written (ingest started but produced nothing, or the++ // process died before ffmpeg opened the file). Nothing to review — delete++ // the row so a 0:00 ghost never reaches listings.++ console.warn(`[VOD] vod ${vodId}: no recording file — deleting empty row`);++ _webhookForVod(vod, 'vod.failed');++ try { db.run('DELETE FROM vods WHERE id = ?', [vodId]); } catch { /* */ }++ return null;}// Merge any pending browser-chunk segments before remuxing.@@@ -223,9 +223,39 @@ async function _tick() {}}++// One boot-time sweep for 0:00 ghost VODs that older failure paths marked++// ready+public (finalize crash fallbacks, killed processes). Rows with no++// media at all are deleted; short-but-nonempty ones are quarantined.++function sweepJunkVods() {++ try {++ const fs = require('fs');++ const rows = db.all(`SELECT id, file_path, file_size, duration_seconds FROM vods++ WHERE is_recording = 0 AND is_public = 1++ AND COALESCE(duration_seconds, 0) < 3++ AND COALESCE(file_size, 0) < 10000000++ AND (storage_provider IS NULL OR storage_provider = 'local')++ AND COALESCE(health_status, 'unknown') IN ('unknown', '')`);++ let deleted = 0, quarantined = 0;++ for (const v of rows) {++ const hasFile = v.file_path && fs.existsSync(v.file_path) && (() => { try { return fs.statSync(v.file_path).size > 0; } catch { return false; } })();++ if (!hasFile) {++ try { if (v.file_path && fs.existsSync(v.file_path)) fs.unlinkSync(v.file_path); } catch { /* */ }++ db.run('DELETE FROM vods WHERE id = ?', [v.id]);++ deleted++;++ } else {++ db.run(`UPDATE vods SET health_status = 'needs_review', health_issues_json = ?, quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,++ [JSON.stringify(['short_duration']), v.id]);++ quarantined++;++ }++ }++ if (deleted || quarantined) console.log(`[VOD-Health] Junk sweep: deleted ${deleted} empty VODs, quarantined ${quarantined} short ones`);++ } catch (e) { console.warn('[VOD-Health] junk sweep error:', e.message); }++}++function start() {if (_running) return;_running = true;++ sweepJunkVods();// First pass shortly after boot (idle window), then on the interval.const first = setTimeout(() => { _tick().catch(() => {}); }, 90 * 1000);if (first.unref) first.unref();@@@ -257,7 +257,11 @@ class StreamRecorder {const filePath = rec?.filePath;this._teardown(vodId);require('./finalize').finalizeVod(vodId, { startTimeMs: rec?.startTime }).catch(() => {-- db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);++ // Never bare-mark a failed recording as ready — that published++ // 0:00 ghosts. Quarantine it out of listings instead.++ db.run(`UPDATE vods SET is_recording = 0, health_status = 'needs_review',++ health_issues_json = ?, quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,++ [JSON.stringify(['finalize_failed']), vodId]);});if (filePath && !fs.existsSync(filePath)) this._cleanupFailedVod(vodId, filePath);});@@@ -319,7 +319,8 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {}// Bare object per CONTRACTS.md: { id, title, status, duration, … }-- res.json(vodPublic(vod));++ // Detail responses carry the transcript too (lists stay light).++ res.json({ ...vodPublic(vod), ai_transcript: vod.ai_transcript || null });} catch (err) {console.error('[VOD] Get error:', err.message);res.status(500).json({ error: 'Failed to get VOD' });@@@ -3,12 +3,17 @@// Consistent top bar across all services with logo, navigation,// notification bell, account switcher, and theme-aware styling.// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })++// Optional: loginUrl (override the Sign In href), sessionUrl (same-origin++// endpoint returning { user } — used only when no ov_token exists),++// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves++// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then++// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.// ═══════════════════════════════════════════════════════════════(function (root) {'use strict';-- let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null };++ let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null };let _navEl = null;function injectStyles() {@@@ -362,6 +367,9 @@],media: [{ label: 'Home', href: '/' },++ { label: 'Network', href: 'https://openvibe.network' },++ { label: 'Live', href: 'https://openvibe.live' },++ { label: 'Tools', href: 'https://openvibe.tools' },],network: [{ label: 'Home', href: '/' },@@@ -429,6 +437,200 @@try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }}++ // ─── SSO auth resolution ───────────────────────────────────++ // Cookie policy (CONTRACTS.md): ov_token is host-only on openvibe.network,++ // and Domain=.openvibe.tools on the tools apex + every tool subdomain.++ // The tools gateway (apex) owns the OAuth client: /auth/login, /auth/logout,++ // /auth/me, POST /auth/refresh. Satellite subdomains (maps., audio., text.,++ // img., yt., docs., food., …) do NOT mount /auth/* — they only read the++ // shared cookie — so sign-in/out always goes through the gateway apex.++ // Detection order: ov_token cookie → localStorage → page-provided token →++ // app-specific session endpoint (opt-in via config.sessionUrl).++ const TOOLS_APEX = 'openvibe.tools';++ const TOKEN_KEY = 'ov_token';++++ function currentHost() {++ return (typeof location !== 'undefined' && location.hostname) || '';++ }++++ function onToolsDomain(host) {++ const h = host !== undefined ? host : currentHost();++ return h === TOOLS_APEX || h.endsWith('.' + TOOLS_APEX);++ }++++ function onNetworkDomain(host) {++ const h = host !== undefined ? host : currentHost();++ try { return h !== '' && h === new URL(_config.apiBase).hostname; } catch { return false; }++ }++++ function parseCookieToken(cookieStr) {++ const m = String(cookieStr || '').match(/(?:^|;\s*)ov_token=([^;]*)/);++ if (!m || !m[1]) return null;++ try { return decodeURIComponent(m[1]); } catch { return m[1]; }++ }++++ function getCookieToken() {++ if (typeof document === 'undefined') return null;++ return parseCookieToken(document.cookie);++ }++++ function getStoredToken() {++ try { return localStorage.getItem(TOKEN_KEY) || null; } catch { return null; }++ }++++ function resolveToken() {++ return getCookieToken() || getStoredToken() || _config.token || null;++ }++++ /** Where "Sign In" goes on this host. */++ function resolveLoginHref(host, returnUrl) {++ if (_config.loginUrl) return _config.loginUrl;++ if (onToolsDomain(host)) {++ // Every *.openvibe.tools host signs in through the gateway apex: it++ // holds the OAuth state cookie + redirect_uri (both apex-host-only)++ // and sets ov_token with Domain=.openvibe.tools, so the session++ // reaches all satellites. A host-local /auth/login would 404 on++ // satellites and break the OAuth state check on gateway subdomains.++ return `https://${TOOLS_APEX}/auth/login?next=${encodeURIComponent(returnUrl)}`;++ }++ if (onNetworkDomain(host)) return `/login?return=${encodeURIComponent(returnUrl)}`;++ return `${_config.apiBase}/login?return=${encodeURIComponent(returnUrl)}`;++ }++++ function setAuthCookie(token) {++ if (typeof document === 'undefined') return;++ const maxAge = 24 * 60 * 60; // matches the 24h access JWT++ if (onToolsDomain()) {++ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};domain=.${TOOLS_APEX};SameSite=Lax;Secure`;++ } else {++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';++ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};SameSite=Lax${secure}`;++ }++ }++++ function clearAuthState() {++ if (typeof document !== 'undefined') {++ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';++ if (onToolsDomain()) {++ document.cookie = `ov_token=;path=/;max-age=0;domain=.${TOOLS_APEX};SameSite=Lax`;++ }++ }++ try {++ localStorage.removeItem(TOKEN_KEY);++ localStorage.removeItem('openvibe_anon_token');++ localStorage.removeItem('openvibe_active_account');++ } catch { /* storage unavailable */ }++ }++++ function persistToken(token) {++ try { localStorage.setItem(TOKEN_KEY, token); } catch { /* storage unavailable */ }++ setAuthCookie(token);++ }++++ /** GET {apiBase}/api/auth/me with the Bearer token. */++ async function fetchMe(token) {++ try {++ const res = await fetch(`${_config.apiBase}/api/auth/me`, {++ headers: { Authorization: `Bearer ${token}` },++ });++ if (res.ok) {++ const data = await res.json();++ return { ok: true, user: (data && (data.user || data)) || null };++ }++ return { ok: false, unauthorized: res.status === 401 || res.status === 403 };++ } catch {++ return { ok: false, unauthorized: false };++ }++ }++++ /** Same-origin session refresh — mounted by the tools gateway (POST /auth/refresh). */++ async function gatewayRefresh() {++ try {++ const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'same-origin' });++ if (!res.ok) return null;++ const data = await res.json();++ return data && data.token ? data : null;++ } catch { return null; }++ }++++ /** Network-side refresh — accepts tokens expired up to 7 days ago (grace). */++ async function networkRefresh(token) {++ try {++ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {++ method: 'POST',++ headers: { Authorization: `Bearer ${token}` },++ });++ if (!res.ok) return null;++ const data = await res.json();++ return data && data.token ? data : null;++ } catch { return null; }++ }++++ /**++ * Resolve the signed-in user from the shared SSO state.++ * With a token: validate it against the Network. If it is rejected, make++ * ONE refresh attempt (same-origin gateway refresh first on tools hosts,++ * then the Network's grace refresh) before giving up — never leave a++ * stale "Sign In" when a recoverable session exists.++ */++ async function resolveSessionUser() {++ const token = resolveToken();++ if (token) {++ const me = await fetchMe(token);++ if (me.ok && me.user) return { user: me.user, token };++ if (me.unauthorized) {++ if (onToolsDomain()) {++ const r = await gatewayRefresh();++ if (r) {++ const user = r.user || (await fetchMe(r.token)).user;++ if (user) { persistToken(r.token); return { user, token: r.token }; }++ }++ }++ const n = await networkRefresh(token);++ if (n) {++ const user = n.user || (await fetchMe(n.token)).user;++ if (user) { persistToken(n.token); return { user, token: n.token }; }++ }++ }++ return null;++ }++ // No token anywhere — ask the page's own session endpoint if it has one++ if (_config.sessionUrl) {++ try {++ const res = await fetch(_config.sessionUrl, { credentials: 'include' });++ if (res.ok) {++ const data = await res.json();++ const user = (data && (data.user || data)) || null;++ if (user && (user.username || user.id)) return { user, token: null };++ }++ } catch { /* signed out */ }++ }++ return null;++ }++++ let _authInFlight = null;++++ function refreshAuthState() {++ if (_authInFlight) return _authInFlight;++ _authInFlight = resolveSessionUser()++ .then((session) => {++ _authInFlight = null;++ if (session && session.user) {++ _config.user = session.user;++ if (session.token) _config.token = session.token;++ render();++ }++ try {++ document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {++ detail: { user: session ? session.user : null, token: session ? session.token : null },++ }));++ } catch { /* non-DOM environment */ }++ return session ? session.user : null;++ })++ .catch(() => { _authInFlight = null; return null; });++ return _authInFlight;++ }++function escapeAttr(value) {return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"');}@@@ -497,8 +699,8 @@const u = _config.user;const accounts = getAccounts();const isAnon = u && u.is_anon;-- const loginHref = `https://openvibe.network/login?return=${encodeURIComponent(window.location.href)}`;-- const addAccountHref = `https://openvibe.network/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;++ const loginHref = resolveLoginHref(currentHost(), window.location.href);++ const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;nav.innerHTML = `<a class="openvibe-navbar-brand" href="${brandHref}">@@@ -588,12 +790,16 @@dropdown.classList.remove('open');if (_config.onLogout) _config.onLogout();else {-- document.cookie = 'ov_token=;path=/;max-age=0';-- document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools';-- localStorage.removeItem('ov_token');-- localStorage.removeItem('openvibe_anon_token');-- localStorage.removeItem('openvibe_active_account');-- window.location.reload();++ clearAuthState();++ _config.user = null;++ _config.token = null;++ if (onToolsDomain()) {++ // The gateway also clears the Domain=.openvibe.tools cookie++ // and the httpOnly refresh cookie, then sends us back here.++ window.location.href = `https://${TOOLS_APEX}/auth/logout?next=${encodeURIComponent(window.location.href)}`;++ } else {++ window.location.reload();++ }}});} else {@@@ -621,9 +827,19 @@init(opts = {}) {Object.assign(_config, opts);injectStyles();-- return render();++ const el = render();++ // Pages that hand us a resolved user (openvibe.network, the tools++ // gateway hub) keep full control. Everyone else — pages that pass++ // only a token, or nothing at all — gets the user resolved from++ // the shared SSO state (ov_token cookie / localStorage / optional++ // sessionUrl) and the navbar re-renders when it arrives.++ if (!_config.user) refreshAuthState();++ return el;},++ /** Re-resolve the signed-in user from the shared SSO state. */++ refreshAuth() { return refreshAuthState(); },++/** Update user (after account switch). */setUser(user) {_config.user = user;@@@ -643,6 +859,18 @@_navEl?.remove();_navEl = null;},++++ /** Internal auth helpers — exposed for tests, not a public API. */++ _auth: {++ parseCookieToken,++ onToolsDomain,++ onNetworkDomain,++ resolveLoginHref,++ resolveToken,++ resolveSessionUser,++ clearAuthState,++ persistToken,++ },};if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+/**
+ * OpenVibe.Media — app asset API (emotes / channel sounds).
+ * Mounted at /api/v1/:app/assets, app-key auth only: the owning app syncs its
+ * chat assets here so Media is their canonical public home (served at /a/:id,
+ * browsable on the media index with uploader + channel attribution).
+ *
+ * POST / multipart `file` + { kind, name, username?, user_id?,
+ * channel_username?, duration_seconds?, meta? } → upsert
+ * GET /?kind= list
+ * DELETE /:id
+ */
+'use strict';
+
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const multer = require('multer');
+const config = require('../config');
+const db = require('../db/database');
+const { tenantAuth } = require('../auth');
+
+const ASSET_DIR = config.assets.path;
+if (!fs.existsSync(ASSET_DIR)) fs.mkdirSync(ASSET_DIR, { recursive: true });
+
+const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
+const router = express.Router({ mergeParams: true });
+
+const assetUrl = (a) => `${config.publicUrl}/a/${a.id}`;
+function assetPublic(a) {
+ return {
+ id: a.id, app_id: a.app_id, kind: a.kind, name: a.name,
+ url: assetUrl(a), mime: a.mime,
+ user_id: a.user_id, username: a.username, channel_username: a.channel_username,
+ duration_seconds: a.duration_seconds || 0, created_at: a.created_at,
+ };
+}
+
+router.post('/', tenantAuth(), upload.single('file'), (req, res) => {
+ try {
+ const { kind, name } = req.body || {};
+ if (!['emote', 'sound'].includes(kind)) return res.status(400).json({ error: "kind must be 'emote' or 'sound'" });
+ if (!name || !req.file) return res.status(400).json({ error: 'name and file are required' });
+ const ext = (path.extname(req.file.originalname || '') || '').toLowerCase().slice(0, 8) || '.bin';
+ const filename = `${kind}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
+ const filePath = path.join(ASSET_DIR, filename);
+ fs.writeFileSync(filePath, req.file.buffer);
+ let meta = {};
+ try { meta = req.body.meta ? JSON.parse(req.body.meta) : {}; } catch { /* */ }
+ const prev = db.get('SELECT file_path FROM assets WHERE app_id = ? AND kind = ? AND name = ? AND channel_username = ?',
+ [req.appId, kind, String(name), String(req.body.channel_username || '')]);
+ const row = db.upsertAsset({
+ app_id: req.appId, kind, name: String(name).slice(0, 100),
+ file_path: filePath, mime: req.file.mimetype || 'application/octet-stream',
+ user_id: req.body.user_id != null ? parseInt(req.body.user_id, 10) || null : null,
+ username: String(req.body.username || '').slice(0, 60),
+ channel_username: String(req.body.channel_username || '').slice(0, 60),
+ duration_seconds: parseFloat(req.body.duration_seconds) || 0,
+ meta,
+ });
+ // Replaced an earlier upload for the same identity → drop the old file.
+ if (prev && prev.file_path && prev.file_path !== filePath && fs.existsSync(prev.file_path)) {
+ try { fs.unlinkSync(prev.file_path); } catch { /* */ }
+ }
+ res.status(201).json({ asset: assetPublic(row) });
+ } catch (err) {
+ console.error('[Assets] upload error:', err.message);
+ res.status(500).json({ error: 'Failed to store asset' });
+ }
+});
+
+router.get('/', tenantAuth(), (req, res) => {
+ try {
+ const limit = Math.min(Math.max(parseInt(req.query.limit || '200', 10), 1), 1000);
+ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
+ const kind = ['emote', 'sound'].includes(req.query.kind) ? req.query.kind : null;
+ const assets = db.listAssets(req.appId, { kind, limit, offset }).map(assetPublic);
+ res.json({ assets, total: db.countAssets(req.appId, { kind }), limit, offset });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to list assets' });
+ }
+});
+
+router.delete('/:id', tenantAuth(), (req, res) => {
+ try {
+ const a = db.getAssetById(parseInt(req.params.id, 10));
+ if (!a || a.app_id !== req.appId) return res.status(404).json({ error: 'Not found' });
+ try { if (a.file_path && fs.existsSync(a.file_path)) fs.unlinkSync(a.file_path); } catch { /* */ }
+ db.deleteAsset(a.id);
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to delete asset' });
+ }
+});
+
+module.exports = router;
+module.exports.assetPublic = assetPublic;
@@ -32,6 +32,9 @@ const config = {
@@ -32,6 +32,9 @@ const config = {
pastes: {
pastes: {
path: process.env.PASTES_PATH || './data/pastes',
path: process.env.PASTES_PATH || './data/pastes',
},
},
+ assets: {
+ path: process.env.ASSETS_PATH || './data/assets',
+ },
thumbnails: {
thumbnails: {
path: process.env.THUMBNAILS_PATH || './data/thumbnails',
path: process.env.THUMBNAILS_PATH || './data/thumbnails',
},
},
@@ -65,6 +68,7 @@ for (const p of ['path']) {
@@ -65,6 +68,7 @@ for (const p of ['path']) {
}
}
config.vod.clipsPath = path.resolve(config.vod.clipsPath);
config.vod.clipsPath = path.resolve(config.vod.clipsPath);
config.pastes.path = path.resolve(config.pastes.path);
config.pastes.path = path.resolve(config.pastes.path);
+config.assets.path = path.resolve(config.assets.path);
config.thumbnails.path = path.resolve(config.thumbnails.path);
config.thumbnails.path = path.resolve(config.thumbnails.path);
config.files.path = path.resolve(config.files.path);
config.files.path = path.resolve(config.files.path);
config.db.path = path.resolve(config.db.path);
config.db.path = path.resolve(config.db.path);
@@ -532,6 +532,34 @@ function getFileByKey(key, appId = null) {
@@ -532,6 +532,34 @@ function getFileByKey(key, appId = null) {
return get(`SELECT * FROM files WHERE key = ?${clause}`, params);
return get(`SELECT * FROM files WHERE key = ?${clause}`, params);
}
}
+// ── App assets (emotes / channel sounds) ─────────────────────
+function upsertAsset({ app_id, kind, name, file_path, mime, user_id, username, channel_username, duration_seconds, meta }) {
+ const info = run(`
+ INSERT INTO assets (app_id, kind, name, file_path, mime, user_id, username, channel_username, duration_seconds, meta_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(app_id, kind, name, channel_username) DO UPDATE SET
+ file_path = excluded.file_path, mime = excluded.mime,
+ user_id = excluded.user_id, username = excluded.username,
+ duration_seconds = excluded.duration_seconds, meta_json = excluded.meta_json`,
+ [app_id, kind, name, file_path, mime || 'application/octet-stream', user_id ?? null,
+ username || '', channel_username || '', duration_seconds || 0, JSON.stringify(meta || {})]);
+ return get('SELECT * FROM assets WHERE app_id = ? AND kind = ? AND name = ? AND channel_username = ?',
+ [app_id, kind, name, channel_username || '']);
+}
+function getAssetById(id) { return get('SELECT * FROM assets WHERE id = ?', [id]); }
+function listAssets(appId, { kind = null, limit = 100, offset = 0 } = {}) {
+ const conds = ['app_id = ?']; const params = [appId];
+ if (kind) { conds.push('kind = ?'); params.push(kind); }
+ params.push(limit, offset);
+ return all(`SELECT * FROM assets WHERE ${conds.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, params);
+}
+function countAssets(appId, { kind = null } = {}) {
+ const conds = ['app_id = ?']; const params = [appId];
+ if (kind) { conds.push('kind = ?'); params.push(kind); }
+ return get(`SELECT COUNT(*) c FROM assets WHERE ${conds.join(' AND ')}`, params).c;
+}
+function deleteAsset(id) { return run('DELETE FROM assets WHERE id = ?', [id]); }
+
function listFiles(appId, { limit = 100, offset = 0 } = {}) {
function listFiles(appId, { limit = 100, offset = 0 } = {}) {
return all('SELECT * FROM files WHERE app_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', [appId, limit, offset]);
return all('SELECT * FROM files WHERE app_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', [appId, limit, offset]);
}
}
@@ -585,6 +613,7 @@ module.exports = {
@@ -585,6 +613,7 @@ module.exports = {
// vods
// vods
createVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,
createVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,
latestVodThumbsByManagedStreams, getAppStats,
latestVodThumbsByManagedStreams, getAppStats,
+ upsertAsset, getAssetById, listAssets, countAssets, deleteAsset,
updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
// clips
// clips
createClip, getClipById, getClipByFileBasename, listClips, countClips, setClipVisibility, findDuplicateClip,
createClip, getClipById, getClipByFileBasename, listClips, countClips, setClipVisibility, findDuplicateClip,
@@ -182,3 +182,23 @@ CREATE TABLE IF NOT EXISTS media_settings (
@@ -182,3 +182,23 @@ CREATE TABLE IF NOT EXISTS media_settings (
type TEXT DEFAULT 'string',
type TEXT DEFAULT 'string',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
);
+
+-- ── App assets (emotes / channel sounds) ─────────────────────
+-- Canonical public home for per-channel chat assets the apps upload; browse
+-- index shows uploader + channel. Unique per (app, kind, name, channel).
+CREATE TABLE IF NOT EXISTS assets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ app_id TEXT NOT NULL DEFAULT 'live',
+ kind TEXT NOT NULL, -- 'emote' | 'sound'
+ name TEXT NOT NULL, -- emote code or !command
+ file_path TEXT NOT NULL,
+ mime TEXT DEFAULT 'application/octet-stream',
+ user_id INTEGER, -- uploader (app-local id)
+ username TEXT DEFAULT '', -- uploader name snapshot
+ channel_username TEXT DEFAULT '', -- channel it belongs to
+ duration_seconds REAL DEFAULT 0,
+ meta_json TEXT DEFAULT '{}',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX IF NOT EXISTS idx_assets_app_kind ON assets(app_id, kind, created_at DESC);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_identity ON assets(app_id, kind, name, channel_username);
@@ -69,6 +69,7 @@ app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
@@ -69,6 +69,7 @@ app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
app.use('/api/v1/:app/pastes', require('./pastes/routes'));
app.use('/api/v1/:app/pastes', require('./pastes/routes'));
app.use('/api/v1/:app/files', require('./files/routes'));
app.use('/api/v1/:app/files', require('./files/routes'));
app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));
app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));
+app.use('/api/v1/:app/assets', require('./assets/routes'));
app.use('/api/v1/:app/admin/storage', require('./admin/routes'));
app.use('/api/v1/:app/admin/storage', require('./admin/routes'));
app.use('/', require('./public/routes')); // /v /c /p /t /f
app.use('/', require('./public/routes')); // /v /c /p /t /f
@@ -173,6 +173,9 @@ function pastePublic(paste) {
@@ -173,6 +173,9 @@ function pastePublic(paste) {
copies: paste.copies || 0,
copies: paste.copies || 0,
likes: paste.likes || 0,
likes: paste.likes || 0,
is_nsfw: !!paste.is_nsfw,
is_nsfw: !!paste.is_nsfw,
+ ai_summary: paste.ai_summary || null,
+ ai_tags: paste.ai_tags || null,
+ ai_analyzed_at: paste.ai_analyzed_at || null,
url: `/p/${paste.slug}`,
url: `/p/${paste.slug}`,
raw_url: `/p/${paste.slug}/raw`,
raw_url: `/p/${paste.slug}/raw`,
created_at: paste.created_at,
created_at: paste.created_at,
@@ -73,6 +73,20 @@ function fileCard(f) {
@@ -73,6 +73,20 @@ function fileCard(f) {
source: null,
source: null,
};
};
}
}
+function assetCard(a) {
+ const isEmote = a.kind === 'emote';
+ const channel = a.channel_username || a.username || '';
+ return {
+ kind: a.kind, title: isEmote ? a.name : `!${a.name}`, date: a.created_at, app: a.app_id,
+ thumb: isEmote ? `${config.publicUrl}/a/${a.id}` : null,
+ duration: !isEmote ? a.duration_seconds : null,
+ text: `uploaded by ${a.username || 'unknown'}${channel ? ` · channel @${channel}` : ''}`,
+ view: `${config.publicUrl}/a/${a.id}`,
+ source: channel ? `${appUrl(a.app_id)}/@${encodeURIComponent(channel)}` : null,
+ sourceLabel: channel ? `@${channel}'s channel` : null,
+ };
+}
+
function thumbCard(name, mtimeMs) {
function thumbCard(name, mtimeMs) {
const m = /^(vod|clip|stream)-(\d+)/.exec(name);
const m = /^(vod|clip|stream)-(\d+)/.exec(name);
let source = null, sourceLabel = null;
let source = null, sourceLabel = null;
@@ -133,6 +147,14 @@ function fetchTab(tab, page) {
@@ -133,6 +147,14 @@ function fetchTab(tab, page) {
total: db.get('SELECT COUNT(*) c FROM files').c,
total: db.get('SELECT COUNT(*) c FROM files').c,
cards: db.all(`SELECT * FROM files ${L}`).map(fileCard),
cards: db.all(`SELECT * FROM files ${L}`).map(fileCard),
};
};
+ case 'emotes': return {
+ total: db.get("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'").c,
+ cards: db.all(`SELECT * FROM assets WHERE kind = 'emote' ${L}`).map(assetCard),
+ };
+ case 'sounds': return {
+ total: db.get("SELECT COUNT(*) c FROM assets WHERE kind = 'sound'").c,
+ cards: db.all(`SELECT * FROM assets WHERE kind = 'sound' ${L}`).map(assetCard),
+ };
case 'thumbnails': {
case 'thumbnails': {
const list = thumbList();
const list = thumbList();
return { total: list.length, cards: list.slice(off, off + PAGE_SIZE).map(([f, t]) => thumbCard(f, t)) };
return { total: list.length, cards: list.slice(off, off + PAGE_SIZE).map(([f, t]) => thumbCard(f, t)) };
@@ -144,13 +166,17 @@ function fetchTab(tab, page) {
@@ -144,13 +166,17 @@ function fetchTab(tab, page) {
SELECT 'clip', id, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM clips WHERE ${C_WHERE}
SELECT 'clip', id, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM clips WHERE ${C_WHERE}
UNION ALL
UNION ALL
SELECT 'paste', id, title, NULL, NULL, NULL, NULL, created_at, app_id, slug, type, substr(COALESCE(content,''),1,300), language FROM pastes WHERE ${P_WHERE}
SELECT 'paste', id, title, NULL, NULL, NULL, NULL, created_at, app_id, slug, type, substr(COALESCE(content,''),1,300), language FROM pastes WHERE ${P_WHERE}
+ UNION ALL
+ SELECT 'asset', id, name, username, channel_username, kind, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM assets
ORDER BY created_at DESC LIMIT ${PAGE_SIZE} OFFSET ${off}`);
ORDER BY created_at DESC LIMIT ${PAGE_SIZE} OFFSET ${off}`);
const total = db.get(`SELECT
const total = db.get(`SELECT
(SELECT COUNT(*) FROM vods WHERE ${V_WHERE}) +
(SELECT COUNT(*) FROM vods WHERE ${V_WHERE}) +
(SELECT COUNT(*) FROM clips WHERE ${C_WHERE}) +
(SELECT COUNT(*) FROM clips WHERE ${C_WHERE}) +
- (SELECT COUNT(*) FROM pastes WHERE ${P_WHERE}) c`).c;
+ (SELECT COUNT(*) FROM pastes WHERE ${P_WHERE}) +
+ (SELECT COUNT(*) FROM assets) c`).c;
const cards = rows.map(r => r.k === 'vod' ? vodCard({ ...r, id: r.ref })
const cards = rows.map(r => r.k === 'vod' ? vodCard({ ...r, id: r.ref })
: r.k === 'clip' ? clipCard({ ...r, id: r.ref })
: r.k === 'clip' ? clipCard({ ...r, id: r.ref })
+ : r.k === 'asset' ? assetCard({ id: r.ref, name: r.title, username: r.description, channel_username: r.ai_overview, kind: r.thumbnail_url, duration_seconds: r.duration_seconds, created_at: r.created_at, app_id: r.app_id })
: pasteCard({ ...r, id: r.ref }));
: pasteCard({ ...r, id: r.ref }));
return { total, cards };
return { total, cards };
}
}
@@ -167,9 +193,11 @@ function tabCounts() {
@@ -167,9 +193,11 @@ function tabCounts() {
images: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`),
images: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`),
text: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`),
text: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`),
files: c('SELECT COUNT(*) c FROM files'),
files: c('SELECT COUNT(*) c FROM files'),
+ emotes: c("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'"),
+ sounds: c("SELECT COUNT(*) c FROM assets WHERE kind = 'sound'"),
thumbnails: thumbList().length,
thumbnails: thumbList().length,
};
};
- counts.all = counts.videos + counts.clips + counts.images + counts.text;
+ counts.all = counts.videos + counts.clips + counts.images + counts.text + counts.emotes + counts.sounds;
_countCache = { at: Date.now(), counts };
_countCache = { at: Date.now(), counts };
return counts;
return counts;
}
}
@@ -196,7 +224,7 @@ function renderCard(c) {
@@ -196,7 +224,7 @@ function renderCard(c) {
</div>`;
</div>`;
}
}
-const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['images', 'Images'], ['text', 'Text'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];
+const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['images', 'Images'], ['text', 'Text'], ['emotes', 'Emotes'], ['sounds', 'Sounds'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];
function renderPage(tab, page, data, counts) {
function renderPage(tab, page, data, counts) {
const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
@@ -208,7 +236,7 @@ function renderPage(tab, page, data, counts) {
@@ -208,7 +236,7 @@ function renderPage(tab, page, data, counts) {
<style>
<style>
:root{--bg:#0f1420;--panel:#161c2c;--border:#232b40;--text:#e6e9f2;--muted:#9aa3b8;--accent:#8b5cf6}
:root{--bg:#0f1420;--panel:#161c2c;--border:#232b40;--text:#e6e9f2;--muted:#9aa3b8;--accent:#8b5cf6}
*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:15px/1.5 system-ui,'Segoe UI',Arial,sans-serif;padding-bottom:3rem}
*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:15px/1.5 system-ui,'Segoe UI',Arial,sans-serif;padding-bottom:3rem}
-header{display:flex;align-items:center;gap:.9rem;padding:1rem 1.4rem;border-bottom:1px solid var(--border);position:sticky;top:0;background:rgba(15,20,32,.92);backdrop-filter:blur(8px);z-index:2}
+header{display:flex;align-items:center;gap:.9rem;padding:1rem 1.4rem;border-bottom:1px solid var(--border);background:rgba(15,20,32,.92)}
header .logo{width:30px;height:30px}
header .logo{width:30px;height:30px}
header h1{font-size:1.15rem}header h1 b{color:var(--accent)}
header h1{font-size:1.15rem}header h1 b{color:var(--accent)}
header .sub{color:var(--muted);font-size:.82rem;margin-left:auto}
header .sub{color:var(--muted);font-size:.82rem;margin-left:auto}
@@ -232,7 +260,11 @@ nav a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}
@@ -232,7 +260,11 @@ nav a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}
.pg{color:var(--accent);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--border);border-radius:8px}
.pg{color:var(--accent);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--border);border-radius:8px}
.pg.dis{color:var(--muted);opacity:.4}
.pg.dis{color:var(--muted);opacity:.4}
.empty{padding:3rem;text-align:center;color:var(--muted)}
.empty{padding:3rem;text-align:center;color:var(--muted)}
-</style></head><body>
+</style>
+<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
+</head><body>
+<script src="https://openvibe.network/shared/navbar.js"></script>
+<script>try { OpenVibeNavbar.init({ service: 'media' }); } catch (e) { /* navbar optional */ }</script>
<header>
<header>
<svg class="logo" viewBox="0 0 100 100" fill="none" stroke="#8b5cf6" stroke-width="7"><circle cx="28" cy="66" r="11"/><circle cx="72" cy="66" r="11"/><circle cx="50" cy="30" r="11"/><line x1="34" y1="58" x2="45" y2="40"/><line x1="66" y1="58" x2="55" y2="40"/><line x1="39" y1="66" x2="61" y2="66"/></svg>
<svg class="logo" viewBox="0 0 100 100" fill="none" stroke="#8b5cf6" stroke-width="7"><circle cx="28" cy="66" r="11"/><circle cx="72" cy="66" r="11"/><circle cx="50" cy="30" r="11"/><line x1="34" y1="58" x2="45" y2="40"/><line x1="66" y1="58" x2="55" y2="40"/><line x1="39" y1="66" x2="61" y2="66"/></svg>
<h1>OpenVibe<b>.Media</b></h1>
<h1>OpenVibe<b>.Media</b></h1>
@@ -249,6 +249,24 @@ router.get('/live/:sel/frame.jpg', async (req, res) => {
@@ -249,6 +249,24 @@ router.get('/live/:sel/frame.jpg', async (req, res) => {
}
}
});
});
+// ── App assets (emotes / sounds) by id ───────────────────────
+router.get('/a/:id', (req, res) => {
+ try {
+ const a = db.getAssetById(parseInt(req.params.id, 10));
+ if (!a || !a.file_path || !fs.existsSync(a.file_path)) return res.status(404).json({ error: 'Not found' });
+ // Content is replaced under the same URL on re-upload — cache a day, not immutable.
+ streamFileWithRange(req, res, a.file_path, {
+ 'Content-Type': a.mime || 'application/octet-stream',
+ 'Cache-Control': 'public, max-age=86400',
+ 'Access-Control-Allow-Origin': '*',
+ 'X-Robots-Tag': 'noindex',
+ });
+ } catch (err) {
+ console.error('[Public] /a error:', err.message);
+ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve asset' });
+ }
+});
+
// ── Media index home page ────────────────────────────────────
// ── Media index home page ────────────────────────────────────
// GET / — server-rendered mass index of all public media (tabs: All / Videos /
// GET / — server-rendered mass index of all public media (tabs: All / Videos /
// Clips / Images / Text / Thumbnails / Files), each card linking back to its
// Clips / Images / Text / Thumbnails / Files), each card linking back to its
@@ -78,6 +78,8 @@ function clipPublic(clip) {
@@ -78,6 +78,8 @@ function clipPublic(clip) {
is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',
is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',
storage_provider: clip.storage_provider || 'local',
storage_provider: clip.storage_provider || 'local',
auto_generated: !!clip.auto_generated,
auto_generated: !!clip.auto_generated,
+ ai_overview: clip.ai_overview || null,
+ ai_analyzed_at: clip.ai_analyzed_at || null,
view_count: clip.view_count || 0,
view_count: clip.view_count || 0,
created_at: clip.created_at,
created_at: clip.created_at,
};
};
@@ -300,7 +302,8 @@ router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
@@ -300,7 +302,8 @@ router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
const clip = _getClipScoped(req, res);
const clip = _getClipScoped(req, res);
if (!clip) return;
if (!clip) return;
// Bare object, mirroring GET /vods/:id per CONTRACTS.md.
// Bare object, mirroring GET /vods/:id per CONTRACTS.md.
- res.json(clipPublic(clip));
+ // Detail responses carry the transcript too (lists stay light).
+ res.json({ ...clipPublic(clip), ai_transcript: clip.ai_transcript || null });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to get clip' });
res.status(500).json({ error: 'Failed to get clip' });
}
}
@@ -64,6 +64,8 @@ function vodPublic(vod) {
@@ -64,6 +64,8 @@ function vodPublic(vod) {
health_status: vod.health_status,
health_status: vod.health_status,
clips_only: !!vod.clips_only,
clips_only: !!vod.clips_only,
is_recording: !!vod.is_recording,
is_recording: !!vod.is_recording,
+ ai_overview: vod.ai_overview || null,
+ ai_analyzed_at: vod.ai_analyzed_at || null,
view_count: vod.view_count || 0,
view_count: vod.view_count || 0,
created_at: vod.created_at,
created_at: vod.created_at,
meta: (() => { try { return JSON.parse(vod.meta_json || '{}'); } catch { return {}; } })(),
meta: (() => { try { return JSON.parse(vod.meta_json || '{}'); } catch { return {}; } })(),
@@ -154,10 +156,13 @@ async function _doFinalize(vodId, opts) {
@@ -154,10 +156,13 @@ async function _doFinalize(vodId, opts) {
}
}
if (!filePath || !fs.existsSync(filePath)) {
if (!filePath || !fs.existsSync(filePath)) {
- db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);
- const failed = db.getVodById(vodId);
- _webhookForVod(failed, 'vod.failed');
- return failed;
+ // No media was ever written (ingest started but produced nothing, or the
+ // process died before ffmpeg opened the file). Nothing to review — delete
+ // the row so a 0:00 ghost never reaches listings.
+ console.warn(`[VOD] vod ${vodId}: no recording file — deleting empty row`);
+ _webhookForVod(vod, 'vod.failed');
+ try { db.run('DELETE FROM vods WHERE id = ?', [vodId]); } catch { /* */ }
+ return null;
}
}
// Merge any pending browser-chunk segments before remuxing.
// Merge any pending browser-chunk segments before remuxing.
@@ -223,9 +223,39 @@ async function _tick() {
@@ -223,9 +223,39 @@ async function _tick() {
}
}
}
}
+// One boot-time sweep for 0:00 ghost VODs that older failure paths marked
+// ready+public (finalize crash fallbacks, killed processes). Rows with no
+// media at all are deleted; short-but-nonempty ones are quarantined.
+function sweepJunkVods() {
+ try {
+ const fs = require('fs');
+ const rows = db.all(`SELECT id, file_path, file_size, duration_seconds FROM vods
+ WHERE is_recording = 0 AND is_public = 1
+ AND COALESCE(duration_seconds, 0) < 3
+ AND COALESCE(file_size, 0) < 10000000
+ AND (storage_provider IS NULL OR storage_provider = 'local')
+ AND COALESCE(health_status, 'unknown') IN ('unknown', '')`);
+ let deleted = 0, quarantined = 0;
+ for (const v of rows) {
+ const hasFile = v.file_path && fs.existsSync(v.file_path) && (() => { try { return fs.statSync(v.file_path).size > 0; } catch { return false; } })();
+ if (!hasFile) {
+ try { if (v.file_path && fs.existsSync(v.file_path)) fs.unlinkSync(v.file_path); } catch { /* */ }
+ db.run('DELETE FROM vods WHERE id = ?', [v.id]);
+ deleted++;
+ } else {
+ db.run(`UPDATE vods SET health_status = 'needs_review', health_issues_json = ?, quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,
+ [JSON.stringify(['short_duration']), v.id]);
+ quarantined++;
+ }
+ }
+ if (deleted || quarantined) console.log(`[VOD-Health] Junk sweep: deleted ${deleted} empty VODs, quarantined ${quarantined} short ones`);
+ } catch (e) { console.warn('[VOD-Health] junk sweep error:', e.message); }
+}
+
function start() {
function start() {
if (_running) return;
if (_running) return;
_running = true;
_running = true;
+ sweepJunkVods();
// First pass shortly after boot (idle window), then on the interval.
// First pass shortly after boot (idle window), then on the interval.
const first = setTimeout(() => { _tick().catch(() => {}); }, 90 * 1000);
const first = setTimeout(() => { _tick().catch(() => {}); }, 90 * 1000);
if (first.unref) first.unref();
if (first.unref) first.unref();
@@ -257,7 +257,11 @@ class StreamRecorder {
@@ -257,7 +257,11 @@ class StreamRecorder {
const filePath = rec?.filePath;
const filePath = rec?.filePath;
this._teardown(vodId);
this._teardown(vodId);
require('./finalize').finalizeVod(vodId, { startTimeMs: rec?.startTime }).catch(() => {
require('./finalize').finalizeVod(vodId, { startTimeMs: rec?.startTime }).catch(() => {
- db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);
+ // Never bare-mark a failed recording as ready — that published
+ // 0:00 ghosts. Quarantine it out of listings instead.
+ db.run(`UPDATE vods SET is_recording = 0, health_status = 'needs_review',
+ health_issues_json = ?, quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,
+ [JSON.stringify(['finalize_failed']), vodId]);
});
});
if (filePath && !fs.existsSync(filePath)) this._cleanupFailedVod(vodId, filePath);
if (filePath && !fs.existsSync(filePath)) this._cleanupFailedVod(vodId, filePath);
});
});
@@ -319,7 +319,8 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
@@ -319,7 +319,8 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
}
}
// Bare object per CONTRACTS.md: { id, title, status, duration, … }
// Bare object per CONTRACTS.md: { id, title, status, duration, … }
- res.json(vodPublic(vod));
+ // Detail responses carry the transcript too (lists stay light).
+ res.json({ ...vodPublic(vod), ai_transcript: vod.ai_transcript || null });
} catch (err) {
} catch (err) {
console.error('[VOD] Get error:', err.message);
console.error('[VOD] Get error:', err.message);
res.status(500).json({ error: 'Failed to get VOD' });
res.status(500).json({ error: 'Failed to get VOD' });
@@ -3,12 +3,17 @@
@@ -3,12 +3,17 @@
// Consistent top bar across all services with logo, navigation,
// Consistent top bar across all services with logo, navigation,
// notification bell, account switcher, and theme-aware styling.
// notification bell, account switcher, and theme-aware styling.
// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })
// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })
+// Optional: loginUrl (override the Sign In href), sessionUrl (same-origin
+// endpoint returning { user } — used only when no ov_token exists),
+// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
+// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
+// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
(function (root) {
(function (root) {
'use strict';
'use strict';
- let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null };
+ let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null };
let _navEl = null;
let _navEl = null;
function injectStyles() {
function injectStyles() {
@@ -362,6 +367,9 @@
@@ -362,6 +367,9 @@
],
],
media: [
media: [
{ label: 'Home', href: '/' },
{ label: 'Home', href: '/' },
+ { label: 'Network', href: 'https://openvibe.network' },
+ { label: 'Live', href: 'https://openvibe.live' },
+ { label: 'Tools', href: 'https://openvibe.tools' },
],
],
network: [
network: [
{ label: 'Home', href: '/' },
{ label: 'Home', href: '/' },
@@ -429,6 +437,200 @@
@@ -429,6 +437,200 @@
try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }
try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }
}
}
+ // ─── SSO auth resolution ───────────────────────────────────
+ // Cookie policy (CONTRACTS.md): ov_token is host-only on openvibe.network,
+ // and Domain=.openvibe.tools on the tools apex + every tool subdomain.
+ // The tools gateway (apex) owns the OAuth client: /auth/login, /auth/logout,
+ // /auth/me, POST /auth/refresh. Satellite subdomains (maps., audio., text.,
+ // img., yt., docs., food., …) do NOT mount /auth/* — they only read the
+ // shared cookie — so sign-in/out always goes through the gateway apex.
+ // Detection order: ov_token cookie → localStorage → page-provided token →
+ // app-specific session endpoint (opt-in via config.sessionUrl).
+ const TOOLS_APEX = 'openvibe.tools';
+ const TOKEN_KEY = 'ov_token';
+
+ function currentHost() {
+ return (typeof location !== 'undefined' && location.hostname) || '';
+ }
+
+ function onToolsDomain(host) {
+ const h = host !== undefined ? host : currentHost();
+ return h === TOOLS_APEX || h.endsWith('.' + TOOLS_APEX);
+ }
+
+ function onNetworkDomain(host) {
+ const h = host !== undefined ? host : currentHost();
+ try { return h !== '' && h === new URL(_config.apiBase).hostname; } catch { return false; }
+ }
+
+ function parseCookieToken(cookieStr) {
+ const m = String(cookieStr || '').match(/(?:^|;\s*)ov_token=([^;]*)/);
+ if (!m || !m[1]) return null;
+ try { return decodeURIComponent(m[1]); } catch { return m[1]; }
+ }
+
+ function getCookieToken() {
+ if (typeof document === 'undefined') return null;
+ return parseCookieToken(document.cookie);
+ }
+
+ function getStoredToken() {
+ try { return localStorage.getItem(TOKEN_KEY) || null; } catch { return null; }
+ }
+
+ function resolveToken() {
+ return getCookieToken() || getStoredToken() || _config.token || null;
+ }
+
+ /** Where "Sign In" goes on this host. */
+ function resolveLoginHref(host, returnUrl) {
+ if (_config.loginUrl) return _config.loginUrl;
+ if (onToolsDomain(host)) {
+ // Every *.openvibe.tools host signs in through the gateway apex: it
+ // holds the OAuth state cookie + redirect_uri (both apex-host-only)
+ // and sets ov_token with Domain=.openvibe.tools, so the session
+ // reaches all satellites. A host-local /auth/login would 404 on
+ // satellites and break the OAuth state check on gateway subdomains.
+ return `https://${TOOLS_APEX}/auth/login?next=${encodeURIComponent(returnUrl)}`;
+ }
+ if (onNetworkDomain(host)) return `/login?return=${encodeURIComponent(returnUrl)}`;
+ return `${_config.apiBase}/login?return=${encodeURIComponent(returnUrl)}`;
+ }
+
+ function setAuthCookie(token) {
+ if (typeof document === 'undefined') return;
+ const maxAge = 24 * 60 * 60; // matches the 24h access JWT
+ if (onToolsDomain()) {
+ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};domain=.${TOOLS_APEX};SameSite=Lax;Secure`;
+ } else {
+ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
+ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};SameSite=Lax${secure}`;
+ }
+ }
+
+ function clearAuthState() {
+ if (typeof document !== 'undefined') {
+ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';
+ if (onToolsDomain()) {
+ document.cookie = `ov_token=;path=/;max-age=0;domain=.${TOOLS_APEX};SameSite=Lax`;
+ }
+ }
+ try {
+ localStorage.removeItem(TOKEN_KEY);
+ localStorage.removeItem('openvibe_anon_token');
+ localStorage.removeItem('openvibe_active_account');
+ } catch { /* storage unavailable */ }
+ }
+
+ function persistToken(token) {
+ try { localStorage.setItem(TOKEN_KEY, token); } catch { /* storage unavailable */ }
+ setAuthCookie(token);
+ }
+
+ /** GET {apiBase}/api/auth/me with the Bearer token. */
+ async function fetchMe(token) {
+ try {
+ const res = await fetch(`${_config.apiBase}/api/auth/me`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (res.ok) {
+ const data = await res.json();
+ return { ok: true, user: (data && (data.user || data)) || null };
+ }
+ return { ok: false, unauthorized: res.status === 401 || res.status === 403 };
+ } catch {
+ return { ok: false, unauthorized: false };
+ }
+ }
+
+ /** Same-origin session refresh — mounted by the tools gateway (POST /auth/refresh). */
+ async function gatewayRefresh() {
+ try {
+ const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'same-origin' });
+ if (!res.ok) return null;
+ const data = await res.json();
+ return data && data.token ? data : null;
+ } catch { return null; }
+ }
+
+ /** Network-side refresh — accepts tokens expired up to 7 days ago (grace). */
+ async function networkRefresh(token) {
+ try {
+ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) return null;
+ const data = await res.json();
+ return data && data.token ? data : null;
+ } catch { return null; }
+ }
+
+ /**
+ * Resolve the signed-in user from the shared SSO state.
+ * With a token: validate it against the Network. If it is rejected, make
+ * ONE refresh attempt (same-origin gateway refresh first on tools hosts,
+ * then the Network's grace refresh) before giving up — never leave a
+ * stale "Sign In" when a recoverable session exists.
+ */
+ async function resolveSessionUser() {
+ const token = resolveToken();
+ if (token) {
+ const me = await fetchMe(token);
+ if (me.ok && me.user) return { user: me.user, token };
+ if (me.unauthorized) {
+ if (onToolsDomain()) {
+ const r = await gatewayRefresh();
+ if (r) {
+ const user = r.user || (await fetchMe(r.token)).user;
+ if (user) { persistToken(r.token); return { user, token: r.token }; }
+ }
+ }
+ const n = await networkRefresh(token);
+ if (n) {
+ const user = n.user || (await fetchMe(n.token)).user;
+ if (user) { persistToken(n.token); return { user, token: n.token }; }
+ }
+ }
+ return null;
+ }
+ // No token anywhere — ask the page's own session endpoint if it has one
+ if (_config.sessionUrl) {
+ try {
+ const res = await fetch(_config.sessionUrl, { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ const user = (data && (data.user || data)) || null;
+ if (user && (user.username || user.id)) return { user, token: null };
+ }
+ } catch { /* signed out */ }
+ }
+ return null;
+ }
+
+ let _authInFlight = null;
+
+ function refreshAuthState() {
+ if (_authInFlight) return _authInFlight;
+ _authInFlight = resolveSessionUser()
+ .then((session) => {
+ _authInFlight = null;
+ if (session && session.user) {
+ _config.user = session.user;
+ if (session.token) _config.token = session.token;
+ render();
+ }
+ try {
+ document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
+ detail: { user: session ? session.user : null, token: session ? session.token : null },
+ }));
+ } catch { /* non-DOM environment */ }
+ return session ? session.user : null;
+ })
+ .catch(() => { _authInFlight = null; return null; });
+ return _authInFlight;
+ }
+
function escapeAttr(value) {
function escapeAttr(value) {
return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"');
return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"');
}
}
@@ -497,8 +699,8 @@
@@ -497,8 +699,8 @@
const u = _config.user;
const u = _config.user;
const accounts = getAccounts();
const accounts = getAccounts();
const isAnon = u && u.is_anon;
const isAnon = u && u.is_anon;
- const loginHref = `https://openvibe.network/login?return=${encodeURIComponent(window.location.href)}`;
- const addAccountHref = `https://openvibe.network/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
+ const loginHref = resolveLoginHref(currentHost(), window.location.href);
+ const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
nav.innerHTML = `
nav.innerHTML = `
<a class="openvibe-navbar-brand" href="${brandHref}">
<a class="openvibe-navbar-brand" href="${brandHref}">
@@ -588,12 +790,16 @@
@@ -588,12 +790,16 @@
dropdown.classList.remove('open');
dropdown.classList.remove('open');
if (_config.onLogout) _config.onLogout();
if (_config.onLogout) _config.onLogout();
else {
else {
- document.cookie = 'ov_token=;path=/;max-age=0';
- document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools';
- localStorage.removeItem('ov_token');
- localStorage.removeItem('openvibe_anon_token');
- localStorage.removeItem('openvibe_active_account');
- window.location.reload();
+ clearAuthState();
+ _config.user = null;
+ _config.token = null;
+ if (onToolsDomain()) {
+ // The gateway also clears the Domain=.openvibe.tools cookie
+ // and the httpOnly refresh cookie, then sends us back here.
+ window.location.href = `https://${TOOLS_APEX}/auth/logout?next=${encodeURIComponent(window.location.href)}`;
+ } else {
+ window.location.reload();
+ }
}
}
});
});
} else {
} else {
@@ -621,9 +827,19 @@
@@ -621,9 +827,19 @@
init(opts = {}) {
init(opts = {}) {
Object.assign(_config, opts);
Object.assign(_config, opts);
injectStyles();
injectStyles();
- return render();
+ const el = render();
+ // Pages that hand us a resolved user (openvibe.network, the tools
+ // gateway hub) keep full control. Everyone else — pages that pass
+ // only a token, or nothing at all — gets the user resolved from
+ // the shared SSO state (ov_token cookie / localStorage / optional
+ // sessionUrl) and the navbar re-renders when it arrives.
+ if (!_config.user) refreshAuthState();
+ return el;
},
},
+ /** Re-resolve the signed-in user from the shared SSO state. */
+ refreshAuth() { return refreshAuthState(); },
+
/** Update user (after account switch). */
/** Update user (after account switch). */
setUser(user) {
setUser(user) {
_config.user = user;
_config.user = user;
@@ -643,6 +859,18 @@
@@ -643,6 +859,18 @@
_navEl?.remove();
_navEl?.remove();
_navEl = null;
_navEl = null;
},
},
+
+ /** Internal auth helpers — exposed for tests, not a public API. */
+ _auth: {
+ parseCookieToken,
+ onToolsDomain,
+ onNetworkDomain,
+ resolveLoginHref,
+ resolveToken,
+ resolveSessionUser,
+ clearAuthState,
+ persistToken,
+ },
};
};
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;