@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.
- package/package.json +23 -17
- package/src/crons/drip-emails.ts +2 -1
- package/src/crons.ts +4 -2
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/endpoints/auth/CreateAdminEndpoint.ts +3 -2
- package/src/endpoints/auth/CreateTokenEndpoint.ts +2 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +3 -2
- package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
- package/src/endpoints/auth/SignupEndpoint.ts +4 -2
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +9 -5
- package/src/endpoints/global/files/UploadFile.test.ts +206 -0
- package/src/endpoints/global/files/UploadFile.ts +31 -6
- package/src/endpoints/global/files/UploadImage.test.ts +177 -0
- package/src/endpoints/global/files/UploadImage.ts +23 -4
- package/src/endpoints/global/files/upload-security.test.ts +837 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
- package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
- package/src/excel-loaders/balance-items.ts +1 -1
- package/src/excel-loaders/event-notifications.ts +6 -7
- package/src/excel-loaders/members.test.ts +2 -2
- package/src/excel-loaders/members.ts +9 -10
- package/src/excel-loaders/organizations.ts +14 -20
- package/src/excel-loaders/payments.ts +1 -1
- package/src/excel-loaders/platform-memberships.ts +7 -7
- package/src/excel-loaders/platform-sheets.test.ts +113 -0
- package/src/excel-loaders/receivable-balances.ts +1 -1
- package/src/excel-loaders/registrations.ts +9 -9
- package/src/helpers/AdminPermissionChecker.ts +1 -1
- package/src/helpers/AuthenticatedStructures.ts +12 -9
- package/src/helpers/MembershipCharger.ts +3 -2
- package/src/services/BalanceItemService.ts +2 -1
- package/src/services/DocumentRenderService.test.ts +229 -0
- package/src/services/DocumentRenderService.ts +180 -0
- package/src/services/EmailPreviewService.test.ts +300 -0
- package/src/services/EmailPreviewService.ts +239 -0
- package/src/services/FileSignService.test.ts +85 -0
- package/src/services/FileSignService.ts +23 -1
- package/src/services/OrderService.test.ts +308 -0
- package/src/services/OrderService.ts +214 -0
- package/src/services/OrganizationDNSService.test.ts +177 -0
- package/src/services/OrganizationDNSService.ts +282 -0
- package/src/services/OrganizationEmailService.test.ts +57 -0
- package/src/services/OrganizationEmailService.ts +211 -0
- package/src/services/PasswordForgotService.test.ts +99 -0
- package/src/services/PasswordForgotService.ts +38 -0
- package/src/services/PlatformMembershipService.test.ts +180 -0
- package/src/services/PlatformMembershipService.ts +281 -4
- package/src/services/RegistrationService.ts +3 -2
- package/src/services/STPackageService.test.ts +191 -0
- package/src/services/STPackageService.ts +114 -2
- package/src/services/VerificationCodeService.test.ts +71 -0
- package/src/services/VerificationCodeService.ts +100 -0
- package/tests/e2e/documents.test.ts +2 -1
- 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) :
|
|
146
|
-
const key = prefix + fileId + '/' + (Formatter.slug(filenameWithoutExt)
|
|
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:
|
|
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:
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
}
|