Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion api/youtube/live.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,15 @@ export default async function handler(request) {
}
}

return new Response(JSON.stringify({ videoId, isLive: videoId !== null, channelExists, channelName }), {
// Extract HLS manifest URL for native playback (available for live streams).
// The URL is in streamingData and contains a signed googlevideo.com manifest.
let hlsUrl = null;
const hlsMatch = html.match(/"hlsManifestUrl"\s*:\s*"([^"]+)"/);
if (hlsMatch && videoId) {
hlsUrl = hlsMatch[1].replace(/\\u0026/g, '&');
}

return new Response(JSON.stringify({ videoId, isLive: videoId !== null, channelExists, channelName, hlsUrl }), {
status: 200,
headers: {
'Content-Type': 'application/json',
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' https://worldmonitor.app https://tech.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:* https://worldmonitor.app https://tech.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" />
<meta name="referrer" content="strict-origin-when-cross-origin" />

<!-- Primary Meta Tags -->
Expand Down
236 changes: 236 additions & 0 deletions scripts/validate-rss-feeds.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
#!/usr/bin/env node

import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { XMLParser } from 'fast-xml-parser';

const __dirname = dirname(fileURLToPath(import.meta.url));
const FEEDS_PATH = join(__dirname, '..', 'src', 'config', 'feeds.ts');

const CHROME_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
const FETCH_TIMEOUT = 15_000;
const CONCURRENCY = 10;
const STALE_DAYS = 30;

function extractFeeds() {
const src = readFileSync(FEEDS_PATH, 'utf8');
const feeds = [];
const seen = new Set();

// Match rss('url') or railwayRss('url') — capture raw URL
const rssUrlRe = /(?:rss|railwayRss)\(\s*'([^']+)'\s*\)/g;
// Match name: 'X' or name: "X" (Tom's Hardware uses double quotes)
const nameRe = /name:\s*(?:'([^']+)'+|"([^"]+)")/;
// Match lang key like `en: rss(`, `fr: rss(` — find all on a line with positions
const langKeyAllRe = /(?:^|[\s{,])([a-z]{2}):\s*(?:rss|railwayRss)\(/g;

const lines = src.split('\n');
let currentName = null;

for (let i = 0; i < lines.length; i++) {
const line = lines[i];

const nameMatch = line.match(nameRe);
if (nameMatch) currentName = nameMatch[1] || nameMatch[2];

// Build position→lang map for this line
const langMap = [];
let lm;
langKeyAllRe.lastIndex = 0;
while ((lm = langKeyAllRe.exec(line)) !== null) {
langMap.push({ pos: lm.index, lang: lm[1] });
}

let m;
rssUrlRe.lastIndex = 0;
while ((m = rssUrlRe.exec(line)) !== null) {
const rawUrl = m[1];
const rssPos = m.index;

// Find the closest preceding lang key for this rss() call
let lang = null;
for (let k = langMap.length - 1; k >= 0; k--) {
if (langMap[k].pos < rssPos) { lang = langMap[k].lang; break; }
}

const label = lang ? `${currentName} [${lang}]` : currentName;
const key = `${label}|${rawUrl}`;

if (!seen.has(key)) {
seen.add(key);
feeds.push({ name: label || 'Unknown', url: rawUrl });
}
}
}

// Also pick up non-rss() URLs like '/api/fwdstart'
const directUrlRe = /name:\s*'([^']+)'[^}]*url:\s*'(\/[^']+)'/g;
let dm;
while ((dm = directUrlRe.exec(src)) !== null) {
const key = `${dm[1]}|${dm[2]}`;
if (!seen.has(key)) {
seen.add(key);
feeds.push({ name: dm[1], url: dm[2], isLocal: true });
}
}

return feeds;
}

async function fetchFeed(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
try {
const resp = await fetch(url, {
signal: controller.signal,
headers: { 'User-Agent': CHROME_UA, 'Accept': 'application/rss+xml, application/xml, text/xml, */*' },
redirect: 'follow',
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return await resp.text();
} finally {
clearTimeout(timer);
}
}

function parseNewestDate(xml) {
const parser = new XMLParser({ ignoreAttributes: false });
const doc = parser.parse(xml);

const dates = [];

// RSS 2.0
const channel = doc?.rss?.channel;
if (channel) {
const items = Array.isArray(channel.item) ? channel.item : channel.item ? [channel.item] : [];
for (const item of items) {
if (item.pubDate) dates.push(new Date(item.pubDate));
}
}

// Atom
const atomFeed = doc?.feed;
if (atomFeed) {
const entries = Array.isArray(atomFeed.entry) ? atomFeed.entry : atomFeed.entry ? [atomFeed.entry] : [];
for (const entry of entries) {
const d = entry.updated || entry.published;
if (d) dates.push(new Date(d));
}
}

// RDF (RSS 1.0)
const rdf = doc?.['rdf:RDF'];
if (rdf) {
const items = Array.isArray(rdf.item) ? rdf.item : rdf.item ? [rdf.item] : [];
for (const item of items) {
const d = item['dc:date'] || item.pubDate;
if (d) dates.push(new Date(d));
}
}

const valid = dates.filter(d => !isNaN(d.getTime()));
if (valid.length === 0) return null;
return new Date(Math.max(...valid.map(d => d.getTime())));
}

async function validateFeed(feed) {
if (feed.isLocal) {
return { ...feed, status: 'SKIP', detail: 'Local API endpoint' };
}

try {
const xml = await fetchFeed(feed.url);
const newest = parseNewestDate(xml);

if (!newest) {
return { ...feed, status: 'EMPTY', detail: 'No parseable dates' };
}

const age = Date.now() - newest.getTime();
const staleCutoff = STALE_DAYS * 24 * 60 * 60 * 1000;

if (age > staleCutoff) {
return { ...feed, status: 'STALE', detail: newest.toISOString().slice(0, 10), newest };
}

return { ...feed, status: 'OK', newest };
} catch (err) {
const msg = err.name === 'AbortError' ? 'Timeout (15s)' : err.message;
return { ...feed, status: 'DEAD', detail: msg };
}
}

async function runBatch(items, fn, concurrency) {
const results = [];
let idx = 0;

async function worker() {
while (idx < items.length) {
const i = idx++;
results[i] = await fn(items[i]);
}
}

const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
await Promise.all(workers);
return results;
}

function pad(str, len) {
return str.length > len ? str.slice(0, len - 1) + '…' : str.padEnd(len);
}

async function main() {
const feeds = extractFeeds();
console.log(`Validating ${feeds.length} RSS feeds (${CONCURRENCY} concurrent, ${FETCH_TIMEOUT / 1000}s timeout)...\n`);

const results = await runBatch(feeds, validateFeed, CONCURRENCY);

const ok = results.filter(r => r.status === 'OK');
const stale = results.filter(r => r.status === 'STALE');
const dead = results.filter(r => r.status === 'DEAD');
const empty = results.filter(r => r.status === 'EMPTY');
const skipped = results.filter(r => r.status === 'SKIP');

if (stale.length) {
stale.sort((a, b) => a.newest - b.newest);
console.log(`STALE (newest item > ${STALE_DAYS} days):`);
console.log(` ${pad('Feed Name', 35)} | ${pad('Newest Item', 12)} | URL`);
console.log(` ${'-'.repeat(35)} | ${'-'.repeat(12)} | ---`);
for (const r of stale) {
console.log(` ${pad(r.name, 35)} | ${pad(r.detail, 12)} | ${r.url}`);
}
console.log();
}

if (dead.length) {
console.log('DEAD (fetch/parse failed):');
console.log(` ${pad('Feed Name', 35)} | ${pad('Error', 20)} | URL`);
console.log(` ${'-'.repeat(35)} | ${'-'.repeat(20)} | ---`);
for (const r of dead) {
console.log(` ${pad(r.name, 35)} | ${pad(r.detail, 20)} | ${r.url}`);
}
console.log();
}

if (empty.length) {
console.log('EMPTY (no items/dates found):');
console.log(` ${pad('Feed Name', 35)} | URL`);
console.log(` ${'-'.repeat(35)} | ---`);
for (const r of empty) {
console.log(` ${pad(r.name, 35)} | ${r.url}`);
}
console.log();
}

console.log(`Summary: ${ok.length} OK, ${stale.length} stale, ${dead.length} dead, ${empty.length} empty` +
(skipped.length ? `, ${skipped.length} skipped` : ''));

if (stale.length || dead.length) process.exit(1);
}

main().catch(err => {
console.error('Fatal:', err);
process.exit(2);
});
17 changes: 17 additions & 0 deletions src-tauri/sidecar/local-api-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,23 @@ async function dispatch(requestUrl, req, routes, context) {
return handleLocalServiceStatus(context);
}

// YouTube embed bridge — exempt from auth because iframe src cannot carry
// Authorization headers. Serves a minimal HTML page that loads the YouTube
// IFrame Player API from a localhost origin (which YouTube accepts, unlike
// tauri://localhost). No sensitive data is exposed.
if (requestUrl.pathname === '/api/youtube-embed') {
const videoId = requestUrl.searchParams.get('videoId');
if (!videoId || !/^[A-Za-z0-9_-]{11}$/.test(videoId)) {
return new Response('Invalid videoId', { status: 400, headers: { 'content-type': 'text/plain' } });
}
const autoplay = requestUrl.searchParams.get('autoplay') === '0' ? '0' : '1';
const mute = requestUrl.searchParams.get('mute') === '0' ? '0' : '1';
const vq = ['small','medium','large','hd720','hd1080'].includes(requestUrl.searchParams.get('vq') || '') ? requestUrl.searchParams.get('vq') : '';
const origin = `http://127.0.0.1:${context.port}`;
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>html,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden}#player{width:100%;height:100%}#play-overlay{position:absolute;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;cursor:pointer;background:rgba(0,0,0,0.4)}#play-overlay svg{width:72px;height:72px;opacity:0.9;filter:drop-shadow(0 2px 8px rgba(0,0,0,0.5))}#play-overlay.hidden{display:none}</style></head><body><div id="player"></div><div id="play-overlay"><svg viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="red"/><path d="M45 24L27 14v20" fill="#fff"/></svg></div><script>var tag=document.createElement('script');tag.src='https://www.youtube.com/iframe_api';document.head.appendChild(tag);var player,overlay=document.getElementById('play-overlay'),started=false,parentOrigin='${origin}';function hideOverlay(){overlay.classList.add('hidden')}function onYouTubeIframeAPIReady(){player=new YT.Player('player',{videoId:'${videoId}',host:'https://www.youtube.com',playerVars:{autoplay:${autoplay},mute:${mute},playsinline:1,rel:0,controls:1,modestbranding:1,enablejsapi:1,origin:'${origin}',widget_referrer:'${origin}'},events:{onReady:function(){window.parent.postMessage({type:'yt-ready'},parentOrigin);${vq ? `if(player.setPlaybackQuality)player.setPlaybackQuality('${vq}');` : ''}if(${autoplay}===1){player.playVideo()}},onError:function(e){window.parent.postMessage({type:'yt-error',code:e.data},parentOrigin)},onStateChange:function(e){window.parent.postMessage({type:'yt-state',state:e.data},parentOrigin);if(e.data===1||e.data===3){hideOverlay();started=true}}}})}overlay.addEventListener('click',function(){if(player&&player.playVideo){player.playVideo();player.unMute();hideOverlay()}});setTimeout(function(){if(!started)overlay.classList.remove('hidden')},3000);window.addEventListener('message',function(e){if(e.origin!==parentOrigin)return;if(!player||!player.getPlayerState)return;var m=e.data;if(!m||!m.type)return;switch(m.type){case'play':player.playVideo();break;case'pause':player.pauseVideo();break;case'mute':player.mute();break;case'unmute':player.unMute();break;case'loadVideo':if(m.videoId)player.loadVideoById(m.videoId);break;case'setQuality':if(m.quality&&player.setPlaybackQuality)player.setPlaybackQuality(m.quality);break}})<\/script></body></html>`;
return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...makeCorsHeaders(req) } });
}

// ── Global auth gate ────────────────────────────────────────────────────
// Every endpoint below requires a valid LOCAL_API_TOKEN. This prevents
// other local processes, malicious browser scripts, and rogue extensions
Expand Down
8 changes: 3 additions & 5 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1219,14 +1219,12 @@ fn main() {
// AppImage — the AppImage itself already provides isolation.
if env::var_os("APPIMAGE").is_some() {
// WebKitGTK 2.39.3+ deprecated WEBKIT_FORCE_SANDBOX and now expects
// WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 instead.
// WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 instead. Setting the
// old variable on newer WebKitGTK triggers a noisy deprecation
// warning in the system journal, so only set the new one.
if env::var_os("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS").is_none() {
unsafe { env::set_var("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1") };
}
// Keep the legacy var for older WebKitGTK releases that still use it.
if env::var_os("WEBKIT_FORCE_SANDBOX").is_none() {
unsafe { env::set_var("WEBKIT_FORCE_SANDBOX", "0") };
}
// Prevent GTK from loading host input-method modules that may
// link against incompatible library versions.
if env::var_os("GTK_IM_MODULE").is_none() {
Expand Down
Loading