@stamhoofd/backend 2.137.4 → 2.137.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +23 -17
  2. package/src/crons/drip-emails.ts +2 -1
  3. package/src/crons.ts +4 -2
  4. package/src/email-recipient-loaders/payments.ts +3 -2
  5. package/src/endpoints/auth/CreateAdminEndpoint.ts +3 -2
  6. package/src/endpoints/auth/CreateTokenEndpoint.ts +2 -1
  7. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +3 -2
  8. package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
  9. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
  10. package/src/endpoints/auth/SignupEndpoint.ts +4 -2
  11. package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -1
  12. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
  13. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
  14. package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
  15. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
  16. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
  17. package/src/endpoints/global/email/PatchEmailEndpoint.ts +2 -1
  18. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +9 -5
  19. package/src/endpoints/global/files/UploadFile.test.ts +206 -0
  20. package/src/endpoints/global/files/UploadFile.ts +31 -6
  21. package/src/endpoints/global/files/UploadImage.test.ts +177 -0
  22. package/src/endpoints/global/files/UploadImage.ts +23 -4
  23. package/src/endpoints/global/files/upload-security.test.ts +837 -0
  24. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
  25. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
  26. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
  27. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
  28. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
  29. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
  30. package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
  31. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
  32. package/src/excel-loaders/balance-items.ts +1 -1
  33. package/src/excel-loaders/event-notifications.ts +6 -7
  34. package/src/excel-loaders/members.test.ts +2 -2
  35. package/src/excel-loaders/members.ts +9 -10
  36. package/src/excel-loaders/organizations.ts +14 -20
  37. package/src/excel-loaders/payments.ts +1 -1
  38. package/src/excel-loaders/platform-memberships.ts +7 -7
  39. package/src/excel-loaders/platform-sheets.test.ts +113 -0
  40. package/src/excel-loaders/receivable-balances.ts +1 -1
  41. package/src/excel-loaders/registrations.ts +9 -9
  42. package/src/helpers/AdminPermissionChecker.ts +1 -1
  43. package/src/helpers/AuthenticatedStructures.ts +12 -9
  44. package/src/helpers/MembershipCharger.ts +3 -2
  45. package/src/services/BalanceItemService.ts +2 -1
  46. package/src/services/DocumentRenderService.test.ts +229 -0
  47. package/src/services/DocumentRenderService.ts +180 -0
  48. package/src/services/EmailPreviewService.test.ts +300 -0
  49. package/src/services/EmailPreviewService.ts +239 -0
  50. package/src/services/FileSignService.test.ts +85 -0
  51. package/src/services/FileSignService.ts +23 -1
  52. package/src/services/OrderService.test.ts +308 -0
  53. package/src/services/OrderService.ts +214 -0
  54. package/src/services/OrganizationDNSService.test.ts +177 -0
  55. package/src/services/OrganizationDNSService.ts +282 -0
  56. package/src/services/OrganizationEmailService.test.ts +57 -0
  57. package/src/services/OrganizationEmailService.ts +211 -0
  58. package/src/services/PasswordForgotService.test.ts +99 -0
  59. package/src/services/PasswordForgotService.ts +38 -0
  60. package/src/services/PlatformMembershipService.test.ts +180 -0
  61. package/src/services/PlatformMembershipService.ts +281 -4
  62. package/src/services/RegistrationService.ts +3 -2
  63. package/src/services/STPackageService.test.ts +191 -0
  64. package/src/services/STPackageService.ts +114 -2
  65. package/src/services/VerificationCodeService.test.ts +71 -0
  66. package/src/services/VerificationCodeService.ts +100 -0
  67. package/tests/e2e/documents.test.ts +2 -1
  68. package/tests/e2e/private-files.test.ts +77 -14
