Files
jacuzzi/tests/unit_direct_probe.js
Simon 7fc718158a Check a direct route hands back the video it was asked for
The proxy exists to get around CORS, hotlink protection and TLS
fingerprinting, so the player probes whether a CDN will serve the browser
itself and skips the hop when it will. The probe took a readable status
line and some bytes as proof.

Not every hotlink guard refuses a foreign Referer with a 403. eporner's
CDN answers one with a 302 to a one-minute "watch this at our site
instead" clip, served 206 with Access-Control-Allow-Origin: *. fetch()
follows that redirect without comment, so the probe read the stand-in as
a win, cached the host as direct, and the player restarted playback on
the raw URL -- where the browser sent the page's own Referer again and
got the stand-in a second time. An eporner video played as 61 seconds of
advertisement instead of itself.

Only the resolved path was affected: yt-dlp's eporner formats carry no
Referer, so refererRequired was false and the race ran. The listing's own
formats name one and were always proxied.

So ask the proxy, which carries the Referer the origin wants, how long
the media is, and refuse a direct route that reports another length. The
question goes out alongside the direct fetch rather than after it, and an
unknown length on either side leaves the old bytes test standing, so
chunked responses and playlists decide as before. No Range header: that
would cost a CORS preflight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-22 20:58:45 +00:00

189 lines
7.0 KiB
JavaScript

