@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,206 @@
1
+ import type { PutObjectCommand } from '@aws-sdk/client-s3';
2
+ import { Request } from '@simonbackx/simple-endpoints';
3
+ import { Image, OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
4
+ import { PermissionLevel, Permissions } from '@stamhoofd/structures';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+ import { promises as fs } from 'fs';
7
+ import type { IncomingMessage } from 'node:http';
8
+ import { Readable } from 'node:stream';
9
+
10
+ import { testServer } from '../../../../tests/helpers/TestServer.js';
11
+ import { UploadFile } from './UploadFile.js';
12
+
13
+ describe('Endpoint.UploadFile', () => {
14
+ const endpoint = new UploadFile();
15
+
16
+ /**
17
+ * All the objects that were sent to the (mocked) file server
18
+ */
19
+ let uploads: PutObjectCommand['input'][] = [];
20
+
21
+ /**
22
+ * All the paths the endpoint tried to clean up
23
+ */
24
+ let temporaryFiles: string[] = [];
25
+
26
+ beforeEach(() => {
27
+ TestUtils.setEnvironment('SPACES_BUCKET', 'test-bucket');
28
+ TestUtils.setEnvironment('SPACES_ENDPOINT', 'test.digitaloceanspaces.com');
29
+ TestUtils.setEnvironment('SPACES_KEY', 'test-key');
30
+ TestUtils.setEnvironment('SPACES_SECRET', 'test-secret');
31
+
32
+ uploads = [];
33
+ Image.s3Client = {
34
+ send: (command: PutObjectCommand) => {
35
+ uploads.push(command.input);
36
+ return Promise.resolve({});
37
+ },
38
+ } as any;
39
+
40
+ temporaryFiles = [];
41
+ const rm = fs.rm;
42
+ vi.spyOn(fs, 'rm').mockImplementation(async (path, options) => {
43
+ temporaryFiles.push(path as string);
44
+ return await rm(path, options);
45
+ });
46
+ });
47
+
48
+ afterEach(() => {
49
+ Image.s3Client = null;
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ /**
54
+ * Builds a multipart/form-data request with a single uploaded file, like a browser would send it
55
+ */
56
+ const buildUploadRequest = async (data: { filename: string; contentType: string; content?: string; isPrivate?: boolean }) => {
57
+ const organization = await new OrganizationFactory({}).create();
58
+ const user = await new UserFactory({
59
+ organization,
60
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
61
+ }).create();
62
+ const token = await Token.createToken(user);
63
+
64
+ const boundary = '--------------------------StamhoofdTest';
65
+ const body = Buffer.concat([
66
+ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${data.filename}"\r\nContent-Type: ${data.contentType}\r\n\r\n`),
67
+ Buffer.from(data.content ?? 'Hello world'),
68
+ Buffer.from(`\r\n--${boundary}--\r\n`),
69
+ ]);
70
+
71
+ const stream = Readable.from([body]) as unknown as IncomingMessage;
72
+ stream.headers = {
73
+ 'content-type': `multipart/form-data; boundary=${boundary}`,
74
+ 'content-length': body.length.toString(),
75
+ };
76
+
77
+ const r = Request.buildJson('POST', '/v1/upload-file', organization.getApiHost());
78
+ r.headers.authorization = 'Bearer ' + token.accessToken;
79
+ r.request = stream;
80
+ r.query = data.isPrivate ? { private: true } : {};
81
+
82
+ return r;
83
+ };
84
+
85
+ test('It stores a pdf with the reported content type, and lets the browser render it', async () => {
86
+ const r = await buildUploadRequest({ filename: 'Mijn document.pdf', contentType: 'application/pdf' });
87
+ const response = await testServer.test(endpoint, r);
88
+
89
+ expect(response.body.contentType).toBe('application/pdf');
90
+ expect(response.body.name).toBe('Mijn document.pdf');
91
+ expect(response.body.path).toMatch(/\/mijn-document\.pdf$/);
92
+
93
+ expect(uploads).toHaveLength(1);
94
+ expect(uploads[0]).toMatchObject({
95
+ Key: response.body.path,
96
+ ContentType: 'application/pdf',
97
+ ContentDisposition: 'inline',
98
+ ACL: 'public-read',
99
+ });
100
+ });
101
+
102
+ test('It uses the extension when the uploader does not report a usable content type', async () => {
103
+ const r = await buildUploadRequest({ filename: 'ledenlijst.xlsx', contentType: 'application/octet-stream' });
104
+ const response = await testServer.test(endpoint, r);
105
+
106
+ expect(response.body.contentType).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
107
+ expect(uploads[0]).toMatchObject({
108
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
109
+ // A browser can execute an office document, so it is never rendered
110
+ ContentDisposition: 'attachment',
111
+ });
112
+ });
113
+
114
+ test('It ignores a content type we do not support, and uses the extension', async () => {
115
+ const r = await buildUploadRequest({ filename: 'document.pdf', contentType: 'text/html', content: '<script>alert(1)</script>' });
116
+ const response = await testServer.test(endpoint, r);
117
+
118
+ // Whatever is inside the file, a browser only ever gets it as a pdf
119
+ expect(response.body.contentType).toBe('application/pdf');
120
+ expect(uploads[0]).toMatchObject({
121
+ ContentType: 'application/pdf',
122
+ });
123
+ });
124
+
125
+ test('It renames a file that was uploaded with the extension of another content type', async () => {
126
+ const r = await buildUploadRequest({ filename: 'foto.png', contentType: 'image/jpeg' });
127
+ const response = await testServer.test(endpoint, r);
128
+
129
+ expect(response.body.contentType).toBe('image/jpeg');
130
+ expect(response.body.name).toBe('foto.jpg');
131
+ expect(response.body.path).toMatch(/\/foto\.jpg$/);
132
+ expect(uploads[0]).toMatchObject({
133
+ ContentType: 'image/jpeg',
134
+ });
135
+ });
136
+
137
+ test('It stores the file with the extension of the resolved content type', async () => {
138
+ const r = await buildUploadRequest({ filename: 'Foto.JPEG', contentType: 'image/jpg' });
139
+ const response = await testServer.test(endpoint, r);
140
+
141
+ expect(response.body.contentType).toBe('image/jpeg');
142
+ expect(response.body.name).toBe('Foto.jpeg');
143
+ expect(response.body.path).toMatch(/\/foto\.jpeg$/);
144
+ expect(uploads[0]).toMatchObject({
145
+ ContentType: 'image/jpeg',
146
+ ContentDisposition: 'inline',
147
+ });
148
+ });
149
+
150
+ test('It stores a file without a usable name', async () => {
151
+ const r = await buildUploadRequest({ filename: '.pdf', contentType: 'application/pdf' });
152
+ const response = await testServer.test(endpoint, r);
153
+
154
+ expect(response.body.name).toBeNull();
155
+
156
+ // Falls back to the id of the file, instead of storing it without a name
157
+ expect(response.body.path).toMatch(new RegExp('/' + response.body.id + '/' + response.body.id + '\\.pdf$'));
158
+ });
159
+
160
+ test('It refuses a file with a content type browsers could execute', async () => {
161
+ const r = await buildUploadRequest({ filename: 'evil.html', contentType: 'text/html', content: '<script>alert(1)</script>' });
162
+
163
+ await expect(testServer.test(endpoint, r)).rejects.toThrow(/unsupported file type/i);
164
+ expect(uploads).toHaveLength(0);
165
+ });
166
+
167
+ test('It refuses an svg image', async () => {
168
+ const r = await buildUploadRequest({ filename: 'evil.svg', contentType: 'image/svg+xml', content: '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>' });
169
+
170
+ await expect(testServer.test(endpoint, r)).rejects.toThrow(/unsupported file type/i);
171
+ expect(uploads).toHaveLength(0);
172
+ });
173
+
174
+ test('It refuses a file we cannot determine a safe content type for', async () => {
175
+ const r = await buildUploadRequest({ filename: 'backup', contentType: 'application/octet-stream' });
176
+
177
+ await expect(testServer.test(endpoint, r)).rejects.toThrow(/unsupported file type/i);
178
+ expect(uploads).toHaveLength(0);
179
+ });
180
+
181
+ test('It stores private files as an attachment too', async () => {
182
+ const r = await buildUploadRequest({ filename: 'contract.pdf', contentType: 'application/pdf', isPrivate: true });
183
+ const response = await testServer.test(endpoint, r);
184
+
185
+ expect(response.body.isPrivate).toBe(true);
186
+ expect(response.body.signature).not.toBeNull();
187
+ expect(uploads[0]).toMatchObject({
188
+ ContentType: 'application/pdf',
189
+ ACL: 'private',
190
+ });
191
+ });
192
+
193
+ test('It cleans up the temporary file of the upload', async () => {
194
+ const rejected = await buildUploadRequest({ filename: 'evil.html', contentType: 'text/html' });
195
+ await expect(testServer.test(endpoint, rejected)).rejects.toThrow(/unsupported file type/i);
196
+
197
+ const accepted = await buildUploadRequest({ filename: 'document.pdf', contentType: 'application/pdf' });
198
+ await testServer.test(endpoint, accepted);
199
+
200
+ expect(temporaryFiles).toHaveLength(2);
201
+
202
+ for (const path of temporaryFiles) {
203
+ await expect(fs.stat(path)).rejects.toThrow(/ENOENT/);
204
+ }
205
+ });
206
+ });
@@ -108,6 +108,15 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
108
108
  });
109
109
  });
