fix yt metadata extraction once and for all (hopefully)
This commit is contained in:
@@ -752,6 +752,38 @@ window.initUploadForm = (selector) => {
|
|||||||
return [...new Set(fields)];
|
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) {
|
if (urlInput) {
|
||||||
const fetchMetadata = async (url) => {
|
const fetchMetadata = async (url) => {
|
||||||
const currentVal = urlInput.value.trim();
|
const currentVal = urlInput.value.trim();
|
||||||
@@ -765,8 +797,12 @@ window.initUploadForm = (selector) => {
|
|||||||
// In shitpost mode: fetch silently (cache only, badge stays hidden)
|
// In shitpost mode: fetch silently (cache only, badge stays hidden)
|
||||||
if (isShitpost) {
|
if (isShitpost) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(currentVal)}`);
|
// YouTube: try client-side oEmbed first (avoids Tor consent walls)
|
||||||
const data = await resp.json();
|
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) {
|
if (data.success && data.meta) {
|
||||||
const fields = extractFieldsFromMeta(data.meta);
|
const fields = extractFieldsFromMeta(data.meta);
|
||||||
metaCache.set(currentVal, fields);
|
metaCache.set(currentVal, fields);
|
||||||
@@ -785,8 +821,12 @@ window.initUploadForm = (selector) => {
|
|||||||
urlBadge.style.display = 'flex';
|
urlBadge.style.display = 'flex';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(currentVal)}`);
|
// YouTube: try client-side oEmbed first (avoids Tor consent walls)
|
||||||
const data = await resp.json();
|
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) {
|
if (data.success && data.meta) {
|
||||||
const fields = extractFieldsFromMeta(data.meta);
|
const fields = extractFieldsFromMeta(data.meta);
|
||||||
// Cache the fields for instant use when item is committed
|
// Cache the fields for instant use when item is committed
|
||||||
@@ -1672,10 +1712,14 @@ window.initUploadForm = (selector) => {
|
|||||||
|
|
||||||
setBadgeFetching();
|
setBadgeFetching();
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(item.url)}`, {
|
// YouTube: try client-side oEmbed first (avoids Tor consent walls)
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
let data = ytRegex.test(item.url) ? await tryClientYouTubeOembed(item.url) : null;
|
||||||
});
|
if (!data) {
|
||||||
const data = await resp.json();
|
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) {
|
if (data.success && data.meta) {
|
||||||
const fields = extractFieldsFromMeta(data.meta);
|
const fields = extractFieldsFromMeta(data.meta);
|
||||||
metaCache.set(item.url, fields);
|
metaCache.set(item.url, fields);
|
||||||
|
|||||||
@@ -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.
|
// 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.
|
// Guests may read from the cache (in-memory or DB); only authenticated users trigger real outbound fetches.
|
||||||
group.get(/\/meta\/fetch$/, async (req, res) => {
|
group.get(/\/meta\/fetch$/, async (req, res) => {
|
||||||
@@ -370,19 +410,26 @@ export default router => {
|
|||||||
oembedUrl
|
oembedUrl
|
||||||
], { ignoreExitCode: true });
|
], { ignoreExitCode: true });
|
||||||
if (oembedOut && oembedOut.trim()) {
|
if (oembedOut && oembedOut.trim()) {
|
||||||
const data = JSON.parse(oembedOut);
|
const trimmed = oembedOut.trim();
|
||||||
if (data.title) {
|
// YouTube often returns HTML (consent/cookie walls, CAPTCHAs, geo-blocks)
|
||||||
const meta = {
|
// instead of JSON when accessed through a proxy — skip those gracefully.
|
||||||
title: data.title,
|
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||||
site_name: 'youtube.com',
|
console.warn(`[META-FETCH] YouTube oEmbed returned non-JSON (likely HTML wall), falling back to yt-dlp`);
|
||||||
author: data.author_name || 'Unknown'
|
} else {
|
||||||
};
|
const data = JSON.parse(trimmed);
|
||||||
await setCache(url, meta);
|
if (data.title) {
|
||||||
return res.json({ success: true, meta });
|
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) {
|
} 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.)
|
// oEmbed failed — fall back to yt-dlp for YouTube (handles age-restricted, private, etc.)
|
||||||
|
|||||||
Reference in New Issue
Block a user