Photos for vehicles

This commit is contained in:
Eric Gullickson
2025-12-15 21:39:51 -06:00
parent e1c48b7a26
commit 263fc434b0
17 changed files with 745 additions and 58 deletions

View File

@@ -9,6 +9,11 @@ import { VehiclesRepository } from '../data/vehicles.repository';
import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/vehicles.types';
import { getStorageService } from '../../../core/storage/storage.service';
import { Transform, TransformCallback, Readable } from 'stream';
import crypto from 'crypto';
import FileType from 'file-type';
import path from 'path';
export class VehiclesController {
private vehiclesService: VehiclesService;
@@ -291,4 +296,300 @@ export class VehiclesController {
});
}
}
async uploadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
const userId = (request as any).user.sub;
const vehicleId = request.params.id;
logger.info('Vehicle image upload requested', {
operation: 'vehicles.uploadImage',
userId,
vehicleId,
});
try {
// Verify vehicle ownership
const vehicle = await this.vehiclesService.getVehicleRaw(vehicleId, userId);
if (!vehicle) {
logger.warn('Vehicle not found for image upload', {
operation: 'vehicles.uploadImage.not_found',
userId,
vehicleId,
});
return reply.code(404).send({ error: 'Not Found', message: 'Vehicle not found' });
}
const mp = await (request as any).file({ limits: { files: 1 } });
if (!mp) {
logger.warn('No file provided for image upload', {
operation: 'vehicles.uploadImage.no_file',
userId,
vehicleId,
});
return reply.code(400).send({ error: 'Bad Request', message: 'No file provided' });
}
// Allowed image types
const allowedTypes = new Map([
['image/jpeg', new Set(['image/jpeg'])],
['image/png', new Set(['image/png'])],
]);
const contentType = mp.mimetype as string | undefined;
if (!contentType || !allowedTypes.has(contentType)) {
logger.warn('Unsupported image type', {
operation: 'vehicles.uploadImage.unsupported_type',
userId,
vehicleId,
contentType,
fileName: mp.filename,
});
return reply.code(415).send({
error: 'Unsupported Media Type',
message: 'Only JPEG and PNG images are allowed'
});
}
// Read first 4100 bytes to detect file type via magic bytes
const chunks: Buffer[] = [];
let totalBytes = 0;
const targetBytes = 4100;
for await (const chunk of mp.file) {
chunks.push(chunk);
totalBytes += chunk.length;
if (totalBytes >= targetBytes) {
break;
}
}
const headerBuffer = Buffer.concat(chunks);
// Validate actual file content using magic bytes
const detectedType = await FileType.fromBuffer(headerBuffer);
if (!detectedType) {
logger.warn('Unable to detect file type from content', {
operation: 'vehicles.uploadImage.type_detection_failed',
userId,
vehicleId,
contentType,
fileName: mp.filename,
});
return reply.code(415).send({
error: 'Unsupported Media Type',
message: 'Unable to verify file type from content'
});
}
// Verify detected type matches claimed Content-Type
const allowedDetectedTypes = allowedTypes.get(contentType);
if (!allowedDetectedTypes || !allowedDetectedTypes.has(detectedType.mime)) {
logger.warn('File content does not match Content-Type header', {
operation: 'vehicles.uploadImage.type_mismatch',
userId,
vehicleId,
claimedType: contentType,
detectedType: detectedType.mime,
fileName: mp.filename,
});
return reply.code(415).send({
error: 'Unsupported Media Type',
message: `File content (${detectedType.mime}) does not match claimed type (${contentType})`
});
}
// Delete existing image if present
if (vehicle.imageStorageKey && vehicle.imageStorageBucket) {
const storage = getStorageService();
try {
await storage.deleteObject(vehicle.imageStorageBucket, vehicle.imageStorageKey);
logger.info('Deleted existing vehicle image', {
operation: 'vehicles.uploadImage.delete_existing',
userId,
vehicleId,
storageKey: vehicle.imageStorageKey,
});
} catch (e) {
logger.warn('Failed to delete existing vehicle image', {
operation: 'vehicles.uploadImage.delete_existing_failed',
userId,
vehicleId,
storageKey: vehicle.imageStorageKey,
error: e instanceof Error ? e.message : 'Unknown error',
});
}
}
const originalName: string = mp.filename || 'vehicle-image';
const ext = contentType === 'image/jpeg' ? 'jpg' : 'png';
class CountingStream extends Transform {
public bytes = 0;
override _transform(chunk: any, _enc: BufferEncoding, cb: TransformCallback) {
this.bytes += chunk.length || 0;
cb(null, chunk);
}
}
const counter = new CountingStream();
// Create a new readable stream from the header buffer + remaining file chunks
const headerStream = Readable.from([headerBuffer]);
const remainingStream = mp.file;
// Pipe header first, then remaining content through counter
headerStream.pipe(counter, { end: false });
headerStream.on('end', () => {
remainingStream.pipe(counter);
});
const storage = getStorageService();
const bucket = 'vehicle-images';
const unique = crypto.randomBytes(32).toString('hex');
const key = `vehicle-images/${userId}/${vehicleId}/${unique}.${ext}`;
await storage.putObject(bucket, key, counter, contentType, { 'x-original-filename': originalName });
const updated = await this.vehiclesService.updateVehicleImage(vehicleId, userId, {
imageStorageBucket: bucket,
imageStorageKey: key,
imageFileName: originalName,
imageContentType: contentType,
imageFileSize: counter.bytes,
});
logger.info('Vehicle image upload completed', {
operation: 'vehicles.uploadImage.success',
userId,
vehicleId,
fileName: originalName,
contentType,
detectedType: detectedType.mime,
fileSize: counter.bytes,
storageKey: key,
});
return reply.code(200).send(updated);
} catch (error) {
logger.error('Error uploading vehicle image', { error, vehicleId, userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to upload image'
});
}
}
async downloadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
const userId = (request as any).user.sub;
const vehicleId = request.params.id;
logger.info('Vehicle image download requested', {
operation: 'vehicles.downloadImage',
userId,
vehicleId,
});
try {
const vehicle = await this.vehiclesService.getVehicleRaw(vehicleId, userId);
if (!vehicle || !vehicle.imageStorageBucket || !vehicle.imageStorageKey) {
logger.warn('Vehicle or image not found for download', {
operation: 'vehicles.downloadImage.not_found',
userId,
vehicleId,
hasVehicle: !!vehicle,
hasStorageInfo: !!(vehicle?.imageStorageBucket && vehicle?.imageStorageKey),
});
return reply.code(404).send({ error: 'Not Found', message: 'Vehicle image not found' });
}
const storage = getStorageService();
const contentType = vehicle.imageContentType || 'application/octet-stream';
const filename = vehicle.imageFileName || path.basename(vehicle.imageStorageKey);
reply.header('Content-Type', contentType);
reply.header('Content-Disposition', `inline; filename="${encodeURIComponent(filename)}"`);
logger.info('Vehicle image download initiated', {
operation: 'vehicles.downloadImage.success',
userId,
vehicleId,
fileName: filename,
contentType,
fileSize: vehicle.imageFileSize,
});
const stream = await storage.getObjectStream(vehicle.imageStorageBucket, vehicle.imageStorageKey);
return reply.send(stream);
} catch (error) {
logger.error('Error downloading vehicle image', { error, vehicleId, userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to download image'
});
}
}
async deleteImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
const userId = (request as any).user.sub;
const vehicleId = request.params.id;
logger.info('Vehicle image delete requested', {
operation: 'vehicles.deleteImage',
userId,
vehicleId,
});
try {
const vehicle = await this.vehiclesService.getVehicleRaw(vehicleId, userId);
if (!vehicle) {
logger.warn('Vehicle not found for image delete', {
operation: 'vehicles.deleteImage.not_found',
userId,
vehicleId,
});
return reply.code(404).send({ error: 'Not Found', message: 'Vehicle not found' });
}
// Delete file from storage if exists
if (vehicle.imageStorageKey && vehicle.imageStorageBucket) {
const storage = getStorageService();
try {
await storage.deleteObject(vehicle.imageStorageBucket, vehicle.imageStorageKey);
logger.info('Vehicle image file deleted from storage', {
operation: 'vehicles.deleteImage.storage_cleanup',
userId,
vehicleId,
storageKey: vehicle.imageStorageKey,
});
} catch (e) {
logger.warn('Failed to delete vehicle image file from storage', {
operation: 'vehicles.deleteImage.storage_cleanup_failed',
userId,
vehicleId,
storageKey: vehicle.imageStorageKey,
error: e instanceof Error ? e.message : 'Unknown error',
});
}
}
// Clear image metadata from database
await this.vehiclesService.updateVehicleImage(vehicleId, userId, null);
logger.info('Vehicle image deleted', {
operation: 'vehicles.deleteImage.success',
userId,
vehicleId,
hadFile: !!(vehicle.imageStorageKey),
});
return reply.code(204).send();
} catch (error) {
logger.error('Error deleting vehicle image', { error, vehicleId, userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to delete image'
});
}
}
}