@stamhoofd/backend 2.15.0 → 2.17.0

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 (31) hide show
  1. package/.env.template.json +2 -1
  2. package/index.ts +15 -1
  3. package/package.json +14 -12
  4. package/src/email-recipient-loaders/members.ts +61 -0
  5. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +1 -1
  6. package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +5 -183
  7. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +163 -0
  8. package/src/endpoints/global/files/GetFileCache.ts +69 -0
  9. package/src/endpoints/global/files/UploadFile.ts +4 -1
  10. package/src/endpoints/global/files/UploadImage.ts +14 -2
  11. package/src/endpoints/global/members/GetMembersEndpoint.ts +12 -299
  12. package/src/endpoints/organization/dashboard/email/EmailEndpoint.ts +22 -2
  13. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +6 -134
  14. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.ts +5 -3
  15. package/src/endpoints/organization/dashboard/webshops/GetWebshopTicketsEndpoint.ts +5 -3
  16. package/src/excel-loaders/members.ts +101 -0
  17. package/src/excel-loaders/payments.ts +539 -0
  18. package/src/helpers/AdminPermissionChecker.ts +0 -3
  19. package/src/helpers/AuthenticatedStructures.ts +2 -0
  20. package/src/helpers/FileCache.ts +158 -0
  21. package/src/helpers/fetchToAsyncIterator.ts +34 -0
  22. package/src/sql-filters/balance-item-payments.ts +13 -0
  23. package/src/sql-filters/members.ts +179 -0
  24. package/src/sql-filters/organizations.ts +115 -0
  25. package/src/sql-filters/payments.ts +78 -0
  26. package/src/sql-filters/registrations.ts +24 -0
  27. package/src/sql-sorters/members.ts +46 -0
  28. package/src/sql-sorters/organizations.ts +71 -0
  29. package/src/sql-sorters/payments.ts +50 -0
  30. package/tsconfig.json +3 -4
  31. package/src/endpoints/organization/dashboard/payments/legacy/GetPaymentsEndpoint.ts +0 -170
@@ -61,5 +61,6 @@
61
61
  "NOLT_SSO_SECRET_KEY": "",
62
62
  "INTERNAL_SECRET_KEY": "",
63
63
  "CRONS_DISABLED": false,
64
- "WHITELISTED_EMAIL_DESTINATIONS": ["*"]
64
+ "WHITELISTED_EMAIL_DESTINATIONS": ["*"],
65
+ "CACHE_PATH": "<fill in a safe path exlusive for Stamhoofd to store cached files>"
65
66
  }
package/index.ts CHANGED
@@ -9,8 +9,8 @@ import { Version } from '@stamhoofd/structures';
9
9
  import { sleep } from "@stamhoofd/utility";
10
10
 
11
11
  import { areCronsRunning, crons, stopCronScheduling } from './src/crons';
12
- import { ContextMiddleware } from "./src/middleware/ContextMiddleware";
13
12
  import { resumeEmails } from "./src/helpers/EmailResumer";
13
+ import { ContextMiddleware } from "./src/middleware/ContextMiddleware";
14
14
 
