Skip to content

Commit 37181d7

Browse files
refactor: centralize file API URL construction and path resolution
- Extract getFileApiUrl helper with options interface for type-safe URL building - Handle paths containing '.' and '..' segments via query parameters - Remove duplicate URL construction logic from FileBrowser, FilePreview, and FileTree components - Add getSpecialRoutePathFromRequest helper in backend for consistent path extraction
1 parent 95854ff commit 37181d7

5 files changed

Lines changed: 58 additions & 54 deletions

File tree

backend/src/routes/files.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,24 @@ function getFilePathFromRequest(c: Context, fallbackPath: string): string {
1313
return c.req.query('path') ?? decodeFilePath(fallbackPath)
1414
}
1515

16+
function getSpecialRoutePathFromRequest(c: Context, routeName: string): string | undefined {
17+
const queryPath = c.req.query('path')
18+
if (queryPath !== undefined) {
19+
return queryPath
20+
}
21+
22+
const match = c.req.path.match(new RegExp(`/api/files/(.+?)/${routeName}$`))
23+
return match?.[1] ? decodeFilePath(match[1]) : undefined
24+
}
25+
1626
export function createFileRoutes() {
1727
const app = new Hono()
1828

1929
app.get('*', async (c) => {
2030
const path = c.req.path
2131

2232
if (path.endsWith('/download-zip')) {
23-
const match = path.match(/\/api\/files\/(.+?)\/download-zip$/)
24-
const userPath = match?.[1] ? decodeFilePath(match[1]) : undefined
33+
const userPath = getSpecialRoutePathFromRequest(c, 'download-zip')
2534

2635
if (!userPath) {
2736
return c.json({ error: 'No path provided' }, 400)
@@ -69,7 +78,7 @@ export function createFileRoutes() {
6978
}
7079

7180
if (path.endsWith('/ignored-paths')) {
72-
const userPath = decodeFilePath(path.replace(/\/api\/files\/(.+?)\/ignored-paths$/, '$1'))
81+
const userPath = getSpecialRoutePathFromRequest(c, 'ignored-paths')
7382

7483
if (!userPath || userPath === '/ignored-paths') {
7584
return c.json({ error: 'No path provided' }, 400)

frontend/src/api/files.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,34 @@ import { fetchWrapper, fetchWrapperBlob } from './fetchWrapper'
33
import { API_BASE_URL } from '@/config'
44
import type { FileInfo, ChunkedFileInfo, PatchOperation } from '@/types/files'
55

6-
function getFileApiUrl(path: string): string {
7-
if (path.includes('..')) {
8-
return `${API_BASE_URL}/api/files/?path=${encodeURIComponent(path)}`
6+
interface FileApiUrlOptions {
7+
route?: string
8+
params?: Record<string, string | number | boolean | undefined>
9+
}
10+
11+
function pathRequiresQuery(path: string): boolean {
12+
return path.split('/').some(segment => segment === '.' || segment === '..')
13+
}
14+
15+
export function getFileApiUrl(path: string, options: FileApiUrlOptions = {}): string {
16+
const searchParams = new URLSearchParams()
17+
Object.entries(options.params ?? {}).forEach(([key, value]) => {
18+
if (value !== undefined) {
19+
searchParams.append(key, String(value))
20+
}
21+
})
22+
23+
const routePath = options.route ? `/${options.route}` : ''
24+
25+
if (pathRequiresQuery(path)) {
26+
searchParams.set('path', path)
27+
const query = searchParams.toString()
28+
return `${API_BASE_URL}/api/files${routePath}${query ? `?${query}` : ''}`
929
}
1030

11-
return `${API_BASE_URL}/api/files/${path.split('/').map(encodeURIComponent).join('/')}`
31+
const encodedPath = path.split('/').map(encodeURIComponent).join('/')
32+
const query = searchParams.toString()
33+
return `${API_BASE_URL}/api/files/${encodedPath}${routePath}${query ? `?${query}` : ''}`
1234
}
1335

1436
async function fetchFile(path: string): Promise<FileInfo> {
@@ -38,7 +60,7 @@ export async function applyFilePatches(path: string, patches: PatchOperation[]):
3860
}
3961

4062
export async function getIgnoredPaths(path: string): Promise<{ ignoredPaths: string[] }> {
41-
return fetchWrapper(`${API_BASE_URL}/api/files/${path}/ignored-paths`)
63+
return fetchWrapper(getFileApiUrl(path, { route: 'ignored-paths' }))
4264
}
4365

4466
export interface DownloadOptions {
@@ -47,11 +69,13 @@ export interface DownloadOptions {
4769
}
4870

4971
export async function downloadDirectoryAsZip(path: string, options?: DownloadOptions): Promise<void> {
50-
const params = new URLSearchParams()
51-
if (options?.includeGit) params.append('includeGit', 'true')
52-
if (options?.includePaths?.length) params.append('includePaths', options.includePaths.join(','))
53-
54-
const url = `${API_BASE_URL}/api/files/${path}/download-zip${params.toString() ? '?' + params.toString() : ''}`
72+
const url = getFileApiUrl(path, {
73+
route: 'download-zip',
74+
params: {
75+
includeGit: options?.includeGit || undefined,
76+
includePaths: options?.includePaths?.length ? options.includePaths.join(',') : undefined,
77+
},
78+
})
5579

5680
const blob = await fetchWrapperBlob(url)
5781
const urlObj = window.URL.createObjectURL(blob)

frontend/src/components/file-browser/FileBrowser.tsx

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
1010
import { Progress } from '@/components/ui/progress'
1111
import { FolderOpen, Upload, RefreshCw, X } from 'lucide-react'
1212
import type { FileInfo } from '@/types/files'
13-
import { API_BASE_URL } from '@/config'
1413
import { useMobile } from '@/hooks/useMobile'
15-
import { useFile } from '@/api/files'
14+
import { getFileApiUrl, useFile } from '@/api/files'
1615

1716
export interface FileBrowserHandle {
1817
goBack: () => void
@@ -156,19 +155,6 @@ useEffect(() => {
156155
}
157156
}, [initialFileError])
158157

159-
const getFileApiUrl = useCallback((path: string) => {
160-
if (path.includes('..')) {
161-
return `${API_BASE_URL}/api/files/?path=${encodeURIComponent(path)}`
162-
}
163-
164-
const encodedPath = path
165-
.split('/')
166-
.map(segment => encodeURIComponent(segment))
167-
.join('/')
168-
169-
return `${API_BASE_URL}/api/files/${encodedPath}`
170-
}, [])
171-
172158
const loadFiles = useCallback(async (path: string) => {
173159
setLoading(true)
174160
setError(null)
@@ -188,7 +174,7 @@ useEffect(() => {
188174
} finally {
189175
setLoading(false)
190176
}
191-
}, [getFileApiUrl, onDirectoryLoad])
177+
}, [onDirectoryLoad])
192178

193179
const normalizePath = useCallback((path: string) => {
194180
const normalized = path
@@ -276,7 +262,7 @@ useEffect(() => {
276262
} finally {
277263
setLoading(false)
278264
}
279-
}, [getFileApiUrl, onFileSelect, isMobile, onPreviewStateChange])
265+
}, [onFileSelect, isMobile, onPreviewStateChange])
280266

281267
const handleCloseModal = useCallback(() => {
282268
setIsPreviewModalOpen(false)
@@ -312,7 +298,7 @@ useEffect(() => {
312298
} catch (err) {
313299
return err instanceof Error ? err.message : 'Upload failed'
314300
}
315-
}, [currentPath, getFileApiUrl])
301+
}, [currentPath])
316302

317303
const handleUploadItems = useCallback(async (items: UploadItem[]) => {
318304
if (items.length === 0) return
@@ -382,7 +368,7 @@ useEffect(() => {
382368
} catch (err) {
383369
setError(err instanceof Error ? err.message : 'Create failed')
384370
}
385-
}, [currentPath, getFileApiUrl, loadFiles])
371+
}, [currentPath, loadFiles])
386372

387373
const handleDelete = useCallback(async (path: string) => {
388374
try {
@@ -399,7 +385,7 @@ useEffect(() => {
399385
} catch (err) {
400386
setError(err instanceof Error ? err.message : 'Delete failed')
401387
}
402-
}, [currentPath, getFileApiUrl, loadFiles])
388+
}, [currentPath, loadFiles])
403389

404390
const handleRename = useCallback(async (oldPath: string, newPath: string) => {
405391
try {
@@ -417,7 +403,7 @@ useEffect(() => {
417403
} catch (err) {
418404
setError(err instanceof Error ? err.message : 'Rename failed')
419405
}
420-
}, [currentPath, getFileApiUrl, loadFiles])
406+
}, [currentPath, loadFiles])
421407

422408
const handleDragEnter = (e: React.DragEvent) => {
423409
e.preventDefault()

frontend/src/components/file-browser/FilePreview.tsx

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,14 @@ import { useState, useCallback, useRef, useEffect, memo } from 'react'
22
import { Button } from '@/components/ui/button'
33
import { Download, X, Edit3, Save, X as XIcon, WrapText, Eye, Code } from 'lucide-react'
44
import type { FileInfo } from '@/types/files'
5-
import { API_BASE_URL } from '@/config'
5+
import { getFileApiUrl } from '@/api/files'
66
import { VirtualizedTextView, type VirtualizedTextViewHandle } from '@/components/ui/virtualized-text-view'
77
import { MarkdownRenderer } from './MarkdownRenderer'
88

99

10-
const API_BASE = API_BASE_URL
11-
1210
const VIRTUALIZATION_THRESHOLD_BYTES = 50_000
1311
const MARKDOWN_PREVIEW_SIZE_LIMIT = 1_000_000
1412

15-
function getFileApiUrl(path: string, query?: string): string {
16-
const separator = query ? (path.includes('..') ? '&' : '?') : ''
17-
const suffix = query ? `${separator}${query}` : ''
18-
19-
if (path.includes('..')) {
20-
return `${API_BASE}/api/files/?path=${encodeURIComponent(path)}${suffix}`
21-
}
22-
23-
return `${API_BASE}/api/files/${path.split('/').map(encodeURIComponent).join('/')}${suffix}`
24-
}
25-
2613
interface FilePreviewProps {
2714
file: FileInfo
2815
hideHeader?: boolean
@@ -106,7 +93,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
10693

10794
const handleDownload = () => {
10895
const link = document.createElement('a')
109-
link.href = getFileApiUrl(file.path, 'download=true')
96+
link.href = getFileApiUrl(file.path, { params: { download: true } })
11097
link.download = file.name
11198
document.body.appendChild(link)
11299
link.click()
@@ -200,7 +187,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
200187
return (
201188
<div className="flex justify-center p-4">
202189
<img
203-
src={getFileApiUrl(file.path, 'raw=true')}
190+
src={getFileApiUrl(file.path, { params: { raw: true } })}
204191
alt={file.name}
205192
className="max-w-full h-auto object-contain rounded"
206193
/>

frontend/src/components/file-browser/FileTree.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useMobile } from '@/hooks/useMobile'
33
import { Button } from '@/components/ui/button'
44
import { Input } from '@/components/ui/input'
55
import { DeleteDialog } from '@/components/ui/delete-dialog'
6-
import { API_BASE_URL } from '@/config'
6+
import { getFileApiUrl } from '@/api/files'
77
import {
88
File,
99
Folder,
@@ -95,9 +95,7 @@ function TreeNode({ file, level, onFileSelect, onDirectoryClick, selectedFile, o
9595
const handleDownload = () => {
9696
if (file.isDirectory) return
9797

98-
const downloadUrl = file.path.includes('..')
99-
? `${API_BASE_URL}/api/files/?path=${encodeURIComponent(file.path)}&download=true`
100-
: `${API_BASE_URL}/api/files/${file.path.split('/').map(encodeURIComponent).join('/')}?download=true`
98+
const downloadUrl = getFileApiUrl(file.path, { params: { download: true } })
10199
const link = document.createElement('a')
102100
link.href = downloadUrl
103101
link.download = file.name

0 commit comments

Comments
 (0)