Initial file id changes

This commit is contained in:
Reece Browne 2025-09-01 14:25:32 +01:00
parent fe466b3ebf
commit 21483c4ec3
19 changed files with 447 additions and 141 deletions

55
frontend/.eslintrc.js Normal file
View File

@ -0,0 +1,55 @@
module.exports = {
extends: [
'react-app',
'react-app/jest'
],
rules: {
// Custom rules to prevent dangerous file.name as ID patterns
'no-file-name-as-id': 'error',
'prefer-file-with-id': 'warn'
},
overrides: [
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
// Prevent file.name being used where FileId is expected
'no-restricted-syntax': [
'error',
{
selector: 'MemberExpression[object.name="file"][property.name="name"]',
message: 'Avoid using file.name directly. Use FileWithId.fileId or safeGetFileId() instead to prevent ID collisions.'
},
{
selector: 'CallExpression[callee.name="createOperation"] > ArrayExpression > CallExpression[callee.property.name="map"] > ArrowFunctionExpression > MemberExpression[object.name="f"][property.name="name"]',
message: 'Dangerous pattern: Using file.name as ID in createOperation. Use FileWithId.fileId instead.'
},
{
selector: 'ArrayExpression[elements.length>0] CallExpression[callee.property.name="map"] > ArrowFunctionExpression > MemberExpression[property.name="name"]',
message: 'Potential file.name as ID usage detected. Ensure proper FileId usage instead of file.name.'
}
]
}
}
],
settings: {
// Custom settings for our file ID validation
'file-id-validation': {
// Functions that should only accept FileId, not strings
'file-id-only-functions': [
'recordOperation',
'markOperationApplied',
'markOperationFailed',
'removeFiles',
'updateFileRecord',
'pinFile',
'unpinFile'
],
// Functions that should accept FileWithId instead of File
'file-with-id-functions': [
'createOperation',
'executeOperation',
'isFilePinned'
]
}
}
};

View File

