openvibers/openvibe.network Commits
Menu cards carry their images, and /my draws them (#628)
Changes
@@@ -1027,7 +1027,7 @@ Every change also sends the person a Network notification, so a swap nobody askeMIT (`license` in [package.json](package.json)).<!-- versions:start -->--- openvibe-contracts: v0.319.0++- openvibe-contracts: v0.320.0- openvibe-sdk: v0.94.0- openvibe-shared: v4.129.0<!-- versions:end -->@@@ -6,7 +6,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4000,"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",-- "contracts": "openvibe-contracts v0.319.0",++ "contracts": "openvibe-contracts v0.320.0","sdk": "openvibe-sdk v0.94.0","shared": "openvibe-shared v4.129.0","capabilities": [@@@ -20,7 +20,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.319.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0","pg": "^8.23.0",@@@ -2303,9 +2303,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.319.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.319.0",-- "integrity": "sha512-v0HSuq5d4XZ3tKfXRDgNcmO+M6pQVMsl/pWOhAGRfzSIdjV/G1s9mnsoi7WXhpsJiZ3kEYPP1ie2viyJ+Lg/Zg==",++ "version": "0.320.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0",++ "integrity": "sha512-ECShhtflTLicQosxVSEPlBFQn9eSnOUyO//Z2oS2NPscvb5RtZusrVq3xYWcJIs9XiJ7ws2Q9pAWPaRZIpEvwQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -24,7 +24,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.319.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0","pg": "^8.23.0",@@@ -579,6 +579,7 @@ button.td-stat:hover span { color: var(--accent-light); }.td-net-list li + li .td-net-row { border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); border-radius: 0; }.td-net-list li + li .td-net-row:hover { border-radius: 10px; }.td-net-i { flex: none; width: 22px; font-size: 15px; font-weight: 850; color: var(--text-muted); text-align: center; font-variant-numeric: tabular-nums; }++.td-net-thumb { flex: none; width: 64px; height: 36px; border-radius: 8px; object-fit: cover; background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }.td-net-list li:first-child .td-net-i { color: var(--accent-light); }.td-net-t { display: grid; min-width: 0; }.td-net-t b { font-size: 14px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }@@@ -128,10 +128,14 @@ function renderNet(frame) {wrap.replaceChildren(...cards.map((c) => {const site = TD_SITES[c.site] || [c.site || 'OpenVibe', 'fa-globe', null];const rows = c.items.filter((it) => tdSafeUrl(it.url)).slice(0, 5);-- const list = tdEl('ol', 'td-net-list', rows.map((it, i) => tdEl('li', '', [tdEl('a', 'td-net-row', [-- tdEl('span', 'td-net-i', [String(i + 1)]),-- tdEl('span', 'td-net-t', [tdEl('b', '', [String(it.title || '')]), it.meta ? tdEl('small', '', [String(it.meta)]) : null]),-- ], { href: tdSafeUrl(it.url) })])));++ const list = tdEl('ol', 'td-net-list', rows.map((it, i) => {++ const src = it.image && tdSafeUrl(it.image.src);++ return tdEl('li', '', [tdEl('a', 'td-net-row', [++ tdEl('span', 'td-net-i', [String(i + 1)]),++ src ? tdEl('img', 'td-net-thumb', null, { src, alt: '', width: 64, height: 36, loading: 'lazy', decoding: 'async' }) : null,++ tdEl('span', 'td-net-t', [tdEl('b', '', [String(it.title || '')]), it.meta ? tdEl('small', '', [String(it.meta)]) : null]),++ ], { href: tdSafeUrl(it.url) })]);++ }));const more = c.more && tdSafeUrl(c.more.url) ? tdEl('a', 'td-more', [String(c.more.label || 'More')], { href: tdSafeUrl(c.more.url) }) : null;return tdEl('section', 'td-box td-net-card', [tdEl('div', 'td-net-h', [tdEl('span', 'td-site-ic', [tdIcon(site[1])]), tdEl('span', 'td-net-ht', [tdEl('b', '', [String(c.title || site[0])]), tdEl('small', '', [site[0]])]), more]),@@@ -6,8 +6,8 @@* A card is frame.menu-card@1: a rail, rows or chips of up to six items. The fetch is small and strict —* a five-second timeout, 64 KB at most, no redirects, JSON only — and every card is checked against the contract* after unknown fields are dropped and its text is stripped of markup and cut to the schema's limits. An item's-- * URL must be https on the publishing site's own host or an openvibe.* host, and the card's `site` must be the-- * site that served it. A site that fails keeps its last good cards for 30 minutes, then contributes none. The++ * URL and its optional image `src` must be https on the publishing site's own host or an openvibe.* host (an image++ * from anywhere else is dropped and the item stays), and the card's `site` must be the site that served it. A site that fails keeps its last good cards for 30 minutes, then contributes none. The* result is kept in frame_cache, so a restart starts from the last good set rather than an empty menu.** Ranking: cards with items only; Live's card first while anyone is live, then the site's place in the Frame's@@@ -47,7 +47,25 @@ function cardUrl(value, site) {return raw;}--/** One item ({ title, meta?, url }), or null. */++/**++ * One item image ({ src, w?, h?, alt? }), or null. A picture's src follows the item url's rule (https on the++ * site's own host or an openvibe.* host); an image from anywhere else is dropped and the item stays. A width or++ * height the schema would refuse is dropped on its own, and alt is plain text cut to its limit.++ */++function imageOf(raw, site) {++ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;++ const src = cardUrl(raw.src, site);++ if (!src) return null;++ const image = { src };++ for (const k of ['w', 'h']) {++ const n = Number(raw[k]);++ if (raw[k] !== undefined && Number.isInteger(n) && n >= 16 && n <= 1920) image[k] = n;++ }++ if (raw.alt !== undefined) image.alt = text(raw.alt, 120);++ return image;++}++++/** One item ({ title, meta?, url, image? }), or null. */function itemOf(raw, site) {if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;const title = text(raw.title, 80);@@@ -56,6 +74,8 @@ function itemOf(raw, site) {const item = { title, url };const meta = text(raw.meta, 120);if (raw.meta !== undefined) item.meta = meta;++ const image = imageOf(raw.image, site);++ if (image) item.image = image;return item;}@@@ -238,4 +258,4 @@ function createMenuCards({ sites, rankOf, load, save, onChange = () => {}, fetchreturn { init, refresh, cards, start, stop, _state: () => state };}--module.exports = { createMenuCards, cardOf, cardsOf, text, cardUrl, MAX_CARDS, KEEP_MS, REFRESH_MS, PATH };++module.exports = { createMenuCards, cardOf, cardsOf, text, cardUrl, imageOf, MAX_CARDS, KEEP_MS, REFRESH_MS, PATH };@@@ -2,14 +2,14 @@// The menu's network cards (frame.menu-card@1): Network fetches every open site's /.well-known/openvibe-menu.json// every five minutes (5 s, 64 KB, no redirects), drops unknown fields and markup, validates against the contract,// keeps a failed site's last good cards for 30 minutes, sends at most four with Live first while anyone is live,--// then in Frame rank order, and carries them in GET /api/frame. One migrated test database, stubbed fetches.++// then in Frame rank order, and carries them in GET /api/frame, images included. One migrated test database, stubbed fetches.// node test/frame-cards.test.jsconst assert = require('assert');const express = require('express');const http = require('http');const { getDb } = require('../server/db/database');const { createFrameService } = require('../server/frame/service');--const { createMenuCards, cardOf, cardsOf } = require('../server/frame/menu-cards');++const { createMenuCards, cardOf, cardsOf, imageOf } = require('../server/frame/menu-cards');const SITES = [{ id: 'live', host: 'openvibe.live' },@@@ -22,7 +22,7 @@ const card = (site, over = {}) => ({site,title: 'The card',kind: 'rows',-- items: [{ title: 'One', meta: '1 watching', url: `https://${SITES.find((s) => s.id === site).host}/one` }],++ items: [{ title: 'One', meta: '1 watching', url: `https://${SITES.find((s) => s.id === site).host}/one`, image: { src: `https://${SITES.find((s) => s.id === site).host}/t/one.jpg`, w: 640, h: 360 } }],updated_at: '2026-11-07T08:00:00Z',ttl_s: 30,...over,@@@ -33,9 +33,11 @@ const dirty = card('live', {extra: 'dropped',title: ' Live <b>now</b> ',items: [-- { title: '<i>Nova</i>', meta: '<b>12</b> watching', url: 'https://openvibe.live/@nova', extra: 1 },++ { title: '<i>Nova</i>', meta: '<b>12</b> watching', url: 'https://openvibe.live/@nova', extra: 1, image: { src: 'https://openvibe.live/t/nova.jpg?w=640', w: 640, h: 360, alt: '<b>Nova</b> at the desk', credit: 'dropped' } },{ title: 'Elsewhere', url: 'https://example.com/x' },-- { title: 'Video host', url: 'https://openvibe.video/v/2' },++ { title: 'Video host', url: 'https://openvibe.video/v/2', image: { src: 'https://openvibe.media/t/2.jpg', w: 8, h: 1440, alt: 'x'.repeat(200) } },++ { title: 'Elsewhere image', url: 'https://openvibe.live/elsewhere', image: { src: 'https://example.com/x.jpg' } },++ { title: 'Http image', url: 'https://openvibe.live/plain', image: { src: 'http://openvibe.live/x.jpg' } },{ title: 'Plain http', url: 'http://openvibe.live/x' },{ url: 'https://openvibe.live/x' },],@@@ -44,9 +46,15 @@ const clean = cardOf(dirty, SITES[0]);assert.deepStrictEqual(Object.keys(clean).sort(), ['id', 'items', 'kind', 'site', 'title', 'ttl_s', 'updated_at']);assert.strictEqual(clean.title, 'Live now');assert.deepStrictEqual(clean.items, [-- { title: 'Nova', meta: '12 watching', url: 'https://openvibe.live/@nova' },-- { title: 'Video host', url: 'https://openvibe.video/v/2' },--]);++ { title: 'Nova', meta: '12 watching', url: 'https://openvibe.live/@nova', image: { src: 'https://openvibe.live/t/nova.jpg?w=640', w: 640, h: 360, alt: 'Nova at the desk' } },++ { title: 'Video host', url: 'https://openvibe.video/v/2', image: { src: 'https://openvibe.media/t/2.jpg', h: 1440, alt: 'x'.repeat(120) } },++ { title: 'Elsewhere image', url: 'https://openvibe.live/elsewhere' },++ { title: 'Http image', url: 'https://openvibe.live/plain' },++], 'an image on an allowed host is kept and sanitized; one from anywhere else is dropped and the item stays');++assert.deepStrictEqual(Object.keys(clean.items[0].image).sort(), ['alt', 'h', 'src', 'w'], 'unknown image fields never reach the output');++assert.strictEqual(imageOf({ src: 'https://openvibe.live/x.jpg', w: 16, h: 1920 }, SITES[0]).w, 16, 'the smallest width is kept');++assert.strictEqual(imageOf({ src: 'https://openvibe.live/x.jpg', w: 1921 }, SITES[0]).w, undefined, 'a width over 1920 is dropped');++assert.strictEqual(imageOf({ src: 'https://openvibe.live/x.jpg', w: 15.5 }, SITES[0]).w, undefined, 'a fractional width is dropped');assert.strictEqual(cardOf(card('games'), SITES[0]), null, 'a card is refused by a host other than its site');assert.strictEqual(cardOf(card('live', { title: 'x'.repeat(200) }), SITES[0]).title.length, 60, 'text is cut to the schema');assert.strictEqual(cardOf(card('live', { ttl_s: 5 }), SITES[0]), null, 'a ttl under 30 is refused');@@@ -134,6 +142,7 @@ function response(body, { status = 200, headers = {} } = {}) {assert.strictEqual(r.status, 200);assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');assert.strictEqual(body.cards[0].site, 'live');++ assert.deepStrictEqual(body.cards[0].items[0].image, { src: 'https://openvibe.live/t/one.jpg', w: 640, h: 360 }, 'an image reaches GET /api/frame');srv.close();console.log('frame menu cards: sanitize, 64 KB, Live first, rank, 30-minute last good, /api/frame carries them');@@@ -93,14 +93,25 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;assert.ok(!/Notifications\d/.test(acts.textContent), 'no zero count');p.sandbox.currentUser = { role: 'streamer' };-- // On OpenVibe now: the sites' own cards, https entries only, an empty card left out.++ // On OpenVibe now: the sites' own cards, https entries and pictures only, an empty card left out.p.sandbox.renderNet({ cards: [-- { id: 'trending', site: 'media-hub', title: 'Trending', items: [{ title: 'A walk', url: 'https://openvibe.video/v/1', meta: '9 views this week' }, { title: 'Bad', url: 'javascript:alert(1)' }], more: { label: 'Watch more', url: 'https://openvibe.video/' } },++ { id: 'trending', site: 'media-hub', title: 'Trending', items: [++ { title: 'A walk', url: 'https://openvibe.video/v/1', meta: '9 views this week', image: { src: 'https://openvibe.media/t/1.jpg?w=640', w: 640, h: 360 } },++ { title: 'No picture', url: 'https://openvibe.video/v/2', image: { src: 'javascript:alert(1)' } },++ { title: 'Bad', url: 'javascript:alert(1)', image: { src: 'https://openvibe.media/t/3.jpg' } },++ ], more: { label: 'Watch more', url: 'https://openvibe.video/' } },{ id: 'empty', site: 'games', title: 'Nothing', items: [] },] });assert.strictEqual(p.ids['td-net'].hidden, false);-- assert.deepStrictEqual(links(p.ids['td-net-cards']), ['https://openvibe.video/', 'https://openvibe.video/v/1']);++ assert.deepStrictEqual(links(p.ids['td-net-cards']), ['https://openvibe.video/', 'https://openvibe.video/v/1', 'https://openvibe.video/v/2']);assert.match(p.ids['td-net-cards'].textContent, /TrendingVideo/);++ const thumbs = p.ids['td-net-cards'].all((c) => c.tagName === 'IMG');++ assert.strictEqual(thumbs.length, 1, 'only an https picture is drawn');++ assert.deepStrictEqual(++ ['src', 'alt', 'width', 'height', 'loading', 'decoding'].map((k) => thumbs[0].getAttribute(k)),++ ['https://openvibe.media/t/1.jpg?w=640', '', '64', '36', 'lazy', 'async'],++ 'a 64x36 lazy thumbnail with empty alt');++ assert.strictEqual(p.ids['td-net-cards'].all((c) => c.tagName === 'OL')[0].children[0].children[0].children.map((c) => c.className).join(','), 'td-net-i,td-net-thumb,td-net-t', 'number, picture, then the title');p.sandbox.renderNet(null);assert.strictEqual(p.ids['td-net'].hidden, true, 'no cards: the block stays out');
@@ -1027,7 +1027,7 @@ Every change also sends the person a Network notification, so a swap nobody aske
@@ -1027,7 +1027,7 @@ Every change also sends the person a Network notification, so a swap nobody aske
MIT (`license` in [package.json](package.json)).
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.319.0
+- openvibe-contracts: v0.320.0
- openvibe-sdk: v0.94.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.129.0
- openvibe-shared: v4.129.0
<!-- versions:end -->
<!-- versions:end -->
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
- "contracts": "openvibe-contracts v0.319.0",
+ "contracts": "openvibe-contracts v0.320.0",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.129.0",
"shared": "openvibe-shared v4.129.0",
"capabilities": [
"capabilities": [
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.319.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2303,9 +2303,9 @@
@@ -2303,9 +2303,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.319.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.319.0",
- "integrity": "sha512-v0HSuq5d4XZ3tKfXRDgNcmO+M6pQVMsl/pWOhAGRfzSIdjV/G1s9mnsoi7WXhpsJiZ3kEYPP1ie2viyJ+Lg/Zg==",
+ "version": "0.320.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0",
+ "integrity": "sha512-ECShhtflTLicQosxVSEPlBFQn9eSnOUyO//Z2oS2NPscvb5RtZusrVq3xYWcJIs9XiJ7ws2Q9pAWPaRZIpEvwQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.319.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -579,6 +579,7 @@ button.td-stat:hover span { color: var(--accent-light); }
@@ -579,6 +579,7 @@ button.td-stat:hover span { color: var(--accent-light); }
.td-net-list li + li .td-net-row { border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); border-radius: 0; }
.td-net-list li + li .td-net-row { border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); border-radius: 0; }
.td-net-list li + li .td-net-row:hover { border-radius: 10px; }
.td-net-list li + li .td-net-row:hover { border-radius: 10px; }
.td-net-i { flex: none; width: 22px; font-size: 15px; font-weight: 850; color: var(--text-muted); text-align: center; font-variant-numeric: tabular-nums; }
.td-net-i { flex: none; width: 22px; font-size: 15px; font-weight: 850; color: var(--text-muted); text-align: center; font-variant-numeric: tabular-nums; }
+.td-net-thumb { flex: none; width: 64px; height: 36px; border-radius: 8px; object-fit: cover; background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }
.td-net-list li:first-child .td-net-i { color: var(--accent-light); }
.td-net-list li:first-child .td-net-i { color: var(--accent-light); }
.td-net-t { display: grid; min-width: 0; }
.td-net-t { display: grid; min-width: 0; }
.td-net-t b { font-size: 14px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.td-net-t b { font-size: 14px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -128,10 +128,14 @@ function renderNet(frame) {
@@ -128,10 +128,14 @@ function renderNet(frame) {
wrap.replaceChildren(...cards.map((c) => {
wrap.replaceChildren(...cards.map((c) => {
const site = TD_SITES[c.site] || [c.site || 'OpenVibe', 'fa-globe', null];
const site = TD_SITES[c.site] || [c.site || 'OpenVibe', 'fa-globe', null];
const rows = c.items.filter((it) => tdSafeUrl(it.url)).slice(0, 5);
const rows = c.items.filter((it) => tdSafeUrl(it.url)).slice(0, 5);
- const list = tdEl('ol', 'td-net-list', rows.map((it, i) => tdEl('li', '', [tdEl('a', 'td-net-row', [
- tdEl('span', 'td-net-i', [String(i + 1)]),
- tdEl('span', 'td-net-t', [tdEl('b', '', [String(it.title || '')]), it.meta ? tdEl('small', '', [String(it.meta)]) : null]),
- ], { href: tdSafeUrl(it.url) })])));
+ const list = tdEl('ol', 'td-net-list', rows.map((it, i) => {
+ const src = it.image && tdSafeUrl(it.image.src);
+ return tdEl('li', '', [tdEl('a', 'td-net-row', [
+ tdEl('span', 'td-net-i', [String(i + 1)]),
+ src ? tdEl('img', 'td-net-thumb', null, { src, alt: '', width: 64, height: 36, loading: 'lazy', decoding: 'async' }) : null,
+ tdEl('span', 'td-net-t', [tdEl('b', '', [String(it.title || '')]), it.meta ? tdEl('small', '', [String(it.meta)]) : null]),
+ ], { href: tdSafeUrl(it.url) })]);
+ }));
const more = c.more && tdSafeUrl(c.more.url) ? tdEl('a', 'td-more', [String(c.more.label || 'More')], { href: tdSafeUrl(c.more.url) }) : null;
const more = c.more && tdSafeUrl(c.more.url) ? tdEl('a', 'td-more', [String(c.more.label || 'More')], { href: tdSafeUrl(c.more.url) }) : null;
return tdEl('section', 'td-box td-net-card', [
return tdEl('section', 'td-box td-net-card', [
tdEl('div', 'td-net-h', [tdEl('span', 'td-site-ic', [tdIcon(site[1])]), tdEl('span', 'td-net-ht', [tdEl('b', '', [String(c.title || site[0])]), tdEl('small', '', [site[0]])]), more]),
tdEl('div', 'td-net-h', [tdEl('span', 'td-site-ic', [tdIcon(site[1])]), tdEl('span', 'td-net-ht', [tdEl('b', '', [String(c.title || site[0])]), tdEl('small', '', [site[0]])]), more]),
@@ -6,8 +6,8 @@
@@ -6,8 +6,8 @@
* A card is frame.menu-card@1: a rail, rows or chips of up to six items. The fetch is small and strict —
* A card is frame.menu-card@1: a rail, rows or chips of up to six items. The fetch is small and strict —
* a five-second timeout, 64 KB at most, no redirects, JSON only — and every card is checked against the contract
* a five-second timeout, 64 KB at most, no redirects, JSON only — and every card is checked against the contract
* after unknown fields are dropped and its text is stripped of markup and cut to the schema's limits. An item's
* after unknown fields are dropped and its text is stripped of markup and cut to the schema's limits. An item's
- * URL must be https on the publishing site's own host or an openvibe.* host, and the card's `site` must be the
- * site that served it. A site that fails keeps its last good cards for 30 minutes, then contributes none. The
+ * URL and its optional image `src` must be https on the publishing site's own host or an openvibe.* host (an image
+ * from anywhere else is dropped and the item stays), and the card's `site` must be the site that served it. A site that fails keeps its last good cards for 30 minutes, then contributes none. The
* result is kept in frame_cache, so a restart starts from the last good set rather than an empty menu.
* result is kept in frame_cache, so a restart starts from the last good set rather than an empty menu.
*
*
* Ranking: cards with items only; Live's card first while anyone is live, then the site's place in the Frame's
* Ranking: cards with items only; Live's card first while anyone is live, then the site's place in the Frame's
@@ -47,7 +47,25 @@ function cardUrl(value, site) {
@@ -47,7 +47,25 @@ function cardUrl(value, site) {
return raw;
return raw;
}
}
-/** One item ({ title, meta?, url }), or null. */
+/**
+ * One item image ({ src, w?, h?, alt? }), or null. A picture's src follows the item url's rule (https on the
+ * site's own host or an openvibe.* host); an image from anywhere else is dropped and the item stays. A width or
+ * height the schema would refuse is dropped on its own, and alt is plain text cut to its limit.
+ */
+function imageOf(raw, site) {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
+ const src = cardUrl(raw.src, site);
+ if (!src) return null;
+ const image = { src };
+ for (const k of ['w', 'h']) {
+ const n = Number(raw[k]);
+ if (raw[k] !== undefined && Number.isInteger(n) && n >= 16 && n <= 1920) image[k] = n;
+ }
+ if (raw.alt !== undefined) image.alt = text(raw.alt, 120);
+ return image;
+}
+
+/** One item ({ title, meta?, url, image? }), or null. */
function itemOf(raw, site) {
function itemOf(raw, site) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const title = text(raw.title, 80);
const title = text(raw.title, 80);
@@ -56,6 +74,8 @@ function itemOf(raw, site) {
@@ -56,6 +74,8 @@ function itemOf(raw, site) {
const item = { title, url };
const item = { title, url };
const meta = text(raw.meta, 120);
const meta = text(raw.meta, 120);
if (raw.meta !== undefined) item.meta = meta;
if (raw.meta !== undefined) item.meta = meta;
+ const image = imageOf(raw.image, site);
+ if (image) item.image = image;
return item;
return item;
}
}
@@ -238,4 +258,4 @@ function createMenuCards({ sites, rankOf, load, save, onChange = () => {}, fetch
@@ -238,4 +258,4 @@ function createMenuCards({ sites, rankOf, load, save, onChange = () => {}, fetch
return { init, refresh, cards, start, stop, _state: () => state };
return { init, refresh, cards, start, stop, _state: () => state };
}
}
-module.exports = { createMenuCards, cardOf, cardsOf, text, cardUrl, MAX_CARDS, KEEP_MS, REFRESH_MS, PATH };
+module.exports = { createMenuCards, cardOf, cardsOf, text, cardUrl, imageOf, MAX_CARDS, KEEP_MS, REFRESH_MS, PATH };
@@ -2,14 +2,14 @@
@@ -2,14 +2,14 @@
// The menu's network cards (frame.menu-card@1): Network fetches every open site's /.well-known/openvibe-menu.json
// The menu's network cards (frame.menu-card@1): Network fetches every open site's /.well-known/openvibe-menu.json
// every five minutes (5 s, 64 KB, no redirects), drops unknown fields and markup, validates against the contract,
// every five minutes (5 s, 64 KB, no redirects), drops unknown fields and markup, validates against the contract,
// keeps a failed site's last good cards for 30 minutes, sends at most four with Live first while anyone is live,
// keeps a failed site's last good cards for 30 minutes, sends at most four with Live first while anyone is live,
-// then in Frame rank order, and carries them in GET /api/frame. One migrated test database, stubbed fetches.
+// then in Frame rank order, and carries them in GET /api/frame, images included. One migrated test database, stubbed fetches.
// node test/frame-cards.test.js
// node test/frame-cards.test.js
const assert = require('assert');
const assert = require('assert');
const express = require('express');
const express = require('express');
const http = require('http');
const http = require('http');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
const { createFrameService } = require('../server/frame/service');
const { createFrameService } = require('../server/frame/service');
-const { createMenuCards, cardOf, cardsOf } = require('../server/frame/menu-cards');
+const { createMenuCards, cardOf, cardsOf, imageOf } = require('../server/frame/menu-cards');
const SITES = [
const SITES = [
{ id: 'live', host: 'openvibe.live' },
{ id: 'live', host: 'openvibe.live' },
@@ -22,7 +22,7 @@ const card = (site, over = {}) => ({
@@ -22,7 +22,7 @@ const card = (site, over = {}) => ({
site,
site,
title: 'The card',
title: 'The card',
kind: 'rows',
kind: 'rows',
- items: [{ title: 'One', meta: '1 watching', url: `https://${SITES.find((s) => s.id === site).host}/one` }],
+ items: [{ title: 'One', meta: '1 watching', url: `https://${SITES.find((s) => s.id === site).host}/one`, image: { src: `https://${SITES.find((s) => s.id === site).host}/t/one.jpg`, w: 640, h: 360 } }],
updated_at: '2026-11-07T08:00:00Z',
updated_at: '2026-11-07T08:00:00Z',
ttl_s: 30,
ttl_s: 30,
...over,
...over,
@@ -33,9 +33,11 @@ const dirty = card('live', {
@@ -33,9 +33,11 @@ const dirty = card('live', {
extra: 'dropped',
extra: 'dropped',
title: ' Live <b>now</b> ',
title: ' Live <b>now</b> ',
items: [
items: [
- { title: '<i>Nova</i>', meta: '<b>12</b> watching', url: 'https://openvibe.live/@nova', extra: 1 },
+ { title: '<i>Nova</i>', meta: '<b>12</b> watching', url: 'https://openvibe.live/@nova', extra: 1, image: { src: 'https://openvibe.live/t/nova.jpg?w=640', w: 640, h: 360, alt: '<b>Nova</b> at the desk', credit: 'dropped' } },
{ title: 'Elsewhere', url: 'https://example.com/x' },
{ title: 'Elsewhere', url: 'https://example.com/x' },
- { title: 'Video host', url: 'https://openvibe.video/v/2' },
+ { title: 'Video host', url: 'https://openvibe.video/v/2', image: { src: 'https://openvibe.media/t/2.jpg', w: 8, h: 1440, alt: 'x'.repeat(200) } },
+ { title: 'Elsewhere image', url: 'https://openvibe.live/elsewhere', image: { src: 'https://example.com/x.jpg' } },
+ { title: 'Http image', url: 'https://openvibe.live/plain', image: { src: 'http://openvibe.live/x.jpg' } },
{ title: 'Plain http', url: 'http://openvibe.live/x' },
{ title: 'Plain http', url: 'http://openvibe.live/x' },
{ url: 'https://openvibe.live/x' },
{ url: 'https://openvibe.live/x' },
],
],
@@ -44,9 +46,15 @@ const clean = cardOf(dirty, SITES[0]);
@@ -44,9 +46,15 @@ const clean = cardOf(dirty, SITES[0]);
assert.deepStrictEqual(Object.keys(clean).sort(), ['id', 'items', 'kind', 'site', 'title', 'ttl_s', 'updated_at']);
assert.deepStrictEqual(Object.keys(clean).sort(), ['id', 'items', 'kind', 'site', 'title', 'ttl_s', 'updated_at']);
assert.strictEqual(clean.title, 'Live now');
assert.strictEqual(clean.title, 'Live now');
assert.deepStrictEqual(clean.items, [
assert.deepStrictEqual(clean.items, [
- { title: 'Nova', meta: '12 watching', url: 'https://openvibe.live/@nova' },
- { title: 'Video host', url: 'https://openvibe.video/v/2' },
-]);
+ { title: 'Nova', meta: '12 watching', url: 'https://openvibe.live/@nova', image: { src: 'https://openvibe.live/t/nova.jpg?w=640', w: 640, h: 360, alt: 'Nova at the desk' } },
+ { title: 'Video host', url: 'https://openvibe.video/v/2', image: { src: 'https://openvibe.media/t/2.jpg', h: 1440, alt: 'x'.repeat(120) } },
+ { title: 'Elsewhere image', url: 'https://openvibe.live/elsewhere' },
+ { title: 'Http image', url: 'https://openvibe.live/plain' },
+], 'an image on an allowed host is kept and sanitized; one from anywhere else is dropped and the item stays');
+assert.deepStrictEqual(Object.keys(clean.items[0].image).sort(), ['alt', 'h', 'src', 'w'], 'unknown image fields never reach the output');
+assert.strictEqual(imageOf({ src: 'https://openvibe.live/x.jpg', w: 16, h: 1920 }, SITES[0]).w, 16, 'the smallest width is kept');
+assert.strictEqual(imageOf({ src: 'https://openvibe.live/x.jpg', w: 1921 }, SITES[0]).w, undefined, 'a width over 1920 is dropped');
+assert.strictEqual(imageOf({ src: 'https://openvibe.live/x.jpg', w: 15.5 }, SITES[0]).w, undefined, 'a fractional width is dropped');
assert.strictEqual(cardOf(card('games'), SITES[0]), null, 'a card is refused by a host other than its site');
assert.strictEqual(cardOf(card('games'), SITES[0]), null, 'a card is refused by a host other than its site');
assert.strictEqual(cardOf(card('live', { title: 'x'.repeat(200) }), SITES[0]).title.length, 60, 'text is cut to the schema');
assert.strictEqual(cardOf(card('live', { title: 'x'.repeat(200) }), SITES[0]).title.length, 60, 'text is cut to the schema');
assert.strictEqual(cardOf(card('live', { ttl_s: 5 }), SITES[0]), null, 'a ttl under 30 is refused');
assert.strictEqual(cardOf(card('live', { ttl_s: 5 }), SITES[0]), null, 'a ttl under 30 is refused');
@@ -134,6 +142,7 @@ function response(body, { status = 200, headers = {} } = {}) {
@@ -134,6 +142,7 @@ function response(body, { status = 200, headers = {} } = {}) {
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
assert.strictEqual(body.cards[0].site, 'live');
assert.strictEqual(body.cards[0].site, 'live');
+ assert.deepStrictEqual(body.cards[0].items[0].image, { src: 'https://openvibe.live/t/one.jpg', w: 640, h: 360 }, 'an image reaches GET /api/frame');
srv.close();
srv.close();
console.log('frame menu cards: sanitize, 64 KB, Live first, rank, 30-minute last good, /api/frame carries them');
console.log('frame menu cards: sanitize, 64 KB, Live first, rank, 30-minute last good, /api/frame carries them');
@@ -93,14 +93,25 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
@@ -93,14 +93,25 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
assert.ok(!/Notifications\d/.test(acts.textContent), 'no zero count');
assert.ok(!/Notifications\d/.test(acts.textContent), 'no zero count');
p.sandbox.currentUser = { role: 'streamer' };
p.sandbox.currentUser = { role: 'streamer' };
- // On OpenVibe now: the sites' own cards, https entries only, an empty card left out.
+ // On OpenVibe now: the sites' own cards, https entries and pictures only, an empty card left out.
p.sandbox.renderNet({ cards: [
p.sandbox.renderNet({ cards: [
- { id: 'trending', site: 'media-hub', title: 'Trending', items: [{ title: 'A walk', url: 'https://openvibe.video/v/1', meta: '9 views this week' }, { title: 'Bad', url: 'javascript:alert(1)' }], more: { label: 'Watch more', url: 'https://openvibe.video/' } },
+ { id: 'trending', site: 'media-hub', title: 'Trending', items: [
+ { title: 'A walk', url: 'https://openvibe.video/v/1', meta: '9 views this week', image: { src: 'https://openvibe.media/t/1.jpg?w=640', w: 640, h: 360 } },
+ { title: 'No picture', url: 'https://openvibe.video/v/2', image: { src: 'javascript:alert(1)' } },
+ { title: 'Bad', url: 'javascript:alert(1)', image: { src: 'https://openvibe.media/t/3.jpg' } },
+ ], more: { label: 'Watch more', url: 'https://openvibe.video/' } },
{ id: 'empty', site: 'games', title: 'Nothing', items: [] },
{ id: 'empty', site: 'games', title: 'Nothing', items: [] },
] });
] });
assert.strictEqual(p.ids['td-net'].hidden, false);
assert.strictEqual(p.ids['td-net'].hidden, false);
- assert.deepStrictEqual(links(p.ids['td-net-cards']), ['https://openvibe.video/', 'https://openvibe.video/v/1']);
+ assert.deepStrictEqual(links(p.ids['td-net-cards']), ['https://openvibe.video/', 'https://openvibe.video/v/1', 'https://openvibe.video/v/2']);
assert.match(p.ids['td-net-cards'].textContent, /TrendingVideo/);
assert.match(p.ids['td-net-cards'].textContent, /TrendingVideo/);
+ const thumbs = p.ids['td-net-cards'].all((c) => c.tagName === 'IMG');
+ assert.strictEqual(thumbs.length, 1, 'only an https picture is drawn');
+ assert.deepStrictEqual(
+ ['src', 'alt', 'width', 'height', 'loading', 'decoding'].map((k) => thumbs[0].getAttribute(k)),
+ ['https://openvibe.media/t/1.jpg?w=640', '', '64', '36', 'lazy', 'async'],
+ 'a 64x36 lazy thumbnail with empty alt');
+ assert.strictEqual(p.ids['td-net-cards'].all((c) => c.tagName === 'OL')[0].children[0].children[0].children.map((c) => c.className).join(','), 'td-net-i,td-net-thumb,td-net-t', 'number, picture, then the title');
p.sandbox.renderNet(null);
p.sandbox.renderNet(null);
assert.strictEqual(p.ids['td-net'].hidden, true, 'no cards: the block stays out');
assert.strictEqual(p.ids['td-net'].hidden, true, 'no cards: the block stays out');