diff --git a/apps/server/src/routes/custom.ts b/apps/server/src/routes/custom.ts index e4494a6cd..b27a710f2 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,11 +38,19 @@ 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: RegExpMatchArray | null = null; try { - match = path.match(regex); + // 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 '${attr.value}' failed with error: ${errMessage}, stack: ${errStack}`); 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.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 89aad1bbb..5d5559d25 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 | null | undefined): string | null | undefined { + if (!url || typeof url !== 'string') { + return url; + } + + // Trim whitespace + url = url.trim(); + + if (!url) { + 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); + } + + 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 | null | undefined): (string | null | undefined)[] { + 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,