diff --git a/server/__tests__/youtube-api-url.test.js b/server/__tests__/youtube-api-url.test.js index b6ce095..3321348 100644 --- a/server/__tests__/youtube-api-url.test.js +++ b/server/__tests__/youtube-api-url.test.js @@ -199,3 +199,44 @@ test('scrapeYouTube: a hostile @handle cannot truncate or inject parameters', as else process.env.YOUTUBE_API_KEY = oldKey; } }); + +// --------------------------------------------------------------------------- +// Coverage guard. +// +// The builder only helps where it is used, and the original injection survived +// precisely because it was spread across ~13 hand-rolled call sites. This walks +// server/ and fails if any module builds a Data API URL directly again. +// --------------------------------------------------------------------------- + +const fs = require('node:fs'); +const path = require('node:path'); + +test('no module builds a YouTube Data API URL by hand', () => { + const root = path.join(__dirname, '..'); + const skipDirs = new Set(['node_modules', '__tests__']); + // youtube-api.js owns the one literal. publish/oauth.js holds a static OAuth + // userinfo URL with no interpolated values (mine=true), so nothing + // user-supplied can reach it. + const allowed = new Set(['youtube-api.js', path.join('publish', 'oauth.js')]); + + const offenders = []; + (function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) walk(full); + continue; + } + if (!entry.name.endsWith('.js')) continue; + const rel = path.relative(root, full); + if (allowed.has(rel)) continue; + if (fs.readFileSync(full, 'utf8').includes('googleapis.com/youtube/v3')) offenders.push(rel); + } + })(root); + + assert.deepStrictEqual( + offenders, [], + 'these build a YouTube Data API URL directly — route them through ' + + 'youtubeApiUrl() instead:\n ' + offenders.join('\n ') + ); +}); diff --git a/server/content-metrics.js b/server/content-metrics.js index 0dc1884..162a641 100644 --- a/server/content-metrics.js +++ b/server/content-metrics.js @@ -8,6 +8,7 @@ const fetch = require('./proxy-fetch'); const { safeFetchRaw } = require('./web/web-fetch'); const log = require('./logger'); +const { youtubeApiUrl } = require('./youtube-api'); const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY; // `content_url` is user-supplied, so `hostname.includes('tiktok.com')` was @@ -68,7 +69,9 @@ async function scrapeYouTubeVideo(url) { try { const res = await fetch( - `https://www.googleapis.com/youtube/v3/videos?part=statistics,snippet&id=${encodeURIComponent(videoId)}&key=${YOUTUBE_API_KEY}` + youtubeApiUrl('videos', { + part: ['statistics', 'snippet'], id: videoId, key: YOUTUBE_API_KEY, + }) ); const data = await res.json(); const video = data.items?.[0]; diff --git a/server/youtube-discovery.js b/server/youtube-discovery.js index 56ee5d0..7b2cd05 100644 --- a/server/youtube-discovery.js +++ b/server/youtube-discovery.js @@ -5,6 +5,7 @@ const fetch = require('./proxy-fetch'); const quota = require('./youtube-quota'); +const { youtubeApiUrl } = require('./youtube-api'); const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY; function detectCategory(text) { @@ -61,7 +62,10 @@ async function searchYouTubeChannels({ keywords, maxResults = 50, minSubscribers } const searchRes = await fetch( - `https://www.googleapis.com/youtube/v3/search?part=snippet&type=channel&q=${encodeURIComponent(query)}&maxResults=${Math.min(maxResults, 50)}&key=${YOUTUBE_API_KEY}` + youtubeApiUrl('search', { + part: 'snippet', type: 'channel', q: query, + maxResults: Math.min(maxResults, 50), key: YOUTUBE_API_KEY, + }) ); const searchData = await searchRes.json(); quota.record('search', 1); @@ -85,7 +89,9 @@ async function searchYouTubeChannels({ keywords, maxResults = 50, minSubscribers } const statsRes = await fetch( - `https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics&id=${channelIds.map(encodeURIComponent).join(',')}&key=${YOUTUBE_API_KEY}` + youtubeApiUrl('channels', { + part: ['snippet', 'statistics'], id: channelIds, key: YOUTUBE_API_KEY, + }) ); const statsData = await statsRes.json(); quota.record('channels', 1);