Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions app/_launch_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@
from routers.lan_auth import create_lan_auth_router
from services.durable_generation_queue import DurableGenerationQueue
from services.lan_auth import LanAuthMiddleware, describe_lan_auth_startup
from services.media_paths import MediaPathNotAllowed, resolve_permitted_media_path
from services.media_paths import MediaPathNotAllowed, resolve_permitted_media_path, resolve_voice_ref_paths
from services.upload_stream import (
UploadTooLargeError,
UploadTranscodeError,
Expand Down Expand Up @@ -23812,7 +23812,11 @@ def _legacy_h3_progress(
# Pop them out of raw_params so they don't leak into the
# generation handler (other handlers don't understand them).
pp_voice_clone_enabled = bool(raw_params.pop("voice_clone_enabled", False))
pp_voice_clone_refs = raw_params.pop("voice_clone_refs", None) or []
pp_voice_clone_refs = resolve_voice_ref_paths(
raw_params.pop("voice_clone_refs", None) or [],
uploads_root=os.path.join(os.getcwd(), "uploads"),
workspace_root=_workspace_dir(job.get("workspace")),
)
pp_voice_clone_mode = raw_params.pop("voice_clone_mode", "single")

defer_output_publication = bool(
Expand Down
31 changes: 31 additions & 0 deletions app/services/media_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,34 @@ def resolve_permitted_media_path(
return candidate
raise FileNotFoundError("Permitted media file was not found")


def resolve_voice_ref_paths(
refs: Iterable[str],
*,
uploads_root: str,
workspace_root: str,
) -> list[str]:
"""Resolve SeedVC voice refs to real files under uploads or the workspace.

Upload-audio stores files in uploads/audio/; the UI must send that
subfolder (or an absolute path). Bare filenames only match a workspace
file or a file sitting directly in uploads/. Unresolvable entries are
dropped so a stale ref cannot crash generation.
"""
resolved: list[str] = []
for value in refs:
if not isinstance(value, str) or not value.strip() or "\x00" in value:
continue
try:
resolved.append(
resolve_permitted_media_path(
value,
uploads_root=uploads_root,
workspace_root=workspace_root,
kinds=("audio", "video"),
)
)
except (MediaPathNotAllowed, FileNotFoundError, ValueError):
continue
return resolved

63 changes: 62 additions & 1 deletion tests/test_media_path_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

import pytest

from services.media_paths import MediaPathNotAllowed, resolve_permitted_media_path
from services.media_paths import (
MediaPathNotAllowed,
resolve_permitted_media_path,
resolve_voice_ref_paths,
)


ROOT = Path(__file__).parents[1]
Expand Down Expand Up @@ -78,6 +82,48 @@ def test_permitted_media_path_rejects_a_symlink_escape(tmp_path):
)


def test_permitted_media_path_resolves_audio_upload_subdir(tmp_path):
uploads = tmp_path / "uploads"
workspace = tmp_path / "workspace"
audio = uploads / "audio"
audio.mkdir(parents=True)
workspace.mkdir()
sample = audio / "9f2.wav"
sample.write_bytes(b"RIFF")

assert resolve_permitted_media_path(
"audio/9f2.wav",
uploads_root=str(uploads),
workspace_root=str(workspace),
kinds=("audio",),
) == str(sample.resolve())
with pytest.raises(FileNotFoundError):
resolve_permitted_media_path(
"9f2.wav",
uploads_root=str(uploads),
workspace_root=str(workspace),
kinds=("audio",),
)


def test_resolve_voice_ref_paths_keeps_audio_subdir_and_workspace_names(tmp_path):
uploads = tmp_path / "uploads"
workspace = tmp_path / "workspace"
audio = uploads / "audio"
audio.mkdir(parents=True)
workspace.mkdir()
uploaded = audio / "9f2.wav"
clip = workspace / "hero.wav"
uploaded.write_bytes(b"RIFF")
clip.write_bytes(b"RIFF")

assert resolve_voice_ref_paths(
["audio/9f2.wav", "hero.wav", "missing.wav", "9f2.wav"],
uploads_root=str(uploads),
workspace_root=str(workspace),
) == [str(uploaded.resolve()), str(clip.resolve())]


def test_permitted_media_path_distinguishes_missing_from_forbidden(tmp_path):
uploads = tmp_path / "uploads"
workspace = tmp_path / "workspace"
Expand Down Expand Up @@ -114,6 +160,21 @@ def test_audio_trim_and_analysis_endpoints_use_the_shared_resolver():
assert "_resolve_request_media_path" in calls, name


def test_generation_resolves_voice_clone_refs_before_seedvc():
tree = ast.parse(LAUNCH.read_text(encoding="utf-8"), filename=str(LAUNCH))
run = next(
node for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name == "_run_generation"
)
calls = [
node.func.id
for node in ast.walk(run)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
]
assert "resolve_voice_ref_paths" in calls


def test_adopting_audio_is_confined_to_audio_the_uploader_would_not_transcode():
"""Adoption takes a file in place, so it may only accept what the rest of
the pipeline can already open. The uploader transcodes mp3/m4a/aac to PCM
Expand Down
4 changes: 2 additions & 2 deletions ui/src/components/Sidebar/PostProcessing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useStore } from '../../stores/useStore'
import { useUiTranslation } from '../../i18n'
import * as api from '../../api/client'
import type { ApiOutput } from '../../api/outputs'
import { catalogItemToOutput } from '../../features/asset-picker'
import { catalogItemToOutput, voiceRefFromOutput } from '../../features/asset-picker'
import { AssetInput } from '../../features/asset-picker/AssetInput.tsx'

const baseOptions = [
Expand Down Expand Up @@ -221,7 +221,7 @@ export function PostProcessing() {
accept="audio/*,video/*"
optional
constraints={{ kinds: ['audio', 'video'], maxCount: 1, optional: true }}
onChoose={item => setVoiceCloneRef(idx, item ? { filename: item.name, path: item.name } : null)}
onChoose={item => setVoiceCloneRef(idx, item ? voiceRefFromOutput(item, activeWorkspace) : null)}
/>
{ref?.path && (
<div className="mt-1 flex items-center gap-2 bg-bg-tertiary border border-border rounded-lg px-2 py-1.5">
Expand Down
4 changes: 2 additions & 2 deletions ui/src/components/Sidebar/ToolsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useUiTranslation } from '../../i18n'
import * as api from '../../api/client'
import type { AssetCatalogItem } from '../../api/assets'
import type { ApiOutput } from '../../api/outputs'
import { catalogItemToOutput, matchCatalogByOutput } from '../../features/asset-picker'
import { catalogItemToOutput, matchCatalogByOutput, voiceRefFromOutput } from '../../features/asset-picker'
import { ToolsParamsPanel } from './ToolsParamsPanel'
import { ToolsSourcePanel, type ToolSource } from './ToolsSourcePanel'

Expand Down Expand Up @@ -110,7 +110,7 @@ export function ToolsPanel() {
setRevoiceRef(index, null)
return
}
setRevoiceRef(index, { filename: item.name, path: item.name })
setRevoiceRef(index, voiceRefFromOutput(item, activeWorkspace))
}

const hasRefs = revoiceRefs.some(r => r && r.path)
Expand Down
7 changes: 6 additions & 1 deletion ui/src/features/asset-picker/adapters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ApiOutput } from '../../api/outputs'
import { getServerMediaReference, type ApiOutput } from '../../api/outputs'
import type { AssetCatalogItem, AssetKind } from '../../api/assets'
import { displayAssetTitle, knownCreatedAt } from './titles.ts'
import type { AssetConstraints, AssetRef, Compatibility, LegacyOutputRef, PickerItem } from './types.ts'
Expand Down Expand Up @@ -85,6 +85,11 @@ export function matchCatalogByOutput(
})
}

export function voiceRefFromOutput(item: ApiOutput, workspaceId?: string): { filename: string; path: string } {
const ref = getServerMediaReference(item.url, item.name, workspaceId)
return { filename: item.name, path: ref?.audio_path || item.name }
}

export function outputToPickerItem(item: ApiOutput, workspaceId: string): PickerItem {
const kind = OUTPUT_KIND[item.type]
const createdAt = knownCreatedAt(item.created_at)
Expand Down
1 change: 1 addition & 0 deletions ui/src/features/asset-picker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
checkCompatibility,
matchCatalogByOutput,
outputToPickerItem,
voiceRefFromOutput,
resolveCatalogMatch,
} from './adapters.ts'
export { confirmPickerChoice, livePickerItem, matchOutputByPicker } from './confirmChoice.ts'
Expand Down
12 changes: 8 additions & 4 deletions ui/src/lib/labsImagePick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,15 @@ export async function fileFromOutput(item: ApiOutput): Promise<File> {
return new File([blob], item.name, { type: blob.type || 'image/png' })
}

/** Series import copies from uploads/ only. Catalog picks are copied once into uploads. */
/**
* Copy the pick into uploads/ and return the upload API payload.
* Callers (Series import, Character Creator describe, H3 isfile checks)
* only share that absolute filesystem path. A synthetic `uploads/${name}`
* is not enough: describe resolves relative names under uploads/, so
* `uploads/hero.png` becomes `uploads/uploads/hero.png` and the default
* empty-prompt Character Creator flow 400s.
*/
export async function ensureUploadsPath(item: ApiOutput): Promise<{ path: string; name: string; url: string }> {
if (isUploadOutput(item)) {
return { path: `uploads/${item.name}`, name: item.name, url: item.url }
}
const uploaded = await uploadImage(await fileFromOutput(item))
return { path: uploaded.path, name: uploaded.filename, url: uploaded.url }
}
Expand Down
36 changes: 36 additions & 0 deletions ui/tests/assetPickerContract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
catalogItemToOutput,
catalogItemToPickerItem,
matchCatalogByOutput,
voiceRefFromOutput,
checkCompatibility,
confirmPickerChoice,
createCatalogQuerySession,
Expand Down Expand Up @@ -39,6 +40,41 @@ function catalogItem(overrides: Partial<AssetCatalogItem> & Pick<AssetCatalogIte
}
}

test('voice refs keep the uploads/audio subfolder the backend can resolve', () => {
const uploaded = voiceRefFromOutput({
name: '9f2.wav',
type: 'audio',
mode: null,
size: 12,
created_at: 1,
url: '/api/v1/uploads/audio/9f2.wav',
thumbnail_url: '',
}, 'default')
assert.deepEqual(uploaded, { filename: '9f2.wav', path: 'audio/9f2.wav' })

const library = voiceRefFromOutput({
name: 'hero.wav',
type: 'audio',
mode: null,
size: 12,
created_at: 1,
url: '/api/v1/file/hero.wav?workspace=default',
thumbnail_url: '',
}, 'default')
assert.deepEqual(library, { filename: 'hero.wav', path: 'hero.wav' })

const videoUpload = voiceRefFromOutput({
name: 'take.mp4',
type: 'video',
mode: null,
size: 12,
created_at: 1,
url: '/api/v1/uploads/take.mp4',
thumbnail_url: '',
}, 'default')
assert.deepEqual(videoUpload, { filename: 'take.mp4', path: 'take.mp4' })
})

test('catalog items map to outputs and match back by name and url', () => {
const item = catalogItem({ id: 'asset-hero', filename: 'hero.png' })
const output = catalogItemToOutput(item, 'default')
Expand Down
56 changes: 53 additions & 3 deletions ui/tests/labsImagePick.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,58 @@ const uploadItem = {
thumbnail_url: '/api/v1/uploads/hero.png',
}

test('upload catalog URLs stay in uploads/ without a second POST', async () => {
const catalogItem = {
name: 'shot.png',
type: 'image',
mode: 'image',
size: 20,
created_at: 2,
url: '/api/v1/file/shot.png?workspace=default',
thumbnail_url: '/api/v1/file/shot.png?workspace=default',
}

function mockUploadFetch(expectedUrl) {
const original = globalThis.fetch
globalThis.fetch = (async (input, init) => {
const url = String(input)
if (url.includes(expectedUrl) && !init?.method) {
return new Response(new Uint8Array([137, 80, 78, 71]), { headers: { 'content-type': 'image/png' } })
}
if (url.includes('/api/v1/upload') && init?.method === 'POST') {
return new Response(JSON.stringify({
filename: 'copied.png',
path: '/abs/uploads/copied.png',
url: '/api/v1/uploads/copied.png',
}), { headers: { 'content-type': 'application/json' } })
}
throw new Error(`unexpected fetch ${url}`)
})
return () => { globalThis.fetch = original }
}

test('upload catalog URLs still copy through /api/v1/upload so callers get an absolute path', async () => {
assert.equal(isUploadOutput(uploadItem), true)
const ensured = await ensureUploadsPath(uploadItem)
assert.deepEqual(ensured, { path: 'uploads/hero.png', name: 'hero.png', url: '/api/v1/uploads/hero.png' })
const restore = mockUploadFetch('/api/v1/uploads/hero.png')
try {
const ensured = await ensureUploadsPath(uploadItem)
assert.deepEqual(ensured, {
path: '/abs/uploads/copied.png',
name: 'copied.png',
url: '/api/v1/uploads/copied.png',
})
} finally {
restore()
}
})

test('workspace catalog picks are fetched from their file URL and copied into uploads/', async () => {
assert.equal(isUploadOutput(catalogItem), false)
const restore = mockUploadFetch('/api/v1/file/shot.png?workspace=default')
try {
const ensured = await ensureUploadsPath(catalogItem)
assert.equal(ensured.path, '/abs/uploads/copied.png')
assert.equal(ensured.name, 'copied.png')
} finally {
restore()
}
})
12 changes: 11 additions & 1 deletion ui/tests/seriesLabsPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ test('Series canon identity can be chosen from HocusPocus without approving the
total: 1,
}), { headers: { 'content-type': 'application/json' } })
}
if (url.includes('/api/v1/uploads/hero.png') && !init?.method) {
return new Response(new Uint8Array([137, 80, 78, 71]), { headers: { 'content-type': 'image/png' } })
}
if (url.includes('/api/v1/upload') && init?.method === 'POST') {
return new Response(JSON.stringify({
filename: 'copied.png',
path: '/abs/uploads/copied.png',
url: '/api/v1/uploads/copied.png',
}), { headers: { 'content-type': 'application/json' } })
}
if (url.includes('/assets/import') && init?.method === 'POST') {
imported.push(JSON.parse(String(init.body || '{}')))
return new Response(JSON.stringify({ asset: { id: 'asset_1' }, series }), { headers: { 'content-type': 'application/json' } })
Expand All @@ -64,7 +74,7 @@ test('Series canon identity can be chosen from HocusPocus without approving the
fireEvent.click(card)
fireEvent.click(screen.getByRole('button', { name: 'Choose' }))
await waitFor(() => assert.equal(imported.length, 1))
assert.equal(imported[0].uploadPath, 'uploads/hero.png')
assert.equal(imported[0].uploadPath, '/abs/uploads/copied.png')
assert.equal(imported[0].ownerType, 'character')
assert.equal(imported[0].ownerId, character.id)
assert.equal(imported[0].referenceRole, 'primary_portrait')
Expand Down
Loading