Adjust encoded paths so filenames resolve as a basename properly when downloading with things like wget.

This commit is contained in:
Jaret Burkett 2026-08-15 08:56:55 -06:00
parent 151ad0e959
commit e6cffbc002
15 changed files with 94 additions and 39 deletions

View File

@ -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)

View File

@ -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 `<folder>/<filename>` form.
const filepath = catchAllToFilePath(audioPath);
// Security check
const datasetRoot = await getDatasetsRoot();

View File

@ -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 `<folder>/<filename>` form.
const decodedFilePath = catchAllToFilePath(filePath);
// Get allowed directories
const datasetRoot = await getDatasetsRoot();

View File

@ -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 `<folder>/<filename>` form.
const filepath = catchAllToFilePath(imagePath);
// Get allowed directories
const datasetRoot = await getDatasetsRoot();

View File

@ -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({

View File

@ -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`;

View File

@ -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<DatasetImageCardProps> = ({
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<DatasetImageCardProps> = ({
>
{streamVideo && (
<video
src={`/api/img/${encodeURIComponent(imageUrl)}`}
src={`/api/img/${encodeFilePathForUrl(imageUrl)}`}
className={classNames('w-full h-full object-contain', {
'cursor-zoom-in': !!onImageClick,
})}
@ -240,7 +240,7 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
</div>
)}
{isItAudio && showAudioPlayer && (
<AudioPlayer src={`/api/img/${encodeURIComponent(imageUrl)}`} title={imageUrl.replace(/^.*[\\/]/, '')} />
<AudioPlayer src={`/api/img/${encodeFilePathForUrl(imageUrl)}`} title={imageUrl.replace(/^.*[\\/]/, '')} />
)}
{!isItAudio && blobUrl && (
<img

View File

@ -7,7 +7,7 @@ import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
import classNames from 'classnames';
import { openConfirm } from './ConfirmModal';
import { apiClient } from '@/utils/api';
import { isVideo, isAudio } from '@/utils/basic';
import { isVideo, isAudio, encodeFilePathForUrl } from '@/utils/basic';
import AudioPlayer from './AudioPlayer';
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
import { BoundingBoxEditor, parseBoundingBoxes, extractBoxes } from './BoundingBoxOverlay';
@ -410,11 +410,11 @@ export default function DatasetImageViewer({
{imgPath &&
(isAudio(imgPath) ? (
<div className="w-[500px] h-[500px] max-w-full max-h-[50vh] sm:max-h-[90vh]">
<AudioPlayer src={`/api/img/${encodeURIComponent(imgPath)}`} title={filename} autoPlay />
<AudioPlayer src={`/api/img/${encodeFilePathForUrl(imgPath)}`} title={filename} autoPlay />
</div>
) : isVideo(imgPath) ? (
<video
src={`/api/img/${encodeURIComponent(imgPath)}`}
src={`/api/img/${encodeFilePathForUrl(imgPath)}`}
className="w-auto h-auto max-w-full max-h-[50vh] sm:max-h-[90vh] object-contain"
preload="none"
playsInline
@ -438,7 +438,7 @@ export default function DatasetImageViewer({
<TransformComponent>
<div className="relative">
<img
src={`/api/img/${encodeURIComponent(imgPath)}`}
src={`/api/img/${encodeFilePathForUrl(imgPath)}`}
alt="Dataset Image"
draggable={false}
className="w-auto h-auto max-w-full max-h-[50vh] sm:max-h-[90vh] object-contain select-none !pointer-events-auto"
@ -493,7 +493,7 @@ export default function DatasetImageViewer({
<MenuItem>
<a
className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded block"
href={`/api/img/${encodeURIComponent(imgPath)}`}
href={`/api/img/${encodeFilePathForUrl(imgPath)}`}
download={filename}
>
Download

View File

@ -2,7 +2,7 @@ import React from 'react';
import useFilesList from '@/hooks/useFilesList';
import { Loader2, AlertCircle, Download, Box, Brain, Trash2, SlidersHorizontal } from 'lucide-react';
import { openMergeLoRAsModal } from './MergeLoRAsModal';
import { getFilename, getFoldername } from '@/utils/basic';
import { getFilename, getFoldername, encodeFilePathForUrl } from '@/utils/basic';
import { openConfirm } from './ConfirmModal';
import { apiClient } from '@/utils/api';
@ -99,7 +99,7 @@ export default function FilesWidget({ jobID, jobName }: { jobID: string; jobName
>
<a
target="_blank"
href={`/api/files/${encodeURIComponent(file.path)}`}
href={`/api/files/${encodeFilePathForUrl(file.path)}`}
className="flex items-center space-x-2 min-w-0 flex-1"
>
<Box className="w-4 h-4 text-purple-600 dark:text-purple-400 flex-shrink-0" />
@ -116,7 +116,7 @@ export default function FilesWidget({ jobID, jobName }: { jobID: string; jobName
<span className="text-xs text-gray-400">{cleanSize(file.size)}</span>
<a
target="_blank"
href={`/api/files/${encodeURIComponent(file.path)}`}
href={`/api/files/${encodeFilePathForUrl(file.path)}`}
className="bg-purple-500 bg-opacity-0 group-hover:bg-opacity-10 rounded-full p-1 transition-all"
>
<Download className="w-3 h-3 text-purple-600 dark:text-purple-400" />
@ -138,7 +138,7 @@ export default function FilesWidget({ jobID, jobName }: { jobID: string; jobName
<div className="group flex items-center justify-between px-2 py-1.5 rounded-lg border-t border-gray-800 mt-1 pt-2 hover:bg-gray-800 transition-all duration-200">
<a
target="_blank"
href={`/api/files/${encodeURIComponent(optimizerFile.path)}`}
href={`/api/files/${encodeFilePathForUrl(optimizerFile.path)}`}
className="flex items-center space-x-2 min-w-0 flex-1"
>
<SlidersHorizontal className="w-4 h-4 text-amber-500 flex-shrink-0" />
@ -153,7 +153,7 @@ export default function FilesWidget({ jobID, jobName }: { jobID: string; jobName
<span className="text-xs text-gray-400">{cleanSize(optimizerFile.size)}</span>
<a
target="_blank"
href={`/api/files/${encodeURIComponent(optimizerFile.path)}`}
href={`/api/files/${encodeFilePathForUrl(optimizerFile.path)}`}
className="bg-amber-500 bg-opacity-0 group-hover:bg-opacity-10 rounded-full p-1 transition-all"
>
<Download className="w-3 h-3 text-amber-500" />

View File

@ -5,6 +5,7 @@ import classNames from 'classnames';
import { useDropzone } from 'react-dropzone';
import { FaUpload, FaImage, FaTimes } from 'react-icons/fa';
import { apiClient } from '@/utils/api';
import { encodeFilePathForUrl } from '@/utils/basic';
import type { AxiosProgressEvent } from 'axios';
const VIDEO_EXTS = ['.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.wmv', '.flv'];
@ -31,7 +32,7 @@ export default function SampleControlImage({
const backgroundUrl = useMemo(() => {
if (localPreview) return localPreview;
// videos preview as a server-generated thumbnail
if (src) return `/api/img/${encodeURIComponent(src)}${isVideoPath(src) ? '?thumb=1' : ''}`;
if (src) return `/api/img/${encodeFilePathForUrl(src)}${isVideoPath(src) ? '?thumb=1' : ''}`;
return null;
}, [src, localPreview]);

View File

@ -1,5 +1,5 @@
import React, { useRef, useEffect, useState, ReactNode } from 'react';
import { isVideo, isAudio } from '@/utils/basic';
import { isVideo, isAudio, encodeFilePathForUrl } from '@/utils/basic';
interface SampleImageCardProps {
imageUrl: string;
@ -78,7 +78,7 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
// ?thumb=1: the server sends the small pre-generated thumbnail when one
// exists, otherwise the full file. Videos without a thumb come back as
// video/* — abort the transfer and render the <video> element instead.
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}`);
const contentType = r.headers.get('content-type') || '';
@ -137,7 +137,7 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
) : isItVideo && videoFallback ? (
<video
ref={videoRef}
src={`/api/img/${encodeURIComponent(imageUrl)}`}
src={`/api/img/${encodeFilePathForUrl(imageUrl)}`}
className="w-full h-full object-cover"
preload="none"
playsInline

View File

@ -8,7 +8,7 @@ import classNames from 'classnames';
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
import { openConfirm } from './ConfirmModal';
import { apiClient } from '@/utils/api';
import { isVideo, isAudio } from '@/utils/basic';
import { isVideo, isAudio, encodeFilePathForUrl } from '@/utils/basic';
import AudioPlayer from './AudioPlayer';
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
import BoundingBoxOverlay, { parseBoundingBoxes } from './BoundingBoxOverlay';
@ -314,14 +314,14 @@ export default function SampleImageViewer({
(isAudio(displayedImgPath) ? (
<div className="w-[500px] h-[500px] max-w-full sm:max-w-[95vw] max-h-[82vh]">
<AudioPlayer
src={`/api/img/${encodeURIComponent(displayedImgPath)}`}
src={`/api/img/${encodeFilePathForUrl(displayedImgPath)}`}
title={displayedImgPath.replace(/^.*[\\/]/, '')}
autoPlay
/>
</div>
) : isVideo(displayedImgPath) ? (
<video
src={`/api/img/${encodeURIComponent(displayedImgPath)}`}
src={`/api/img/${encodeFilePathForUrl(displayedImgPath)}`}
className="w-auto h-auto max-w-full sm:max-w-[95vw] max-h-[82vh] object-contain"
preload="none"
playsInline
@ -345,7 +345,7 @@ export default function SampleImageViewer({
<TransformComponent>
<div className="relative">
<img
src={`/api/img/${encodeURIComponent(displayedImgPath)}`}
src={`/api/img/${encodeFilePathForUrl(displayedImgPath)}`}
alt="Sample Image"
draggable={false}
className="w-auto h-auto max-w-full sm:max-w-[95vw] max-h-[82vh] object-contain select-none !pointer-events-auto"
@ -372,7 +372,7 @@ export default function SampleImageViewer({
<div key={imgPath} className="flex space-x-2 mr-4">
{showingControlIdx !== null && (
<img
src={`/api/img/${encodeURIComponent(imgPath!)}?thumb=1`}
src={`/api/img/${encodeFilePathForUrl(imgPath!)}?thumb=1`}
alt="Main"
className="max-h-12 max-w-12 object-contain bg-black border-2 border-gray-700 hover:border-gray-500 rounded cursor-pointer"
onClick={() => setShowingControlIdx(null)}
@ -382,7 +382,7 @@ export default function SampleImageViewer({
{controlImages.map((ci, idx) => (
<img
key={idx}
src={`/api/img/${encodeURIComponent(ci)}?thumb=1`}
src={`/api/img/${encodeFilePathForUrl(ci)}?thumb=1`}
alt={`Control ${idx + 1}`}
className={`max-h-12 max-w-12 object-contain bg-black border-2 rounded cursor-pointer ${
showingControlIdx === idx ? 'border-blue-500' : 'border-gray-700 hover:border-gray-500'
@ -433,7 +433,7 @@ export default function SampleImageViewer({
<MenuItem>
<a
className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded block"
href={`/api/img/${encodeURIComponent(imgPath)}`}
href={`/api/img/${encodeFilePathForUrl(imgPath)}`}
download={imgPath.replace(/^.*[\\/]/, '')}
>
Download

View File

@ -8,6 +8,7 @@ import { LuImageOff, LuLoader, LuBan } from 'react-icons/lu';
import { Button } from '@headlessui/react';
import { FaDownload } from 'react-icons/fa';
import { apiClient } from '@/utils/api';
import { encodeFilePathForUrl } from '@/utils/basic';
import classNames from 'classnames';
import { FaCaretDown, FaCaretUp } from 'react-icons/fa';
import SampleImageViewer from './SampleImageViewer';
@ -32,7 +33,7 @@ export const SampleImagesMenu = ({ job }: SampleImagesMenuProps) => {
const zipPath = res.data.zipPath; // e.g. /mnt/Train2/out/ui/.../samples.zip
if (!zipPath) throw new Error('No zipPath in response');
const downloadPath = `/api/files/${encodeURIComponent(zipPath)}`;
const downloadPath = `/api/files/${encodeFilePathForUrl(zipPath)}`;
const a = document.createElement('a');
a.href = downloadPath;
// optional: suggest filename (browser may ignore if server sets Content-Disposition)

View File

@ -0,0 +1,19 @@
/**
* Turn a `[...param]` catch-all route param into the requested filesystem path.
*
* Next.js hands catch-all params over as an array of URL segments with the
* percent-encoding of each segment already decoded, so both URL forms map to
* the same path:
* /api/files/%2Fmnt%2Fout%2Fjob%2Ffile.safetensors (legacy: one segment)
* /api/files/%2Fmnt%2Fout%2Fjob/file.safetensors (folder / filename)
* The second form gives downloaders (wget, browsers) the real filename as the
* last URL segment. Windows paths arrive with their backslashes intact inside
* a segment (`C:\out\job` + `/` + `file.safetensors`); path.resolve on the
* caller side normalizes the mixed separators.
*
* Mirrors the per-segment decode in cron/fileServer.ts serveFile().
*/
export const catchAllToFilePath = (param: string | string[] | undefined): string => {
if (param === undefined) return '';
return (Array.isArray(param) ? param : [param]).join('/');
};

View File

@ -46,6 +46,23 @@ export const getFoldername = (filePath: string) => {
return idx === -1 ? '' : filePath.slice(0, idx);
};
/**
* Encode an absolute file path for /api/files/ and /api/img/ URLs as
* `<encoded folder>/<encoded filename>` two URL segments, so the last one is
* the real filename and downloaders (wget, curl -O, browsers) save it under
* that name instead of the fully-escaped path. Works for posix and Windows
* paths (`C:\foo\bar.safetensors` -> `C%3A%5Cfoo/bar.safetensors`). The
* servers accept both this and the legacy single-segment
* `encodeURIComponent(fullPath)` form.
*/
export const encodeFilePathForUrl = (filePath: string) => {
const idx = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
if (idx === -1) return encodeURIComponent(filePath);
// keep a root-only folder ("/" or "C:\") as a non-empty segment
const folder = idx === 0 ? filePath[0] : filePath.slice(0, idx);
return `${encodeURIComponent(folder)}/${encodeURIComponent(filePath.slice(idx + 1))}`;
};
export const pathJoin = (...parts: string[]) => {
const sep = parts.length > 0 && parts[0].includes('\\') ? '\\' : '/';
const leadingTrailing = sep === '\\' ? /^\\+|\\+$/g : /^\/+|\/+$/g;