mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
cc79f561e5
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.
323 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|