style(server): format the files the health lanes added or moved

Prettier on every new or moved file that was not formatted, so lint:format
holds them; the files taken off the format baseline are the ones that moved
or were formatted on the way.
This commit is contained in:
Maurice
2026-10-10 14:25:09 +02:00
parent a28044dbd4
commit 2c3fb15263
81 changed files with 1656 additions and 684 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
import { z } from 'zod';
import type { JsonColumn } from '../utils/json-column';
import { z } from 'zod';
/**
* The JSON-in-TEXT columns the services decode, one declaration each: the shape
* the column holds and what a reader gets when a row is empty or broken. Read
@@ -1,7 +1,7 @@
import type { EntityManager } from '@mikro-orm/core';
import type { SqlEntityManager } from '@mikro-orm/sql';
import { Users } from '../entities/Users.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { EntityManager } from '@mikro-orm/core';
import type { SqlEntityManager } from '@mikro-orm/sql';
/**
* The AirTrail connection a user keeps on their own `users` row
@@ -57,10 +57,21 @@ export class UserAirtrailRepository extends TrekRepository<Users> {
* wrap its writes in `uow.transactional`): preserved exactly, not
* "fixed" to match Dawarich's shape.
*/
async setAirtrailSettingsWithKey(id: number, url: string | null, apiKey: string, allowInsecureTls: number, writeEnabled: number): Promise<void> {
async setAirtrailSettingsWithKey(
id: number,
url: string | null,
apiKey: string,
allowInsecureTls: number,
writeEnabled: number,
): Promise<void> {
await this.nativeUpdate(
{ id },
{ airtrail_url: url, airtrail_api_key: apiKey, airtrail_allow_insecure_tls: allowInsecureTls, airtrail_write_enabled: writeEnabled },
{
airtrail_url: url,
airtrail_api_key: apiKey,
airtrail_allow_insecure_tls: allowInsecureTls,
airtrail_write_enabled: writeEnabled,
},
);
}
@@ -70,8 +81,16 @@ export class UserAirtrailRepository extends TrekRepository<Users> {
* airtrail_write_enabled = ? WHERE id = ?`. Same no-transaction asymmetry
* as {@link setAirtrailSettingsWithKey} — preserved, not fixed.
*/
async setAirtrailSettings(id: number, url: string | null, allowInsecureTls: number, writeEnabled: number): Promise<void> {
await this.nativeUpdate({ id }, { airtrail_url: url, airtrail_allow_insecure_tls: allowInsecureTls, airtrail_write_enabled: writeEnabled });
async setAirtrailSettings(
id: number,
url: string | null,
allowInsecureTls: number,
writeEnabled: number,
): Promise<void> {
await this.nativeUpdate(
{ id },
{ airtrail_url: url, airtrail_allow_insecure_tls: allowInsecureTls, airtrail_write_enabled: writeEnabled },
);
}
/**
@@ -1,8 +1,8 @@
import type { EntityManager } from '@mikro-orm/core';
import type { SqlEntityManager } from '@mikro-orm/sql';
import { coalesceOverrideWhileSame } from '../dialect/sql-functions';
import { Users } from '../entities/Users.entity';
import { TrekRepository } from './_shared/trek-repository';
import { coalesceOverrideWhileSame } from '../dialect/sql-functions';
import type { EntityManager } from '@mikro-orm/core';
import type { SqlEntityManager } from '@mikro-orm/sql';
/**
* The Immich connection a user keeps on their own `users` row (`immich_url`,
@@ -26,9 +26,11 @@ export class UserImmichRepository extends TrekRepository<Users> {
* immich_api_key, immich_allow_insecure_tls FROM users WHERE id = ?`
* (the TLS switch joined the read with #2475).
*/
async getImmichCredentials(
id: number,
): Promise<{ immich_url: string | null; immich_api_key: string | null; immich_allow_insecure_tls: number | null } | null> {
async getImmichCredentials(id: number): Promise<{
immich_url: string | null;
immich_api_key: string | null;
immich_allow_insecure_tls: number | null;
} | null> {
const row = await this.findOne({ id }, { fields: ['immich_url', 'immich_api_key', 'immich_allow_insecure_tls'] });
return row
? {
@@ -55,7 +57,10 @@ export class UserImmichRepository extends TrekRepository<Users> {
): Promise<{ immich_auto_upload: number | null; immich_allow_insecure_tls: number | null } | null> {
const row = await this.findOne({ id }, { fields: ['immich_auto_upload', 'immich_allow_insecure_tls'] });
return row
? { immich_auto_upload: row.immich_auto_upload ?? null, immich_allow_insecure_tls: row.immich_allow_insecure_tls ?? null }
? {
immich_auto_upload: row.immich_auto_upload ?? null,
immich_allow_insecure_tls: row.immich_allow_insecure_tls ?? null,
}
: null;
}
@@ -88,7 +93,13 @@ export class UserImmichRepository extends TrekRepository<Users> {
{
immich_url,
immich_api_key,
immich_allow_insecure_tls: coalesceOverrideWhileSame(platform, allow_insecure_tls, 'immich_allow_insecure_tls', 'immich_url', immich_url),
immich_allow_insecure_tls: coalesceOverrideWhileSame(
platform,
allow_insecure_tls,
'immich_allow_insecure_tls',
'immich_url',
immich_url,
),
},
);
}
@@ -1,14 +1,15 @@
import type { EntityManager } from '@mikro-orm/core';
import type { SqlEntityManager } from '@mikro-orm/sql';
import { Users } from '../entities/Users.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { EntityManager } from '@mikro-orm/core';
import type { SqlEntityManager } from '@mikro-orm/sql';
/**
* The six Synology credential/session columns `_readSynologyUser` reads
* selectively — a closed union, not a dynamic column string, matching
* `InstanceApiKeyName`'s reasoning in `Users.repository.ts`.
*/
export type SynologyUserColumn = 'synology_url' | 'synology_username' | 'synology_password' | 'synology_sid' | 'synology_did' | 'synology_skip_ssl';
export type SynologyUserColumn =
'synology_url' | 'synology_username' | 'synology_password' | 'synology_sid' | 'synology_did' | 'synology_skip_ssl';
/**
* {@link UserSynologyRepository.getSynologyFields}'s return shape — structurally
@@ -94,7 +95,13 @@ export class UserSynologyRepository extends TrekRepository<Users> {
}
/** SY5 (`SynologyService.updateSynologySettings`) — `UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?`. */
async setSynologySettings(id: number, synology_url: string, synology_username: string, synology_password: string | null, synology_skip_ssl: number): Promise<void> {
async setSynologySettings(
id: number,
synology_url: string,
synology_username: string,
synology_password: string | null,
synology_skip_ssl: number,
): Promise<void> {
await this.nativeUpdate({ id }, { synology_url, synology_username, synology_password, synology_skip_ssl });
}
+9 -1
View File
@@ -1,4 +1,12 @@
import { demoDenied, type McpAccessGroup, type McpAccessMode, type McpAccessPolicy, type McpAccessValidator, type McpToolGate, type ToolOptions } from '../nest-mcp';
import {
demoDenied,
type McpAccessGroup,
type McpAccessMode,
type McpAccessPolicy,
type McpAccessValidator,
type McpToolGate,
type ToolOptions,
} from '../nest-mcp';
import { ALL_SCOPES, canRead, canWrite, type Scope, type ScopeGroup } from './scopes';
/** The mode half of every scope: 'read' | 'write' | 'delete' | 'share'. */
+5 -1
View File
@@ -17,7 +17,11 @@ export class McpRegistryService extends McpRegistry implements OnModuleInit {
@Inject(MCP_MODULE_OPTIONS) options: McpModuleOptions,
@Inject(MCP_TOOL_GATE) toolGate: McpToolGate | null,
) {
super({ accessPolicy: options.accessPolicy, validateAccess: options.validateAccess, toolGate: toolGate ?? undefined });
super({
accessPolicy: options.accessPolicy,
validateAccess: options.validateAccess,
toolGate: toolGate ?? undefined,
});
}
onModuleInit(): void {
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Scope, ScopeGroup } from '../mcp/scopes';
import type { InjectionToken, ModuleMetadata } from '@nestjs/common';
import type { ZodRawShape, ZodType } from 'zod';
/**
+5 -5
View File
@@ -1,10 +1,10 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { PermissionsModule } from '../nest/permissions/permissions.module';
import { AddonsModule } from '../nest/addons/addons.module';
import { Users } from '../db/entities/Users.entity';
import { Trips } from '../db/entities/Trips.entity';
import { Users } from '../db/entities/Users.entity';
import { AddonsModule } from '../nest/addons/addons.module';
import { PermissionsModule } from '../nest/permissions/permissions.module';
import { PluginGuards } from './plugin-guards.service';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
/**
* A leaf module whose only job is to hand PluginGuards to the domain modules.
+9 -8
View File
@@ -1,14 +1,14 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { PermissionsService } from '../nest/permissions/permissions.service';
import { AddonsService } from '../nest/addons/addons.service';
import { Users } from '../db/entities/Users.entity';
import type { UsersRepository } from '../db/repositories/Users.repository';
import { Trips } from '../db/entities/Trips.entity';
import { Users } from '../db/entities/Users.entity';
import type { TripsRepository } from '../db/repositories/Trips.repository';
import type { UsersRepository } from '../db/repositories/Users.repository';
import { AddonsService } from '../nest/addons/addons.service';
import { PermissionsService } from '../nest/permissions/permissions.service';
import { BadParams, ForbiddenResource } from './rpc-errors';
import { num } from './rpc-params';
import type { PluginRpcContext } from './rpc-kit/types';
import { num } from './rpc-params';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
/**
* The resource gates every plugin RPC handler needs, lifted out of PluginRpcHost's
@@ -66,7 +66,8 @@ export class PluginGuards {
/** A write is allowed only if the acting user can access AND edit the trip. */
async requireTripEdit(tripId: number, userId: number, action: string): Promise<void> {
if (!(await this.trips.findAccessible(tripId, userId))) throw new ForbiddenResource(`no access to trip ${tripId}`);
if (!(await this.canEditAs(action, tripId, userId))) throw new ForbiddenResource(`no permission to edit trip ${tripId}`);
if (!(await this.canEditAs(action, tripId, userId)))
throw new ForbiddenResource(`no permission to edit trip ${tripId}`);
}
/**
+2 -2
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { addEntry, markController, type ClassRef } from './metadata';
import type { HookKey, KnownMethod, MethodPermission, UnconditionalMethod } from '../../nest/plugins/protocol/envelope';
import { addEntry, markController, type ClassRef } from './metadata';
import type { PluginHookOptions, PluginRpcEntry } from './types';
import { Injectable } from '@nestjs/common';
/**
* Marks a class as a plugin RPC controller. Implies `@Injectable()`.
@@ -1,7 +1,7 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
import { isPluginController } from './metadata';
import { PluginRpcRegistry } from './registry';
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
/**
* DI-discovered registry: at boot, scans every PROVIDER marked with
+1 -1
View File
@@ -1,5 +1,5 @@
import type { HookKey, HookPermission, KnownMethod, UnconditionalMethod } from '../../nest/plugins/protocol/envelope';
import type { PluginDataDb } from '../../nest/plugins/host/plugin-data.service';
import type { HookKey, HookPermission, KnownMethod, UnconditionalMethod } from '../../nest/plugins/protocol/envelope';
/**
* Everything a plugin RPC handler may see. Built PER DISPATCH, never cached.
+1
View File
@@ -1,4 +1,5 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
/**
+14 -6
View File
@@ -1,9 +1,10 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/core';
import type { Request } from 'express';
import { Users } from '../../db/entities/Users.entity';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import { Users } from '../../db/entities/Users.entity';
import { verifyJwtAndLoadUser } from './jwt-verify';
import { EntityManager } from '@mikro-orm/core';
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
/**
* Mirrors the legacy `requireCookieAuth` middleware: accepts ONLY the httpOnly
@@ -24,9 +25,16 @@ export class CookieAuthGuard implements CanActivate {
const req = context.switchToHttp().getRequest<Request & { cookies?: Record<string, string> }>();
const cookieToken = req.cookies?.trek_session;
if (!cookieToken) {
throw new HttpException({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' }, 401);
throw new HttpException(
{ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' },
401,
);
}
const user = await verifyJwtAndLoadUser(cookieToken, this.em.getRepository(Users), this.em.getRepository(UserSessions));
const user = await verifyJwtAndLoadUser(
cookieToken,
this.em.getRepository(Users),
this.em.getRepository(UserSessions),
);
if (!user) {
throw new HttpException({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' }, 401);
}
@@ -1,12 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { User } from '../../types';
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* Resolves the authenticated user attached by JwtAuthGuard.
* Use on guarded handlers: `getThing(@CurrentUser() user: User) { ... }`.
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): User | undefined => {
return context.switchToHttp().getRequest().user;
},
);
export const CurrentUser = createParamDecorator((_data: unknown, context: ExecutionContext): User | undefined => {
return context.switchToHttp().getRequest().user;
});
@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common';
import { EphemeralTokenService } from './ephemeral-token.service';
import { Module } from '@nestjs/common';
/**
* A leaf module whose only job is to hand EphemeralTokenService out.
@@ -1,4 +1,3 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import {
createEphemeralToken,
consumeEphemeralToken,
@@ -7,6 +6,7 @@ import {
stopTokenCleanup,
type EphemeralTokenMeta,
} from './ephemeral-tokens';
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
/**
* The injectable face of the ephemeral-token store, and the owner of its
@@ -27,11 +27,7 @@ export interface EphemeralTokenMeta {
const store = new Map<string, TokenEntry>();
export function createEphemeralToken(
userId: number,
purpose: string,
meta?: EphemeralTokenMeta,
): string | null {
export function createEphemeralToken(userId: number, purpose: string, meta?: EphemeralTokenMeta): string | null {
if (store.size >= MAX_STORE_SIZE) return null;
const token = crypto.randomBytes(32).toString('hex');
const ttl = TTL[purpose] ?? 60_000;
@@ -52,10 +48,7 @@ export function consumeEphemeralToken(token: string, purpose: string): number |
* token was minted with. Used by the WebSocket handshake so a token issued
* before a password change can be rejected even within its short TTL.
*/
export function consumeEphemeralTokenWithMeta(
token: string,
purpose: string,
): { userId: number; pv?: number } | null {
export function consumeEphemeralTokenWithMeta(token: string, purpose: string): { userId: number; pv?: number } | null {
const entry = store.get(token);
if (!entry) return null;
store.delete(token);
+14 -7
View File
@@ -1,12 +1,13 @@
import { UserSessions } from '../../db/entities/UserSessions.entity';
import { Users } from '../../db/entities/Users.entity';
import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
import { IS_PUBLIC, OPTIONAL_AUTH } from './public.decorator';
import { EntityManager } from '@mikro-orm/core';
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
import { EntityManager } from '@mikro-orm/core';
import type { Request } from 'express';
import { Users } from '../../db/entities/Users.entity';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
import { IS_PUBLIC, OPTIONAL_AUTH } from './public.decorator';
/**
* Default-deny. Registered as an APP_GUARD, so a route is authenticated unless
@@ -50,7 +51,10 @@ export class GlobalAuthGuard implements CanActivate {
if (this.reflector.getAllAndOverride(OPTIONAL_AUTH, [handler, controller])) {
const token = extractToken(req);
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users), this.em.getRepository(UserSessions)) : null) || null;
(req as { user: unknown }).user =
(token
? await verifyJwtAndLoadUser(token, this.em.getRepository(Users), this.em.getRepository(UserSessions))
: null) || null;
return true;
}
@@ -67,7 +71,10 @@ export class GlobalAuthGuard implements CanActivate {
];
if (declared.length > 0) {
const declaredToken = extractToken(req);
(req as { user: unknown }).user = (declaredToken ? await verifyJwtAndLoadUser(declaredToken, this.em.getRepository(Users), this.em.getRepository(UserSessions)) : null) || null;
(req as { user: unknown }).user =
(declaredToken
? await verifyJwtAndLoadUser(declaredToken, this.em.getRepository(Users), this.em.getRepository(UserSessions))
: null) || null;
return true;
}
+5 -4
View File
@@ -1,9 +1,10 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/core';
import type { Request } from 'express';
import { Users } from '../../db/entities/Users.entity';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import { Users } from '../../db/entities/Users.entity';
import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
import { EntityManager } from '@mikro-orm/core';
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
/**
* Validates TREK's existing JWT session — the same httpOnly `trek_session`
+6 -5
View File
@@ -1,10 +1,11 @@
import { JWT_SECRET } from '../../config';
import type { UserSessionsRepository } from '../../db/repositories/UserSessions.repository';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import { dbNow } from '../../db/types';
import type { User } from '../../types';
import type { Request } from 'express';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from '../../config';
import type { User } from '../../types';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import type { UserSessionsRepository } from '../../db/repositories/UserSessions.repository';
import { dbNow } from '../../db/types';
/**
* The canonical JWT session check. Every auth surface goes through here — the
+13 -12
View File
@@ -1,18 +1,19 @@
import { CanActivate, ExecutionContext, HttpException, Injectable, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { Request } from 'express';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import { Users } from '../../db/entities/Users.entity';
import { WebauthnCredentials } from '../../db/entities/WebauthnCredentials.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import type { WebauthnCredentialsRepository } from '../../db/repositories/WebauthnCredentials.repository';
import type { User } from '../../types';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { readAppSetting } from '../common/app-settings.registry';
import { DEMO_EMAILS } from '../common/demo';
import { IS_PUBLIC } from './public.decorator';
import type { User } from '../../types';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import { WebauthnCredentials } from '../../db/entities/WebauthnCredentials.entity';
import type { WebauthnCredentialsRepository } from '../../db/repositories/WebauthnCredentials.repository';
import { readAppSetting } from '../common/app-settings.registry';
import { InjectRepository } from '@mikro-orm/nestjs';
import { CanActivate, ExecutionContext, HttpException, Injectable, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
/** Metadata key `@MfaExempt()` writes. */
export const MFA_EXEMPT = 'trek:mfa-exempt';
@@ -1,9 +1,10 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/core';
import type { Request } from 'express';
import { Users } from '../../db/entities/Users.entity';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import { Users } from '../../db/entities/Users.entity';
import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
import { EntityManager } from '@mikro-orm/core';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import type { Request } from 'express';
/**
* Mirrors the legacy `optionalAuth` middleware: populates req.user with the
@@ -21,7 +22,10 @@ export class OptionalJwtGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const token = extractToken(req);
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users), this.em.getRepository(UserSessions)) : null) || null;
(req as { user: unknown }).user =
(token
? await verifyJwtAndLoadUser(token, this.em.getRepository(Users), this.em.getRepository(UserSessions))
: null) || null;
return true;
}
}
@@ -1,7 +1,8 @@
import { z } from 'zod';
import { readEnv } from '../../app-config';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { z } from 'zod';
/**
* The instance-wide settings (`app_settings`) the server reads by name, in one
* typed register.
@@ -51,10 +52,25 @@ export const APP_SETTINGS = {
allow_registration: { schema: flag, default: 'true', adminForm: true },
allowed_file_types: { schema: text, default: null, adminForm: true },
require_mfa: { schema: flag, default: 'false', adminForm: true, publicConfig: true },
smtp_host: { schema: text, default: null, env: (e) => e.smtp.host, adminForm: true, managedLocked: true, publicConfig: true },
smtp_host: {
schema: text,
default: null,
env: (e) => e.smtp.host,
adminForm: true,
managedLocked: true,
publicConfig: true,
},
smtp_port: { schema: intText, default: null, env: (e) => e.smtp.port, adminForm: true, managedLocked: true },
smtp_user: { schema: text, default: null, env: (e) => e.smtp.user, adminForm: true, managedLocked: true },
smtp_pass: { schema: text, default: null, env: (e) => e.smtp.pass, adminForm: true, masked: true, encrypted: 'always', managedLocked: true },
smtp_pass: {
schema: text,
default: null,
env: (e) => e.smtp.pass,
adminForm: true,
masked: true,
encrypted: 'always',
managedLocked: true,
},
smtp_from: { schema: text, default: null, env: (e) => e.smtp.from, adminForm: true, managedLocked: true },
smtp_skip_tls_verify: { schema: flag, default: 'false', adminForm: true, managedLocked: true },
notification_channels: { schema: text, default: null, adminForm: true, publicConfig: true },
@@ -101,7 +117,8 @@ export const APP_SETTINGS = {
export type AppSettingKey = keyof typeof APP_SETTINGS;
const ENTRIES = Object.entries(APP_SETTINGS) as Array<[AppSettingKey, AppSettingDef]>;
const keysWhere = (pick: (def: AppSettingDef) => unknown): AppSettingKey[] => ENTRIES.filter(([, def]) => pick(def)).map(([key]) => key);
const keysWhere = (pick: (def: AppSettingDef) => unknown): AppSettingKey[] =>
ENTRIES.filter(([, def]) => pick(def)).map(([key]) => key);
/** The admin settings form's keys, in the order its answer has always listed them. */
export const ADMIN_FORM_SETTING_KEYS: readonly AppSettingKey[] = keysWhere((d) => d.adminForm);
+1 -2
View File
@@ -15,6 +15,5 @@ export const GOOGLE_SHORT_HOSTS = ['goo.gl', 'maps.app.goo.gl'];
* `google.evil.com` is not a Google host.
*/
export function isGoogleMapsHost(hostname: string): boolean {
return GOOGLE_SHORT_HOSTS.includes(hostname)
|| /^(www\.|maps\.)?google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(hostname);
return GOOGLE_SHORT_HOSTS.includes(hostname) || /^(www\.|maps\.)?google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(hostname);
}
+5 -2
View File
@@ -1,5 +1,5 @@
import { TRANSIT_PROVIDERS, type TransitProvider } from '@trek/shared';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { TRANSIT_PROVIDERS, type TransitProvider } from '@trek/shared';
/**
* Which backend answers /api/transit (#1699), stored as one `app_settings` row.
@@ -34,7 +34,10 @@ export async function readTransitProvider(appSettings: AppSettingsRepository): P
return isTransitProvider(value) ? value : DEFAULT_TRANSIT_PROVIDER;
}
export async function writeTransitProvider(appSettings: AppSettingsRepository, provider: TransitProvider): Promise<TransitProvider> {
export async function writeTransitProvider(
appSettings: AppSettingsRepository,
provider: TransitProvider,
): Promise<TransitProvider> {
await appSettings.setValue(TRANSIT_PROVIDER_SETTING, provider);
return provider;
}
@@ -1,7 +1,7 @@
import { Global, Module } from '@nestjs/common';
import { JOURNEY_ACCESS } from '../realtime/journey-access.types';
import { JourneyDomainModule } from './journey-domain.module';
import { JourneyDomainService } from './journey-domain.service';
import { Global, Module } from '@nestjs/common';
/**
* Binds the realtime gateway's JOURNEY_ACCESS port to JourneyDomainService.
+21 -6
View File
@@ -4,11 +4,18 @@
* need no branch per source. Pure: the calls themselves are trek-places.client's,
* the decision to make them is MapsService's.
*/
import { buildOsmDetails, parsePoiCategories } from './maps.helpers';
import { POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client';
import {
POI_CATEGORY_TO_TREK,
isOsmHit,
osmPlaceId,
toPlaceRecord,
type TrekPlace,
type TrekSearchHit,
} from './trek-places.client';
import { normalizePlaceWebsite } from '@trek/shared';
import type { MapsAutocompleteResult } from '@trek/shared';
import { buildOsmDetails, parsePoiCategories } from './maps.helpers';
import { POI_CATEGORY_TO_TREK, isOsmHit, osmPlaceId, toPlaceRecord, type TrekPlace, type TrekSearchHit } from './trek-places.client';
import { POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client';
type PoiBbox = { south: number; west: number; north: number; east: number };
@@ -69,7 +76,10 @@ export function indexPoiPlan(category: string, bbox: PoiBbox, limit: number): In
// Half the diagonal, so the circle covers the viewport corners rather
// than leaving the edges of the map empty.
const reach = Math.round(
Math.hypot((bbox.north - bbox.south) * 111_320, (bbox.east - bbox.west) * 111_320 * Math.cos((lat * Math.PI) / 180)) / 2,
Math.hypot(
(bbox.north - bbox.south) * 111_320,
(bbox.east - bbox.west) * 111_320 * Math.cos((lat * Math.PI) / 180),
) / 2,
);
const radius = Math.min(20000, Math.max(300, reach));
// The same budget the Overpass path spends: per category, capped, so a
@@ -182,7 +192,11 @@ export function indexSuggestions(found: TrekSearchHit[]): MapsAutocompleteResult
* disappeared from its card afterwards, which reads like data loss. `osmTags`
* is null when no OSM match passed the identity gates.
*/
export function indexPlaceDetails(found: TrekPlace, osmTags: Record<string, string> | null, placeId: string): Record<string, unknown> {
export function indexPlaceDetails(
found: TrekPlace,
osmTags: Record<string, string> | null,
placeId: string,
): Record<string, unknown> {
const record = toPlaceRecord(found);
// Hours the index read off the operator's own site, run through the same
// expansion OSM's go through — the client reads a list of weekday lines,
@@ -194,7 +208,8 @@ export function indexPlaceDetails(found: TrekPlace, osmTags: Record<string, stri
// fallback, not the first choice: an OSM entry describes this exact
// object and gets corrected by people who walked past, where a chain's
// website often carries one set of hours for every branch.
const fromSite = typeof found.hours?.osm === 'string' ? buildOsmDetails({ opening_hours: found.hours.osm }, '', '') : null;
const fromSite =
typeof found.hours?.osm === 'string' ? buildOsmDetails({ opening_hours: found.hours.osm }, '', '') : null;
if (!osmTags) {
return fromSite?.opening_hours
? {
+14 -7
View File
@@ -8,13 +8,13 @@
* caller to reverse-geocode: inside China Amap answers that before Nominatim,
* and choosing who answers is MapsService's job, not this resolver's.
*/
import { Injectable } from '@nestjs/common';
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
import { discardBody, exceedsDeclaredLength, readCappedText } from '../../utils/cappedFetch';
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
import { GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../common/google-maps-hosts';
import { UA, googleFtidFromMapsUrl } from './maps.helpers';
import { AMAP_SHORT_HOSTS, isAmapHost, parseAmapUrl } from './providers/amap.provider';
import { OsmClient } from './providers/osm.client';
import { GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../common/google-maps-hosts';
import { Injectable } from '@nestjs/common';
// A Google Maps place page is a few hundred KB; the coordinates sit in the
// embedded map data near the top, so two megabytes is plenty and keeps an
@@ -31,8 +31,7 @@ export interface ResolvedMapsUrl {
/** A Google link resolved in full, or an Amap point still to be named. */
export type MapsLink =
| { kind: 'resolved'; result: ResolvedMapsUrl }
| { kind: 'amap'; lat: number; lng: number; name: string | null };
{ kind: 'resolved'; result: ResolvedMapsUrl } | { kind: 'amap'; lat: number; lng: number; name: string | null };
// Extract coordinates from a string (URL or page body). Google Maps encodes
// them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=.
@@ -48,7 +47,11 @@ function extractCoords(s: string): { lat: number; lng: number } | null {
async function followRedirects(target: string, init?: RequestInit): Promise<Response> {
try {
return await safeFetchFollow(target, { signal: AbortSignal.timeout(10000), ...init }, { bypassInternalIpAllowed: true });
return await safeFetchFollow(
target,
{ signal: AbortSignal.timeout(10000), ...init },
{ bypassInternalIpAllowed: true },
);
} catch (err) {
if (err instanceof SsrfBlockedError) {
throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
@@ -79,7 +82,11 @@ export class MapsUrlResolver {
}
let resolvedHost = '';
try { resolvedHost = new URL(resolvedUrl).hostname; } catch { /* keep the empty host, both host branches are skipped */ }
try {
resolvedHost = new URL(resolvedUrl).hostname;
} catch {
/* keep the empty host, both host branches are skipped */
}
// Amap links first, and on their own: they spell the coordinate `lng,lat`
// in GCJ-02, which the Google patterns below would read as a WGS-84
+30 -14
View File
@@ -7,18 +7,18 @@
* MapsService.getPlaceDetails / getPlaceDetailsExpanded delegate here and hand
* in whether the index is switched on, which is a deployment property they own.
*/
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { normalizePlaceWebsite } from '@trek/shared';
import { PlaceDetailsCache } from '../../db/entities/PlaceDetailsCache.entity';
import type { PlaceDetailsCacheRepository } from '../../db/repositories/PlaceDetailsCache.repository';
import { indexPlaceDetails } from './maps-index.helpers';
import { toApiLang, isGooglePlaceId, OSM_PLACE_ID } from './maps.helpers';
import { PlacesProviderSelector } from './places-provider.selector';
import { isAmapPlaceId } from './providers/amap.provider';
import { GooglePlacesClient } from './providers/google-places.provider';
import { OsmClient } from './providers/osm.client';
import { isAmapPlaceId } from './providers/amap.provider';
import { PlacesProviderSelector } from './places-provider.selector';
import { trekPlacesById } from './trek-places.client';
import { toApiLang, isGooglePlaceId, OSM_PLACE_ID } from './maps.helpers';
import { indexPlaceDetails } from './maps-index.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import { normalizePlaceWebsite } from '@trek/shared';
/**
* A details row as the cache holds it. A row written before #2483 still has a
@@ -66,10 +66,12 @@ export class PlaceDetailsResolver {
// resolveOsmIdentity applies everywhere: within range, and sharing a
// substantial word of the name. A confident description of the building
// next door is worse than none.
const osm = await this.osm.resolveOsmIdentity(found.name, found.lat, found.lng, {
lang,
maxDistanceM: 150,
}).catch(() => null);
const osm = await this.osm
.resolveOsmIdentity(found.name, found.lat, found.lng, {
lang,
maxDistanceM: 150,
})
.catch(() => null);
return { place: indexPlaceDetails(found, osm?.tags ?? null, placeId) };
}
@@ -122,7 +124,13 @@ export class PlaceDetailsResolver {
if (!place) return { place: null };
try {
await this.placeDetailsCache.upsertEntry({ place_id: placeId, lang: langKey, expanded: 0, payload_json: JSON.stringify(place), fetched_at: Date.now() });
await this.placeDetailsCache.upsertEntry({
place_id: placeId,
lang: langKey,
expanded: 0,
payload_json: JSON.stringify(place),
fetched_at: Date.now(),
});
} catch (dbErr) {
console.error('Failed to cache place details:', dbErr);
}
@@ -184,10 +192,18 @@ export class PlaceDetailsResolver {
if (cached) return { place: cachedDetails(cached.payload_json) };
}
const place = await this.googlePlaces.provider({ key: apiKey, source: null, userId }).placeDetailsExpanded(placeId, lang);
const place = await this.googlePlaces
.provider({ key: apiKey, source: null, userId })
.placeDetailsExpanded(placeId, lang);
try {
await this.placeDetailsCache.upsertEntry({ place_id: placeId, lang: langKey, expanded: 1, payload_json: JSON.stringify(place), fetched_at: Date.now() });
await this.placeDetailsCache.upsertEntry({
place_id: placeId,
lang: langKey,
expanded: 1,
payload_json: JSON.stringify(place),
fetched_at: Date.now(),
});
} catch (dbErr) {
console.error('Failed to cache expanded place details:', dbErr);
}
+9 -6
View File
@@ -8,16 +8,16 @@
* failed" (minutes), the in-flight dedupe of concurrent requests for one place
* and the shared photo-fetch slots. MapsService.getPlacePhoto delegates to it.
*/
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Places } from '../../db/entities/Places.entity';
import type { PlacesRepository } from '../../db/repositories/Places.repository';
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { isGooglePlaceId } from './maps.helpers';
import { withPhotoFetchSlot } from './photo-fetch-slot';
import { PlacesProviderSelector } from './places-provider.selector';
import { GooglePlacesClient } from './providers/google-places.provider';
import { WikimediaClient } from './providers/wikimedia.client';
import { PlacesProviderSelector } from './places-provider.selector';
import { withPhotoFetchSlot } from './photo-fetch-slot';
import { isGooglePlaceId } from './maps.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
@Injectable()
export class PlacePhotoResolver {
@@ -63,7 +63,10 @@ export class PlacePhotoResolver {
if (existing !== undefined) {
const result = await existing;
if (!result) return noPhoto;
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
return {
photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`,
attribution: result.attribution,
};
}
// Tells the two empty outcomes apart for the negative cache below: a place that
@@ -10,18 +10,18 @@
* this class only resolves it, through the same app_settings and users rows
* the key chain always read, in the same order.
*/
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { isOutsideChina } from '@trek/shared';
import { readEnv } from '../../app-config';
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { Users } from '../../db/entities/Users.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import { GoogleQuotaService } from '../google-quota/google-quota.service';
import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/places-provider';
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
import { AmapPlacesProvider, AmapTipStash, isAmapPlaceId } from './providers/amap.provider';
import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/places-provider';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import { isOutsideChina } from '@trek/shared';
/**
* The app_settings row that names the keyed places provider.
@@ -44,8 +44,7 @@ export const PLACES_GOOGLE_ONLY_SETTING = 'places_google_only';
* credential, an Amap provider, or nobody (the OpenStreetMap stack alone).
*/
export type KeyedProvider =
| { id: 'google'; key: string; source: ApiKeySource | null }
| { id: 'amap'; provider: AmapPlacesProvider };
{ id: 'google'; key: string; source: ApiKeySource | null } | { id: 'amap'; provider: AmapPlacesProvider };
@Injectable()
export class PlacesProviderSelector {
@@ -130,7 +129,8 @@ export class PlacesProviderSelector {
const google = await this.resolveMapsKey(userId);
// Past the daily ceiling (#1582) the key is spent for today: answer as if
// there were none, so `auto` moves on and OpenStreetMap fills in.
if (google.key && !(await this.googleQuota.exhausted())) return { id: 'google', key: google.key, source: google.source };
if (google.key && !(await this.googleQuota.exhausted()))
return { id: 'google', key: google.key, source: google.source };
// An explicit 'google' choice with no key is not a reason to query Amap
// instead: this install is on Google and is misconfigured. OSM answers,
// the way a keyless install has always been answered.
@@ -15,11 +15,9 @@
* orchestrator depends on its providers, not the other way round
* (lint:boundaries holds that).
*/
import { Injectable } from '@nestjs/common';
import { normalizePlaceWebsite } from '@trek/shared';
import { readEnv, getAppUrl } from '../../../app-config';
import type { ApiKeySource } from '../../settings/instance-api-keys';
import { GoogleQuotaService } from '../../google-quota/google-quota.service';
import type { ApiKeySource } from '../../settings/instance-api-keys';
import {
googleFtidFromMapsUrl,
isGooglePlaceId,
@@ -37,6 +35,8 @@ import type {
SearchBias,
ViewportBias,
} from './places-provider';
import { Injectable } from '@nestjs/common';
import { normalizePlaceWebsite } from '@trek/shared';
// ── Google API call counter ───────────────────────────────────────────────────
@@ -160,7 +160,11 @@ const DETAILS_FIELD_MASK =
'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri';
/** A Google error answer as the Error the callers have always thrown: Google's message, Google's status. */
function googleError(data: { error?: { message?: string } }, fallback: string, status: number): Error & { status: number } {
function googleError(
data: { error?: { message?: string } },
fallback: string,
status: number,
): Error & { status: number } {
const err = new Error(data.error?.message || fallback) as Error & { status: number };
err.status = status;
return err;
@@ -223,12 +227,20 @@ export class GooglePlacesClient {
* call for the whole strip, while every reference turned into an image is a
* separate billed /media call. Callers fetch bytes only for what they show.
*/
async fetchGooglePhotoRefs(placeId: string, apiKey: string, cap: number): Promise<{ name: string; attribution: string | null }[]> {
async fetchGooglePhotoRefs(
placeId: string,
apiKey: string,
cap: number,
): Promise<{ name: string; attribution: string | null }[]> {
if (!isGooglePlaceId(placeId) || cap < 1) return [];
try {
const res = await this.fetch(`https://places.googleapis.com/v1/places/${placeId}`, `fetchGooglePhotoRefs(${placeId})`, {
headers: { 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'photos' },
});
const res = await this.fetch(
`https://places.googleapis.com/v1/places/${placeId}`,
`fetchGooglePhotoRefs(${placeId})`,
{
headers: { 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'photos' },
},
);
if (!res.ok) return [];
const data = (await res.json()) as GooglePlaceDetails;
return (data.photos ?? []).slice(0, cap).map((photo) => ({
@@ -292,12 +304,16 @@ export class GooglePlacesClient {
*/
async firstPhoto(placeId: string, apiKey: string): Promise<GooglePhotoOutcome> {
// Fetch details to get the photo name
const detailsRes = await this.fetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
const detailsRes = await this.fetch(
`https://places.googleapis.com/v1/places/${placeId}`,
`getPlacePhoto/details(${placeId})`,
{
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
},
});
);
const body = await detailsRes.text();
if (!detailsRes.ok) {
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
@@ -384,10 +400,19 @@ export class GooglePlacesProvider implements PlacesProvider {
}
/** Places of any kind around a point, nearest first by Google's own ranking (#976). */
async searchNearby(origin: { lat: number; lng: number }, radius: number, limit: number, lang: string): Promise<ProviderPlace[]> {
async searchNearby(
origin: { lat: number; lng: number },
radius: number,
limit: number,
lang: string,
): Promise<ProviderPlace[]> {
const response = await this.client.fetch('https://places.googleapis.com/v1/places:searchNearby', 'searchNearby', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': this.credential.key, 'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK },
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': this.credential.key,
'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK,
},
body: JSON.stringify({
maxResultCount: limit,
rankPreference: 'DISTANCE',
@@ -403,7 +428,12 @@ export class GooglePlacesProvider implements PlacesProvider {
return (data.places || []).filter(isOpenGooglePlace).map(googlePlaceRecord);
}
async autocomplete(input: string, lang?: string, bias?: ViewportBias, sessionToken?: string): Promise<ProviderSuggestion[]> {
async autocomplete(
input: string,
lang?: string,
bias?: ViewportBias,
sessionToken?: string,
): Promise<ProviderSuggestion[]> {
const body: Record<string, unknown> = {
input,
languageCode: toApiLang(lang),
+21 -13
View File
@@ -16,9 +16,8 @@
* Never imports MapsService: providers sit below the orchestrator
* (lint:boundaries holds that).
*/
import { Injectable } from '@nestjs/common';
import { normalizePlaceWebsite } from '@trek/shared';
import { nominatimFetch, type GeoLane } from '../../geo/nominatim.client';
import { nearbyOverpassQuery, overpassNearbyRecords } from '../maps-nearby.helpers';
import {
UA,
toApiLang,
@@ -35,8 +34,9 @@ import {
OSM_PLACE_ID,
type OverpassPoi,
} from '../maps.helpers';
import { nearbyOverpassQuery, overpassNearbyRecords } from '../maps-nearby.helpers';
import { readWikiIdentity } from './wiki-identity';
import { Injectable } from '@nestjs/common';
import { normalizePlaceWebsite } from '@trek/shared';
function nominatimCategory(item: { class?: string; type?: string }): string | null {
if (!item.class || !item.type || item.type === 'yes') return null;
@@ -202,7 +202,12 @@ export class OsmClient {
* whole import. Yielding does not make the import faster, it stops it from
* being the only thing the process will do for half a minute.
*/
async searchNominatim(query: string, lang?: string, lane: GeoLane = 'interactive', bias?: { lat: number; lng: number }) {
async searchNominatim(
query: string,
lang?: string,
lane: GeoLane = 'interactive',
bias?: { lat: number; lng: number },
) {
const params = new URLSearchParams({
q: query,
format: 'json',
@@ -259,7 +264,10 @@ export class OsmClient {
}
/** Suggestions out of a Nominatim search, for an install with neither the index nor a key. */
async autocompleteNominatim(input: string, lang?: string): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: 'nominatim' }> {
async autocompleteNominatim(
input: string,
lang?: string,
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: 'nominatim' }> {
try {
const places = await this.searchNominatim(input, lang);
const suggestions = places
@@ -472,11 +480,7 @@ export class OsmClient {
// Nominatim's extratags carry the wikidata/wikipedia/commons tags too, so
// a place keeps its pictures and its description when Overpass times out
// instead of falling back to "photographed within 300m".
const details = buildOsmDetails(
{ ...(nominatim?.extratags ?? {}), ...(element?.tags ?? {}) },
osmType,
osmId,
);
const details = buildOsmDetails({ ...(nominatim?.extratags ?? {}), ...(element?.tags ?? {}) }, osmType, osmId);
return {
...details,
@@ -517,7 +521,12 @@ export class OsmClient {
}
/** Places of any kind around a point, from the Overpass mirrors, nearest first (#976). */
async nearby(origin: { lat: number; lng: number }, radius: number, limit: number, osmLang: string): Promise<Record<string, unknown>[]> {
async nearby(
origin: { lat: number; lng: number },
radius: number,
limit: number,
osmLang: string,
): Promise<Record<string, unknown>[]> {
const elements = await overpassFetch(nearbyOverpassQuery(origin.lat, origin.lng, radius, limit));
return overpassNearbyRecords(elements, origin, osmLang, limit);
}
@@ -586,8 +595,7 @@ export class OsmClient {
// `operator` comes last but matters for the road categories: petrol stations,
// charging points and service areas are routinely mapped with an operator and no
// name, and dropping those would empty the road trip corridor over long stretches.
const name =
tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || tags.operator || null;
const name = tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || tags.operator || null;
if (!name) continue; // unnamed POIs aren't useful to add to a plan
// A shut-down place is not somewhere to plan a visit (#1341). OSM usually
// re-tags one with a `disused:`/`abandoned:` prefix, and those never match
@@ -12,11 +12,12 @@
* column both ask through this client. It never imports MapsService:
* providers sit below the orchestrator (lint:boundaries holds that).
*/
import { Injectable } from '@nestjs/common';
import { Jimp } from 'jimp';
import { safeFetchFollow } from '../../../utils/ssrfGuard';
import { discardBody, exceedsDeclaredLength, readCapped } from '../../../utils/cappedFetch';
import { safeFetchFollow } from '../../../utils/ssrfGuard';
import { UA, stripWikiMarkup, parseWikipediaTag } from '../maps.helpers';
import { Injectable } from '@nestjs/common';
import { Jimp } from 'jimp';
interface WikidataSnak {
mainsnak?: { datavalue?: { value?: string } };
@@ -84,7 +85,11 @@ function wikidataImageClaims(claims: WikidataClaims, limit: number): string[] {
/** `File:` prefix off, underscores and case normalised — Commons treats these as one title. */
function normalizeFileTitle(title: string): string {
return title.replace(/^File:/i, '').replaceAll('_', ' ').trim().toLowerCase();
return title
.replace(/^File:/i, '')
.replaceAll('_', ' ')
.trim()
.toLowerCase();
}
/**
@@ -325,7 +330,11 @@ export class WikimediaClient {
// The same six seconds the Wikidata hop above allows. Without a deadline
// this waited on undici's five-minute default, holding a request context
// and a socket per stalled logo while the pin sat on its fallback icon.
const imgRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) }, { bypassInternalIpAllowed: true });
const imgRes = await safeFetchFollow(
url,
{ signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) },
{ bypassInternalIpAllowed: true },
);
if (!imgRes.ok) return remember(null);
if (exceedsDeclaredLength(imgRes, BRAND_LOGO_MAX_BYTES)) {
@@ -360,7 +369,11 @@ export class WikimediaClient {
try {
const wiki = await this.fetchWikimediaPhoto(lat, lng, name);
if (!wiki) return { kind: 'none' };
const imgRes = await safeFetchFollow(wiki.photoUrl, { signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) }, { bypassInternalIpAllowed: true });
const imgRes = await safeFetchFollow(
wiki.photoUrl,
{ signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) },
{ bypassInternalIpAllowed: true },
);
if (!imgRes.ok) return { kind: 'failed' };
if (exceedsDeclaredLength(imgRes, WIKIMEDIA_PHOTO_MAX_BYTES)) {
discardBody(imgRes);
@@ -376,7 +389,11 @@ export class WikimediaClient {
// ── Wikimedia Commons photo lookup ─────────────────────────────────────────
async fetchWikimediaPhoto(lat: number, lng: number, name?: string): Promise<{ photoUrl: string; attribution: string | null } | null> {
async fetchWikimediaPhoto(
lat: number,
lng: number,
name?: string,
): Promise<{ photoUrl: string; attribution: string | null } | null> {
// Strategy 1: Search Wikipedia for the place name -> get the article image
if (name) {
try {
@@ -397,7 +414,9 @@ export class WikimediaClient {
signal: AbortSignal.timeout(WIKI_TIMEOUT_MS),
});
if (res.ok) {
const data = (await res.json()) as { query?: { pages?: Record<string, { thumbnail?: { source?: string } }> } };
const data = (await res.json()) as {
query?: { pages?: Record<string, { thumbnail?: { source?: string } }> };
};
const pages = data.query?.pages;
if (pages) {
for (const page of Object.values(pages)) {
@@ -592,7 +611,11 @@ export class WikimediaClient {
* tag — which is most of them, because mappers add one or the other. One
* request, a few hundred bytes.
*/
async fetchWikidataSitelinks(wikidataId: string, sites: string[], signal?: AbortSignal): Promise<Record<string, string>> {
async fetchWikidataSitelinks(
wikidataId: string,
sites: string[],
signal?: AbortSignal,
): Promise<Record<string, string>> {
const qid = wikidataId.trim();
if (!/^Q\d+$/.test(qid) || sites.length === 0) return {};
const params = new URLSearchParams({
@@ -642,10 +665,13 @@ export class WikimediaClient {
const qid = wikidataId.trim();
if (!/^Q\d+$/.test(qid)) return empty;
try {
const res = await fetch(`https://www.wikidata.org/w/api.php?action=wbgetentities&props=claims&ids=${qid}&format=json`, {
headers: { 'User-Agent': UA },
signal: AbortSignal.timeout(WIKI_TIMEOUT_MS),
});
const res = await fetch(
`https://www.wikidata.org/w/api.php?action=wbgetentities&props=claims&ids=${qid}&format=json`,
{
headers: { 'User-Agent': UA },
signal: AbortSignal.timeout(WIKI_TIMEOUT_MS),
},
);
if (!res.ok) return empty;
const data = (await res.json()) as { entities?: Record<string, { claims?: WikidataClaims }> };
const claims = data.entities?.[qid]?.claims;
+2 -1
View File
@@ -1,5 +1,6 @@
import { createZodDto } from 'nestjs-zod';
import { adminOidcUpdateRequestSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
/** Body of the admin OIDC settings route OidcController serves. */
export class AdminOidcUpdateDto extends createZodDto(adminOidcUpdateRequestSchema) {}
@@ -108,7 +108,10 @@ function fromPath(url: URL): DirWaypoint[] {
// The viewport and the blob end the list of stops; everything after them belongs to
// how the map was framed, not to where the car goes.
if (part.startsWith('@')) continue;
if (part.startsWith('data=')) { data = part; continue; }
if (part.startsWith('data=')) {
data = part;
continue;
}
if (data) continue;
const wp = asWaypoint(decodeSegment(part));
if (wp) names.push(wp);
@@ -119,7 +122,7 @@ function fromPath(url: URL): DirWaypoint[] {
// match cannot be lined up — Google drops the pair for "your location" and adds pairs
// of its own for a route through several countries — and half a link read confidently
// is worse than a link read as names and geocoded.
const pairs = [...(data.matchAll(DATA_LNG_LAT))]
const pairs = [...data.matchAll(DATA_LNG_LAT)]
.map((m) => ({ lng: Number.parseFloat(m[1]), lat: Number.parseFloat(m[2]) }))
.filter((p) => onEarth(p.lat, p.lng));
if (pairs.length !== names.length) return names;
+3 -1
View File
@@ -84,7 +84,9 @@ export interface GpxDocument {
/** A GPX 1.1 document, elements in the schema's order (metadata, wpt, rte, trk). */
export function writeGpx(doc: GpxDocument): string {
const namespaces = Object.fromEntries(Object.entries(doc.namespaces ?? {}).map(([prefix, uri]) => [`@_xmlns:${prefix}`, uri]));
const namespaces = Object.fromEntries(
Object.entries(doc.namespaces ?? {}).map(([prefix, uri]) => [`@_xmlns:${prefix}`, uri]),
);
return gpxBuilder.build({
'?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' },
gpx: {
+13 -8
View File
@@ -9,12 +9,13 @@
* Which placemarks become places, and under which category, is the importing
* domain's decision.
*/
import { TextDecoder } from 'util';
import { XMLParser, XMLValidator } from 'fast-xml-parser';
import unzipper from 'unzipper';
import { stripHtmlTags } from '../common/stripHtmlTags';
import type { KmlDocumentRead, KmlImportSummary, ParsedKmlPlacemark } from './place-import.types';
import { XMLParser, XMLValidator } from 'fast-xml-parser';
import unzipper from 'unzipper';
import { TextDecoder } from 'util';
export type { KmlImportSummary, ParsedKmlPlacemark } from './place-import.types';
export interface KmlPlacemarkNode {
@@ -110,14 +111,16 @@ export function sanitizeKmlDescription(value: unknown): string | null {
return decoded || null;
}
export function parseKmlLineStringCoordinates(value: unknown): Array<{ lat: number; lng: number; ele: number | null }> | null {
export function parseKmlLineStringCoordinates(
value: unknown,
): Array<{ lat: number; lng: number; ele: number | null }> | null {
const coordinates = asTrimmedString(value);
if (!coordinates) return null;
const points = coordinates
.trim()
.split(/\s+/)
.map(coord => {
.map((coord) => {
const parts = coord.split(',');
const lng = Number.parseFloat(parts[0] ?? '');
const lat = Number.parseFloat(parts[1] ?? '');
@@ -189,8 +192,8 @@ export function parsePlacemarkNode(node: KmlPlacemarkNode): ParsedKmlPlacemark {
const linePts = parseKmlLineStringCoordinates(node.placemark?.LineString?.coordinates);
if (linePts) {
pathFirstPt = { lat: linePts[0].lat, lng: linePts[0].lng };
const hasAllEle = linePts.every(p => p.ele !== null);
routeGeometry = JSON.stringify(linePts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]));
const hasAllEle = linePts.every((p) => p.ele !== null);
routeGeometry = JSON.stringify(linePts.map((p) => (hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng])));
}
}
@@ -249,7 +252,9 @@ export async function unpackKmzToKml(
throw new Error('Invalid KMZ archive.');
}
const kmlEntries = zip.files.filter((entry) => !entry.path.endsWith('/') && entry.path.toLowerCase().endsWith('.kml'));
const kmlEntries = zip.files.filter(
(entry) => !entry.path.endsWith('/') && entry.path.toLowerCase().endsWith('.kml'),
);
if (kmlEntries.length === 0) {
throw new Error('KMZ archive does not contain a KML file.');
}
@@ -1,11 +1,11 @@
import { Module } from '@nestjs/common';
import { PlaceImportService } from './place-import.service';
import { GpxProvider } from './providers/gpx.provider';
import { CollectionGpxProvider } from './providers/collection-gpx.provider';
import { KmlProvider } from './providers/kml.provider';
import { GoogleListProvider } from './providers/google-list.provider';
import { GoogleDirectionsProvider } from './providers/google-directions.provider';
import { GoogleListProvider } from './providers/google-list.provider';
import { GpxProvider } from './providers/gpx.provider';
import { KmlProvider } from './providers/kml.provider';
import { NaverListProvider } from './providers/naver-list.provider';
import { Module } from '@nestjs/common';
/**
* Place import (leaf module): reads places out of GPX, KML/KMZ, Google lists,
@@ -1,11 +1,3 @@
import { Injectable } from '@nestjs/common';
import type { CollectionGpxExport, CollectionGpxReadResult } from '@trek/shared';
import { GpxProvider, type GpxExportInput, type GpxExportOptions } from './providers/gpx.provider';
import { CollectionGpxProvider, type ExportedCollectionFile } from './providers/collection-gpx.provider';
import { KmlProvider } from './providers/kml.provider';
import { GoogleListProvider } from './providers/google-list.provider';
import { GoogleDirectionsProvider } from './providers/google-directions.provider';
import { NaverListProvider } from './providers/naver-list.provider';
import type {
DirectionsRead,
DirectionsRedirect,
@@ -17,6 +9,14 @@ import type {
NaverListPlace,
PreparedGpxPlace,
} from './place-import.types';
import { CollectionGpxProvider, type ExportedCollectionFile } from './providers/collection-gpx.provider';
import { GoogleDirectionsProvider } from './providers/google-directions.provider';
import { GoogleListProvider } from './providers/google-list.provider';
import { GpxProvider, type GpxExportInput, type GpxExportOptions } from './providers/gpx.provider';
import { KmlProvider } from './providers/kml.provider';
import { NaverListProvider } from './providers/naver-list.provider';
import { Injectable } from '@nestjs/common';
import type { CollectionGpxExport, CollectionGpxReadResult } from '@trek/shared';
// The directions-link reading is also what the road trip planner reads a
// shared route with, and what the import routes dispatch on.
@@ -1,5 +1,6 @@
import { coord, gpxTextParser, writeGpx } from '../gpx.codec';
import { CollectionGpxError } from '../place-import.types';
import { Injectable } from '@nestjs/common';
import type { ZodType } from 'zod';
import {
COLLECTION_FILE_FORMAT,
COLLECTION_FILE_VERSION,
@@ -19,8 +20,8 @@ import {
type CollectionGpxReadResult,
type CollectionLink,
} from '@trek/shared';
import { coord, gpxTextParser, writeGpx } from '../gpx.codec';
import { CollectionGpxError } from '../place-import.types';
import type { ZodType } from 'zod';
export { CollectionGpxError } from '../place-import.types';
@@ -51,7 +52,7 @@ const HTTP_URL = /^https?:\/\//i;
/** The extension fields that hold one value each; labels and links repeat. */
const SCALAR_EXTENSIONS = (Object.keys(COLLECTION_GPX_PLACE_FIELDS) as (keyof CollectionFilePlace)[]).filter(
field => COLLECTION_GPX_PLACE_FIELDS[field] === 'extension' && field !== 'labels' && field !== 'links',
(field) => COLLECTION_GPX_PLACE_FIELDS[field] === 'extension' && field !== 'labels' && field !== 'links',
);
// ---------------------------------------------------------------------------
@@ -64,7 +65,7 @@ const SCALAR_EXTENSIONS = (Object.keys(COLLECTION_GPX_PLACE_FIELDS) as (keyof Co
* name is enough for a strict reader to refuse the whole file.
*/
function xmlText(value: string): string {
return value.replace(/[\p{Cc}\p{Cs}\uFFFE\uFFFF]/gu, ch => (ch === '\t' || ch === '\n' || ch === '\r' ? ch : ''));
return value.replace(/[\p{Cc}\p{Cs}\uFFFE\uFFFF]/gu, (ch) => (ch === '\t' || ch === '\n' || ch === '\r' ? ch : ''));
}
/** A value as element text, or nothing, since an empty element is noise to every reader. */
@@ -92,9 +93,9 @@ function placeExtensions(place: CollectionFilePlace): Record<string, unknown> |
}
const labels = (place.labels ?? []).map(textOf).filter(Boolean);
if (labels.length) ext[`${TREK}:label`] = labels;
const links = (place.links ?? []).filter(link => HTTP_URL.test(link.url));
const links = (place.links ?? []).filter((link) => HTTP_URL.test(link.url));
if (links.length) {
ext[`${TREK}:link`] = links.map(link => ({ '@_href': xmlText(link.url), '#text': textOf(link.label) }));
ext[`${TREK}:link`] = links.map((link) => ({ '@_href': xmlText(link.url), '#text': textOf(link.label) }));
}
return Object.keys(ext).length ? ext : undefined;
}
@@ -119,7 +120,7 @@ function metadata(file: ExportedCollectionFile): Record<string, unknown> {
if (file.color) ext[`${TREK}:color`] = textOf(file.color);
if (file.icon) ext[`${TREK}:icon`] = textOf(file.icon);
if (file.labels?.length) {
ext[`${TREK}:label`] = file.labels.map(label => ({
ext[`${TREK}:label`] = file.labels.map((label) => ({
...(label.color ? { '@_color': xmlText(label.color) } : {}),
'#text': xmlText(label.name),
}));
@@ -238,8 +239,8 @@ function fit<T>(schema: ZodType<T>, raw: Record<string, unknown>, required: read
const given = Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== null && v !== undefined));
const first = schema.safeParse(given);
if (first.success) return first.data;
const refused = new Set(first.error.issues.map(issue => String(issue.path[0])));
if (required.some(field => refused.has(field))) return null;
const refused = new Set(first.error.issues.map((issue) => String(issue.path[0])));
if (required.some((field) => refused.has(field))) return null;
const second = schema.safeParse(Object.fromEntries(Object.entries(given).filter(([key]) => !refused.has(key))));
return second.success ? second.data : null;
}
@@ -248,10 +249,10 @@ const LABEL_NAME = collectionFilePlaceSchema.shape.labels.unwrap().element;
/** GPX 1.1 `<link href>`, or GPX 1.0's `<url>` beside `<urlname>`. */
function gpxLinks(el: XmlNode): CollectionLink[] {
const found = asArray(el.link).map(link => ({ url: attr(link, 'href'), label: text(node(link).text) }));
const found = asArray(el.link).map((link) => ({ url: attr(link, 'href'), label: text(node(link).text) }));
if (found.length === 0 && text(el.url)) found.push({ url: text(el.url), label: text(el.urlname) });
return found
.map(link => fit(collectionLinkSchema, link, ['url']))
.map((link) => fit(collectionLinkSchema, link, ['url']))
.filter((link): link is CollectionLink => link !== null);
}
@@ -294,7 +295,7 @@ function readPlace(value: unknown, fallbackName: string | null, prefixes: Prefix
const cmt = text(el.cmt);
const [website, ...moreLinks] = gpxLinks(el);
const ownLinks = asArray(own('link'))
.map(link => fit(collectionLinkSchema, { url: attr(link, 'href'), label: text(link) }, ['url']))
.map((link) => fit(collectionLinkSchema, { url: attr(link, 'href'), label: text(link) }, ['url']))
.filter((link): link is CollectionLink => link !== null);
const raw: Record<string, unknown> = {
@@ -325,7 +326,11 @@ function readPlace(value: unknown, fallbackName: string | null, prefixes: Prefix
}
function listName(root: XmlNode, meta: XmlNode, fileName: string | undefined): string {
const fromFile = fileName?.split(/[\\/]/).pop()?.replace(/\.gpx$/i, '').trim();
const fromFile = fileName
?.split(/[\\/]/)
.pop()
?.replace(/\.gpx$/i, '')
.trim();
// GPX 1.1 keeps the name in <metadata>, GPX 1.0 on the root.
const name = text(meta.name) ?? text(root.name) ?? (fromFile || 'GPX');
return clip(name, collectionFileSchema.shape.name.maxLength);
@@ -416,7 +421,7 @@ export function gpxToCollectionFile(source: string, fileName?: string): Collecti
const metaExt = node(meta.extensions);
const own = (field: string): unknown => (prefixes.trek ? metaExt[`${prefixes.trek}:${field}`] : undefined);
const labels = asArray(own('label'))
.map(label => fit(collectionFileLabelSchema, { name: text(label), color: attr(label, 'color') }, ['name']))
.map((label) => fit(collectionFileLabelSchema, { name: text(label), color: attr(label, 'color') }, ['name']))
.filter((label): label is CollectionFileLabel => label !== null)
.slice(0, MAX_COLLECTION_FILE_LABELS);
@@ -13,11 +13,11 @@
* provider asks no geocoder: choosing which source answers is MapsService's
* job, reached through PlaceImportService.
*/
import { Injectable } from '@nestjs/common';
import { checkSsrf, safeFetchFollow, SsrfBlockedError } from '../../../utils/ssrfGuard';
import { GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../../common/google-maps-hosts';
import { parseDirectionsUrl, type DirWaypoint } from '../directions-url.helpers';
import type { ListImportError } from '../place-import.types';
import { Injectable } from '@nestjs/common';
@Injectable()
export class GoogleDirectionsProvider {
@@ -27,7 +27,11 @@ export class GoogleDirectionsProvider {
if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 };
let parsed: URL;
try { parsed = new URL(url); } catch { return { error: 'Invalid URL', status: 400 }; }
try {
parsed = new URL(url);
} catch {
return { error: 'Invalid URL', status: 400 };
}
// Short links are resolved hop by hop through the guard, exactly as the list import
// does it: a maps.app.goo.gl that 302s to an internal address is still blocked.
@@ -45,14 +49,22 @@ export class GoogleDirectionsProvider {
// Checked after resolving, not before: the host that counts is the one the link lands
// on, and `/maps/dir/` is a path anybody could serve.
let host = '';
try { host = new URL(resolvedUrl).hostname; } catch { /* an unparseable hop fails the check below */ }
try {
host = new URL(resolvedUrl).hostname;
} catch {
/* an unparseable hop fails the check below */
}
if (!isGoogleMapsHost(host)) {
return { error: 'That link is not a Google Maps link.', status: 400 };
}
const waypoints = parseDirectionsUrl(resolvedUrl);
if (waypoints.length < 2) {
return { error: 'Could not read any stops from that directions link. Open the route in Google Maps and use its Share button.', status: 400 };
return {
error:
'Could not read any stops from that directions link. Open the route in Google Maps and use its Share button.',
status: 400,
};
}
return waypoints;
}
@@ -9,10 +9,10 @@
* attacker-influenced through the list id. Every refusal is the exact message
* and status the import route has always answered.
*/
import { Injectable } from '@nestjs/common';
import { checkSsrf, safeFetchFollow, SsrfBlockedError } from '../../../utils/ssrfGuard';
import { isDirectionsUrl } from '../directions-url.helpers';
import type { DirectionsRedirect, GoogleListPlace, ListImportError, ListRead } from '../place-import.types';
import { Injectable } from '@nestjs/common';
/** Cap on a provider list-import response body — the payload is attacker-influenced via the list id. */
export const MAX_LIST_RESPONSE_BYTES = 8 * 1024 * 1024; // 8 MB
@@ -37,10 +37,7 @@ export function googleMapsHexId(value: unknown): string | null {
export function googleMapsFeatureIdFromItem(item: unknown): string | null {
if (!Array.isArray(item)) return null;
const candidates = [
Array.isArray(item[1]) ? item[1][6] : null,
Array.isArray(item[7]) ? item[7][1] : null,
];
const candidates = [Array.isArray(item[1]) ? item[1][6] : null, Array.isArray(item[7]) ? item[7][1] : null];
for (const ids of candidates) {
if (!Array.isArray(ids) || ids.length < 2) continue;
@@ -100,7 +97,11 @@ export class GoogleListProvider {
// A single-place share link (…/maps/place/…) carries no list id — point the user at
// the place search box instead of a cryptic "could not extract list ID" (#1304).
if (resolvedUrl.includes('/maps/place/')) {
return { error: 'That link points to a single place, not a list. To add it, paste the link into the place search box instead of using the list import.', status: 400 };
return {
error:
'That link points to a single place, not a list. To add it, paste the link into the place search box instead of using the list import.',
status: 400,
};
}
return { error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.', status: 400 };
}
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { coord, gpxGeometryParser, writeGpx } from '../gpx.codec';
import type { GpxImportOptions, PreparedGpxPlace } from '../place-import.types';
import { Injectable } from '@nestjs/common';
/**
* A trip as GPX and back: the reader that turns a GPX file into the places an
@@ -98,7 +98,7 @@ export function buildGpx(input: GpxExportInput, opts: GpxExportOptions = {}): st
name: place.name,
desc: describe(place),
trkseg: {
trkpt: geometry.map(p => ({
trkpt: geometry.map((p) => ({
'@_lat': coord(p.lat),
'@_lon': coord(p.lng),
...(p.ele != null ? { ele: coord(p.ele) } : {}),
@@ -131,7 +131,7 @@ export function buildGpx(input: GpxExportInput, opts: GpxExportOptions = {}): st
: String(day.dayNumber);
rte.push({
name: label,
rtept: day.points.map(p => ({
rtept: day.points.map((p) => ({
'@_lat': coord(p.lat),
'@_lon': coord(p.lng),
name: p.name,
@@ -174,7 +174,10 @@ export function prepareGpxRows(fileBuffer: Buffer, opts: GpxImportOptions = {}):
if (!gpx) return [];
const str = (v: unknown) => (v != null ? String(v).trim() : null);
const num = (v: unknown) => { const n = Number.parseFloat(String(v)); return Number.isNaN(n) ? null : n; };
const num = (v: unknown) => {
const n = Number.parseFloat(String(v));
return Number.isNaN(n) ? null : n;
};
// Routes and tracks rarely carry their own <name>. Without one they all fall back to the
// same generic label, so name-based dedup drops every import after the first. Derive a
@@ -198,7 +201,12 @@ export function prepareGpxRows(fileBuffer: Buffer, opts: GpxImportOptions = {}):
const lat = num(wpt['@_lat']);
const lng = num(wpt['@_lon']);
if (lat === null || lng === null) continue;
waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) });
waypoints.push({
lat,
lng,
name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`,
description: str(wpt.desc),
});
}
}
@@ -207,11 +215,19 @@ export function prepareGpxRows(fileBuffer: Buffer, opts: GpxImportOptions = {}):
for (const rte of gpx.rte ?? []) {
const pts = (rte.rtept ?? [])
.map((pt: Record<string, unknown>) => ({ lat: num(pt['@_lat']), lng: num(pt['@_lon']), ele: num(pt['ele']) }))
.filter((p: { lat: number | null; lng: number | null; ele: number | null }) => p.lat !== null && p.lng !== null) as Array<{ lat: number; lng: number; ele: number | null }>;
.filter(
(p: { lat: number | null; lng: number | null; ele: number | null }) => p.lat !== null && p.lng !== null,
) as Array<{ lat: number; lng: number; ele: number | null }>;
if (pts.length === 0) continue;
const hasAllEle = pts.every(p => p.ele !== null);
const routeGeometry = pts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: geoName(str(rte.name), 'GPX Route'), description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) });
const hasAllEle = pts.every((p) => p.ele !== null);
const routeGeometry = pts.map((p) => (hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]));
waypoints.push({
lat: pts[0].lat,
lng: pts[0].lng,
name: geoName(str(rte.name), 'GPX Route'),
description: str(rte.desc),
routeGeometry: JSON.stringify(routeGeometry),
});
}
}
@@ -229,9 +245,15 @@ export function prepareGpxRows(fileBuffer: Buffer, opts: GpxImportOptions = {}):
}
if (trackPoints.length === 0) continue;
const start = trackPoints[0];
const hasAllEle = trackPoints.every(p => p.ele !== null);
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
waypoints.push({ lat: start.lat, lng: start.lng, name: geoName(str(trk.name), 'GPX Track'), description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
const hasAllEle = trackPoints.every((p) => p.ele !== null);
const routeGeometry = trackPoints.map((p) => (hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]));
waypoints.push({
lat: start.lat,
lng: start.lng,
name: geoName(str(trk.name), 'GPX Track'),
description: str(trk.desc),
routeGeometry: JSON.stringify(routeGeometry),
});
}
}
@@ -3,9 +3,9 @@
* into its placemarks. The file type is the upload's extension, as it has
* always been; anything else is refused with the route's own message.
*/
import { Injectable } from '@nestjs/common';
import { readKmlDocument, unpackKmzToKml } from '../kml.codec';
import type { KmlDocumentRead } from '../place-import.types';
import { Injectable } from '@nestjs/common';
@Injectable()
export class KmlProvider {
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripAccess, TripsRepository } from '../../db/repositories/Trips.repository';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
/**
* "May this user see this trip?" for every domain that is not the trips domain.
+6 -4
View File
@@ -1,6 +1,7 @@
import type { ZodType } from 'zod';
import { logWarn } from '../nest/audit/audit-log.logger';
import type { ZodType } from 'zod';
/**
* One JSON-in-TEXT column: where it lives, the shape it holds, and what a reader
* gets when the stored text is missing or broken.
@@ -27,8 +28,7 @@ export interface JsonColumn<T> {
export type JsonDecodeFailure = 'empty' | 'invalid-json' | 'schema';
export type JsonDecodeResult<T> =
| { ok: true; value: T; reason?: never }
| { ok: false; value?: never; reason: JsonDecodeFailure };
{ ok: true; value: T; reason?: never } | { ok: false; value?: never; reason: JsonDecodeFailure };
/**
* Decode a stored value without a fallback, for the readers that react to a
@@ -65,7 +65,9 @@ export function decodeJson<T>(col: JsonColumn<T>, raw: unknown, context?: string
/** Log a row whose JSON did not decode; for callers of {@link decodeJsonResult} that handle the failure themselves. */
export function logJsonFailure(col: JsonColumn<unknown>, reason: JsonDecodeFailure, context?: string): void {
logWarn(`[json] ${col.column}${context ? ` (${context})` : ''}: stored value ${reason === 'schema' ? 'has the wrong shape' : 'is not valid JSON'}, using the fallback`);
logWarn(
`[json] ${col.column}${context ? ` (${context})` : ''}: stored value ${reason === 'schema' ? 'has the wrong shape' : 'is not valid JSON'}, using the fallback`,
);
}
/** The text to store for a value, checked against the column's shape first (a mismatch throws: it is a bug in the writer). */