mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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'. */
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import type { Scope, ScopeGroup } from '../mcp/scopes';
|
||||
|
||||
import type { InjectionToken, ModuleMetadata } from '@nestjs/common';
|
||||
|
||||
import type { ZodRawShape, ZodType } from 'zod';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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,4 +1,5 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { Request } from 'express';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user