Skip to content

Commit

Permalink
Implement sending email notifications via POST /notification (#3)
Browse files Browse the repository at this point in the history
Also
- give tests more time to run in .mocharc.yml
- refactor body schemata and update apidocs
  • Loading branch information
mrkvon authored Jan 17, 2024
1 parent ed09e46 commit 97fdfc8
Show file tree
Hide file tree
Showing 12 changed files with 388 additions and 178 deletions.
1 change: 1 addition & 0 deletions .mocharc.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
extension: [ts]
spec: src/test/*.spec.ts
require: ts-node/register
timeout: 10000
121 changes: 109 additions & 12 deletions apidocs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,7 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email"
}
},
"required": [
"email"
],
"additionalProperties": false
"$ref": "#/components/schemas/init"
}
}
}
Expand All @@ -51,13 +41,23 @@
}
}
},
"/webhook-receiver": {
"/notification": {
"post": {
"description": "",
"responses": {
"default": {
"description": ""
}
},
"requestBody": {
"required": true,
"content": {
"application/ld+json": {
"schema": {
"$ref": "#/components/schemas/notification"
}
}
}
}
}
},
Expand All @@ -81,5 +81,102 @@
}
}
}
},
"components": {
"schemas": {
"init": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email"
}
},
"required": [
"email"
],
"additionalProperties": false
},
"notification": {
"type": "object",
"properties": {
"@context": {
"const": "https://www.w3.org/ns/activitystreams"
},
"id": {
"type": "string"
},
"type": {
"const": "Create"
},
"actor": {
"type": "object",
"properties": {
"type": {
"const": "Person"
},
"id": {
"type": "string",
"format": "uri"
},
"name": {
"type": "string"
}
},
"required": [
"type",
"id"
]
},
"object": {
"type": "object",
"properties": {
"type": {
"const": "Note"
},
"id": {
"type": "string",
"format": "uri"
},
"content": {
"type": "string"
}
},
"required": [
"type",
"id",
"content"
]
},
"target": {
"type": "object",
"properties": {
"type": {
"const": "Person"
},
"id": {
"type": "string",
"format": "uri"
},
"name": {
"type": "string"
}
},
"required": [
"type",
"id"
]
}
},
"required": [
"@context",
"type",
"actor",
"object",
"target"
],
"additionalProperties": false
}
}
}
}
41 changes: 25 additions & 16 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import {
finishIntegration,
initializeIntegration,
} from './controllers/integration'
import { notification } from './controllers/notification'
import { getStatus } from './controllers/status'
import { webhookReceiver } from './controllers/webhookReceiver'
import {
authorizeGroups,
checkParamGroupMembership,
checkGroupMembership,
} from './middlewares/authorizeGroup'
import { solidAuth } from './middlewares/solidAuth'
import { validateBody } from './middlewares/validate'
import * as schema from './schema'

const app = new Koa()
app.proxy = isBehindProxy
Expand All @@ -33,32 +34,40 @@ router
content: {
'application/json': {
schema: {
type: 'object',
properties: {
email: { type: 'string', format: 'email' },
},
required: ['email'],
additionalProperties: false,
$ref: '#/components/schemas/init',
},
},
},
}
*/
validateBody({
type: 'object',
properties: { email: { type: 'string', format: 'email' } },
required: ['email'],
additionalProperties: false,
}),
validateBody(schema.init),
initializeIntegration,
)
.get('/verify-email', checkVerificationLink, finishIntegration)
.post('/webhook-receiver', webhookReceiver)
.post(
'/notification',
solidAuth,
authorizeGroups(allowedGroups),
/* #swagger.requestBody = {
required: true,
content: {
'application/ld+json': {
schema: {
$ref: '#/components/schemas/notification',
},
},
},
}
*/
validateBody(schema.notification),
checkGroupMembership(allowedGroups, 'request.body.target.id', 400),
notification,
)
.get(
'/status/:webId',
solidAuth,
authorizeGroups(allowedGroups),
checkParamGroupMembership(allowedGroups, 'webId' as const),
checkGroupMembership(allowedGroups, 'params.webId', 400),
getStatus,
)

