openvibers/openvibe.live Commits

Live: social cards' pictures come through Live's image proxy (a Twitch avatar was blocked); an image-only panel link has a name; the weather strip takes focus (#120)

OP OpenVibers committed 14 Oct 2026 · 1c46daee24 · 4 files, +35 −5

The Live browser check after #119 failed on one channel, /@goosely, with three findings. None comes from #119.

**1. A social card's avatar was blocked by the CSP** (console errors at 768 and 1280 px)
- The channel's Twitch card showed the account's avatar straight from `static-cdn.jtvnw.net`, which Live's `img-src` does not allow.
- The social preview cards use pictures from every platform's CDN (Bluesky, Mastodon, GitHub, Twitch, YouTube, …).
- Fix: the preview route now answers every picture (avatar, live thumbnail, post images) through Live's own `/api/img-proxy` (`forPage` in `server/social/preview.js`).
- The page needs no list of CDNs, and a viewer's browser never asks the platform itself, the same privacy rule the offline screen and panels already follow.
- Plain-http pictures are dropped. Checked: the proxy serves that Twitch avatar (200 image/png).

**2. axe `link-name`: an image-only About panel link had no name**
- The panel `<a href="https://Hobo.Tools"><img alt=""></a>` now carries `aria-label`: the panel's title, else "Open <host>".

**3. axe `scrollable-region-focusable`: the weather panel's hourly strip**
- The strip scrolls sideways but could not be reached by keyboard. It is now `tabindex="0"`, `role="group"`, `aria-label="Hourly forecast"`.

**Tests**
- `social-links` gains a check: every preview picture goes through `/api/img-proxy`, a plain-http one is dropped, and the route answers `forPage(p)`.
- The full suite passes: 169/171 (the 2 usual skips).

Changes

ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 1747ba65..00ee10ff 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -1341,7 +1341,7 @@ function renderWeatherWidget(data) {
if (hasHourly) {
const utcOff = data.utc_offset_seconds;
html += `<div class="weather-hourly weather-tab-panel" data-panel="hourly">`;
-- html += `<div class="weather-hourly-scroll">`;
++ html += `<div class="weather-hourly-scroll" tabindex="0" role="group" aria-label="Hourly forecast">`;
for (const h of data.hourly) {
const hw = getWeatherInfo(h.weather_code, true);
const isCurrent = isCurrentHour(h.time, utcOff);
@@@ -2495,6 +2495,12 @@ function _startCaptions(username, displayName, isLive) {
function _stopCaptions() { if (_captionsTimer) { clearInterval(_captionsTimer); _captionsTimer = null; } }
function toggleCaptionsPanel() { document.getElementById('ch-captions')?.classList.toggle('collapsed'); }
++// An image-only panel link needs a name for screen readers: the panel's title, else where it goes.
++function _panelLinkName(p) {
++ if (p.title) return p.title;
++ try { return `Open ${new URL(p.link).hostname.replace(/^www\./i, '')}`; } catch { return 'Open link'; }
++}
++
function _aboutPanelViewHTML(p, i) {
const w = ABOUT_WIDTHS.includes(p.width) ? p.width : 'md';
if (p.type === 'weather') {
@@@ -2504,7 +2510,7 @@ function _aboutPanelViewHTML(p, i) {
</div>`;
}
const img = p.image
-- ? (p.link ? `<a href="${esc(p.link)}" target="_blank" rel="noopener"><img src="${esc(p.image)}" alt="" loading="lazy"></a>`
++ ? (p.link ? `<a href="${esc(p.link)}" target="_blank" rel="noopener" aria-label="${esc(_panelLinkName(p))}"><img src="${esc(p.image)}" alt="" loading="lazy"></a>`
: `<img src="${esc(p.image)}" alt="" loading="lazy">`)
: '';
const title = p.title ? `<div class="ch-about-panel-title">${esc(p.title)}</div>` : '';
ddiff --git a/server/social/preview.js b/server/social/preview.js
iindex 707485a9..a3473a53 100644
---- a/server/social/preview.js
++++ b/server/social/preview.js
@@@ -38,6 +38,20 @@ const clip = (s, n = 280) => { const t = decode(String(s || '').replace(/<[^>]+>
const RARITY = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };
const httpsOnly = (u) => (typeof u === 'string' && /^https:\/\//i.test(u) ? u : null);
++/**
++ * A preview as the channel page shows it: every picture (avatar, live thumbnail, post image) through Live's image proxy
++ * (/api/img-proxy), so a viewer's browser never asks the platform itself. That keeps viewers private, and the page's
++ * img-src needs no list of every platform's CDN (a Twitch avatar on static-cdn.jtvnw.net was blocked).
++ */
++function forPage(p) {
++ if (!p || typeof p !== 'object') return p;
++ const via = (u) => (httpsOnly(u) ? `/api/img-proxy?url=${encodeURIComponent(u)}` : null);
++ const out = { ...p, image: via(p.image) };
++ if (p.live) out.live = { ...p.live, thumbnail: via(p.live.thumbnail) };
++ if (Array.isArray(p.items)) out.items = p.items.map((it) => ({ ...it, image: via(it.image) }));
++ return out;
++}
++
/** OpenGraph / meta tags of a page. */
async function openGraph(url) {
const r = await getText(url);
@@@ -180,4 +194,4 @@ function remember(key, value, ttl) {
cache.set(key, { at: Date.now(), ttl, value });
}
--module.exports = { preview, openGraph, ADAPTERS, _cache: cache };
++module.exports = { preview, openGraph, ADAPTERS, _cache: cache, forPage };
ddiff --git a/server/social/routes.js b/server/social/routes.js
iindex 9b1504fc..b408edd3 100644
---- a/server/social/routes.js
++++ b/server/social/routes.js
@@@ -12,7 +12,7 @@
const express = require('express');
const db = require('../db/database');
const { channelSocialLinks, catalog } = require('./links');
--const { preview } = require('./preview');
++const { preview, forPage } = require('./preview');
const cache = require('openvibe-shared/cache-policy');
const router = express.Router();
@@@ -33,7 +33,7 @@ router.get('/preview/:username/:index', async (req, res) => {
if (link.preview === false) return res.json({ kind: link.kind, url: link.url, items: [], disabled: true });
const p = await preview(link);
res.set('Cache-Control', 'public, max-age=300');
-- res.json(p);
++ res.json(forPage(p));
} catch (err) {
console.error('[Social] preview error:', err.message);
res.status(500).json({ error: 'Preview failed' });
ddiff --git a/test/social-links.test.js b/test/social-links.test.js
iindex 43955fe1..84df4c1c 100644
---- a/test/social-links.test.js
++++ b/test/social-links.test.js
@@@ -119,5 +119,15 @@ async function check(name, fn) { await fn(); passed++; console.log(` ✓ ${name
assert.ok(/\/preview\/:username\/:index/.test(src));
assert.ok(!/req\.query\.url/.test(src));
});
++ await check('the page gets every preview picture through /api/img-proxy, never the platform\'s own CDN', async () => {
++ const { forPage } = require('../server/social/preview');
++ const avatar = 'https://static-cdn.jtvnw.net/user-default-pictures-uv/x-profile_image-300x300.png';
++ const p = forPage({ title: 'goosely', image: avatar, live: { live: true, thumbnail: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_goosely-440x248.jpg?t=1' }, items: [{ text: 'hi', image: 'https://cdn.bsky.app/img/a.jpg' }, { text: 'no picture' }] });
++ assert.strictEqual(p.image, `/api/img-proxy?url=${encodeURIComponent(avatar)}`);
++ assert.ok(p.live.thumbnail.startsWith('/api/img-proxy?url=https%3A%2F%2Fstatic-cdn.jtvnw.net'));
++ assert.deepStrictEqual(p.items.map((i) => i.image && i.image.slice(0, 15)), ['/api/img-proxy?', null]);
++ assert.strictEqual(forPage({ title: 'x', image: 'http://plain.example/a.png' }).image, null, 'never a plain-http picture');
++ assert.match(require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'social', 'routes.js'), 'utf8'), /res\.json\(forPage\(p\)\)/);
++ });
console.log(`social-links: ${passed} checks passed`);
})().catch((err) => { console.error(err); process.exit(1); });