Skip to content

Commit 97fdfc8

Browse files
authored
Implement sending email notifications via POST /notification (#3)
Also - give tests more time to run in .mocharc.yml - refactor body schemata and update apidocs
1 parent ed09e46 commit 97fdfc8

File tree

12 files changed

+388
-178
lines changed

12 files changed

+388
-178
lines changed

.mocharc.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
extension: [ts]
22
spec: src/test/*.spec.ts
33
require: ts-node/register
4+
timeout: 10000

apidocs/openapi.json

Lines changed: 109 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,7 @@
2424
"content": {
2525
"application/json": {
2626
"schema": {
27-
"type": "object",
28-
"properties": {
29-
"email": {
30-
"type": "string",
31-
"format": "email"
32-
}
33-
},
34-
"required": [
35-
"email"
36-
],
37-
"additionalProperties": false
27+
"$ref": "#/components/schemas/init"
3828
}
3929
}
4030
}
@@ -51,13 +41,23 @@
5141
}
5242
}
5343
},
54-
"/webhook-receiver": {
44+
"/notification": {
5545
"post": {
5646
"description": "",
5747
"responses": {
5848
"default": {
5949
"description": ""
6050
}
51+
},
52+
"requestBody": {
53+
"required": true,
54+
"content": {
55+
"application/ld+json": {
56+
"schema": {
57+
"$ref": "#/components/schemas/notification"
58+
}
59+
}
60+
}
6161
}
6262
}
6363
},
@@ -81,5 +81,102 @@
8181
}
8282
}
8383
}
84+
},
85+
"components": {
86+
"schemas": {
87+
"init": {
88+
"type": "object",
89+
"properties": {
90+
"email": {
91+
"type": "string",
92+
"format": "email"
93+
}
94+
},
95+
"required": [
96+
"email"
97+
],
98+
"additionalProperties": false
99+
},
100+
"notification": {
101+
"type": "object",
102+
"properties": {
103+
"@context": {
104+
"const": "https://www.w3.org/ns/activitystreams"
105+
},
106+
"id": {
107+
"type": "string"
108+
},
109+
"type": {
110+
"const": "Create"
111+
},
112+
"actor": {
113+
"type": "object",
114+
"properties": {
115+
"type": {
116+
"const": "Person"
117+
},
118+
"id": {
119+
"type": "string",
120+
"format": "uri"
121+
},
122+
"name": {
123+
"type": "string"
124+
}
125+
},
126+
"required": [
127+
"type",
128+
"id"
129+
]
130+
},
131+
"object": {
132+
"type": "object",
133+
"properties": {
134+
"type": {
135+
"const": "Note"
136+
},
137+
"id": {
138+
"type": "string",
139+
"format": "uri"
140+
},
141+
"content": {
142+
"type": "string"
143+
}
144+
},
145+
"required": [
146+
"type",
147+
"id",
148+
"content"
149+
]
150+
},
151+
"target": {
152+
"type": "object",
153+
"properties": {
154+
"type": {
155+
"const": "Person"
156+
},
157+
"id": {
158+
"type": "string",
159+
"format": "uri"
160+
},
161+
"name": {
162+
"type": "string"
163+
}
164+
},
165+
"required": [
166+
"type",
167+
"id"
168+
]
169+
}
170+
},
171+
"required": [
172+
"@context",
173+
"type",
174+
"actor",
175+
"object",
176+
"target"
177+
],
178+
"additionalProperties": false
179+
}
180+
}
84181
}
85182
}

src/app.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ import {
1010
finishIntegration,
1111
initializeIntegration,
1212
} from './controllers/integration'
13+
import { notification } from './controllers/notification'
1314
import { getStatus } from './controllers/status'
14-
import { webhookReceiver } from './controllers/webhookReceiver'
1515
import {
1616
authorizeGroups,
17-
checkParamGroupMembership,
17+
checkGroupMembership,
1818
} from './middlewares/authorizeGroup'
1919
import { solidAuth } from './middlewares/solidAuth'
2020
import { validateBody } from './middlewares/validate'
21+
import * as schema from './schema'
2122

2223
const app = new Koa()
2324
app.proxy = isBehindProxy
@@ -33,32 +34,40 @@ router
3334
content: {
3435
'application/json': {
3536
schema: {
36-
type: 'object',
37-
properties: {
38-
email: { type: 'string', format: 'email' },
39-
},
40-
required: ['email'],
41-
additionalProperties: false,
37+
$ref: '#/components/schemas/init',
4238
},
4339
},
4440
},
4541
}
4642
*/
47-
validateBody({
48-
type: 'object',
49-
properties: { email: { type: 'string', format: 'email' } },
50-
required: ['email'],
51-
additionalProperties: false,
52-
}),
43+
validateBody(schema.init),
5344
initializeIntegration,
5445
)
5546
.get('/verify-email', checkVerificationLink, finishIntegration)
56-
.post('/webhook-receiver', webhookReceiver)
47+
.post(
48+
'/notification',
49+
solidAuth,
50+
authorizeGroups(allowedGroups),
51+
/* #swagger.requestBody = {
52+
required: true,
53+
content: {
54+
'application/ld+json': {
55+
schema: {
56+
$ref: '#/components/schemas/notification',
57+
},
58+
},
59+
},
60+
}
61+
*/
62+
validateBody(schema.notification),
63+
checkGroupMembership(allowedGroups, 'request.body.target.id', 400),
64+
notification,
65+
)
5766
.get(
5867
'/status/:webId',
5968
solidAuth,
6069
authorizeGroups(allowedGroups),
61-
checkParamGroupMembership(allowedGroups, 'webId' as const),
70+
checkGroupMembership(allowedGroups, 'params.webId', 400),
6271
getStatus,
6372
)
6473

