This repository has been archived on 2026-07-15. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
drive/frontend/src/lib/api.ts
Elijah e88243f097
All checks were successful
Automated Container Build / build-and-push (push) Successful in 24s
fix: resolve token refresh race condition and pdf viewer empty token rendering
2026-05-31 20:44:55 -07:00

528 lines
15 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}`;
const retryResponse = await fetch(`${API_BASE}${endpoint}`, {
method: options.method || 'GET',
headers,
body: options.body instanceof FormData
? options.body
: options.body
? JSON.stringify(options.body)
: undefined,
});
if (!retryResponse.ok && retryResponse.status === 401) {
if (typeof window !== 'undefined') window.dispatchEvent(new Event('auth_error'));
throw new Error(`HTTP 401`);
}
return retryResponse;
}
}
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);
}
// Notify TaskManager to poll if a task might have been created
if (typeof window !== 'undefined' && endpoint !== '/api/tasks' && endpoint !== '/api/auth/refresh') {
window.dispatchEvent(new Event('api_activity'));
}
return response;
}
private refreshPromise: Promise<boolean> | null = null;
private async refreshAccessToken(): Promise<boolean> {
if (!this.refreshToken) return false;
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = (async () => {
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;
}
this.clearTokens();
return false;
} catch {
this.clearTokens();
return false;
} finally {
this.refreshPromise = null;
}
})();
return this.refreshPromise;
}
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/${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 this.request('/api/auth/download-token');
const data = await res.json();
return data.token;
}
async createEditToken(): Promise<string> {
const res = await this.request('/api/auth/edit-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' | 'cell') {
const res = await this.request('/api/onlyoffice/create', {
method: 'POST',
body: { type }
});
return res.json();
}
async createFromTemplate(templateName: string) {
const res = await this.request('/api/onlyoffice/template', {
method: 'POST',
body: { template: templateName }
});
return res.json();
}
async signOnlyOfficeConfig(config: any) {
const res = await this.request('/api/onlyoffice/sign', {
method: 'POST',
body: config
});
return res.json();
}
async listOfficeFiles(type: 'docx' | 'pptx' | 'xlsx') {
const res = await this.request(`/api/onlyoffice/files?type=${type}`);
return res.json();
}
}
export const api = new ApiClient();
export default api;