diff --git a/public/s/js/upload.js b/public/s/js/upload.js index ecebdb4..15f592d 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -752,6 +752,38 @@ window.initUploadForm = (selector) => { return [...new Set(fields)]; }; + // Try YouTube oEmbed directly from the client browser (bypasses Tor/proxy consent walls). + // Returns { success, meta } matching the server's /meta/fetch response shape, or null on failure. + const tryClientYouTubeOembed = async (url) => { + try { + const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`; + const resp = await fetch(oembedUrl); + if (!resp.ok) return null; + const data = await resp.json(); + if (!data.title) return null; + const meta = { + title: data.title, + site_name: 'youtube.com', + author: data.author_name || 'Unknown' + }; + // Push to server cache so future requests (sidebar, other users) don't need Tor + try { + const csrf = window.f0ckSession?.csrf_token; + fetch('/api/v2/meta/cache', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(csrf ? { 'X-CSRF-Token': csrf } : {}) + }, + body: JSON.stringify({ url, meta }) + }).catch(() => {}); // fire-and-forget + } catch (_) {} + return { success: true, meta }; + } catch (e) { + return null; + } + }; + if (urlInput) { const fetchMetadata = async (url) => { const currentVal = urlInput.value.trim(); @@ -765,8 +797,12 @@ window.initUploadForm = (selector) => { // In shitpost mode: fetch silently (cache only, badge stays hidden) if (isShitpost) { try { - const resp = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(currentVal)}`); - const data = await resp.json(); + // YouTube: try client-side oEmbed first (avoids Tor consent walls) + let data = ytRegex.test(currentVal) ? await tryClientYouTubeOembed(currentVal) : null; + if (!data) { + const resp = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(currentVal)}`); + data = await resp.json(); + } if (data.success && data.meta) { const fields = extractFieldsFromMeta(data.meta); metaCache.set(currentVal, fields); @@ -785,8 +821,12 @@ window.initUploadForm = (selector) => { urlBadge.style.display = 'flex'; try { - const resp = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(currentVal)}`); - const data = await resp.json(); + // YouTube: try client-side oEmbed first (avoids Tor consent walls) + let data = ytRegex.test(currentVal) ? await tryClientYouTubeOembed(currentVal) : null; + if (!data) { + const resp = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(currentVal)}`); + data = await resp.json(); + } if (data.success && data.meta) { const fields = extractFieldsFromMeta(data.meta); // Cache the fields for instant use when item is committed @@ -1672,10 +1712,14 @@ window.initUploadForm = (selector) => { setBadgeFetching(); try { - const resp = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(item.url)}`, { - headers: { 'X-Requested-With': 'XMLHttpRequest' } - }); - const data = await resp.json(); + // YouTube: try client-side oEmbed first (avoids Tor consent walls) + let data = ytRegex.test(item.url) ? await tryClientYouTubeOembed(item.url) : null; + if (!data) { + const resp = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(item.url)}`, { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }); + data = await resp.json(); + } if (data.success && data.meta) { const fields = extractFieldsFromMeta(data.meta); metaCache.set(item.url, fields); diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index a4bda81..d7e127d 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -299,6 +299,46 @@ export default router => { } }); + // Allow authenticated clients to push metadata they fetched client-side (e.g. YouTube oEmbed + // fetched directly from the browser, bypassing Tor/proxy consent walls). + group.post(/\/meta\/cache$/, lib.loggedin, async (req, res) => { + if (!cfg.websrv.web_meta_extraction) { + return res.json({ success: false, msg: 'Metadata extraction is disabled' }, 403); + } + try { + const body = await collectBody(req); + const payload = JSON.parse(body.toString()); + const { url, meta } = payload || {}; + if (!url || !meta || !meta.title) { + return res.json({ success: false, msg: 'url and meta.title required' }, 400); + } + // Only accept YouTube URLs to prevent arbitrary cache poisoning + if (!/(youtube\.com|youtu\.be)/i.test(url)) { + return res.json({ success: false, msg: 'Only YouTube URLs accepted' }, 400); + } + // Sanitise — only store known-safe fields + const safeMeta = { + title: String(meta.title).substring(0, 500), + site_name: 'youtube.com', + author: meta.author ? String(meta.author).substring(0, 200) : 'Unknown' + }; + metaCache.set(url, safeMeta); + if (metaCache.size > MAX_META_CACHE) { + const first = metaCache.keys().next().value; + metaCache.delete(first); + } + try { + await db`INSERT INTO meta_cache (url, data) VALUES (${url}, ${safeMeta}) + ON CONFLICT (url) DO UPDATE SET data = EXCLUDED.data, created_at = CURRENT_TIMESTAMP`; + } catch (err) { + console.error('[META-CACHE] DB save failed:', err); + } + return res.json({ success: true }); + } catch (err) { + return res.json({ success: false, msg: 'Invalid request body' }, 400); + } + }); + // F-002 Security: Require authentication to prevent SSRF via arbitrary URL fetching. // Guests may read from the cache (in-memory or DB); only authenticated users trigger real outbound fetches. group.get(/\/meta\/fetch$/, async (req, res) => { @@ -370,19 +410,26 @@ export default router => { oembedUrl ], { ignoreExitCode: true }); if (oembedOut && oembedOut.trim()) { - const data = JSON.parse(oembedOut); - if (data.title) { - const meta = { - title: data.title, - site_name: 'youtube.com', - author: data.author_name || 'Unknown' - }; - await setCache(url, meta); - return res.json({ success: true, meta }); + const trimmed = oembedOut.trim(); + // YouTube often returns HTML (consent/cookie walls, CAPTCHAs, geo-blocks) + // instead of JSON when accessed through a proxy — skip those gracefully. + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + console.warn(`[META-FETCH] YouTube oEmbed returned non-JSON (likely HTML wall), falling back to yt-dlp`); + } else { + const data = JSON.parse(trimmed); + if (data.title) { + const meta = { + title: data.title, + site_name: 'youtube.com', + author: data.author_name || 'Unknown' + }; + await setCache(url, meta); + return res.json({ success: true, meta }); + } } } } catch (oembedErr) { - console.error(`[META-FETCH] YouTube oEmbed failed, will try yt-dlp:`, oembedErr.message || oembedErr); + console.warn(`[META-FETCH] YouTube oEmbed failed, will try yt-dlp:`, oembedErr.message || oembedErr); } // oEmbed failed — fall back to yt-dlp for YouTube (handles age-restricted, private, etc.)