Expand Down
43 changes: 43 additions & 0 deletions src/controllers/notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { DefaultContext, Middleware } from 'koa'
import { emailSender } from '../config'
import { sendMail } from '../services/mailerService'
import { getVerifiedEmails } from './status'

export type GoodBody = {
'@context': 'https://www.w3.org/ns/activitystreams'
type: 'Create'
actor: { type: 'Person'; id: string; name?: string }
object: { type: 'Note'; id: string; content: string }
target: { type: 'Person'; id: string; name?: string }
}

export const notification: Middleware<
{ user: string; client: string | undefined },
DefaultContext & { request: { body: GoodBody } }
> = async ctx => {
const body: GoodBody = ctx.request.body

if (ctx.state.user !== body.actor.id)
return ctx.throw(403, "You can't send notification as somebody else")

// find email address
const emails = await getVerifiedEmails(body.target.id)

if (emails.length === 0)
return ctx.throw(
404,
"Receiving person doesn't have available email address",
)

for (const email of emails) {
await sendMail({
from: emailSender,
to: email,
subject: 'You have a new message from sleepy.bike!', // TODO generalize
html: body.object.content,
text: body.object.content,
})
}
ctx.response.status = 202
ctx.response.body = 'Accepted'
}
32 changes: 0 additions & 32 deletions src/controllers/webhookReceiver.ts

This file was deleted.

3 changes: 2 additions & 1 deletion src/generate-api-docs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// https://swagger-autogen.github.io/docs/getting-started/advanced-usage#openapi-3x
import swaggerAutogen from 'swagger-autogen'
import { init, notification } from './schema'

const doc = {
info: {
Expand All @@ -10,7 +11,7 @@ const doc = {
},
servers: [{ url: '/' }],
tags: [],
components: {},
components: { '@schemas': { init, notification } },
}

const outputFile = '../apidocs/openapi.json'
Expand Down
58 changes: 22 additions & 36 deletions src/middlewares/authorizeGroup.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,17 @@
import { DefaultContext, DefaultState, Middleware } from 'koa'
import { Middleware } from 'koa'
import { get } from 'lodash'
import { Parser } from 'n3'
import { vcard } from 'rdf-namespaces'

export const authorizeGroups =
(groups: string[]): Middleware<{ user: string }> =>
async (ctx, next) => {
// if array of groups are empty, we allow everybody (default)
if (groups.length === 0) return await next()

const user = ctx.state.user

const isAllowed = await isSomeGroupMember(user, groups)

if (!isAllowed) {
return ctx.throw(
403,
'Authenticated user is not a member of any allowed group',
)
}

await next()
}
export const authorizeGroups = (
groups: string[],
): Middleware<{ user: string }> =>
checkGroupMembership(
groups,
'state.user',
403,
'Authenticated user is not a member of any allowed group',
)

const isSomeGroupMember = async (user: string, groups: string[]) => {
const memberships = await Promise.allSettled(
Expand All @@ -37,29 +28,24 @@ const isSomeGroupMember = async (user: string, groups: string[]) => {
/**
* Check whether a user specified in param is member of any of the given groups
*/
export const checkParamGroupMembership =
<T extends string>(
export const checkGroupMembership =
(
groups: string[],
param: T,
): Middleware<
DefaultState,
DefaultContext & { params: { [K in T]: string } }
> =>
path: string,
status: number,
error = 'Person is not a member of any allowed group',
): Middleware =>
async (ctx, next) => {
// if array of groups are empty, we allow everybody (default)
if (groups.length === 0) return await next()
const webId = ctx.params[param]
const webId = get(ctx, path)
if (typeof webId !== 'string')
throw new Error('Expected string, got ' + typeof webId)
const isAllowed = await isSomeGroupMember(webId, groups)

if (!isAllowed) {
return ctx.throw(400, {
error: 'Person is not a member of any allowed group',
person: webId,
groups,
})
}

await next()
return ctx.throw(status, error)
} else await next()
}

const isGroupMember = async (user: string, group: string) => {
Expand Down
Loading

0 comments on commit 97fdfc8

Please sign in to comment.