src/controllers/notification.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { DefaultContext, Middleware } from 'koa'
2+
import { emailSender } from '../config'
3+
import { sendMail } from '../services/mailerService'
4+
import { getVerifiedEmails } from './status'
5+
6+
export type GoodBody = {
7+
'@context': 'https://www.w3.org/ns/activitystreams'
8+
type: 'Create'
9+
actor: { type: 'Person'; id: string; name?: string }
10+
object: { type: 'Note'; id: string; content: string }
11+
target: { type: 'Person'; id: string; name?: string }
12+
}
13+
14+
export const notification: Middleware<
15+
{ user: string; client: string | undefined },
16+
DefaultContext & { request: { body: GoodBody } }
17+
> = async ctx => {
18+
const body: GoodBody = ctx.request.body
19+
20+
if (ctx.state.user !== body.actor.id)
21+
return ctx.throw(403, "You can't send notification as somebody else")
22+
23+
// find email address
24+
const emails = await getVerifiedEmails(body.target.id)
25+
26+
if (emails.length === 0)
27+
return ctx.throw(
28+
404,
29+
"Receiving person doesn't have available email address",
30+
)
31+
32+
for (const email of emails) {
33+
await sendMail({
34+
from: emailSender,
35+
to: email,
36+
subject: 'You have a new message from sleepy.bike!', // TODO generalize
37+
html: body.object.content,
38+
text: body.object.content,
39+
})
40+
}
41+
ctx.response.status = 202
42+
ctx.response.body = 'Accepted'
43+
}

src/controllers/webhookReceiver.ts

Lines changed: 0 additions & 32 deletions
This file was deleted.

src/generate-api-docs.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// https://swagger-autogen.github.io/docs/getting-started/advanced-usage#openapi-3x
22
import swaggerAutogen from 'swagger-autogen'
3+
import { init, notification } from './schema'
34

45
const doc = {
56
info: {
@@ -10,7 +11,7 @@ const doc = {
1011
},
1112
servers: [{ url: '/' }],
1213
tags: [],
13-
components: {},
14+
components: { '@schemas': { init, notification } },
1415
}
1516

1617
const outputFile = '../apidocs/openapi.json'

src/middlewares/authorizeGroup.ts

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,17 @@
1-
import { DefaultContext, DefaultState, Middleware } from 'koa'
1+
import { Middleware } from 'koa'
2+
import { get } from 'lodash'
23
import { Parser } from 'n3'
34
import { vcard } from 'rdf-namespaces'
45

5-
export const authorizeGroups =
6-
(groups: string[]): Middleware<{ user: string }> =>
7-
async (ctx, next) => {
8-
// if array of groups are empty, we allow everybody (default)
9-
if (groups.length === 0) return await next()
10-
11-
const user = ctx.state.user
12-
13-
const isAllowed = await isSomeGroupMember(user, groups)
14-
15-
if (!isAllowed) {
16-
return ctx.throw(
17-
403,
18-
'Authenticated user is not a member of any allowed group',
19-
)
20-
}
21-
22-
await next()
23-
}
6+
export const authorizeGroups = (
7+
groups: string[],
8+
): Middleware<{ user: string }> =>
9+
checkGroupMembership(
10+
groups,
11+
'state.user',
12+
403,
13+
'Authenticated user is not a member of any allowed group',
14+
)
2415

2516
const isSomeGroupMember = async (user: string, groups: string[]) => {
2617
const memberships = await Promise.allSettled(
@@ -37,29 +28,24 @@ const isSomeGroupMember = async (user: string, groups: string[]) => {
3728
/**
3829
* Check whether a user specified in param is member of any of the given groups
3930
*/
40-
export const checkParamGroupMembership =
41-
<T extends string>(
31+
export const checkGroupMembership =
32+
(
4233
groups: string[],
43-
param: T,
44-
): Middleware<
45-
DefaultState,
46-
DefaultContext & { params: { [K in T]: string } }
47-
> =>
34+
path: string,
35+
status: number,
36+
error = 'Person is not a member of any allowed group',
37+
): Middleware =>
4838
async (ctx, next) => {
4939
// if array of groups are empty, we allow everybody (default)
5040
if (groups.length === 0) return await next()
51-
const webId = ctx.params[param]
41+
const webId = get(ctx, path)
42+
if (typeof webId !== 'string')
43+
throw new Error('Expected string, got ' + typeof webId)
5244
const isAllowed = await isSomeGroupMember(webId, groups)
5345

5446
if (!isAllowed) {
55-
return ctx.throw(400, {
56-
error: 'Person is not a member of any allowed group',
57-
person: webId,
58-
groups,
59-
})
60-
}
61-
62-
await next()
47+
return ctx.throw(status, error)
48+
} else await next()
6349
}
6450

6551
const isGroupMember = async (user: string, group: string) => {

0 commit comments

Comments
 (0)