@@ -0,0 +1,71 @@
1
+ import { I18n } from '@stamhoofd/backend-i18n';
2
+ import { EmailMocker } from '@stamhoofd/email';
3
+ import { EmailTemplateFactory, EmailVerificationCode, OrganizationFactory, Platform, UserFactory } from '@stamhoofd/models';
4
+ import { EmailTemplateType } from '@stamhoofd/structures';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+ import { VerificationCodeService } from './VerificationCodeService.js';
7
+
8
+ const i18n = new I18n(I18n.defaultLanguage, I18n.defaultCountry);
9
+
10
+ const PLATFORM_NAME = 'The test platform';
11
+
12
+ /**
13
+ * A template that only contains the organizationName replacement, so the name that ends up in the
14
+ * email can be asserted without depending on the real (translated) template contents.
15
+ */
16
+ async function createVerifyEmailTemplate() {
17
+ await new EmailTemplateFactory({
18
+ type: EmailTemplateType.VerifyEmail,
19
+ html: '<p>{{organizationName}}</p>',
20
+ text: '{{organizationName}}',
21
+ }).create();
22
+ }
23
+
24
+ describe('VerificationCodeService', () => {
25
+ let originalPlatformName: string;
26
+
27
+ beforeAll(async () => {
28
+ const platform = await Platform.getForEditing();
29
+ originalPlatformName = platform.config.name;
30
+ platform.config.name = PLATFORM_NAME;
31
+ await platform.save();
32
+ });
33
+
34
+ afterAll(async () => {
35
+ const platform = await Platform.getForEditing();
36
+ platform.config.name = originalPlatformName;
37
+ await platform.save();
38
+ });
39
+
40
+ describe('send', () => {
41
+ test('Uses the name of the organization when the code is scoped to an organization', async () => {
42
+ TestUtils.setEnvironment('userMode', 'organization');
43
+ await createVerifyEmailTemplate();
44
+
45
+ const organization = await new OrganizationFactory({ name: 'Organization with a name' }).create();
46
+ const user = await new UserFactory({ organization }).create();
47
+ const code = await EmailVerificationCode.createFor(user, user.email);
48
+
49
+ await VerificationCodeService.send(code, user, organization, i18n);
50
+
51
+ const emails = await EmailMocker.transactional.getSucceededEmails();
52
+ expect(emails.length).toBe(1);
53
+ expect(emails[0].text).toContain('Organization with a name');
54
+ expect(emails[0].text).not.toContain(PLATFORM_NAME);
55
+ });
56
+
57
+ test('Falls back to the name of the platform when there is no organization', async () => {
58
+ TestUtils.setEnvironment('userMode', 'platform');
59
+ await createVerifyEmailTemplate();
60
+
61
+ const user = await new UserFactory({}).create();
62
+ const code = await EmailVerificationCode.createFor(user, user.email);
63
+
64
+ await VerificationCodeService.send(code, user, null, i18n);
65
+
66
+ const emails = await EmailMocker.transactional.getSucceededEmails();
67
+ expect(emails.length).toBe(1);
68
+ expect(emails[0].text).toContain(PLATFORM_NAME);
69
+ });
70
+ });
71
+ });
@@ -0,0 +1,100 @@
1
+ import type { I18n } from '@stamhoofd/backend-i18n';
2
+ import type { Organization } from '@stamhoofd/models';
3
+ import { EmailVerificationCode, Platform, sendEmailTemplate, User } from '@stamhoofd/models';
4
+ import { EmailTemplateType, getAppHost, Recipient, Replacement } from '@stamhoofd/structures';
5
+
6
+ /**
7
+ * Sends the verification emails for an EmailVerificationCode. The code itself is generated, stored
8
+ * and verified on the model.
9
+ */
10
+ export class VerificationCodeService {
11
+ static getEmailVerificationUrl(code: EmailVerificationCode, user: User, organization: Organization | null, i18n: I18n) {
12
+ const host = getAppHost('verify-email', organization, !!user.permissions, i18n);
13
+ return 'https://' + host + '?code=' + encodeURIComponent(code.code) + '&token=' + encodeURIComponent(code.token) + '&email=' + encodeURIComponent(code.email);
14
+ }
15
+
16
+ static async send(code: EmailVerificationCode, user: User, organization: Organization | null, i18n: I18n, withCode = true) {
17
+ const url = this.getEmailVerificationUrl(code, user, organization, i18n);
18
+
19
+ const name = organization?.name ?? (await Platform.getSharedPrivateStruct()).config.name;
20
+
21
+ const replacements: Replacement[] = [
22
+ Replacement.create({
23
+ token: 'organizationName',
24
+ value: name,
25
+ }),
26
+ Replacement.create({
27
+ token: 'confirmEmailUrl',
28
+ value: url,
29
+ }),
30
+ ];
31
+
32
+ if (withCode) {
33
+ const formattedCode = code.code.substr(0, 3) + ' ' + code.code.substr(3);
34
+
35
+ await sendEmailTemplate(organization, {
36
+ recipients: [
37
+ Recipient.create({
38
+ email: code.email,
39
+ replacements: [
40
+ ...replacements,
41
+ Replacement.create({
42
+ token: 'confirmEmailCode',
43
+ value: formattedCode,
44
+ }),
45
+ ],
46
+ }),
47
+ ],
48
+ template: {
49
+ type: EmailTemplateType.VerifyEmail,
50
+ },
51
+ type: 'transactional',
52
+ });
53
+ } else {
54
+ await sendEmailTemplate(organization, {
55
+ recipients: [
56
+ Recipient.create({
57
+ email: code.email,
58
+ replacements,
59
+ }),
60
+ ],
61
+ template: {
62
+ type: EmailTemplateType.VerifyEmailWithoutCode,
63
+ },
64
+ type: 'transactional',
65
+ });
66
+ }
67
+ }
68
+
69
+ static async resend(organization: Organization | null, token: string, i18n: I18n) {
70
+ const verificationCodes = await EmailVerificationCode.where({
71
+ token,
72
+ organizationId: organization
73
+ ? {
74
+ sign: 'IN',
75
+ value: [organization.id, null],
76
+ }
77
+ : null,
78
+ }, { limit: 1 });
79
+
80
+ if (verificationCodes.length == 0) {
81
+ console.log("Can't resend code, no coded found for token", token);
82
+ // TODO: maybe send a note via email
83
+ return;
84
+ }
85
+
86
+ const verificationCode = verificationCodes[0];
87
+
88
+ if (verificationCode.expiresAt < new Date()) {
89
+ // Don't report error, could be brute forced
90
+ console.log("Can't resend code, token is expired", token);
91
+ return;
92
+ }
93
+
94
+ const user = await User.getByID(verificationCode.userId);
95
+ if (!user) {
96
+ return;
97
+ }
98
+ await this.send(verificationCode, user, organization, i18n);
99
+ }
100
+ }
@@ -12,6 +12,7 @@ import { testServer } from '../helpers/TestServer.js';
12
12
  import { initAdmin } from '../init/initAdmin.js';
