Skip to content
This repository was archived by the owner on Jun 24, 2025. It is now read-only.

Commit a861def

Browse files
authored
feat(server): lint for trailing slashes in sync URL and extra slashes… (#2345)
2 parents ea0b570 + 2704b15 commit a861def

File tree

4 files changed

+151
-5
lines changed

4 files changed

+151
-5
lines changed

apps/server/src/routes/custom.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import cls from "../services/cls.js";
55
import sql from "../services/sql.js";
66
import becca from "../becca/becca.js";
77
import type { Request, Response, Router } from "express";
8-
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
8+
import { safeExtractMessageAndStackFromError, normalizeCustomHandlerPattern } from "../services/utils.js";
99

1010
function handleRequest(req: Request, res: Response) {
1111

@@ -38,11 +38,19 @@ function handleRequest(req: Request, res: Response) {
3838
continue;
3939
}
4040

41-
const regex = new RegExp(`^${attr.value}$`);
42-
let match;
41+
// Get normalized patterns to handle both trailing slash cases
42+
const patterns = normalizeCustomHandlerPattern(attr.value);
43+
let match: RegExpMatchArray | null = null;
4344

4445
try {
45-
match = path.match(regex);
46+
// Try each pattern until we find a match
47+
for (const pattern of patterns) {
48+
const regex = new RegExp(`^${pattern}$`);
49+
match = path.match(regex);
50+
if (match) {
51+
break; // Found a match, exit pattern loop
52+
}
53+
}
4654
} catch (e: unknown) {
4755
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
4856
log.error(`Testing path for label '${attr.attributeId}', regex '${attr.value}' failed with error: ${errMessage}, stack: ${errStack}`);

apps/server/src/services/sync_options.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import optionService from "./options.js";
44
import config from "./config.js";
5+
import { normalizeUrl } from "./utils.js";
56

67
/*
78
* 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) {
1718
export default {
1819
// env variable is the easiest way to guarantee we won't overwrite prod data during development
1920
// after copying prod document/data directory
20-
getSyncServerHost: () => get("syncServerHost"),
21+
getSyncServerHost: () => {
22+
const host = get("syncServerHost");
23+
return host ? normalizeUrl(host) : host;
24+
},
2125
isSyncSetup: () => {
2226
const syncServerHost = get("syncServerHost");
2327

apps/server/src/services/utils.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,3 +628,56 @@ describe("#formatDownloadTitle", () => {
628628
});
629629
});
630630
});
631+
632+
describe("#normalizeUrl", () => {
633+
const testCases: TestCase<typeof utils.normalizeUrl>[] = [
634+
[ "should remove trailing slash from simple URL", [ "https://example.com/" ], "https://example.com" ],
635+
[ "should remove trailing slash from URL with path", [ "https://example.com/path/" ], "https://example.com/path" ],
636+
[ "should preserve URL without trailing slash", [ "https://example.com" ], "https://example.com" ],
637+
[ "should preserve URL without trailing slash with path", [ "https://example.com/path" ], "https://example.com/path" ],
638+
[ "should preserve protocol-only URLs", [ "https://" ], "https://" ],
639+
[ "should preserve protocol-only URLs", [ "http://" ], "http://" ],
640+
[ "should fix double slashes in path", [ "https://example.com//api//test" ], "https://example.com/api/test" ],
641+
[ "should handle multiple double slashes", [ "https://example.com///api///test" ], "https://example.com/api/test" ],
642+
[ "should handle trailing slash with double slashes", [ "https://example.com//api//" ], "https://example.com/api" ],
643+
[ "should preserve protocol double slash", [ "https://example.com/api" ], "https://example.com/api" ],
644+
[ "should handle empty string", [ "" ], "" ],
645+
[ "should handle whitespace-only string", [ " " ], "" ],
646+
[ "should trim whitespace", [ " https://example.com/ " ], "https://example.com" ],
647+
[ "should handle null as empty", [ null as any ], null ],
648+
[ "should handle undefined as empty", [ undefined as any ], undefined ]
649+
];
650+
651+
testCases.forEach((testCase) => {
652+
const [ desc, fnParams, expected ] = testCase;
653+
it(desc, () => {
654+
const result = utils.normalizeUrl(...fnParams);
655+
expect(result).toStrictEqual(expected);
656+
});
657+
});
658+
});
659+
660+
describe("#normalizeCustomHandlerPattern", () => {
661+
const testCases: TestCase<typeof utils.normalizeCustomHandlerPattern>[] = [
662+
[ "should handle pattern without ending - add both versions", [ "foo" ], [ "foo", "foo/" ] ],
663+
[ "should handle pattern with trailing slash - add both versions", [ "foo/" ], [ "foo", "foo/" ] ],
664+
[ "should handle pattern ending with $ - add optional slash", [ "foo$" ], [ "foo/?$" ] ],
665+
[ "should handle pattern with trailing slash and $ - add both versions", [ "foo/$" ], [ "foo$", "foo/$" ] ],
666+
[ "should preserve existing optional slash pattern", [ "foo/?$" ], [ "foo/?$" ] ],
667+
[ "should preserve existing optional slash pattern (alternative)", [ "foo/?)" ], [ "foo/?)" ] ],
668+
[ "should handle regex pattern with special chars", [ "api/[a-z]+$" ], [ "api/[a-z]+/?$" ] ],
669+
[ "should handle complex regex pattern", [ "user/([0-9]+)/profile$" ], [ "user/([0-9]+)/profile/?$" ] ],
670+
[ "should handle empty string", [ "" ], [ "" ] ],
671+
[ "should handle whitespace-only string", [ " " ], [ "" ] ],
672+
[ "should handle null", [ null as any ], [ null ] ],
673+
[ "should handle undefined", [ undefined as any ], [ undefined ] ]
674+
];
675+
676+
testCases.forEach((testCase) => {
677+
const [ desc, fnParams, expected ] = testCase;
678+
it(desc, () => {
679+
const result = utils.normalizeCustomHandlerPattern(...fnParams);
680+
expect(result).toStrictEqual(expected);
681+
});
682+
});
683+
});

apps/server/src/services/utils.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,85 @@ export function safeExtractMessageAndStackFromError(err: unknown): [errMessage:
375375
return (err instanceof Error) ? [err.message, err.stack] as const : ["Unknown Error", undefined] as const;
376376
}
377377

378+
/**
379+
* Normalizes URL by removing trailing slashes and fixing double slashes.
380+
* Preserves the protocol (http://, https://) but removes trailing slashes from the rest.
381+
*
382+
* @param url The URL to normalize
383+
* @returns The normalized URL without trailing slashes
384+
*/
385+
export function normalizeUrl(url: string | null | undefined): string | null | undefined {
386+
if (!url || typeof url !== 'string') {
387+
return url;
388+
}
389+
390+
// Trim whitespace
391+
url = url.trim();
392+
393+
if (!url) {
394+
return url;
395+
}
396+
397+
// Fix double slashes (except in protocol) first
398+
url = url.replace(/([^:]\/)\/+/g, '$1');
399+
400+
// Remove trailing slash, but preserve protocol
401+
if (url.endsWith('/') && !url.match(/^https?:\/\/$/)) {
402+
url = url.slice(0, -1);
403+
}
404+
405+
return url;
406+
}
407+
408+
/**
409+
* Normalizes a path pattern for custom request handlers.
410+
* Ensures both trailing slash and non-trailing slash versions are handled.
411+
*
412+
* @param pattern The original pattern from customRequestHandler attribute
413+
* @returns An array of patterns to match both with and without trailing slash
414+
*/
415+
export function normalizeCustomHandlerPattern(pattern: string | null | undefined): (string | null | undefined)[] {
416+
if (!pattern || typeof pattern !== 'string') {
417+
return [pattern];
418+
}
419+
420+
pattern = pattern.trim();
421+
422+
if (!pattern) {
423+
return [pattern];
424+
}
425+
426+
// If pattern already ends with optional trailing slash, return as-is
427+
if (pattern.endsWith('/?$') || pattern.endsWith('/?)')) {
428+
return [pattern];
429+
}
430+
431+
// If pattern ends with $, handle it specially
432+
if (pattern.endsWith('$')) {
433+
const basePattern = pattern.slice(0, -1);
434+
435+
// If already ends with slash, create both versions
436+
if (basePattern.endsWith('/')) {
437+
const withoutSlash = basePattern.slice(0, -1) + '$';
438+
const withSlash = pattern;
439+
return [withoutSlash, withSlash];
440+
} else {
441+
// Add optional trailing slash
442+
const withSlash = basePattern + '/?$';
443+
return [withSlash];
444+
}
445+
}
446+
447+
// For patterns without $, add both versions
448+
if (pattern.endsWith('/')) {
449+
const withoutSlash = pattern.slice(0, -1);
450+
return [withoutSlash, pattern];
451+
} else {
452+
const withSlash = pattern + '/';
453+
return [pattern, withSlash];
454+
}
455+
}
456+
378457

379458
export default {
380459
compareVersions,
@@ -400,6 +479,8 @@ export default {
400479
md5,
401480
newEntityId,
402481
normalize,
482+
normalizeCustomHandlerPattern,
483+
normalizeUrl,
403484
quoteRegex,
404485
randomSecureToken,
405486
randomString,

0 commit comments

Comments
 (0)