Security fix: Implement Google Maps API photo proxy (Fix 3)
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>
This commit is contained in:
@@ -23,7 +23,7 @@ interface CreateStationBody {
|
|||||||
pricePremium?: number;
|
pricePremium?: number;
|
||||||
priceDiesel?: number;
|
priceDiesel?: number;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
photoUrl?: string;
|
photoReference?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateStationBody {
|
interface UpdateStationBody {
|
||||||
@@ -35,7 +35,7 @@ interface UpdateStationBody {
|
|||||||
pricePremium?: number;
|
pricePremium?: number;
|
||||||
priceDiesel?: number;
|
priceDiesel?: number;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
photoUrl?: string;
|
photoReference?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StationParams {
|
interface StationParams {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface CreateStationData {
|
|||||||
pricePremium?: number;
|
pricePremium?: number;
|
||||||
priceDiesel?: number;
|
priceDiesel?: number;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
photoUrl?: string;
|
photoReference?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateStationData {
|
interface UpdateStationData {
|
||||||
@@ -32,7 +32,7 @@ interface UpdateStationData {
|
|||||||
pricePremium?: number;
|
pricePremium?: number;
|
||||||
priceDiesel?: number;
|
priceDiesel?: number;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
photoUrl?: string;
|
photoReference?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StationListResult {
|
interface StationListResult {
|
||||||
@@ -63,7 +63,7 @@ export class StationOversightService {
|
|||||||
let dataQuery = `
|
let dataQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
id, place_id, name, address, latitude, longitude,
|
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
|
FROM station_cache
|
||||||
`;
|
`;
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
@@ -114,7 +114,7 @@ export class StationOversightService {
|
|||||||
pricePremium: data.pricePremium,
|
pricePremium: data.pricePremium,
|
||||||
priceDiesel: data.priceDiesel,
|
priceDiesel: data.priceDiesel,
|
||||||
rating: data.rating,
|
rating: data.rating,
|
||||||
photoUrl: data.photoUrl,
|
photoReference: data.photoReference,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.stationsRepository.cacheStation(station);
|
await this.stationsRepository.cacheStation(station);
|
||||||
@@ -198,9 +198,9 @@ export class StationOversightService {
|
|||||||
updates.push(`rating = $${paramIndex++}`);
|
updates.push(`rating = $${paramIndex++}`);
|
||||||
values.push(data.rating);
|
values.push(data.rating);
|
||||||
}
|
}
|
||||||
if (data.photoUrl !== undefined) {
|
if (data.photoReference !== undefined) {
|
||||||
updates.push(`photo_url = $${paramIndex++}`);
|
updates.push(`photo_reference = $${paramIndex++}`);
|
||||||
values.push(data.photoUrl);
|
values.push(data.photoReference);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
@@ -429,7 +429,7 @@ export class StationOversightService {
|
|||||||
pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined,
|
pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined,
|
||||||
priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined,
|
priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined,
|
||||||
rating: row.rating ? parseFloat(row.rating) : undefined,
|
rating: row.rating ? parseFloat(row.rating) : undefined,
|
||||||
photoUrl: row.photo_url,
|
photoReference: row.photo_reference,
|
||||||
lastUpdated: row.cached_at,
|
lastUpdated: row.cached_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
StationParams,
|
StationParams,
|
||||||
UpdateSavedStationBody
|
UpdateSavedStationBody
|
||||||
} from '../domain/stations.types';
|
} from '../domain/stations.types';
|
||||||
|
import { googleMapsClient } from '../external/google-maps/google-maps.client';
|
||||||
|
|
||||||
export class StationsController {
|
export class StationsController {
|
||||||
private stationsService: StationsService;
|
private stationsService: StationsService;
|
||||||
@@ -168,4 +169,34 @@ export class StationsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ export const stationsRoutes: FastifyPluginAsync = async (
|
|||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
handler: stationsController.removeSavedStation.bind(stationsController)
|
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
|
// For backward compatibility during migration
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ export class StationsRepository {
|
|||||||
const query = `
|
const query = `
|
||||||
INSERT INTO station_cache (
|
INSERT INTO station_cache (
|
||||||
place_id, name, address, latitude, longitude,
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (place_id) DO UPDATE
|
ON CONFLICT (place_id) DO UPDATE
|
||||||
SET name = $2, address = $3, latitude = $4, longitude = $5,
|
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, [
|
await this.pool.query(query, [
|
||||||
@@ -30,7 +30,7 @@ export class StationsRepository {
|
|||||||
station.pricePremium,
|
station.pricePremium,
|
||||||
station.priceDiesel,
|
station.priceDiesel,
|
||||||
station.rating,
|
station.rating,
|
||||||
station.photoUrl
|
station.photoReference
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ export class StationsRepository {
|
|||||||
pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined,
|
pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined,
|
||||||
priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined,
|
priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined,
|
||||||
rating: row.rating ? parseFloat(row.rating) : undefined,
|
rating: row.rating ? parseFloat(row.rating) : undefined,
|
||||||
photoUrl: row.photo_url,
|
photoReference: row.photo_reference,
|
||||||
lastUpdated: row.cached_at
|
lastUpdated: row.cached_at
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ Search for gas stations near a location using Google Maps Places API.
|
|||||||
"latitude": 37.7750,
|
"latitude": 37.7750,
|
||||||
"longitude": -122.4195,
|
"longitude": -122.4195,
|
||||||
"rating": 4.2,
|
"rating": 4.2,
|
||||||
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?...",
|
"photoReference": "/api/stations/photo/{reference}",
|
||||||
"distance": 150
|
"distance": 150
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -85,7 +85,7 @@ Search for gas stations near a location using Google Maps Places API.
|
|||||||
| stations[].latitude | number | Station latitude |
|
| stations[].latitude | number | Station latitude |
|
||||||
| stations[].longitude | number | Station longitude |
|
| stations[].longitude | number | Station longitude |
|
||||||
| stations[].rating | number | Google rating (0-5) |
|
| 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) |
|
| stations[].distance | number | Distance from search location (meters) |
|
||||||
| searchLocation | object | Original search coordinates |
|
| searchLocation | object | Original search coordinates |
|
||||||
| searchRadius | number | Actual search radius used |
|
| searchRadius | number | Actual search radius used |
|
||||||
@@ -187,7 +187,7 @@ Save a station to user's favorites with optional metadata.
|
|||||||
"latitude": 37.7750,
|
"latitude": 37.7750,
|
||||||
"longitude": -122.4195,
|
"longitude": -122.4195,
|
||||||
"rating": 4.2,
|
"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,
|
"latitude": 37.7750,
|
||||||
"longitude": -122.4195,
|
"longitude": -122.4195,
|
||||||
"rating": 4.2,
|
"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,
|
"latitude": 37.7750,
|
||||||
"longitude": -122.4195,
|
"longitude": -122.4195,
|
||||||
"rating": 4.2,
|
"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,
|
"latitude": 37.7750,
|
||||||
"longitude": -122.4195,
|
"longitude": -122.4195,
|
||||||
"rating": 4.2,
|
"rating": 4.2,
|
||||||
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..."
|
"photoReference": "/api/stations/photo/{reference}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ export const mockStations = [
|
|||||||
latitude: 37.7750,
|
latitude: 37.7750,
|
||||||
longitude: -122.4195,
|
longitude: -122.4195,
|
||||||
rating: 4.2,
|
rating: 4.2,
|
||||||
photoUrl: 'https://example.com/photo1.jpg',
|
photoReference: 'mock-photo-reference-1',
|
||||||
distance: 150
|
distance: 150
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -481,7 +481,7 @@ export const mockStations = [
|
|||||||
latitude: 37.7755,
|
latitude: 37.7755,
|
||||||
longitude: -122.4190,
|
longitude: -122.4190,
|
||||||
rating: 4.0,
|
rating: 4.0,
|
||||||
photoUrl: null,
|
photoReference: null,
|
||||||
distance: 300
|
distance: 300
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export class StationsService {
|
|||||||
latitude: station?.latitude || 0,
|
latitude: station?.latitude || 0,
|
||||||
longitude: station?.longitude || 0,
|
longitude: station?.longitude || 0,
|
||||||
rating: station?.rating,
|
rating: station?.rating,
|
||||||
photoUrl: station?.photoUrl,
|
photoReference: station?.photoReference,
|
||||||
priceRegular: station?.priceRegular,
|
priceRegular: station?.priceRegular,
|
||||||
pricePremium: station?.pricePremium,
|
pricePremium: station?.pricePremium,
|
||||||
priceDiesel: station?.priceDiesel,
|
priceDiesel: station?.priceDiesel,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface Station {
|
|||||||
distance?: number; // Distance from search point in meters
|
distance?: number; // Distance from search point in meters
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
photoUrl?: string;
|
photoReference?: string;
|
||||||
isSaved?: boolean;
|
isSaved?: boolean;
|
||||||
savedMetadata?: StationSavedMetadata;
|
savedMetadata?: StationSavedMetadata;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export class GoogleMapsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private transformPlaceToStation(place: GooglePlace, searchLat: number, searchLng: number): Station {
|
private transformPlaceToStation(place: GooglePlace, searchLat: number, searchLng: number): Station {
|
||||||
// Calculate distance from search point
|
|
||||||
const distance = this.calculateDistance(
|
const distance = this.calculateDistance(
|
||||||
searchLat,
|
searchLat,
|
||||||
searchLng,
|
searchLng,
|
||||||
@@ -73,10 +72,10 @@ export class GoogleMapsClient {
|
|||||||
place.geometry.location.lng
|
place.geometry.location.lng
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate photo URL if available
|
// Store photo reference instead of full URL to avoid exposing API key
|
||||||
let photoUrl: string | undefined;
|
let photoReference: string | undefined;
|
||||||
if (place.photos && place.photos.length > 0 && place.photos[0]) {
|
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 = {
|
const station: Station = {
|
||||||
@@ -89,9 +88,8 @@ export class GoogleMapsClient {
|
|||||||
distance
|
distance
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only set optional properties if defined
|
if (photoReference !== undefined) {
|
||||||
if (photoUrl !== undefined) {
|
station.photoReference = photoReference;
|
||||||
station.photoUrl = photoUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (place.opening_hours?.open_now !== undefined) {
|
if (place.opening_hours?.open_now !== undefined) {
|
||||||
@@ -105,6 +103,31 @@ export class GoogleMapsClient {
|
|||||||
return station;
|
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<Buffer> {
|
||||||
|
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 {
|
private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
const R = 6371e3; // Earth's radius in meters
|
const R = 6371e3; // Earth's radius in meters
|
||||||
const φ1 = lat1 * Math.PI / 180;
|
const φ1 = lat1 * Math.PI / 180;
|
||||||
|
|||||||
@@ -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)';
|
||||||
@@ -14,7 +14,7 @@ export const mockStations: Station[] = [
|
|||||||
longitude: -122.4194,
|
longitude: -122.4194,
|
||||||
rating: 4.2,
|
rating: 4.2,
|
||||||
distance: 250,
|
distance: 250,
|
||||||
photoUrl: 'https://example.com/shell-downtown.jpg',
|
photoReference: 'mock-photo-reference-shell',
|
||||||
priceRegular: 4.29,
|
priceRegular: 4.29,
|
||||||
pricePremium: 4.79,
|
pricePremium: 4.79,
|
||||||
priceDiesel: 4.49
|
priceDiesel: 4.49
|
||||||
@@ -28,7 +28,7 @@ export const mockStations: Station[] = [
|
|||||||
longitude: -122.3989,
|
longitude: -122.3989,
|
||||||
rating: 4.5,
|
rating: 4.5,
|
||||||
distance: 1200,
|
distance: 1200,
|
||||||
photoUrl: 'https://example.com/chevron-fd.jpg',
|
photoReference: 'mock-photo-reference-chevron',
|
||||||
priceRegular: 4.39,
|
priceRegular: 4.39,
|
||||||
pricePremium: 4.89
|
pricePremium: 4.89
|
||||||
},
|
},
|
||||||
@@ -41,7 +41,7 @@ export const mockStations: Station[] = [
|
|||||||
longitude: -122.4148,
|
longitude: -122.4148,
|
||||||
rating: 3.8,
|
rating: 3.8,
|
||||||
distance: 1850,
|
distance: 1850,
|
||||||
photoUrl: 'https://example.com/exxon-mission.jpg',
|
photoReference: 'mock-photo-reference-exxon',
|
||||||
priceRegular: 4.19,
|
priceRegular: 4.19,
|
||||||
priceDiesel: 4.39
|
priceDiesel: 4.39
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const mockStation: Station = {
|
|||||||
longitude: -122.4194,
|
longitude: -122.4194,
|
||||||
rating: 4.2,
|
rating: 4.2,
|
||||||
distance: 250,
|
distance: 250,
|
||||||
photoUrl: 'https://example.com/photo.jpg'
|
photoReference: 'mock-photo-reference'
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('StationCard', () => {
|
describe('StationCard', () => {
|
||||||
@@ -38,7 +38,7 @@ describe('StationCard', () => {
|
|||||||
|
|
||||||
const photo = screen.getByAltText('Shell Gas Station');
|
const photo = screen.getByAltText('Shell Gas Station');
|
||||||
expect(photo).toBeInTheDocument();
|
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', () => {
|
it('should render rating when available', () => {
|
||||||
@@ -54,7 +54,7 @@ describe('StationCard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not crash when photo is missing', () => {
|
it('should not crash when photo is missing', () => {
|
||||||
const stationWithoutPhoto = { ...mockStation, photoUrl: undefined };
|
const stationWithoutPhoto = { ...mockStation, photoReference: undefined };
|
||||||
render(<StationCard station={stationWithoutPhoto} isSaved={false} />);
|
render(<StationCard station={stationWithoutPhoto} isSaved={false} />);
|
||||||
|
|
||||||
expect(screen.getByText('Shell Gas Station')).toBeInTheDocument();
|
expect(screen.getByText('Shell Gas Station')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
|
|||||||
import DirectionsIcon from '@mui/icons-material/Directions';
|
import DirectionsIcon from '@mui/icons-material/Directions';
|
||||||
import { Station, SavedStation } from '../types/stations.types';
|
import { Station, SavedStation } from '../types/stations.types';
|
||||||
import { formatDistance } from '../utils/distance';
|
import { formatDistance } from '../utils/distance';
|
||||||
|
import { getStationPhotoUrl } from '../utils/photo-utils';
|
||||||
|
|
||||||
interface StationCardProps {
|
interface StationCardProps {
|
||||||
station: Station;
|
station: Station;
|
||||||
@@ -83,11 +84,11 @@ export const StationCard: React.FC<StationCardProps> = ({
|
|||||||
flexDirection: 'column'
|
flexDirection: 'column'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{station.photoUrl && (
|
{station.photoReference && (
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
height="200"
|
height="200"
|
||||||
image={station.photoUrl}
|
image={getStationPhotoUrl(station.photoReference)}
|
||||||
alt={station.name}
|
alt={station.name}
|
||||||
sx={{ objectFit: 'cover' }}
|
sx={{ objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ export interface Station {
|
|||||||
rating: number;
|
rating: number;
|
||||||
/** Distance from search location in meters */
|
/** Distance from search location in meters */
|
||||||
distance?: number;
|
distance?: number;
|
||||||
/** URL to station photo if available */
|
/** Photo reference for station photo if available */
|
||||||
photoUrl?: string;
|
photoReference?: string;
|
||||||
/** Whether the station is saved for the user */
|
/** Whether the station is saved for the user */
|
||||||
isSaved?: boolean;
|
isSaved?: boolean;
|
||||||
/** Saved-station metadata if applicable */
|
/** Saved-station metadata if applicable */
|
||||||
|
|||||||
15
frontend/src/features/stations/utils/photo-utils.ts
Normal file
15
frontend/src/features/stations/utils/photo-utils.ts
Normal file
@@ -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)}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user