From 0fe89115d13b84d3e74b16fbf5775ac8c0fa12f0 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Tue, 17 Jun 2025 19:37:40 +0000 Subject: [PATCH] feat(server): lint for trailing slashes in sync URL and extra slashes in customRequestHandler --- apps/server/src/routes/custom.ts | 26 +++++--- apps/server/src/services/sync_options.ts | 6 +- apps/server/src/services/utils.ts | 81 ++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/apps/server/src/routes/custom.ts b/apps/server/src/routes/custom.ts index e4494a6cd..e667a371b 100644 --- a/apps/server/src/routes/custom.ts +++ b/apps/server/src/routes/custom.ts @@ -5,7 +5,7 @@ import cls from "../services/cls.js"; import sql from "../services/sql.js"; import becca from "../becca/becca.js"; import type { Request, Response, Router } from "express"; -import { safeExtractMessageAndStackFromError } from "../services/utils.js"; +import { safeExtractMessageAndStackFromError, normalizeCustomHandlerPattern } from "../services/utils.js"; function handleRequest(req: Request, res: Response) { @@ -38,15 +38,23 @@ function handleRequest(req: Request, res: Response) { continue; } - const regex = new RegExp(`^${attr.value}$`); - let match; + // Get normalized patterns to handle both trailing slash cases + const patterns = normalizeCustomHandlerPattern(attr.value); + let match = null; - try { - match = path.match(regex); - } catch (e: unknown) { - const [errMessage, errStack] = safeExtractMessageAndStackFromError(e); - log.error(`Testing path for label '${attr.attributeId}', regex '${attr.value}' failed with error: ${errMessage}, stack: ${errStack}`); - continue; + // Try each pattern until we find a match + for (const pattern of patterns) { + try { + const regex = new RegExp(`^${pattern}$`); + match = path.match(regex); + if (match) { + break; // Found a match, exit pattern loop + } + } catch (e: unknown) { + const [errMessage, errStack] = safeExtractMessageAndStackFromError(e); + log.error(`Testing path for label '${attr.attributeId}', regex '${pattern}' failed with error: ${errMessage}, stack: ${errStack}`); + continue; + } } if (!match) { diff --git a/apps/server/src/services/sync_options.ts b/apps/server/src/services/sync_options.ts index 289323b54..657c1b214 100644 --- a/apps/server/src/services/sync_options.ts +++ b/apps/server/src/services/sync_options.ts @@ -2,6 +2,7 @@ import optionService from "./options.js"; import config from "./config.js"; +import { normalizeUrl } from "./utils.js"; /* * Primary configuration for sync is in the options (document), but we allow to override @@ -17,7 +18,10 @@ function get(name: keyof typeof config.Sync) { export default { // env variable is the easiest way to guarantee we won't overwrite prod data during development // after copying prod document/data directory - getSyncServerHost: () => get("syncServerHost"), + getSyncServerHost: () => { + const host = get("syncServerHost"); + return host ? normalizeUrl(host) : host; + }, isSyncSetup: () => { const syncServerHost = get("syncServerHost"); diff --git a/apps/server/src/services/utils.ts b/apps/server/src/services/utils.ts index 89aad1bbb..d33e54879 100644 --- a/apps/server/src/services/utils.ts +++ b/apps/server/src/services/utils.ts @@ -375,6 +375,85 @@ export function safeExtractMessageAndStackFromError(err: unknown): [errMessage: return (err instanceof Error) ? [err.message, err.stack] as const : ["Unknown Error", undefined] as const; } +/** + * Normalizes URL by removing trailing slashes and fixing double slashes. + * Preserves the protocol (http://, https://) but removes trailing slashes from the rest. + * + * @param url The URL to normalize + * @returns The normalized URL without trailing slashes + */ +export function normalizeUrl(url: string): string { + if (!url || typeof url !== 'string') { + return url; + } + + // Trim whitespace + url = url.trim(); + + if (!url) { + return url; + } + + // Remove trailing slash, but preserve protocol + if (url.endsWith('/') && !url.match(/^https?:\/\/$/)) { + url = url.slice(0, -1); + } + + // Fix double slashes (except in protocol) + url = url.replace(/([^:]\/)\/+/g, '$1'); + + return url; +} + +/** + * Normalizes a path pattern for custom request handlers. + * Ensures both trailing slash and non-trailing slash versions are handled. + * + * @param pattern The original pattern from customRequestHandler attribute + * @returns An array of patterns to match both with and without trailing slash + */ +export function normalizeCustomHandlerPattern(pattern: string): string[] { + if (!pattern || typeof pattern !== 'string') { + return [pattern]; + } + + pattern = pattern.trim(); + + if (!pattern) { + return [pattern]; + } + + // If pattern already ends with optional trailing slash, return as-is + if (pattern.endsWith('/?$') || pattern.endsWith('/?)')) { + return [pattern]; + } + + // If pattern ends with $, handle it specially + if (pattern.endsWith('$')) { + const basePattern = pattern.slice(0, -1); + + // If already ends with slash, create both versions + if (basePattern.endsWith('/')) { + const withoutSlash = basePattern.slice(0, -1) + '$'; + const withSlash = pattern; + return [withoutSlash, withSlash]; + } else { + // Add optional trailing slash + const withSlash = basePattern + '/?$'; + return [withSlash]; + } + } + + // For patterns without $, add both versions + if (pattern.endsWith('/')) { + const withoutSlash = pattern.slice(0, -1); + return [withoutSlash, pattern]; + } else { + const withSlash = pattern + '/'; + return [pattern, withSlash]; + } +} + export default { compareVersions, @@ -400,6 +479,8 @@ export default { md5, newEntityId, normalize, + normalizeCustomHandlerPattern, + normalizeUrl, quoteRegex, randomSecureToken, randomString,