diff --git a/ui/cron/fileServer.ts b/ui/cron/fileServer.ts index 128df58a..9ec26cbe 100644 --- a/ui/cron/fileServer.ts +++ b/ui/cron/fileServer.ts @@ -248,6 +248,13 @@ async function serveFile(req: http.IncomingMessage, res: http.ServerResponse, pr const isImg = prefix === '/api/img/'; const urlPath = (req.url || '').split('?')[0]; const rest = urlPath.slice(prefix.length); + // Decode per URL segment so both forms resolve to the same file: + // /api/files/%2Fmnt%2Fout%2Fjob%2Ffile.safetensors (legacy: whole path in one segment) + // /api/files/%2Fmnt%2Fout%2Fjob/file.safetensors (folder / filename — what the UI emits; + // gives wget & co. the real filename) + // Windows folders arrive as `C%3A%5Cout%5Cjob` and decode to `C:\out\job`; + // path.resolve below normalizes the mixed `\`/`/` separators. Same logic as + // catchAllToFilePath in src/server/catchAllPath.ts for the Next.js routes. const decodedFilePath = rest .split('/') .map(decodeURIComponent) diff --git a/ui/src/app/api/audio/art/[...audioPath]/route.ts b/ui/src/app/api/audio/art/[...audioPath]/route.ts index fbc65047..1c3cd464 100644 --- a/ui/src/app/api/audio/art/[...audioPath]/route.ts +++ b/ui/src/app/api/audio/art/[...audioPath]/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; import { getDatasetsRoot, getTrainingFolder, getDataRoot } from '@/server/settings'; +import { catchAllToFilePath } from '@/server/catchAllPath'; /** * Serves embedded album art from an MP3 file's ID3v2 tag. @@ -125,10 +126,12 @@ function extractArtFromTag(buf: Buffer): ArtResult { return null; } -export async function GET(request: NextRequest, { params }: { params: { audioPath: string } }) { +export async function GET(request: NextRequest, { params }: { params: { audioPath: string | string[] } }) { const { audioPath } = await params; try { - const filepath = decodeURIComponent(audioPath); + // Segments are already URL-decoded by Next.js; accepts both the legacy + // single-segment form and the `/` form. + const filepath = catchAllToFilePath(audioPath); // Security check const datasetRoot = await getDatasetsRoot(); diff --git a/ui/src/app/api/files/[...filePath]/route.ts b/ui/src/app/api/files/[...filePath]/route.ts index 40874d0c..2508c28d 100644 --- a/ui/src/app/api/files/[...filePath]/route.ts +++ b/ui/src/app/api/files/[...filePath]/route.ts @@ -4,12 +4,14 @@ import fs from 'fs'; import path from 'path'; import { Readable } from 'stream'; import { getDatasetsRoot, getTrainingFolder } from '@/server/settings'; +import { catchAllToFilePath } from '@/server/catchAllPath'; -export async function GET(request: NextRequest, { params }: { params: { filePath: string } }) { +export async function GET(request: NextRequest, { params }: { params: { filePath: string | string[] } }) { const { filePath } = await params; try { - // Decode the path - const decodedFilePath = decodeURIComponent(filePath); + // Segments are already URL-decoded by Next.js; accepts both the legacy + // single-segment form and the `/` form. + const decodedFilePath = catchAllToFilePath(filePath); // Get allowed directories const datasetRoot = await getDatasetsRoot(); diff --git a/ui/src/app/api/img/[...imagePath]/route.ts b/ui/src/app/api/img/[...imagePath]/route.ts index 70c25ab3..fa32832d 100644 --- a/ui/src/app/api/img/[...imagePath]/route.ts +++ b/ui/src/app/api/img/[...imagePath]/route.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import path from 'path'; import { Readable } from 'stream'; import { getDatasetsRoot, getTrainingFolder, getDataRoot } from '@/server/settings'; +import { catchAllToFilePath } from '@/server/catchAllPath'; const contentTypeMap: { [key: string]: string } = { // Images @@ -29,11 +30,12 @@ const contentTypeMap: { [key: string]: string } = { '.ogg': 'audio/ogg', }; -export async function GET(request: NextRequest, { params }: { params: { imagePath: string } }) { +export async function GET(request: NextRequest, { params }: { params: { imagePath: string | string[] } }) { const { imagePath } = await params; try { - // Decode the path - const filepath = decodeURIComponent(imagePath); + // Segments are already URL-decoded by Next.js; accepts both the legacy + // single-segment form and the `/` form. + const filepath = catchAllToFilePath(imagePath); // Get allowed directories const datasetRoot = await getDatasetsRoot(); diff --git a/ui/src/components/AudioPlayer.tsx b/ui/src/components/AudioPlayer.tsx index 93f1a588..250c529e 100644 --- a/ui/src/components/AudioPlayer.tsx +++ b/ui/src/components/AudioPlayer.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { apiClient } from '@/utils/api'; +import { encodeFilePathForUrl } from '@/utils/basic'; type AudioPlayerProps = { src: string; @@ -40,16 +41,17 @@ function broadcastExclusivePlay(token: string) { /** * Build the server-side album-art URL from the audio src. - * The audio src is `/api/img/{encodedPath}` — we extract the path - * and point to `/api/audio/art/{encodedPath}` instead. + * The audio src is `/api/img/{encodedFolder}/{encodedFile}` (or the legacy + * `/api/img/{encodedPath}`) — we keep the encoded tail as-is and point to + * `/api/audio/art/...` instead; that route accepts both shapes. */ function albumArtUrlFromSrc(src: string): string { const prefix = '/api/img/'; if (src.startsWith(prefix)) { return `/api/audio/art/${src.slice(prefix.length)}`; } - // Fallback: assume src is already an encoded path - return `/api/audio/art/${encodeURIComponent(src)}`; + // Fallback: assume src is a raw file path + return `/api/audio/art/${encodeFilePathForUrl(src)}`; } export default function AudioPlayer({ diff --git a/ui/src/components/DatasetActionBar.tsx b/ui/src/components/DatasetActionBar.tsx index cfba9d47..eb41d1dd 100644 --- a/ui/src/components/DatasetActionBar.tsx +++ b/ui/src/components/DatasetActionBar.tsx @@ -5,6 +5,7 @@ import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'; import { Cog, Download, Captions } from 'lucide-react'; import { LuLoader } from 'react-icons/lu'; import { apiClient } from '@/utils/api'; +import { encodeFilePathForUrl } from '@/utils/basic'; type DatasetZipTarget = 'dataset' | 'dataset_captions'; @@ -26,7 +27,7 @@ export default function DatasetActionBar({ datasetName, className }: DatasetActi // Cache-buster: /api/files serves with a long max-age, and the zip is rebuilt // at the same path every time, so a bare URL could hand back a stale download. - const downloadPath = `/api/files/${encodeURIComponent(zipPath)}?v=${Date.now()}`; + const downloadPath = `/api/files/${encodeFilePathForUrl(zipPath)}?v=${Date.now()}`; const a = document.createElement('a'); a.href = downloadPath; a.download = res.data.fileName || `${datasetName}.zip`; diff --git a/ui/src/components/DatasetImageCard.tsx b/ui/src/components/DatasetImageCard.tsx index 2f8f483f..c03ce0ae 100644 --- a/ui/src/components/DatasetImageCard.tsx +++ b/ui/src/components/DatasetImageCard.tsx @@ -4,7 +4,7 @@ import { openConfirm } from './ConfirmModal'; import classNames from 'classnames'; import { apiClient } from '@/utils/api'; import AudioPlayer from './AudioPlayer'; -import { isVideo, isAudio } from '@/utils/basic'; +import { isVideo, isAudio, encodeFilePathForUrl } from '@/utils/basic'; import useCaptionBatch, { setCachedCaption } from '@/hooks/useCaptionBatch'; interface DatasetImageCardProps { @@ -85,7 +85,7 @@ const DatasetImageCard: React.FC = ({ let objectUrl: string | null = null; const timer = window.setTimeout(() => { - fetch(`/api/img/${encodeURIComponent(imageUrl)}?thumb=1`, { signal: controller.signal }) + fetch(`/api/img/${encodeFilePathForUrl(imageUrl)}?thumb=1`, { signal: controller.signal }) .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); if ((r.headers.get('content-type') || '').startsWith('video/')) { @@ -212,7 +212,7 @@ const DatasetImageCard: React.FC = ({ > {streamVideo && (