const API_BASE = process.env.NEXT_PUBLIC_API_URL || ''; import * as tus from 'tus-js-client'; interface RequestOptions { method?: string; body?: any; headers?: Record; } class ApiClient { private accessToken: string | null = null; private refreshToken: string | null = null; constructor() { if (typeof window !== 'undefined') { this.accessToken = localStorage.getItem('access_token'); this.refreshToken = localStorage.getItem('refresh_token'); } } setTokens(accessToken: string, refreshToken: string) { this.accessToken = accessToken; this.refreshToken = refreshToken; localStorage.setItem('access_token', accessToken); localStorage.setItem('refresh_token', refreshToken); } clearTokens() { this.accessToken = null; this.refreshToken = null; localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); } getAccessToken() { return this.accessToken; } isAuthenticated() { return !!this.accessToken; } private async request(endpoint: string, options: RequestOptions = {}) { const headers: Record = { ...options.headers, }; if (this.accessToken) { headers['Authorization'] = `Bearer ${this.accessToken}`; } if (options.body && !(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json'; } const response = await fetch(`${API_BASE}${endpoint}`, { method: options.method || 'GET', headers, body: options.body instanceof FormData ? options.body : options.body ? JSON.stringify(options.body) : undefined, }); // Try to refresh token on 401 if (response.status === 401 && this.refreshToken && endpoint !== '/api/auth/refresh') { const refreshed = await this.refreshAccessToken(); if (refreshed) { headers['Authorization'] = `Bearer ${this.accessToken}`; return fetch(`${API_BASE}${endpoint}`, { method: options.method || 'GET', headers, body: options.body instanceof FormData ? options.body : options.body ? JSON.stringify(options.body) : undefined, }); } } if (!response.ok && endpoint !== '/api/auth/refresh') { if (response.status === 401) { if (typeof window !== 'undefined') window.dispatchEvent(new Event('auth_error')); } let errorMsg = `HTTP ${response.status}`; try { const errData = await response.clone().json(); if (errData.error) errorMsg = errData.error; } catch {} throw new Error(errorMsg); } return response; } private async refreshAccessToken(): Promise { if (!this.refreshToken) return false; try { const response = await fetch(`${API_BASE}/api/auth/refresh`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.refreshToken}`, }, }); if (response.ok) { const data = await response.json(); this.setTokens(data.access_token, data.refresh_token); return true; } // Refresh failed this.clearTokens(); return false; } catch (e) { this.clearTokens(); return false; } } async ensureTokenFresh(): Promise { if (!this.accessToken) return false; try { const parts = this.accessToken.split('.'); if (parts.length !== 3) return false; const payload = JSON.parse(atob(parts[1])); const exp = payload.exp * 1000; // If expiring within 60 seconds, refresh it if (Date.now() > exp - 60000) { return await this.refreshAccessToken(); } return true; } catch (e) { return false; } } // --- Setup --- async checkSetup() { const res = await this.request('/api/setup/status'); return res.json(); } async setup(username: string, password: string) { const res = await this.request('/api/setup', { method: 'POST', body: { username, password }, }); return res.json(); } // --- Auth --- async login(username: string, password: string, totpCode?: string, rememberMe?: boolean) { const res = await this.request('/api/auth/login', { method: 'POST', body: { username, password, totp_code: totpCode, remember_me: rememberMe }, }); const data = await res.json(); if (res.ok && data.access_token) { this.setTokens(data.access_token, data.refresh_token); } return { ok: res.ok, status: res.status, data }; } logout() { this.clearTokens(); } async changePassword(oldPassword: string, newPassword: string) { const res = await this.request('/api/auth/password', { method: 'PUT', body: { old_password: oldPassword, new_password: newPassword }, }); return res.json(); } // --- Files --- async checkConflicts(destPath: string, files: string[]) { const res = await this.request('/api/files/check-conflicts', { method: 'POST', body: { dest_path: destPath, files }, }); return res.json(); } async listDirectory(path: string = '.') { const res = await this.request(`/api/files/?path=${encodeURIComponent(path)}`); return res.json(); } async getFolderTree() { const res = await this.request('/api/files/folders-tree'); return res.json(); } async getExtendedFileInfo(path: string) { const res = await this.request(`/api/files/info/?path=${encodeURIComponent(path)}`); return res.json(); } async createFolder(path: string) { const res = await this.request('/api/files/mkdir', { method: 'POST', body: { path }, }); return res.json(); } async rename(oldPath: string, newName: string) { const res = await this.request('/api/files/rename', { method: 'POST', body: { old_path: oldPath, new_name: newName }, }); return res.json(); } async move(sourcePath: string, destPath: string, resolution: string = "rename") { const res = await this.request('/api/files/move', { method: 'POST', body: { source_path: sourcePath, dest_path: destPath, resolution }, }); return res.json(); } async copy(sourcePath: string, destPath: string, resolution: string = "rename") { const res = await this.request('/api/files/copy', { method: 'POST', body: { source_path: sourcePath, dest_path: destPath, resolution }, }); return res.json(); } async deleteFile(path: string, permanent: boolean = false) { const query = permanent ? `?permanent=true&path=${encodeURIComponent(path)}` : `?path=${encodeURIComponent(path)}`; const res = await this.request(`/api/files/${encodeURIComponent(path)}${query}`, { method: 'DELETE', }); return res.json(); } upload(file: File, destPath: string, options?: { onProgress?: (percent: number) => void, overwrite?: boolean, signal?: AbortSignal }): Promise { return new Promise((resolve, reject) => { const upload = new tus.Upload(file, { endpoint: `${API_BASE}/api/tus/`, chunkSize: 5 * 1024 * 1024, // 5MB chunks bypass Next.js 10MB proxy limit retryDelays: [0, 3000, 5000, 10000, 20000], metadata: { filename: file.name, filetype: file.type, path: destPath, overwrite: options?.overwrite ? "true" : "false", }, onBeforeRequest: async (req) => { await this.ensureTokenFresh(); const xhr = req.getUnderlyingObject(); xhr.setRequestHeader('Authorization', `Bearer ${this.accessToken}`); }, onError: function (error) { reject(error); }, onProgress: function (bytesUploaded, bytesTotal) { const percentage = (bytesUploaded / bytesTotal) * 100; options?.onProgress?.(percentage); }, onSuccess: function () { resolve(); }, }); if (options?.signal) { options.signal.addEventListener('abort', () => { upload.abort(true).then(() => { reject(new Error('Upload canceled')); }).catch(reject); }); } upload.start(); }); } async createDownloadToken(): Promise { const res = await fetch(`${API_BASE}/api/auth/download-token`, { method: 'GET', headers: { Authorization: `Bearer ${this.accessToken}`, }, }); if (!res.ok) throw new Error('Failed to fetch download token'); const data = await res.json(); return data.token; } getDownloadUrl(path: string, token: string) { return `${API_BASE}/api/files/download/?path=${encodeURIComponent(path)}&token=${token}`; } getDownloadFolderUrl(path: string, token: string) { return `${API_BASE}/api/files/download-folder?path=${encodeURIComponent(path)}&token=${token}`; } getBulkDownloadUrl(token: string) { return `${API_BASE}/api/files/download-bulk?token=${token}`; } getThumbnailUrl(checksum: string, token: string) { return `${API_BASE}/api/files/thumbnail/${encodeURIComponent(checksum)}?token=${token}`; } getStreamHeaders(): Record { return this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {}; } // --- Search --- async search(query: string) { const res = await this.request(`/api/search?q=${encodeURIComponent(query)}`); return res.json(); } // --- Pinned --- async listPinned() { const res = await this.request('/api/files/pinned'); if (!res.ok) throw await res.json(); return res.json(); } async togglePin(path: string) { const res = await this.request('/api/files/pin', { method: 'POST', body: { path }, }); if (!res.ok) throw await res.json(); return res.json(); } async getTasks() { const res = await this.request('/api/tasks'); if (!res.ok) throw await res.json(); return res.json(); } async getSettings() { const res = await this.request('/api/settings'); if (!res.ok) throw await res.json(); return res.json(); } async updateSettings(settings: any) { const res = await this.request('/api/settings', { method: 'PUT', body: settings, }); return res.json(); } async getStorageDashboard() { const res = await this.request('/api/storage/dashboard'); return res.json(); } // --- Trash --- async listTrash() { const res = await this.request('/api/trash'); return res.json(); } async restoreFromTrash(path: string) { const res = await this.request('/api/trash/restore', { method: 'POST', body: { path }, }); return res.json(); } async emptyTrash() { const res = await this.request('/api/trash', { method: 'DELETE' }); return res.json(); } // --- Audit --- async getAuditLog(limit = 100, offset = 0) { const res = await this.request(`/api/audit?limit=${limit}&offset=${offset}`); return res.json(); } // --- 2FA --- async enable2FA() { const res = await this.request('/api/auth/2fa/enable', { method: 'POST' }); return res.json(); } async confirm2FA(code: string) { const res = await this.request('/api/auth/2fa/confirm', { method: 'POST', body: { code }, }); return res.json(); } async disable2FA() { const res = await this.request('/api/auth/2fa/disable', { method: 'POST' }); return res.json(); } // --- Sharing --- async createShare(path: string, password?: string, expiresIn?: number, maxDownloads?: number) { const res = await this.request('/api/share', { method: 'POST', body: { path, password, expires_in: expiresIn, max_downloads: maxDownloads }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to create share'); return data; } async listShares() { const res = await this.request('/api/share'); return res.json(); } async revokeShare(id: string) { const res = await this.request(`/api/share/${id}`, { method: 'DELETE' }); return res.json(); } async getPublicShare(id: string, password?: string, path?: string) { // Note: No auth headers here, so we use fetch directly to avoid interceptors let url = `${API_BASE}/api/public/share/${id}`; if (path) { url += `?path=${encodeURIComponent(path)}`; } const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw err; } return res.json(); } async createPublicShareToken(id: string, password?: string) { const res = await fetch(`${API_BASE}/api/public/share/${id}/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: password || '' }), }); if (!res.ok) throw await res.json(); const data = await res.json(); return data.token; } getPublicDownloadUrl(id: string, token: string, path?: string) { let url = `${API_BASE}/api/public/download/${id}`; const params = new URLSearchParams(); if (token) params.set('token', token); if (path) params.set('path', path); const qs = params.toString(); if (qs) { url += `?${qs}`; } return url; } } export const api = new ApiClient(); export default api;