Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement sending email notifications via POST /notification #3

Merged
merged 3 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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