@stamhoofd/backend 2.137.3 → 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,300 @@
1
+ import { I18n } from '@stamhoofd/backend-i18n/I18n';
2
+ import type { Member, Organization, RegistrationPeriod, User } from '@stamhoofd/models';
3
+ import { Email, EmailRecipient, MemberFactory, OrganizationFactory, RegistrationPeriodFactory, UserFactory } from '@stamhoofd/models';
4
+ import { EmailContent, EmailStatus, Replacement } from '@stamhoofd/structures';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+ import { Country } from '@stamhoofd/types/Country';
7
+ import { Language } from '@stamhoofd/types/Language';
8
+ import { EmailPreviewService } from './EmailPreviewService.js';
9
+
10
+ describe('EmailPreviewService', () => {
11
+ let period: RegistrationPeriod;
12
+ let organization: Organization;
13
+ let user: User;
14
+ let member: Member;
15
+
16
+ beforeAll(async () => {
17
+ TestUtils.setPermanentEnvironment('userMode', 'platform');
18
+
19
+ period = await new RegistrationPeriodFactory({
20
+ startDate: new Date(2023, 0, 1),
21
+ endDate: new Date(2023, 11, 31),
22
+ }).create();
23
+
24
+ organization = await new OrganizationFactory({ period }).create();
25
+
26
+ user = await new UserFactory({ organization }).create();
27
+ member = await new MemberFactory({ organization, user }).create();
28
+ });
29
+
30
+ afterEach(async () => {
31
+ await Email.delete();
32
+ });
33
+
34
+ /**
35
+ * A sent email that is visible in the member portal.
36
+ */
37
+ const createSentEmail = async (data: { html: string; text?: string; subject?: string } & Partial<Email>) => {
38
+ const email = new Email();
39
+ email.subject = data.subject ?? 'Test subject';
40
+ email.status = EmailStatus.Sent;
41
+ email.html = data.html;
42
+ email.text = data.text ?? 'Test text';
43
+ email.json = {};
44
+ email.language = data.language ?? null;
45
+ email.translations = data.translations ?? new Map();
46
+ email.organizationId = organization.id;
47
+ email.showInMemberPortal = true;
48
+ email.sentAt = new Date();
49
+ await email.save();
50
+ return email;
51
+ };
52
+
53
+ const createRecipient = async (email: Email, data: Partial<EmailRecipient> & { memberId: string }) => {
54
+ const recipient = new EmailRecipient();
55
+ recipient.emailId = email.id;
56
+ recipient.memberId = data.memberId;
57
+ recipient.userId = data.userId ?? null;
58
+ recipient.email = data.email ?? null;
59
+ recipient.firstName = data.firstName ?? 'Test';
60
+ recipient.lastName = data.lastName ?? 'Test';
61
+ recipient.language = data.language ?? null;
62
+ recipient.replacements = data.replacements ?? [];
63
+ recipient.sentAt = new Date();
64
+ await recipient.save();
65
+ return recipient;
66
+ };
67
+
68
+ describe('getStructureForUser', () => {
69
+ test('strips the sensitive replacements that were stored for another recipient', async () => {
70
+ // The signInUrl of another user is a login link: it may never end up in the structure
71
+ // that is returned to this user.
72
+ const secret = 'private-signin-token-67890';
73
+ const otherUser = await new UserFactory({ organization }).create();
74
+
75
+ const email = await createSentEmail({
76
+ subject: 'Privacy boundary',
77
+ // The token has to be used in the content, otherwise removeUnusedReplacements
78
+ // drops it and the leak would not be observable
79
+ html: '<p>Sign in here: {{signInUrl}}</p>',
80
+ text: 'Sign in here: {{signInUrl}}',
81
+ });
82
+
83
+ // A recipient of the same member, but belonging to a different user
84
+ await createRecipient(email, {
85
+ memberId: member.id,
86
+ userId: otherUser.id,
87
+ email: otherUser.email,
88
+ replacements: [
89
+ Replacement.create({
90
+ token: 'signInUrl',
91
+ value: 'https://example.com/login?token=' + secret,
92
+ }),
93
+ ],
94
+ });
95
+
96
+ const structure = await EmailPreviewService.getStructureForUser(email, user, [member.id]);
97
+
98
+ expect(structure.recipients).toHaveLength(1);
99
+ const recipient = structure.recipients[0];
100
+
101
+ // The stored replacements of the other user are gone
102
+ expect(JSON.stringify(recipient.replacements)).not.toContain(secret);
103
+
104
+ // ...and replaced by a sign in url generated for the user that is viewing the email
105
+ const signInUrl = recipient.replacements.find(r => r.token === 'signInUrl');
106
+ expect(signInUrl).toBeDefined();
107
+ expect(signInUrl!.value).toContain(encodeURIComponent(user.email));
108
+ });
109
+
110
+ test('renders the replacements in the language of the recipient, not in the ambient locale', async () => {
111
+ TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
112
+
113
+ // The web greeting is looked up in shared/locales/dist/locales/digit/fr-BE.json,
114
+ // hardcoded on purpose so we don't verify $t with the same $t machinery we're testing
115
+ const frenchGreeting = 'Bonjour,';
116
+
117
+ const email = await createSentEmail({
118
+ subject: 'Nederlands onderwerp',
119
+ html: '<p>{{greeting}} Nederlandse inhoud</p>',
120
+ text: '{{greeting}} Nederlandse tekst',
121
+ language: Language.Dutch,
122
+ translations: new Map([
123
+ [Language.French, EmailContent.create({
124
+ subject: 'Sujet français',
125
+ html: '<p>{{greeting}} Contenu français</p>',
126
+ text: 'Texte français',
127
+ })],
128
+ ]),
129
+ });
130
+
131
+ await createRecipient(email, {
132
+ memberId: member.id,
133
+ userId: user.id,
134
+ email: user.email,
135
+ language: Language.French,
136
+ });
137
+
138
+ // The caller is viewing in Dutch, the recipient received the email in French
139
+ const structure = await I18n.runWithLocale(
140
+ new I18n(Language.Dutch, Country.Belgium),
141
+ async () => await EmailPreviewService.getStructureForUser(email, user, [member.id]),
142
+ );
143
+
144
+ expect(structure.recipients).toHaveLength(1);
145
+ const recipient = structure.recipients[0];
146
+
147
+ expect(recipient.language).toBe(Language.French);
148
+ expect(recipient.replacements.find(r => r.token === 'greeting')?.value).toBe(frenchGreeting);
149
+ expect(structure.getSubjectFor(recipient)).toBe('Sujet français');
150
+ expect(structure.getHtmlFor(recipient)).toBe(`<p>${frenchGreeting} Contenu français</p>`);
151
+ });
152
+
153
+ test('merges the recipients of different members when their replacements are equal', async () => {
154
+ const secondMember = await new MemberFactory({ organization, user }).create();
155
+
156
+ const email = await createSentEmail({
157
+ subject: 'Equal content',
158
+ html: '<p>Hello {{memberFirstName}}</p>',
159
+ });
160
+
161
+ for (const memberId of [member.id, secondMember.id]) {
162
+ await createRecipient(email, {
163
+ memberId,
164
+ userId: user.id,
165
+ email: user.email,
166
+ replacements: [
167
+ Replacement.create({ token: 'memberFirstName', value: 'Same name' }),
168
+ ],
169
+ });
170
+ }
171
+
172
+ const structure = await EmailPreviewService.getStructureForUser(email, user, [member.id, secondMember.id]);
173
+
174
+ expect(structure.recipients).toHaveLength(1);
175
+ expect(structure.recipients[0].replacements.find(r => r.token === 'memberFirstName')?.value).toBe('Same name');
176
+ });
177
+
178
+ test('keeps the recipients of different members separate when their replacements differ', async () => {
179
+ const secondMember = await new MemberFactory({ organization, user }).create();
180
+
181
+ const email = await createSentEmail({
182
+ subject: 'Different content',
183
+ html: '<p>Hello {{memberFirstName}}</p>',
184
+ });
185
+
186
+ await createRecipient(email, {
187
+ memberId: member.id,
188
+ userId: user.id,
189
+ email: user.email,
190
+ replacements: [Replacement.create({ token: 'memberFirstName', value: 'Alice' })],
191
+ });
192
+
193
+ await createRecipient(email, {
194
+ memberId: secondMember.id,
195
+ userId: user.id,
196
+ email: user.email,
197
+ replacements: [Replacement.create({ token: 'memberFirstName', value: 'Bob' })],
198
+ });
199
+
200
+ const structure = await EmailPreviewService.getStructureForUser(email, user, [member.id, secondMember.id]);
201
+
202
+ expect(structure.recipients).toHaveLength(2);
203
+ const names = structure.recipients.map(r => r.replacements.find(rr => rr.token === 'memberFirstName')?.value);
204
+ expect(names).toIncludeSameMembers(['Alice', 'Bob']);
205
+ });
206
+ });
207
+
208
+ describe('getPreviewStructure', () => {
209
+ test('returns an example recipient for every language of a translated email', async () => {
210
+ TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
211
+
212
+ const email = await createSentEmail({
213
+ subject: 'Nederlands onderwerp',
214
+ html: '<p>{{greeting}} Nederlandse inhoud</p>',
215
+ language: Language.Dutch,
216
+ translations: new Map([
217
+ [Language.French, EmailContent.create({
218
+ subject: 'Sujet français',
219
+ html: '<p>{{greeting}} Contenu français</p>',
220
+ text: 'Texte français',
221
+ })],
222
+ ]),
223
+ });
224
+
225
+ const preview = await EmailPreviewService.getPreviewStructure(email, { allLanguages: true });
226
+
227
+ expect(preview.exampleRecipient).not.toBeNull();
228
+ expect([...preview.exampleRecipients.keys()]).toIncludeSameMembers([Language.Dutch, Language.French]);
229
+ expect(preview.exampleRecipients.get(Language.French)!.language).toBe(Language.French);
230
+ expect(preview.exampleRecipients.get(Language.Dutch)!.language).toBe(Language.Dutch);
231
+
232
+ // The replacements are regenerated per language, so the greeting differs
233
+ const dutchGreeting = preview.exampleRecipients.get(Language.Dutch)!.replacements.find(r => r.token === 'greeting')?.value;
234
+ const frenchGreeting = preview.exampleRecipients.get(Language.French)!.replacements.find(r => r.token === 'greeting')?.value;
235
+ expect(typeof dutchGreeting).toBe('string');
236
+ expect(typeof frenchGreeting).toBe('string');
237
+ expect(frenchGreeting).not.toBe(dutchGreeting);
238
+ });
239
+
240
+ test('returns no per-language example recipients for an email with a single language', async () => {
241
+ TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
242
+
243
+ const email = await createSentEmail({
244
+ subject: 'Nederlands onderwerp',
245
+ html: '<p>{{greeting}} Nederlandse inhoud</p>',
246
+ language: Language.Dutch,
247
+ });
248
+
249
+ const preview = await EmailPreviewService.getPreviewStructure(email, { allLanguages: true });
250
+
251
+ expect(preview.exampleRecipient).not.toBeNull();
252
+ expect(preview.exampleRecipients.size).toBe(0);
253
+ });
254
+
255
+ test('the allLanguages option is not read: the example recipients only depend on the languages of the email', async () => {
256
+ TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
257
+
258
+ const email = await createSentEmail({
259
+ subject: 'Nederlands onderwerp',
260
+ html: '<p>{{greeting}} Nederlandse inhoud</p>',
261
+ language: Language.Dutch,
262
+ translations: new Map([
263
+ [Language.French, EmailContent.create({
264
+ subject: 'Sujet français',
265
+ html: '<p>{{greeting}} Contenu français</p>',
266
+ text: 'Texte français',
267
+ })],
268
+ ]),
269
+ });
270
+
271
+ const withOption = await EmailPreviewService.getPreviewStructure(email, { allLanguages: true });
272
+ const withoutOption = await EmailPreviewService.getPreviewStructure(email);
273
+
274
+ expect([...withOption.exampleRecipients.keys()]).toIncludeSameMembers([Language.Dutch, Language.French]);
275
+ expect([...withoutOption.exampleRecipients.keys()]).toIncludeSameMembers([Language.Dutch, Language.French]);
276
+ });
277
+
278
+ test('uses a stored recipient as the example recipient when the email has one', async () => {
279
+ const email = await createSentEmail({
280
+ subject: 'With a recipient',
281
+ html: '<p>Hello {{memberFirstName}}</p>',
282
+ });
283
+
284
+ await createRecipient(email, {
285
+ memberId: member.id,
286
+ userId: user.id,
287
+ email: user.email,
288
+ firstName: 'Stored',
289
+ lastName: 'Recipient',
290
+ replacements: [Replacement.create({ token: 'memberFirstName', value: 'Stored' })],
291
+ });
292
+
293
+ const preview = await EmailPreviewService.getPreviewStructure(email, { allLanguages: true });
294
+
295
+ expect(preview.exampleRecipient).not.toBeNull();
296
+ expect(preview.exampleRecipient!.firstName).toBe('Stored');
297
+ expect(preview.exampleRecipient!.replacements.find(r => r.token === 'memberFirstName')?.value).toBe('Stored');
298
+ });
299
+ });
300
+ });
@@ -0,0 +1,239 @@
1
+ import { I18n } from '@stamhoofd/backend-i18n/I18n';
2
+ import type { Email } from '@stamhoofd/models';
3
+ import { EmailRecipient, Organization, User } from '@stamhoofd/models';
4
+ import { fillRecipientReplacements, mergeReplacementsIfEqual, removeUnusedReplacements, runWithRecipientLocale, stripRecipientReplacementsForWebDisplay, stripSensitiveRecipientReplacements } from '@stamhoofd/models/helpers/EmailBuilder.js';
5
+ import type { BaseOrganization, EmailRecipient as EmailRecipientStruct, Replacement, User as UserStruct } from '@stamhoofd/structures';
6
+ import { EmailPreview, EmailRecipientFilter, EmailWithRecipients, getExampleRecipient } from '@stamhoofd/structures';
7
+ import { ExampleReplacements } from '@stamhoofd/structures/email/exampleReplacements.js';
8
+ import type { Language } from '@stamhoofd/types/Language';
9
+
10
+ /**
11
+ * Builds the read-only structures of an email: the preview an administrator sees while composing or
12
+ * browsing emails, and the version of a sent email a user sees in the member portal.
13
+ */
14
+ export class EmailPreviewService {
15
+ /**
16
+ * @param options.allLanguages Also fill exampleRecipients: the same recipient with its
17
+ * replacements generated in each supported language. Only use this for detail endpoints,
18
+ * it repeats the replacement queries for every language.
19
+ */
20
+ static async getPreviewStructure(email: Email, options: { allLanguages?: boolean } = {}) {
21
+ const emailRecipient = await EmailRecipient.select()
22
+ .where('emailId', email.id)
23
+ .where('email', '!=', null)
24
+ .first(false);
25
+
26
+ let baseRow: EmailRecipientStruct | undefined;
27
+
28
+ if (emailRecipient) {
29
+ baseRow = await emailRecipient.getStructure();
30
+ }
31
+
32
+ if (!baseRow) {
33
+ baseRow = getExampleRecipient();
34
+ }
35
+
36
+ const organization = email.organizationId ? (await Organization.getByID(email.organizationId))! : null;
37
+ const allowedLanguages = email.getLanguages();
38
+
39
+ const fillRow = async (row: EmailRecipientStruct) => {
40
+ const virtualRecipient = row.getRecipient();
41
+
42
+ await fillRecipientReplacements(virtualRecipient, {
43
+ organization,
44
+ from: email.getFromAddress(),
45
+ replyTo: null,
46
+ forPreview: true,
47
+ forceRefresh: !email.sentAt,
48
+ allowedLanguages,
49
+ });
50
+ row.replacements = virtualRecipient.replacements;
51
+ return row;
52
+ };
53
+
54
+ const recipientRow = await fillRow(baseRow.clone());
55
+
56
+ // The same recipient in every supported language: the replacements are regenerated in
57
+ // each language (the recipient's own language is ignored), so the composer can show
58
+ // example values in the language that is being edited
59
+ const exampleRecipients = new Map<Language, EmailRecipientStruct>();
60
+ if (allowedLanguages && allowedLanguages.length > 1) {
61
+ // The same country the recipient locale will resolve to (see getRecipientI18n)
62
+ const country = organization?.address?.country ?? $getCountry();
63
+ for (const language of allowedLanguages) {
64
+ const i18n = new I18n(language, country);
65
+ await I18n.runWithLocale(i18n, async () => {
66
+ const clone = baseRow.clone();
67
+ clone.language = language;
68
+
69
+ if (baseRow.language !== language) {
70
+ // Todo: this can be improved
71
+ // Replace all replacements with defaults
72
+ for (const replacement of clone.replacements) {
73
+ const example: Replacement | undefined = ExampleReplacements.all[replacement.token];
74
+ if (example) {
75
+ replacement.html = example.html;
76
+ replacement.value = example.value;
77
+ }
78
+ }
79
+
80
+ clone.replacements = clone.replacements.filter(r => !['organizationName'].includes(r.token));
81
+ }
82
+
83
+ const rr = await fillRow(clone);
84
+ exampleRecipients.set(language, rr);
85
+ });
86
+ }
87
+ }
88
+
89
+ let user: UserStruct | null = null;
90
+ if (email.userId) {
91
+ const u = await User.getByID(email.userId);
92
+ if (u) {
93
+ user = u.getStructure();
94
+ }
95
+ }
96
+
97
+ let organizationStruct: BaseOrganization | null = null;
98
+ if (organization) {
99
+ organizationStruct = organization.getBaseStructure();
100
+ }
101
+
102
+ return EmailPreview.create({
103
+ ...email,
104
+ user,
105
+ organization: organizationStruct,
106
+ exampleRecipient: recipientRow,
107
+ exampleRecipients,
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Whether the email has content in this language (strict: the default content only counts
113
+ * for its own language, see getEmailContentForLanguage)
114
+ */
115
+ private static hasContentForLanguage(email: Email, language: Language): boolean {
116
+ return language === email.language || email.translations.has(language);
117
+ }
118
+
119
+ /**
120
+ * @param options.language The language the caller is viewing in (from the request headers).
121
+ * Recipients are returned in this language when the email has content for it, otherwise in
122
+ * the language they received the email in.
123
+ */
124
+ static async getStructureForUser(email: Email, user: User, memberIds: string[], options: { language?: Language | null } = {}) {
125
+ const emailRecipients = await EmailRecipient.select()
126
+ .where('emailId', email.id)
127
+ .where('memberId', memberIds)
128
+ .fetch();
129
+ const organization = email.organizationId ? (await Organization.getByID(email.organizationId))! : null;
130
+
131
+ const recipientsMap: Map<string, EmailRecipient> = new Map();
132
+ for (const memberId of memberIds) {
133
+ const preferred = emailRecipients.find(e => e.memberId === memberId && (e.userId === user.id || e.email === user.email));
134
+ if (preferred) {
135
+ recipientsMap.set(preferred.duplicateOfRecipientId ?? preferred.id, preferred);
136
+ continue;
137
+ }
138
+
139
+ const byMember = emailRecipients.find(e => e.memberId === memberId && e.userId === null && e.email === null);
140
+ if (byMember) {
141
+ recipientsMap.set(byMember.duplicateOfRecipientId ?? byMember.id, byMember);
142
+ continue;
143
+ }
144
+ const anyData = emailRecipients.find(e => e.memberId === memberId);
145
+ if (anyData) {
146
+ recipientsMap.set(anyData.duplicateOfRecipientId ?? anyData.id, anyData);
147
+ continue;
148
+ }
149
+ }
150
+
151
+ // Remove duplicates that are marked as the same recipient
152
+ const cleanedRecipients: EmailRecipient[] = [...recipientsMap.values()];
153
+ const structures = await EmailRecipient.getStructures(cleanedRecipients);
154
+
155
+ for (const struct of structures) {
156
+ if (!(struct.userId === user.id || struct.email === user.email) && !((struct.userId === null && struct.email === null))) {
157
+ stripSensitiveRecipientReplacements(struct, {
158
+ organization,
159
+ willFill: true,
160
+ });
161
+ }
162
+
163
+ struct.firstName = user.firstName;
164
+ struct.lastName = user.lastName;
165
+ struct.email = user.email;
166
+ struct.userId = user.id;
167
+
168
+ // Show the email in the language the caller is viewing in, but only when the email
169
+ // has content for it: otherwise keep the language the recipient received it in
170
+ // (better correct content in the wrong language than wrong content in the right language)
171
+ if (options.language && this.hasContentForLanguage(email, options.language)) {
172
+ struct.language = options.language;
173
+ }
174
+
175
+ // We always refresh the data when we display it on the web (so everything is up to date)
176
+ // The replacements are regenerated in struct.language, so they match the displayed content
177
+ await fillRecipientReplacements(struct, {
178
+ organization,
179
+ from: email.getFromAddress(),
180
+ replyTo: null,
181
+ forPreview: false,
182
+ forceRefresh: true,
183
+ allowedLanguages: email.getLanguages(),
184
+ });
185
+ runWithRecipientLocale(struct, organization, () => {
186
+ stripRecipientReplacementsForWebDisplay(struct, {
187
+ organization,
188
+ });
189
+ });
190
+ if (email.html) {
191
+ struct.replacements = removeUnusedReplacements(email.getCombinedHtml(), struct.replacements);
192
+ }
193
+ }
194
+
195
+ // Loop structures and remove if they have exactly the same content
196
+ // We do this here, because it is possible the user didn't receive any emails, so
197
+ // the merging at time of sending the emails didn't happen (uniqueness happened on email)
198
+ const uniqueStructures: EmailRecipientStruct[] = [];
199
+ for (const struct of structures) {
200
+ let found = false;
201
+ for (const unique of uniqueStructures) {
202
+ const merged = mergeReplacementsIfEqual(unique.replacements, struct.replacements);
203
+ if (merged !== false) {
204
+ unique.replacements = merged;
205
+ found = true;
206
+ break;
207
+ }
208
+ }
209
+
210
+ if (!found) {
211
+ uniqueStructures.push(struct);
212
+ }
213
+ }
214
+
215
+ let organizationStruct: BaseOrganization | null = null;
216
+ if (organization) {
217
+ organizationStruct = organization.getBaseStructure();
218
+ }
219
+
220
+ return EmailWithRecipients.create({
221
+ ...email,
222
+ organization: organizationStruct,
223
+ recipients: uniqueStructures,
224
+
225
+ // Remove private-like data
226
+ softBouncesCount: 0,
227
+ failedCount: 0,
228
+ emailErrors: null,
229
+ recipientsErrors: null,
230
+ succeededCount: 1,
231
+ emailRecipientsCount: 1,
232
+ hardBouncesCount: 0,
233
+ spamComplaintsCount: 0,
234
+ recipientFilter: EmailRecipientFilter.create({}),
235
+ membersCount: 1,
236
+ otherRecipientsCount: 0,
237
+ });
238
+ }
239
+ }
@@ -0,0 +1,85 @@
1
+ import { S3Client } from '@aws-sdk/client-s3';
2
+ import { File } from '@stamhoofd/structures';
3
+ import { TestUtils } from '@stamhoofd/test-utils';
4
+
5
+ import { FileSignService } from './FileSignService.js';
6
+
7
+ describe('FileSignService', () => {
8
+ let originalClient: S3Client;
9
+
10
+ beforeEach(() => {
11
+ TestUtils.setEnvironment('SPACES_BUCKET', 'test-bucket');
12
+ TestUtils.setEnvironment('SPACES_ENDPOINT', 'test.digitaloceanspaces.com');
13
+
14
+ originalClient = FileSignService.s3;
15
+ FileSignService.s3 = new S3Client({
16
+ forcePathStyle: false,
17
+ endpoint: 'https://test.digitaloceanspaces.com',
18
+ credentials: {
19
+ accessKeyId: 'test-key',
20
+ secretAccessKey: 'test-secret',
21
+ },
22
+ region: 'eu-west-1',
23
+ });
24
+ });
25
+
26
+ afterEach(() => {
27
+ FileSignService.s3 = originalClient;
28
+ });
29
+
30
+ const buildFile = (data: { path: string; contentType?: string | null }) => {
31
+ return new File({
32
+ id: '1c9ab9e6-1234-4c5e-9f1a-000000000000',
33
+ server: 'https://test-bucket.test.digitaloceanspaces.com',
34
+ path: data.path,
35
+ size: 100,
36
+ isPrivate: true,
37
+ contentType: data.contentType ?? null,
38
+ });
39
+ };
40
+
41
+ const getSignedQuery = async (file: File) => {
42
+ const signed = await FileSignService.withSignedUrl(file);
43
+ expect(signed?.signedUrl).toBeDefined();
44
+ return new URL(signed!.signedUrl!).searchParams;
45
+ };
46
+
47
+ test('A signed url never renders a file type a browser could execute', async () => {
48
+ const query = await getSignedQuery(buildFile({ path: 'users/1/abc/report.docx', contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }));
49
+
50
+ expect(query.get('response-content-disposition')).toBe('attachment');
51
+
52
+ // The response headers are part of what is signed, so they can't be stripped from the url
53
+ expect(query.get('X-Amz-SignedHeaders')).toBeDefined();
54
+ expect(query.get('X-Amz-Signature')).toBeTruthy();
55
+ });
56
+
57
+ test('A signed url renders file types a browser cannot execute', async () => {
58
+ const pdf = await getSignedQuery(buildFile({ path: 'users/1/abc/invoice.pdf', contentType: 'application/pdf' }));
59
+ expect(pdf.get('response-content-disposition')).toBe('inline');
60
+
61
+ const image = await getSignedQuery(buildFile({ path: 'users/1/abc/photo.jpg', contentType: 'image/jpeg' }));
62
+ expect(image.get('response-content-disposition')).toBe('inline');
63
+ });
64
+
65
+ test('It downloads files that were uploaded before we validated content types', async () => {
66
+ // These were stored with a content type chosen by the uploader, and with an extension derived from it
67
+ const svg = await getSignedQuery(buildFile({ path: 'users/1/abc/evil.svg', contentType: 'image/svg+xml' }));
68
+ expect(svg.get('response-content-disposition')).toBe('attachment');
69
+
70
+ const html = await getSignedQuery(buildFile({ path: 'users/1/abc/evil', contentType: 'text/html' }));
71
+ expect(html.get('response-content-disposition')).toBe('attachment');
72
+
73
+ // Both the extension and the content type have to be safe
74
+ const disguised = await getSignedQuery(buildFile({ path: 'users/1/abc/evil.pdf', contentType: 'text/html' }));
75
+ expect(disguised.get('response-content-disposition')).toBe('attachment');
76
+ });
77
+
78
+ test('It uses the path when a file has no content type, like the images we generate', async () => {
79
+ const image = await getSignedQuery(buildFile({ path: 'users/1/abc/def.png', contentType: null }));
80
+ expect(image.get('response-content-disposition')).toBe('inline');
81
+
82
+ const unknown = await getSignedQuery(buildFile({ path: 'users/1/abc/def', contentType: null }));
83
+ expect(unknown.get('response-content-disposition')).toBe('attachment');
84
+ });
85
+ });
@@ -4,7 +4,7 @@ import {
4
4
  } from '@aws-sdk/s3-request-presigner';
5
5
  import type { DecodedRequest, Request, Response } from '@simonbackx/simple-endpoints';
6
6
  import { SimpleError } from '@simonbackx/simple-errors';
7
- import { File } from '@stamhoofd/structures';
7
+ import { File, supportedFileTypes } from '@stamhoofd/structures';
8
8
  import chalk from 'chalk';
9
9
  import * as jose from 'jose';
10
10
 
@@ -103,6 +103,27 @@ export class FileSignService {
103
103
  };
104
104
  }
105
105
 
106
+ /**
107
+ * Files uploaded before we validated content types can have any content type, so we decide here - and not
108
+ * from the stored object - whether a browser may render a file. Note that this only protects private
109
+ * files: public files are served straight from the file server, without a signed url.
110
+ */
111
+ static getContentDisposition(file: File): 'inline' | 'attachment' {
112
+ // The path is generated by us and always ends with the extension the file was stored with, so it says
113
+ // more about how the file server will serve this file than the content type does. Files we generate
114
+ // ourselves (like the images of the Image model) don't even have a content type.
115
+ if (!supportedFileTypes.resolveUpload({ filename: file.path })?.canRenderInline) {
116
+ return 'attachment';
117
+ }
118
+
119
+ // When a file also carries a content type, both have to be safe
120
+ if (file.contentType && !supportedFileTypes.resolveUpload({ contentType: file.contentType })?.canRenderInline) {
121
+ return 'attachment';
122
+ }
123
+
124
+ return 'inline';
125
+ }
126
+
106
127
  static async withSignedUrl(file: File, duration = 60 * 60) {
107
128
  if (file.signedUrl) {
108
129
  console.error('Warning: file already signed');
@@ -112,6 +133,7 @@ export class FileSignService {
112
133
  const command = new GetObjectCommand({
113
134
  Bucket: STAMHOOFD.SPACES_BUCKET,
114
135
  Key: file.path,
136
+ ResponseContentDisposition: this.getContentDisposition(file),
115
137
  });
116
138
  const url = await getSignedUrl(this.s3, command, { expiresIn: duration });
117
139