openvibers/openvibe.network Commits

Menu cards carry their images, and /my draws them (#628)

OP OpenVibers committed 6 days ago · a02f2a1dfc · 9 files, +71 −26

Sanitize and keep frame.menu-card@1 item images: https on the site's own host or an openvibe.* host, with optional 16-1920 w/h and a 120-char alt; drop an image from anywhere else and keep the item. Pass them through in GET /api/frame, draw a 64x36 lazy thumbnail before each /my title, and pin openvibe-contracts v0.320.0.

Changes

ddiff --git a/README.md b/README.md
iindex dfc96379..d7858145 100644
---- a/README.md
++++ b/README.md
@@@ -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)).
<!-- versions:start -->
--- openvibe-contracts: v0.319.0
++- openvibe-contracts: v0.320.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.129.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex c8f26632..130461f2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 241e23ee..11c27b31 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex f869ed51..55a06b05 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/css/my.css b/public/css/my.css
iindex c63c5c61..b2478409 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -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; }
ddiff --git a/public/js/my/today.js b/public/js/my/today.js
iindex 779c8025..72f04517 100644
---- a/public/js/my/today.js
++++ b/public/js/my/today.js
@@@ -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]),
ddiff --git a/server/frame/menu-cards.js b/server/frame/menu-cards.js
iindex b8579f09..88445dca 100644
---- a/server/frame/menu-cards.js
++++ b/server/frame/menu-cards.js
@@@ -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 = () => {}, fetch
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 };
ddiff --git a/test/frame-cards.test.js b/test/frame-cards.test.js
iindex 91f67ab4..c6d85ddb 100644
---- a/test/frame-cards.test.js
++++ b/test/frame-cards.test.js
@@@ -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.js
const 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');
ddiff --git a/test/my-today.test.js b/test/my-today.test.js
iindex 5adf40cd..f3ffb1f5 100644
---- a/test/my-today.test.js
++++ b/test/my-today.test.js
@@@ -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');