@stamhoofd/backend 2.29.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/helpers/AdminPermissionChecker.ts +14 -0
- 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
|
}
|
|
@@ -943,6 +943,10 @@ export class AdminPermissionChecker {
|
|
|
943
943
|
// Notes are not visible for the member.
|
|
944
944
|
data.details.notes = null;
|
|
945
945
|
|
|
946
|
+
if (!(await this.canAccessMember(member, PermissionLevel.Full))) {
|
|
947
|
+
data.details.securityCode = null;
|
|
948
|
+
}
|
|
949
|
+
|
|
946
950
|
return data;
|
|
947
951
|
}
|
|
948
952
|
|
|
@@ -968,6 +972,11 @@ export class AdminPermissionChecker {
|
|
|
968
972
|
}
|
|
969
973
|
}
|
|
970
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
|
+
|
|
971
980
|
return cloned;
|
|
972
981
|
}
|
|
973
982
|
|
|
@@ -983,6 +992,11 @@ export class AdminPermissionChecker {
|
|
|
983
992
|
})
|
|
984
993
|
}
|
|
985
994
|
|
|
995
|
+
if (data.details.securityCode !== undefined) {
|
|
996
|
+
// Unset silently
|
|
997
|
+
data.details.securityCode = undefined
|
|
998
|
+
}
|
|
999
|
+
|
|
986
1000
|
const hasRecordAnswers = !!data.details.recordAnswers;
|
|
987
1001
|
const hasNotes = data.details.notes !== undefined;
|
|
988
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
|