#!/usr/bin/env node
/* The direct-playability probe, tested without a browser.
*
* node tests/unit_direct_probe.js
*
* The probe decides whether the player may skip the proxy and let the browser
* fetch a CDN URL itself. Getting that wrong is not a slow video but a wrong
* one: eporner's CDN answers a browser-origin Referer with a 302 to a
* one-minute "watch this at our site instead" clip, served 206 with
* `Access-Control-Allow-Origin: *`, so "bytes arrived" was satisfied by a
* stand-in and the player switched to it mid-video. What settles it is whether
* the direct route hands back the same media the proxied one does, which is
* two stubbed fetches to check -- no server, no Chromium, no network.
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const noop = () => {};
const element = () => ({
style: { setProperty: noop, removeProperty: noop },
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
dataset: {},
querySelector: () => null,
querySelectorAll: () => [],
addEventListener: noop,
removeEventListener: noop,
appendChild: noop,
removeChild: noop,
remove: noop,
getBoundingClientRect: () => ({ width: 0, height: 0, top: 0, bottom: 0, left: 0, right: 0 }),
setAttribute: noop,
removeAttribute: noop,
getAttribute: () => null,
cloneNode: element,
content: { firstElementChild: { cloneNode: element } },
children: [],
childElementCount: 0,
});
const sandbox = {
console,
setTimeout,
clearTimeout,
URL,
Response,
AbortController,
Image: function () { return element(); },
IntersectionObserver: function () {
return { observe: noop, unobserve: noop, disconnect: noop };
},
requestAnimationFrame: noop,
requestIdleCallback: noop,
performance: { now: () => 0 },
localStorage: { getItem: () => null, setItem: noop, removeItem: noop },
};
sandbox.addEventListener = noop;
sandbox.removeEventListener = noop;
sandbox.window = sandbox;
sandbox.self = sandbox;
sandbox.globalThis = sandbox;
sandbox.document = {
getElementById: () => null,
createElement: element,
querySelectorAll: () => [],
addEventListener: noop,
documentElement: element(),
body: element(),
head: element(),
};
sandbox.window.matchMedia = () => ({ matches: false, addEventListener: noop });
sandbox.window.location = { href: 'http://localhost/' };
const context = vm.createContext(sandbox);
sandbox.App = { state: {}, constants: {}, favorites: { getKey: () => null, has: () => false,
setButtonState: noop }, storage: { getPreferredQuality: () => 'auto' } };
vm.runInContext(
fs.readFileSync(path.join(__dirname, '..', 'frontend', 'js', 'videos.js'), 'utf8'),
context, 'videos.js');
const { probeDirect } = sandbox.App.videos;
let failed = 0;
const ok = (label, cond, detail) => {
if (!cond) failed++;
console.log(` [${cond ? 'PASS' : 'FAIL'}] ${label}` + (!cond && detail ? ` -- ${detail}` : ''));
};
// Stands in for both routes. `direct` is what the CDN hands the browser,
// `proxied` what /api/stream reports -- either given as a byte count, or null
// for a response that states no length at all. Every call is recorded so the
// test can check what was asked of whom.
let calls = [];
const stubFetch = function(direct, proxied) {
calls = [];
sandbox.fetch = async (url, opts) => {
calls.push({ url: String(url), method: (opts && opts.method) || 'GET' });
if (String(url).indexOf('/api/stream') === 0) {
const headers = proxied === null ? {} : { 'Content-Length': String(proxied) };
return new Response('', { status: 200, headers: headers });
}
const headers = direct === null ? {} : { 'Content-Length': String(direct) };
return new Response(new Uint8Array(16), { status: 206, headers: headers });
};
};
// A fresh host each time: the probe caches its answer per host for the session,
// which is the point of it, so reusing one would answer from the cache.
let hostSeq = 0;
const sourceOn = (host) => ({
url: `https://${host}/v4/token/10299874-1080p.mp4`,
referer: 'https://www.eporner.com/',
headers: { Referer: 'https://www.eporner.com/' },
});
const freshSource = () => sourceOn(`cdn${++hostSeq}.example.com`);
const run = async () => {
console.log('\nwhat the two routes say about the same URL');
stubFetch(486933813, 486933813);
ok('the same length both ways is a shortcut', await probeDirect(freshSource()) === true);
// The bug: 642497 is static.eporner.com/na.mp4, the stand-in clip.
stubFetch(642497, 486933813);
ok('a shorter file than the proxy reports is a substitution',
await probeDirect(freshSource()) === false);
// Not every mismatch is a decoy -- a longer one is just as wrong.
stubFetch(486933813, 642497);
ok('any disagreement about length is refused',
await probeDirect(freshSource()) === false);
console.log('\nwhen a length cannot be had');
stubFetch(642497, null);
ok('a proxy that states no length leaves the bytes test standing',
await probeDirect(freshSource()) === true);
stubFetch(null, 486933813);
ok('a direct route that states no length is not accused',
await probeDirect(freshSource()) === true);
stubFetch(null, null);
ok('neither stating one still plays direct',
await probeDirect(freshSource()) === true);
console.log('\nhow the cross-check is addressed');
stubFetch(486933813, 486933813);
const src = freshSource();
await probeDirect(src);
const head = calls.find((c) => c.url.indexOf('/api/stream') === 0);
ok('the proxy is asked, by HEAD', !!head && head.method === 'HEAD',
head ? head.method : 'never asked');
ok('with the Referer the origin wants, not the page\'s',
!!head && head.url.indexOf(`referer=${encodeURIComponent('https://www.eporner.com/')}`) !== -1,
head ? head.url : '');
ok('about the very URL being probed',
!!head && head.url.indexOf(encodeURIComponent(src.url)) !== -1);
console.log('\nthe answer is kept per host');
stubFetch(486933813, 486933813);
const repeat = sourceOn('cached.example.com');
await probeDirect(repeat);
const afterFirst = calls.length;
await probeDirect(repeat);
ok('a second probe of a known host asks nobody', calls.length === afterFirst,
`${calls.length - afterFirst} extra call(s)`);
console.log('\na bare URL still works');
stubFetch(642497, 486933813);
ok('a string is probed, and still cross-checked',
await probeDirect('https://bare.example.com/v.mp4') === false);
stubFetch(486933813, 486933813);
ok('nothing to probe is not playable', await probeDirect('') === false);
ok('neither is a source without a URL', await probeDirect({ url: '' }) === false);
};
run().then(() => {
console.log(`\n${failed ? 'FAILED' : 'OK'}: ${failed} check(s) failed`);
process.exit(failed ? 1 : 0);
}).catch((err) => {
console.error(err);
process.exit(1);
});