export interface FileBrowserItem { name: string path: string size: number modified: string isDir: boolean type: string extension: string } interface FileBrowserListResponse { items: FileBrowserItem[] numDirs: number numFiles: number sorting: { by: string; asc: boolean } } /** * Normalize a path: resolve `.` and `..`, reject traversal outside root. * Always returns a path starting with `/` and never containing `..`. */ export function sanitizePath(path: string): string { const segments = path.split('/').filter(Boolean) const resolved: string[] = [] for (const seg of segments) { if (seg === '.') continue if (seg === '..') { resolved.pop() // go up one level, but never past root } else { resolved.push(seg) } } return '/' + resolved.join('/') } class FileBrowserClient { private _authenticated = false private baseUrl: string constructor() { this.baseUrl = `${window.location.origin}/app/filebrowser` } get isAuthenticated(): boolean { return this._authenticated } private getAuthCookie(): string | null { const match = document.cookie.match(/(?:^|;\s*)auth=([^;]+)/) return match ? match[1]! : null } async login(): Promise { try { // Get a filebrowser JWT via the authenticated backend (no credentials exposed to browser) // Use credentials: 'include' and CSRF token for proper auth const csrfMatch = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/) const csrfToken = csrfMatch ? csrfMatch[1]! : '' const headers: Record = { 'Content-Type': 'application/json' } if (csrfToken) headers['X-CSRF-Token'] = csrfToken const rpcRes = await fetch('/rpc/v1', { method: 'POST', headers, body: JSON.stringify({ method: 'app.filebrowser-token' }), credentials: 'include', }) if (!rpcRes.ok) return false const rpcData = await rpcRes.json() const token = rpcData?.result?.token if (!token) return false const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString() const secure = window.location.protocol === 'https:' ? '; Secure' : '' document.cookie = `auth=${token}; path=/; SameSite=Lax${secure}; expires=${expires}` this._authenticated = true return true } catch { return false } } private headers(): Record { const h: Record = {} const cookie = this.getAuthCookie() if (cookie) h['X-Auth'] = cookie return h } /** Ensure we're authenticated before making a request. Auto-logins if needed. */ private async ensureAuth(): Promise { if (this._authenticated && this.getAuthCookie()) return const ok = await this.login() if (!ok) throw new Error('FileBrowser authentication failed — please open Cloud to log in') } async listDirectory(path: string): Promise { await this.ensureAuth() const safePath = sanitizePath(path) const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, { headers: this.headers(), }) if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`) // When File Browser isn't installed, nginx falls through to the SPA and // returns index.html (200, text/html); when it's down it returns 502. // Either way res.json() would throw the opaque "Unexpected token '<'" // error, so detect a non-JSON body and surface a friendly message instead. const contentType = res.headers.get('content-type') || '' if (!contentType.includes('application/json')) { throw new Error('File Browser is not available — install or start the File Browser app to use your folders') } const data: FileBrowserListResponse = await res.json() return (data.items || []).map((item) => ({ ...item, extension: item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : '', })) } /** * @deprecated Use fetchBlobUrl() instead to avoid exposing tokens in URLs. * Returns a plain URL (no token in query string). */ downloadUrl(path: string): string { const safePath = sanitizePath(path) return `${this.baseUrl}/api/raw${safePath}` } /** * Fetch a file as a blob URL using header-based auth (no token in URL). * Use this for img/video/audio src attributes and download links. * For large files (video/audio), prefer streamUrl() instead. */ async fetchBlobUrl(path: string): Promise { await this.ensureAuth() const safePath = sanitizePath(path) const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, { headers: this.headers(), }) if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`) const blob = await res.blob() return URL.createObjectURL(blob) } /** * Get a direct streaming URL with auth token in query string. * Use for video/audio where browser needs to stream (range requests). * The token is a short-lived JWT so exposure in URL is acceptable. */ async streamUrl(path: string): Promise { await this.ensureAuth() const token = this.getAuthCookie() const safePath = sanitizePath(path) return `${this.baseUrl}/api/raw${safePath}?auth=${token}` } /** * Trigger a file download using header-based auth (no token in URL). */ async downloadFile(path: string): Promise { const blobUrl = await this.fetchBlobUrl(path) const filename = path.split('/').pop() || 'download' const a = document.createElement('a') a.href = blobUrl a.download = filename document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(blobUrl) } async upload(dirPath: string, file: File): Promise { await this.ensureAuth() const sanitized = sanitizePath(dirPath) const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const encodedName = encodeURIComponent(file.name) const res = await fetch( `${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`, { method: 'POST', headers: this.headers(), body: file, }, ) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(`Upload failed (${res.status}): ${text}`) } } async createFolder(parentPath: string, name: string): Promise { await this.ensureAuth() const sanitized = sanitizePath(parentPath) const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '') const res = await fetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, { method: 'POST', headers: this.headers(), }) if (!res.ok) throw new Error(`Create folder failed: ${res.status}`) } async deleteItem(path: string): Promise { await this.ensureAuth() const safePath = sanitizePath(path) const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, { method: 'DELETE', headers: this.headers(), }) if (!res.ok) throw new Error(`Delete failed: ${res.status}`) } async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> { if (!this._authenticated || !this.getAuthCookie()) { const ok = await this.login() if (!ok) return { totalSize: 0, folderCount: 0, fileCount: 0 } } const res = await fetch(`${this.baseUrl}/api/resources/`, { headers: this.headers(), }) if (!res.ok) return { totalSize: 0, folderCount: 0, fileCount: 0 } const data: FileBrowserListResponse = await res.json() const items = data.items || [] const folderCount = items.filter(i => i.isDir).length const fileCount = items.filter(i => !i.isDir).length const totalSize = items.reduce((sum, i) => sum + (i.size || 0), 0) return { totalSize, folderCount, fileCount } } private static TEXT_EXTENSIONS = new Set([ 'txt', 'md', 'json', 'csv', 'log', 'conf', 'yaml', 'yml', 'toml', 'xml', 'html', 'css', 'js', 'ts', 'py', 'sh', 'bash', 'env', 'ini', 'cfg', 'sql', 'rs', 'go', 'java', 'c', 'h', 'cpp', 'hpp', 'rb', 'php', 'dockerfile', 'makefile', 'gitignore', 'editorconfig', ]) isTextFile(path: string): boolean { const ext = path.includes('.') ? path.split('.').pop()!.toLowerCase() : '' const name = path.split('/').pop()?.toLowerCase() || '' return FileBrowserClient.TEXT_EXTENSIONS.has(ext) || FileBrowserClient.TEXT_EXTENSIONS.has(name) } async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> { if (!this._authenticated || !this.getAuthCookie()) { const ok = await this.login() if (!ok) throw new Error('FileBrowser authentication failed') } if (!this.isTextFile(path)) { throw new Error(`Cannot read binary file: ${path}`) } const safePath = sanitizePath(path) const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, { headers: this.headers(), }) if (!res.ok) throw new Error(`Failed to read file: ${res.status}`) const blob = await res.blob() const size = blob.size const truncated = size > maxBytes const slice = truncated ? blob.slice(0, maxBytes) : blob const content = await slice.text() return { content, truncated, size } } async rename(oldPath: string, newName: string): Promise { const safePath = sanitizePath(oldPath) const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1) const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '') const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, { method: 'PATCH', headers: { ...this.headers(), 'Content-Type': 'application/json', }, body: JSON.stringify({ destination: `${dir}${sanitizedName}` }), }) if (!res.ok) throw new Error(`Rename failed: ${res.status}`) } } export const fileBrowserClient = new FileBrowserClient()