tududi/frontend/utils/inboxService.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

273 lines
8.1 KiB
TypeScript

import { InboxItem } from '../entities/InboxItem';
import { useStore } from '../store/useStore';
import { handleAuthResponse, getPostHeadersWithCsrf } from './authUtils';
import { getApiPath } from '../config/paths';
import { getCsrfToken } from './csrfService';
// API functions
export const fetchInboxItems = async (
limit: number = 20,
offset: number = 0
): Promise<{
items: InboxItem[];
pagination: {
total: number;
limit: number;
offset: number;
hasMore: boolean;
};
}> => {
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
});
const response = await fetch(getApiPath(`inbox?${params}`), {
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
await handleAuthResponse(response, 'Failed to fetch inbox items.');
const result = await response.json();
// Handle backward compatibility - if it's an array, convert to new format
if (Array.isArray(result)) {
return {
items: result,
pagination: {
total: result.length,
limit: result.length,
offset: 0,
hasMore: false,
},
};
}
if (!result.items || !Array.isArray(result.items)) {
throw new Error('Resulting inbox items are not in expected format.');
}
return result;
};
export const createInboxItem = async (
content: string,
source?: string
): Promise<InboxItem> => {
const response = await fetch(getApiPath('inbox'), {
method: 'POST',
credentials: 'include',
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify(source ? { content, source } : { content }),
});
await handleAuthResponse(response, 'Failed to create inbox item.');
return await response.json();
};
export const updateInboxItem = async (
itemUid: string,
content: string
): Promise<InboxItem> => {
const response = await fetch(getApiPath(`inbox/${itemUid}`), {
method: 'PATCH',
credentials: 'include',
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify({ content }),
});
await handleAuthResponse(response, 'Failed to update inbox item.');
return await response.json();
};
export const processInboxItem = async (itemUid: string): Promise<InboxItem> => {
const response = await fetch(getApiPath(`inbox/${itemUid}/process`), {
method: 'PATCH',
credentials: 'include',
headers: {
Accept: 'application/json',
'x-csrf-token': await getCsrfToken(),
},
});
await handleAuthResponse(response, 'Failed to process inbox item.');
return await response.json();
};
export const deleteInboxItem = async (itemUid: string): Promise<void> => {
const response = await fetch(getApiPath(`inbox/${itemUid}`), {
method: 'DELETE',
credentials: 'include',
headers: {
Accept: 'application/json',
'x-csrf-token': await getCsrfToken(),
},
});
await handleAuthResponse(response, 'Failed to delete inbox item.');
};
// Track last check time to detect new items
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const lastCheckTimestamp = Date.now();
// Store-aware functions
export const loadInboxItemsToStore = async (
isInitialLoad: boolean = false,
requestedCount: number = 20
): Promise<void> => {
const inboxStore = useStore.getState().inboxStore;
// Only show loading for initial load, not for polling
if (isInitialLoad && inboxStore.inboxItems.length === 0) {
inboxStore.setLoading(true);
inboxStore.resetPagination();
}
try {
// Load the requested number of items (for pagination preservation)
const { items, pagination } = await fetchInboxItems(requestedCount, 0);
// Check for new items since last check (only for non-initial loads)
if (!isInitialLoad) {
const currentItemUids = new Set(
inboxStore.inboxItems.map((item) => item.uid).filter(Boolean)
);
// New telegram items
const newTelegramItems = items.filter(
(item) =>
item.uid &&
!currentItemUids.has(item.uid) &&
item.source === 'telegram'
);
// Only show notifications if we have detected changes
if (
inboxStore.inboxItems.length > 0 &&
newTelegramItems.length > 0
) {
// Get some minimal info about the items for the notification
const previewSource =
newTelegramItems[0].title ||
newTelegramItems[0].content ||
'';
const notificationData = {
count: newTelegramItems.length,
firstItemContent:
previewSource.substring(0, 30) +
(previewSource.length > 30 ? '...' : ''),
};
// Dispatch a custom event with the notification data
window.dispatchEvent(
new CustomEvent('inboxItemsUpdated', {
detail: notificationData,
})
);
}
}
// Update state
inboxStore.setInboxItems(items);
inboxStore.setPagination(pagination);
inboxStore.setError(false);
} catch (error) {
console.error('Failed to load inbox items:', error);
inboxStore.setError(true);
} finally {
// Only set loading to false if we were actually loading
if (isInitialLoad) {
inboxStore.setLoading(false);
}
}
};
export const loadMoreInboxItemsToStore = async (): Promise<void> => {
const inboxStore = useStore.getState().inboxStore;
if (!inboxStore.pagination.hasMore || inboxStore.isLoading) {
return;
}
inboxStore.setLoading(true);
try {
const nextOffset =
inboxStore.pagination.offset + inboxStore.pagination.limit;
const { items, pagination } = await fetchInboxItems(20, nextOffset);
// Append new items to existing ones
inboxStore.appendInboxItems(items);
inboxStore.setPagination(pagination);
inboxStore.setError(false);
} catch (error) {
console.error('Failed to load more inbox items:', error);
inboxStore.setError(true);
} finally {
inboxStore.setLoading(false);
}
};
export const createInboxItemWithStore = async (
content: string,
source?: string
): Promise<InboxItem> => {
const inboxStore = useStore.getState().inboxStore;
try {
const newItem = await createInboxItem(content, source);
inboxStore.addInboxItem(newItem);
return newItem;
} catch (error) {
console.error('Failed to create inbox item:', error);
throw error;
}
};
export const updateInboxItemWithStore = async (
itemUid: string,
content: string
): Promise<InboxItem> => {
const inboxStore = useStore.getState().inboxStore;
try {
const updatedItem = await updateInboxItem(itemUid, content);
inboxStore.updateInboxItem(updatedItem);
return updatedItem;
} catch (error) {
console.error('Failed to update inbox item:', error);
throw error;
}
};
export const processInboxItemWithStore = async (
itemUid: string
): Promise<InboxItem> => {
const inboxStore = useStore.getState().inboxStore;
try {
const processedItem = await processInboxItem(itemUid);
inboxStore.removeInboxItemByUid(itemUid);
return processedItem;
} catch (error) {
console.error('Failed to process inbox item:', error);
throw error;
}
};
export const deleteInboxItemWithStore = async (
itemUid: string
): Promise<void> => {
const inboxStore = useStore.getState().inboxStore;
try {
await deleteInboxItem(itemUid);
inboxStore.removeInboxItemByUid(itemUid);
} catch (error) {
console.error('Failed to delete inbox item:', error);
throw error;
}
};