All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m28s
491 lines
14 KiB
TypeScript
491 lines
14 KiB
TypeScript
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
|
import * as tus from 'tus-js-client';
|
|
|
|
interface RequestOptions {
|
|
method?: string;
|
|
body?: any;
|
|
headers?: Record<string, string>;
|
|
}
|
|
|
|
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<string, string> = {
|
|
...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<boolean> {
|
|
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 || this.refreshToken!);
|
|
return true;
|
|
}
|
|
|
|
// Refresh failed
|
|
this.clearTokens();
|
|
return false;
|
|
} catch (e) {
|
|
this.clearTokens();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async ensureTokenFresh(): Promise<boolean> {
|
|
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<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const upload = new tus.Upload(file, {
|
|
endpoint: `${API_BASE}/api/tus/`,
|
|
storeFingerprintForResuming: false,
|
|
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<string> {
|
|
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<string, string> {
|
|
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;
|
|
}
|
|
|
|
// --- OnlyOffice ---
|
|
async createOnlyOfficeFile(type: 'word' | 'slide') {
|
|
const res = await this.request('/api/onlyoffice/create', {
|
|
method: 'POST',
|
|
body: { type }
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
async signOnlyOfficeConfig(config: any) {
|
|
const res = await this.request('/api/onlyoffice/sign', {
|
|
method: 'POST',
|
|
body: config
|
|
});
|
|
return res.json();
|
|
}
|
|
}
|
|
|
|
export const api = new ApiClient();
|
|
export default api;
|