@stamhoofd/backend 2.28.0 → 2.30.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.
- package/package.json +10 -10
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +103 -28
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +1 -1
- package/src/helpers/AdminPermissionChecker.ts +20 -1
- package/src/helpers/MemberUserSyncer.ts +26 -4
- /package/src/seeds/{1722344161-sync-member-users.ts → 1722344162-sync-member-users.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.30.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -36,14 +36,14 @@
|
|
|
36
36
|
"@simonbackx/simple-encoding": "2.15.1",
|
|
37
37
|
"@simonbackx/simple-endpoints": "1.14.0",
|
|
38
38
|
"@simonbackx/simple-logging": "^1.0.1",
|
|
39
|
-
"@stamhoofd/backend-i18n": "2.
|
|
40
|
-
"@stamhoofd/backend-middleware": "2.
|
|
41
|
-
"@stamhoofd/email": "2.
|
|
42
|
-
"@stamhoofd/models": "2.
|
|
43
|
-
"@stamhoofd/queues": "2.
|
|
44
|
-
"@stamhoofd/sql": "2.
|
|
45
|
-
"@stamhoofd/structures": "2.
|
|
46
|
-
"@stamhoofd/utility": "2.
|
|
39
|
+
"@stamhoofd/backend-i18n": "2.30.0",
|
|
40
|
+
"@stamhoofd/backend-middleware": "2.30.0",
|
|
41
|
+
"@stamhoofd/email": "2.30.0",
|
|
42
|
+
"@stamhoofd/models": "2.30.0",
|
|
43
|
+
"@stamhoofd/queues": "2.30.0",
|
|
44
|
+
"@stamhoofd/sql": "2.30.0",
|
|
45
|
+
"@stamhoofd/structures": "2.30.0",
|
|
46
|
+
"@stamhoofd/utility": "2.30.0",
|
|
47
47
|
"archiver": "^7.0.1",
|
|
48
48
|
"aws-sdk": "^2.885.0",
|
|
49
49
|
"axios": "1.6.8",
|
|
@@ -60,5 +60,5 @@
|
|
|
60
60
|
"postmark": "4.0.2",
|
|
61
61
|
"stripe": "^16.6.0"
|
|
62
62
|
},
|
|
63
|
-
"gitHead": "
|
|
63
|
+
"gitHead": "969b619ea8a6d82ce94d3ce8faf5694a3a2e4bb3"
|
|
64
64
|
}
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encoding';
|
|
2
2
|
import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
|
|
3
3
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
4
|
-
import { Document, Member } from '@stamhoofd/models';
|
|
4
|
+
import { Document, Member, mergeTwoMembers, RateLimiter } from '@stamhoofd/models';
|
|
5
5
|
import { MemberWithRegistrationsBlob, MembersBlob } from "@stamhoofd/structures";
|
|
6
6
|
|
|
7
7
|
import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
|
|
8
8
|
import { Context } from '../../../helpers/Context';
|
|
9
9
|
import { MemberUserSyncer } from '../../../helpers/MemberUserSyncer';
|
|
10
10
|
import { PatchOrganizationMembersEndpoint } from '../../global/members/PatchOrganizationMembersEndpoint';
|
|
11
|
+
import { Email } from '@stamhoofd/email';
|
|
11
12
|
type Params = Record<string, never>;
|
|
12
13
|
type Query = undefined;
|
|
13
14
|
type Body = PatchableArrayAutoEncoder<MemberWithRegistrationsBlob>
|
|
14
15
|
type ResponseBody = MembersBlob
|
|
15
16
|
|
|
17
|
+
export const securityCodeLimiter = new RateLimiter({
|
|
18
|
+
limits: [
|
|
19
|
+
{
|
|
20
|
+
// Max 10 a day
|
|
21
|
+
limit: 10,
|
|
22
|
+
duration: 24 * 60 * 1000 * 60
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* Allow to add, patch and delete multiple members simultaneously, which is needed in order to sync relational data that is saved encrypted in multiple members (e.g. parents)
|
|
18
30
|
*/
|
|
@@ -48,43 +60,19 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
|
|
|
48
60
|
struct.details.cleanData()
|
|
49
61
|
member.details = struct.details
|
|
50
62
|
|
|
51
|
-
|
|
52
|
-
const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member);
|
|
63
|
+
const duplicate = await this.checkDuplicate(member, struct.details.securityCode)
|
|
53
64
|
if (duplicate) {
|
|
54
|
-
// Merge data
|
|
55
|
-
duplicate.details.merge(member.details)
|
|
56
|
-
await duplicate.save()
|
|
57
65
|
addedMembers.push(duplicate)
|
|
58
|
-
continue
|
|
66
|
+
continue
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
await member.save()
|
|
62
70
|
addedMembers.push(member)
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
if (addedMembers.length > 0) {
|
|
66
|
-
// Give access to created members
|
|
67
|
-
await Member.users.reverse("members").link(user, addedMembers)
|
|
68
|
-
|
|
69
|
-
}
|
|
70
73
|
|
|
71
74
|
// Modify members
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
for (const member of addedMembers) {
|
|
75
|
-
const updatedMember = members.find(m => m.id === member.id);
|
|
76
|
-
if (updatedMember) {
|
|
77
|
-
// Make sure we also give access to other parents
|
|
78
|
-
await MemberUserSyncer.onChangeMember(updatedMember)
|
|
79
|
-
|
|
80
|
-
if (!updatedMember.users.find(u => u.id === user.id)) {
|
|
81
|
-
// Also link the user to the member if the email address is missing in the details
|
|
82
|
-
await MemberUserSyncer.linkUser(user.email, updatedMember, true)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
await Document.updateForMember(updatedMember.id)
|
|
86
|
-
}
|
|
87
|
-
}
|
|
75
|
+
let members = await Member.getMembersWithRegistrationForUser(user)
|
|
88
76
|
|
|
89
77
|
for (let struct of request.body.getPatches()) {
|
|
90
78
|
const member = members.find((m) => m.id == struct.id)
|
|
@@ -95,6 +83,7 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
|
|
|
95
83
|
human: "Je probeert een lid aan te passen die niet (meer) bestaat. Er ging ergens iets mis."
|
|
96
84
|
})
|
|
97
85
|
}
|
|
86
|
+
const securityCode = struct.details?.securityCode // will get cleared after the filter
|
|
98
87
|
struct = await Context.auth.filterMemberPatch(member, struct)
|
|
99
88
|
|
|
100
89
|
if (struct.details) {
|
|
@@ -118,6 +107,17 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
|
|
|
118
107
|
field: "details"
|
|
119
108
|
})
|
|
120
109
|
}
|
|
110
|
+
|
|
111
|
+
const duplicate = await this.checkDuplicate(member, securityCode)
|
|
112
|
+
if (duplicate) {
|
|
113
|
+
// Remove the member from the list
|
|
114
|
+
members.splice(members.findIndex(m => m.id === member.id), 1)
|
|
115
|
+
|
|
116
|
+
// Add new
|
|
117
|
+
addedMembers.push(duplicate)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
121
|
await member.save();
|
|
122
122
|
await MemberUserSyncer.onChangeMember(member)
|
|
123
123
|
|
|
@@ -125,8 +125,83 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
|
|
|
125
125
|
await Document.updateForMember(member.id)
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// Modify members
|
|
129
|
+
if (addedMembers.length > 0) {
|
|
130
|
+
// Give access to created members
|
|
131
|
+
await Member.users.reverse("members").link(user, addedMembers)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
members = await Member.getMembersWithRegistrationForUser(user)
|
|
135
|
+
|
|
136
|
+
for (const member of addedMembers) {
|
|
137
|
+
const updatedMember = members.find(m => m.id === member.id);
|
|
138
|
+
if (updatedMember) {
|
|
139
|
+
// Make sure we also give access to other parents
|
|
140
|
+
await MemberUserSyncer.onChangeMember(updatedMember)
|
|
141
|
+
|
|
142
|
+
if (!updatedMember.users.find(u => u.id === user.id)) {
|
|
143
|
+
// Also link the user to the member if the email address is missing in the details
|
|
144
|
+
await MemberUserSyncer.linkUser(user.email, updatedMember, true)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await Document.updateForMember(updatedMember.id)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
128
152
|
return new Response(
|
|
129
153
|
await AuthenticatedStructures.membersBlob(members)
|
|
130
154
|
);
|
|
131
155
|
}
|
|
156
|
+
|
|
157
|
+
async checkDuplicate(member: Member, securityCode: string|null|undefined) {
|
|
158
|
+
// Check for duplicates and prevent creating a duplicate member by a user
|
|
159
|
+
const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member);
|
|
160
|
+
if (duplicate) {
|
|
161
|
+
if (await duplicate.isSafeToMergeDuplicateWithoutSecurityCode()) {
|
|
162
|
+
console.log("Merging duplicate without security code: allowed for " + duplicate.id)
|
|
163
|
+
} else if (securityCode) {
|
|
164
|
+
try {
|
|
165
|
+
securityCodeLimiter.track(member.details.name, 1);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
Email.sendWebmaster({
|
|
168
|
+
subject: "[Limiet] Limiet bereikt voor aantal beveiligingscodes",
|
|
169
|
+
text: "Beste, \nDe limiet werd bereikt voor het aantal beveiligingscodes per dag. \nNaam lid: "+member.details.name+" (ID: "+duplicate.id+")" + "\n\n" + e.message + "\n\nStamhoofd"
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
throw new SimpleError({
|
|
173
|
+
code: "too_many_tries",
|
|
174
|
+
message: "Too many securityCodes limited",
|
|
175
|
+
human: "Oeps! Om spam te voorkomen limiteren we het aantal beveiligingscodes die je kan proberen. Probeer morgen opnieuw.",
|
|
176
|
+
field: "details.securityCode"
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Entered the security code, so we can link the user to the member
|
|
181
|
+
if (!duplicate.details.securityCode || securityCode !== duplicate.details.securityCode) {
|
|
182
|
+
throw new SimpleError({
|
|
183
|
+
code: "invalid_field",
|
|
184
|
+
field: 'details.securityCode',
|
|
185
|
+
message: "Invalid security code",
|
|
186
|
+
human: Context.i18n.$t(`Deze beveiligingscode is ongeldig. Probeer het opnieuw of neem contact op met jouw vereniging om de juiste code te ontvangen.`),
|
|
187
|
+
statusCode: 400
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log("Merging duplicate: security code is correct - for " + duplicate.id)
|
|
192
|
+
} else {
|
|
193
|
+
throw new SimpleError({
|
|
194
|
+
code: "known_member_missing_rights",
|
|
195
|
+
message: "Creating known member without sufficient access rights",
|
|
196
|
+
human: `${member.details.firstName} is al gekend in ons systeem, maar jouw e-mailadres niet. Om toegang te krijgen heb je de beveiligingscode nodig.`,
|
|
197
|
+
statusCode: 400
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Merge data
|
|
202
|
+
// NOTE: We use mergeTwoMembers instead of mergeMultipleMembers, because we should never safe 'member' , because that one does not exist in the database
|
|
203
|
+
await mergeTwoMembers(duplicate, member)
|
|
204
|
+
return duplicate
|
|
205
|
+
}
|
|
206
|
+
}
|
|
132
207
|
}
|
|
@@ -121,7 +121,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
121
121
|
throw new SimpleError({
|
|
122
122
|
code: "forbidden",
|
|
123
123
|
message: "No permission to register this member",
|
|
124
|
-
human: 'Je hebt niet de juiste toegangsrechten om dit lid in te schrijven.',
|
|
124
|
+
human: 'Je hebt niet de juiste toegangsrechten om dit lid in te schrijven. Je kan enkel leden inschrijven als je minstens bewerkrechten hebt voor dat lid.',
|
|
125
125
|
statusCode: 403
|
|
126
126
|
});
|
|
127
127
|
}
|
|
@@ -271,9 +271,14 @@ export class AdminPermissionChecker {
|
|
|
271
271
|
return true;
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
if (registration.deactivatedAt || !registration.registeredAt) {
|
|
275
|
+
// No full access: cannot access deactivated registrations
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
274
279
|
const allGroups = await this.getOrganizationGroups(registration.organizationId)
|
|
275
280
|
const group = allGroups.find(g => g.id === registration.groupId)
|
|
276
|
-
if (!group) {
|
|
281
|
+
if (!group || group.deletedAt) {
|
|
277
282
|
return false;
|
|
278
283
|
}
|
|
279
284
|
|
|
@@ -938,6 +943,10 @@ export class AdminPermissionChecker {
|
|
|
938
943
|
// Notes are not visible for the member.
|
|
939
944
|
data.details.notes = null;
|
|
940
945
|
|
|
946
|
+
if (!(await this.canAccessMember(member, PermissionLevel.Full))) {
|
|
947
|
+
data.details.securityCode = null;
|
|
948
|
+
}
|
|
949
|
+
|
|
941
950
|
return data;
|
|
942
951
|
}
|
|
943
952
|
|
|
@@ -963,6 +972,11 @@ export class AdminPermissionChecker {
|
|
|
963
972
|
}
|
|
964
973
|
}
|
|
965
974
|
|
|
975
|
+
// At least write permissions is required for now to obtain the security code
|
|
976
|
+
if (!(await this.canAccessMember(member, PermissionLevel.Write))) {
|
|
977
|
+
cloned.details.securityCode = null
|
|
978
|
+
}
|
|
979
|
+
|
|
966
980
|
return cloned;
|
|
967
981
|
}
|
|
968
982
|
|
|
@@ -978,6 +992,11 @@ export class AdminPermissionChecker {
|
|
|
978
992
|
})
|
|
979
993
|
}
|
|
980
994
|
|
|
995
|
+
if (data.details.securityCode !== undefined) {
|
|
996
|
+
// Unset silently
|
|
997
|
+
data.details.securityCode = undefined
|
|
998
|
+
}
|
|
999
|
+
|
|
981
1000
|
const hasRecordAnswers = !!data.details.recordAnswers;
|
|
982
1001
|
const hasNotes = data.details.notes !== undefined;
|
|
983
1002
|
const isSetFinancialSupportTrue = data.details.shouldApplyReducedPrice;
|
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { Member, MemberResponsibilityRecord, MemberWithRegistrations, User } from "@stamhoofd/models";
|
|
2
2
|
import { SQL } from "@stamhoofd/sql";
|
|
3
3
|
import { MemberDetails, Permissions, UserPermissions } from "@stamhoofd/structures";
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
import basex from "base-x";
|
|
6
|
+
|
|
7
|
+
const ALPHABET = '123456789ABCDEFGHJKMNPQRSTUVWXYZ' // Note: we removed 0, O, I and l to make it easier for humans
|
|
8
|
+
const customBase = basex(ALPHABET)
|
|
9
|
+
|
|
10
|
+
async function randomBytes(size: number): Promise<Buffer> {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
crypto.randomBytes(size, (err: Error | null, buf: Buffer) => {
|
|
13
|
+
if (err) {
|
|
14
|
+
reject(err);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
resolve(buf);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
4
21
|
|
|
5
22
|
export class MemberUserSyncerStatic {
|
|
6
23
|
/**
|
|
@@ -9,12 +26,8 @@ export class MemberUserSyncerStatic {
|
|
|
9
26
|
* - email addresses have changed
|
|
10
27
|
*/
|
|
11
28
|
async onChangeMember(member: MemberWithRegistrations, unlinkUsers: boolean = false) {
|
|
12
|
-
console.log('onchange member', member.id, 'unlink', unlinkUsers)
|
|
13
|
-
|
|
14
29
|
const {userEmails, parentAndUnverifiedEmails} = this.getMemberAccessEmails(member.details)
|
|
15
30
|
|
|
16
|
-
console.log('emails', userEmails, parentAndUnverifiedEmails)
|
|
17
|
-
|
|
18
31
|
// Make sure all these users have access to the member
|
|
19
32
|
for (const email of userEmails) {
|
|
20
33
|
// Link users that are found with these email addresses.
|
|
@@ -56,6 +69,15 @@ export class MemberUserSyncerStatic {
|
|
|
56
69
|
}
|
|
57
70
|
}
|
|
58
71
|
}
|
|
72
|
+
|
|
73
|
+
if (member.details.securityCode === null) {
|
|
74
|
+
console.log("Generating security code for member "+member.id)
|
|
75
|
+
|
|
76
|
+
const length = 16;
|
|
77
|
+
const code = customBase.encode(await randomBytes(100)).toUpperCase().substring(0, length);
|
|
78
|
+
member.details.securityCode = code
|
|
79
|
+
await member.save()
|
|
80
|
+
}
|
|
59
81
|
}
|
|
60
82
|
|
|
61
83
|
getMemberAccessEmails(details: MemberDetails) {
|
|
File without changes
|