mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-08 17:51:20 +02:00
Fix filewithid use
This commit is contained in:
parent
0cb2161d33
commit
4886ef7ddb
55
frontend/.eslintrc.js
Normal file
55
frontend/.eslintrc.js
Normal 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'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
@ -14,6 +14,9 @@ import "./styles/cookieconsent.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
|
||||||
|
@ -16,12 +16,12 @@ import styles from './FileEditor.module.css';
|
|||||||
import FileEditorThumbnail from './FileEditorThumbnail';
|
import FileEditorThumbnail from './FileEditorThumbnail';
|
||||||
import FilePickerModal from '../shared/FilePickerModal';
|
import FilePickerModal from '../shared/FilePickerModal';
|
||||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||||
import { FileId } from '../../types/file';
|
import { FileId, FileWithId } from '../../types/fileContext';
|
||||||
|
|
||||||
|
|
||||||
interface FileEditorProps {
|
interface FileEditorProps {
|
||||||
onOpenPageEditor?: (file: File) => void;
|
onOpenPageEditor?: (file: FileWithId) => void;
|
||||||
onMergeFiles?: (files: File[]) => void;
|
onMergeFiles?: (files: FileWithId[]) => void;
|
||||||
toolMode?: boolean;
|
toolMode?: boolean;
|
||||||
showUpload?: boolean;
|
showUpload?: boolean;
|
||||||
showBulkActions?: boolean;
|
showBulkActions?: boolean;
|
||||||
@ -421,7 +421,7 @@ const FileEditor = ({
|
|||||||
if (startIndex === -1) return;
|
if (startIndex === -1) return;
|
||||||
|
|
||||||
const recordsToMerge = activeFileRecords.slice(startIndex);
|
const recordsToMerge = activeFileRecords.slice(startIndex);
|
||||||
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
|
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as FileWithId[];
|
||||||
if (onMergeFiles) {
|
if (onMergeFiles) {
|
||||||
onMergeFiles(filesToMerge);
|
onMergeFiles(filesToMerge);
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,7 @@ interface FileCardProps {
|
|||||||
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
||||||
const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
|
const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
|
||||||
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
||||||
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
@ -124,8 +124,12 @@ 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 as FileId /* FIX ME: This doesn't seem right */;
|
if (!item.record?.id) {
|
||||||
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
|
console.error('FileGrid: File missing FileRecord with proper ID:', item.file.name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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
|
||||||
|
@ -22,13 +22,13 @@ import {
|
|||||||
OUTPUT_OPTIONS,
|
OUTPUT_OPTIONS,
|
||||||
FIT_OPTIONS
|
FIT_OPTIONS
|
||||||
} from "../../../constants/convertConstants";
|
} from "../../../constants/convertConstants";
|
||||||
import { FileId } from "../../../types/file";
|
import { FileWithId } from "../../../types/fileContext";
|
||||||
|
|
||||||
interface ConvertSettingsProps {
|
interface ConvertSettingsProps {
|
||||||
parameters: ConvertParameters;
|
parameters: ConvertParameters;
|
||||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||||
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
||||||
selectedFiles: File[];
|
selectedFiles: FileWithId[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,7 +129,7 @@ const ConvertSettings = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filterFilesByExtension = (extension: string) => {
|
const filterFilesByExtension = (extension: string) => {
|
||||||
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
|
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as FileWithId[];
|
||||||
return files.filter(file => {
|
return files.filter(file => {
|
||||||
const fileExtension = detectFileExtension(file.name);
|
const fileExtension = detectFileExtension(file.name);
|
||||||
|
|
||||||
@ -143,21 +143,8 @@ const ConvertSettings = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateFileSelection = (files: File[]) => {
|
const updateFileSelection = (files: FileWithId[]) => {
|
||||||
// Map File objects to their actual IDs in FileContext
|
const fileIds = files.map(file => file.fileId);
|
||||||
const fileIds = files.map(file => {
|
|
||||||
// Find the file ID by matching file properties
|
|
||||||
const fileRecord = state.files.ids
|
|
||||||
.map(id => selectors.getFileRecord(id))
|
|
||||||
.find(record =>
|
|
||||||
record &&
|
|
||||||
record.name === file.name &&
|
|
||||||
record.size === file.size &&
|
|
||||||
record.lastModified === file.lastModified
|
|
||||||
);
|
|
||||||
return fileRecord?.id;
|
|
||||||
}).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings
|
|
||||||
|
|
||||||
setSelectedFiles(fileIds);
|
setSelectedFiles(fileIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,11 +3,12 @@ import { Stack, Text, Select, Alert } from '@mantine/core';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
|
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
|
||||||
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
|
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
|
||||||
|
import { FileWithId } from '../../../types/fileContext';
|
||||||
|
|
||||||
interface ConvertToPdfaSettingsProps {
|
interface ConvertToPdfaSettingsProps {
|
||||||
parameters: ConvertParameters;
|
parameters: ConvertParameters;
|
||||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||||
selectedFiles: File[];
|
selectedFiles: FileWithId[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,9 +6,10 @@ import UploadIcon from '@mui/icons-material/Upload';
|
|||||||
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
|
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
|
||||||
import { useAllFiles } from "../../../contexts/FileContext";
|
import { useAllFiles } from "../../../contexts/FileContext";
|
||||||
import { useFileManager } from "../../../hooks/useFileManager";
|
import { useFileManager } from "../../../hooks/useFileManager";
|
||||||
|
import { FileWithId } from "../../../types/fileContext";
|
||||||
|
|
||||||
export interface FileStatusIndicatorProps {
|
export interface FileStatusIndicatorProps {
|
||||||
selectedFiles?: File[];
|
selectedFiles?: FileWithId[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import FileStatusIndicator from './FileStatusIndicator';
|
import FileStatusIndicator from './FileStatusIndicator';
|
||||||
|
import { FileWithId } from '../../../types/fileContext';
|
||||||
|
|
||||||
export interface FilesToolStepProps {
|
export interface FilesToolStepProps {
|
||||||
selectedFiles: File[];
|
selectedFiles: FileWithId[];
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onCollapsedClick?: () => void;
|
onCollapsedClick?: () => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
@ -4,9 +4,10 @@ import { createToolSteps, ToolStepProvider } from './ToolStep';
|
|||||||
import OperationButton from './OperationButton';
|
import OperationButton from './OperationButton';
|
||||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||||
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
|
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
|
||||||
|
import { FileWithId } from '../../../types/fileContext';
|
||||||
|
|
||||||
export interface FilesStepConfig {
|
export interface FilesStepConfig {
|
||||||
selectedFiles: File[];
|
selectedFiles: FileWithId[];
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onCollapsedClick?: () => void;
|
onCollapsedClick?: () => void;
|
||||||
|
@ -15,6 +15,7 @@ import { fileStorage } from "../../services/fileStorage";
|
|||||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||||
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
|
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
|
||||||
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
||||||
|
import { isFileObject } from "../../types/fileContext";
|
||||||
import { FileId } from "../../types/file";
|
import { FileId } from "../../types/file";
|
||||||
|
|
||||||
|
|
||||||
@ -201,7 +202,7 @@ const Viewer = ({
|
|||||||
const effectiveFile = React.useMemo(() => {
|
const effectiveFile = React.useMemo(() => {
|
||||||
if (previewFile) {
|
if (previewFile) {
|
||||||
// Validate the preview file
|
// Validate the preview file
|
||||||
if (!(previewFile instanceof File)) {
|
if (!isFileObject(previewFile)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,10 +4,11 @@ import { useEndpointEnabled } from '../../useEndpointConfig';
|
|||||||
import { BaseToolProps } from '../../../types/tool';
|
import { BaseToolProps } from '../../../types/tool';
|
||||||
import { ToolOperationHook } from './useToolOperation';
|
import { ToolOperationHook } from './useToolOperation';
|
||||||
import { BaseParametersHook } from './useBaseParameters';
|
import { BaseParametersHook } from './useBaseParameters';
|
||||||
|
import { FileWithId } from '../../../types/fileContext';
|
||||||
|
|
||||||
interface BaseToolReturn<TParams> {
|
interface BaseToolReturn<TParams> {
|
||||||
// File management
|
// File management
|
||||||
selectedFiles: File[];
|
selectedFiles: FileWithId[];
|
||||||
|
|
||||||
// Tool-specific hooks
|
// Tool-specific hooks
|
||||||
params: BaseParametersHook<TParams>;
|
params: BaseParametersHook<TParams>;
|
||||||
|
@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
||||||
import { FileMetadata } from '../types/file';
|
import { FileMetadata } from '../types/file';
|
||||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||||
import { FileId } from '../types/file';
|
import { FileId } from '../types/fileContext';
|
||||||
|
|
||||||
export const useFileManager = () => {
|
export const useFileManager = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { isFileObject } from '../types/fileContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to convert a File object to { file: File; url: string } format
|
* Hook to convert a File object to { file: File; url: string } format
|
||||||
@ -8,8 +9,8 @@ export function useFileWithUrl(file: File | Blob | null): { file: File | Blob; u
|
|||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
|
|
||||||
// Validate that file is a proper File or Blob object
|
// Validate that file is a proper File, FileWithId, or Blob object
|
||||||
if (!(file instanceof File) && !(file instanceof Blob)) {
|
if (!isFileObject(file) && !(file instanceof Blob)) {
|
||||||
console.warn('useFileWithUrl: Expected File or Blob, got:', file);
|
console.warn('useFileWithUrl: Expected File or Blob, got:', file);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { FileMetadata } from "../types/file";
|
import { FileMetadata } from "../types/file";
|
||||||
import { useIndexedDB } from "../contexts/IndexedDBContext";
|
import { useIndexedDB } from "../contexts/IndexedDBContext";
|
||||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||||
|
import { FileId } from "../types/fileContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate optimal scale for thumbnail generation
|
* Calculate optimal scale for thumbnail generation
|
||||||
@ -53,7 +54,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
|||||||
|
|
||||||
// Try to load file from IndexedDB using new context
|
// Try to load file from IndexedDB using new context
|
||||||
if (file.id && indexedDB) {
|
if (file.id && indexedDB) {
|
||||||
const loadedFile = await indexedDB.loadFile(file.id);
|
const loadedFile = await indexedDB.loadFile(file.id as FileId);
|
||||||
if (!loadedFile) {
|
if (!loadedFile) {
|
||||||
throw new Error('File not found in IndexedDB');
|
throw new Error('File not found in IndexedDB');
|
||||||
}
|
}
|
||||||
@ -70,7 +71,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
|||||||
// Save thumbnail to IndexedDB for persistence
|
// Save thumbnail to IndexedDB for persistence
|
||||||
if (file.id && indexedDB && thumbnail) {
|
if (file.id && indexedDB && thumbnail) {
|
||||||
try {
|
try {
|
||||||
await indexedDB.updateThumbnail(file.id, thumbnail);
|
await indexedDB.updateThumbnail(file.id as FileId, thumbnail);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to save thumbnail to IndexedDB:', error);
|
console.warn('Failed to save thumbnail to IndexedDB:', error);
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||||
|
import { FileWithId } from '../types/fileContext';
|
||||||
|
|
||||||
export interface PdfSignatureDetectionResult {
|
export interface PdfSignatureDetectionResult {
|
||||||
hasDigitalSignatures: boolean;
|
hasDigitalSignatures: boolean;
|
||||||
isChecking: boolean;
|
isChecking: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionResult => {
|
export const usePdfSignatureDetection = (files: FileWithId[]): PdfSignatureDetectionResult => {
|
||||||
const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false);
|
const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false);
|
||||||
const [isChecking, setIsChecking] = useState(false);
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
|
||||||
|
@ -14,6 +14,8 @@ import i18n from '../../i18n/config';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { detectFileExtension } from '../../utils/fileUtils';
|
import { detectFileExtension } from '../../utils/fileUtils';
|
||||||
import { FIT_OPTIONS } from '../../constants/convertConstants';
|
import { FIT_OPTIONS } from '../../constants/convertConstants';
|
||||||
|
import { createTestFileWithId, createTestFilesWithId } from '../utils/testFileHelpers';
|
||||||
|
import { FileWithId } from '../../types/fileContext';
|
||||||
|
|
||||||
// Mock axios
|
// Mock axios
|
||||||
vi.mock('axios');
|
vi.mock('axios');
|
||||||
@ -81,7 +83,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create mock DOCX file
|
// Create mock DOCX file
|
||||||
const docxFile = new File(['docx content'], 'document.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
|
const docxFile = createTestFileWithId('document.docx', 'docx content', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||||
|
|
||||||
// Test auto-detection
|
// Test auto-detection
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -117,7 +119,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create mock unknown file
|
// Create mock unknown file
|
||||||
const unknownFile = new File(['unknown content'], 'document.xyz', { type: 'application/octet-stream' });
|
const unknownFile = createTestFileWithId('document.xyz', 'unknown content', 'application/octet-stream');
|
||||||
|
|
||||||
// Test auto-detection
|
// Test auto-detection
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -156,11 +158,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create mock image files
|
// Create mock image files
|
||||||
const imageFiles = [
|
const imageFiles = createTestFilesWithId([
|
||||||
new File(['jpg content'], 'photo1.jpg', { type: 'image/jpeg' }),
|
{ name: 'photo1.jpg', content: 'jpg content', type: 'image/jpeg' },
|
||||||
new File(['png content'], 'photo2.png', { type: 'image/png' }),
|
{ name: 'photo2.png', content: 'png content', type: 'image/png' },
|
||||||
new File(['gif content'], 'photo3.gif', { type: 'image/gif' })
|
{ name: 'photo3.gif', content: 'gif content', type: 'image/gif' }
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Test smart detection for all images
|
// Test smart detection for all images
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -202,11 +204,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create mixed file types
|
// Create mixed file types
|
||||||
const mixedFiles = [
|
const mixedFiles = createTestFilesWithId([
|
||||||
new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }),
|
{ name: 'document.pdf', content: 'pdf content', type: 'application/pdf' },
|
||||||
new File(['docx content'], 'spreadsheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
{ name: 'spreadsheet.xlsx', content: 'docx content', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
|
||||||
new File(['pptx content'], 'presentation.pptx', { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
|
{ name: 'presentation.pptx', content: 'pptx content', type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Test smart detection for mixed types
|
// Test smart detection for mixed types
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -243,10 +245,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create mock web files
|
// Create mock web files
|
||||||
const webFiles = [
|
const webFiles = createTestFilesWithId([
|
||||||
new File(['<html>content</html>'], 'page1.html', { type: 'text/html' }),
|
{ name: 'page1.html', content: '<html>content</html>', type: 'text/html' },
|
||||||
new File(['zip content'], 'site.zip', { type: 'application/zip' })
|
{ name: 'site.zip', content: 'zip content', type: 'application/zip' }
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Test smart detection for web files
|
// Test smart detection for web files
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -288,7 +290,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
});
|
});
|
||||||
|
|
||||||
const htmlFile = new File(['<html>content</html>'], 'page.html', { type: 'text/html' });
|
const htmlFile = createTestFileWithId('page.html', '<html>content</html>', 'text/html');
|
||||||
|
|
||||||
// Set up HTML conversion parameters
|
// Set up HTML conversion parameters
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -318,7 +320,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
});
|
});
|
||||||
|
|
||||||
const emlFile = new File(['email content'], 'email.eml', { type: 'message/rfc822' });
|
const emlFile = createTestFileWithId('email.eml', 'email content', 'message/rfc822');
|
||||||
|
|
||||||
// Set up email conversion parameters
|
// Set up email conversion parameters
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -355,7 +357,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdfFile = new File(['pdf content'], 'document.pdf', { type: 'application/pdf' });
|
const pdfFile = createTestFileWithId('document.pdf', 'pdf content', 'application/pdf');
|
||||||
|
|
||||||
// Set up PDF/A conversion parameters
|
// Set up PDF/A conversion parameters
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -392,10 +394,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageFiles = [
|
const imageFiles = createTestFilesWithId([
|
||||||
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
{ name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
|
||||||
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
|
{ name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Set up image conversion parameters
|
// Set up image conversion parameters
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -432,10 +434,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageFiles = [
|
const imageFiles = createTestFilesWithId([
|
||||||
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
{ name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
|
||||||
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
|
{ name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Set up for separate processing
|
// Set up for separate processing
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -477,10 +479,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
})
|
})
|
||||||
.mockRejectedValueOnce(new Error('File 2 failed'));
|
.mockRejectedValueOnce(new Error('File 2 failed'));
|
||||||
|
|
||||||
const mixedFiles = [
|
const mixedFiles = createTestFilesWithId([
|
||||||
new File(['file1'], 'doc1.txt', { type: 'text/plain' }),
|
{ name: 'doc1.txt', content: 'file1', type: 'text/plain' },
|
||||||
new File(['file2'], 'doc2.xyz', { type: 'application/octet-stream' })
|
{ name: 'doc2.xyz', content: 'file2', type: 'application/octet-stream' }
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Set up for separate processing (mixed smart detection)
|
// Set up for separate processing (mixed smart detection)
|
||||||
act(() => {
|
act(() => {
|
||||||
|
50
frontend/src/types/fileIdSafety.d.ts
vendored
Normal file
50
frontend/src/types/fileIdSafety.d.ts
vendored
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Type safety declarations to prevent file.name/UUID confusion
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FileId, FileWithId, OperationType, FileOperation } 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {};
|
79
frontend/src/utils/fileIdSafety.ts
Normal file
79
frontend/src/utils/fileIdSafety.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Runtime validation utilities for FileId safety
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FileId } from '../types/fileContext';
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime validation for FileId usage in development
|
||||||
|
export function validateFileId(id: string, context: string): void {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
// 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 validation for File vs FileWithId usage
|
||||||
|
export function validateFileWithId(file: File, context: string): void {
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assertion for FileId validation (throws in development)
|
||||||
|
export function assertValidFileId(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}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global debug helpers (can be enabled in dev tools)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__FILE_ID_DEBUG = process.env.NODE_ENV === 'development';
|
||||||
|
window.__validateFileId = validateFileId;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user