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)
Changes
@@@ -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>` : '';@@@ -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 };@@@ -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' });@@@ -119,5 +119,15 @@ async function check(name, fn) { await fn(); passed++; console.log(` ✓ ${nameassert.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); });
@@ -1341,7 +1341,7 @@ function renderWeatherWidget(data) {
@@ -1341,7 +1341,7 @@ function renderWeatherWidget(data) {
if (hasHourly) {
if (hasHourly) {
const utcOff = data.utc_offset_seconds;
const utcOff = data.utc_offset_seconds;
html += `<div class="weather-hourly weather-tab-panel" data-panel="hourly">`;
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) {
for (const h of data.hourly) {
const hw = getWeatherInfo(h.weather_code, true);
const hw = getWeatherInfo(h.weather_code, true);
const isCurrent = isCurrentHour(h.time, utcOff);
const isCurrent = isCurrentHour(h.time, utcOff);
@@ -2495,6 +2495,12 @@ function _startCaptions(username, displayName, isLive) {
@@ -2495,6 +2495,12 @@ function _startCaptions(username, displayName, isLive) {
function _stopCaptions() { if (_captionsTimer) { clearInterval(_captionsTimer); _captionsTimer = null; } }
function _stopCaptions() { if (_captionsTimer) { clearInterval(_captionsTimer); _captionsTimer = null; } }
function toggleCaptionsPanel() { document.getElementById('ch-captions')?.classList.toggle('collapsed'); }
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) {
function _aboutPanelViewHTML(p, i) {
const w = ABOUT_WIDTHS.includes(p.width) ? p.width : 'md';
const w = ABOUT_WIDTHS.includes(p.width) ? p.width : 'md';
if (p.type === 'weather') {
if (p.type === 'weather') {
@@ -2504,7 +2510,7 @@ function _aboutPanelViewHTML(p, i) {
@@ -2504,7 +2510,7 @@ function _aboutPanelViewHTML(p, i) {
</div>`;
</div>`;
}
}
const img = p.image
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">`)
: `<img src="${esc(p.image)}" alt="" loading="lazy">`)
: '';
: '';
const title = p.title ? `<div class="ch-about-panel-title">${esc(p.title)}</div>` : '';
const title = p.title ? `<div class="ch-about-panel-title">${esc(p.title)}</div>` : '';
@@ -38,6 +38,20 @@ const clip = (s, n = 280) => { const t = decode(String(s || '').replace(/<[^>]+>
@@ -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 RARITY = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };
const httpsOnly = (u) => (typeof u === 'string' && /^https:\/\//i.test(u) ? u : null);
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. */
/** OpenGraph / meta tags of a page. */
async function openGraph(url) {
async function openGraph(url) {
const r = await getText(url);
const r = await getText(url);
@@ -180,4 +194,4 @@ function remember(key, value, ttl) {
@@ -180,4 +194,4 @@ function remember(key, value, ttl) {
cache.set(key, { at: Date.now(), ttl, value });
cache.set(key, { at: Date.now(), ttl, value });
}
}
-module.exports = { preview, openGraph, ADAPTERS, _cache: cache };
+module.exports = { preview, openGraph, ADAPTERS, _cache: cache, forPage };
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
const express = require('express');
const express = require('express');
const db = require('../db/database');
const db = require('../db/database');
const { channelSocialLinks, catalog } = require('./links');
const { channelSocialLinks, catalog } = require('./links');
-const { preview } = require('./preview');
+const { preview, forPage } = require('./preview');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const router = express.Router();
const router = express.Router();
@@ -33,7 +33,7 @@ router.get('/preview/:username/:index', async (req, res) => {
@@ -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 });
if (link.preview === false) return res.json({ kind: link.kind, url: link.url, items: [], disabled: true });
const p = await preview(link);
const p = await preview(link);
res.set('Cache-Control', 'public, max-age=300');
res.set('Cache-Control', 'public, max-age=300');
- res.json(p);
+ res.json(forPage(p));
} catch (err) {
} catch (err) {
console.error('[Social] preview error:', err.message);
console.error('[Social] preview error:', err.message);
res.status(500).json({ error: 'Preview failed' });
res.status(500).json({ error: 'Preview failed' });
@@ -119,5 +119,15 @@ async function check(name, fn) { await fn(); passed++; console.log(` ✓ ${name
@@ -119,5 +119,15 @@ async function check(name, fn) { await fn(); passed++; console.log(` ✓ ${name
assert.ok(/\/preview\/:username\/:index/.test(src));
assert.ok(/\/preview\/:username\/:index/.test(src));
assert.ok(!/req\.query\.url/.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`);
console.log(`social-links: ${passed} checks passed`);
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });