Files
TREK/server/src/nest/maps/maps.controller.ts
T
Maurice cc79f561e5 refactor(auth): guards, auth decorators and ephemeral tokens move into an auth-core kernel
JwtAuthGuard, OptionalJwtAuthGuard, CookieAuthGuard, AdminGuard, the global
and MFA guards, @CurrentUser, @Public, JWT verification and the ephemeral
token service lived in the auth domain, so every controller that checked a
session depended on the whole account module and its imports. They import
no domain themselves; under src/nest/auth-core they are shared kernel like
common/, every importer points there directly, and AtlasModule and
NotificationsModule stop importing AuthModule, which they only did for the
demo gate. lint:boundaries counts auth-core as kernel: 57 fewer cyclic
domain edges, the largest cycle shrinks from 46 to 40 domains.
2026-10-10 20:15:58 +02:00

323 lines
12 KiB
TypeScript

import {
Body,
Controller,
Get,
HttpCode,
HttpException,
Param,
Post,
Query,
Res,
UseGuards,
} from '@nestjs/common';
import type { Response } from 'express';
import type { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type {
MapsAutocompleteResult,
MapsPlaceDetailsResult,
MapsPlacePhotoResult,
MapsResolveUrlResult,
MapsReverseResult,
MapsSearchResult,
} from '@trek/shared';
import type { User } from '../../types';
import { MapsService } from './maps.service';
import { StorageService } from '../storage/storage.service';
import { isClientAbortError } from '../storage/storage.types';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { MapsSearchDto, MapsNearbyDto, MapsAutocompleteDto, MapsResolveUrlDto } from './maps.dto';
/** Google's session-token shape: URL-safe ASCII, at most 36 characters. The
* autocomplete body is validated by the Zod pipe; the details query is not,
* so it is checked here rather than forwarded blindly. */
const SESSION_TOKEN = /^[A-Za-z0-9_-]{1,36}$/;
/** Maps a thrown service error to the same status + { error } body Express sent. */
function toHttpException(err: unknown, fallbackMessage: string, defaultStatus: number): HttpException {
const status = (err as { status?: number }).status || defaultStatus;
const message = err instanceof Error ? err.message : fallbackMessage;
return new HttpException({ error: message }, status);
}
/**
* /api/maps — place search, autocomplete, details, photos, reverse geocoding and
* Google-Maps-URL resolution.
*
* Behaviour matches the legacy Express route (server/src/routes/maps.ts): same
* auth, same per-endpoint kill-switch short-circuits, same error status/body
* mapping, same diagnostic logging, and the same bespoke 400s for non-body
* validation (reverse's query params). The SSRF guard lives in the underlying
* service and is reused unchanged.
*
* Bodies are validated against the @trek/shared maps schemas via maps.dto.ts
* (global ZodValidationPipe). This replaced the legacy bespoke 400s ('Search
* query is required', 'Input is required', 'Input too long (max 200 chars)',
* 'URL is required', the two 'Invalid locationBias: …' messages) with the
* pipe's uniform { error: 'field: message; …' } envelope — and the pipe runs
* before the handler, so an invalid autocomplete body now 400s even while the
* autocomplete kill-switch is on (the legacy route answered the disabled
* envelope first).
*/
@Controller('api/maps')
@UseGuards(JwtAuthGuard)
export class MapsController {
constructor(
private readonly maps: MapsService,
private readonly storage: StorageService,
) {}
@Post('search')
@HttpCode(200) // Express answers with res.json (200); Nest would otherwise default POST to 201.
async search(
@CurrentUser() user: User,
@Body() body: MapsSearchDto,
@Query('lang') lang?: string,
): Promise<MapsSearchResult> {
try {
return await this.maps.search(user.id, body.query, lang, body.locationBias, body.provider);
} catch (err: unknown) {
console.error('Maps search error:', err);
throw toHttpException(err, 'Search error', 500);
}
}
// Places of any kind around a point, nearest first (#976).
@Post('nearby')
@HttpCode(200)
async nearby(
@CurrentUser() user: User,
@Body() body: MapsNearbyDto,
@Query('lang') lang?: string,
): Promise<MapsSearchResult> {
try {
return await this.maps.nearbyPlaces(user.id, body.lat, body.lng, { radius: body.radius, limit: body.limit, lang });
} catch (err: unknown) {
throw toHttpException(err, 'Nearby search error', 500);
}
}
// OSM-only POI explore: places of a category within the current map viewport.
@Get('pois')
async pois(
@Query('category') category?: string,
@Query('south') south?: string,
@Query('west') west?: string,
@Query('north') north?: string,
@Query('east') east?: string,
@Query('lang') lang?: string,
) {
if (!category) throw new HttpException({ error: 'A category is required' }, 400);
const bbox = { south: Number(south), west: Number(west), north: Number(north), east: Number(east) };
if (Object.values(bbox).some(v => !Number.isFinite(v))) {
throw new HttpException({ error: 'A valid bbox (south, west, north, east) is required' }, 400);
}
try {
return await this.maps.pois(category, bbox, lang);
} catch (err: unknown) {
throw toHttpException(err, 'POI search error', 500);
}
}
/**
* Every place in a box, for the offline cache. Read-only and cheap.
*
* Not a browse endpoint: the index caps the box at 1.5 degrees a side, and
* this passes the cap's refusal straight through rather than paging around
* it. The honest use is one trip's area, taken once.
*/
@Get('area')
async area(
@Query('minLat') minLat?: string,
@Query('minLng') minLng?: string,
@Query('maxLat') maxLat?: string,
@Query('maxLng') maxLng?: string,
@Query('limit') limit?: string,
) {
const bbox = {
minLat: Number(minLat),
minLng: Number(minLng),
maxLat: Number(maxLat),
maxLng: Number(maxLng),
};
if (Object.values(bbox).some((v) => !Number.isFinite(v))) {
throw new HttpException({ error: 'A valid bbox (minLat, minLng, maxLat, maxLng) is required' }, 400);
}
const capped = Math.min(Math.max(Number(limit) || 2000, 1), 5000);
const area = await this.maps.placesInArea(bbox, capped);
// A null means the index is off or unreachable. An empty area is not an
// error — the caller caches nothing and carries on.
return area ?? { results: [], truncated: false, unavailable: true };
}
@Post('autocomplete')
@HttpCode(200)
async autocomplete(
@CurrentUser() user: User,
@Body() body: MapsAutocompleteDto,
): Promise<MapsAutocompleteResult | { suggestions: never[]; source: string }> {
if (await this.maps.autocompleteDisabled()) {
return { suggestions: [], source: 'disabled' };
}
try {
return await this.maps.autocomplete(user.id, body.input, body.lang, body.locationBias, body.sessionToken);
} catch (err: unknown) {
console.error('Maps autocomplete error:', err);
throw toHttpException(err, 'Autocomplete error', 500);
}
}
@Get('details/:placeId')
async details(
@CurrentUser() user: User,
@Param('placeId') placeId: string,
@Query('expand') expand?: string,
@Query('lang') lang?: string,
@Query('refresh') refresh?: string,
// Closes the autocomplete session this lookup came from. Only forwarded when
// it matches Google's shape, so a junk value bills per request instead of
// breaking the lookup.
@Query('sessionToken') sessionToken?: string,
): Promise<MapsPlaceDetailsResult> {
if (await this.maps.detailsDisabled()) {
return { place: null, disabled: true };
}
try {
return expand
? await this.maps.detailsExpanded(user.id, placeId, lang, refresh === '1')
: await this.maps.details(user.id, placeId, lang, SESSION_TOKEN.test(sessionToken ?? '') ? sessionToken : undefined);
} catch (err: unknown) {
console.error('Maps details error:', err);
throw toHttpException(err, 'Error fetching place details', 500);
}
}
@Get('place-photo/:placeId')
async placePhoto(
@CurrentUser() user: User,
@Param('placeId') placeId: string,
@Query('lat') lat?: string,
@Query('lng') lng?: string,
@Query('name') name?: string,
): Promise<MapsPlacePhotoResult | { photoUrl: null }> {
// Kill-switch only applies to Google Places fetches — Wikimedia (coords:) stays allowed.
if (!placeId.startsWith('coords:') && (await this.maps.photosDisabled())) {
return { photoUrl: null };
}
// A place with no photo resolves to the same { photoUrl: null } body. It is an
// empty result, not a missing resource, and one 404 per photo-less place gets
// the user's IP banned by any 404-rate IPS in front of TREK (#1727).
try {
return await this.maps.photo(user.id, placeId, Number.parseFloat(lat as string), Number.parseFloat(lng as string), name);
} catch (err: unknown) {
const status = (err as { status?: number }).status || 500;
if (status >= 500) console.error('Place photo error:', err);
throw toHttpException(err, 'Error fetching photo', 500);
}
}
@Get('place-photo/:placeId/bytes')
async placePhotoBytes(@Param('placeId') placeId: string, @Res() res: Response): Promise<void> {
const key = await this.maps.photoBytesKey(placeId);
if (!key) {
// Same reasoning as the JSON endpoint above, and the bigger half of #1727:
// places keep this URL in image_url, so a trip render asks for one photo per
// place and every entry the cache sweep dropped answered 404 — a whole map
// worth of them from one IP in one second. An empty 204 leaves the <img>
// with nothing to show, exactly like the 404 did.
this.emptyPhoto(res);
return;
}
// Bytes are stream-piped (spec §Serving pins this route's stream contract);
// headers go on before the stream attempt, matching the old createReadStream
// ordering — an early failure overrides them via emptyPhoto exactly as the
// old error event did. Cached photos are always JPEG (placePhotoCache
// writes `<hash>.jpg`).
res.set('Cache-Control', 'public, max-age=2592000, immutable');
res.type('image/jpeg');
let stream: Readable;
try {
({ stream } = await this.storage.getStream('photos-google', key));
} catch {
// Cache-delete race — same terminal state as the old stream error path.
if (!res.headersSent) this.emptyPhoto(res);
return;
}
try {
// pipeline destroys the source on any failure (no leaked read handle),
// matching storage.service.ts's sendToResponse contract.
await pipeline(stream, res);
} catch (err) {
if (!res.headersSent) {
// Same terminal state as the old pre-flush stream error path.
this.emptyPhoto(res);
return;
}
// Bytes are already on the wire — a client abort mid-download is the
// client's problem now, not ours; a real source error still surfaces
// (to the exception filter, which now no-ops safely on headersSent).
if (!isClientAbortError(err)) throw err;
}
}
/**
* A brand's logo, by Wikidata id — what makes a corridor full of petrol stations
* readable at a glance.
*
* Proxied rather than linked: the browser never announces to Wikimedia which brands
* a user is looking at, and one instance asking for a handful of logos is a very
* different egress profile from every visitor doing it. An unknown or logo-less
* brand answers 204, like the photo route above and for the same reason — a marker
* per petrol station would otherwise be a burst of 404s from one address.
*/
@Get('brand-logo/:wikidataId')
async brandLogo(@Param('wikidataId') wikidataId: string, @Res() res: Response): Promise<void> {
const logo = await this.maps.brandLogo(wikidataId);
if (!logo) {
this.emptyPhoto(res);
return;
}
res.set('Cache-Control', 'public, max-age=2592000, immutable');
res.type(logo.contentType);
res.send(logo.bytes);
}
// 204 for "no bytes to serve". Overrides the immutable Cache-Control the hit
// path already set — a photo that reappears in the cache must not stay hidden
// behind a month-old empty response.
private emptyPhoto(res: Response): void {
res.set('Cache-Control', 'no-store');
res.status(204).end();
}
@Get('reverse')
async reverse(
@Query('lat') lat?: string,
@Query('lng') lng?: string,
@Query('lang') lang?: string,
): Promise<MapsReverseResult> {
if (!lat || !lng) {
throw new HttpException({ error: 'lat and lng required' }, 400);
}
try {
return await this.maps.reverse(lat, lng, lang);
} catch {
// The legacy route swallows reverse-geocode failures into an empty result.
return { name: null, address: null };
}
}
@Post('resolve-url')
@HttpCode(200)
async resolveUrl(@Body() body: MapsResolveUrlDto): Promise<MapsResolveUrlResult> {
try {
return await this.maps.resolveUrl(body.url);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to resolve URL';
console.error('[Maps] URL resolve error:', message);
throw toHttpException(err, 'Failed to resolve URL', 400);
}
}
}