13
13
  import { initStripe } from '../init/initStripe.js';
14
14
  import { registrationUpdateQueue } from '../../src/services/BalanceItemService.js';
15
+ import { DocumentRenderService } from '../../src/services/DocumentRenderService.js';
15
16
  import { initMembershipOrganization } from '../init/initMembershipOrganization.js';
16
17
 
17
18
  const baseUrl = `/members/register`;
@@ -119,7 +120,7 @@ describe('E2E.Documents', () => {
119
120
  const document = await Document.select().where('registrationId', registration.id).first(false);
120
121
  expect(document).not.toBeNull();
121
122
 
122
- const html = await document!.getRenderedHtml(organization);
123
+ const html = await DocumentRenderService.getRenderedHtml(document!, organization);
123
124
 
124
125
  const registrationModel = (await Registration.getByID(registration.id))!;
125
126
 
@@ -1,7 +1,7 @@
1
1
  import { File, MemberDetails, MemberWithRegistrationsBlob, RecordCategory, RecordFileAnswer, RecordSettings, RecordType, TranslatedString, Version } from '@stamhoofd/structures';
2
2
 
3
3
  import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
4
- import { PatchableArray } from '@simonbackx/simple-encoding';
4
+ import { encodeObject, EncodeMedium, PatchableArray } from '@simonbackx/simple-encoding';
5
5
  import { Request } from '@simonbackx/simple-endpoints';
6
6
  import { Member, OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
7
7
  import { PermissionLevel, Permissions } from '@stamhoofd/structures';
@@ -62,7 +62,7 @@ describe('E2E.PrivateFiles', () => {
62
62
  // access to the signed URL. This should not be possible without a valid signature
63
63
  const privateFile = new File({
64
64
  id: 'test',
65
- server: 'test.com',
65
+ server: 'https://test.com',
66
66
  path: 'test.txt',
67
67
  size: 100,
68
68
  isPrivate: true,
@@ -105,7 +105,7 @@ describe('E2E.PrivateFiles', () => {
105
105
  // access to the signed URL. This should not be possible without a valid signature
106
106
  const privateFile = new File({
107
107
  id: 'test',
108
- server: 'test.com',
108
+ server: 'https://test.com',
109
109
  path: 'test.txt',
110
110
  size: 100,
111
111
  isPrivate: true,
@@ -149,7 +149,7 @@ describe('E2E.PrivateFiles', () => {
149
149
  // access to the signed URL. This should not be possible without a valid signature
150
150
  const privateFile = new File({
151
151
  id: 'test',
152
- server: 'test.com',
152
+ server: 'https://test.com',
153
153
  path: 'test.txt',
154
154
  size: 100,
155
155
  isPrivate: true,
@@ -188,6 +188,69 @@ describe('E2E.PrivateFiles', () => {
188
188
  expect(answer.file!.signedUrl).toBeTruthy();
189
189
  });
190
190
 
191
+ test('Cannot set a public file with a url a browser would run as code', async () => {
192
+ const organization = await createOrganization();
193
+
194
+ const user = await new UserFactory({
195
+ organization,
196
+ permissions: Permissions.create({
197
+ level: PermissionLevel.Full,
198
+ }),
199
+ }).create();
200
+ const token = await Token.createToken(user);
201
+
202
+ // Public files are not signed, so a malicious user can put anything in them. The url of a file ends up
203
+ // in a link (and in the source of an image), so a javascript: url would run on our own domain.
204
+ const buildBody = (server: string) => {
205
+ const member = MemberWithRegistrationsBlob.create({
206
+ details: MemberDetails.create({
207
+ firstName: 'John',
208
+ lastName: 'Doe',
209
+ }),
210
+ });
211
+
212
+ member.details.recordAnswers.set(recordSettings.id, RecordFileAnswer.create({
213
+ file: new File({
214
+ id: 'test',
215
+ server: 'https://replace-me.example.com',
216
+ path: 'test.pdf',
217
+ size: 100,
218
+ isPrivate: false,
219
+ }),
220
+ settings: recordSettings,
221
+ }));
222
+
223
+ const arr = new PatchableArray() as PatchableArrayAutoEncoder<MemberWithRegistrationsBlob>;
224
+ arr.addPut(member);
225
+
226
+ // An attacker doesn't use our client, so build the request body they would send instead
227
+ const encoded = encodeObject(arr, { version: Version, medium: EncodeMedium.Network });
228
+ return JSON.parse(JSON.stringify(encoded).replaceAll('https://replace-me.example.com', server));
229
+ };
230
+
231
+ const attacks = [
232
+ { server: 'javascript:alert(document.domain)', error: /can only be http or https/i },
233
+ { server: 'data:text/html,<script>alert(1)</script>', error: /can only be http or https/i },
234
+ { server: 'not-a-url', error: /invalid url for a file/i },
235
+ { server: '', error: /invalid url for a file/i },
236
+ ];
237
+
238
+ for (const attack of attacks) {
239
+ const r = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), buildBody(attack.server));
240
+ r.headers.authorization = 'Bearer ' + token.accessToken;
241
+
242
+ await expect(testServer.test(endpoint, r), attack.server).rejects.toThrow(attack.error);
243
+ }
244
+
245
+ // ... and our own code can never store one either
246
+ expect(() => Request.buildJson('PATCH', baseUrl, organization.getApiHost(), new File({
247
+ id: 'test',
248
+ server: 'javascript:alert(document.domain)',
249
+ path: 'test.pdf',
250
+ size: 100,
251
+ }))).toThrow(/can only be http or https/i);
252
+ });
253
+
191
254
  test('Can set public files', async () => {
192
255
  const organization = await createOrganization();
193
256
 
@@ -211,7 +274,7 @@ describe('E2E.PrivateFiles', () => {
211
274
  // access to the signed URL. This should not be possible without a valid signature
212
275
  const publicFile = new File({
213
276
  id: 'test',
214
- server: 'test.com',
277
+ server: 'https://test.com',
215
278
  path: 'test.txt',
216
279
  size: 100,
217
280
  isPrivate: false,
@@ -275,7 +338,7 @@ describe('E2E.PrivateFiles', () => {
275
338
  // access to the signed URL. This should not be possible without a valid signature
276
339
  const maliciousFile = new File({
277
340
  id: 'test',
278
- server: 'test.com',
341
+ server: 'https://test.com',
279
342
  path: 'test.txt',
280
343
  size: 100,
281
344
  isPrivate: true,
@@ -355,7 +418,7 @@ describe('E2E.PrivateFiles', () => {
355
418
  // access to the signed URL. This should not be possible without a valid signature
356
419
  const maliciousFile = new File({
357
420
  id: 'test',
358
- server: 'test.com',
421
+ server: 'https://test.com',
359
422
  path: 'test.txt',
360
423
  size: 100,
361
424
  isPrivate: true,
@@ -418,7 +481,7 @@ describe('E2E.PrivateFiles', () => {
418
481
  // access to the signed URL. This should not be possible without a valid signature
419
482
  const privateFile = new File({
420
483
  id: 'test',
421
- server: 'test.com',
484
+ server: 'https://test.com',
422
485
  path: 'test.txt',
423
486
  size: 100,
424
487
  isPrivate: true,
@@ -447,7 +510,7 @@ describe('E2E.PrivateFiles', () => {
447
510
  // access to the signed URL. This should not be possible without a valid signature
448
511
  const privateFile = new File({
449
512
  id: 'test',
450
- server: 'test.com',
513
+ server: 'https://test.com',
451
514
  path: 'test.txt',
452
515
  size: 100,
453
516
  isPrivate: true,
@@ -481,7 +544,7 @@ describe('E2E.PrivateFiles', () => {
481
544
  // access to the signed URL. This should not be possible without a valid signature
482
545
  const privateFile = new File({
483
546
  id: 'test',
484
- server: 'test.com',
547
+ server: 'https://test.com',
485
548
  path: 'test.txt',
486
549
  size: 100,
487
550
  isPrivate: true,
@@ -509,7 +572,7 @@ describe('E2E.PrivateFiles', () => {
509
572
  // access to the signed URL. This should not be possible without a valid signature
510
573
  const privateFile = new File({
511
574
  id: 'test',
512
- server: 'test.com',
575
+ server: 'https://test.com',
513
576
  path: 'test.txt',
514
577
  size: 100,
515
578
  isPrivate: true,
@@ -537,7 +600,7 @@ describe('E2E.PrivateFiles', () => {
537
600
  // access to the signed URL. This should not be possible without a valid signature
538
601
  const privateFile = new File({
539
602
  id: 'test',
540
- server: 'test.com',
603
+ server: 'https://test.com',
541
604
  path: 'test.txt',
542
605
  size: 100,
543
606
  isPrivate: true,
@@ -564,7 +627,7 @@ describe('E2E.PrivateFiles', () => {
564
627
  // access to the signed URL. This should not be possible without a valid signature
565
628
  const privateFile = new File({
566
629
  id: 'test',
567
- server: 'test.com',
630
+ server: 'https://test.com',
568
631
  path: 'test.txt',
569
632
  size: 100,
570
633
  isPrivate: true,
@@ -613,7 +676,7 @@ describe('E2E.PrivateFiles', () => {
613
676
  // access to the signed URL. This should not be possible without a valid signature
614
677
  const maliciousFile = new File({
615
678
  id: 'test',
616
- server: 'test.com',
679
+ server: 'https://test.com',
617
680
  path: 'test.txt',
618
681
  size: 100,
619
682
  isPrivate: true,