110
110
 
111
+ try {
112
+ return await this.upload(request, file, user);
113
+ } finally {
114
+ // Formidable wrote the upload to a temporary file
115
+ await fs.rm(file.filepath, { force: true }).catch(() => { /* we can't do anything about this */ });
116
+ }
117
+ }
118
+
119
+ private async upload(request: DecodedRequest<Params, Query, Body>, file: FormidableFile, user: { id: string }) {
111
120
  if (!STAMHOOFD.SPACES_BUCKET || !STAMHOOFD.SPACES_ENDPOINT || !STAMHOOFD.SPACES_KEY || !STAMHOOFD.SPACES_SECRET) {
112
121
  throw new SimpleError({
113
122
  code: 'not_available',
@@ -116,6 +125,20 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
116
125
  });
117
126
  }
118
127
 
128
+ // Never trust the content type of the uploader: it is served back to browsers when the file is
129
+ // downloaded, so we only allow content types we know are safe to serve.
130
+ const uploadType = File.resolveUploadType({ contentType: file.mimetype, filename: file.originalFilename });
131
+
132
+ if (!uploadType) {
133
+ throw new SimpleError({
134
+ code: 'invalid_file_type',
135
+ message: 'Unsupported file type ' + (file.mimetype ?? 'unknown') + ' for file ' + (file.originalFilename ?? 'unknown'),
136
+ human: $t('Dit type bestand kan je niet uploaden.'),
137
+ field: 'file',
138
+ statusCode: 400,
139
+ });
140
+ }
141
+
119
142
  const fileContent = await fs.readFile(file.filepath);
120
143
 
121
144
  let prefix = (STAMHOOFD.SPACES_PREFIX ?? '');
@@ -140,19 +163,19 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
140
163
 
141
164
  // Also include the source, in private mode
142
165
  const fileId = uuidv4();
143
- const uploadExt = File.contentTypeToExtension(file.mimetype ?? '') ?? '';
144
166
 
145
- const filenameWithoutExt = file.originalFilename ? File.removeExtension(file.originalFilename) : fileId;
146
- const key = prefix + fileId + '/' + (Formatter.slug(filenameWithoutExt) + (uploadExt ? ('.' + uploadExt) : ''));
167
+ const filenameWithoutExt = file.originalFilename ? File.removeExtension(file.originalFilename) : '';
168
+ const key = prefix + fileId + '/' + ((Formatter.slug(filenameWithoutExt) || fileId) + '.' + uploadType.extension);
147
169
 
148
170
  const fileStruct = new File({
149
171
  id: fileId,
150
172
  server: 'https://' + STAMHOOFD.SPACES_BUCKET + '.' + STAMHOOFD.SPACES_ENDPOINT,
151
173
  path: key,
174
+ // Always keep the extension in sync with the content type we store the file with
175
+ name: filenameWithoutExt ? filenameWithoutExt + '.' + uploadType.extension : null,
152
176
  size: fileContent.length,
153
- name: file.originalFilename,
154
177
  isPrivate: request.query.isPrivate,
155
- contentType: file.mimetype ?? null,
178
+ contentType: uploadType.contentType,
156
179
  });
157
180
 
158
181
  // Generate an upload signature for this file if it is private
@@ -171,7 +194,9 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
171
194
  Bucket: STAMHOOFD.SPACES_BUCKET,
172
195
  Key: key,
173
196
  Body: fileContent,
174
- ContentType: file.mimetype ?? 'application/octet-stream',
197
+ ContentType: uploadType.contentType,
198
+ // Only let the browser render file types it can't execute
199
+ ContentDisposition: uploadType.canRenderInline ? 'inline' : 'attachment',
175
200
  ACL: request.query.isPrivate ? 'private' : 'public-read',
176
201
  });
