@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.28.0",
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.28.0",
40
- "@stamhoofd/backend-middleware": "2.28.0",
41
- "@stamhoofd/email": "2.28.0",
42
- "@stamhoofd/models": "2.28.0",
43
- "@stamhoofd/queues": "2.28.0",
44
- "@stamhoofd/sql": "2.28.0",
45
- "@stamhoofd/structures": "2.28.0",
46
- "@stamhoofd/utility": "2.28.0",
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": "3598a1baed3fc513175f081cd0aa1e6741226fbd"
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
- // Check for duplicates and prevent creating a duplicate member by a user
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
- const members = await Member.getMembersWithRegistrationForUser(user)
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) {