file-upload
Complete guide for implementing file uploads in IntelliFill with React-dropzone frontend, Multer backend, file validation, Bull queue processing, and security best practices
Installation
Suivez les instructions du dépôt pour votre agent. Consultez les fichiers et dépendances avant l’installation.
Voir la source ↗GuidesInstructions
File Upload Skill - IntelliFill
This skill provides comprehensive patterns for implementing secure file uploads in the IntelliFill project, covering frontend drag-and-drop, backend processing, validation, queue management, and security.
Table of Contents
- Frontend: React-Dropzone Integration
- Backend: Multer Configuration
- File Validation & Security
- Upload Progress Tracking
- Bull Queue Integration
- Error Handling Patterns
- Complete Implementation Examples
- Testing Strategies
Frontend: React-Dropzone Integration
Base File Upload Component
Location: quikadmin-web/src/components/features/file-upload-zone.tsx
The project uses a reusable FileUploadZone component built on react-dropzone:
import { useDropzone, type FileRejection, type DropzoneOptions } from "react-dropzone"
import { Upload, File, X, AlertCircle, CheckCircle2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
interface FileUploadZoneProps {
onFilesAccepted: (files: File[]) => void
onFilesRejected?: (rejections: FileRejection[]) => void
accept?: DropzoneOptions["accept"]
maxSize?: number
maxFiles?: number
multiple?: boolean
disabled?: boolean
showFileList?: boolean
uploadProgress?: number
children?: React.ReactNode
}
export function FileUploadZone({
onFilesAccepted,
onFilesRejected,
accept,
maxSize = 10 * 1024 * 1024, // 10MB default
maxFiles = 1,
multiple = false,
disabled = false,
showFileList = true,
uploadProgress,
children,
}: FileUploadZoneProps) {
const [acceptedFiles, setAcceptedFiles] = React.useState<File[]>([])
const [rejectedFiles, setRejectedFiles] = React.useState<FileRejection[]>([])
const onDrop = React.useCallback(
(accepted: File[], rejected: FileRejection[]) => {
setAcceptedFiles(accepted)
setRejectedFiles(rejected)
if (accepted.length > 0) {
onFilesAccepted(accepted)
}
if (rejected.length > 0 && onFilesRejected) {
onFilesRejected(rejected)
}
},
[onFilesAccepted, onFilesRejected]
)
const {
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
} = useDropzone({
onDrop,
accept,
maxSize,
maxFiles,
multiple,
disabled,
})
return (
<div data-slot="file-upload-zone" className="space-y-4">
{/* Drop Zone */}
<div
{...getRootProps()}
className={cn(
"relative flex flex-col items-center justify-center gap-4 rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer",
"hover:border-primary hover:bg-accent/50",
isDragActive && "border-primary bg-accent/50",
isDragAccept && "border-green-500 bg-green-50",
isDragReject && "border-red-500 bg-red-50",
disabled && "opacity-50 cursor-not-allowed pointer-events-none"
)}
>
<input {...getInputProps()} />
<Upload className="h-8 w-8 text-primary" />
<p className="text-sm font-medium">
{isDragActive ? "Drop files here" : "Drag and drop files here, or click to browse"}
</p>
</div>
{/* Upload Progress */}
{uploadProgress !== undefined && uploadProgress > 0 && uploadProgress < 100 && (
<Progress value={uploadProgress} showPercentage label="Uploading..." />
)}
{/* Accepted Files List */}
{showFileList && acceptedFiles.length > 0 && (
<div className="space-y-2">
{acceptedFiles.map((file, index) => (
<div key={index} className="flex items-center gap-3 rounded-lg border p-3">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<p className="text-sm font-medium">{file.name}</p>
</div>
))}
</div>
)}
{/* Rejected Files List */}
{rejectedFiles.length > 0 && (
<div className="space-y-2">
{rejectedFiles.map(({ file, errors }, index) => (
<div key={index} className="flex items-start gap-3 rounded-lg border border-destructive p-3">
<AlertCircle className="h-5 w-5 text-destructive" />
<div>
<p className="text-sm font-medium">{file.name}</p>
<ul className="text-xs text-destructive">
{errors.map((error) => (
<li key={error.code}>{error.message}</li>
))}
</ul>
</div>
</div>
))}
</div>
)}
</div>
)
}
Usage Examples
PDF Upload (Single File)
<FileUploadZone
onFilesAccepted={(files) => handlePDFUpload(files[0])}
accept={{ 'application/pdf': ['.pdf'] }}
maxSize={10 * 1024 * 1024} // 10MB
maxFiles={1}
uploadProgress={uploadProgress}
/>
Document Upload (Multiple Files)
<FileUploadZone
multiple
maxFiles={5}
onFilesAccepted={handleMultipleUpload}
onFilesRejected={(rejections) => {
toast.error(`${rejections.length} files rejected`)
}}
accept={{
'application/pdf': ['.pdf'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'text/plain': ['.txt'],
}}
maxSize={50 * 1024 * 1024} // 50MB for knowledge base docs
/>
Image Upload
<FileUploadZone
onFilesAccepted={handleImageUpload}
accept={{
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png'],
'image/tiff': ['.tif', '.tiff'],
}}
maxSize={5 * 1024 * 1024} // 5MB
/>
Backend: Multer Configuration
Standard Multer Configuration
Location: quikadmin/src/api/*.routes.ts
IntelliFill uses different Multer configurations based on upload type:
1. PDF Form Upload (10MB Limit)
import multer from 'multer';
import path from 'path';
// Basic configuration for form PDFs
const upload = multer({
dest: 'uploads/',
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (ext === '.pdf') {
cb(null, true);
} else {
cb(new Error('Only PDF forms are supported'));
}
}
});
// Usage in route
router.post('/upload', authenticateSupabase, upload.single('form'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Form file is required' });
}
// File available at req.file.path
const filePath = req.file.path;
// Process file...
});
2. Knowledge Base Documents (50MB Limit)
import multer from 'multer';
import path from 'path';
// Enhanced configuration with custom storage
const storage = multer.diskStorage({
destination: 'uploads/knowledge/',
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `knowledge-${uniqueSuffix}${ext}`);
},
});
const upload = multer({
storage,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit for knowledge documents
},
fileFilter: (req, file, cb) => {
const allowedTypes = ['.pdf', '.docx', '.doc', '.txt', '.csv'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowedTypes.includes(ext)) {
cb(null, true);
} else {
cb(new Error(`File type ${ext} not supported for knowledge base`));
}
},
});
// Usage in route
router.post(
'/sources/upload',
authenticateSupabase,
validateOrganization,
upload.single('document'),
async (req: Request, res: Response) => {
if (!req.file) {
return res.status(400).json({ error: 'Document file is required' });
}
// File metadata
const { path, originalname, size, mimetype } = req.file;
// Store in database
const source = await prisma.documentSource.create({
data: {
organizationId: req.organizationId,
userId: req.user.id,
title: req.body.title,
filename: originalname,
fileSize: size,
mimeType: mimetype,
storageUrl: path,
status: 'PENDING',
},
});
res.status(201).json({ success: true, source });
}
);
3. Memory-Optimized Configuration (Large Files)
import multer from 'multer';
import * as fs from 'fs/promises';
// For files that need immediate processing
const upload = multer({
storage: multer.memoryStorage(), // Store in memory for immediate access
limits: {
fileSize: 10 * 1024 * 1024,
files: 1,
},
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (['.pdf', '.png', '.jpg'].includes(ext)) {
cb(null, true);
} else {
cb(new Error('Invalid file type'));
}
},
});
router.post('/process', upload.single('document'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// File buffer available at req.file.buffer
const buffer = req.file.buffer;
// Process immediately without saving to disk
const result = await processBuffer(buffer);
res.json({ success: true, result });
});
Multer Error Handling
import { MulterError } from 'multer';
router.post('/upload', upload.single('file'), (err, req, res, next) => {
if (err instanceof MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
error: 'File too large',
maxSize: '10MB'
});
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({
error: 'Too many files',
maxFiles: 5
});
}
return res.status(400).json({ error: err.message });
}
if (err) {
return res.status(400).json({ error: err.message });
}
next();
}, async (req, res) => {
// Normal upload handler
});
File Validation & Security
Comprehensive File Validation Service
Location: quikadmin/src/services/fileValidation.service.ts
IntelliFill includes a production-ready file validation service:
import { FileValidationService } from '@/services/fileValidation.service';
const validationService = new FileValidationService({
maxFileSize: 10 * 1024 * 1024, // 10MB
maxPages: 50,
allowedMimeTypes: [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain',
],
});
// In upload route
router.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Read file buffer
const buffer = await fs.readFile(req.file.path);
// Validate file
const validation = await validationService.validateFile(
buffer,
req.file.originalname,
req.file.mimetype
);
if (!validation.isValid) {
// Delete invalid file
await fs.unlink(req.file.path);
return res.status(400).json({
error: 'File validation failed',
details: validation.errors,
securityFlags: validation.securityFlags,
});
}
// Log security flags for monitoring
if (validation.securityFlags.length > 0) {
logger.warn('File upload security flags', {
filename: validation.sanitizedFilename,
flags: validation.securityFlags,
userId: req.user.id,
});
}
// Proceed with processing
// Use validation.sanitizedFilename for safe storage
});
Key Validation Features
- Magic Number Validation: Detects actual file type regardless of extension
- Path Traversal Prevention: Sanitizes filenames to prevent directory attacks
- PDF Security Scanning: Detects JavaScript, embedded files, suspicious patterns
- MIME Type Verification: Ensures declared type matches actual content
- File Size Limits: Enforces maximum file sizes
- Extension Validation: Verifies extension matches MIME type
Security Best Practices
// 1. Always sanitize filenames
const sanitizedName = validationService.sanitizeFilename(file.originalname);
// 2. Generate unique filenames to prevent overwrites
const uniqueName = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}-${sanitizedName}`;
// 3. Use file hashing for deduplication
const fileHash = validationService.generateFileHash(buffer);
// 4. Check for path traversal
if (validationService.hasPathTraversal(filename)) {
throw new Error('Invalid filename: path traversal detected');
}
// 5. Validate against allowed extensions
if (!validationService.validateExtension(filename, 'application/pdf')) {
throw new Error('File extension does not match PDF type');
}
PDF-Specific Validation
// Deep PDF validation
const pdfValidation = await validationService.validatePDF(buffer);
if (!pdfValidation.isValid) {
logger.error('Dangerous PDF detected', {
errors: pdfValidation.errors,
flags: pdfValidation.flags,
});
return res.status(400).json({
error: 'PDF contains potentially dangerous content',
details: pdfValidation.errors,
});
}
// Check for specific threats
if (pdfValidation.flags.includes('PDF_CONTAINS_JAVASCRIPT')) {
// Reject PDFs with JavaScript
return res.status(400).json({
error: 'PDFs with JavaScript are not allowed',
});
}
if (pdfValidation.flags.includes('PDF_HAS_EMBEDDED_FILES')) {
// Allow but log for monitoring
logger.warn('PDF contains embedded files', {
filename: sanitizedName,
userId: req.user.id,
});
}
Upload Progress Tracking
Frontend: Progress State Management
// Create upload store
import { create } from 'zustand';
interface UploadState {
progress: number;
uploading: boolean;
error: string | null;
setProgress: (progress: number) => void;
setUploading: (uploading: boolean) => void;
setError: (error: string | null) => void;
reset: () => void;
}
export const useUploadStore = create<UploadState>((set) => ({
progress: 0,
uploading: false,
error: null,
setProgress: (progress) => set({ progress }),
setUploading: (uploading) => set({ uploading }),
setError: (error) => set({ error }),
reset: () => set({ progress: 0, uploading: false, error: null }),
}));
Frontend: Upload with Progress
import axios from 'axios';
import { useUploadStore } from '@/stores/uploadStore';
async function uploadWithProgress(file: File) {
const { setProgress, setUploading, setError } = useUploadStore.getState();
const formData = new FormData();
formData.append('document', file);
setUploading(true);
setError(null);
setProgress(0);
try {
const response = await axios.post('/api/documents/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / (progressEvent.total || 1)
);
setProgress(percentCompleted);
},
});
setProgress(100);
return response.data;
} catch (error) {
setError(error.message);
throw error;
} finally {
setUploading(false);
}
}
Frontend: Progress UI Component
function UploadProgress() {
const { progress, uploading, error } = useUploadStore();
if (!uploading && progress === 0) return null;
return (
<div className="space-y-2">
{error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : (
<>
<Progress value={progress} />
<p className="text-sm text-muted-foreground">
Uploading... {progress}%
</p>
</>
)}
</div>
);
}
Backend: Processing Status
// Update document status during processing
router.get('/documents/:id/status', authenticateSupabase, async (req, res) => {
const { id } = req.params;
const userId = req.user.id;
const document = await prisma.document.findFirst({
where: { id, userId },
select: {
id: true,
fileName: true,
status: true,
confidence: true,
processedAt: true,
},
});
if (!document) {
return res.status(404).json({ error: 'Document not found' });
}
// Try to find associated job status
let jobStatus = null;
try {
const job = await getJobStatus(id);
if (job) {
jobStatus = {
progress: job.progress,
state: job.status,
created: job.created_at,
};
}
} catch (error) {
logger.warn('Failed to fetch queue status:', error);
}
res.json({
success: true,
document,
job: jobStatus,
});
});
Bull Queue Integration
Document Queue Configuration
Location: quikadmin/src/queues/documentQueue.ts
import Bull from 'bull';
import { logger } from '../utils/logger';
export interface DocumentProcessingJob {
documentId: string;
userId: string;
filePath: string;
options?: {
extractTables?: boolean;
ocrEnabled?: boolean;
language?: string;
confidenceThreshold?: number;
};
}
const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD,
};
// Create document processing queue
export const documentQueue = new Bull<DocumentProcessingJob>('document-processing', {
redis: redisConfig,
defaultJobOptions: {
removeOnComplete: 100, // Keep last 100 completed jobs
removeOnFail: 50, // Keep last 50 failed jobs
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
},
});
// Process document jobs
documentQueue.process(async (job) => {
const { documentId, filePath, options } = job.data;
try {
// Update progress
await job.progress(10);
logger.info(`Processing document ${documentId}`);
// Parse document
await job.progress(30);
const parsedContent = await parser.parse(filePath);
// Extract data
await job.progress(50);
const extractedData = await extractor.extract(parsedContent);
// Map fields
await job.progress(70);
const mappedFields = await mapper.mapFields(extractedData, []);
// Complete
await job.progress(100);
return {
documentId,
status: 'completed',
extractedData,
mappedFields,
processingTime: Date.now() - job.timestamp,
};
} catch (error) {
logger.error(`Failed to process document ${documentId}:`, error);
throw error;
}
});
// Event handlers
documentQueue.on('completed', (job, result) => {
logger.info(`Job ${job.id} completed`, { documentId: result.documentId });
});
documentQueue.on('failed', (job, err) => {
logger.error(`Job ${job.id} failed:`, err);
});
// Get job status
export async function getJobStatus(jobId: string) {
const job = await documentQueue.getJob(jobId);
if (!job) {
return null;
}
return {
id: job.id,
type: 'document_processing',
status: await job.getState(),
progress: job.progress(),
created_at: new Date(job.timestamp),
started_at: job.processedOn ? new Date(job.processedOn) : undefined,
completed_at: job.finishedOn ? new Date(job.finishedOn) : undefined,
result: job.returnvalue,
error: job.failedReason,
};
}
Adding Jobs to Queue
import { documentQueue } from '@/queues/documentQueue';
// In upload route
router.post('/upload', authenticateSupabase, upload.single('document'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const userId = req.user.id;
// Validate file
const buffer = await fs.readFile(req.file.path);
const validation = await validationService.validateFile(
buffer,
req.file.originalname,
req.file.mimetype
);
if (!validation.isValid) {
await fs.unlink(req.file.path);
return res.status(400).json({ error: 'Invalid file', details: validation.errors });
}
// Create document record
const document = await prisma.document.create({
data: {
userId,
fileName: validation.sanitizedFilename,
fileType: validation.detectedMimeType || req.file.mimetype,
fileSize: req.file.size,
storageUrl: req.file.path,
status: 'PENDING',
},
});
// Add to processing queue
const job = await documentQueue.add({
documentId: document.id,
userId,
filePath: req.file.path,
options: {
extractTables: true,
ocrEnabled: true,
language: 'eng',
},
});
res.status(201).json({
success: true,
document: {
id: document.id,
fileName: document.fileName,
status: document.status,
},
job: {
id: job.id,
status: 'queued',
},
statusUrl: `/api/documents/${document.id}/status`,
});
});
Batch Processing
export const batchQueue = new Bull<BatchProcessingJob>('batch-processing', {
redis: redisConfig,
defaultJobOptions: {
removeOnComplete: 50,
removeOnFail: 25,
attempts: 2,
},
});
interface BatchProcessingJob {
documentIds: string[];
userId: string;
targetFormId?: string;
options?: {
parallel?: boolean;
stopOnError?: boolean;
};
}
// Process batch jobs
batchQueue.process(async (job) => {
const { documentIds, userId, options } = job.data;
const results = [];
for (let i = 0; i < documentIds.length; i++) {
const progress = Math.round((i / documentIds.length) * 100);
await job.progress(progress);
// Add individual document to processing queue
const childJob = await documentQueue.add({
documentId: documentIds[i],
userId,
filePath: `pending`, // Fetched from database
options: {},
});
// Wait for completion if not parallel
if (!options?.parallel) {
const result = await childJob.finished();
results.push(result);
// Stop on error if configured
if (options?.stopOnError && result.status === 'failed') {
break;
}
} else {
results.push({ documentId: documentIds[i], jobId: childJob.id });
}
}
await job.progress(100);
return {
batchId: job.id,
documentsProcessed: results.length,
results,
};
});
Error Handling Patterns
Frontend Error Handling
import { toast } from '@/components/ui/use-toast';
async function handleUpload(files: File[]) {
const { setUploading, setError, reset } = useUploadStore.getState();
reset();
try {
setUploading(true);
for (const file of files) {
const result = await uploadWithProgress(file);
toast({
title: 'Upload successful',
description: `${file.name} uploaded successfully`,
});
}
} catch (error) {
const message = error.response?.data?.error || error.message || 'Upload failed';
setError(message);
toast({
title: 'Upload failed',
description: message,
variant: 'destructive',
});
logger.error('Upload error:', error);
} finally {
setUploading(false);
}
}
Backend Error Handling
router.post('/upload', authenticateSupabase, upload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Validate file
const buffer = await fs.readFile(req.file.path);
const validation = await validationService.validateFile(
buffer,
req.file.originalname,
req.file.mimetype
);
if (!validation.isValid) {
// Cleanup
await fs.unlink(req.file.path).catch(() => {});
return res.status(400).json({
error: 'File validation failed',
details: validation.errors,
securityFlags: validation.securityFlags,
});
}
// Process file...
} catch (error) {
// Cleanup on error
if (req.file?.path) {
await fs.unlink(req.file.path).catch(() => {});
}
logger.error('Upload error:', {
error,
userId: req.user?.id,
filename: req.file?.originalname,
});
next(error);
}
});
// Global error handler
app.use((err, req, res, next) => {
if (err instanceof MulterError) {
return res.status(400).json({
error: 'Upload error',
message: err.message,
code: err.code,
});
}
if (err.message?.includes('validation')) {
return res.status(400).json({
error: 'Validation error',
message: err.message,
});
}
logger.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
Queue Error Handling
documentQueue.on('failed', async (job, err) => {
logger.error(`Job ${job.id} failed:`, {
error: err,
documentId: job.data.documentId,
attempts: job.attemptsMade,
});
// Update document status
try {
await prisma.document.update({
where: { id: job.data.documentId },
data: {
status: 'FAILED',
errorMessage: err.message,
},
});
} catch (dbError) {
logger.error('Failed to update document status:', dbError);
}
// Notify user via websocket or email
// await notifyUser(job.data.userId, 'Document processing failed');
});
documentQueue.on('stalled', (job) => {
logger.warn(`Job ${job.id} stalled`, {
documentId: job.data.documentId,
});
});
Complete Implementation Examples
Example 1: Simple PDF Upload Page
// pages/Upload.tsx
import { useState } from 'react';
import { FileUploadZone } from '@/components/features/file-upload-zone';
import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { useUploadStore } from '@/stores/uploadStore';
import { uploadDocument } from '@/services/documentService';
export function UploadPage() {
const { progress, uploading } = useUploadStore();
const [uploadedDocs, setUploadedDocs] = useState([]);
const handleUpload = async (files: File[]) => {
for (const file of files) {
const result = await uploadDocument(file);
setUploadedDocs(prev => [...prev, result]);
}
};
return (
<div className="container mx-auto p-6">
<Card>
<CardHeader>
<h2 className="text-2xl font-bold">Upload Documents</h2>
</CardHeader>
<CardContent>
<FileUploadZone
onFilesAccepted={handleUpload}
accept={{ 'application/pdf': ['.pdf'] }}
maxSize={10 * 1024 * 1024}
uploadProgress={progress}
disabled={uploading}
/>
{uploadedDocs.length > 0 && (
<div className="mt-6">
<h3 className="font-semibold mb-2">Uploaded Documents</h3>
<ul className="space-y-2">
{uploadedDocs.map(doc => (
<li key={doc.id}>{doc.fileName} - {doc.status}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
</div>
);
}
Example 2: Knowledge Base Upload with Processing
// services/knowledgeService.ts
import axios from 'axios';
import { useUploadStore } from '@/stores/uploadStore';
export async function uploadKnowledgeDocument(file: File, title: string) {
const { setProgress, setUploading, setError } = useUploadStore.getState();
const formData = new FormData();
formData.append('document', file);
formData.append('title', title);
setUploading(true);
setError(null);
try {
const response = await axios.post('/api/knowledge/sources/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
const percent = Math.round((e.loaded * 100) / (e.total || 1));
setProgress(percent);
},
});
return response.data;
} catch (error) {
setError(error.response?.data?.error || 'Upload failed');
throw error;
} finally {
setUploading(false);
}
}
// Component
function KnowledgeUpload() {
const [title, setTitle] = useState('');
const { progress, uploading, error } = useUploadStore();
const handleUpload = async (files: File[]) => {
const file = files[0];
try {
const result = await uploadKnowledgeDocument(file, title || file.name);
toast({ title: 'Upload successful', description: result.message });
} catch (error) {
toast({ title: 'Upload failed', variant: 'destructive' });
}
};
return (
<div className="space-y-4">
<Input
placeholder="Document title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<FileUploadZone
onFilesAccepted={handleUpload}
accept={{
'application/pdf': ['.pdf'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'text/plain': ['.txt'],
}}
maxSize={50 * 1024 * 1024}
uploadProgress={progress}
disabled={uploading || !title}
/>
{error && <Alert variant="destructive">{error}</Alert>}
</div>
);
}
Testing Strategies
Frontend Tests
// components/FileUploadZone.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { FileUploadZone } from './file-upload-zone';
describe('FileUploadZone', () => {
it('accepts valid PDF files', async () => {
const onFilesAccepted = vi.fn();
render(
<FileUploadZone
onFilesAccepted={onFilesAccepted}
accept={{ 'application/pdf': ['.pdf'] }}
/>
);
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
const input = screen.getByLabelText('File upload input');
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
expect(onFilesAccepted).toHaveBeenCalledWith([file]);
});
});
it('rejects files exceeding size limit', async () => {
const onFilesRejected = vi.fn();
render(
<FileUploadZone
onFilesAccepted={vi.fn()}
onFilesRejected={onFilesRejected}
maxSize={1024} // 1KB
/>
);
const largeFile = new File(['x'.repeat(2048)], 'large.pdf', { type: 'application/pdf' });
const input = screen.getByLabelText('File upload input');
fireEvent.change(input, { target: { files: [largeFile] } });
await waitFor(() => {
expect(onFilesRejected).toHaveBeenCalled();
});
});
});
Backend Tests
// api/documents.routes.test.ts
import request from 'supertest';
import { app } from '../app';
import * as fs from 'fs/promises';
describe('POST /api/documents/upload', () => {
it('uploads valid PDF file', async () => {
const response = await request(app)
.post('/api/documents/upload')
.set('Authorization', `Bearer ${validToken}`)
.attach('document', 'test/fixtures/valid.pdf')
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.document.id).toBeDefined();
});
it('rejects non-PDF files', async () => {
const response = await request(app)
.post('/api/documents/upload')
.set('Authorization', `Bearer ${validToken}`)
.attach('document', 'test/fixtures/test.txt')
.expect(400);
expect(response.body.error).toContain('Only PDF forms are supported');
});
it('rejects files exceeding size limit', async () => {
// Create 15MB file (exceeds 10MB limit)
const largePath = 'test/fixtures/large.pdf';
await fs.writeFile(largePath, Buffer.alloc(15 * 1024 * 1024));
const response = await request(app)
.post('/api/documents/upload')
.set('Authorization', `Bearer ${validToken}`)
.attach('document', largePath)
.expect(400);
expect(response.body.error).toContain('File too large');
await fs.unlink(largePath);
});
});
Validation Service Tests
// services/fileValidation.service.test.ts
import { FileValidationService } from '../fileValidation.service';
import * as fs from 'fs/promises';
describe('FileValidationService', () => {
const service = new FileValidationService();
it('detects PDF magic numbers', async () => {
const buffer = await fs.readFile('test/fixtures/valid.pdf');
const result = await service.validateFile(buffer, 'test.pdf', 'application/pdf');
expect(result.isValid).toBe(true);
expect(result.detectedMimeType).toBe('application/pdf');
});
it('rejects files with mismatched MIME types', async () => {
const buffer = Buffer.from('plain text');
const result = await service.validateFile(buffer, 'fake.pdf', 'application/pdf');
expect(result.isValid).toBe(false);
expect(result.securityFlags).toContain('MIME_TYPE_MISMATCH');
});
it('sanitizes dangerous filenames', () => {
const dangerous = '../../../etc/passwd';
const sanitized = service.sanitizeFilename(dangerous);
expect(sanitized).not.toContain('..');
expect(sanitized).not.toContain('/');
});
it('detects JavaScript in PDFs', async () => {
const pdfWithJS = Buffer.from('%PDF-1.4\n/JavaScript (alert(1))');
const result = await service.validatePDF(pdfWithJS);
expect(result.isValid).toBe(false);
expect(result.flags).toContain('PDF_CONTAINS_JAVASCRIPT');
});
});
Key Takeaways
-
Frontend: Use
FileUploadZonecomponent with react-dropzone for consistent UI - Backend: Configure Multer based on file type and size requirements
-
Security: Always validate files with
FileValidationServicebefore processing -
Progress: Track upload progress with Zustand store and axios
onUploadProgress - Queues: Use Bull queues for async processing with progress tracking
- Error Handling: Implement comprehensive error handling at all layers
- Testing: Test validation, upload, and error scenarios
Related Files
Frontend:
-
quikadmin-web/src/components/features/file-upload-zone.tsx -
quikadmin-web/src/stores/uploadStore.ts -
quikadmin-web/src/services/documentService.ts
Backend:
-
quikadmin/src/api/documents.routes.ts -
quikadmin/src/api/knowledge.routes.ts -
quikadmin/src/services/fileValidation.service.ts -
quikadmin/src/queues/documentQueue.ts
Configuration:
-
quikadmin/package.json(multer, bull dependencies) -
quikadmin-web/package.json(react-dropzone dependency)
Exemples
Consultez les exemples du dépôt source.
Voir la source ↗Historique source
- Date d’indexation
- 2026-01-20
- Mise à jour de l’index
- 2026-03-03
- Branch
- main
Les dates concernent ce répertoire. Consultez le dépôt pour l’historique du code.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!