diff --git a/backend/src/features/admin/api/stations.controller.ts b/backend/src/features/admin/api/stations.controller.ts index f528d9c..17ec3b1 100644 --- a/backend/src/features/admin/api/stations.controller.ts +++ b/backend/src/features/admin/api/stations.controller.ts @@ -23,7 +23,7 @@ interface CreateStationBody { pricePremium?: number; priceDiesel?: number; rating?: number; - photoUrl?: string; + photoReference?: string; } interface UpdateStationBody { @@ -35,7 +35,7 @@ interface UpdateStationBody { pricePremium?: number; priceDiesel?: number; rating?: number; - photoUrl?: string; + photoReference?: string; } interface StationParams { diff --git a/backend/src/features/admin/domain/station-oversight.service.ts b/backend/src/features/admin/domain/station-oversight.service.ts index 9dfef98..14a61db 100644 --- a/backend/src/features/admin/domain/station-oversight.service.ts +++ b/backend/src/features/admin/domain/station-oversight.service.ts @@ -20,7 +20,7 @@ interface CreateStationData { pricePremium?: number; priceDiesel?: number; rating?: number; - photoUrl?: string; + photoReference?: string; } interface UpdateStationData { @@ -32,7 +32,7 @@ interface UpdateStationData { pricePremium?: number; priceDiesel?: number; rating?: number; - photoUrl?: string; + photoReference?: string; } interface StationListResult { @@ -63,7 +63,7 @@ export class StationOversightService { let dataQuery = ` SELECT id, place_id, name, address, latitude, longitude, - price_regular, price_premium, price_diesel, rating, photo_url, cached_at + price_regular, price_premium, price_diesel, rating, photo_reference, cached_at FROM station_cache `; const params: any[] = []; @@ -114,7 +114,7 @@ export class StationOversightService { pricePremium: data.pricePremium, priceDiesel: data.priceDiesel, rating: data.rating, - photoUrl: data.photoUrl, + photoReference: data.photoReference, }; await this.stationsRepository.cacheStation(station); @@ -198,9 +198,9 @@ export class StationOversightService { updates.push(`rating = $${paramIndex++}`); values.push(data.rating); } - if (data.photoUrl !== undefined) { - updates.push(`photo_url = $${paramIndex++}`); - values.push(data.photoUrl); + if (data.photoReference !== undefined) { + updates.push(`photo_reference = $${paramIndex++}`); + values.push(data.photoReference); } if (updates.length === 0) { @@ -429,7 +429,7 @@ export class StationOversightService { pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined, priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined, rating: row.rating ? parseFloat(row.rating) : undefined, - photoUrl: row.photo_url, + photoReference: row.photo_reference, lastUpdated: row.cached_at, }; } diff --git a/backend/src/features/stations/api/stations.controller.ts b/backend/src/features/stations/api/stations.controller.ts index 0daade7..e4afaf5 100644 --- a/backend/src/features/stations/api/stations.controller.ts +++ b/backend/src/features/stations/api/stations.controller.ts @@ -14,6 +14,7 @@ import { StationParams, UpdateSavedStationBody } from '../domain/stations.types'; +import { googleMapsClient } from '../external/google-maps/google-maps.client'; export class StationsController { private stationsService: StationsService; @@ -148,24 +149,54 @@ export class StationsController { 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' + }); + } + } } diff --git a/backend/src/features/stations/api/stations.routes.ts b/backend/src/features/stations/api/stations.routes.ts index f0efdf5..a744d7d 100644 --- a/backend/src/features/stations/api/stations.routes.ts +++ b/backend/src/features/stations/api/stations.routes.ts @@ -51,6 +51,12 @@ export const stationsRoutes: FastifyPluginAsync = async ( preHandler: [fastify.authenticate], handler: stationsController.removeSavedStation.bind(stationsController) }); + + // GET /api/stations/photo/:reference - Proxy for Google Maps photos + fastify.get<{ Params: { reference: string } }>('/stations/photo/:reference', { + preHandler: [fastify.authenticate], + handler: stationsController.getStationPhoto.bind(stationsController) + }); }; // For backward compatibility during migration diff --git a/backend/src/features/stations/data/stations.repository.ts b/backend/src/features/stations/data/stations.repository.ts index 7fc41e3..28b33b6 100644 --- a/backend/src/features/stations/data/stations.repository.ts +++ b/backend/src/features/stations/data/stations.repository.ts @@ -12,14 +12,14 @@ export class StationsRepository { const query = ` INSERT INTO station_cache ( place_id, name, address, latitude, longitude, - price_regular, price_premium, price_diesel, rating, photo_url + price_regular, price_premium, price_diesel, rating, photo_reference ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (place_id) DO UPDATE SET name = $2, address = $3, latitude = $4, longitude = $5, - rating = $9, photo_url = $10, cached_at = NOW() + rating = $9, photo_reference = $10, cached_at = NOW() `; - + await this.pool.query(query, [ station.placeId, station.name, @@ -30,7 +30,7 @@ export class StationsRepository { station.pricePremium, station.priceDiesel, station.rating, - station.photoUrl + station.photoReference ]); } @@ -166,7 +166,7 @@ export class StationsRepository { pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined, priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined, rating: row.rating ? parseFloat(row.rating) : undefined, - photoUrl: row.photo_url, + photoReference: row.photo_reference, lastUpdated: row.cached_at }; } diff --git a/backend/src/features/stations/docs/API.md b/backend/src/features/stations/docs/API.md index fdbbeca..89a202c 100644 --- a/backend/src/features/stations/docs/API.md +++ b/backend/src/features/stations/docs/API.md @@ -62,7 +62,7 @@ Search for gas stations near a location using Google Maps Places API. "latitude": 37.7750, "longitude": -122.4195, "rating": 4.2, - "photoUrl": "https://maps.googleapis.com/maps/api/place/photo?...", + "photoReference": "/api/stations/photo/{reference}", "distance": 150 } ], @@ -85,7 +85,7 @@ Search for gas stations near a location using Google Maps Places API. | stations[].latitude | number | Station latitude | | stations[].longitude | number | Station longitude | | stations[].rating | number | Google rating (0-5) | -| stations[].photoUrl | string | Photo URL (nullable) | +| stations[].photoReference | string | Photo URL (nullable) | | stations[].distance | number | Distance from search location (meters) | | searchLocation | object | Original search coordinates | | searchRadius | number | Actual search radius used | @@ -187,7 +187,7 @@ Save a station to user's favorites with optional metadata. "latitude": 37.7750, "longitude": -122.4195, "rating": 4.2, - "photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..." + "photoReference": "/api/stations/photo/{reference}" } } ``` @@ -286,7 +286,7 @@ Retrieve all stations saved by the authenticated user. "latitude": 37.7750, "longitude": -122.4195, "rating": 4.2, - "photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..." + "photoReference": "/api/stations/photo/{reference}" } } ] @@ -355,7 +355,7 @@ Retrieve a specific saved station by Google Place ID. "latitude": 37.7750, "longitude": -122.4195, "rating": 4.2, - "photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..." + "photoReference": "/api/stations/photo/{reference}" } } ``` @@ -437,7 +437,7 @@ Update metadata for a saved station. "latitude": 37.7750, "longitude": -122.4195, "rating": 4.2, - "photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..." + "photoReference": "/api/stations/photo/{reference}" } } ``` diff --git a/backend/src/features/stations/docs/TESTING.md b/backend/src/features/stations/docs/TESTING.md index 497b389..a594f76 100644 --- a/backend/src/features/stations/docs/TESTING.md +++ b/backend/src/features/stations/docs/TESTING.md @@ -471,7 +471,7 @@ export const mockStations = [ latitude: 37.7750, longitude: -122.4195, rating: 4.2, - photoUrl: 'https://example.com/photo1.jpg', + photoReference: 'mock-photo-reference-1', distance: 150 }, { @@ -481,7 +481,7 @@ export const mockStations = [ latitude: 37.7755, longitude: -122.4190, rating: 4.0, - photoUrl: null, + photoReference: null, distance: 300 } ]; diff --git a/backend/src/features/stations/domain/stations.service.ts b/backend/src/features/stations/domain/stations.service.ts index 9ed5162..56746e0 100644 --- a/backend/src/features/stations/domain/stations.service.ts +++ b/backend/src/features/stations/domain/stations.service.ts @@ -141,7 +141,7 @@ export class StationsService { latitude: station?.latitude || 0, longitude: station?.longitude || 0, rating: station?.rating, - photoUrl: station?.photoUrl, + photoReference: station?.photoReference, priceRegular: station?.priceRegular, pricePremium: station?.pricePremium, priceDiesel: station?.priceDiesel, diff --git a/backend/src/features/stations/domain/stations.types.ts b/backend/src/features/stations/domain/stations.types.ts index 33f47c5..435ab5e 100644 --- a/backend/src/features/stations/domain/stations.types.ts +++ b/backend/src/features/stations/domain/stations.types.ts @@ -17,7 +17,7 @@ export interface Station { distance?: number; // Distance from search point in meters isOpen?: boolean; rating?: number; - photoUrl?: string; + photoReference?: string; isSaved?: boolean; savedMetadata?: StationSavedMetadata; } diff --git a/backend/src/features/stations/external/google-maps/google-maps.client.ts b/backend/src/features/stations/external/google-maps/google-maps.client.ts index bf150f3..780375d 100644 --- a/backend/src/features/stations/external/google-maps/google-maps.client.ts +++ b/backend/src/features/stations/external/google-maps/google-maps.client.ts @@ -65,7 +65,6 @@ export class GoogleMapsClient { } private transformPlaceToStation(place: GooglePlace, searchLat: number, searchLng: number): Station { - // Calculate distance from search point const distance = this.calculateDistance( searchLat, searchLng, @@ -73,10 +72,10 @@ export class GoogleMapsClient { place.geometry.location.lng ); - // Generate photo URL if available - let photoUrl: string | undefined; + // Store photo reference instead of full URL to avoid exposing API key + let photoReference: string | undefined; if (place.photos && place.photos.length > 0 && place.photos[0]) { - photoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photo_reference=${place.photos[0].photo_reference}&key=${this.apiKey}`; + photoReference = place.photos[0].photo_reference; } const station: Station = { @@ -89,9 +88,8 @@ export class GoogleMapsClient { distance }; - // Only set optional properties if defined - if (photoUrl !== undefined) { - station.photoUrl = photoUrl; + if (photoReference !== undefined) { + station.photoReference = photoReference; } if (place.opening_hours?.open_now !== undefined) { @@ -105,6 +103,31 @@ export class GoogleMapsClient { return station; } + /** + * Fetch photo from Google Maps API using photo reference + * Used by photo proxy endpoint to serve photos without exposing API key + */ + async fetchPhoto(photoReference: string, maxWidth: number = 400): Promise { + try { + const response = await axios.get( + `${this.baseURL}/photo`, + { + params: { + photo_reference: photoReference, + maxwidth: maxWidth, + key: this.apiKey + }, + responseType: 'arraybuffer' + } + ); + + return Buffer.from(response.data); + } catch (error) { + logger.error('Failed to fetch photo from Google Maps', { error, photoReference }); + throw error; + } + } + private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371e3; // Earth's radius in meters const φ1 = lat1 * Math.PI / 180; diff --git a/backend/src/features/stations/migrations/003_rename_photo_url_to_photo_reference.sql b/backend/src/features/stations/migrations/003_rename_photo_url_to_photo_reference.sql new file mode 100644 index 0000000..bd252b5 --- /dev/null +++ b/backend/src/features/stations/migrations/003_rename_photo_url_to_photo_reference.sql @@ -0,0 +1,8 @@ +-- Rename photo_url column to photo_reference in station_cache table +-- This is part of the security fix to prevent API key exposure + +ALTER TABLE station_cache +RENAME COLUMN photo_url TO photo_reference; + +-- Add comment explaining the column stores references, not full URLs +COMMENT ON COLUMN station_cache.photo_reference IS 'Google Maps photo reference token (not full URL)'; diff --git a/backend/src/features/stations/tests/fixtures/mock-stations.ts b/backend/src/features/stations/tests/fixtures/mock-stations.ts index 8a3c9f0..accf055 100644 --- a/backend/src/features/stations/tests/fixtures/mock-stations.ts +++ b/backend/src/features/stations/tests/fixtures/mock-stations.ts @@ -14,7 +14,7 @@ export const mockStations: Station[] = [ longitude: -122.4194, rating: 4.2, distance: 250, - photoUrl: 'https://example.com/shell-downtown.jpg', + photoReference: 'mock-photo-reference-shell', priceRegular: 4.29, pricePremium: 4.79, priceDiesel: 4.49 @@ -28,7 +28,7 @@ export const mockStations: Station[] = [ longitude: -122.3989, rating: 4.5, distance: 1200, - photoUrl: 'https://example.com/chevron-fd.jpg', + photoReference: 'mock-photo-reference-chevron', priceRegular: 4.39, pricePremium: 4.89 }, @@ -41,7 +41,7 @@ export const mockStations: Station[] = [ longitude: -122.4148, rating: 3.8, distance: 1850, - photoUrl: 'https://example.com/exxon-mission.jpg', + photoReference: 'mock-photo-reference-exxon', priceRegular: 4.19, priceDiesel: 4.39 } diff --git a/frontend/src/features/stations/__tests__/components/StationCard.test.tsx b/frontend/src/features/stations/__tests__/components/StationCard.test.tsx index 31cadec..2340f48 100644 --- a/frontend/src/features/stations/__tests__/components/StationCard.test.tsx +++ b/frontend/src/features/stations/__tests__/components/StationCard.test.tsx @@ -16,7 +16,7 @@ const mockStation: Station = { longitude: -122.4194, rating: 4.2, distance: 250, - photoUrl: 'https://example.com/photo.jpg' + photoReference: 'mock-photo-reference' }; describe('StationCard', () => { @@ -38,7 +38,7 @@ describe('StationCard', () => { const photo = screen.getByAltText('Shell Gas Station'); expect(photo).toBeInTheDocument(); - expect(photo).toHaveAttribute('src', 'https://example.com/photo.jpg'); + expect(photo).toHaveAttribute('src', '/api/stations/photo/mock-photo-reference'); }); it('should render rating when available', () => { @@ -54,7 +54,7 @@ describe('StationCard', () => { }); it('should not crash when photo is missing', () => { - const stationWithoutPhoto = { ...mockStation, photoUrl: undefined }; + const stationWithoutPhoto = { ...mockStation, photoReference: undefined }; render(); expect(screen.getByText('Shell Gas Station')).toBeInTheDocument(); diff --git a/frontend/src/features/stations/components/StationCard.tsx b/frontend/src/features/stations/components/StationCard.tsx index c354c56..efd7776 100644 --- a/frontend/src/features/stations/components/StationCard.tsx +++ b/frontend/src/features/stations/components/StationCard.tsx @@ -18,6 +18,7 @@ import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder'; import DirectionsIcon from '@mui/icons-material/Directions'; import { Station, SavedStation } from '../types/stations.types'; import { formatDistance } from '../utils/distance'; +import { getStationPhotoUrl } from '../utils/photo-utils'; interface StationCardProps { station: Station; @@ -83,11 +84,11 @@ export const StationCard: React.FC = ({ flexDirection: 'column' }} > - {station.photoUrl && ( + {station.photoReference && ( diff --git a/frontend/src/features/stations/types/stations.types.ts b/frontend/src/features/stations/types/stations.types.ts index bd3d2a3..284d168 100644 --- a/frontend/src/features/stations/types/stations.types.ts +++ b/frontend/src/features/stations/types/stations.types.ts @@ -56,8 +56,8 @@ export interface Station { rating: number; /** Distance from search location in meters */ distance?: number; - /** URL to station photo if available */ - photoUrl?: string; + /** Photo reference for station photo if available */ + photoReference?: string; /** Whether the station is saved for the user */ isSaved?: boolean; /** Saved-station metadata if applicable */ diff --git a/frontend/src/features/stations/utils/photo-utils.ts b/frontend/src/features/stations/utils/photo-utils.ts new file mode 100644 index 0000000..035665e --- /dev/null +++ b/frontend/src/features/stations/utils/photo-utils.ts @@ -0,0 +1,15 @@ +/** + * @ai-summary Helper utilities for station photos + */ + +/** + * Generate secure photo URL using backend proxy endpoint + * This prevents exposing the Google Maps API key to the frontend + */ +export function getStationPhotoUrl(photoReference: string | undefined): string | undefined { + if (!photoReference) { + return undefined; + } + + return `/api/stations/photo/${encodeURIComponent(photoReference)}`; +}