15
15
  process.on("unhandledRejection", (error: Error) => {
16
16
  console.error("unhandledRejection");
@@ -80,6 +80,13 @@ const start = async () => {
80
80
  // Add CORS headers
81
81
  routerServer.addResponseMiddleware(CORSMiddleware)
82
82
 
83
+ // Register Excel loaders
84
+ await import('./src/excel-loaders/members');
85
+ await import('./src/excel-loaders/payments');
86
+
87
+ // Register Email Recipient loaders
88
+ await import('./src/email-recipient-loaders/members');
89
+
83
90
  routerServer.listen(STAMHOOFD.PORT ?? 9090);
84
91
 
85
92
  resumeEmails().catch(console.error);
@@ -106,6 +113,13 @@ const start = async () => {
106
113
  stopCronScheduling();
107
114
  clearInterval(cronInterval)
108
115
 
116
+ if (STAMHOOFD.environment === 'development') {
117
+ setTimeout(() => {
118
+ console.error("Forcing exit after 5 seconds")
119
+ process.exit(1);
120
+ }, 5000);
121
+ }
122
+
109
123
  try {
110
124
  await routerServer.close()
111
125
  console.log("HTTP server stopped");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.15.0",
3
+ "version": "2.17.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -32,17 +32,19 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@mollie/api-client": "3.7.0",
35
- "@simonbackx/simple-database": "1.24.0",
36
- "@simonbackx/simple-endpoints": "1.13.0",
35
+ "@simonbackx/simple-database": "1.25.0",
36
+ "@simonbackx/simple-encoding": "2.15.0",
37
+ "@simonbackx/simple-endpoints": "1.14.0",
37
38
  "@simonbackx/simple-logging": "^1.0.1",
38
- "@stamhoofd/backend-i18n": "^2.13.0",
39
- "@stamhoofd/backend-middleware": "^2.13.0",
40
- "@stamhoofd/email": "^2.13.0",
41
- "@stamhoofd/models": "^2.13.0",
42
- "@stamhoofd/queues": "^2.13.0",
43
- "@stamhoofd/sql": "^2.13.0",
44
- "@stamhoofd/structures": "^2.15.0",
45
- "@stamhoofd/utility": "^2.13.0",
39
+ "@stamhoofd/backend-i18n": "^2.17.0",
40
+ "@stamhoofd/backend-middleware": "^2.17.0",
41
+ "@stamhoofd/email": "^2.17.0",
42
+ "@stamhoofd/models": "^2.17.0",
43
+ "@stamhoofd/queues": "^2.17.0",
44
+ "@stamhoofd/sql": "^2.17.0",
45
+ "@stamhoofd/structures": "^2.17.0",
46
+ "@stamhoofd/utility": "^2.17.0",
47
+ "archiver": "^7.0.1",
46
48
  "aws-sdk": "^2.885.0",
47
49
  "axios": "1.6.8",
48
50
  "cookie": "^0.5.0",
@@ -58,5 +60,5 @@
58
60
  "postmark": "4.0.2",
59
61
  "stripe": "^16.6.0"
60
62
  },
61
- "gitHead": "e5b597cd9e149990b2eefb31d61480f4c0333b35"
63
+ "gitHead": "5f940486646acd12e23e96a750bc12ca2be4df44"
62
64
  }
@@ -0,0 +1,61 @@
1
+ import { Email } from "@stamhoofd/models";
2
+ import { SQL } from "@stamhoofd/sql";
3
+ import { EmailRecipientFilterType, LimitedFilteredRequest, PaginatedResponse, mergeFilters } from "@stamhoofd/structures";
4
+ import { GetMembersEndpoint } from "../endpoints/global/members/GetMembersEndpoint";
5
+
6
+ Email.recipientLoaders.set(EmailRecipientFilterType.Members, {
7
+ fetch: async (query: LimitedFilteredRequest) => {
8
+ const result = await GetMembersEndpoint.buildData(query)
9
+
10
+ return new PaginatedResponse({
11
+ results: result.results.members.flatMap(m => m.getEmailRecipients(['member'])),
12
+ next: result.next
13
+ });
14
+ },
15
+
16
+ count: async (query: LimitedFilteredRequest) => {
17
+ query.filter = mergeFilters([query.filter, {
18
+ 'email': {
19
+ $neq: null
20
+ }
21
+ }])
22
+ const q = await GetMembersEndpoint.buildQuery(query)
23
+ return await q.count();
24
+ }
25
+ });
26
+
27
+ Email.recipientLoaders.set(EmailRecipientFilterType.MemberParents, {
28
+ fetch: async (query: LimitedFilteredRequest) => {
29
+ const result = await GetMembersEndpoint.buildData(query)
30
+
31
+ return new PaginatedResponse({
32
+ results: result.results.members.flatMap(m => m.getEmailRecipients(['parents'])),
33
+ next: result.next
34
+ });
35
+ },
36
+
37
+ count: async (query: LimitedFilteredRequest) => {
38
+ const q = await GetMembersEndpoint.buildQuery(query)
39
+ return await q.sum(
40
+ SQL.jsonLength(SQL.column('details'), '$.value.parents[*].email')
41
+ );
42
+ }
43
+ });
44
+
45
+ Email.recipientLoaders.set(EmailRecipientFilterType.MemberUnverified, {
46
+ fetch: async (query: LimitedFilteredRequest) => {
47
+ const result = await GetMembersEndpoint.buildData(query)
48
+
49
+ return new PaginatedResponse({
50
+ results: result.results.members.flatMap(m => m.getEmailRecipients(['unverified'])),
51
+ next: result.next
52
+ });
53
+ },
54
+
55
+ count: async (query: LimitedFilteredRequest) => {
56
+ const q = await GetMembersEndpoint.buildQuery(query)
57
+ return await q.sum(
58
+ SQL.jsonLength(SQL.column('details'), '$.value.unverifiedEmails')
59
+ );
60
+ }
61
+ });
@@ -1,5 +1,5 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
- import { SQL, SQLAlias, SQLSum, SQLCount, SQLDistinct, SQLSelectAs } from '@stamhoofd/sql';
2
+ import { SQL, SQLAlias, SQLCount, SQLDistinct, SQLSelectAs, SQLSum } from '@stamhoofd/sql';
3
3
  import { ChargeMembershipsSummary, ChargeMembershipsTypeSummary } from '@stamhoofd/structures';
4
4
  import { Context } from '../../../helpers/Context';
5
5
 
@@ -3,199 +3,21 @@ import { Decoder } from '@simonbackx/simple-encoding';
3
3
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
4
4
  import { SimpleError } from '@simonbackx/simple-errors';
5
5
  import { Organization } from '@stamhoofd/models';
6
- import { SQL, SQLConcat, SQLFilterDefinitions, SQLNow, SQLNull, SQLOrderBy, SQLOrderByDirection, SQLScalar, SQLSortDefinitions, SQLWhereEqual, SQLWhereOr, SQLWhereSign, baseSQLFilterCompilers, compileToSQLFilter, compileToSQLSorter, createSQLColumnFilterCompiler, createSQLExpressionFilterCompiler, createSQLRelationFilterCompiler } from "@stamhoofd/sql";
6
+ import { SQL, compileToSQLFilter, compileToSQLSorter } from "@stamhoofd/sql";
7
7
  import { CountFilteredRequest, LimitedFilteredRequest, Organization as OrganizationStruct, PaginatedResponse, PermissionLevel, StamhoofdFilter, getSortFilter } from '@stamhoofd/structures';
8
8
 
9
9
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
10
10
  import { Context } from '../../../helpers/Context';
11
+ import { organizationFilterCompilers } from '../../../sql-filters/organizations';
12
+ import { organizationSorters } from '../../../sql-sorters/organizations';
11
13
 
12
14
  type Params = Record<string, never>;
13
15
  type Query = LimitedFilteredRequest;
14
16
  type Body = undefined;
15
17
  type ResponseBody = PaginatedResponse<OrganizationStruct[], LimitedFilteredRequest>
16
18
 
17
- export const filterCompilers: SQLFilterDefinitions = {
18
- ...baseSQLFilterCompilers,
19
- id: createSQLExpressionFilterCompiler(
20
- SQL.column('organizations', 'id')
21
- ),
22
- uri: createSQLExpressionFilterCompiler(
23
- SQL.column('organizations', 'uri')
24
- ),
25
- name: createSQLExpressionFilterCompiler(
26
- SQL.column('organizations', 'name')
27
- ),
28
- active: createSQLExpressionFilterCompiler(
29
- SQL.column('organizations', 'active')
30
- ),
31
- city: createSQLExpressionFilterCompiler(
32
- SQL.jsonValue(SQL.column('organizations', 'address'), '$.value.city'),
33
- {isJSONValue: true}
34
- ),
35
- country: createSQLExpressionFilterCompiler(
36
- SQL.jsonValue(SQL.column('organizations', 'address'), '$.value.country'),
37
- {isJSONValue: true}
38
- ),
39
- umbrellaOrganization: createSQLExpressionFilterCompiler(
40
- SQL.jsonValue(SQL.column('organizations', 'meta'), '$.value.umbrellaOrganization'),
41
- {isJSONValue: true}
42
- ),
43
- type: createSQLExpressionFilterCompiler(
44
- SQL.jsonValue(SQL.column('organizations', 'meta'), '$.value.type'),
45
- {isJSONValue: true}
46
- ),
47
- tags: createSQLExpressionFilterCompiler(
48
- SQL.jsonValue(SQL.column('organizations', 'meta'), '$.value.tags'),
49
- {isJSONValue: true, isJSONObject: true}
50
- ),
51
- packages: createSQLRelationFilterCompiler(
52
- SQL.select().from(
53
- SQL.table('stamhoofd_packages')
54
- ).where(
55
- SQL.column('organizationId'),
56
- SQL.column('organizations', 'id'),
57
- )
58
- .andWhere(
59
- SQL.column('validAt'),
60
- SQLWhereSign.NotEqual,
61
- new SQLNull()
62
- ).andWhere(
63
- new SQLWhereOr([
64
- new SQLWhereEqual(
65
- SQL.column('validUntil'),
66
- SQLWhereSign.Equal,
67
- new SQLNull()
68
- ),
69
- new SQLWhereEqual(
70
- SQL.column('validUntil'),
71
- SQLWhereSign.Greater,
72
- new SQLNow()
73
- )
74
- ])
75
- ).andWhere(
76
- new SQLWhereOr([
77
- new SQLWhereEqual(
78
- SQL.column('removeAt'),
79
- SQLWhereSign.Equal,
80
- new SQLNull()
81
- ),
82
- new SQLWhereEqual(
83
- SQL.column('removeAt'),
84
- SQLWhereSign.Greater,
85
- new SQLNow()
86
- )
87
- ])
88
- ),
89
-
90
- // const pack1 = await STPackage.where({ organizationId, validAt: { sign: "!=", value: null }, removeAt: { sign: ">", value: new Date() }})
91
- // const pack2 = await STPackage.where({ organizationId, validAt: { sign: "!=", value: null }, removeAt: null })
92
- {
93
- ...baseSQLFilterCompilers,
94
- "type": createSQLExpressionFilterCompiler(
95
- SQL.jsonValue(SQL.column('meta'), '$.value.type'),
96
- {isJSONValue: true}
97
- )
98
- }
99
- ),
100
- members: createSQLRelationFilterCompiler(
101
- SQL.select().from(
102
- SQL.table('members')
103
- ).join(
104
- SQL.join(
105
- SQL.table('registrations')
106
- ).where(
107
- SQL.column('members', 'id'),
108
- SQL.column('registrations', 'memberId')
109
- )
110
- ).where(
111
- SQL.column('registrations', 'organizationId'),
112
- SQL.column('organizations', 'id'),
113
- ),
114
-
115
- {
116
- ...baseSQLFilterCompilers,
117
- name: createSQLExpressionFilterCompiler(
118
- new SQLConcat(
119
- SQL.column('firstName'),
120
- new SQLScalar(' '),
121
- SQL.column('lastName'),
122
- )
123
- ),
124
- "firstName": createSQLColumnFilterCompiler('firstName'),
125
- "lastName": createSQLColumnFilterCompiler('lastName'),
126
- "email": createSQLColumnFilterCompiler('email')
127
- }
128
- ),
129
- }
130
-
131
- const sorters: SQLSortDefinitions<Organization> = {
132
- 'id': {
133
- getValue(a) {
134
- return a.id
135
- },
136
- toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
137
- return new SQLOrderBy({
138
- column: SQL.column('id'),
139
- direction
140
- })
141
- }
142
- },
143
- 'name': {
144
- getValue(a) {
145
- return a.name
146
- },
147
- toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
148
- return new SQLOrderBy({
149
- column: SQL.column('name'),
150
- direction
151
- })
152
- }
153
- },
154
- 'uri': {
155
- getValue(a) {
156
- return a.uri
157
- },
158
- toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
159
- return new SQLOrderBy({
160
- column: SQL.column('uri'),
161
- direction
162
- })
163
- }
164
- },
165
- 'type': {
166
- getValue(a) {
167
- return a.meta.type
168
- },
169
- toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
170
- return new SQLOrderBy({
171
- column: SQL.jsonValue(SQL.column('meta'), '$.value.type'),
172
- direction
173
- })
174
- }
175
- },
176
- 'city': {
177
- getValue(a) {
178
- return a.address.city
179
- },
180
- toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
181
- return new SQLOrderBy({
182
- column: SQL.jsonValue(SQL.column('address'), '$.value.city'),
183
- direction
184
- })
185
- }
186
- },
187
- 'country': {
188
- getValue(a) {
189
- return a.address.country
190
- },
191
- toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
192
- return new SQLOrderBy({
193
- column: SQL.jsonValue(SQL.column('address'), '$.value.country'),
194
- direction
195
- })
196
- }
197
- }
198
- }
19
+ const sorters = organizationSorters
20
+ const filterCompilers = organizationFilterCompilers
199
21
 