@ -13,6 +13,9 @@ import "./styles/tailwind.css";
import "./index.css"; import "./index.css";
import { RightRailProvider } from "./contexts/RightRailContext"; import { RightRailProvider } from "./contexts/RightRailContext";
// Import file ID safety validators (development only)
import "./utils/fileIdSafety";
// Loading component for i18next suspense // Loading component for i18next suspense
const LoadingFallback = () => ( const LoadingFallback = () => (
<div <div

View File

@ -5,9 +5,8 @@ import {
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile'; import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext'; import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
import { useNavigationActions } from '../../contexts/NavigationContext'; import { useNavigationActions } from '../../contexts/NavigationContext';
import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage'; import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import { zipFileService } from '../../services/zipFileService'; import { zipFileService } from '../../services/zipFileService';
@ -53,8 +52,6 @@ const FileEditor = ({
const selectedFileIds = state.ui.selectedFileIds; const selectedFileIds = state.ui.selectedFileIds;
const isProcessing = state.ui.isProcessing; const isProcessing = state.ui.isProcessing;
// Get the real context actions
const { actions } = useFileActions();
const { actions: navActions } = useNavigationActions(); const { actions: navActions } = useNavigationActions();
// Get file selection context // Get file selection context
@ -212,25 +209,6 @@ const FileEditor = ({
// Process all extracted files // Process all extracted files
if (allExtractedFiles.length > 0) { if (allExtractedFiles.length > 0) {
// Record upload operations for PDF files
for (const file of allExtractedFiles) {
const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'upload',
timestamp: Date.now(),
fileIds: [file.name],
status: 'pending',
metadata: {
originalFileName: file.name,
fileSize: file.size,
parameters: {
uploadMethod: 'drag-drop'
}
}
};
}
// Add files to context (they will be processed automatically) // Add files to context (they will be processed automatically)
await addFiles(allExtractedFiles); await addFiles(allExtractedFiles);
setStatus(`Added ${allExtractedFiles.length} files`); setStatus(`Added ${allExtractedFiles.length} files`);

View File

@ -123,8 +123,13 @@ const FileGrid = ({
style={{ overflowY: "auto", width: "100%" }} style={{ overflowY: "auto", width: "100%" }}
> >
{displayFiles.map((item, idx) => { {displayFiles.map((item, idx) => {
const fileId = item.record?.id || item.file.name; // Use record ID if available, otherwise throw error for missing FileRecord
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId); if (!item.record?.id) {
console.error('FileGrid: File missing FileRecord with proper ID:', item.file.name);
return null; // Skip rendering files without proper IDs
}
const fileId = item.record.id;
const originalIdx = files.findIndex(f => f.record?.id === fileId);
const supported = isFileSupported ? isFileSupported(item.file.name) : true; const supported = isFileSupported ? isFileSupported(item.file.name) : true;
return ( return (
<FileCard <FileCard

View File

@ -20,7 +20,9 @@ import {
FileContextActionsValue, FileContextActionsValue,
FileContextActions, FileContextActions,
FileId, FileId,
FileRecord FileRecord,
FileWithId,
createFileWithId
} from '../types/fileContext'; } from '../types/fileContext';
// Import modular components // Import modular components
@ -73,7 +75,7 @@ function FileContextInner({
}, []); }, []);
// File operations using unified addFiles helper with persistence // File operations using unified addFiles helper with persistence
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string }): Promise<File[]> => { const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string }): Promise<FileWithId[]> => {
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager); const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
// Persist to IndexedDB if enabled // Persist to IndexedDB if enabled
@ -87,56 +89,40 @@ function FileContextInner({
})); }));
} }
return addedFilesWithIds.map(({ file }) => file); // Convert to FileWithId objects
return addedFilesWithIds.map(({ file, id }) => createFileWithId(file, id));
}, [indexedDB, enablePersistence]); }, [indexedDB, enablePersistence]);
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<File[]> => { const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<FileWithId[]> => {
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager); const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
return result.map(({ file }) => file); // Convert to FileWithId objects
return result.map(({ file, id }) => createFileWithId(file, id));
}, []); }, []);
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>): Promise<File[]> => { const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>): Promise<FileWithId[]> => {
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager); const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
return result.map(({ file }) => file); // Convert to FileWithId objects
return result.map(({ file, id }) => createFileWithId(file, id));
}, []); }, []);
// Action creators // Action creators
const baseActions = useMemo(() => createFileActions(dispatch), []); const baseActions = useMemo(() => createFileActions(dispatch), []);
// Helper functions for pinned files // Helper functions for pinned files
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<void> => { const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileWithId[]> => {
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch); const result = await consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch);
// Convert results to FileWithId objects
return result.map(({ file, id }) => createFileWithId(file, id));
}, []); }, []);
// Helper to find FileId from File object // File pinning functions - now use FileWithId directly
const findFileId = useCallback((file: File): FileId | undefined => { const pinFileWrapper = useCallback((file: FileWithId) => {
return Object.keys(stateRef.current.files.byId).find(id => { baseActions.pinFile(file.fileId);
const storedFile = filesRef.current.get(id); }, [baseActions]);
return storedFile &&
storedFile.name === file.name &&
storedFile.size === file.size &&
storedFile.lastModified === file.lastModified;
});
}, []);
// File-to-ID wrapper functions for pinning const unpinFileWrapper = useCallback((file: FileWithId) => {
const pinFileWrapper = useCallback((file: File) => { baseActions.unpinFile(file.fileId);
const fileId = findFileId(file); }, [baseActions]);
if (fileId) {
baseActions.pinFile(fileId);
} else {
console.warn('File not found for pinning:', file.name);
}
}, [baseActions, findFileId]);
const unpinFileWrapper = useCallback((file: File) => {
const fileId = findFileId(file);
if (fileId) {
baseActions.unpinFile(fileId);
} else {
console.warn('File not found for unpinning:', file.name);
}
}, [baseActions, findFileId]);
// Complete actions object // Complete actions object
const actions = useMemo<FileContextActions>(() => ({ const actions = useMemo<FileContextActions>(() => ({

View File

@ -326,11 +326,11 @@ export async function consumeFiles(
stateRef: React.MutableRefObject<FileContextState>, stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>, filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction> dispatch: React.Dispatch<FileContextAction>
): Promise<void> { ): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`); if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
// Process output files through the 'processed' path to generate thumbnails // Process output files through the 'processed' path to generate thumbnails
const outputFileRecords = await Promise.all( const processedOutputs: Array<{ file: File; id: FileId; thumbnail?: string; record: FileRecord }> = await Promise.all(
outputFiles.map(async (file) => { outputFiles.map(async (file) => {
const fileId = createFileId(); const fileId = createFileId();
filesRef.current.set(fileId, file); filesRef.current.set(fileId, file);
@ -357,10 +357,13 @@ export async function consumeFiles(
record.processedFile = createProcessedFile(pageCount, thumbnail); record.processedFile = createProcessedFile(pageCount, thumbnail);
} }
return record; return { file, id: fileId, thumbnail, record };
}) })
); );
// Extract records for dispatch
const outputFileRecords = processedOutputs.map(({ record }) => record);
// Dispatch the consume action // Dispatch the consume action
dispatch({ dispatch({
type: 'CONSUME_FILES', type: 'CONSUME_FILES',
@ -371,6 +374,9 @@ export async function consumeFiles(
}); });
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`); if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
// Return file data for FileWithId conversion
return processedOutputs.map(({ file, id, thumbnail }) => ({ file, id, thumbnail }));
} }
/** /**

View File

@ -168,16 +168,7 @@ export function useFileContext() {
markOperationApplied: (fileId: string, operationId: string) => {}, // Operation tracking not implemented markOperationApplied: (fileId: string, operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (fileId: string, operationId: string, error: string) => {}, // Operation tracking not implemented markOperationFailed: (fileId: string, operationId: string, error: string) => {}, // Operation tracking not implemented
// File ID lookup // File ID lookup removed - use FileWithId.fileId directly for better performance and type safety
findFileId: (file: File) => {
return state.files.ids.find(id => {
const record = state.files.byId[id];
return record &&
record.name === file.name &&
record.size === file.size &&
record.lastModified === file.lastModified;
});
},
// Pinned files // Pinned files
pinnedFiles: state.pinnedFiles, pinnedFiles: state.pinnedFiles,

View File

@ -6,7 +6,9 @@ import {
FileId, FileId,
FileRecord, FileRecord,
FileContextState, FileContextState,
FileContextSelectors FileContextSelectors,
FileWithId,
createFileWithId
} from '../../types/fileContext'; } from '../../types/fileContext';
/** /**
@ -17,11 +19,19 @@ export function createFileSelectors(
filesRef: React.MutableRefObject<Map<FileId, File>> filesRef: React.MutableRefObject<Map<FileId, File>>
): FileContextSelectors { ): FileContextSelectors {
return { return {
getFile: (id: FileId) => filesRef.current.get(id), getFile: (id: FileId) => {
const file = filesRef.current.get(id);
return file ? createFileWithId(file, id) : undefined;
},
getFiles: (ids?: FileId[]) => { getFiles: (ids?: FileId[]) => {
const currentIds = ids || stateRef.current.files.ids; const currentIds = ids || stateRef.current.files.ids;
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[]; return currentIds
.map(id => {
const file = filesRef.current.get(id);
return file ? createFileWithId(file, id) : undefined;
})
.filter(Boolean) as FileWithId[];
}, },
getFileRecord: (id: FileId) => stateRef.current.files.byId[id], getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
@ -35,8 +45,11 @@ export function createFileSelectors(
getSelectedFiles: () => { getSelectedFiles: () => {
return stateRef.current.ui.selectedFileIds return stateRef.current.ui.selectedFileIds
.map(id => filesRef.current.get(id)) .map(id => {
.filter(Boolean) as File[]; const file = filesRef.current.get(id);
return file ? createFileWithId(file, id) : undefined;
})
.filter(Boolean) as FileWithId[];
}, },
getSelectedFileRecords: () => { getSelectedFileRecords: () => {
@ -52,8 +65,11 @@ export function createFileSelectors(
getPinnedFiles: () => { getPinnedFiles: () => {
return Array.from(stateRef.current.pinnedFiles) return Array.from(stateRef.current.pinnedFiles)
.map(id => filesRef.current.get(id)) .map(id => {
.filter(Boolean) as File[]; const file = filesRef.current.get(id);
return file ? createFileWithId(file, id) : undefined;
})
.filter(Boolean) as FileWithId[];
}, },
getPinnedFileRecords: () => { getPinnedFileRecords: () => {
@ -62,16 +78,8 @@ export function createFileSelectors(
.filter(Boolean); .filter(Boolean);
}, },
isFilePinned: (file: File) => { isFilePinned: (file: FileWithId) => {
// Find FileId by matching File object properties return stateRef.current.pinnedFiles.has(file.fileId);
const fileId = Object.keys(stateRef.current.files.byId).find(id => {
const storedFile = filesRef.current.get(id);
return storedFile &&
storedFile.name === file.name &&
storedFile.size === file.size &&
storedFile.lastModified === file.lastModified;
});
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
}, },
// Stable signature for effects - prevents unnecessary re-renders // Stable signature for effects - prevents unnecessary re-renders
@ -119,12 +127,15 @@ export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; siz
export function getPrimaryFile( export function getPrimaryFile(
stateRef: React.MutableRefObject<FileContextState>, stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>> filesRef: React.MutableRefObject<Map<FileId, File>>
): { file?: File; record?: FileRecord } { ): { file?: FileWithId; record?: FileRecord } {
const primaryFileId = stateRef.current.files.ids[0]; const primaryFileId = stateRef.current.files.ids[0];
if (!primaryFileId) return {}; if (!primaryFileId) return {};
const file = filesRef.current.get(primaryFileId);
const record = stateRef.current.files.byId[primaryFileId];
return { return {
file: filesRef.current.get(primaryFileId), file: file ? createFileWithId(file, primaryFileId) : undefined,
record: stateRef.current.files.byId[primaryFileId] record
}; };
} }

View File

@ -7,6 +7,7 @@ import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
import { useToolResources } from './useToolResources'; import { useToolResources } from './useToolResources';
import { extractErrorMessage } from '../../../utils/toolErrorHandler'; import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import { createOperation } from '../../../utils/toolOperationTracker'; import { createOperation } from '../../../utils/toolOperationTracker';
import { FileWithId, extractFiles } from '../../../types/fileContext';
import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { ResponseHandler } from '../../../utils/toolResponseProcessor';
// Re-export for backwards compatibility // Re-export for backwards compatibility
@ -82,7 +83,7 @@ export interface ToolOperationHook<TParams = void> {
progress: ProcessingProgress | null; progress: ProcessingProgress | null;
// Actions // Actions
executeOperation: (params: TParams, selectedFiles: File[]) => Promise<void>; executeOperation: (params: TParams, selectedFiles: FileWithId[]) => Promise<void>;
resetResults: () => void; resetResults: () => void;
clearError: () => void; clearError: () => void;
cancelOperation: () => void; cancelOperation: () => void;
@ -107,7 +108,7 @@ export const useToolOperation = <TParams = void>(
config: ToolOperationConfig<TParams> config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => { ): ToolOperationHook<TParams> => {
const { t } = useTranslation(); const { t } = useTranslation();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId } = useFileContext(); const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles } = useFileContext();
// Composed hooks // Composed hooks
const { state, actions } = useToolState(); const { state, actions } = useToolState();
@ -116,7 +117,7 @@ export const useToolOperation = <TParams = void>(
const executeOperation = useCallback(async ( const executeOperation = useCallback(async (
params: TParams, params: TParams,
selectedFiles: File[] selectedFiles: FileWithId[]
): Promise<void> => { ): Promise<void> => {
// Validation // Validation
if (selectedFiles.length === 0) { if (selectedFiles.length === 0) {
@ -130,7 +131,7 @@ export const useToolOperation = <TParams = void>(
return; return;
} }
// Setup operation tracking // Setup operation tracking with proper FileWithId
const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles); const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles);
recordOperation(fileId, operation); recordOperation(fileId, operation);
@ -143,15 +144,18 @@ export const useToolOperation = <TParams = void>(
try { try {
let processedFiles: File[]; let processedFiles: File[];
// Convert FileWithId to regular File objects for API processing
const validRegularFiles = extractFiles(validFiles);
if (config.customProcessor) { if (config.customProcessor) {
actions.setStatus('Processing files...'); actions.setStatus('Processing files...');
processedFiles = await config.customProcessor(params, validFiles); processedFiles = await config.customProcessor(params, validRegularFiles);
} else { } else {
// Use explicit multiFileEndpoint flag to determine processing approach // Use explicit multiFileEndpoint flag to determine processing approach
if (config.multiFileEndpoint) { if (config.multiFileEndpoint) {
// Multi-file processing - single API call with all files // Multi-file processing - single API call with all files
actions.setStatus('Processing files...'); actions.setStatus('Processing files...');
const formData = (config.buildFormData as (params: TParams, files: File[]) => FormData)(params, validFiles); const formData = (config.buildFormData as (params: TParams, files: File[]) => FormData)(params, validRegularFiles);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint; const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
const response = await axios.post(endpoint, formData, { responseType: 'blob' }); const response = await axios.post(endpoint, formData, { responseType: 'blob' });
@ -159,11 +163,11 @@ export const useToolOperation = <TParams = void>(
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs // Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
if (config.responseHandler) { if (config.responseHandler) {
// Use custom responseHandler for multi-file (handles ZIP extraction) // Use custom responseHandler for multi-file (handles ZIP extraction)
processedFiles = await config.responseHandler(response.data, validFiles); processedFiles = await config.responseHandler(response.data, validRegularFiles);
} else if (response.data.type === 'application/pdf' || } else if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) { (response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - use original filename // Single PDF response (e.g. split with merge option) - use original filename
const originalFileName = validFiles[0]?.name || 'document.pdf'; const originalFileName = validRegularFiles[0]?.name || 'document.pdf';
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' }); const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
processedFiles = [singleFile]; processedFiles = [singleFile];
} else { } else {
@ -185,7 +189,7 @@ export const useToolOperation = <TParams = void>(
}; };
processedFiles = await processFiles( processedFiles = await processFiles(
params, params,
validFiles, validRegularFiles,
apiCallsConfig, apiCallsConfig,
actions.setProgress, actions.setProgress,
actions.setStatus actions.setStatus
@ -208,7 +212,7 @@ export const useToolOperation = <TParams = void>(
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename); actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
// Replace input files with processed files (consumeFiles handles pinning) // Replace input files with processed files (consumeFiles handles pinning)
const inputFileIds = validFiles.map(file => findFileId(file)).filter(Boolean) as string[]; const inputFileIds = validFiles.map(file => file.fileId);
await consumeFiles(inputFileIds, processedFiles); await consumeFiles(inputFileIds, processedFiles);
markOperationApplied(fileId, operationId); markOperationApplied(fileId, operationId);
@ -223,7 +227,7 @@ export const useToolOperation = <TParams = void>(
actions.setLoading(false); actions.setLoading(false);
actions.setProgress(null); actions.setProgress(null);
} }
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]); }, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
const cancelOperation = useCallback(() => { const cancelOperation = useCallback(() => {
cancelApiCalls(); cancelApiCalls();

View File

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { PDFDocument, PDFPage } from '../types/pageEditor'; import { PDFDocument, PDFPage } from '../types/pageEditor';
import { pdfWorkerManager } from '../services/pdfWorkerManager'; import { pdfWorkerManager } from '../services/pdfWorkerManager';
import { createQuickKey } from '../types/fileContext';
export function usePDFProcessor() { export function usePDFProcessor() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -75,7 +76,7 @@ export function usePDFProcessor() {
// Create pages without thumbnails initially - load them lazily // Create pages without thumbnails initially - load them lazily
for (let i = 1; i <= totalPages; i++) { for (let i = 1; i <= totalPages; i++) {
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
originalPageNumber: i, originalPageNumber: i,
thumbnail: null, // Will be loaded lazily thumbnail: null, // Will be loaded lazily

View File

@ -1,5 +1,6 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
import { createQuickKey } from '../types/fileContext';
// Request queue to handle concurrent thumbnail requests // Request queue to handle concurrent thumbnail requests
interface ThumbnailRequest { interface ThumbnailRequest {
@ -70,8 +71,8 @@ async function processRequestQueue() {
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`); console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
// Use file name as fileId for PDF document caching // Use quickKey for PDF document caching (same metadata, consistent format)
const fileId = file.name + '_' + file.size + '_' + file.lastModified; const fileId = createQuickKey(file);
const results = await thumbnailGenerationService.generateThumbnails( const results = await thumbnailGenerationService.generateThumbnails(
fileId, fileId,

View File

@ -5,6 +5,7 @@ import { FileHasher } from '../utils/fileHash';
import { FileAnalyzer } from './fileAnalyzer'; import { FileAnalyzer } from './fileAnalyzer';
import { ProcessingErrorHandler } from './processingErrorHandler'; import { ProcessingErrorHandler } from './processingErrorHandler';
import { pdfWorkerManager } from './pdfWorkerManager'; import { pdfWorkerManager } from './pdfWorkerManager';
import { createQuickKey } from '../types/fileContext';
export class EnhancedPDFProcessingService { export class EnhancedPDFProcessingService {
private static instance: EnhancedPDFProcessingService; private static instance: EnhancedPDFProcessingService;
@ -201,7 +202,7 @@ export class EnhancedPDFProcessingService {
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality); const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
thumbnail, thumbnail,
rotation: 0, rotation: 0,
@ -251,7 +252,7 @@ export class EnhancedPDFProcessingService {
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality); const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
thumbnail, thumbnail,
rotation: 0, rotation: 0,
@ -266,7 +267,7 @@ export class EnhancedPDFProcessingService {
// Create placeholder pages for remaining pages // Create placeholder pages for remaining pages
for (let i = priorityCount + 1; i <= totalPages; i++) { for (let i = priorityCount + 1; i <= totalPages; i++) {
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
thumbnail: null, // Will be loaded lazily thumbnail: null, // Will be loaded lazily
rotation: 0, rotation: 0,
@ -313,7 +314,7 @@ export class EnhancedPDFProcessingService {
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality); const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
thumbnail, thumbnail,
rotation: 0, rotation: 0,
@ -334,7 +335,7 @@ export class EnhancedPDFProcessingService {
// Create placeholders for remaining pages // Create placeholders for remaining pages
for (let i = firstChunkEnd + 1; i <= totalPages; i++) { for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
thumbnail: null, thumbnail: null,
rotation: 0, rotation: 0,
@ -368,7 +369,7 @@ export class EnhancedPDFProcessingService {
const pages: PDFPage[] = []; const pages: PDFPage[] = [];
for (let i = 1; i <= totalPages; i++) { for (let i = 1; i <= totalPages; i++) {
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
thumbnail: null, thumbnail: null,
rotation: 0, rotation: 0,

View File

@ -1,6 +1,7 @@
import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing'; import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing';
import { ProcessingCache } from './processingCache'; import { ProcessingCache } from './processingCache';
import { pdfWorkerManager } from './pdfWorkerManager'; import { pdfWorkerManager } from './pdfWorkerManager';
import { createQuickKey } from '../types/fileContext';
export class PDFProcessingService { export class PDFProcessingService {
private static instance: PDFProcessingService; private static instance: PDFProcessingService;
@ -113,7 +114,7 @@ export class PDFProcessingService {
const thumbnail = canvas.toDataURL(); const thumbnail = canvas.toDataURL();
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
thumbnail, thumbnail,
rotation: 0, rotation: 0,

View File

@ -29,7 +29,7 @@ vi.mock('../../services/fileStorage', () => ({
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
storeFile: vi.fn().mockImplementation((file, thumbnail) => { storeFile: vi.fn().mockImplementation((file, thumbnail) => {
return Promise.resolve({ return Promise.resolve({
id: `mock-id-${file.name}`, id: `mock-uuid-${Math.random().toString(36).substring(2)}`,
name: file.name, name: file.name,
size: file.size, size: file.size,
type: file.type, type: file.type,

View File

@ -25,7 +25,7 @@ vi.mock('../../services/fileStorage', () => ({
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
storeFile: vi.fn().mockImplementation((file, thumbnail) => { storeFile: vi.fn().mockImplementation((file, thumbnail) => {
return Promise.resolve({ return Promise.resolve({
id: `mock-id-${file.name}`, id: `mock-uuid-${Math.random().toString(36).substring(2)}`,
name: file.name, name: file.name,
size: file.size, size: file.size,
type: file.type, type: file.type,

View File

@ -25,8 +25,8 @@ export type ModeType =
| 'unlockPdfForms' | 'unlockPdfForms'
| 'removeCertificateSign'; | 'removeCertificateSign';
// Normalized state types // Normalized state types - Branded type to prevent string/FileId confusion
export type FileId = string; export type FileId = string & { readonly __brand: 'FileId' };
export interface ProcessedFilePage { export interface ProcessedFilePage {
thumbnail?: string; thumbnail?: string;
@ -85,6 +85,127 @@ export function createQuickKey(file: File): string {
return `${file.name}|${file.size}|${file.lastModified}`; return `${file.name}|${file.size}|${file.lastModified}`;
} }
// File with embedded UUID - replaces loose File + FileId parameter passing
export interface FileWithId extends File {
readonly fileId: FileId;
readonly quickKey: string; // Fast deduplication key: name|size|lastModified
}
// Type guard to check if a File object has an embedded fileId
export function isFileWithId(file: File): file is FileWithId {
return 'fileId' in file && typeof (file as any).fileId === 'string' &&
'quickKey' in file && typeof (file as any).quickKey === 'string';
}
// Create a FileWithId from a regular File object
export function createFileWithId(file: File, id?: FileId): FileWithId {
const fileId = id || createFileId();
const quickKey = createQuickKey(file);
// Create new File-like object with embedded fileId and quickKey
const fileWithId = Object.create(file);
Object.defineProperty(fileWithId, 'fileId', {
value: fileId,
writable: false,
enumerable: true,
configurable: false
});
Object.defineProperty(fileWithId, 'quickKey', {
value: quickKey,
writable: false,
enumerable: true,
configurable: false
});
return fileWithId as FileWithId;
}
// Wrap array of Files with FileIds
export function wrapFilesWithIds(files: File[], ids?: FileId[]): FileWithId[] {
return files.map((file, index) =>
createFileWithId(file, ids?.[index])
);
}
// Extract FileIds from FileWithId array
export function extractFileIds(files: FileWithId[]): FileId[] {
return files.map(file => file.fileId);
}
// Extract regular File objects from FileWithId array
export function extractFiles(files: FileWithId[]): File[] {
return files.map(file => {
// Create clean File object without the fileId property
return new File([file], file.name, {
type: file.type,
lastModified: file.lastModified
});
});
}
// Type guards and validation functions
// Validate that a string is a proper FileId (has UUID format)
export function isValidFileId(id: string): id is FileId {
// Check UUID v4 format: 8-4-4-4-12 hex digits
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(id);
}
// Runtime assertion for FileId validation
export function assertValidFileId(id: string): asserts id is FileId {
if (!isValidFileId(id)) {
throw new Error(`Invalid FileId format: "${id}". Expected UUID format.`);
}
}
// Detect potentially dangerous file.name usage as ID
export function isDangerousFileNameAsId(fileName: string, context: string = ''): boolean {
// Check if it's definitely a UUID (safe)
if (isValidFileId(fileName)) {
return false;
}
// Check if it's a quickKey (safe) - format: name|size|lastModified
if (/^.+\|\d+\|\d+$/.test(fileName)) {
return false; // quickKeys are legitimate, not dangerous
}
// Common patterns that suggest file.name is being used as ID
const dangerousPatterns = [
/^[^-]+-page-\d+$/, // pattern: filename-page-123
/\.(pdf|jpg|png|doc|docx)$/i, // ends with file extension
/\s/, // contains whitespace (filenames often have spaces)
/[()[\]{}]/, // contains brackets/parentheses common in filenames
/['"]/, // contains quotes
/[^a-zA-Z0-9\-._]/ // contains special characters not in UUIDs
];
// Check dangerous patterns
const isDangerous = dangerousPatterns.some(pattern => pattern.test(fileName));
if (isDangerous && context) {
console.warn(`⚠️ Potentially dangerous file.name usage detected in ${context}: "${fileName}"`);
}
return isDangerous;
}
// Safe file ID getter that throws if file.name is used as ID
export function safeGetFileId(file: File, context: string = ''): FileId {
if (isFileWithId(file)) {
return file.fileId;
}
// If we reach here, someone is trying to use a regular File without embedded ID
throw new Error(`Attempted to get FileId from regular File object in ${context}. Use FileWithId instead.`);
}
// Prevent accidental file.name usage as FileId
export function preventFileNameAsId(value: string, context: string = ''): never {
throw new Error(`Blocked attempt to use string "${value}" as FileId in ${context}. Use proper FileId from createFileId() or FileWithId.fileId instead.`);
}
export function toFileRecord(file: File, id?: FileId): FileRecord { export function toFileRecord(file: File, id?: FileId): FileRecord {
@ -217,21 +338,21 @@ export type FileContextAction =
export interface FileContextActions { export interface FileContextActions {
// File management - lightweight actions only // File management - lightweight actions only
addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise<File[]>; addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise<FileWithId[]>;
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>; addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<FileWithId[]>;
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<File[]>; addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<FileWithId[]>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void; updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
reorderFiles: (orderedFileIds: FileId[]) => void; reorderFiles: (orderedFileIds: FileId[]) => void;
clearAllFiles: () => Promise<void>; clearAllFiles: () => Promise<void>;
clearAllData: () => Promise<void>; clearAllData: () => Promise<void>;
// File pinning // File pinning - now accepts FileWithId for safer type checking
pinFile: (file: File) => void; pinFile: (file: FileWithId) => void;
unpinFile: (file: File) => void; unpinFile: (file: FileWithId) => void;
// File consumption (replace unpinned files with outputs) // File consumption (replace unpinned files with outputs) - now returns FileWithId
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<void>; consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileWithId[]>;
// Selection management // Selection management
setSelectedFiles: (fileIds: FileId[]) => void; setSelectedFiles: (fileIds: FileId[]) => void;
setSelectedPages: (pageNumbers: number[]) => void; setSelectedPages: (pageNumbers: number[]) => void;
@ -254,24 +375,24 @@ export interface FileContextActions {
// File selectors (separate from actions to avoid re-renders) // File selectors (separate from actions to avoid re-renders)
export interface FileContextSelectors { export interface FileContextSelectors {
// File access - no state dependency, uses ref // File access - now returns FileWithId for safer type checking
getFile: (id: FileId) => File | undefined; getFile: (id: FileId) => FileWithId | undefined;
getFiles: (ids?: FileId[]) => File[]; getFiles: (ids?: FileId[]) => FileWithId[];
// Record access - uses normalized state // Record access - uses normalized state
getFileRecord: (id: FileId) => FileRecord | undefined; getFileRecord: (id: FileId) => FileRecord | undefined;
getFileRecords: (ids?: FileId[]) => FileRecord[]; getFileRecords: (ids?: FileId[]) => FileRecord[];
// Derived selectors // Derived selectors - now return FileWithId
getAllFileIds: () => FileId[]; getAllFileIds: () => FileId[];
getSelectedFiles: () => File[]; getSelectedFiles: () => FileWithId[];
getSelectedFileRecords: () => FileRecord[]; getSelectedFileRecords: () => FileRecord[];
// Pinned files selectors // Pinned files selectors - now return FileWithId
getPinnedFileIds: () => FileId[]; getPinnedFileIds: () => FileId[];
getPinnedFiles: () => File[]; getPinnedFiles: () => FileWithId[];
getPinnedFileRecords: () => FileRecord[]; getPinnedFileRecords: () => FileRecord[];
isFilePinned: (file: File) => boolean; isFilePinned: (file: FileWithId) => boolean;
// Stable signature for effect dependencies // Stable signature for effect dependencies
getFilesSignature: () => string; getFilesSignature: () => string;

59
frontend/src/types/fileIdSafety.d.ts vendored Normal file
View File

@ -0,0 +1,59 @@
/**
* Type safety declarations to prevent file.name/UUID confusion
*/
import { FileId, FileWithId } from './fileContext';
declare global {
namespace FileIdSafety {
// Mark functions that should never accept file.name as parameters
type SafeFileIdFunction<T extends (...args: any[]) => any> = T extends (...args: infer P) => infer R
? P extends readonly [string, ...any[]]
? never // Reject string parameters in first position for FileId functions
: T
: T;
// Mark functions that should only accept FileWithId, not regular File
type FileWithIdOnlyFunction<T extends (...args: any[]) => any> = T extends (...args: infer P) => infer R
? P extends readonly [File, ...any[]]
? never // Reject File parameters in first position for FileWithId functions
: T
: T;
// Utility type to enforce FileWithId usage
type RequireFileWithId<T> = T extends File ? FileWithId : T;
}
// Extend Window interface to add runtime validation helpers
interface Window {
__FILE_ID_DEBUG?: boolean;
__validateFileId?: (id: string, context: string) => void;
}
}
// Module augmentation for stricter type checking on dangerous functions
declare module '../utils/toolOperationTracker' {
export const createOperation: <TParams = void>(
operationType: string,
params: TParams,
selectedFiles: FileWithId[] // Must be FileWithId, not File[]
) => { operation: FileOperation; operationId: string; fileId: string };
}
// Augment FileContext types to prevent bypassing FileWithId
declare module '../contexts/FileContext' {
export interface StrictFileContextActions {
pinFile: (file: FileWithId) => void; // Must be FileWithId
unpinFile: (file: FileWithId) => void; // Must be FileWithId
addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise<FileWithId[]>; // Returns FileWithId
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileWithId[]>; // Returns FileWithId
}
export interface StrictFileContextSelectors {
getFile: (id: FileId) => FileWithId | undefined; // Returns FileWithId
getFiles: (ids?: FileId[]) => FileWithId[]; // Returns FileWithId[]
isFilePinned: (file: FileWithId) => boolean; // Must be FileWithId
}
}
export {};

View File

@ -0,0 +1,80 @@
/**
* Runtime validation helpers for file ID safety
*/
import { isValidFileId, isDangerousFileNameAsId } from '../types/fileContext';
// Enable debug mode in development
const DEBUG_FILE_ID = process.env.NODE_ENV === 'development';
/**
* Runtime validation for FileId usage
*/
export function validateFileIdUsage(id: string, context: string = ''): void {
if (!DEBUG_FILE_ID) return;
// Check if it's a valid UUID
if (!isValidFileId(id)) {
console.error(`🚨 Invalid FileId detected in ${context}: "${id}". Expected UUID format.`);
// Check if it looks like a dangerous file.name usage
if (isDangerousFileNameAsId(id, context)) {
console.error(`💀 DANGEROUS: file.name used as FileId in ${context}! This will cause ID collisions.`);
console.trace('Stack trace:');
}
}
}
/**
* Runtime check for File vs FileWithId usage
*/
export function validateFileWithIdUsage(file: File, context: string = ''): void {
if (!DEBUG_FILE_ID) return;
// Check if file has embedded fileId
if (!('fileId' in file)) {
console.warn(`⚠️ Regular File object used where FileWithId expected in ${context}: "${file.name}"`);
console.warn('Consider using FileWithId for better type safety');
}
}
/**
* Development-only assertion that fails on dangerous patterns
*/
export function assertSafeFileIdUsage(id: string, context: string = ''): void {
if (process.env.NODE_ENV === 'development') {
if (isDangerousFileNameAsId(id, context)) {
throw new Error(`ASSERTION FAILED: Dangerous file.name as FileId detected in ${context}: "${id}"`);
}
}
}
/**
* Install global runtime validators (development only)
*/
export function installFileIdSafetyValidators(): void {
if (process.env.NODE_ENV !== 'development') return;
// Add to window for debugging
window.__FILE_ID_DEBUG = true;
window.__validateFileId = validateFileIdUsage;
// Monkey patch console.warn to highlight file ID issues
const originalWarn = console.warn;
console.warn = (...args: any[]) => {
const message = args.join(' ');
if (message.includes('file.name') && message.includes('ID')) {
console.error('🚨 FILE ID SAFETY WARNING:', ...args);
console.trace('Location:');
} else {
originalWarn.apply(console, args);
}
};
console.log('🛡️ File ID safety validators installed (development mode)');
}
// Auto-install in development
if (process.env.NODE_ENV === 'development') {
installFileIdSafetyValidators();
}

View File

@ -1,4 +1,4 @@
import { FileOperation } from '../types/fileContext'; import { FileOperation, FileWithId, safeGetFileId, FileId } from '../types/fileContext';
/** /**
* Creates operation tracking data for FileContext integration * Creates operation tracking data for FileContext integration
@ -6,23 +6,26 @@ import { FileOperation } from '../types/fileContext';
export const createOperation = <TParams = void>( export const createOperation = <TParams = void>(
operationType: string, operationType: string,
params: TParams, params: TParams,
selectedFiles: File[] selectedFiles: FileWithId[]
): { operation: FileOperation; operationId: string; fileId: string } => { ): { operation: FileOperation; operationId: string; fileId: string } => {
const operationId = `${operationType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const operationId = `${operationType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const fileId = selectedFiles.map(f => f.name).join(',');
// Use proper FileIds instead of file.name - fixed dangerous pattern
const fileIds = selectedFiles.map(file => file.fileId);
const fileId = fileIds.join(',');
const operation: FileOperation = { const operation: FileOperation = {
id: operationId, id: operationId,
type: operationType, type: operationType,
timestamp: Date.now(), timestamp: Date.now(),
fileIds: selectedFiles.map(f => f.name), fileIds, // Now properly uses FileId[] instead of file.name[]
status: 'pending', status: 'pending',
metadata: { metadata: {
originalFileName: selectedFiles[0]?.name, originalFileName: selectedFiles[0]?.name,
parameters: params, parameters: params,
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0) fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
} }
} as any /* FIX ME*/; };
return { operation, operationId, fileId }; return { operation, operationId, fileId };
}; };