mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
fix: clear every open Sonar security issue, and half the reliability ones (#2037)
* fix(security): mint idempotency keys properly, and resolve the extractor by path Three findings out of a sweep of every open Sonar security issue. The other 49 turned out to be false positives and are documented in sonar-project.properties rather than changed — each entry scoped to a file, never a whole rule, so a future Math.random() on an auth path still fails. The idempotency key is the one that can lose data. Both producers fell back to Math.random when crypto.randomUUID was missing, and randomUUID needs a secure context — TREK's own quickstart is http://localhost:3000 and LAN installs run on http://192.168.x.x, so on the deployments the README documents the fallback is what actually runs. The axios one was `Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a single character and 0 yields the empty string, which the interceptor reads as "no key" and skips deduplication for entirely. Two writes sharing a key collapse into one — the second gets the first one's cached response and the edit is gone with no error anywhere. Both now come from utils/randomId, which has the getRandomValues rung that works in insecure contexts too. The extractor probe stored the bare binary name, so every extraction re-resolved it through whatever PATH held at that moment. The official image sets KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install without it ran an unqualified name from a PATH it does not control. It now walks the search path itself and keeps the absolute path of the file it verified; execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through app-config because process.env is walled off in src/. * fix: four tests and sorts that were not doing what they claimed Sonar reported 43 high-impact reliability issues. Reading all of them, four are real; the rest are cosmetic or would be made worse by the suggested change. budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid ? 1 : 0` and the handler stores the value verbatim — the test asserted a payload the server never sends. The line below it already used a number. admin.controller.test.ts had a `.resolves` assertion with no await. The promise was dropped, so that line could not fail the test. The two to-do category lists sorted with a bare .sort(), which is byte order: every accented name files behind the whole ASCII range, so in a German trip "Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data. One side effect worth knowing: katColor picks a category's dot colour by its index, so re-sorting can move which category shows which colour — it already moves whenever a category is added, and the colour carries no meaning. Deliberately not changed: the two `| 0` truncations (Math.trunc is not equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the two .map() callbacks whose second parameter is declared and used, and the sha1 in thumbnail.service (its own comment says a swap would orphan every existing thumbnail). * refactor: give every button a type, and prefer the Number statics Two mechanical sweeps, no behaviour change intended in either. A <button> with no type attribute is type="submit" per the HTML spec. Outside a form that does nothing today, but it turns into a surprise the moment the markup is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get an explicit type="button". Two things were left alone on purpose: buttons that take a {...spread}, because the spread may carry a type and the merge order decides which wins, and anything inside a <form> — there the answer is a judgement call, and checking all eleven form files found every real submit button already carrying type="submit". The Number sweep covers only the three globals that are exactly equivalent to their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false, and renaming it would change what the code does. It reaches server and plugin-sdk as well as the client; both copies of egress-policy.ts got the identical edit, so the mirror the SDK's parity test guards stays intact.
This commit is contained in:
@@ -233,6 +233,12 @@ export function deriveIntegrations(raw: RawEnv) {
|
||||
overpassUrl: raw.OVERPASS_URL,
|
||||
overpassTimeoutMs: positiveNumberOr(raw.OVERPASS_TIMEOUT_MS, 12000),
|
||||
kitineraryExtractorPath: raw.KITINERARY_EXTRACTOR_PATH,
|
||||
// Windows spells it Path; every other platform PATH. Split here so callers
|
||||
// get a list and never re-implement the delimiter.
|
||||
searchPath: (raw.PATH || raw.Path || '')
|
||||
.split(process.platform === 'win32' ? ';' : ':')
|
||||
.map(p => p.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,12 @@ export const envSchema = z.object({
|
||||
OVERPASS_URL: anyString,
|
||||
OVERPASS_TIMEOUT_MS: positiveNumber,
|
||||
KITINERARY_EXTRACTOR_PATH: anyString,
|
||||
// The OS search path. Not configuration anybody sets for TREK — it is here so
|
||||
// the kitinerary probe can resolve its binary to an absolute path itself
|
||||
// instead of handing an unqualified name to exec, which would re-resolve
|
||||
// through whatever PATH held at that moment.
|
||||
PATH: anyString,
|
||||
Path: anyString,
|
||||
|
||||
// Data / paths
|
||||
TREK_DB_FILE: anyString,
|
||||
|
||||
@@ -90,7 +90,7 @@ const DURATION_UNITS_MS: Record<string, number> = {
|
||||
export function parseDurationMs(value: string): number | null {
|
||||
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim());
|
||||
if (!m) return null;
|
||||
const n = parseFloat(m[1]);
|
||||
const n = Number.parseFloat(m[1]);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return n * DURATION_UNITS_MS[(m[2] || 'ms').toLowerCase()];
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function compareVersions(a: string, b: string): number {
|
||||
const parse = (v: string) => {
|
||||
const [base, pre] = v.split('-pre.');
|
||||
const parts = base.split('.').map(Number);
|
||||
const n = pre !== undefined ? parseInt(pre, 10) : null;
|
||||
const n = pre !== undefined ? Number.parseInt(pre, 10) : null;
|
||||
const preN = n !== null && Number.isFinite(n) ? n : null;
|
||||
return { parts, preN };
|
||||
};
|
||||
|
||||
@@ -249,7 +249,7 @@ export class AdminService {
|
||||
}
|
||||
|
||||
deleteUser(id: string, currentUserId: number) {
|
||||
if (parseInt(id) === currentUserId) {
|
||||
if (Number.parseInt(id) === currentUserId) {
|
||||
return { error: 'Cannot delete own account', status: 400 };
|
||||
}
|
||||
|
||||
@@ -328,8 +328,8 @@ export class AdminService {
|
||||
// ── Audit Log ──────────────────────────────────────────────────────────────
|
||||
|
||||
getAuditLog(query: { limit?: string; offset?: string }) {
|
||||
const limitRaw = parseInt(String(query.limit || '100'), 10);
|
||||
const offsetRaw = parseInt(String(query.offset || '0'), 10);
|
||||
const limitRaw = Number.parseInt(String(query.limit || '100'), 10);
|
||||
const offsetRaw = Number.parseInt(String(query.offset || '0'), 10);
|
||||
const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
|
||||
const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0);
|
||||
|
||||
|
||||
@@ -429,7 +429,7 @@ export class AtlasService {
|
||||
nextTrip.daysUntil = Math.max(0, diff);
|
||||
}
|
||||
|
||||
const tripYears = new Set(trips.filter((t) => t.start_date).map((t) => parseInt(t.start_date!.split('-')[0])));
|
||||
const tripYears = new Set(trips.filter((t) => t.start_date).map((t) => Number.parseInt(t.start_date!.split('-')[0])));
|
||||
let streak = 0;
|
||||
const currentYear = new Date().getFullYear();
|
||||
for (let y = currentYear; y >= 2000; y--) {
|
||||
|
||||
@@ -40,18 +40,18 @@ export class RegistrationInvitesService {
|
||||
createdBy: number,
|
||||
data: { max_uses?: string | number; expires_in_days?: string | number; trip_id?: string | number | null },
|
||||
) {
|
||||
const rawUses = parseInt(String(data.max_uses));
|
||||
const rawUses = Number.parseInt(String(data.max_uses));
|
||||
const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5);
|
||||
const token = crypto.randomBytes(16).toString('hex');
|
||||
const expiresAt = data.expires_in_days
|
||||
? new Date(Date.now() + parseInt(String(data.expires_in_days)) * 86400000).toISOString()
|
||||
? new Date(Date.now() + Number.parseInt(String(data.expires_in_days)) * 86400000).toISOString()
|
||||
: null;
|
||||
|
||||
// Optional trip binding: only persist a trip that actually exists, so a stale
|
||||
// or forged id can never bind (and never auto-adds anyone on registration).
|
||||
let tripId: number | null = null;
|
||||
if (data.trip_id != null && String(data.trip_id).trim() !== '') {
|
||||
const parsed = parseInt(String(data.trip_id));
|
||||
const parsed = Number.parseInt(String(data.trip_id));
|
||||
if (!Number.isInteger(parsed) || !this.db.get('SELECT id FROM trips WHERE id = ?', parsed)) {
|
||||
// Used to bind null silently, handing back a plain registration invite
|
||||
// the admin never asked for.
|
||||
|
||||
@@ -47,7 +47,7 @@ export function formatSize(bytes: number): string {
|
||||
export function parseIntField(raw: unknown, fallback: number): number {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
|
||||
if (typeof raw === 'string' && raw.trim() !== '') {
|
||||
const n = parseInt(raw, 10);
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return fallback;
|
||||
|
||||
@@ -315,7 +315,7 @@ export class BookingImportService {
|
||||
reservationData.metadata && typeof reservationData.metadata === 'object'
|
||||
? (reservationData.metadata as Record<string, unknown>)
|
||||
: null;
|
||||
const price = meta && meta.price != null ? Number(meta.price) : NaN;
|
||||
const price = meta && meta.price != null ? Number(meta.price) : Number.NaN;
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
try {
|
||||
const budgetData = {
|
||||
|
||||
@@ -5,11 +5,14 @@ import { tmpdir } from 'node:os';
|
||||
import { join, extname } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { KiReservation } from './kitinerary.types';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Also the leaf name looked for on each PATH entry. */
|
||||
const BINARY_NAME = 'kitinerary-extractor';
|
||||
const TIMEOUT_MS = 30_000;
|
||||
const MAX_BUFFER = 5 * 1024 * 1024;
|
||||
|
||||
@@ -89,16 +92,26 @@ export class KitineraryExtractorService implements OnModuleInit {
|
||||
// Debian/Ubuntu: /usr/lib/<triplet>/libexec/kf6/kitinerary-extractor
|
||||
try {
|
||||
for (const dir of readdirSync('/usr/lib')) {
|
||||
const candidate = join('/usr/lib', dir, 'libexec', 'kf6', 'kitinerary-extractor');
|
||||
const candidate = join('/usr/lib', dir, 'libexec', 'kf6', BINARY_NAME);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
} catch { /* not a Debian system */ }
|
||||
|
||||
// Fallback: binary in PATH
|
||||
try {
|
||||
execSync('kitinerary-extractor --version', { stdio: 'pipe', timeout: 3000 });
|
||||
return 'kitinerary-extractor';
|
||||
} catch { /* not in PATH */ }
|
||||
// Fallback: binary on the search path — resolved to an absolute path here,
|
||||
// not left as a bare name. Storing 'kitinerary-extractor' meant every later
|
||||
// extraction re-resolved it through whatever PATH held at that moment, so on
|
||||
// a bare-metal install (the official image sets KITINERARY_EXTRACTOR_PATH, so
|
||||
// this branch is dead there) anyone who could write to a PATH directory could
|
||||
// have their binary run as the TREK user. Probing the concrete file with
|
||||
// execFileSync also drops the /bin/sh hop the old execSync string needed.
|
||||
for (const dir of readEnv().integrations.searchPath) {
|
||||
const candidate = join(dir, BINARY_NAME);
|
||||
if (!existsSync(candidate)) continue;
|
||||
try {
|
||||
execFileSync(candidate, ['--version'], { stdio: 'pipe', timeout: 3000 });
|
||||
return candidate;
|
||||
} catch { /* present but not runnable — keep looking */ }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function flightSignature(flight: AirtrailFlightRaw): string | null {
|
||||
function orderConnectionChain(group: AirtrailFlightRaw[]): AirtrailFlightRaw[] | null {
|
||||
if (group.length < 2) return null;
|
||||
const norm = group.map(raw => ({ raw, n: normalizeFlight(raw) }));
|
||||
const depMs = (n: (typeof norm)[number]['n']): number => (n.departure ? Date.parse(n.departure) : NaN);
|
||||
const depMs = (n: (typeof norm)[number]['n']): number => (n.departure ? Date.parse(n.departure) : Number.NaN);
|
||||
if (norm.some(x => Number.isNaN(depMs(x.n)))) return null;
|
||||
norm.sort((a, b) => depMs(a.n) - depMs(b.n));
|
||||
const origin = norm[0].n.fromCode;
|
||||
@@ -71,7 +71,7 @@ function orderConnectionChain(group: AirtrailFlightRaw[]): AirtrailFlightRaw[] |
|
||||
const next = norm[i].n;
|
||||
if (!prev.toCode || !next.fromCode || prev.toCode.toUpperCase() !== next.fromCode.toUpperCase()) return null;
|
||||
if (origin && next.toCode && next.toCode.toUpperCase() === origin.toUpperCase()) return null;
|
||||
const arrMs = prev.arrival ? Date.parse(prev.arrival) : NaN;
|
||||
const arrMs = prev.arrival ? Date.parse(prev.arrival) : Number.NaN;
|
||||
if (Number.isNaN(arrMs)) return null;
|
||||
const gap = depMs(next) - arrMs;
|
||||
if (gap < 0 || gap > 24 * 3600 * 1000) return null;
|
||||
|
||||
@@ -22,7 +22,7 @@ export class AirtrailSyncJob implements OnApplicationBootstrap {
|
||||
onApplicationBootstrap(): void {
|
||||
if (!this.registrar.isEnabled()) return;
|
||||
const value = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', 'airtrail_poll_interval_minutes')?.value;
|
||||
const raw = parseInt(value || '5', 10);
|
||||
const raw = Number.parseInt(value || '5', 10);
|
||||
const minutes = Number.isFinite(raw) && raw >= 1 && raw <= 59 ? raw : 5;
|
||||
logInfo(`AirTrail sync: scheduled every ${minutes}m`);
|
||||
this.registrar.register('airtrail-sync', `*/${minutes} * * * *`, () => this.tick());
|
||||
|
||||
@@ -77,7 +77,7 @@ function decodeEntities(s: string): string {
|
||||
return s.replace(/&(#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]{1,30});/g, (whole, ref: string) => {
|
||||
if (ref[0] === '#') {
|
||||
const hex = ref[1] === 'x' || ref[1] === 'X';
|
||||
return entityCodePoint(parseInt(hex ? ref.slice(2) : ref.slice(1), hex ? 16 : 10)) ?? whole;
|
||||
return entityCodePoint(Number.parseInt(hex ? ref.slice(2) : ref.slice(1), hex ? 16 : 10)) ?? whole;
|
||||
}
|
||||
// Case matters (Ü vs ü); only fall back to lower case for names
|
||||
// that are shouted in old templates.
|
||||
|
||||
@@ -163,7 +163,7 @@ export class MapsController {
|
||||
// empty result, not a missing resource, and one 404 per photo-less place gets
|
||||
// the user's IP banned by any 404-rate IPS in front of TREK (#1727).
|
||||
try {
|
||||
return await this.maps.photo(user.id, placeId, parseFloat(lat as string), parseFloat(lng as string), name);
|
||||
return await this.maps.photo(user.id, placeId, Number.parseFloat(lat as string), Number.parseFloat(lng as string), name);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number }).status || 500;
|
||||
if (status >= 500) console.error('Place photo error:', err);
|
||||
|
||||
@@ -328,10 +328,10 @@ function osmPeriods(dayIdx: number, timePart: string): OpeningPeriod[] {
|
||||
const day = (dayIdx + 1) % 7;
|
||||
const periods: OpeningPeriod[] = [];
|
||||
for (const match of timePart.matchAll(OSM_TIME_RANGE)) {
|
||||
const openHour = parseInt(match[1], 10);
|
||||
const openMinute = parseInt(match[2], 10);
|
||||
let closeHour = parseInt(match[3], 10);
|
||||
const closeMinute = parseInt(match[4], 10);
|
||||
const openHour = Number.parseInt(match[1], 10);
|
||||
const openMinute = Number.parseInt(match[2], 10);
|
||||
let closeHour = Number.parseInt(match[3], 10);
|
||||
const closeMinute = Number.parseInt(match[4], 10);
|
||||
if (openHour > 23 || openMinute > 59 || closeHour > 24 || closeMinute > 59) continue;
|
||||
// OSM writes the end of a day as 24:00, a clock reading Google's numbering has no
|
||||
// hour 24 — that is midnight of the following day, same as any range that wraps.
|
||||
@@ -421,8 +421,8 @@ export function parseOpeningHours(ohString: string): {
|
||||
if (timeRanges.length > 0) {
|
||||
const nowMins = now.getHours() * 60 + now.getMinutes();
|
||||
openNow = timeRanges.some((m) => {
|
||||
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
|
||||
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
|
||||
const start = Number.parseInt(m[1]) * 60 + Number.parseInt(m[2]);
|
||||
const end = Number.parseInt(m[3]) * 60 + Number.parseInt(m[4]);
|
||||
return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -639,8 +639,8 @@ export class MapsService {
|
||||
return data.map((item) => {
|
||||
// Number.isFinite, not `|| null`: a place on the equator or prime
|
||||
// meridian has a legitimate 0 coordinate.
|
||||
const lat = parseFloat(item.lat);
|
||||
const lng = parseFloat(item.lon);
|
||||
const lat = Number.parseFloat(item.lat);
|
||||
const lng = Number.parseFloat(item.lon);
|
||||
return {
|
||||
google_place_id: null,
|
||||
google_ftid: null,
|
||||
@@ -714,8 +714,8 @@ export class MapsService {
|
||||
const best = data
|
||||
.map((item) => ({
|
||||
item,
|
||||
lat: parseFloat(item.lat),
|
||||
lng: parseFloat(item.lon),
|
||||
lat: Number.parseFloat(item.lat),
|
||||
lng: Number.parseFloat(item.lon),
|
||||
}))
|
||||
.filter(({ item, lat: hitLat, lng: hitLng }) => {
|
||||
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLng)) return false;
|
||||
@@ -773,8 +773,8 @@ export class MapsService {
|
||||
const data = (await res.json()) as NominatimResult[];
|
||||
const item = data[0];
|
||||
if (!item) return null;
|
||||
const lat = parseFloat(item.lat);
|
||||
const lng = parseFloat(item.lon);
|
||||
const lat = Number.parseFloat(item.lat);
|
||||
const lng = Number.parseFloat(item.lon);
|
||||
return {
|
||||
name: item.name || item.display_name?.split(',')[0] || '',
|
||||
address: item.display_name || '',
|
||||
@@ -2023,11 +2023,11 @@ export class MapsService {
|
||||
// them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=.
|
||||
const extractCoords = (s: string): { lat: number; lng: number } | null => {
|
||||
const at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (at) return { lat: parseFloat(at[1]), lng: parseFloat(at[2]) };
|
||||
if (at) return { lat: Number.parseFloat(at[1]), lng: Number.parseFloat(at[2]) };
|
||||
const data = s.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||||
if (data) return { lat: parseFloat(data[1]), lng: parseFloat(data[2]) };
|
||||
if (data) return { lat: Number.parseFloat(data[1]), lng: Number.parseFloat(data[2]) };
|
||||
const q = s.match(/[?&](?:q|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (q) return { lat: parseFloat(q[1]), lng: parseFloat(q[2]) };
|
||||
if (q) return { lat: Number.parseFloat(q[1]), lng: Number.parseFloat(q[2]) };
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -46,11 +46,11 @@ export class MailerService {
|
||||
if (!host || !port || !from) return null;
|
||||
return {
|
||||
host,
|
||||
port: parseInt(port, 10),
|
||||
port: Number.parseInt(port, 10),
|
||||
user: user || '',
|
||||
pass: pass || '',
|
||||
from,
|
||||
secure: parseInt(port, 10) === 465,
|
||||
secure: Number.parseInt(port, 10) === 465,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -132,8 +132,8 @@ export class NotificationsController {
|
||||
@Query('unread_only') unreadOnly?: string,
|
||||
) {
|
||||
return this.notifications.listInApp(user.id, {
|
||||
limit: Math.min(parseInt(limit as string) || 20, 50),
|
||||
offset: parseInt(offset as string) || 0,
|
||||
limit: Math.min(Number.parseInt(limit as string) || 20, 50),
|
||||
offset: Number.parseInt(offset as string) || 0,
|
||||
unreadOnly: unreadOnly === 'true',
|
||||
});
|
||||
}
|
||||
@@ -197,7 +197,7 @@ export class NotificationsController {
|
||||
|
||||
/** parseInt + the legacy "Invalid id" 400 guard, shared by the /:id handlers. */
|
||||
private parseId(idParam: string): number {
|
||||
const id = parseInt(idParam);
|
||||
const id = Number.parseInt(idParam);
|
||||
if (isNaN(id)) {
|
||||
throw new HttpException({ error: 'Invalid id' }, 400);
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ export class PackingController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
// You can drop your own pledge; the owner can remove anyone's.
|
||||
const target = parseInt(userId);
|
||||
const target = Number.parseInt(userId);
|
||||
const item = this.packing.removeContributor(tripId, id, target);
|
||||
if (!item) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
|
||||
@@ -434,7 +434,7 @@ export class PackingService {
|
||||
for (const item of items) {
|
||||
if (!item.name?.trim()) continue;
|
||||
const checked = item.checked ? 1 : 0;
|
||||
const weight = item.weight_grams ? parseInt(String(item.weight_grams)) || null : null;
|
||||
const weight = item.weight_grams ? Number.parseInt(String(item.weight_grams)) || null : null;
|
||||
|
||||
// Resolve bag by name if provided
|
||||
let bagId = null;
|
||||
|
||||
Binary file not shown.
@@ -109,7 +109,7 @@ export function parseKmlLineStringCoordinates(value: unknown): Array<{ lat: numb
|
||||
const parts = coord.split(',');
|
||||
const lng = Number.parseFloat(parts[0] ?? '');
|
||||
const lat = Number.parseFloat(parts[1] ?? '');
|
||||
const eleRaw = parts[2] != null ? Number.parseFloat(parts[2]) : NaN;
|
||||
const eleRaw = parts[2] != null ? Number.parseFloat(parts[2]) : Number.NaN;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
return { lat, lng, ele: Number.isFinite(eleRaw) ? eleRaw : null };
|
||||
})
|
||||
|
||||
@@ -623,7 +623,7 @@ export class PlacesService {
|
||||
if (!gpx) return null;
|
||||
|
||||
const str = (v: unknown) => (v != null ? String(v).trim() : null);
|
||||
const num = (v: unknown) => { const n = parseFloat(String(v)); return isNaN(n) ? null : n; };
|
||||
const num = (v: unknown) => { const n = Number.parseFloat(String(v)); return 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
|
||||
|
||||
@@ -82,7 +82,7 @@ function readTarGz(buf: Buffer, lim: Required<ExtractLimits>): Member[] {
|
||||
let name = block.subarray(0, 100).toString('utf8').replace(/\0.*$/, '');
|
||||
const prefix = block.subarray(345, 500).toString('utf8').replace(/\0.*$/, '');
|
||||
if (prefix) name = `${prefix}/${name}`;
|
||||
const size = parseInt(block.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim() || '0', 8);
|
||||
const size = Number.parseInt(block.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim() || '0', 8);
|
||||
// A negative size walks the cursor backwards: `off += 512` and then
|
||||
// `off += Math.ceil(size / 512) * 512` cancel out at -512 and go negative
|
||||
// below it, so the same header is read forever while every pass appends a
|
||||
|
||||
@@ -84,7 +84,7 @@ function expandV6(ip: string): number[] | null {
|
||||
const groups =
|
||||
halves.length === 2 ? [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail] : head;
|
||||
if (groups.length !== 8) return null;
|
||||
const nums = groups.map((x) => (x === '' ? NaN : parseInt(x, 16)));
|
||||
const nums = groups.map((x) => (x === '' ? Number.NaN : Number.parseInt(x, 16)));
|
||||
return nums.some((n) => !Number.isInteger(n) || n < 0 || n > 0xffff) ? null : nums;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export class TripInviteLinkController {
|
||||
) {
|
||||
this.requireManage(tripId, user);
|
||||
const days = body?.expires_in_days != null && String(body.expires_in_days).trim() !== ''
|
||||
? parseInt(String(body.expires_in_days))
|
||||
? Number.parseInt(String(body.expires_in_days))
|
||||
: null;
|
||||
const info = this.invites.createOrRotate(tripId, user.id, Number.isFinite(days as number) ? days : null);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_create', resource: tripId, ip: getClientIp(req), details: { expires_in_days: days } });
|
||||
|
||||
@@ -84,7 +84,7 @@ export class TripMembersController {
|
||||
if (!access) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
const targetId = parseInt(userId);
|
||||
const targetId = Number.parseInt(userId);
|
||||
if (targetId !== user.id && !this.roster.can('member_manage', user.role, access.user_id, user.id, access.user_id !== user.id)) {
|
||||
throw new HttpException({ error: 'No permission to remove members' }, 403);
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export class TripMembersController {
|
||||
@RequireTripOwner('Only the owner can manage guests', { param: 'id' })
|
||||
renameGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: TripRenameGuestDto) {
|
||||
try {
|
||||
if (!this.roster.renameGuest(id, parseInt(userId), body.name)) {
|
||||
if (!this.roster.renameGuest(id, Number.parseInt(userId), body.name)) {
|
||||
throw new HttpException({ error: 'Guest not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
@@ -154,7 +154,7 @@ export class TripMembersController {
|
||||
@UseGuards(TripOwnerGuard)
|
||||
@RequireTripOwner('Only the owner can manage guests', { param: 'id' })
|
||||
deleteGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) {
|
||||
if (!this.roster.deleteGuest(id, parseInt(userId))) {
|
||||
if (!this.roster.deleteGuest(id, Number.parseInt(userId))) {
|
||||
throw new HttpException({ error: 'Guest not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
|
||||
@@ -83,7 +83,7 @@ export class VacayController {
|
||||
@Body() body: VacayUpdateHolidayCalendarDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const id = parseInt(idParam);
|
||||
const id = Number.parseInt(idParam);
|
||||
const planId = this.vacay.getActivePlanId(user.id);
|
||||
const calendar = this.vacay.updateHolidayCalendar(id, planId, body, socketId);
|
||||
if (!calendar) {
|
||||
@@ -94,7 +94,7 @@ export class VacayController {
|
||||
|
||||
@Delete('plan/holiday-calendars/:id')
|
||||
deleteHolidayCalendar(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const id = parseInt(idParam);
|
||||
const id = Number.parseInt(idParam);
|
||||
const planId = this.vacay.getActivePlanId(user.id);
|
||||
if (!this.vacay.deleteHolidayCalendar(id, planId, socketId)) {
|
||||
throw new HttpException({ error: 'Calendar not found' }, 404);
|
||||
@@ -109,7 +109,7 @@ export class VacayController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const planId = this.vacay.getActivePlanId(user.id);
|
||||
const userId = body.target_user_id ? parseInt(String(body.target_user_id)) : user.id;
|
||||
const userId = body.target_user_id ? Number.parseInt(String(body.target_user_id)) : user.id;
|
||||
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === userId)) {
|
||||
throw new HttpException({ error: 'User not in plan' }, 403);
|
||||
}
|
||||
@@ -187,7 +187,7 @@ export class VacayController {
|
||||
|
||||
@Delete('years/:year')
|
||||
deleteYear(@CurrentUser() user: User, @Param('year') yearParam: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const year = parseInt(yearParam);
|
||||
const year = Number.parseInt(yearParam);
|
||||
const planId = this.vacay.getActivePlanId(user.id);
|
||||
return { years: this.vacay.deleteYear(planId, year, socketId) };
|
||||
}
|
||||
@@ -222,8 +222,8 @@ export class VacayController {
|
||||
) {
|
||||
const planId = this.vacay.getActivePlanId(user.id);
|
||||
let userId = user.id;
|
||||
if (body.target_user_id && parseInt(String(body.target_user_id)) !== user.id) {
|
||||
const tid = parseInt(String(body.target_user_id));
|
||||
if (body.target_user_id && Number.parseInt(String(body.target_user_id)) !== user.id) {
|
||||
const tid = Number.parseInt(String(body.target_user_id));
|
||||
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === tid)) {
|
||||
throw new HttpException({ error: 'User not in plan' }, 403);
|
||||
}
|
||||
@@ -249,7 +249,7 @@ export class VacayController {
|
||||
|
||||
@Get('stats/:year')
|
||||
stats(@CurrentUser() user: User, @Param('year') yearParam: string) {
|
||||
const year = parseInt(yearParam);
|
||||
const year = Number.parseInt(yearParam);
|
||||
const planId = this.vacay.getActivePlanId(user.id);
|
||||
return { stats: this.vacay.getStats(planId, year) };
|
||||
}
|
||||
@@ -261,9 +261,9 @@ export class VacayController {
|
||||
@Body() body: VacayUpdateStatsDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const year = parseInt(yearParam);
|
||||
const year = Number.parseInt(yearParam);
|
||||
const planId = this.vacay.getActivePlanId(user.id);
|
||||
const userId = body.target_user_id ? parseInt(String(body.target_user_id)) : user.id;
|
||||
const userId = body.target_user_id ? Number.parseInt(String(body.target_user_id)) : user.id;
|
||||
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === userId)) {
|
||||
throw new HttpException({ error: 'User not in plan' }, 403);
|
||||
}
|
||||
@@ -286,7 +286,7 @@ export class VacayController {
|
||||
if (!body.user_id) {
|
||||
throw new HttpException({ error: 'user_id required' }, 400);
|
||||
}
|
||||
const result = this.vacay.shareCalendar(user.id, user.email, parseInt(String(body.user_id)), socketId);
|
||||
const result = this.vacay.shareCalendar(user.id, user.email, Number.parseInt(String(body.user_id)), socketId);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
@@ -310,7 +310,7 @@ export class VacayController {
|
||||
@Body() body: VacayShareUpdateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
if (!this.vacay.setShareHidden(parseInt(idParam), user.id, body.hidden, socketId)) {
|
||||
if (!this.vacay.setShareHidden(Number.parseInt(idParam), user.id, body.hidden, socketId)) {
|
||||
throw new HttpException({ error: 'Share not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
@@ -318,7 +318,7 @@ export class VacayController {
|
||||
|
||||
@Delete('shares/:id')
|
||||
deleteShare(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!this.vacay.removeShare(parseInt(idParam), user.id, socketId)) {
|
||||
if (!this.vacay.removeShare(Number.parseInt(idParam), user.id, socketId)) {
|
||||
throw new HttpException({ error: 'Share not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
|
||||
@@ -158,7 +158,7 @@ function normalizeYearType(value: unknown): 'calendar' | 'fiscal' | 'anniversary
|
||||
* ending on Jan 1 (the calendar default) stops inside the previous year.
|
||||
*/
|
||||
function windowEndYear(end: string): number {
|
||||
const y = parseInt(end.slice(0, 4), 10);
|
||||
const y = Number.parseInt(end.slice(0, 4), 10);
|
||||
return end.endsWith('-01-01') ? y - 1 : y;
|
||||
}
|
||||
|
||||
@@ -255,8 +255,8 @@ export class VacayService {
|
||||
let day = s.year_start_day || 1;
|
||||
if (s.year_type === 'anniversary') {
|
||||
const parts = s.hire_date!.split('-');
|
||||
month = parseInt(parts[1], 10) || 1;
|
||||
day = parseInt(parts[2], 10) || 1;
|
||||
month = Number.parseInt(parts[1], 10) || 1;
|
||||
day = Number.parseInt(parts[2], 10) || 1;
|
||||
}
|
||||
month = Math.min(12, Math.max(1, month));
|
||||
// A Feb 29 hire date (or a stored Feb 30) would name a boundary that most years
|
||||
@@ -272,7 +272,7 @@ export class VacayService {
|
||||
* plain calendar year, which is exactly what a 'calendar' user resolves to.
|
||||
*/
|
||||
private viewerWindow(year: number | string, viewerId?: number): { start: string; end: string } {
|
||||
const y = typeof year === 'number' ? year : parseInt(year, 10);
|
||||
const y = typeof year === 'number' ? year : Number.parseInt(year, 10);
|
||||
// A non-numeric year matched nothing under the old date prefix; an empty range
|
||||
// matches nothing either, so a bad parameter still yields an empty result.
|
||||
if (!Number.isFinite(y)) return { start: '', end: '' };
|
||||
@@ -316,8 +316,8 @@ export class VacayService {
|
||||
data: { year_type?: unknown; year_start_month?: unknown; year_start_day?: unknown; hire_date?: unknown },
|
||||
): VacayUserSettings {
|
||||
const type = normalizeYearType(data.year_type);
|
||||
const month = Math.min(12, Math.max(1, parseInt(String(data.year_start_month ?? 1), 10) || 1));
|
||||
const day = Math.min(31, Math.max(1, parseInt(String(data.year_start_day ?? 1), 10) || 1));
|
||||
const month = Math.min(12, Math.max(1, Number.parseInt(String(data.year_start_month ?? 1), 10) || 1));
|
||||
const day = Math.min(31, Math.max(1, Number.parseInt(String(data.year_start_day ?? 1), 10) || 1));
|
||||
const hire = typeof data.hire_date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(data.hire_date) ? data.hire_date : null;
|
||||
this.db.run(`
|
||||
INSERT INTO vacay_user_settings (user_id, year_type, year_start_month, year_start_day, hire_date)
|
||||
|
||||
@@ -49,7 +49,7 @@ async function fetchOpenMeteo(url: string): Promise<{ response: Response; data:
|
||||
function coord(value: string, max: number, label: string): number {
|
||||
// Number('') and Number(' ') are 0, which would silently geolocate the Gulf
|
||||
// of Guinea instead of failing, so an empty value is rejected outright.
|
||||
const n = String(value ?? '').trim() === '' ? NaN : Number(value);
|
||||
const n = String(value ?? '').trim() === '' ? Number.NaN : Number(value);
|
||||
if (!Number.isFinite(n) || Math.abs(n) > max) {
|
||||
throw new ApiError(400, `Invalid ${label}`);
|
||||
}
|
||||
@@ -199,8 +199,8 @@ const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
|
||||
const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export function cacheKey(lat: string, lng: string, date?: string): string {
|
||||
const rlat = parseFloat(lat).toFixed(2);
|
||||
const rlng = parseFloat(lng).toFixed(2);
|
||||
const rlat = Number.parseFloat(lat).toFixed(2);
|
||||
const rlng = Number.parseFloat(lng).toFixed(2);
|
||||
return `${rlat}_${rlng}_${date || 'current'}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export function expandIpv6(ip: string): number[] | null {
|
||||
const groups =
|
||||
halves.length === 2 ? [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail] : head;
|
||||
if (groups.length !== 8) return null;
|
||||
const nums = groups.map((x) => (x === '' ? NaN : parseInt(x, 16)));
|
||||
const nums = groups.map((x) => (x === '' ? Number.NaN : Number.parseInt(x, 16)));
|
||||
return nums.some((n) => !Number.isInteger(n) || n < 0 || n > 0xffff) ? null : nums;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user