| name | file-upload |
| description | Complete guide for implementing file uploads in IntelliFill with React-dropzone frontend, Multer backend, file validation, Bull queue processing, and security best practices |
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.tsxquikadmin-web/src/stores/uploadStore.tsquikadmin-web/src/services/documentService.ts
Backend:
quikadmin/src/api/documents.routes.tsquikadmin/src/api/knowledge.routes.tsquikadmin/src/services/fileValidation.service.tsquikadmin/src/queues/documentQueue.ts
Configuration:
quikadmin/package.json(multer, bull dependencies)quikadmin-web/package.json(react-dropzone dependency)