200
22
  export class GetOrganizationsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
201
23
  queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>
@@ -0,0 +1,163 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-argument */
2
+ import { Decoder, EncodableObject } from '@simonbackx/simple-encoding';
3
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
4
+ import { SimpleError } from '@simonbackx/simple-errors';
5
+ import { Email } from '@stamhoofd/email';
6
+ import { ArchiverWriterAdapter, exportToExcel, XlsxTransformerSheet, XlsxWriter } from '@stamhoofd/excel-writer';
7
+ import { getEmailBuilderForTemplate, Platform, RateLimiter } from '@stamhoofd/models';
8
+ import { EmailTemplateType, ExcelExportRequest, ExcelExportResponse, ExcelExportType, LimitedFilteredRequest, PaginatedResponse, Recipient, Replacement } from '@stamhoofd/structures';
9
+ import { sleep } from "@stamhoofd/utility";
10
+ import { Context } from '../../../helpers/Context';
11
+ import { fetchToAsyncIterator } from '../../../helpers/fetchToAsyncIterator';
12
+ import { FileCache } from '../../../helpers/FileCache';
13
+
14
+ type Params = { type: string };
15
+ type Query = undefined;
16
+ type Body = ExcelExportRequest;
17
+ type ResponseBody = ExcelExportResponse;
18
+
19
+ type ExcelExporter<T extends EncodableObject> = {
20
+ fetch(request: LimitedFilteredRequest): Promise<PaginatedResponse<T[], LimitedFilteredRequest>>
21
+ sheets: XlsxTransformerSheet<T, unknown>[]
22
+ }
23
+
24
+ export const limiter = new RateLimiter({
25
+ limits: [
26
+ {
27
+ // Max 200 per day
28
+ limit: 200,
29
+ duration: 60 * 1000 * 60 * 24
30
+ }
31
+ ]
32
+ });
33
+
34
+ export class ExportToExcelEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
35
+ bodyDecoder = ExcelExportRequest as Decoder<ExcelExportRequest>
36
+
37
+ // Other endpoints can register exports here
38
+ static loaders: Map<ExcelExportType, ExcelExporter<EncodableObject>> = new Map()
39
+
40
+ protected doesMatch(request: Request): [true, Params] | [false] {
41
+ if (request.method != "POST") {
42
+ return [false];
43
+ }
44
+
45
+ const params = Endpoint.parseParameters(request.url, "/export/excel/@type", {type: String});
46
+
47
+ if (params) {
48
+ return [true, params as Params];
49
+ }
50
+ return [false];
51
+ }
52
+
53
+ async handle(request: DecodedRequest<Params, Query, Body>) {
54
+ const organization = await Context.setOptionalOrganizationScope();
55
+ const {user} = await Context.authenticate()
56
+
57
+ if (user.isApiUser) {
58
+ throw new SimpleError({
59
+ code: "not_allowed",
60
+ message: "API users are not allowed to export to Excel. The Excel export endpoint has a side effect of sending e-mails. Please use normal API endpoints to get the data you need.",
61
+ statusCode: 403
62
+ })
63
+ }
64
+
65
+ const loader = ExportToExcelEndpoint.loaders.get(request.params.type as ExcelExportType);
66
+
67
+ if (!loader) {
68
+ throw new SimpleError({
69
+ code: "invalid_type",
70
+ message: "Invalid type " + request.params.type,
71
+ statusCode: 400
72
+ })
73
+ }
74
+
75
+ limiter.track(user.id, 1);
76
+ let sendEmail = false;
77
+
78
+ await Platform.getSharedStruct();
79
+
80
+ const result = await Promise.race([
81
+ this.job(loader, request.body, request.params.type).then(async (url: string) => {
82
+ if (sendEmail) {
83
+ const builder = await getEmailBuilderForTemplate(organization, {
84
+ template: {
85
+ type: EmailTemplateType.ExcelExportSucceeded
86
+ },
87
+ recipients: [
88
+ user.createRecipient(Replacement.create({
89
+ token: 'downloadUrl',
90
+ value: url
91
+ }))
92
+ ],
93
+ from: Email.getInternalEmailFor(Context.i18n)
94
+ })
95
+
96
+ if (builder) {
97
+ Email.schedule(builder)
98
+ }
99
+
100
+ }
101
+
102
+ return url;
103
+ }).catch(async (error) => {
104
+ if (sendEmail) {
105
+ const builder = await getEmailBuilderForTemplate(organization, {
106
+ template: {
107
+ type: EmailTemplateType.ExcelExportFailed
108
+ },
109
+ recipients: [
110
+ user.createRecipient()
111
+ ],
112
+ from: Email.getInternalEmailFor(Context.i18n)
113
+ })
114
+
115
+ if (builder) {
116
+ Email.schedule(builder)
117
+ }
118
+ }
119
+ throw error
120
+ }),
121
+ sleep(3000)
122
+ ])
123
+
124
+ if (typeof result === 'string') {
125
+ return new Response(ExcelExportResponse.create({
126
+ url: result
127
+ }))
128
+ }
129
+
130
+ // We'll send an e-mail
131
+ // Let the job know to send an e-mail when it is done
132
+ sendEmail = true;
133
+
134
+ return new Response(ExcelExportResponse.create({
135
+ url: null
136
+ }))
137
+ }
138
+
139
+ async job(loader: ExcelExporter<EncodableObject>, request: ExcelExportRequest, type: string): Promise<string> {
140
+ // Estimate how long it will take.
141
+ // If too long, we'll schedule it and write it to Digitalocean Spaces
142
+ // Otherwise we'll just return the file directly
143
+ const {file, stream} = await FileCache.getWriteStream('.xlsx');
144
+
145
+ const zipWriterAdapter = new ArchiverWriterAdapter(stream);
146
+ const writer = new XlsxWriter(zipWriterAdapter);
147
+
148
+ // Limit to pages of 100
149
+ request.filter.limit = 100;
150
+
151
+ await exportToExcel({
152
+ definitions: loader.sheets,
153
+ writer,
154
+ dataGenerator: fetchToAsyncIterator(request.filter, loader),
155
+ filter: request.workbookFilter
156
+ })
157
+
158
+ console.log('Done writing excel file')
159
+
160
+ const url = 'https://'+ STAMHOOFD.domains.api + '/file-cache?file=' + encodeURIComponent(file) + '&name=' + encodeURIComponent(type)
161
+ return url;
162
+ }
163
+ }
@@ -0,0 +1,69 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-argument */
2
+ import { AutoEncoder, Decoder, field, StringDecoder } from '@simonbackx/simple-encoding';
3
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
4
+ import { Readable } from 'node:stream';
5
+ import { Context } from '../../../helpers/Context';
6
+ import { FileCache } from '../../../helpers/FileCache';
7
+ import { Formatter } from '@stamhoofd/utility';
8
+ import { RateLimiter } from '@stamhoofd/models';
9
+
10
+ type Params = Record<never, never>;
11
+ class Query extends AutoEncoder {
12
+ @field({ decoder: StringDecoder})
13
+ file: string
14
+
15
+ @field({ decoder: StringDecoder, optional: true })
16
+ name?: string
17
+ }
18
+
19
+ type Body = undefined;
20
+ type ResponseBody = Readable;
21
+
22
+ export const limiter = new RateLimiter({
23
+ limits: [
24
+ {
25
+ // Max 200 per day
26
+ limit: 200,
27
+ duration: 60 * 1000 * 60 * 24
28
+ }
29
+ ]
30
+ });
31
+
32
+ export class GetFileCache extends Endpoint<Params, Query, Body, ResponseBody> {
33
+ queryDecoder = Query as Decoder<Query>
34
+
35
+ protected doesMatch(request: Request): [true, Params] | [false] {
36
+ if (request.method != "GET") {
37
+ return [false];
38
+ }
39
+
40
+ const params = Endpoint.parseParameters(request.url, "/file-cache", {});
41
+
42
+ if (params) {
43
+ return [true, params as Params];
44
+ }
45
+ return [false];
46
+ }
47
+
48
+ async handle(request: DecodedRequest<Params, Query, Body>) {
49
+ await Context.setOptionalOrganizationScope();
50
+
51
+ limiter.track(request.request.getIP(), 1);
52
+
53
+ // Return readable stream
54
+ const {stream, contentLength, extension} = await FileCache.read(request.query.file, 1);
55
+
56
+ const response = new Response(stream);
57
+ response.headers['Content-Type'] = 'application/octet-stream';
58
+
59
+ if (request.query.name) {
60
+ const slug = Formatter.fileSlug(request.query.name) + extension;
61
+ response.headers['Content-Disposition'] = `attachment; filename="${slug}"`;
62
+ } else {
63
+ response.headers['Content-Disposition'] = `attachment; filename="bestand${extension}"`;
64
+ }
65
+ response.headers['Content-Length'] = contentLength.toString();
66
+
67
+ return response;
68
+ }
69
+ }
@@ -9,6 +9,7 @@ import { promises as fs } from "fs";
9
9
  import { v4 as uuidv4 } from "uuid";
