Adjust encoded paths so filenames resolve as a basename properly when downloading with things like wget.
This commit is contained in:
parent
151ad0e959
commit
e6cffbc002
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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('/');
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue