openvibers/openvibe.ai Commits

AI: media.analyze follows Media's redirect to its shield host edge.openvibe.media (B2 recordings were unreadable) (#61)

OP OpenVibers committed 12 Oct 2026 · 20b9a9d2c6 · 3 files, +7 −4

`media.analyze` could not read any recording Media keeps on B2. Live's first real run (VOD 5715, during Live#111's review) failed in about a second with `media.unreadable: ffprobe could not read the media (exit 1)`.

The cause: the signed `openvibe.media/v/<id>` URL answers 302 to Media's origin shield, `edge.openvibe.media`. That is where Media sends a B2 copy's reads, through its nginx slice cache. AI's fetcher follows a redirect from Media only to its `storageHosts`, and that list held B2 and R2 but not the shield. So the media proxy refused the hop, and ffprobe got nothing.

**The change:** `edge.openvibe.media` is added to the default `storageHosts` (`AI_MEDIA_STORAGE_HOSTS`). The rules don't change: these hosts are reachable only as a redirect from Media itself (its public host or internal origin), never as a URL a caller names; https only; no credentials; public addresses checked at connect time. Production doesn't override the list, so the default applies.

Tests: `ssrf.test.js` covers Media → its shield being followed, and the shield refused as a named URL. It passes 8 of 8.

Changes

ddiff --git a/.env.example b/.env.example
iindex 19a910b..9fc4a53 100644
---- a/.env.example
++++ b/.env.example
@@@ -99,7 +99,7 @@ AI_MODEL=
# downloaded, and speech windows seek with range requests. 0 downloads it first (the disk cap then applies).
#AI_MEDIA_STREAM=1
# Object storage Media redirects offloaded recordings to (presigned URLs), followed only from Media itself.
--#AI_MEDIA_STORAGE_HOSTS=*.backblazeb2.com,*.r2.cloudflarestorage.com
++#AI_MEDIA_STORAGE_HOSTS=*.backblazeb2.com,*.r2.cloudflarestorage.com,edge.openvibe.media
#AI_LOCAL_LLM_MODEL=qwen2.5-1.5b-instruct
#AI_LOCAL_LLM_TIMEOUT_MS=180000
# media.analyze: file cap (also bounded by free temp disk minus the reserve), download timeout, scene threshold
ddiff --git a/server/config.js b/server/config.js
iindex 390dbbb..64ed39b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -188,9 +188,10 @@ function load(env = process.env) {
internalUrl: trimUrl(env.OV_MEDIA_INTERNAL_URL || ''),
// https hostnames (exact, or *.suffix) that media/image URLs may point at.
allowHosts: list(env.AI_FETCH_ALLOW_HOSTS, ['openvibe.media', 'openvibe.live', 'openvibe.network', '*.openvibe.network', 'openvibe.community', 'openvibe.tools', 'openvibe.games']),
-- // Object storage Media hands recordings off to (a 302 to a presigned URL): reachable ONLY as a redirect
-- // from Media itself (its public host or the internal origin), never as a URL a caller names.
-- storageHosts: list(env.AI_MEDIA_STORAGE_HOSTS, ['*.backblazeb2.com', '*.r2.cloudflarestorage.com']),
++ // Object storage Media hands recordings off to (a 302 to a presigned URL), and Media's origin shield
++ // edge.openvibe.media (where it sends a B2 copy's reads, through its slice cache): reachable ONLY as a
++ // redirect from Media itself (its public host or the internal origin), never as a URL a caller names.
++ storageHosts: list(env.AI_MEDIA_STORAGE_HOSTS, ['*.backblazeb2.com', '*.r2.cloudflarestorage.com', 'edge.openvibe.media']),
maxBytes: int(env.AI_FETCH_MAX_BYTES, 512 * 1024 * 1024),
maxImageBytes: int(env.AI_FETCH_MAX_IMAGE_BYTES, 12 * 1024 * 1024),
timeoutMs: int(env.AI_FETCH_TIMEOUT_MS, 120000),
ddiff --git a/test/ssrf.test.js b/test/ssrf.test.js
iindex 496a3f6..9db1546 100644
---- a/test/ssrf.test.js
++++ b/test/ssrf.test.js
@@@ -36,6 +36,8 @@ t.test('object storage is followed only as a redirect from Media', async () => {
const b2 = 'https://s3.us-west-004.backblazeb2.com/bucket/vod-1.mp4?X-Amz-Signature=abc';
assert.strictEqual(f.follow(media, b2).url.hostname, 's3.us-west-004.backblazeb2.com', 'Media -> B2');
assert.strictEqual(f.follow(internal, 'https://acct.r2.cloudflarestorage.com/b/x').internal, false, 'internal Media -> R2, as a public target');
++ assert.strictEqual(f.follow(media, 'https://edge.openvibe.media/v/1').url.hostname, 'edge.openvibe.media', 'Media -> its shield (a B2 copy)');
++ await (async () => { try { f.judge('https://edge.openvibe.media/v/1'); assert.fail('named'); } catch (e) { assert.strictEqual(e.code, 'fetch.refused', 'the shield is never a URL a caller names'); } })();
const refused = (fn, why) => { try { fn(); assert.fail(why); } catch (e) { assert.strictEqual(e.code, 'fetch.refused', why); } };
await refused(() => f.follow(other, b2), 'another host may not send it to storage');
await refused(() => f.judge(b2), 'storage is never a URL a caller names');