Skip to content

Commit

Permalink
feat: implement and test leaving a group
Browse files Browse the repository at this point in the history
  • Loading branch information
mrkvon committed Jan 7, 2025
1 parent 7cb515a commit 94309eb
Showing 11 changed files with 288 additions and 35 deletions.
5 changes: 4 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -9,4 +9,7 @@ PORT=

## URI of the group to which the joining person should be added (required)
## The service will need read and write access to the group
GROUP_TO_JOIN=
GROUP_TO_JOIN=""

## comma-separated list of URIs of the groups from which the person can be removed if present (required)
GROUPS_TO_LEAVE=""
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Implement and test leaving a group

## [0.1.0] - 2025-01-07

### Added
5 changes: 3 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ import { solidIdentity } from '@soid/koa'
import Koa from 'koa'
import helmet from 'koa-helmet'
import serve from 'koa-static'
import { processRequest, verifyReqest } from './controllers/inbox.js'
import { processRequest, verifyRequest } from './controllers/inbox.js'
import { loadConfig } from './middlewares/loadConfig.js'
import { solidAuth } from './middlewares/solidAuth.js'
import { validateBody } from './middlewares/validate.js'
@@ -16,6 +16,7 @@ export interface AppConfig {
readonly webId: string
readonly isBehindProxy?: boolean
readonly groupToJoin: string
readonly groupsToLeave: string[]
}

const createApp = async (config: AppConfig) => {
@@ -40,7 +41,7 @@ const createApp = async (config: AppConfig) => {
}
*/
validateBody(schema.notification),
verifyReqest,
verifyRequest,
processRequest,
)

10 changes: 5 additions & 5 deletions src/config/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
export const stringToBoolean = (value: string | undefined): boolean => {
export const envToBoolean = (value: string | undefined): boolean => {
if (value === 'false') return false
if (value === '0') return false
return !!value
}

// export const stringToArray = (value: string) => {
// if (!value) return []
// return value.split(/\s*,\s*/)
// }
export const envToArray = (value: string | undefined) => {
if (!value) return []
return value.split(/\s*,\s*/)
}
6 changes: 4 additions & 2 deletions src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ConfigError } from '../utils/errors.js'
import { stringToBoolean } from './helpers.js'
import { envToArray, envToBoolean } from './helpers.js'

// define environment variables via .env file, or via environment variables directly

@@ -16,11 +16,13 @@ const baseUrl =

export const webId = new URL('/profile/card#bot', baseUrl).toString()

export const isBehindProxy = stringToBoolean(process.env.BEHIND_PROXY)
export const isBehindProxy = envToBoolean(process.env.BEHIND_PROXY)

if (!process.env.GROUP_TO_JOIN)
throw new ConfigError(
'Please specify GROUP_TO_JOIN in environment variables.',
)

export const groupToJoin = process.env.GROUP_TO_JOIN

export const groupsToLeave = envToArray(process.env.GROUPS_TO_LEAVE)
81 changes: 70 additions & 11 deletions src/controllers/inbox.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,74 @@
import { getAuthenticatedFetch } from '@soid/koa'
import { type Middleware } from 'koa'
import assert from 'node:assert'
import { solid, vcard } from 'rdf-namespaces'
import { AppConfig } from '../app.js'
import { HttpError, ValidationError } from '../utils/errors.js'

export const verifyReqest: Middleware<{
export const verifyRequest: Middleware<{
user: string
config: AppConfig
}> = async (ctx, next) => {
const actor = ctx.request.body.actor.id
const authenticatedUser = ctx.state.user
const object = ctx.request.body.object.id
const group = ctx.state.config.groupToJoin
const { groupToJoin, groupsToLeave } = ctx.state.config
const task: 'Join' | 'Leave' = ctx.request.body.type

if (actor !== authenticatedUser)
ctx.throw(
403,
`Actor does not match authenticated agent.\nActor: ${actor}\nAuthenticated agent: ${authenticatedUser}`,
)

if (object !== group)
throw new ValidationError(
`Object does not match expected group.\nExpected: ${group}\nActual: ${object}`,
[],
)
switch (task) {
case 'Join': {
if (object !== groupToJoin)
throw new ValidationError(
`Object does not match expected group.\nExpected: ${groupToJoin}\nActual: ${object}`,
[],
)
break
}
case 'Leave': {
if (!groupsToLeave.includes(object))
throw new ValidationError(
`Object does not match any of the expected groups.\nExpected: ${groupsToLeave.join(',')}\nActual: ${object}`,
[],
)
break
}
default: {
throw new Error(`Unrecognized task: ${task}`)
}
}

await next()
}

export const processRequest: Middleware<{
user: string
config: AppConfig
}> = async (ctx, next) => {
const task: 'Join' | 'Leave' = ctx.request.body.type

switch (task) {
case 'Join': {
return await joinGroup(ctx, next)
}
case 'Leave': {
return await leaveGroup(ctx, next)
}
default:
throw new Error('Unexpected task')
}
}

const joinGroup: Middleware<{
user: string
config: AppConfig
}> = async ctx => {
const group = ctx.state.config.groupToJoin
const group = ctx.request.body.object.id

const authFetch = await getAuthenticatedFetch(ctx.state.config.webId)
const response = await authFetch(group, {
headers: { 'content-type': 'text/n3' },
@@ -47,10 +82,34 @@ export const processRequest: Middleware<{
throw new HttpError(
`Adding person ${ctx.state.user} to group ${group} failed.`,
response,
500,
)

assert(response.ok)

ctx.status = 200
ctx.set('location', group)
}

const leaveGroup: Middleware<{
user: string
config: AppConfig
}> = async ctx => {
const group = ctx.request.body.object.id

const authFetch = await getAuthenticatedFetch(ctx.state.config.webId)
const response = await authFetch(group, {
headers: { 'content-type': 'text/n3' },
method: 'PATCH',
body: `_:remove a <${solid.InsertDeletePatch}>;
<${solid.deletes}> { <${group}> <${vcard.hasMember}> <${ctx.state.user}> . }.
`,
})

if (!response.ok)
throw new HttpError(
`Removing person ${ctx.state.user} from group ${group} failed.`,
response,
response.status === 409 ? 409 : 500,
)

ctx.status = 200
}
10 changes: 8 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
/* eslint-disable no-console */
import { createApp } from './app.js'
import { groupToJoin, isBehindProxy, port, webId } from './config/index.js'
import {
groupsToLeave,
groupToJoin,
isBehindProxy,
port,
webId,
} from './config/index.js'

createApp({ webId, isBehindProxy, groupToJoin }).then(app =>
createApp({ webId, isBehindProxy, groupToJoin, groupsToLeave }).then(app =>
app.listen(port, async () => {
console.log(`community inbox service is listening on port ${port}`)
}),
4 changes: 2 additions & 2 deletions src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { JSONSchemaType } from 'ajv/dist/2020.js'

export const notification: JSONSchemaType<{
type: 'Join'
type: 'Join' | 'Leave'
id: string
'@context': 'https://www.w3.org/ns/activitystreams'
actor: { type: 'Person'; id: string }
@@ -17,7 +17,7 @@ export const notification: JSONSchemaType<{
const: 'https://www.w3.org/ns/activitystreams',
},
id: { type: 'string' },
type: { type: 'string', enum: ['Join'] },
type: { type: 'string', enum: ['Join', 'Leave'] },
actor: {
type: 'object',
properties: {
Loading

0 comments on commit 94309eb

Please sign in to comment.