Photos for vehicles
This commit is contained in:
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user