- 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
337 lines
12 KiB
TypeScript
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' });
|
|
}
|
|
});
|
|
}
|