ca-grow-ops-manager/backend/src/routes/upload.routes.ts
fullsizemalt bfa3cd21cc
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run
polish: Enhance demo data for Audit Logs and SOPs, fix photo delete bug
- Seed script now generates 100+ realistic audit logs
- Added Policy, Training, Form, and Checklist document types to seed data
- Fixed bug in photo deletion logic for older photos
2025-12-19 15:05:40 -08:00

337 lines
12 KiB
TypeScript

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<Buffer> {
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<string, string> = {};
const sizes: Record<string, number> = {};
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<string, string> = {};
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' });
}
});
}