10
10
 
11
11
  import { Context } from '../../../helpers/Context';
12
+ import { limiter } from './UploadImage';
12
13
 
13
14
  type Params = Record<string, never>;
14
15
  type Query = {};
@@ -50,7 +51,7 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
50
51
 
51
52
  async handle(request: DecodedRequest<Params, Query, Body>) {
52
53
  await Context.setOptionalOrganizationScope()
53
- await Context.authenticate();
54
+ const {user} = await Context.authenticate();
54
55
 
55
56
  if (!Context.auth.canUpload()) {
56
57
  throw Context.auth.error()
@@ -68,6 +69,8 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
68
69
  throw new Error("Not supported without real request")
69
70
  }
70
71
 
72
+ limiter.track(user.id, 1);
73
+
71
74
  const form = formidable({ maxFileSize: 20 * 1024 * 1024, maxFields: 1, keepExtensions: true });
72
75
  const file = await new Promise<FormidableFile>((resolve, reject) => {
73
76
  if (!request.request.request) {
@@ -2,7 +2,7 @@
2
2
  import { Decoder, ObjectData } from '@simonbackx/simple-encoding';
3
3
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
4
4
  import { SimpleError } from '@simonbackx/simple-errors';
5
- import { Image } from '@stamhoofd/models';
5
+ import { Image, RateLimiter } from '@stamhoofd/models';
6
6
  import { Image as ImageStruct, ResolutionRequest } from '@stamhoofd/structures';
7
7
  import formidable from 'formidable';
8
8
  import { promises as fs } from "fs";
@@ -31,6 +31,16 @@ interface FormidableFile {
31
31
  mimetype: string | null;
32
32
  }
33
33
 
34
+ export const limiter = new RateLimiter({
35
+ limits: [
36
+ {
37
+ // Max 50 per hour
38
+ limit: 50,
39
+ duration: 60 * 1000 * 60
40
+ }
41
+ ]
42
+ });
43
+
34
44
  export class UploadImage extends Endpoint<Params, Query, Body, ResponseBody> {
35
45
  protected doesMatch(request: Request): [true, Params] | [false] {
36
46
  if (request.method != "POST") {
@@ -48,7 +58,7 @@ export class UploadImage extends Endpoint<Params, Query, Body, ResponseBody> {
48
58
 
49
59
  async handle(request: DecodedRequest<Params, Query, Body>) {
50
60
  await Context.setOptionalOrganizationScope()
51
- await Context.authenticate();
61
+ const {user} = await Context.authenticate();
52
62
 
53
63
  if (!Context.auth.canUpload()) {
54
64
  throw Context.auth.error()
@@ -66,6 +76,8 @@ export class UploadImage extends Endpoint<Params, Query, Body, ResponseBody> {
66
76
  throw new Error("Not supported without real request")
67
77
  }
68
78
 
79
+ limiter.track(user.id, 1);
80
+
69
81
  const form = formidable({
70
82
  maxFileSize: 5 * 1024 * 1024,
71
83
  keepExtensions: true,