@stamhoofd/backend 1.0.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/.env.template.json +63 -0
- package/.eslintrc.js +61 -0
- package/README.md +40 -0
- package/index.ts +172 -0
- package/jest.config.js +11 -0
- package/migrations.ts +33 -0
- package/package.json +48 -0
- package/src/crons.ts +845 -0
- package/src/endpoints/admin/organizations/GetOrganizationsCountEndpoint.ts +42 -0
- package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +320 -0
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +171 -0
- package/src/endpoints/auth/CreateAdminEndpoint.ts +137 -0
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +68 -0
- package/src/endpoints/auth/CreateTokenEndpoint.ts +200 -0
- package/src/endpoints/auth/DeleteTokenEndpoint.ts +31 -0
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +70 -0
- package/src/endpoints/auth/GetUserEndpoint.test.ts +64 -0
- package/src/endpoints/auth/GetUserEndpoint.ts +57 -0
- package/src/endpoints/auth/PatchApiUserEndpoint.ts +90 -0
- package/src/endpoints/auth/PatchUserEndpoint.ts +122 -0
- package/src/endpoints/auth/PollEmailVerificationEndpoint.ts +37 -0
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +41 -0
- package/src/endpoints/auth/SignupEndpoint.ts +107 -0
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +89 -0
- package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +95 -0
- package/src/endpoints/global/addresses/ValidateAddressEndpoint.ts +31 -0
- package/src/endpoints/global/caddy/CheckDomainCertEndpoint.ts +101 -0
- package/src/endpoints/global/email/GetEmailAddressEndpoint.ts +53 -0
- package/src/endpoints/global/email/ManageEmailAddressEndpoint.ts +57 -0
- package/src/endpoints/global/files/UploadFile.ts +147 -0
- package/src/endpoints/global/files/UploadImage.ts +119 -0
- package/src/endpoints/global/members/GetMemberFamilyEndpoint.ts +76 -0
- package/src/endpoints/global/members/GetMembersCountEndpoint.ts +43 -0
- package/src/endpoints/global/members/GetMembersEndpoint.ts +429 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +734 -0
- package/src/endpoints/global/organizations/CheckRegisterCodeEndpoint.ts +45 -0
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.test.ts +105 -0
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +146 -0
- package/src/endpoints/global/organizations/GetOrganizationFromDomainEndpoint.test.ts +52 -0
- package/src/endpoints/global/organizations/GetOrganizationFromDomainEndpoint.ts +80 -0
- package/src/endpoints/global/organizations/GetOrganizationFromUriEndpoint.ts +49 -0
- package/src/endpoints/global/organizations/SearchOrganizationEndpoint.test.ts +58 -0
- package/src/endpoints/global/organizations/SearchOrganizationEndpoint.ts +62 -0
- package/src/endpoints/global/payments/ExchangeSTPaymentEndpoint.ts +153 -0
- package/src/endpoints/global/payments/StripeWebhookEndpoint.ts +134 -0
- package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +44 -0
- package/src/endpoints/global/platform/GetPlatformEnpoint.ts +39 -0
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +63 -0
- package/src/endpoints/global/registration/GetPaymentRegistrations.ts +68 -0
- package/src/endpoints/global/registration/GetUserBalanceEndpoint.ts +39 -0
- package/src/endpoints/global/registration/GetUserDocumentsEndpoint.ts +80 -0
- package/src/endpoints/global/registration/GetUserMembersEndpoint.ts +41 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +134 -0
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +521 -0
- package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +37 -0
- package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +115 -0
- package/src/endpoints/global/webshops/GetWebshopFromDomainEndpoint.ts +187 -0
- package/src/endpoints/organization/dashboard/billing/ActivatePackagesEndpoint.ts +424 -0
- package/src/endpoints/organization/dashboard/billing/DeactivatePackageEndpoint.ts +67 -0
- package/src/endpoints/organization/dashboard/billing/GetBillingStatusEndpoint.ts +39 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +57 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplatesEndpoint.ts +50 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentsEndpoint.ts +50 -0
- package/src/endpoints/organization/dashboard/documents/PatchDocumentEndpoint.ts +129 -0
- package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplateEndpoint.ts +114 -0
- package/src/endpoints/organization/dashboard/email/CheckEmailBouncesEndpoint.ts +50 -0
- package/src/endpoints/organization/dashboard/email/EmailEndpoint.ts +234 -0
- package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +62 -0
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +85 -0
- package/src/endpoints/organization/dashboard/mollie/CheckMollieEndpoint.ts +80 -0
- package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +54 -0
- package/src/endpoints/organization/dashboard/mollie/DisconnectMollieEndpoint.ts +49 -0
- package/src/endpoints/organization/dashboard/mollie/GetMollieDashboardEndpoint.ts +63 -0
- package/src/endpoints/organization/dashboard/nolt/CreateNoltTokenEndpoint.ts +61 -0
- package/src/endpoints/organization/dashboard/organization/ApplyRegisterCodeEndpoint.test.ts +64 -0
- package/src/endpoints/organization/dashboard/organization/ApplyRegisterCodeEndpoint.ts +84 -0
- package/src/endpoints/organization/dashboard/organization/GetOrganizationArchivedGroups.ts +43 -0
- package/src/endpoints/organization/dashboard/organization/GetOrganizationDeletedGroups.ts +42 -0
- package/src/endpoints/organization/dashboard/organization/GetOrganizationSSOEndpoint.ts +43 -0
- package/src/endpoints/organization/dashboard/organization/GetRegisterCodeEndpoint.ts +65 -0
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +281 -0
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +338 -0
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +196 -0
- package/src/endpoints/organization/dashboard/organization/SetOrganizationSSOEndpoint.ts +50 -0
- package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +48 -0
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +207 -0
- package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +202 -0
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +233 -0
- package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.ts +66 -0
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +210 -0
- package/src/endpoints/organization/dashboard/stripe/ConnectStripeEndpoint.ts +93 -0
- package/src/endpoints/organization/dashboard/stripe/DeleteStripeAccountEndpoint.ts +59 -0
- package/src/endpoints/organization/dashboard/stripe/GetStripeAccountLinkEndpoint.ts +78 -0
- package/src/endpoints/organization/dashboard/stripe/GetStripeAccountsEndpoint.ts +40 -0
- package/src/endpoints/organization/dashboard/stripe/GetStripeLoginLinkEndpoint.ts +69 -0
- package/src/endpoints/organization/dashboard/stripe/UpdateStripeAccountEndpoint.ts +52 -0
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +73 -0
- package/src/endpoints/organization/dashboard/users/DeleteUserEndpoint.ts +60 -0
- package/src/endpoints/organization/dashboard/users/GetApiUsersEndpoint.ts +47 -0
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +41 -0
- package/src/endpoints/organization/dashboard/webshops/CreateWebshopEndpoint.ts +217 -0
- package/src/endpoints/organization/dashboard/webshops/DeleteWebshopEndpoint.ts +51 -0
- package/src/endpoints/organization/dashboard/webshops/GetDiscountCodesEndpoint.ts +47 -0
- package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.ts +83 -0
- package/src/endpoints/organization/dashboard/webshops/GetWebshopTicketsEndpoint.ts +68 -0
- package/src/endpoints/organization/dashboard/webshops/GetWebshopUriAvailabilityEndpoint.ts +69 -0
- package/src/endpoints/organization/dashboard/webshops/PatchDiscountCodesEndpoint.ts +125 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +204 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +278 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopTicketsEndpoint.ts +80 -0
- package/src/endpoints/organization/dashboard/webshops/VerifyWebshopDomainEndpoint.ts +60 -0
- package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +379 -0
- package/src/endpoints/organization/shared/GetDocumentHtml.ts +54 -0
- package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +45 -0
- package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.test.ts +78 -0
- package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.ts +34 -0
- package/src/endpoints/organization/shared/auth/OpenIDConnectCallbackEndpoint.ts +44 -0
- package/src/endpoints/organization/shared/auth/OpenIDConnectStartEndpoint.ts +82 -0
- package/src/endpoints/organization/webshops/CheckWebshopDiscountCodesEndpoint.ts +59 -0
- package/src/endpoints/organization/webshops/GetOrderByPaymentEndpoint.ts +51 -0
- package/src/endpoints/organization/webshops/GetOrderEndpoint.ts +40 -0
- package/src/endpoints/organization/webshops/GetTicketsEndpoint.ts +124 -0
- package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +130 -0
- package/src/endpoints/organization/webshops/GetWebshopEndpoint.ts +50 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +450 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +335 -0
- package/src/helpers/AddressValidator.test.ts +40 -0
- package/src/helpers/AddressValidator.ts +256 -0
- package/src/helpers/AdminPermissionChecker.ts +1031 -0
- package/src/helpers/AuthenticatedStructures.ts +158 -0
- package/src/helpers/BuckarooHelper.ts +279 -0
- package/src/helpers/CheckSettlements.ts +215 -0
- package/src/helpers/Context.ts +202 -0
- package/src/helpers/CookieHelper.ts +45 -0
- package/src/helpers/ForwardHandler.test.ts +216 -0
- package/src/helpers/ForwardHandler.ts +140 -0
- package/src/helpers/OpenIDConnectHelper.ts +284 -0
- package/src/helpers/StripeHelper.ts +293 -0
- package/src/helpers/StripePayoutChecker.ts +188 -0
- package/src/middleware/ContextMiddleware.ts +16 -0
- package/src/migrations/1646578856-validate-addresses.ts +60 -0
- package/src/seeds/0000000000-example.ts +13 -0
- package/src/seeds/1715028563-user-permissions.ts +52 -0
- package/tests/e2e/stock.test.ts +2120 -0
- package/tests/e2e/tickets.test.ts +926 -0
- package/tests/helpers/StripeMocker.ts +362 -0
- package/tests/helpers/TestServer.ts +21 -0
- package/tests/jest.global.setup.ts +29 -0
- package/tests/jest.setup.ts +59 -0
- package/tsconfig.json +42 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Request } from "@simonbackx/simple-endpoints";
|
|
2
|
+
import { SimpleError } from "@simonbackx/simple-errors";
|
|
3
|
+
import { I18n } from "@stamhoofd/backend-i18n";
|
|
4
|
+
import { Organization, Platform, RateLimiter, Token, User } from "@stamhoofd/models";
|
|
5
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
6
|
+
|
|
7
|
+
import { AdminPermissionChecker } from "./AdminPermissionChecker";
|
|
8
|
+
|
|
9
|
+
export const apiUserRateLimiter = new RateLimiter({
|
|
10
|
+
limits: [
|
|
11
|
+
{
|
|
12
|
+
// Block heavy bursts (5req/s for 5s)
|
|
13
|
+
limit: 25,
|
|
14
|
+
duration: 5 * 1000
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
// max 1req/s during 150s
|
|
18
|
+
limit: 150,
|
|
19
|
+
duration: 150 * 1000
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
// 1000 requests per hour
|
|
23
|
+
limit: 1000,
|
|
24
|
+
duration: 60 * 1000 * 60
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
// 2000 requests per day
|
|
28
|
+
limit: 2000,
|
|
29
|
+
duration: 24 * 60 * 1000 * 60
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export class ContextInstance {
|
|
35
|
+
request: Request
|
|
36
|
+
|
|
37
|
+
user?: User
|
|
38
|
+
organization?: Organization
|
|
39
|
+
|
|
40
|
+
#i18n: I18n|null = null
|
|
41
|
+
#auth: AdminPermissionChecker|null = null
|
|
42
|
+
|
|
43
|
+
constructor(request: Request) {
|
|
44
|
+
this.request = request;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static asyncLocalStorage = new AsyncLocalStorage<ContextInstance>();
|
|
48
|
+
|
|
49
|
+
static get current(): ContextInstance {
|
|
50
|
+
const c = this.asyncLocalStorage.getStore();
|
|
51
|
+
|
|
52
|
+
if (!c) {
|
|
53
|
+
throw new SimpleError({
|
|
54
|
+
code: 'no_context',
|
|
55
|
+
message: 'No context found',
|
|
56
|
+
statusCode: 500
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return c;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static async start<T>(request: Request, handler: () => Promise<T>): Promise<T> {
|
|
64
|
+
const context = new ContextInstance(request);
|
|
65
|
+
|
|
66
|
+
return await this.asyncLocalStorage.run(context, async () => {
|
|
67
|
+
return await handler()
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get version() {
|
|
72
|
+
return this.request.getVersion()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get i18n() {
|
|
76
|
+
if (!this.#i18n) {
|
|
77
|
+
this.#i18n = I18n.fromRequest(this.request)
|
|
78
|
+
}
|
|
79
|
+
return this.#i18n
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get auth() {
|
|
83
|
+
if (!this.#auth) {
|
|
84
|
+
throw new SimpleError({
|
|
85
|
+
code: 'internal_error',
|
|
86
|
+
statusCode: 500,
|
|
87
|
+
message: 'AdminPermissionChecker not set in RequestContext: make sure the request is authenticated before using the permissionChecker'
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
return this.#auth
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get optionalAuth() {
|
|
94
|
+
return this.#auth
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async setOptionalOrganizationScope() {
|
|
98
|
+
try {
|
|
99
|
+
return await this.setOrganizationScope()
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Require organization scope if userMode is not platform
|
|
107
|
+
*/
|
|
108
|
+
async setUserOrganizationScope() {
|
|
109
|
+
if (STAMHOOFD.userMode === 'platform') {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return await this.setOrganizationScope()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async setOrganizationScope() {
|
|
116
|
+
const organization = await Organization.fromApiHost(this.request.host);
|
|
117
|
+
|
|
118
|
+
this.organization = organization
|
|
119
|
+
this.i18n.switchToLocale({ country: organization.address.country })
|
|
120
|
+
|
|
121
|
+
return organization
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async optionalAuthenticate({allowWithoutAccount = false}: {allowWithoutAccount?: boolean} = {}): Promise<{user?: User}> {
|
|
125
|
+
const header = this.request.headers.authorization
|
|
126
|
+
if (!header) {
|
|
127
|
+
return {}
|
|
128
|
+
}
|
|
129
|
+
return this.authenticate({allowWithoutAccount})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async authenticate({allowWithoutAccount = false}: {allowWithoutAccount?: boolean} = {}): Promise<{user: User, token: Token}> {
|
|
133
|
+
const header = this.request.headers.authorization
|
|
134
|
+
if (!header) {
|
|
135
|
+
throw new SimpleError({
|
|
136
|
+
code: "not_authenticated",
|
|
137
|
+
message: "Missing required authorization header",
|
|
138
|
+
statusCode: 401
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!header.startsWith("Bearer ")) {
|
|
143
|
+
throw new SimpleError({
|
|
144
|
+
code: "not_supported_authentication",
|
|
145
|
+
message: "Authentication method not supported. Please authenticate with OAuth2",
|
|
146
|
+
statusCode: 401
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const accessToken = header.substring("Bearer ".length);
|
|
151
|
+
|
|
152
|
+
const token = await Token.getByAccessToken(accessToken, true)
|
|
153
|
+
|
|
154
|
+
if (!token || (this.organization && token.user.organizationId !== null && token.user.organizationId !== this.organization.id) || (!this.organization && token.user.organizationId)) {
|
|
155
|
+
throw new SimpleError({
|
|
156
|
+
code: "invalid_access_token",
|
|
157
|
+
message: "The access token is invalid",
|
|
158
|
+
human: "Je bent automatisch uitgelogd, log opnieuw in om verder te gaan",
|
|
159
|
+
statusCode: 401
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (token.isAccessTokenExpired()) {
|
|
164
|
+
throw new SimpleError({
|
|
165
|
+
code: "expired_access_token",
|
|
166
|
+
message: "The access token is expired",
|
|
167
|
+
human: "Je bent automatisch uitgelogd, log opnieuw in om verder te gaan",
|
|
168
|
+
statusCode: 401
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!token.user.hasAccount() && !allowWithoutAccount) {
|
|
173
|
+
throw new SimpleError({
|
|
174
|
+
code: "not_activated",
|
|
175
|
+
message: "This user is not yet activated",
|
|
176
|
+
human: "Maak een account aan op dit e-mailadres om een wachtwoord in te stellen voor je inlogt.",
|
|
177
|
+
statusCode: 401
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Rate limits for api users
|
|
182
|
+
if (token.user.isApiUser) {
|
|
183
|
+
apiUserRateLimiter.track(this.organization?.id ?? token.user.id)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const user = token.user
|
|
187
|
+
this.user = user
|
|
188
|
+
this.#auth = new AdminPermissionChecker(user, await Platform.getSharedPrivateStruct(), this.organization);
|
|
189
|
+
|
|
190
|
+
return {user, token};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const Context = new Proxy(ContextInstance, {
|
|
195
|
+
get(target, prop, receiver) {
|
|
196
|
+
const c = target.current[prop];
|
|
197
|
+
if (c && typeof c == 'function') {
|
|
198
|
+
return c.bind(target.current)
|
|
199
|
+
}
|
|
200
|
+
return c;
|
|
201
|
+
}
|
|
202
|
+
}) as unknown as ContextInstance;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { DecodedRequest, Response } from "@simonbackx/simple-endpoints";
|
|
2
|
+
import cookie from 'cookie';
|
|
3
|
+
|
|
4
|
+
type DecodedRequestWithCookies = DecodedRequest<any, any, any> & { cookies?: Record<string, string>}
|
|
5
|
+
|
|
6
|
+
export class CookieHelper {
|
|
7
|
+
static getCookies(request: DecodedRequest<any, any, any>): Record<string, string> {
|
|
8
|
+
const r = request as DecodedRequestWithCookies
|
|
9
|
+
if (r.cookies) {
|
|
10
|
+
return r.cookies
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const header = r.headers.cookie
|
|
14
|
+
if (!header) {
|
|
15
|
+
r.cookies = {}
|
|
16
|
+
return r.cookies
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Parse
|
|
20
|
+
r.cookies = cookie.parse(header)
|
|
21
|
+
return r.cookies
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static getCookie(request: DecodedRequest<any, any, any>, name: string): string | undefined {
|
|
25
|
+
const cookies = this.getCookies(request)
|
|
26
|
+
return cookies[name]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static setCookie(response: Response<any>, name: string, value: string, options?: cookie.CookieSerializeOptions | undefined) {
|
|
30
|
+
const cookies = cookie.serialize(name, value, options)
|
|
31
|
+
let currentCookies = response.headers['set-cookie']
|
|
32
|
+
if (!currentCookies) {
|
|
33
|
+
response.headers['set-cookie'] = [
|
|
34
|
+
cookies
|
|
35
|
+
]
|
|
36
|
+
} else {
|
|
37
|
+
if (!Array.isArray(currentCookies)) {
|
|
38
|
+
currentCookies = [currentCookies.toString()]
|
|
39
|
+
response.headers['set-cookie'] = currentCookies
|
|
40
|
+
}
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
42
|
+
(currentCookies ).push(cookies)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/* eslint-disable jest/expect-expect */
|
|
2
|
+
|
|
3
|
+
import { EmailAddress } from "@stamhoofd/email"
|
|
4
|
+
import { OrganizationFactory, UserFactory } from "@stamhoofd/models"
|
|
5
|
+
import { OrganizationEmail, PermissionLevel, Permissions } from "@stamhoofd/structures"
|
|
6
|
+
|
|
7
|
+
import { ForwardHandler } from "./ForwardHandler"
|
|
8
|
+
|
|
9
|
+
describe("ForwardHandler", () => {
|
|
10
|
+
it("should send to default e-mail", async () => {
|
|
11
|
+
const organization = await new OrganizationFactory({}).create()
|
|
12
|
+
organization.privateMeta.emails.push(OrganizationEmail.create({
|
|
13
|
+
name: "First",
|
|
14
|
+
email: "first@example.com"
|
|
15
|
+
}))
|
|
16
|
+
organization.privateMeta.emails.push(OrganizationEmail.create({
|
|
17
|
+
name: "default",
|
|
18
|
+
email: "def@example.com",
|
|
19
|
+
default: true
|
|
20
|
+
}))
|
|
21
|
+
await organization.save()
|
|
22
|
+
|
|
23
|
+
const options = await ForwardHandler.handle("From: someone@example.com\nSubject: Hello\nTo: "+organization.uri + "@stamhoofd.email\nContent-Type: text/plain\n\nContent hier", {
|
|
24
|
+
recipients: [organization.uri + "@stamhoofd.email"],
|
|
25
|
+
spamVerdict: { status: 'PASS' },
|
|
26
|
+
virusVerdict: { status: 'PASS' },
|
|
27
|
+
spfVerdict: { status: 'PASS' },
|
|
28
|
+
dkimVerdict: { status: 'PASS' },
|
|
29
|
+
dmarcVerdict: { status: 'PASS' },
|
|
30
|
+
})
|
|
31
|
+
expect(options).toMatchObject({
|
|
32
|
+
to: [
|
|
33
|
+
{
|
|
34
|
+
email: "def@example.com",
|
|
35
|
+
name: "default",
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
subject: "Hello",
|
|
39
|
+
replyTo: "someone@example.com"
|
|
40
|
+
})
|
|
41
|
+
expect(options!.text).toContain("Content hier")
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("should send to first e-mail", async () => {
|
|
45
|
+
const organization = await new OrganizationFactory({}).create()
|
|
46
|
+
organization.privateMeta.emails.push(OrganizationEmail.create({
|
|
47
|
+
name: "First",
|
|
48
|
+
email: "first@example.com"
|
|
49
|
+
}))
|
|
50
|
+
organization.privateMeta.emails.push(OrganizationEmail.create({
|
|
51
|
+
name: "second",
|
|
52
|
+
email: "second@example.com",
|
|
53
|
+
}))
|
|
54
|
+
await organization.save()
|
|
55
|
+
|
|
56
|
+
const options = await ForwardHandler.handle("From: someone@example.com\nSubject: Hello\nTo: "+organization.uri + "@stamhoofd.email\nContent-Type: text/plain\n\nContent hier", {
|
|
57
|
+
recipients: [organization.uri + "@stamhoofd.email"],
|
|
58
|
+
spamVerdict: { status: 'PASS' },
|
|
59
|
+
virusVerdict: { status: 'PASS' },
|
|
60
|
+
spfVerdict: { status: 'PASS' },
|
|
61
|
+
dkimVerdict: { status: 'PASS' },
|
|
62
|
+
dmarcVerdict: { status: 'PASS' },
|
|
63
|
+
})
|
|
64
|
+
expect(options).toMatchObject({
|
|
65
|
+
to: [
|
|
66
|
+
{
|
|
67
|
+
email: "first@example.com",
|
|
68
|
+
name: "First"
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
subject: "Hello",
|
|
72
|
+
replyTo: "someone@example.com"
|
|
73
|
+
})
|
|
74
|
+
expect(options!.text).toContain("Content hier")
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("should send to administrators if no emails defined", async () => {
|
|
78
|
+
const organization = await new OrganizationFactory({}).create()
|
|
79
|
+
const user = await new UserFactory({ organization, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create()
|
|
80
|
+
|
|
81
|
+
const options = await ForwardHandler.handle("From: someone@example.com\nSubject: Hello\nTo: "+organization.uri + "@stamhoofd.email\nContent-Type: text/plain\n\nContent hier", {
|
|
82
|
+
recipients: [organization.uri + "@stamhoofd.email"],
|
|
83
|
+
spamVerdict: { status: 'PASS' },
|
|
84
|
+
virusVerdict: { status: 'PASS' },
|
|
85
|
+
spfVerdict: { status: 'PASS' },
|
|
86
|
+
dkimVerdict: { status: 'PASS' },
|
|
87
|
+
dmarcVerdict: { status: 'PASS' },
|
|
88
|
+
})
|
|
89
|
+
expect(options).toMatchObject({
|
|
90
|
+
to: [
|
|
91
|
+
{
|
|
92
|
+
email: user.email,
|
|
93
|
+
name: null
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
subject: "Hello",
|
|
97
|
+
replyTo: "someone@example.com"
|
|
98
|
+
})
|
|
99
|
+
expect(options!.text).toContain("Content hier")
|
|
100
|
+
|
|
101
|
+
// Check notice
|
|
102
|
+
expect(options!.text).toContain("naar alle beheerders")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("should send to all full administrators if no emails defined", async () => {
|
|
106
|
+
const organization = await new OrganizationFactory({}).create()
|
|
107
|
+
const user = await new UserFactory({ organization, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create()
|
|
108
|
+
const user2 = await new UserFactory({ organization, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create()
|
|
109
|
+
|
|
110
|
+
// Admin that should get ignored
|
|
111
|
+
await new UserFactory({ organization, permissions: Permissions.create({ level: PermissionLevel.Read }) }).create()
|
|
112
|
+
|
|
113
|
+
const options = await ForwardHandler.handle("From: someone@example.com\nSubject: Hello\nTo: "+organization.uri + "@stamhoofd.email\nContent-Type: text/plain\n\nContent hier", {
|
|
114
|
+
recipients: [organization.uri + "@stamhoofd.email"],
|
|
115
|
+
spamVerdict: { status: 'PASS' },
|
|
116
|
+
virusVerdict: { status: 'PASS' },
|
|
117
|
+
spfVerdict: { status: 'PASS' },
|
|
118
|
+
dkimVerdict: { status: 'PASS' },
|
|
119
|
+
dmarcVerdict: { status: 'PASS' },
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(options).toMatchObject({
|
|
123
|
+
subject: "Hello",
|
|
124
|
+
replyTo: "someone@example.com"
|
|
125
|
+
})
|
|
126
|
+
expect(options!.to).toIncludeAllMembers([
|
|
127
|
+
{
|
|
128
|
+
email: user.email,
|
|
129
|
+
name: null
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
email: user2.email,
|
|
133
|
+
name: null
|
|
134
|
+
}
|
|
135
|
+
])
|
|
136
|
+
expect(options!.text).toContain("Content hier")
|
|
137
|
+
|
|
138
|
+
// Check notice
|
|
139
|
+
expect(options!.text).toContain("naar alle beheerders")
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("should ignore aws bounce emails", async () => {
|
|
143
|
+
const organization = await new OrganizationFactory({}).create()
|
|
144
|
+
organization.privateMeta.emails.push(OrganizationEmail.create({
|
|
145
|
+
name: "First",
|
|
146
|
+
email: "first@example.com"
|
|
147
|
+
}))
|
|
148
|
+
organization.privateMeta.emails.push(OrganizationEmail.create({
|
|
149
|
+
name: "second",
|
|
150
|
+
email: "second@example.com",
|
|
151
|
+
}))
|
|
152
|
+
await organization.save()
|
|
153
|
+
|
|
154
|
+
const options = await ForwardHandler.handle("From: bounces@amazonses.com\nSubject: Hello\nTo: "+organization.uri + "@stamhoofd.email\nContent-Type: text/plain\n\nContent hier", {
|
|
155
|
+
recipients: [organization.uri + "@stamhoofd.email"],
|
|
156
|
+
spamVerdict: { status: 'PASS' },
|
|
157
|
+
virusVerdict: { status: 'PASS' },
|
|
158
|
+
spfVerdict: { status: 'PASS' },
|
|
159
|
+
dkimVerdict: { status: 'PASS' },
|
|
160
|
+
dmarcVerdict: { status: 'PASS' },
|
|
161
|
+
})
|
|
162
|
+
expect(options).toBeUndefined()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("should ignore aws bounce emails for unknown organizations", async () => {
|
|
166
|
+
const options = await ForwardHandler.handle("From: bounces@amazonses.com\nSubject: Hello\nTo: ksjdgsdgkjlsdg@stamhoofd.email\nContent-Type: text/plain\n\nContent hier", {
|
|
167
|
+
recipients: ["ksjdgsdgkjlsdg@stamhoofd.email"],
|
|
168
|
+
spamVerdict: { status: 'PASS' },
|
|
169
|
+
virusVerdict: { status: 'PASS' },
|
|
170
|
+
spfVerdict: { status: 'PASS' },
|
|
171
|
+
dkimVerdict: { status: 'PASS' },
|
|
172
|
+
dmarcVerdict: { status: 'PASS' },
|
|
173
|
+
})
|
|
174
|
+
expect(options).toBeUndefined()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("should unsubscribe email addresses that send to unsubscribe", async () => {
|
|
178
|
+
const address = new EmailAddress()
|
|
179
|
+
address.email = "exampleaddress-unsusbcribe-test@example.com";
|
|
180
|
+
address.organizationId = null
|
|
181
|
+
address.token = null;
|
|
182
|
+
await address.save()
|
|
183
|
+
|
|
184
|
+
const id = address.id
|
|
185
|
+
|
|
186
|
+
const options = await ForwardHandler.handle(`From: bounces@amazonses.com\nSubject: Hello\nTo: unsubscribe+${id}@stamhoofd.email\nContent-Type: text/plain\n\nContent hier`, {
|
|
187
|
+
recipients: [`unsubscribe+${id}@stamhoofd.email`],
|
|
188
|
+
spamVerdict: { status: 'PASS' },
|
|
189
|
+
virusVerdict: { status: 'PASS' },
|
|
190
|
+
spfVerdict: { status: 'PASS' },
|
|
191
|
+
dkimVerdict: { status: 'PASS' },
|
|
192
|
+
dmarcVerdict: { status: 'PASS' },
|
|
193
|
+
})
|
|
194
|
+
expect(options).toBeUndefined()
|
|
195
|
+
|
|
196
|
+
// Refresh adress and check unsubscribed for all
|
|
197
|
+
const updatedAddress = await EmailAddress.getByID(id)
|
|
198
|
+
expect(updatedAddress).toBeDefined()
|
|
199
|
+
expect(updatedAddress!.unsubscribedAll).toEqual(true);
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("should forward unsubscribe emails to unrecognized id", async () => {
|
|
203
|
+
const options = await ForwardHandler.handle(`From: bounces@amazonses.com\nSubject: Hello\nTo: unsubscribe+testid@stamhoofd.email\nContent-Type: text/plain\n\nContent hier`, {
|
|
204
|
+
recipients: [`unsubscribe+testid@stamhoofd.email`],
|
|
205
|
+
spamVerdict: { status: 'PASS' },
|
|
206
|
+
virusVerdict: { status: 'PASS' },
|
|
207
|
+
spfVerdict: { status: 'PASS' },
|
|
208
|
+
dkimVerdict: { status: 'PASS' },
|
|
209
|
+
dmarcVerdict: { status: 'PASS' },
|
|
210
|
+
})
|
|
211
|
+
expect(options).toMatchObject({
|
|
212
|
+
to: "hallo@stamhoofd.be",
|
|
213
|
+
subject: "E-mail unsubscribe mislukt",
|
|
214
|
+
});
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { EmailAddress, EmailInterfaceRecipient } from "@stamhoofd/email";
|
|
2
|
+
import { Organization } from "@stamhoofd/models";
|
|
3
|
+
import { Formatter } from "@stamhoofd/utility";
|
|
4
|
+
import { simpleParser } from "mailparser";
|
|
5
|
+
|
|
6
|
+
export class ForwardHandler {
|
|
7
|
+
static async handle(content: any, receipt: {
|
|
8
|
+
recipients: string[];
|
|
9
|
+
spamVerdict: { status: 'PASS' | string };
|
|
10
|
+
virusVerdict: { status: 'PASS' | string };
|
|
11
|
+
spfVerdict: { status: 'PASS' | string };
|
|
12
|
+
dkimVerdict: { status: 'PASS' | string };
|
|
13
|
+
dmarcVerdict: { status: 'PASS' | string };
|
|
14
|
+
}
|
|
15
|
+
) {
|
|
16
|
+
const recipients = receipt.recipients
|
|
17
|
+
const email: string | undefined = recipients[0]
|
|
18
|
+
const organization: Organization | undefined = email ? await Organization.getByEmail(email) : undefined
|
|
19
|
+
|
|
20
|
+
const parsed = await simpleParser(content);
|
|
21
|
+
const from = parsed.from?.value[0]?.address
|
|
22
|
+
|
|
23
|
+
if (from && from.endsWith("amazonses.com") && organization) {
|
|
24
|
+
console.log("Bounce e-mails from AWS SES for organizations are not forwarded. Received from "+from+", to "+email)
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Unsubscribe email?
|
|
29
|
+
if (email && email?.startsWith("unsubscribe+") && email.endsWith('@stamhoofd.email')) {
|
|
30
|
+
// Get id
|
|
31
|
+
const id = email.substring("unsubscribe+".length, email.indexOf('@stamhoofd.email'))
|
|
32
|
+
const model = await EmailAddress.getByID(id)
|
|
33
|
+
|
|
34
|
+
if (model) {
|
|
35
|
+
console.log('[Unsubscribe] Received an unsubscribe request for ' + model.email + ' from ' + from)
|
|
36
|
+
if (model.unsubscribedAll) {
|
|
37
|
+
// Ignore
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
model.unsubscribedAll = true
|
|
41
|
+
await model.save()
|
|
42
|
+
} else {
|
|
43
|
+
console.error('[Unsubscribe] Received an unsubscribe request for unknown ID ' + id + ' from ' + from)
|
|
44
|
+
|
|
45
|
+
// Forward
|
|
46
|
+
return {
|
|
47
|
+
from: "unsubscribe@stamhoofd.be",
|
|
48
|
+
to: "hallo@stamhoofd.be",
|
|
49
|
+
subject: "E-mail unsubscribe mislukt",
|
|
50
|
+
text: "Beste,\n\nEr werd een unsubscribe gemeld op "+email+" die niet kon worden verwerkt. Gelieve dit na te kijken.\n\nStamhoofd"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (receipt.spamVerdict.status != "PASS" || receipt.virusVerdict.status != "PASS" || !(receipt.spfVerdict.status == "PASS" || receipt.dkimVerdict.status == "PASS")) {
|
|
58
|
+
console.error("Received spam or virus e-mail. Ignoring", 'to', recipients, 'from', email, 'subject', parsed.subject)
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Send a new e-mail
|
|
63
|
+
let defaultEmail: EmailInterfaceRecipient[]|string = "hallo@stamhoofd.be"
|
|
64
|
+
let organizationEmails: EmailInterfaceRecipient[] = []
|
|
65
|
+
const extraDescription = "Dit bericht werd verstuurd naar "+email+", en werd automatisch doorgestuurd naar alle beheerders. Stel in Stamhoofd de e-mailadressen in om ervoor te zorgen dat antwoorden naar een specifiek e-mailadres worden verstuurd."
|
|
66
|
+
|
|
67
|
+
function doBounce() {
|
|
68
|
+
if (!from) {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (from.endsWith("@amazonses.com")) {
|
|
73
|
+
// Ignore
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Send back to receiver without including the original message to avoid spam
|
|
78
|
+
return {
|
|
79
|
+
from: email ?? "unknown@stamhoofd.be",
|
|
80
|
+
to: from,
|
|
81
|
+
subject: "Ongeldig e-mailadres",
|
|
82
|
+
text: "Beste,\n\nDe vereniging die je probeert te bereiken via "+email+" is helaas niet bereikbaar via dit e-mailadres. Dit e-mailadres wordt enkel gebruikt voor het versturen van automatische e-mails in naam van een vereniging. Probeer de vereniging te contacteren via een ander e-mailadres.\n\nBedankt."
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (organization) {
|
|
87
|
+
organizationEmails = await organization.getReplyEmails()
|
|
88
|
+
if (!organizationEmails) {
|
|
89
|
+
if (STAMHOOFD.environment === "test") {
|
|
90
|
+
// ignore
|
|
91
|
+
} else {
|
|
92
|
+
console.error("Missing reply emails for organization "+organization.id)
|
|
93
|
+
}
|
|
94
|
+
return doBounce();
|
|
95
|
+
} else {
|
|
96
|
+
defaultEmail = organizationEmails
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
return doBounce();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log("Forward to "+defaultEmail)
|
|
103
|
+
|
|
104
|
+
let html: string | undefined = undefined
|
|
105
|
+
|
|
106
|
+
if (parsed.html !== false) {
|
|
107
|
+
// Search for body
|
|
108
|
+
const body = parsed.html.toLowerCase().indexOf("<body")
|
|
109
|
+
|
|
110
|
+
if (body != -1) {
|
|
111
|
+
const endTag = parsed.html.indexOf(">", body)
|
|
112
|
+
html = parsed.html.substring(0, endTag + 1) + "<p><i>"+Formatter.escapeHtml(extraDescription)+"<br><br></i></p>"+parsed.html.substring(endTag + 1)
|
|
113
|
+
} else {
|
|
114
|
+
html = "<p><i>"+Formatter.escapeHtml(extraDescription)+"<br><br></i></p>"+parsed.html
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const options = {
|
|
119
|
+
from: email ?? "unknown@stamhoofd.be",
|
|
120
|
+
to: defaultEmail,
|
|
121
|
+
replyTo: parsed.from?.text,
|
|
122
|
+
subject: parsed.subject ?? "Doorgestuurd bericht",
|
|
123
|
+
text: parsed.text ? extraDescription + "\n\n" + parsed.text : undefined,
|
|
124
|
+
html: html,
|
|
125
|
+
attachments: parsed.attachments.flatMap(a => {
|
|
126
|
+
if (a.cid) {
|
|
127
|
+
// Already done inline in html
|
|
128
|
+
return []
|
|
129
|
+
}
|
|
130
|
+
return [{
|
|
131
|
+
filename: a.filename ?? "",
|
|
132
|
+
content: a.content.toString("utf-8"),
|
|
133
|
+
contentType: a.contentType
|
|
134
|
+
}]
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return options
|
|
139
|
+
}
|
|
140
|
+
}
|