Skip to content

Commit 12d147c

Browse files
committed
fix: canonicalize mailing list sourceUrl trailing slash (CM-1318)
Signed-off-by: Uroš Marolt <uros@marolt.me>
1 parent 42406aa commit 12d147c

2 files changed

Lines changed: 27 additions & 6 deletions

File tree

backend/src/api/integration/helpers/mailingListAuthenticate.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ const isSafeSourceUrl = (sourceUrl: string): boolean => {
2929
}
3030
}
3131

32+
// "https://host/list" and "https://host/list/" must resolve to the same DB
33+
// row — the worker's ensure_mirror() already normalizes to this form before
34+
// cloning (mirror_service.py), so storage must match or the same archive
35+
// ends up mirrored twice under two rows. Kept as a plain function (not a
36+
// zod .transform()/.preprocess()) since either makes the field optional in
37+
// z.infer with the installed zod v4 — reproduced in isolation, unrelated to
38+
// this schema's nesting.
39+
export const canonicalizeSourceUrl = (sourceUrl: string): string => sourceUrl.replace(/\/+$/, '')
40+
3241
export const bodySchema = z.object({
3342
lists: z
3443
.array(
@@ -42,14 +51,20 @@ export const bodySchema = z.object({
4251
}),
4352
)
4453
.min(1, 'lists must contain at least one mailing list')
45-
.refine((lists) => new Set(lists.map((l) => l.sourceUrl)).size === lists.length, {
46-
message: 'lists contains duplicate sourceUrl entries',
47-
}),
54+
.refine(
55+
(lists) =>
56+
new Set(lists.map((l) => canonicalizeSourceUrl(l.sourceUrl))).size === lists.length,
57+
{ message: 'lists contains duplicate sourceUrl entries' },
58+
),
4859
})
4960

5061
export default async (req, res) => {
5162
new PermissionChecker(req).validateHas(Permissions.values.tenantEdit)
5263
const integrationData = validateOrThrow(bodySchema, req.body)
64+
integrationData.lists = integrationData.lists.map((l) => ({
65+
...l,
66+
sourceUrl: canonicalizeSourceUrl(l.sourceUrl),
67+
}))
5368

5469
const payload = await new IntegrationService(req).mailingListConnectOrUpdate(integrationData)
5570
await req.responseHandler.success(req, res, payload)

backend/src/bin/scripts/create-mailing-list-integration.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import * as fs from 'fs'
77

88
import { DEFAULT_TENANT_ID, generateUUIDv1 } from '@crowd/common'
99

10-
import { bodySchema } from '@/api/integration/helpers/mailingListAuthenticate'
10+
import {
11+
bodySchema,
12+
canonicalizeSourceUrl,
13+
} from '@/api/integration/helpers/mailingListAuthenticate'
1114
import SegmentRepository from '@/database/repositories/segmentRepository'
1215
import SequelizeRepository from '@/database/repositories/sequelizeRepository'
1316
import IntegrationService from '@/services/integrationService'
@@ -81,7 +84,8 @@ if (parameters.help || !parameters.file) {
8184
// so a malformed file (non-array, missing fields, duplicate/non-https
8285
// sourceUrl) fails fast here instead of reaching IntegrationService with
8386
// whatever shape the file happened to contain.
84-
const { lists } = validateOrThrow(bodySchema, { lists: parsed })
87+
const { lists: parsedLists } = validateOrThrow(bodySchema, { lists: parsed })
88+
const lists = parsedLists.map((l) => ({ ...l, sourceUrl: canonicalizeSourceUrl(l.sourceUrl) }))
8589

8690
const repoOptions = await SequelizeRepository.getDefaultIRepositoryOptions()
8791
repoOptions.currentTenant = { id: DEFAULT_TENANT_ID }
@@ -104,7 +108,9 @@ if (parameters.help || !parameters.file) {
104108
process.exit(1)
105109
}
106110

107-
console.log(`Segment: ${repoOptions.currentSegments[0].id} (${repoOptions.currentSegments[0].name})`)
111+
console.log(
112+
`Segment: ${repoOptions.currentSegments[0].id} (${repoOptions.currentSegments[0].name})`,
113+
)
108114
console.log('Lists:', JSON.stringify(lists, null, 2))
109115

110116
const integration = await new IntegrationService(repoOptions).mailingListConnectOrUpdate(

0 commit comments

Comments
 (0)