mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.
**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.
`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.
**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.
Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.
sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { readEnv } from './env';
|
||||
import { stripTrailingSlashes } from './parsers';
|
||||
|
||||
/**
|
||||
* Instance base-URL resolution: APP_URL → first ALLOWED_ORIGINS entry →
|
||||
@@ -12,7 +13,7 @@ export function getAppUrl(): string {
|
||||
if (appUrl) {
|
||||
try {
|
||||
const _ = new URL(appUrl);
|
||||
return appUrl.replace(/\/+$/, '');
|
||||
return stripTrailingSlashes(appUrl);
|
||||
} catch (_ignored) {}
|
||||
}
|
||||
const origins = readEnv().http.allowedOriginsRaw;
|
||||
@@ -21,7 +22,7 @@ export function getAppUrl(): string {
|
||||
if (first) {
|
||||
try {
|
||||
const _ = new URL(first);
|
||||
return first.replace(/\/+$/, '');
|
||||
return stripTrailingSlashes(first);
|
||||
} catch (_ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +68,13 @@ export function csvListFiltered(raw: string | undefined): string[] | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Strip ALL trailing slashes (`/\/+$/`) — app-url.getAppUrl / TRANSIT_API_URL variant. */
|
||||
/** Strip ALL trailing slashes (`/\/+$/`) — app-url.getAppUrl / TRANSIT_API_URL variant.
|
||||
* Scanned rather than replaced: `/\/+$/` re-walks the slash run from every start
|
||||
* position, which is quadratic on a slash-heavy value. Same result, same quirk. */
|
||||
export function stripTrailingSlashes(value: string): string {
|
||||
return value.replace(/\/+$/, '');
|
||||
let end = value.length;
|
||||
while (end > 0 && value.charCodeAt(end - 1) === 47 /* '/' */) end--;
|
||||
return value.slice(0, end);
|
||||
}
|
||||
|
||||
const DURATION_UNITS_MS: Record<string, number> = {
|
||||
|
||||
@@ -376,7 +376,8 @@ export class AtlasService {
|
||||
const visited = countries.filter((c) => c.status === 'visited');
|
||||
const planned = countries.filter((c) => c.status === 'planned');
|
||||
|
||||
const mostVisited = visited.length > 0 ? visited.reduce((a, b) => (a.placeCount > b.placeCount ? a : b)) : null;
|
||||
const mostVisited =
|
||||
visited.length > 0 ? visited.reduce((a, b) => (a.placeCount > b.placeCount ? a : b), visited[0]) : null;
|
||||
|
||||
const continents: Record<string, number> = {};
|
||||
visited.forEach((c) => {
|
||||
|
||||
@@ -16,8 +16,10 @@ import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
// resetPassword and the dummy-hash timing equaliser below — must stay in sync.
|
||||
export const BCRYPT_COST = 12;
|
||||
|
||||
// Shape check for email input on register and profile update.
|
||||
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
// Shape check for email input on register and profile update. The dot is pinned to
|
||||
// the first one that can split the domain, so the runs on either side of it can
|
||||
// never claim the same characters. Same strings as before, without backtracking.
|
||||
export const EMAIL_REGEX = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/;
|
||||
|
||||
// Pre-computed bcrypt hash to equalise timing of "unknown email" and
|
||||
// "OIDC-only account" branches with the real verification path (CWE-208).
|
||||
|
||||
@@ -855,8 +855,10 @@ export class AuthService {
|
||||
requestPasswordReset(rawEmail: string, createdIp: string | null): PasswordResetRequestOutcome {
|
||||
const email = String(rawEmail || '').trim().toLowerCase();
|
||||
// Basic shape check — a fully empty / malformed email is treated like
|
||||
// "no user" so we still spend the same time internally.
|
||||
const looksLikeEmail = email.length > 0 && /.+@.+\..+/.test(email);
|
||||
// "no user" so we still spend the same time internally. Same "x@y.z somewhere
|
||||
// on one line" test as the old /.+@.+\..+/, but anchored per line and pinned to
|
||||
// the first usable '@' and '.', so a body full of '@' can no longer stall it.
|
||||
const looksLikeEmail = email.length > 0 && /^.[^@\r\n\u2028\u2029]*@.[^.\r\n\u2028\u2029]*\../m.test(email);
|
||||
|
||||
// Global policy check: password login disabled → no reset possible.
|
||||
const toggles = this.resolveAuthToggles();
|
||||
|
||||
@@ -59,7 +59,9 @@ export class WebauthnConfigService {
|
||||
const explicitRpId = (readEnv().webauthn.rpId || this.setting('webauthn_rp_id'))?.trim() || null;
|
||||
const explicitOrigins = (readEnv().webauthn.origins || this.setting('webauthn_origins') || '')
|
||||
.split(',')
|
||||
.map((o) => o.trim().replace(/\/+$/, ''))
|
||||
// The lookbehind matches only the first slash of the trailing run. Without it the
|
||||
// engine retries from every slash, which is quadratic on a slash-heavy value.
|
||||
.map((o) => o.trim().replace(/(?<!\/)\/+$/, ''))
|
||||
.filter(Boolean);
|
||||
|
||||
const appUrl = getAppUrl();
|
||||
@@ -77,7 +79,7 @@ export class WebauthnConfigService {
|
||||
// silently union dev localhost origins into a production allow-list.
|
||||
let origins = explicitOrigins;
|
||||
if (origins.length === 0) {
|
||||
if (appHost) origins = [appUrl.replace(/\/+$/, '')];
|
||||
if (appHost) origins = [appUrl.replace(/(?<!\/)\/+$/, '')];
|
||||
if (rpID === 'localhost') {
|
||||
// Dev: the browser origin is the Vite dev server (:5173), not the API port.
|
||||
origins = Array.from(new Set([...origins, 'http://localhost:5173', 'http://localhost:3001']));
|
||||
|
||||
@@ -239,7 +239,7 @@ export async function createBackup(storage: StorageService, prefix: 'backup' | '
|
||||
let dbToArchive = dbPath;
|
||||
try {
|
||||
if (fs.existsSync(dbSnap)) fs.rmSync(dbSnap, { force: true });
|
||||
db.exec(`VACUUM INTO '${dbSnap.replace(/'/g, "''")}'`);
|
||||
db.exec(`VACUUM INTO '${dbSnap.replaceAll("'", "''")}'`);
|
||||
dbToArchive = dbSnap;
|
||||
} catch (e) {
|
||||
// Snapshot failed (disk/lock) — fall back to the checkpointed live file rather
|
||||
|
||||
@@ -165,12 +165,12 @@ export class CalendarService {
|
||||
.all(tripId) as any[];
|
||||
|
||||
const esc = (s: string) => s
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replaceAll(/\\/g, '\\\\')
|
||||
.replaceAll(';', '\\;')
|
||||
.replaceAll(',', '\\,')
|
||||
.replace(/\r?\n/g, '\\n')
|
||||
.replace(/\r/g, '');
|
||||
const fmtDate = (d: string) => d.replace(/-/g, '');
|
||||
.replaceAll(/\r/g, '');
|
||||
const fmtDate = (d: string) => d.replaceAll('-', '');
|
||||
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
const uid = (id: number, type: string) => `trek-${type}-${id}@trek`;
|
||||
|
||||
@@ -189,7 +189,7 @@ export class CalendarService {
|
||||
// the TZID and leaves a floating time in the feed (#1453 again).
|
||||
if (refDate && d.match(/^\d{2}:\d{2}/)) {
|
||||
const datePart = refDate.split('T')[0];
|
||||
const raw = `${datePart}T${d.replace(/:/g, '')}`.replace(/-/g, '');
|
||||
const raw = `${datePart}T${d.replaceAll(':', '')}`.replaceAll('-', '');
|
||||
return raw.length === 13 ? raw + '00' : raw;
|
||||
}
|
||||
return d.replace(/[-:]/g, '');
|
||||
|
||||
@@ -76,7 +76,7 @@ export class IdempotencyInterceptor implements NestInterceptor {
|
||||
|
||||
const signature = `${userId}|${req.method}|${req.path}|${key}`;
|
||||
const pending = inFlight.get(signature);
|
||||
if (pending) {
|
||||
if (pending !== undefined) {
|
||||
return from(pending).pipe(
|
||||
switchMap(() => {
|
||||
const stored = this.lookup(key, userId, req);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Remove HTML-ish tags from a string, exactly as `/<[^>]+>/g` did.
|
||||
*
|
||||
* Written as a scan rather than a regex because that pattern backtracks: `[^>]`
|
||||
* matches `<` too, so on a run of `<` with no closing `>` the engine retries
|
||||
* from every one of them and rescans to the end each time. Measured on a 16k
|
||||
* run: 0.52s, and both callers take attacker-supplied text — an uploaded KML
|
||||
* description and Wikimedia Commons metadata.
|
||||
*
|
||||
* The semantics are the regex's, quirks included:
|
||||
* - a tag needs at least one character between the brackets, so `<>` is left
|
||||
* alone (`[^>]+` is one-or-more);
|
||||
* - `<` inside a tag is content, so `<a<b>` is one tag, not two — which is
|
||||
* what browsers do with malformed markup, and why narrowing the class to
|
||||
* `[^<>]` would have been a behaviour change rather than a fix;
|
||||
* - an unterminated `<` at the end is literal text.
|
||||
*/
|
||||
export function stripHtmlTags(input: string, replacement = ''): string {
|
||||
let out = '';
|
||||
let cursor = 0;
|
||||
|
||||
for (;;) {
|
||||
const open = input.indexOf('<', cursor);
|
||||
if (open === -1) break;
|
||||
|
||||
const close = input.indexOf('>', open + 1);
|
||||
// No closing bracket anywhere after: the rest is literal, and this is the
|
||||
// case the regex spent quadratic time discovering.
|
||||
if (close === -1) break;
|
||||
|
||||
// `<>` — nothing between the brackets, so not a tag. Keep the `<` and carry
|
||||
// on from the next character, exactly as the regex's next match attempt would.
|
||||
if (close === open + 1) {
|
||||
out += input.slice(cursor, open + 1);
|
||||
cursor = open + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
out += input.slice(cursor, open) + replacement;
|
||||
cursor = close + 1;
|
||||
}
|
||||
|
||||
return out + input.slice(cursor);
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export class FeedsService {
|
||||
.all(user.id, user.id, cutoff) as { id: number }[];
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
|
||||
s.replaceAll('\\', '\\\\').replaceAll(';', '\\;').replaceAll(',', '\\,').replace(/\r?\n/g, '\\n');
|
||||
|
||||
const calName = `${user.username} – All Trips`;
|
||||
let header = CALENDAR_HEADER;
|
||||
|
||||
@@ -116,7 +116,11 @@ function parseSidebar(md: string): WikiNavSection[] {
|
||||
const sections: WikiNavSection[] = [];
|
||||
let current: WikiNavSection | null = null;
|
||||
for (const raw of md.split('\n')) {
|
||||
const heading = raw.match(/^#{1,4}\s+(.+?)\s*$/);
|
||||
// The title, trimmed: a run that starts and ends on a non-space, or a single
|
||||
// character (a heading whose body is only spaces still opens a section, the way
|
||||
// `(.+?)\s*$` did). Spelled out so no two quantifiers compete for the same
|
||||
// spaces — that pairing backtracks quadratically on a long ragged line.
|
||||
const heading = raw.match(/^#{1,4}\s+(\S.*\S|.)\s*$/);
|
||||
if (heading) {
|
||||
current = { title: heading[1].replace(/[*_`]/g, '').trim(), pages: [] };
|
||||
sections.push(current);
|
||||
@@ -151,7 +155,9 @@ function processMarkdown(md: string): string {
|
||||
const hash = anchor ? `#${anchor.trim()}` : '';
|
||||
return `[${title.trim()}](/help/${slug}${hash})`;
|
||||
});
|
||||
out = out.replace(/!\[([^\]]*)\]\(([^)\s]+)([^)]*)\)/g, (m, alt: string, url: string) => {
|
||||
// The optional title after the URL (``) has to start on whitespace,
|
||||
// so it cannot compete with the URL group for the same characters.
|
||||
out = out.replace(/!\[([^\]]*)\]\(([^)\s]+)(\s[^)]*)?\)/g, (m, alt: string, url: string) => {
|
||||
if (/^https?:\/\//i.test(url) || url.startsWith('/api/help/asset/')) return m;
|
||||
const clean = url.replace(/^\.?\//, '').replace(/^wiki\//, '');
|
||||
return ``;
|
||||
@@ -196,8 +202,9 @@ function outsideCode(md: string, fn: (segment: string) => string): string {
|
||||
}
|
||||
|
||||
function extractTitle(md: string, fallback: string): string {
|
||||
const h1 = md.match(/^#\s+(.+?)\s*$/m);
|
||||
return h1 ? h1[1].replace(/[*_`]/g, '').trim() : fallback.replace(/-/g, ' ');
|
||||
// Same shape as the sidebar heading above — see the note there.
|
||||
const h1 = md.match(/^#\s+(\S.*\S|.)\s*$/m);
|
||||
return h1 ? h1[1].replace(/[*_`]/g, '').trim() : fallback.replaceAll('-', ' ');
|
||||
}
|
||||
|
||||
export interface WikiPage {
|
||||
|
||||
@@ -154,7 +154,9 @@ export interface AirtrailPassengerWrite {
|
||||
|
||||
function apiBase(baseUrl: string): string {
|
||||
// Tolerate a pasted trailing slash or '/api' suffix so we never build '/api/api'.
|
||||
const origin = baseUrl.trim().replace(/\/+$/, '').replace(/\/api$/i, '');
|
||||
// The lookbehind matches only the first slash of the trailing run. Without it the
|
||||
// engine retries from every slash, which is quadratic on a slash-heavy value.
|
||||
const origin = baseUrl.trim().replace(/(?<!\/)\/+$/, '').replace(/\/api$/i, '');
|
||||
return origin + '/api';
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ export class JourneyPublicController {
|
||||
{ userId: effectiveOwnerId, ownerId: effectiveOwnerId, assetId },
|
||||
wantThumb,
|
||||
);
|
||||
if (!streaming) {
|
||||
if (streaming === null) {
|
||||
res.status(404).json({ error: 'Provider not supported' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ const MAX_TOKENS = 4096;
|
||||
*/
|
||||
export class OpenAiCompatibleClient implements LlmExtractionClient {
|
||||
async extract(input: LlmExtractionInput): Promise<Record<string, unknown>[]> {
|
||||
const base = (input.baseUrl ?? 'https://api.openai.com/v1').replace(/\/+$/, '');
|
||||
// The lookbehind matches only the first slash of the trailing run. Without it the
|
||||
// engine retries from every slash, which is quadratic on a slash-heavy value.
|
||||
const base = (input.baseUrl ?? 'https://api.openai.com/v1').replace(/(?<!\/)\/+$/, '');
|
||||
const url = `${base}/chat/completions`;
|
||||
const nuextract = isNuExtractModel(input.model);
|
||||
|
||||
|
||||
@@ -23,7 +23,11 @@ export class LlmLocalService {
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new HttpException({ error: 'Base URL must be http(s)' }, 400);
|
||||
}
|
||||
return raw.replace(/\/+$/, '').replace(/\/v1$/, '');
|
||||
// Trailing slashes are scanned off instead of `/\/+$/`-replaced: that pattern
|
||||
// re-walks the slash run from every start position of the admin-supplied URL.
|
||||
let end = raw.length;
|
||||
while (end > 0 && raw.charCodeAt(end - 1) === 47 /* '/' */) end--;
|
||||
return raw.slice(0, end).replace(/\/v1$/, '');
|
||||
}
|
||||
|
||||
/** List models already pulled on the local server. */
|
||||
|
||||
@@ -218,10 +218,10 @@ function decodeQuotedPrintable(payload: string): Buffer {
|
||||
for (let i = 0; i < joined.length; i++) {
|
||||
const hex = joined[i] === '=' ? joined.slice(i + 1, i + 3) : '';
|
||||
if (/^[0-9a-fA-F]{2}$/.test(hex)) {
|
||||
out[len++] = parseInt(hex, 16);
|
||||
out[len++] = Number.parseInt(hex, 16);
|
||||
i += 2;
|
||||
} else {
|
||||
out[len++] = joined.charCodeAt(i) & 0xff;
|
||||
out[len++] = joined.codePointAt(i) & 0xff;
|
||||
}
|
||||
}
|
||||
return out.subarray(0, len);
|
||||
@@ -245,7 +245,7 @@ function decodeEncodedWords(value: string): string {
|
||||
return value
|
||||
.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, (whole, charset: string, encoding: string, payload: string) => {
|
||||
const bytes =
|
||||
encoding.toUpperCase() === 'B' ? decodeBase64(payload) : decodeQuotedPrintable(payload.replace(/_/g, ' '));
|
||||
encoding.toUpperCase() === 'B' ? decodeBase64(payload) : decodeQuotedPrintable(payload.replaceAll('_', ' '));
|
||||
const decoded = decodeCharset(bytes, charset);
|
||||
return decoded || whole;
|
||||
})
|
||||
|
||||
@@ -79,9 +79,12 @@ export function detectFlightNumbers(text: string): string[] {
|
||||
export function extractBookingRef(text: string): string | undefined {
|
||||
// The captured code must contain a digit: real PNRs/booking codes effectively always
|
||||
// do, while the case-insensitive [A-Z0-9] class would otherwise grab a following prose
|
||||
// word ("Confirmation\nThank you…" → "Thank") after a bare label.
|
||||
// word ("Confirmation\nThank you…" → "Thank") after a bare label. The lookahead asserts
|
||||
// exactly that (the run's first digit is only ever preceded by letters), and the
|
||||
// separator says "spaces, then optionally a colon and more spaces" — both spellings
|
||||
// avoid quantifiers that overlap and make document text backtrack.
|
||||
const m = text.match(
|
||||
/(?:PNR|Buchungs(?:code|nummer|referenz)|Booking\s*(?:reference|code|number)|Confirmation\s*(?:number|code)?|Reservierungsnummer|Reservation\s*(?:No\.?|Number|Nr\.?)|Best(?:ä|ae)tigungs[-\s]?(?:nummer|code)|(?:Expedia[-\s]*)?Reiseplan|Reference)\s*:?\s*((?=[A-Z0-9]*\d)[A-Z0-9]{5,})/i,
|
||||
/(?:PNR|Buchungs(?:code|nummer|referenz)|Booking\s*(?:reference|code|number)|Confirmation\s*(?:number|code)?|Reservierungsnummer|Reservation\s*(?:No\.?|Number|Nr\.?)|Best(?:ä|ae)tigungs[-\s]?(?:nummer|code)|(?:Expedia[-\s]*)?Reiseplan|Reference)\s*(?::\s*)?((?=[A-Z]*\d)[A-Z0-9]{5,})/i,
|
||||
);
|
||||
return m?.[1];
|
||||
}
|
||||
@@ -101,12 +104,14 @@ export function extractTotalPrice(text: string): { price: string; currency?: str
|
||||
const strip = (s: string) => s.replace(/[€$£¥\s]/g, '');
|
||||
// A labeled total: "Gesamtpreis: 1.234,56 €", "Total Amount 99 USD", "Bezahlter Betrag 651,86 €".
|
||||
const labeled = text.match(
|
||||
/(?:Gesamtpreis|Gesamtbetrag|Gesamtsumme|Total(?:\s*(?:price|amount))?|Amount|Summe|Betrag)\s*:?\s*([€$£¥]?\s*\d[\d.,]*)\s*(EUR|USD|GBP|CHF|JPY|€|\$|£|¥)?/i,
|
||||
/(?:Gesamtpreis|Gesamtbetrag|Gesamtsumme|Total(?:\s*(?:price|amount))?|Amount|Summe|Betrag)\s*(?::\s*)?([€$£¥]?\s*\d[\d.,]*)\s*(EUR|USD|GBP|CHF|JPY|€|\$|£|¥)?/i,
|
||||
);
|
||||
if (labeled) return { price: strip(labeled[1]), currency: normCurrency(labeled[2] ?? labeled[1]) };
|
||||
// Fallback: a standalone amount carrying a currency symbol on its own line (e.g. a voucher's
|
||||
// "¥9,400") — the price sits far from any label the pattern above can anchor to.
|
||||
const symbol = text.match(/^\s*([€$£¥]\s?\d[\d.,]*)\b/m);
|
||||
// "¥9,400") — the price sits far from any label the pattern above can anchor to. The
|
||||
// indent is horizontal whitespace only: under /m the anchor already sits at every line
|
||||
// start, so a \s* run that could also eat the newlines just re-scans the document.
|
||||
const symbol = text.match(/^[^\S\r\n]*([€$£¥]\s?\d[\d.,]*)\b/m);
|
||||
if (symbol) return { price: strip(symbol[1]), currency: normCurrency(symbol[1]) };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ const HTML_ENTITIES = new Map<string, string>([
|
||||
['larr', '←'],
|
||||
['rarr', '→'],
|
||||
['harr', '↔'],
|
||||
...LATIN1_ENTITIES.map((name, i): [string, string] => [name, String.fromCharCode(0xa0 + i)]),
|
||||
...LATIN1_ENTITIES.map((name, i): [string, string] => [name, String.fromCodePoint(0xa0 + i)]),
|
||||
]);
|
||||
|
||||
function entityCodePoint(code: number): string | null {
|
||||
@@ -85,12 +85,34 @@ function decodeEntities(s: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every tag with a space. Hand-rolled rather than a /<[^>]+>/g replace: a
|
||||
* match can only start at a "<", so a run of brackets with no ">" behind it — an
|
||||
* uploaded .eml or .html is free to carry one — makes the engine rescan the tail
|
||||
* once per bracket. Character for character the same result, including the two
|
||||
* cases the regex leaves alone: a "<" that nothing closes, and an empty "<>",
|
||||
* which [^>]+ cannot match.
|
||||
*/
|
||||
function stripTags(s: string): string {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
for (;;) {
|
||||
const lt = s.indexOf('<', i);
|
||||
if (lt === -1) break;
|
||||
const gt = s.indexOf('>', lt + 1);
|
||||
if (gt === -1) break; // nothing closes it, so the rest of the string is text
|
||||
// An empty "<>" has nothing for [^>]+ to match, so both brackets stay literal.
|
||||
out += s.slice(i, lt) + (gt === lt + 1 ? '<>' : ' ');
|
||||
i = gt + 1;
|
||||
}
|
||||
return out + s.slice(i);
|
||||
}
|
||||
|
||||
/** Strip HTML/XML tags, resolve entities and collapse whitespace for a cleaner LLM prompt. */
|
||||
function stripMarkup(s: string): string {
|
||||
const withoutTags = s
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ');
|
||||
const withoutTags = stripTags(
|
||||
s.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' '),
|
||||
);
|
||||
// Entities come last so that a decoded `<` cannot turn into a tag the
|
||||
// stripper would then eat.
|
||||
return decodeEntities(withoutTags)
|
||||
@@ -137,6 +159,115 @@ async function extractPdfText(buffer: Buffer): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Exactly the code points JS `\s` matches — the class the page-marker pattern walks over. */
|
||||
const SPACE_CODES = new Set([
|
||||
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20, 0xa0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005,
|
||||
0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff,
|
||||
]);
|
||||
|
||||
/** The line breaks a multiline `^` starts after and `$` ends before. */
|
||||
const LINE_END_CODES = new Set([0x0a, 0x0d, 0x2028, 0x2029]);
|
||||
|
||||
function isSpaceAt(text: string, i: number): boolean {
|
||||
return SPACE_CODES.has(text.charCodeAt(i));
|
||||
}
|
||||
|
||||
function isLineEndAt(text: string, i: number): boolean {
|
||||
return LINE_END_CODES.has(text.charCodeAt(i));
|
||||
}
|
||||
|
||||
function isDigitAt(text: string, i: number): boolean {
|
||||
const c = text.charCodeAt(i);
|
||||
return c >= 0x30 && c <= 0x39;
|
||||
}
|
||||
|
||||
function isDashAt(text: string, i: number): boolean {
|
||||
return text.charCodeAt(i) === 0x2d;
|
||||
}
|
||||
|
||||
function skipRun(text: string, from: number, matches: (text: string, i: number) => boolean): number {
|
||||
let i = from;
|
||||
while (i < text.length && matches(text, i)) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page marker from its first dash on: `-+\s*\d+\s+of\s+\d+\s*-+\s*$`. Returns
|
||||
* where the match ends, or -1 when there is none. Every quantifier in there is
|
||||
* followed by a class that excludes what it just matched, so each one can only
|
||||
* take its whole run — giving characters back never opens a second way through,
|
||||
* which is why one forward pass is enough.
|
||||
*/
|
||||
function markerEnd(text: string, dashStart: number): number {
|
||||
let i = skipRun(text, skipRun(text, dashStart, isDashAt), isSpaceAt);
|
||||
const pageNo = i;
|
||||
i = skipRun(text, i, isDigitAt);
|
||||
if (i === pageNo) return -1;
|
||||
const beforeOf = i;
|
||||
i = skipRun(text, i, isSpaceAt);
|
||||
if (i === beforeOf) return -1;
|
||||
const o = text.charCodeAt(i);
|
||||
const f = text.charCodeAt(i + 1);
|
||||
if ((o !== 0x6f && o !== 0x4f) || (f !== 0x66 && f !== 0x46)) return -1;
|
||||
const afterOf = i + 2;
|
||||
i = skipRun(text, afterOf, isSpaceAt);
|
||||
if (i === afterOf) return -1;
|
||||
const pageCount = i;
|
||||
i = skipRun(text, i, isDigitAt);
|
||||
if (i === pageCount) return -1;
|
||||
const closing = skipRun(text, i, isSpaceAt);
|
||||
i = skipRun(text, closing, isDashAt);
|
||||
if (i === closing) return -1;
|
||||
// `\s*$` hands the trailing run back until `$` sits on a line end; with no
|
||||
// line end in it and text still to come, `$` never matches and neither does
|
||||
// the marker.
|
||||
let lastLineEnd = -1;
|
||||
let end = i;
|
||||
while (end < text.length && isSpaceAt(text, end)) {
|
||||
if (isLineEndAt(text, end)) lastLineEnd = end;
|
||||
end++;
|
||||
}
|
||||
return end === text.length ? end : lastLineEnd;
|
||||
}
|
||||
|
||||
/** The first `^` in `[from, to]` — the earliest position the marker can start at. */
|
||||
function markerStart(text: string, from: number, to: number): number {
|
||||
if (from === 0 || isLineEndAt(text, from - 1)) return from;
|
||||
for (let i = from; i < to; i++) if (isLineEndAt(text, i)) return i + 1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop `-- N of M --` page markers: `/^\s*-+\s*\d+\s+of\s+\d+\s*-+\s*$/gim` as a
|
||||
* single left-to-right pass. As a regex it is quadratic — `\s` matches line
|
||||
* breaks, so under `m` every line start inside a whitespace run walks that same
|
||||
* run again looking for a dash (40k of `" \n"` took ~0.6 s, and this runs first
|
||||
* over the raw text layer of an uploaded PDF).
|
||||
*/
|
||||
function stripPageMarkers(text: string): string {
|
||||
let kept = '';
|
||||
let done = 0; // the pattern's lastIndex: nothing before it can match any more
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const dashStart = text.indexOf('-', i);
|
||||
if (dashStart === -1) break;
|
||||
// The leading `\s*` reaches back across the whole whitespace run, and the
|
||||
// match then starts at the first `^` — string start or line start — in it.
|
||||
let runStart = dashStart;
|
||||
while (runStart > done && isSpaceAt(text, runStart - 1)) runStart--;
|
||||
const start = markerStart(text, runStart, dashStart);
|
||||
const end = start === -1 ? -1 : markerEnd(text, dashStart);
|
||||
if (end === -1) {
|
||||
i = dashStart + 1;
|
||||
continue;
|
||||
}
|
||||
kept += text.slice(done, start);
|
||||
done = end;
|
||||
i = end;
|
||||
}
|
||||
return done === 0 ? text : kept + text.slice(done);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up pdf-parse output for the LLM:
|
||||
* - strip `-- N of M --` page markers
|
||||
@@ -145,10 +276,9 @@ async function extractPdfText(buffer: Buffer): Promise<string> {
|
||||
* a common PDF kerning artifact that otherwise hides booking fields
|
||||
*/
|
||||
function cleanPdfText(text: string): string {
|
||||
return text
|
||||
.replace(/^\s*-+\s*\d+\s+of\s+\d+\s*-+\s*$/gim, '')
|
||||
return stripPageMarkers(text)
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\b(?:[A-Z] ){2,}[A-Z]\b/g, m => m.replace(/ /g, ''))
|
||||
.replace(/\b(?:[A-Z] ){2,}[A-Z]\b/g, m => m.replaceAll(' ', ''))
|
||||
.replace(/ *\n */g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readEnv, getAppUrl } from '../../app-config';
|
||||
import { stripHtmlTags } from '../common/stripHtmlTags';
|
||||
|
||||
/**
|
||||
* Pure maps/geo helpers — no DB, no Nest, no side effects beyond reading env.
|
||||
@@ -378,8 +379,12 @@ export function parseOpeningHours(ohString: string): {
|
||||
for (const segment of ohString.split(';')) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) continue;
|
||||
// The time part is `\S.*`, not `.+`: the segment was trimmed above, so both
|
||||
// spell "everything after the gap". But `\s+(.+)` lets the gap and the time
|
||||
// part fight over the same spaces, and a segment that is a weekday followed by
|
||||
// nothing but blanks then costs a pass per space.
|
||||
const match = trimmed.match(
|
||||
/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i,
|
||||
/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(\S.*)$/i,
|
||||
);
|
||||
if (!match) continue;
|
||||
const [, daysPart, timePart] = match;
|
||||
@@ -489,9 +494,8 @@ export function buildOsmDetails(tags: Record<string, string>, osmType: string, o
|
||||
*/
|
||||
export function stripWikiMarkup(value: string | undefined | null): string | null {
|
||||
if (!value) return null;
|
||||
const text = value
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
const text = stripHtmlTags(value, ' ')
|
||||
.replaceAll(' ', ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return text || null;
|
||||
@@ -572,7 +576,10 @@ function seriesStem(title: string): string {
|
||||
// 20260614 100717648 HDR — a camera dump, all from the same minute
|
||||
.replace(/\b\d{8}[ _-]\d{6,9}\b/g, ' ')
|
||||
.replace(/\b(19|20)\d{2}\b/g, ' ')
|
||||
.replace(/[ _-]+\(?\d{1,4}\)?$/g, '')
|
||||
// The character in front of the suffix is matched and put straight back:
|
||||
// /[ _-]+…$/ on its own restarts at every space of a title that has no such
|
||||
// suffix, and re-reads the rest of the run each time.
|
||||
.replace(/([^ _-]|^)[ _-]+\(?\d{1,4}\)?$/g, '$1')
|
||||
.replace(/[^a-z]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -58,7 +58,10 @@ const PLACES_UPSTREAM = 'https://places.googleapis.com';
|
||||
function placesEndpoint(endpoint: string): string {
|
||||
const base = readEnv().maps.placesApiBase;
|
||||
if (!base || !endpoint.startsWith(PLACES_UPSTREAM)) return endpoint;
|
||||
return base.replace(/\/+$/, '') + endpoint.slice(PLACES_UPSTREAM.length);
|
||||
// The character before the run is matched and written straight back. A bare
|
||||
// /\/+$/ restarts at every slash of a base that does not end in one, reading
|
||||
// the rest of the run again from each of them.
|
||||
return base.replace(/([^/]|^)\/+$/, '$1') + endpoint.slice(PLACES_UPSTREAM.length);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,7 +184,7 @@ 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, '').replace(/_/g, ' ').trim().toLowerCase();
|
||||
return title.replace(/^File:/i, '').replaceAll('_', ' ').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1851,7 +1854,7 @@ export class MapsService {
|
||||
|
||||
// Deduplicate concurrent requests for the same placeId
|
||||
const existing = this.photoCache.getInFlight(placeId);
|
||||
if (existing) {
|
||||
if (existing !== undefined) {
|
||||
const result = await existing;
|
||||
if (!result) return noPhoto;
|
||||
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
|
||||
@@ -1872,7 +1875,7 @@ export class MapsService {
|
||||
// so a place added via search still gets a marker image when Google returns
|
||||
// nothing. Returns null (without marking an error) so the caller decides.
|
||||
const fetchWikimediaFallback = async (): Promise<{ attribution: string | null } | null> => {
|
||||
if (isNaN(lat) || isNaN(lng)) return null;
|
||||
if (Number.isNaN(lat) || Number.isNaN(lng)) return null;
|
||||
try {
|
||||
const wiki = await this.fetchWikimediaPhoto(lat, lng, name);
|
||||
if (!wiki) return null;
|
||||
@@ -2091,10 +2094,10 @@ export class MapsService {
|
||||
let placeName: string | null = null;
|
||||
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
|
||||
if (placeMatch) {
|
||||
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
||||
placeName = decodeURIComponent(placeMatch[1].replaceAll(/\+/g, ' '));
|
||||
}
|
||||
|
||||
if (!coords || isNaN(coords.lat) || isNaN(coords.lng)) {
|
||||
if (!coords || Number.isNaN(coords.lat) || Number.isNaN(coords.lng)) {
|
||||
throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 });
|
||||
}
|
||||
const { lat, lng } = coords;
|
||||
|
||||
@@ -50,7 +50,7 @@ export class PhotoResolverService {
|
||||
if (await this.cache.serveFresh(res, key)) return;
|
||||
|
||||
const existing = this.cache.getInFlight(key);
|
||||
if (existing) {
|
||||
if (existing !== undefined) {
|
||||
const bytes = await existing;
|
||||
if (bytes && (await this.cache.serveFresh(res, key))) return;
|
||||
await fallback();
|
||||
|
||||
@@ -12,11 +12,11 @@ import type { NotifEventType } from '../notification-events';
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
// Localized title/body for an event.
|
||||
|
||||
@@ -198,7 +198,7 @@ export class NotificationsController {
|
||||
/** parseInt + the legacy "Invalid id" 400 guard, shared by the /:id handlers. */
|
||||
private parseId(idParam: string): number {
|
||||
const id = Number.parseInt(idParam);
|
||||
if (isNaN(id)) {
|
||||
if (Number.isNaN(id)) {
|
||||
throw new HttpException({ error: 'Invalid id' }, 400);
|
||||
}
|
||||
return id;
|
||||
|
||||
@@ -38,20 +38,22 @@ const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] };
|
||||
export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null): string | null {
|
||||
const topic = userCfg?.topic;
|
||||
if (!topic) return null;
|
||||
const base = (userCfg?.server || adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
// The lookbehind pins the strip to the start of the trailing run. Without it a
|
||||
// configured server of nothing but slashes retries from every one of them.
|
||||
const base = (userCfg?.server || adminCfg.server || 'https://ntfy.sh').replace(/(?<!\/)\/+$/, '');
|
||||
return `${base}/${encodeURIComponent(topic)}`;
|
||||
}
|
||||
|
||||
/** Resolve the ntfy POST URL for admin-scoped sends. Returns null if no admin topic. */
|
||||
export function resolveAdminNtfyUrl(adminCfg: NtfyConfig): string | null {
|
||||
if (!adminCfg.topic) return null;
|
||||
const base = (adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
const base = (adminCfg.server || 'https://ntfy.sh').replace(/(?<!\/)\/+$/, '');
|
||||
return `${base}/${encodeURIComponent(adminCfg.topic)}`;
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
for (const ch of value) {
|
||||
const code = ch.codePointAt(0)!;
|
||||
// Control characters go through the same RFC 2047 wrapper as non-ASCII: a
|
||||
// trip title with a stray newline in it would otherwise be rejected by
|
||||
// undici as an invalid header value and drop the notification silently.
|
||||
|
||||
@@ -9,6 +9,15 @@ import { Public } from '../auth/public.decorator';
|
||||
|
||||
const MIN = 60_000;
|
||||
|
||||
/** Drops every trailing slash from a resource indicator, exactly like the `/\/+$/` replace
|
||||
* it stands in for — as a scan, because that pattern re-walks the slash run from every
|
||||
* start position of a `resource` value the caller controls. */
|
||||
function stripTrailingSlashes(value: string): string {
|
||||
let end = value.length;
|
||||
while (end > 0 && value.charCodeAt(end - 1) === 47 /* '/' */) end--;
|
||||
return value.slice(0, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public OAuth 2.1 endpoints (no session) — byte-identical to the legacy
|
||||
* oauthPublicRouter (server/src/routes/oauth.ts): MCP-addon gated (404), the
|
||||
@@ -57,7 +66,7 @@ export class OauthPublicController {
|
||||
if (!pending) return invalidGrant('code_invalid_or_expired', null);
|
||||
if (pending.clientId !== client_id) return invalidGrant('client_id_mismatch', pending.userId);
|
||||
if (pending.redirectUri !== redirect_uri) return invalidGrant('redirect_uri_mismatch', pending.userId);
|
||||
if (pending.resource && resource && pending.resource !== resource.replace(/\/+$/, '')) return invalidGrant('resource_mismatch', pending.userId);
|
||||
if (pending.resource && resource && pending.resource !== stripTrailingSlashes(resource)) return invalidGrant('resource_mismatch', pending.userId);
|
||||
if (!this.oauth.authenticateClient(client_id, client_secret)) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
@@ -116,7 +125,7 @@ export class OauthPublicController {
|
||||
} else {
|
||||
grantedScopes = allowedScopes;
|
||||
}
|
||||
const audience = resource ? resource.replace(/\/+$/, '') : `${this.oauth.mcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||
const audience = resource ? stripTrailingSlashes(resource) : `${stripTrailingSlashes(this.oauth.mcpSafeUrl())}/mcp`;
|
||||
const tokens = this.oauth.issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
|
||||
this.audit.writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
|
||||
res.json(tokens);
|
||||
|
||||
@@ -121,8 +121,10 @@ export class TrekOAuthProvider implements OAuthServerProvider {
|
||||
|
||||
// Redirects browser to the SPA consent page with OAuth params forwarded.
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||
const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource;
|
||||
// The lookbehind matches only the first slash of the trailing run. Without it the
|
||||
// engine retries from every slash, which is quadratic on a slash-heavy value.
|
||||
const mcpResource = `${getMcpSafeUrl().replace(/(?<!\/)\/+$/, '')}/mcp`;
|
||||
const resource = params.resource ? params.resource.href.replace(/(?<!\/)\/+$/, '') : mcpResource;
|
||||
|
||||
if (resource !== mcpResource) {
|
||||
const url = new URL(params.redirectUri);
|
||||
@@ -143,7 +145,7 @@ export class TrekOAuthProvider implements OAuthServerProvider {
|
||||
if (params.state) qs.set('state', params.state);
|
||||
if (params.resource) qs.set('resource', params.resource.href);
|
||||
|
||||
const base = getMcpSafeUrl().replace(/\/+$/, '');
|
||||
const base = getMcpSafeUrl().replace(/(?<!\/)\/+$/, '');
|
||||
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
|
||||
}
|
||||
|
||||
@@ -169,7 +171,7 @@ export class TrekOAuthProvider implements OAuthServerProvider {
|
||||
if (redirectUri && pending.redirectUri !== redirectUri)
|
||||
throw new Error('Authorization grant is invalid.');
|
||||
|
||||
const resourceStr = resource ? resource.href.replace(/\/+$/, '') : null;
|
||||
const resourceStr = resource ? resource.href.replace(/(?<!\/)\/+$/, '') : null;
|
||||
if (pending.resource && resourceStr && pending.resource !== resourceStr)
|
||||
throw new Error('Authorization grant is invalid.');
|
||||
|
||||
|
||||
@@ -624,9 +624,11 @@ export class OauthService {
|
||||
// bind the token to the MCP endpoint by default — previously this
|
||||
// left `audience = null`, and the audience-bind check on MCP requests
|
||||
// then treated a null audience as "valid for any resource".
|
||||
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||
// The lookbehind matches only the first slash of the trailing run. Without it the
|
||||
// engine retries from every slash, which is quadratic on a slash-heavy value.
|
||||
const mcpResource = `${getMcpSafeUrl().replace(/(?<!\/)\/+$/, '')}/mcp`;
|
||||
const resource = params.resource
|
||||
? params.resource.replace(/\/+$/, '')
|
||||
? params.resource.replace(/(?<!\/)\/+$/, '')
|
||||
: mcpResource;
|
||||
if (resource !== mcpResource) {
|
||||
return { valid: false, error: 'invalid_target', error_description: 'Requested resource must be the TREK MCP endpoint' };
|
||||
|
||||
@@ -84,7 +84,7 @@ type JwksEntry = { keys: Array<Record<string, unknown>>; fetchedAt: number };
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function base64url(buf: Buffer): string {
|
||||
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
return buf.toString('base64url');
|
||||
}
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
@@ -120,7 +120,7 @@ interface InviteTokenRow {
|
||||
}
|
||||
|
||||
function base64UrlDecode(input: string): Buffer {
|
||||
const padded = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (input.length % 4)) % 4);
|
||||
const padded = input.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - (input.length % 4)) % 4);
|
||||
return Buffer.from(padded, 'base64');
|
||||
}
|
||||
|
||||
@@ -259,7 +259,9 @@ export class OidcService implements OnModuleDestroy {
|
||||
const discoveryUrl = oidcEnv.discoveryUrl || get('oidc_discovery_url') || null;
|
||||
|
||||
if (!issuer || !clientId || !clientSecret) return null;
|
||||
return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName, discoveryUrl };
|
||||
// The lookbehind pins the trailing-slash strip (here and below) to the start of
|
||||
// the run — without it an issuer of nothing but slashes retries from every one.
|
||||
return { issuer: issuer.replace(/(?<!\/)\/+$/, ''), clientId, clientSecret, displayName, discoveryUrl };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -283,7 +285,7 @@ export class OidcService implements OnModuleDestroy {
|
||||
// and we reject. When the operator explicitly overrides the discovery URL (e.g.
|
||||
// Authentik realm paths), the discovery doc's issuer is the canonical value —
|
||||
// trust it and warn rather than blocking login.
|
||||
const docIssuer = doc.issuer?.replace(/\/+$/, '') ?? '';
|
||||
const docIssuer = doc.issuer?.replace(/(?<!\/)\/+$/, '') ?? '';
|
||||
if (docIssuer && docIssuer !== issuer) {
|
||||
if (discoveryUrl) {
|
||||
console.warn(
|
||||
@@ -480,7 +482,7 @@ export class OidcService implements OnModuleDestroy {
|
||||
|
||||
// Normalize trailing slash before issuer comparison — some IdPs (e.g. Authentik)
|
||||
// include a trailing slash in the id_token iss claim.
|
||||
const tokenIssuer = typeof claims['iss'] === 'string' ? claims['iss'].replace(/\/+$/, '') : '';
|
||||
const tokenIssuer = typeof claims['iss'] === 'string' ? claims['iss'].replace(/(?<!\/)\/+$/, '') : '';
|
||||
if (tokenIssuer !== expectedIssuer) {
|
||||
return { ok: false, error: `signature_or_claim_mismatch: jwt issuer invalid. expected: ${expectedIssuer}` };
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export function collectFacts(details: Record<string, unknown> | null): PlaceFact
|
||||
|
||||
const cuisine = typeof details.cuisine === 'string' ? details.cuisine : null;
|
||||
// OSM writes several cuisines semicolon-separated and underscored.
|
||||
if (cuisine) push('cuisine', cuisine.split(';').map((c) => c.replace(/_/g, ' ').trim()).filter(Boolean).join(', '));
|
||||
if (cuisine) push('cuisine', cuisine.split(';').map((c) => c.replaceAll('_', ' ').trim()).filter(Boolean).join(', '));
|
||||
|
||||
// `menu_url` is a community-editable OSM tag that becomes an href on the
|
||||
// client, so it goes through the same allow-list as a place's website —
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
import { TextDecoder } from 'util';
|
||||
import { stripHtmlTags } from '../common/stripHtmlTags';
|
||||
|
||||
export interface ParsedKmlPlacemark {
|
||||
name: string | null;
|
||||
@@ -83,14 +84,14 @@ export function sanitizeKmlDescription(value: unknown): string | null {
|
||||
|
||||
// Unwrap CDATA sections — present when fast-xml-parser returns raw node text
|
||||
// via stopNodes. Must happen before tag-stripping so the CDATA markers are
|
||||
// not mis-parsed by the <[^>]+> regex.
|
||||
// not mis-parsed as tags.
|
||||
const withoutCdata = raw.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1');
|
||||
|
||||
const withLineBreaks = withoutCdata.replace(/<br\s*\/?>/gi, '\n');
|
||||
const stripped = withLineBreaks.replace(/<[^>]+>/g, '');
|
||||
const stripped = stripHtmlTags(withLineBreaks);
|
||||
const decoded = decodeHtmlEntities(stripped)
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replaceAll('\r\n', '\n')
|
||||
.replaceAll('\r', '\n')
|
||||
.replace(/[\t\f\v]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
@@ -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 = Number.parseFloat(String(v)); return 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
|
||||
@@ -980,7 +980,7 @@ export class PlacesService {
|
||||
const name = item?.[2];
|
||||
const note = item?.[3] || null;
|
||||
|
||||
if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) {
|
||||
if (name && typeof lat === 'number' && typeof lng === 'number' && !Number.isNaN(lat) && !Number.isNaN(lng)) {
|
||||
places.push({ name, lat, lng, notes: note || null, googleFtid: googleMapsFeatureIdFromItem(item) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ export class PluginDataDb {
|
||||
* FORBIDDEN guard by design. */
|
||||
snapshotInto(destPath: string): void {
|
||||
fs.rmSync(destPath, { force: true }); // VACUUM INTO fails if the target already exists
|
||||
this.db.exec(`VACUUM INTO '${destPath.replace(/'/g, "''")}'`);
|
||||
this.db.exec(`VACUUM INTO '${destPath.replaceAll("'", "''")}'`);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
|
||||
@@ -148,7 +148,7 @@ export class ManifestError extends Error {}
|
||||
|
||||
/** JSON.parse that tolerates a UTF-8 BOM (0xFEFF) — manifests written on Windows often carry one. */
|
||||
export function parseJsonText(text: string): unknown {
|
||||
return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
||||
return JSON.parse(text.codePointAt(0) === 0xfeff ? text.slice(1) : text);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,7 +28,7 @@ export class ExtractError extends Error {}
|
||||
|
||||
/** Assert a member path stays within dest; return the safe absolute path. */
|
||||
export function safeJoin(dest: string, name: string): string {
|
||||
const norm = name.replace(/\\/g, '/');
|
||||
const norm = name.replaceAll(/\\/g, '/');
|
||||
if (norm.startsWith('/') || /^[a-zA-Z]:/.test(norm)) throw new ExtractError(`absolute path rejected: ${name}`);
|
||||
if (norm.split('/').some((seg) => seg === '..')) throw new ExtractError(`path traversal rejected: ${name}`);
|
||||
const resolved = path.resolve(dest, norm);
|
||||
@@ -92,7 +92,7 @@ function readTarGz(buf: Buffer, lim: Required<ExtractLimits>): Member[] {
|
||||
if (!Number.isSafeInteger(size) || size < 0) {
|
||||
throw new ExtractError(`invalid entry size in tar header: ${block.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim()}`);
|
||||
}
|
||||
const typeflag = String.fromCharCode(block[156]);
|
||||
const typeflag = String.fromCodePoint(block[156]);
|
||||
off += 512;
|
||||
const data = tar.subarray(off, off + size);
|
||||
off += Math.ceil(size / 512) * 512;
|
||||
|
||||
@@ -99,6 +99,6 @@ async function readCapped(resp: Response, maxBytes: number): Promise<Buffer> {
|
||||
export function sha256Matches(actual: string, expected: string): boolean {
|
||||
if (actual.length !== expected.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < actual.length; i++) diff |= actual.charCodeAt(i) ^ expected.charCodeAt(i);
|
||||
for (let i = 0; i < actual.length; i++) diff |= (actual.codePointAt(i) ?? 0) ^ (expected.codePointAt(i) ?? 0);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const STATE_TTL_MS = 10 * 60 * 1000; // an authorize round-trip must finish with
|
||||
const REFRESH_SKEW_S = 60; // refresh a token expiring within a minute
|
||||
|
||||
function b64url(buf: Buffer): string {
|
||||
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
return buf.toString('base64url');
|
||||
}
|
||||
|
||||
/** Cheap fast-fail for an obviously-internal token endpoint. This is a pre-check, not the
|
||||
|
||||
@@ -313,7 +313,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
// a drain, and a pass awaits per-row delivery (up to the invoke timeout each), so
|
||||
// running two concurrently would select the SAME rows and deliver an erasure twice.
|
||||
// A caller that awaits still waits for a full pass (the in-flight one).
|
||||
if (this.drainInFlight) return this.drainInFlight;
|
||||
if (this.drainInFlight !== null) return this.drainInFlight;
|
||||
this.drainInFlight = this.runDrainOnce().finally(() => { this.drainInFlight = null; });
|
||||
return this.drainInFlight;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,10 @@ function embeddedTransitionIpv4(g: number[]): string | null {
|
||||
|
||||
/** Expand an IPv6 literal into its 8 numeric hextets, or null if not a valid IPv6. */
|
||||
function expandV6(ip: string): number[] | null {
|
||||
let h = ip.toLowerCase().replace(/%.*$/, '');
|
||||
// Cut the zone id off (`fe80::1%eth0`): indexOf rather than a `%.*$` regex, whose
|
||||
// greedy tail rescans the whole string once per `%` on a host that never matches.
|
||||
const zone = ip.indexOf('%');
|
||||
let h = (zone === -1 ? ip : ip.slice(0, zone)).toLowerCase();
|
||||
if (!h.includes(':')) return null;
|
||||
const dotted = h.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
if (dotted) {
|
||||
@@ -82,7 +85,7 @@ function expandV6(ip: string): number[] | null {
|
||||
const head = halves[0] ? halves[0].split(':') : [];
|
||||
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
||||
const groups =
|
||||
halves.length === 2 ? [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail] : head;
|
||||
halves.length === 2 ? [...head, ...new Array(8 - head.length - tail.length).fill('0'), ...tail] : head;
|
||||
if (groups.length !== 8) return null;
|
||||
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;
|
||||
|
||||
@@ -26,9 +26,12 @@ export function stripEmoji(s: string): string {
|
||||
const stripped = s.replace(EMOJI_RE, '');
|
||||
// Nothing removed → return verbatim so a plain string keeps its exact spacing and
|
||||
// paragraph breaks (matters for PDF paragraphs). Otherwise collapse only the
|
||||
// horizontal gaps an emoji left, never newlines.
|
||||
// horizontal gaps an emoji left, never newlines. The trailing-space trim carries the
|
||||
// char in front of the run (or the line start) into the match and puts it back, so the
|
||||
// run is only ever walked from its own start — plugin text must not make ` +$` restart
|
||||
// inside every space of a long one.
|
||||
if (stripped === s) return s;
|
||||
return stripped.replace(/[^\S\r\n]{2,}/g, ' ').replace(/ +$/gm, '').trim();
|
||||
return stripped.replace(/[^\S\r\n]{2,}/g, ' ').replace(/(^|[^ ]) +$/gm, '$1').trim();
|
||||
}
|
||||
|
||||
/** True if the string contains at least one emoji — used by the dev/validate warnings. */
|
||||
|
||||
@@ -465,11 +465,17 @@ export class S3Driver implements StorageDriver {
|
||||
/**
|
||||
* '' stays ''; anything else becomes one or more slash-joined segments with a
|
||||
* trailing slash, or throws StorageInvalidKeyError. app-config/env.ts's
|
||||
* s3Preconditions validates the same slash-stripped shape at boot — keep the
|
||||
* two strip-regexes identical (`/^\/+|\/+$/g`).
|
||||
* s3Preconditions validates the same slash-stripped shape at boot — keep the two
|
||||
* strips identical: leading AND trailing slash runs removed, as `/^\/+|\/+$/g` did.
|
||||
* Scanned rather than replaced, because that pattern re-walks the trailing run from
|
||||
* every start position.
|
||||
*/
|
||||
function normalizeKeyPrefix(raw: string): string {
|
||||
const trimmed = raw.replace(/^\/+|\/+$/g, '');
|
||||
let start = 0;
|
||||
let end = raw.length;
|
||||
while (start < end && raw.charCodeAt(start) === 47 /* '/' */) start++;
|
||||
while (end > start && raw.charCodeAt(end - 1) === 47) end--;
|
||||
const trimmed = raw.slice(start, end);
|
||||
if (trimmed === '') return '';
|
||||
assertValidPrefix(`${trimmed}/`);
|
||||
return `${trimmed}/`;
|
||||
|
||||
@@ -17,8 +17,8 @@ import { StorageInvalidKeyError } from './storage.types';
|
||||
const MAX_KEY_LENGTH = 1024;
|
||||
|
||||
function hasControlChars(value: string): boolean {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0) as number;
|
||||
if (code < 0x20 || code === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -190,7 +190,7 @@ export class TransitService {
|
||||
|
||||
if (q.time) {
|
||||
const parsed = new Date(q.time);
|
||||
if (isNaN(parsed.getTime())) bad('time must be an ISO date-time');
|
||||
if (Number.isNaN(parsed.getTime())) bad('time must be an ISO date-time');
|
||||
params.set('time', parsed.toISOString());
|
||||
}
|
||||
if (q.arriveBy) params.set('arriveBy', 'true');
|
||||
|
||||
@@ -100,12 +100,12 @@ export class TripsMcp {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (start_date) {
|
||||
const d = new Date(start_date + 'T00:00:00Z');
|
||||
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
|
||||
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
|
||||
}
|
||||
if (end_date) {
|
||||
const d = new Date(end_date + 'T00:00:00Z');
|
||||
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
|
||||
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
|
||||
}
|
||||
if (start_date && end_date && new Date(end_date) < new Date(start_date)) {
|
||||
@@ -145,12 +145,12 @@ export class TripsMcp {
|
||||
if (!this.guards.hasTripPermission('trip_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (start_date) {
|
||||
const d = new Date(start_date + 'T00:00:00Z');
|
||||
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
|
||||
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
|
||||
}
|
||||
if (end_date) {
|
||||
const d = new Date(end_date + 'T00:00:00Z');
|
||||
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
|
||||
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
|
||||
}
|
||||
// update() re-anchors the budget before the trip row moves off the old
|
||||
|
||||
@@ -430,7 +430,7 @@ export async function getWeather(
|
||||
|
||||
const inFlightKey = `${ck}:${lang}`;
|
||||
const existing = inFlight.get(inFlightKey);
|
||||
if (existing) return existing;
|
||||
if (existing !== undefined) return existing;
|
||||
const promise = _getWeatherImpl(lat, lng, date, lang, time);
|
||||
inFlight.set(inFlightKey, promise);
|
||||
try { return await promise; } finally { inFlight.delete(inFlightKey); }
|
||||
@@ -601,7 +601,7 @@ export async function getDetailedWeather(
|
||||
|
||||
const inFlightKey = `${ck}:${lang}`;
|
||||
const existing = inFlight.get(inFlightKey);
|
||||
if (existing) return existing;
|
||||
if (existing !== undefined) return existing;
|
||||
const promise = _getDetailedWeatherImpl(lat, lng, date, lang);
|
||||
inFlight.set(inFlightKey, promise);
|
||||
try { return await promise; } finally { inFlight.delete(inFlightKey); }
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
|
||||
/** Expand an IPv6 literal into its 8 numeric hextets, or null if not a valid IPv6. */
|
||||
export function expandIpv6(ip: string): number[] | null {
|
||||
let h = ip.toLowerCase().replace(/%.*$/, '');
|
||||
// Cut the zone id off (`fe80::1%eth0`): indexOf rather than a `%.*$` regex, whose
|
||||
// greedy tail rescans the whole string once per `%` on a host that never matches.
|
||||
const zone = ip.indexOf('%');
|
||||
let h = (zone === -1 ? ip : ip.slice(0, zone)).toLowerCase();
|
||||
if (!h.includes(':')) return null;
|
||||
const dotted = h.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
if (dotted) {
|
||||
@@ -25,7 +28,7 @@ export function expandIpv6(ip: string): number[] | null {
|
||||
const head = halves[0] ? halves[0].split(':') : [];
|
||||
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
||||
const groups =
|
||||
halves.length === 2 ? [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail] : head;
|
||||
halves.length === 2 ? [...head, ...new Array(8 - head.length - tail.length).fill('0'), ...tail] : head;
|
||||
if (groups.length !== 8) return null;
|
||||
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