177
202
  await Image.getS3Client().send(cmd);
@@ -0,0 +1,177 @@
1
+ import type { PutObjectCommand } from '@aws-sdk/client-s3';
2
+ import { Request } from '@simonbackx/simple-endpoints';
3
+ import { Image, OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
4
+ import { PermissionLevel, Permissions } from '@stamhoofd/structures';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+ import type { IncomingMessage } from 'node:http';
7
+ import { Readable } from 'node:stream';
8
+
9
+ import { testServer } from '../../../../tests/helpers/TestServer.js';
10
+ import { UploadImage } from './UploadImage.js';
11
+
12
+ /**
13
+ * A real (1x1 pixel) png, so sharp can generate resolutions from it
14
+ */
15
+ const pngContent = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
16
+
17
+ const svgContent = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><script>alert(1)</script><rect width="10" height="10" fill="red"/></svg>');
18
+
19
+ describe('Endpoint.UploadImage', () => {
20
+ const endpoint = new UploadImage();
21
+
22
+ /**
23
+ * All the objects that were sent to the (mocked) file server
24
+ */
25
+ let uploads: PutObjectCommand['input'][] = [];
26
+
27
+ beforeEach(() => {
28
+ TestUtils.setEnvironment('SPACES_BUCKET', 'test-bucket');
29
+ TestUtils.setEnvironment('SPACES_ENDPOINT', 'test.digitaloceanspaces.com');
30
+ TestUtils.setEnvironment('SPACES_KEY', 'test-key');
31
+ TestUtils.setEnvironment('SPACES_SECRET', 'test-secret');
32
+
33
+ uploads = [];
34
+ Image.s3Client = {
35
+ send: (command: PutObjectCommand) => {
36
+ uploads.push(command.input);
37
+ return Promise.resolve({});
38
+ },
39
+ } as any;
40
+ });
41
+
42
+ afterEach(() => {
43
+ Image.s3Client = null;
44
+ });
45
+
46
+ /**
47
+ * Builds a multipart/form-data request with a single uploaded image, like a browser would send it
48
+ */
49
+ const buildUploadRequest = async (data: { filename: string; contentType: string; content: Buffer; resolutions?: object[] }) => {
50
+ const organization = await new OrganizationFactory({}).create();
51
+ const user = await new UserFactory({
52
+ organization,
53
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
54
+ }).create();
55
+ const token = await Token.createToken(user);
56
+
57
+ const boundary = '--------------------------StamhoofdTest';
58
+ const body = Buffer.concat([
59
+ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="resolutions"\r\n\r\n${JSON.stringify(data.resolutions ?? [{ width: 50, height: 50, fit: 'inside' }])}\r\n`),
60
+ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${data.filename}"\r\nContent-Type: ${data.contentType}\r\n\r\n`),
61
+ data.content,
62
+ Buffer.from(`\r\n--${boundary}--\r\n`),
63
+ ]);
64
+
65
+ const stream = Readable.from([body]) as unknown as IncomingMessage;
66
+ stream.headers = {
67
+ 'content-type': `multipart/form-data; boundary=${boundary}`,
68
+ 'content-length': body.length.toString(),
69
+ };
70
+
71
+ const r = Request.buildJson('POST', '/v1/upload-image', organization.getApiHost());
72
+ r.headers.authorization = 'Bearer ' + token.accessToken;
73
+ r.request = stream;
74
+ r.query = {};
75
+
76
+ return r;
77
+ };
78
+
79
+ test('It stores a png, and never stores the original with a content type of the uploader', async () => {
80
+ const r = await buildUploadRequest({ filename: 'logo.png', contentType: 'image/png', content: pngContent });
81
+ const response = await testServer.test(endpoint, r);
82
+
83
+ expect(response.body.resolutions).toHaveLength(1);
84
+
85
+ // The generated image is public and rendered inline in the browser
86
+ expect(uploads[0]).toMatchObject({
87
+ ContentType: 'image/png',
88
+ ACL: 'public-read',
89
+ });
90
+
91
+ // Forcing a download here would break every image in our emails and pdfs
92
+ expect(uploads[0].ContentDisposition).toBeUndefined();
93
+
94
+ // The original is never rendered
95
+ expect(uploads[1]).toMatchObject({
96
+ ContentType: 'image/png',
97
+ ContentDisposition: 'attachment',
98
+ ACL: 'private',
99
+ });
100
+ });
101
+
102
+ /**
103
+ * An svg can contain scripts, so a browser may never render one: it is always stored privately and as an
104
+ * attachment, whatever else the upload results in.
105
+ */
106
+ const expectNoRenderableSvg = () => {
107
+ const svgUploads = uploads.filter(upload => upload.ContentType === 'image/svg+xml' || upload.Key?.endsWith('.svg'));
108
+
109
+ for (const upload of svgUploads) {
110
+ expect(upload.ContentDisposition).toBe('attachment');
111
+ expect(upload.ACL).toBe('private');
112
+ }
113
+
114
+ return svgUploads;
115
+ };
116
+
117
+ test('It rasterizes an svg, so the uploaded svg itself is never served', async () => {
118
+ const r = await buildUploadRequest({ filename: 'logo.svg', contentType: 'image/svg+xml', content: svgContent });
119
+ const response = await testServer.test(endpoint, r);
120
+
121
+ expect(response.body.resolutions).toHaveLength(1);
122
+ expect(uploads[0]).toMatchObject({
123
+ ContentType: 'image/png',
124
+ ACL: 'public-read',
125
+ });
126
+
127
+ expect(uploads[1]).toMatchObject({
128
+ ContentType: 'image/svg+xml',
129
+ ContentDisposition: 'attachment',
130
+ ACL: 'private',
131
+ });
132
+
133
+ // Every public file we generated from the svg is a rasterized image
134
+ expect(expectNoRenderableSvg()).toHaveLength(1);
135
+ });
136
+
137
+ test('It never publishes the svg itself, even when no resolutions are requested', async () => {
138
+ // Without resolutions there is nothing to rasterize, so the uploaded svg is the only stored file - and
139
+ // Image.getPublicPath() falls back to it
140
+ const r = await buildUploadRequest({ filename: 'logo.svg', contentType: 'image/svg+xml', content: svgContent, resolutions: [] });
141
+ const response = await testServer.test(endpoint, r);
142
+
143
+ expect(response.body.resolutions).toHaveLength(0);
144
+ expect(uploads).toHaveLength(1);
145
+ expect(expectNoRenderableSvg()).toHaveLength(1);
146
+ });
147
+
148
+ test('It refuses a file that is not an image, even without resolutions', async () => {
149
+ const r = await buildUploadRequest({
150
+ filename: 'evil.html',
151
+ contentType: 'text/html',
152
+ content: Buffer.from('<script>alert(1)</script>'),
153
+ resolutions: [],
154
+ });
155
+
156
+ await expect(testServer.test(endpoint, r)).rejects.toThrow(/unsupported image type/i);
157
+ expect(uploads).toHaveLength(0);
158
+ });
159
+
160
+ test('It refuses an image with a content type we cannot generate resolutions from', async () => {
161
+ const r = await buildUploadRequest({ filename: 'photo.tiff', contentType: 'image/tiff', content: pngContent });
162
+
163
+ await expect(testServer.test(endpoint, r)).rejects.toThrow(/unsupported image type/i);
164
+ expect(uploads).toHaveLength(0);
165
+ });
166
+
167
+ test('It uses the extension when the uploader does not report a usable content type', async () => {
168
+ const r = await buildUploadRequest({ filename: 'logo.png', contentType: 'application/octet-stream', content: pngContent });
169
+ const response = await testServer.test(endpoint, r);
170
+
171
+ expect(response.body.resolutions).toHaveLength(1);
172
+ expect(uploads[1]).toMatchObject({
173
+ ContentType: 'image/png',
174
+ ContentDisposition: 'attachment',
175
+ });
176
+ });
177
+ });
@@ -4,7 +4,7 @@ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
4
4
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
6
  import { Image, RateLimiter } from '@stamhoofd/models';
