Completed HIGH severity security fix (CVSS 6.5) to prevent Google Maps API key exposure to frontend clients. Issue: API key was embedded in photo URLs sent to frontend, allowing potential abuse and quota exhaustion. Solution: Implemented backend proxy endpoint for photos. Backend Changes: - google-maps.client.ts: Changed photoUrl to photoReference, added fetchPhoto() - stations.types.ts: Updated type definition (photoUrl → photoReference) - stations.controller.ts: Added getStationPhoto() proxy method - stations.routes.ts: Added GET /api/stations/photo/:reference route - stations.service.ts: Updated to use photoReference - stations.repository.ts: Updated database queries and mappings - admin controllers/services: Updated for consistency - Created migration 003 to rename photo_url column Frontend Changes: - stations.types.ts: Updated type definition (photoUrl → photoReference) - photo-utils.ts: NEW - Helper to generate proxy URLs - StationCard.tsx: Use photoReference with helper function Tests & Docs: - Updated mock data to use photoReference - Updated test expectations for proxy URLs - Updated API.md and TESTING.md documentation Database Migration: - 003_rename_photo_url_to_photo_reference.sql: Renames column in station_cache Security Benefits: - API key never sent to frontend - All photo requests proxied through authenticated endpoint - Photos cached for 24 hours (Cache-Control header) - No client-side API key exposure Files modified: 16 files New files: 2 (photo-utils.ts, migration 003) Status: All 3 P0 security fixes now complete - Fix 1: crypto.randomBytes() ✓ - Fix 2: Magic byte validation ✓ - Fix 3: API key proxy ✓ Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
203 lines
5.9 KiB
TypeScript
203 lines
5.9 KiB
TypeScript
/**
|
|
* @ai-summary Fastify route handlers for stations API
|
|
* @ai-context HTTP request/response handling with Fastify reply methods
|
|
*/
|
|
|
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
|
import { StationsService } from '../domain/stations.service';
|
|
import { StationsRepository } from '../data/stations.repository';
|
|
import { pool } from '../../../core/config/database';
|
|
import { logger } from '../../../core/logging/logger';
|
|
import {
|
|
StationSearchBody,
|
|
SaveStationBody,
|
|
StationParams,
|
|
UpdateSavedStationBody
|
|
} from '../domain/stations.types';
|
|
import { googleMapsClient } from '../external/google-maps/google-maps.client';
|
|
|
|
export class StationsController {
|
|
private stationsService: StationsService;
|
|
|
|
constructor() {
|
|
const repository = new StationsRepository(pool);
|
|
this.stationsService = new StationsService(repository);
|
|
}
|
|
|
|
async searchStations(request: FastifyRequest<{ Body: StationSearchBody }>, reply: FastifyReply) {
|
|
try {
|
|
const userId = (request as any).user.sub;
|
|
const { latitude, longitude, radius, fuelType } = request.body;
|
|
|
|
if (!latitude || !longitude) {
|
|
return reply.code(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Latitude and longitude are required'
|
|
});
|
|
}
|
|
|
|
const result = await this.stationsService.searchNearbyStations({
|
|
latitude,
|
|
longitude,
|
|
radius,
|
|
fuelType
|
|
}, userId);
|
|
|
|
return reply.code(200).send(result);
|
|
} catch (error: any) {
|
|
logger.error('Error searching stations', { error, userId: (request as any).user?.sub });
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to search stations'
|
|
});
|
|
}
|
|
}
|
|
|
|
async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) {
|
|
try {
|
|
const userId = (request as any).user.sub;
|
|
const {
|
|
placeId,
|
|
nickname,
|
|
notes,
|
|
isFavorite,
|
|
has93Octane,
|
|
has93OctaneEthanolFree
|
|
} = request.body;
|
|
|
|
if (!placeId) {
|
|
return reply.code(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Place ID is required'
|
|
});
|
|
}
|
|
|
|
const result = await this.stationsService.saveStation(placeId, userId, {
|
|
nickname,
|
|
notes,
|
|
isFavorite,
|
|
has93Octane,
|
|
has93OctaneEthanolFree
|
|
});
|
|
|
|
return reply.code(201).send(result);
|
|
} catch (error: any) {
|
|
logger.error('Error saving station', { error, userId: (request as any).user?.sub });
|
|
|
|
if (error.message.includes('not found')) {
|
|
return reply.code(404).send({
|
|
error: 'Not Found',
|
|
message: error.message
|
|
});
|
|
}
|
|
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to save station'
|
|
});
|
|
}
|
|
}
|
|
|
|
async updateSavedStation(
|
|
request: FastifyRequest<{ Params: StationParams; Body: UpdateSavedStationBody }>,
|
|
reply: FastifyReply
|
|
) {
|
|
try {
|
|
const userId = (request as any).user.sub;
|
|
const { placeId } = request.params;
|
|
|
|
const result = await this.stationsService.updateSavedStation(placeId, userId, request.body);
|
|
|
|
return reply.code(200).send(result);
|
|
} catch (error: any) {
|
|
logger.error('Error updating saved station', {
|
|
error,
|
|
placeId: request.params.placeId,
|
|
userId: (request as any).user?.sub
|
|
});
|
|
|
|
if (error.message.includes('not found')) {
|
|
return reply.code(404).send({
|
|
error: 'Not Found',
|
|
message: error.message
|
|
});
|
|
}
|
|
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to update saved station'
|
|
});
|
|
}
|
|
}
|
|
|
|
async getSavedStations(request: FastifyRequest, reply: FastifyReply) {
|
|
try {
|
|
const userId = (request as any).user.sub;
|
|
const result = await this.stationsService.getUserSavedStations(userId);
|
|
|
|
return reply.code(200).send(result);
|
|
} catch (error: any) {
|
|
logger.error('Error getting saved stations', { error, userId: (request as any).user?.sub });
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to get saved stations'
|
|
});
|
|
}
|
|
}
|
|
|
|
async removeSavedStation(request: FastifyRequest<{ Params: StationParams }>, reply: FastifyReply) {
|
|
try {
|
|
const userId = (request as any).user.sub;
|
|
const { placeId } = request.params;
|
|
|
|
await this.stationsService.removeSavedStation(placeId, userId);
|
|
|
|
return reply.code(204).send();
|
|
} catch (error: any) {
|
|
logger.error('Error removing saved station', { error, placeId: request.params.placeId, userId: (request as any).user?.sub });
|
|
|
|
if (error.message.includes('not found')) {
|
|
return reply.code(404).send({
|
|
error: 'Not Found',
|
|
message: error.message
|
|
});
|
|
}
|
|
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to remove saved station'
|
|
});
|
|
}
|
|
}
|
|
|
|
async getStationPhoto(request: FastifyRequest<{ Params: { reference: string } }>, reply: FastifyReply) {
|
|
try {
|
|
const { reference } = request.params;
|
|
|
|
if (!reference) {
|
|
return reply.code(400).send({
|
|
error: 'Bad Request',
|
|
message: 'Photo reference is required'
|
|
});
|
|
}
|
|
|
|
const photoBuffer = await googleMapsClient.fetchPhoto(reference);
|
|
|
|
return reply
|
|
.code(200)
|
|
.header('Content-Type', 'image/jpeg')
|
|
.header('Cache-Control', 'public, max-age=86400')
|
|
.send(photoBuffer);
|
|
} catch (error: any) {
|
|
logger.error('Error fetching station photo', {
|
|
error,
|
|
reference: request.params.reference
|
|
});
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to fetch station photo'
|
|
});
|
|
}
|
|
}
|
|
}
|