import { FastifyInstance } from 'fastify'; import { promises as fs } from 'fs'; import path from 'path'; import crypto from 'crypto'; import sharp from 'sharp'; // Storage base path - configurable via env const STORAGE_PATH = process.env.STORAGE_PATH || '/tmp/ca-grow-ops-manager/photos'; // Image size configurations per spec const IMAGE_SIZES = { thumb: { width: 200, height: 200, quality: 80 }, medium: { width: 800, height: 800, quality: 85 }, full: { width: 1920, height: 1920, quality: 90 } } as const; function generatePhotoId(): string { return `photo_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`; } async function processImage(buffer: Buffer, size: keyof typeof IMAGE_SIZES): Promise { const config = IMAGE_SIZES[size]; return sharp(buffer) .resize(config.width, config.height, { fit: 'inside', withoutEnlargement: true }) .webp({ quality: config.quality }) .toBuffer(); } async function getImageMetadata(buffer: Buffer): Promise<{ width: number; height: number; format: string }> { const metadata = await sharp(buffer).metadata(); return { width: metadata.width || 0, height: metadata.height || 0, format: metadata.format || 'unknown' }; } export async function uploadRoutes(server: FastifyInstance) { // Register multipart support await server.register(import('@fastify/multipart'), { limits: { fileSize: 10 * 1024 * 1024, // 10MB max (before compression) }, }); // Auth middleware server.addHook('onRequest', async (request) => { try { await request.jwtVerify(); } catch (err) { throw err; } }); /** * POST /upload/photo * Upload a photo with automatic compression to 3 sizes (thumb, medium, full) * Returns URLs for all sizes in WebP format */ server.post('/photo', async (request, reply) => { try { const data = await request.file(); if (!data) { return reply.status(400).send({ error: 'No file uploaded' }); } // Validate file type const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']; if (!allowedTypes.includes(data.mimetype)) { return reply.status(400).send({ error: 'Invalid file type', allowed: ['JPEG', 'PNG', 'WebP', 'HEIC'] }); } const buffer = await data.toBuffer(); const photoId = generatePhotoId(); const date = new Date(); // Get original metadata const metadata = await getImageMetadata(buffer); // Build path: /photos/{year}/{month}/{day}/{photoId}/ const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`; const photoPath = path.join(datePath, photoId); const basePath = path.join(STORAGE_PATH, photoPath); // Ensure directory exists await fs.mkdir(basePath, { recursive: true }); // Process and save all sizes const urls: Record = {}; const sizes: Record = {}; for (const [sizeName, config] of Object.entries(IMAGE_SIZES)) { const processedBuffer = await processImage(buffer, sizeName as keyof typeof IMAGE_SIZES); const filename = `${sizeName}.webp`; const filepath = path.join(basePath, filename); await fs.writeFile(filepath, processedBuffer); urls[sizeName] = `/photos/${photoPath}/${filename}`; sizes[sizeName] = processedBuffer.length; } // Calculate compression savings const originalSize = buffer.length; const compressedSize = sizes.full; const savings = Math.round((1 - compressedSize / originalSize) * 100); return { success: true, photoId, urls, metadata: { originalSize, originalFormat: metadata.format, originalDimensions: { width: metadata.width, height: metadata.height }, compressedSizes: sizes, savingsPercent: savings, format: 'webp' }, uploadedAt: new Date().toISOString() }; } catch (error: any) { server.log.error(error); return reply.status(500).send({ error: 'Upload failed', message: error.message }); } }); /** * POST /upload/photos * Bulk upload multiple photos */ server.post('/photos', async (request, reply) => { try { const parts = request.parts(); const results: any[] = []; const errors: any[] = []; for await (const part of parts) { if (part.type === 'file') { try { const buffer = await part.toBuffer(); const photoId = generatePhotoId(); const date = new Date(); const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`; const photoPath = path.join(datePath, photoId); const basePath = path.join(STORAGE_PATH, photoPath); await fs.mkdir(basePath, { recursive: true }); const urls: Record = {}; for (const sizeName of Object.keys(IMAGE_SIZES)) { const processedBuffer = await processImage(buffer, sizeName as keyof typeof IMAGE_SIZES); const filename = `${sizeName}.webp`; const filepath = path.join(basePath, filename); await fs.writeFile(filepath, processedBuffer); urls[sizeName] = `/photos/${photoPath}/${filename}`; } results.push({ photoId, filename: part.filename, urls }); } catch (err: any) { errors.push({ filename: part.filename, error: err.message }); } } } return { success: true, uploaded: results.length, failed: errors.length, results, errors: errors.length > 0 ? errors : undefined }; } catch (error: any) { server.log.error(error); return reply.status(500).send({ error: 'Bulk upload failed', message: error.message }); } }); /** * GET /upload/photo/* * Serve a photo (proxy for auth) */ server.get('/photo/*', async (request, reply) => { try { const { '*': photoPath } = request.params as { '*': string }; const fullPath = path.join(STORAGE_PATH, photoPath); // Security: prevent path traversal const resolvedPath = path.resolve(fullPath); const resolvedBase = path.resolve(STORAGE_PATH); if (!resolvedPath.startsWith(resolvedBase)) { return reply.status(403).send({ error: 'Forbidden' }); } try { const stat = await fs.stat(fullPath); const file = await fs.readFile(fullPath); const ext = path.extname(fullPath).toLowerCase(); const contentType = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'application/octet-stream'; reply.header('Content-Type', contentType); reply.header('Content-Length', stat.size); reply.header('Cache-Control', 'public, max-age=31536000, immutable'); reply.header('ETag', `"${stat.mtime.getTime().toString(16)}"`); return reply.send(file); } catch { return reply.status(404).send({ error: 'Photo not found' }); } } catch (error: any) { server.log.error(error); return reply.status(500).send({ error: 'Failed to serve photo' }); } }); /** * DELETE /upload/photo/:photoId * Delete a photo and all its sizes */ server.delete('/photo/:photoId', async (request, reply) => { try { const { photoId } = request.params as { photoId: string }; // Find the photo directory // Photos are stored as: /photos/{year}/{month}/{day}/{photoId}/ // We need to search for it // Parse date from photoId (format: photo_TIMESTAMP_HASH) const parts = photoId.split('_'); if (parts.length < 2) { return reply.status(400).send({ error: 'Invalid photo ID format' }); } const timestamp = parseInt(parts[1]); const date = new Date(timestamp); if (isNaN(date.getTime())) { return reply.status(400).send({ error: 'Invalid photo timestamp' }); } const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); let photoDir = path.join(STORAGE_PATH, `${year}/${month}/${day}`, photoId); try { await fs.access(photoDir); } catch { return reply.status(404).send({ error: 'Photo not found' }); } // Security check const resolvedPath = path.resolve(photoDir); const resolvedBase = path.resolve(STORAGE_PATH); if (!resolvedPath.startsWith(resolvedBase)) { return reply.status(403).send({ error: 'Forbidden' }); } // Delete all files in the directory const files = await fs.readdir(photoDir); for (const file of files) { await fs.unlink(path.join(photoDir, file)); } // Remove the directory await fs.rmdir(photoDir); return { success: true, deleted: photoId, filesRemoved: files.length }; } catch (error: any) { server.log.error(error); return reply.status(500).send({ error: 'Failed to delete photo' }); } }); /** * GET /upload/stats * Get storage statistics */ server.get('/stats', async (request, reply) => { try { const stats = { storagePath: STORAGE_PATH, sizes: IMAGE_SIZES, format: 'webp' }; // Try to get disk usage try { const { stdout } = await import('child_process').then(cp => new Promise<{ stdout: string }>((resolve, reject) => { cp.exec(`du -sh ${STORAGE_PATH}`, (err, stdout) => { if (err) reject(err); else resolve({ stdout }); }); }) ); (stats as any).diskUsage = stdout.trim().split('\t')[0]; } catch { (stats as any).diskUsage = 'unknown'; } return stats; } catch (error: any) { server.log.error(error); return reply.status(500).send({ error: 'Failed to get stats' }); } }); }