From 0fe89115d13b84d3e74b16fbf5775ac8c0fa12f0 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Tue, 17 Jun 2025 19:37:40 +0000 Subject: [PATCH 1/4] 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, From b47180a21984ec1d40917e6d00cca287409e1709 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Tue, 17 Jun 2025 21:32:27 +0000 Subject: [PATCH 2/4] feat(server): create unit tests for normalizing server URL, and fix logic based on feedback --- apps/server/src/services/utils.spec.ts | 53 ++++++++++++++++++++++++++ apps/server/src/services/utils.ts | 6 +-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/utils.spec.ts b/apps/server/src/services/utils.spec.ts index f6b21a8cd..6e027b7bd 100644 --- a/apps/server/src/services/utils.spec.ts +++ b/apps/server/src/services/utils.spec.ts @@ -628,3 +628,56 @@ describe("#formatDownloadTitle", () => { }); }); }); + +describe("#normalizeUrl", () => { + const testCases: TestCase[] = [ + [ "should remove trailing slash from simple URL", [ "https://example.com/" ], "https://example.com" ], + [ "should remove trailing slash from URL with path", [ "https://example.com/path/" ], "https://example.com/path" ], + [ "should preserve URL without trailing slash", [ "https://example.com" ], "https://example.com" ], + [ "should preserve URL without trailing slash with path", [ "https://example.com/path" ], "https://example.com/path" ], + [ "should preserve protocol-only URLs", [ "https://" ], "https://" ], + [ "should preserve protocol-only URLs", [ "http://" ], "http://" ], + [ "should fix double slashes in path", [ "https://example.com//api//test" ], "https://example.com/api/test" ], + [ "should handle multiple double slashes", [ "https://example.com///api///test" ], "https://example.com/api/test" ], + [ "should handle trailing slash with double slashes", [ "https://example.com//api//" ], "https://example.com/api" ], + [ "should preserve protocol double slash", [ "https://example.com/api" ], "https://example.com/api" ], + [ "should handle empty string", [ "" ], "" ], + [ "should handle whitespace-only string", [ " " ], "" ], + [ "should trim whitespace", [ " https://example.com/ " ], "https://example.com" ], + [ "should handle null as empty", [ null as any ], null ], + [ "should handle undefined as empty", [ undefined as any ], undefined ] + ]; + + testCases.forEach((testCase) => { + const [ desc, fnParams, expected ] = testCase; + it(desc, () => { + const result = utils.normalizeUrl(...fnParams); + expect(result).toStrictEqual(expected); + }); + }); +}); + +describe("#normalizeCustomHandlerPattern", () => { + const testCases: TestCase[] = [ + [ "should handle pattern without ending - add both versions", [ "foo" ], [ "foo", "foo/" ] ], + [ "should handle pattern with trailing slash - add both versions", [ "foo/" ], [ "foo", "foo/" ] ], + [ "should handle pattern ending with $ - add optional slash", [ "foo$" ], [ "foo/?$" ] ], + [ "should handle pattern with trailing slash and $ - add both versions", [ "foo/$" ], [ "foo$", "foo/$" ] ], + [ "should preserve existing optional slash pattern", [ "foo/?$" ], [ "foo/?$" ] ], + [ "should preserve existing optional slash pattern (alternative)", [ "foo/?)" ], [ "foo/?)" ] ], + [ "should handle regex pattern with special chars", [ "api/[a-z]+$" ], [ "api/[a-z]+/?$" ] ], + [ "should handle complex regex pattern", [ "user/([0-9]+)/profile$" ], [ "user/([0-9]+)/profile/?$" ] ], + [ "should handle empty string", [ "" ], [ "" ] ], + [ "should handle whitespace-only string", [ " " ], [ "" ] ], + [ "should handle null", [ null as any ], [ null ] ], + [ "should handle undefined", [ undefined as any ], [ undefined ] ] + ]; + + testCases.forEach((testCase) => { + const [ desc, fnParams, expected ] = testCase; + it(desc, () => { + const result = utils.normalizeCustomHandlerPattern(...fnParams); + expect(result).toStrictEqual(expected); + }); + }); +}); diff --git a/apps/server/src/services/utils.ts b/apps/server/src/services/utils.ts index d33e54879..c3afd0ef0 100644 --- a/apps/server/src/services/utils.ts +++ b/apps/server/src/services/utils.ts @@ -394,14 +394,14 @@ export function normalizeUrl(url: string): string { return url; } + // Fix double slashes (except in protocol) first + url = url.replace(/([^:]\/)\/+/g, '$1'); + // 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; } From acd68817e9e6900049be81b68ad0d81be7e79de4 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Wed, 18 Jun 2025 20:46:11 +0000 Subject: [PATCH 3/4] feat(server): fix lint type errors for normalizing server URLs --- apps/server/src/routes/custom.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/server/src/routes/custom.ts b/apps/server/src/routes/custom.ts index e667a371b..b27a710f2 100644 --- a/apps/server/src/routes/custom.ts +++ b/apps/server/src/routes/custom.ts @@ -40,21 +40,21 @@ function handleRequest(req: Request, res: Response) { // Get normalized patterns to handle both trailing slash cases const patterns = normalizeCustomHandlerPattern(attr.value); - let match = null; + let match: RegExpMatchArray | null = null; - // Try each pattern until we find a match - for (const pattern of patterns) { - try { + try { + // Try each pattern until we find a match + for (const pattern of patterns) { 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; } + } 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; } if (!match) { From 2704b1546b4c328f33e46cb87d90140c4ce08060 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Wed, 18 Jun 2025 21:07:12 +0000 Subject: [PATCH 4/4] feat(server): fix lint type errors for normalizing server URLs --- apps/server/src/services/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/utils.ts b/apps/server/src/services/utils.ts index c3afd0ef0..5d5559d25 100644 --- a/apps/server/src/services/utils.ts +++ b/apps/server/src/services/utils.ts @@ -382,7 +382,7 @@ export function safeExtractMessageAndStackFromError(err: unknown): [errMessage: * @param url The URL to normalize * @returns The normalized URL without trailing slashes */ -export function normalizeUrl(url: string): string { +export function normalizeUrl(url: string | null | undefined): string | null | undefined { if (!url || typeof url !== 'string') { return url; } @@ -412,7 +412,7 @@ export function normalizeUrl(url: string): string { * @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[] { +export function normalizeCustomHandlerPattern(pattern: string | null | undefined): (string | null | undefined)[] { if (!pattern || typeof pattern !== 'string') { return [pattern]; }