tududi/frontend/utils/backupService.ts
Chris 6c9902b584
fix: add CSRF token support to frontend requests (#1025)
This commit implements CSRF token support for all session-based API
requests to fix the "CSRF token missing" and "CSRF token mismatch" errors
introduced after CSRF protection was added in commit 62c4cc84.

Changes:
- Created csrfService.ts utility for fetching and caching CSRF tokens
- Added getPostHeadersWithCsrf() helper to authUtils for async token injection
- Updated all service files (*Service.ts) to include CSRF tokens in POST/PUT/PATCH/DELETE requests
- Updated components with inline fetch calls to use getCsrfToken()
- Fixed CSRF middleware to use single lusca instance instead of creating new instances per request
- Improved generateToken() to use req.csrfToken() when available
- Added CalDAV path exemption to CSRF protection

Technical details:
- CSRF tokens are fetched from /api/csrf-token endpoint
- Tokens are cached and reused across requests to avoid unnecessary fetches
- Tokens are included in x-csrf-token header for state-changing requests
- Public endpoints (login, register) remain exempt from CSRF protection
- Bearer token authentication remains exempt from CSRF protection

Files modified:
- Backend: app.js, middleware/csrf.js
- Frontend: 13 service files, 8 component files
- New file: frontend/utils/csrfService.ts

This ensures all session-based requests properly include CSRF tokens while
maintaining support for API token authentication.
2026-04-14 15:06:56 +03:00

251 lines
6.4 KiB
TypeScript

import { handleAuthResponse, getPostHeadersWithCsrf } from './authUtils';
import { getApiPath } from '../config/paths';
import { getCsrfToken } from './csrfService';
export interface BackupData {
version: string;
exported_at: string;
user: {
uid: string;
email: string;
name: string;
surname: string;
appearance: string;
language: string;
timezone: string;
first_day_of_week: number;
[key: string]: any;
};
data: {
areas: any[];
projects: any[];
tasks: any[];
tags: any[];
notes: any[];
inbox_items: any[];
views: any[];
task_events?: any[];
};
}
export interface ImportStats {
areas: { created: number; skipped: number };
projects: { created: number; skipped: number };
tasks: { created: number; skipped: number };
tags: { created: number; skipped: number };
notes: { created: number; skipped: number };
inbox_items: { created: number; skipped: number };
views: { created: number; skipped: number };
}
export interface ImportResult {
success: boolean;
message: string;
stats: ImportStats;
}
export interface SavedBackup {
id: number;
uid: string;
file_path: string;
file_size: number;
item_counts: {
areas: number;
projects: number;
tasks: number;
tags: number;
notes: number;
inbox_items: number;
views: number;
};
version: string;
created_at: string;
}
export interface BackupListResult {
success: boolean;
backups: SavedBackup[];
}
export interface ValidationResult {
valid: boolean;
message?: string;
version?: string;
exported_at?: string;
summary?: {
areas: number;
projects: number;
tasks: number;
tags: number;
notes: number;
inbox_items: number;
views: number;
};
errors?: string[];
versionIncompatible?: boolean;
backupVersion?: string;
}
/**
* Create a new backup on the server
*/
export const createBackup = async (): Promise<SavedBackup> => {
const response = await fetch(getApiPath('backup/export'), {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
'x-csrf-token': await getCsrfToken(),
},
});
await handleAuthResponse(response, 'Failed to create backup.');
const result = await response.json();
return result.backup;
};
/**
* List all saved backups
*/
export const listSavedBackups = async (): Promise<SavedBackup[]> => {
const response = await fetch(getApiPath('backup/list'), {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
await handleAuthResponse(response, 'Failed to list backups.');
const result: BackupListResult = await response.json();
return result.backups;
};
/**
* Download a saved backup as a compressed file
*/
export const downloadSavedBackup = async (backupUid: string): Promise<void> => {
const response = await fetch(getApiPath(`backup/${backupUid}/download`), {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/gzip, application/json',
},
});
await handleAuthResponse(response, 'Failed to download backup.');
// Get the blob (compressed file)
const blob = await response.blob();
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `tududi-backup-${new Date().toISOString().split('T')[0]}.json.gz`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="(.+)"/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
// Create a download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
// Trigger the download
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
/**
* Restore from a saved backup
*/
export const restoreSavedBackup = async (
backupUid: string,
merge: boolean = true
): Promise<ImportResult> => {
const response = await fetch(getApiPath(`backup/${backupUid}/restore`), {
method: 'POST',
credentials: 'include',
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify({ merge }),
});
await handleAuthResponse(response, 'Failed to restore backup.');
return await response.json();
};
/**
* Delete a saved backup
*/
export const deleteSavedBackup = async (backupUid: string): Promise<void> => {
const response = await fetch(getApiPath(`backup/${backupUid}`), {
method: 'DELETE',
credentials: 'include',
headers: {
Accept: 'application/json',
'x-csrf-token': await getCsrfToken(),
},
});
await handleAuthResponse(response, 'Failed to delete backup.');
};
/**
* Import backup data from a file
*/
export const importBackup = async (
file: File,
merge: boolean = true
): Promise<ImportResult> => {
const formData = new FormData();
formData.append('backup', file);
formData.append('merge', merge.toString());
const response = await fetch(getApiPath('backup/import'), {
method: 'POST',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
body: formData,
});
await handleAuthResponse(response, 'Failed to import backup.');
return await response.json();
};
/**
* Validate backup file without importing
*/
export const validateBackup = async (file: File): Promise<ValidationResult> => {
const formData = new FormData();
formData.append('backup', file);
const response = await fetch(getApiPath('backup/validate'), {
method: 'POST',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
body: formData,
});
if (!response.ok) {
const error = await response.json();
return {
valid: false,
message: error.error || 'Validation failed',
errors: error.errors || [error.message],
};
}
return await response.json();
};