7
- import { Image as ImageStruct, ResolutionRequest } from '@stamhoofd/structures';
7
+ import { Image as ImageStruct, ResolutionRequest, supportedImageTypes } from '@stamhoofd/structures';
8
8
  import formidable from 'formidable';
9
9
  import { promises as fs } from 'fs';
10
10
 
@@ -131,8 +131,27 @@ export class UploadImage extends Endpoint<Params, Query, Body, ResponseBody> {
131
131
  });
132
132
  });
133
133
 
134
- const fileContent = await fs.readFile(file.filepath);
135
- const image = await Image.create(fileContent, file.mimetype ?? undefined, resolutions, request.query.isPrivate, user);
136
- return new Response(ImageStruct.create(image));
134
+ try {
135
+ // Never trust the content type of the uploader: it decides how we store the image, so only accept
136
+ // the image types we know we can generate our resolutions from.
137
+ const imageType = supportedImageTypes.resolveUpload({ contentType: file.mimetype, filename: file.originalFilename });
138
+
139
+ if (!imageType) {
140
+ throw new SimpleError({
141
+ code: 'invalid_file_type',
142
+ message: 'Unsupported image type ' + (file.mimetype ?? 'unknown') + ' for file ' + (file.originalFilename ?? 'unknown'),
143
+ human: $t('Dit soort afbeelding kan je niet opladen.'),
144
+ field: 'file',
145
+ statusCode: 400,
146
+ });
147
+ }
148
+
149
+ const fileContent = await fs.readFile(file.filepath);
150
+ const image = await Image.create(fileContent, imageType.contentType, resolutions, request.query.isPrivate, user);
151
+ return new Response(ImageStruct.create(image));
152
+ } finally {
153
+ // Formidable wrote the upload to a temporary file
154
+ await fs.rm(file.filepath, { force: true }).catch(() => { /* we can't do anything about this */ });
155
+ }
137
156
  }
138
157
  }