Skip to content

Commit

Permalink
feat: allow providing community or group URI as activity object
Browse files Browse the repository at this point in the history
both for joining and leaving
  • Loading branch information
mrkvon committed Jan 17, 2025
1 parent 371ae68 commit 5bd4cc3
Show file tree
Hide file tree
Showing 10 changed files with 154 additions and 28 deletions.
3 changes: 3 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ GROUP_TO_JOIN=""

## comma-separated list of URIs of the groups from which the person can be removed if present (required)
GROUPS_TO_LEAVE=""

## community URI that is also accepted as Activity object (optional)
COMMUNITY=""
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Allow providing community or group URI as activity object

## [0.2.1] - 2025-01-07

### Fixed
Expand Down
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,6 @@ yarn test

Tests are placed in [src/test/](./src/test/)

## TODO

TODO

## License

MIT
1 change: 1 addition & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface AppConfig {
readonly isBehindProxy?: boolean
readonly groupToJoin: string
readonly groupsToLeave: string[]
readonly community?: string
}

const createApp = async (config: AppConfig) => {
Expand Down
2 changes: 2 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ if (!process.env.GROUP_TO_JOIN)
'Please specify GROUP_TO_JOIN in environment variables.',
)

export const community = process.env.COMMUNITY || undefined

export const groupToJoin = process.env.GROUP_TO_JOIN

export const groupsToLeave = envToArray(process.env.GROUPS_TO_LEAVE)
104 changes: 84 additions & 20 deletions src/controllers/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const verifyRequest: Middleware<{
const actor = ctx.request.body.actor.id
const authenticatedUser = ctx.state.user
const object = ctx.request.body.object.id
const { groupToJoin, groupsToLeave } = ctx.state.config
const { groupToJoin, groupsToLeave, community } = ctx.state.config
const task: 'Join' | 'Leave' = ctx.request.body.type

if (actor !== authenticatedUser)
Expand All @@ -22,17 +22,22 @@ export const verifyRequest: Middleware<{

switch (task) {
case 'Join': {
if (object !== groupToJoin)
const allowedObjects = [groupToJoin]
if (community) allowedObjects.push(community)

if (!allowedObjects.includes(object))
throw new ValidationError(
`Object does not match expected group.\nExpected: ${groupToJoin}\nActual: ${object}`,
`Object does not match expected group or community.\nExpected: ${allowedObjects.join(',')}\nActual: ${object}`,
[],
)
break
}
case 'Leave': {
if (!groupsToLeave.includes(object))
const allowedObjects = [...groupsToLeave]
if (community) allowedObjects.push(community)
if (!allowedObjects.includes(object))
throw new ValidationError(
`Object does not match any of the expected groups.\nExpected: ${groupsToLeave.join(',')}\nActual: ${object}`,
`Object does not match any of the expected groups or community.\nExpected: ${allowedObjects.join(',')}\nActual: ${object}`,
[],
)
break
Expand Down Expand Up @@ -67,7 +72,7 @@ const joinGroup: Middleware<{
user: string
config: AppConfig
}> = async ctx => {
const group = ctx.request.body.object.id
const group = ctx.state.config.groupToJoin

const authFetch = await getAuthenticatedFetch(ctx.state.config.webId)
const response = await authFetch(group, {
Expand All @@ -93,23 +98,82 @@ const leaveGroup: Middleware<{
user: string
config: AppConfig
}> = async ctx => {
const group = ctx.request.body.object.id
const groupsToLeave: string[] = []

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}>;
// if object is a community, try removing person from all configured groups
if (ctx.request.body.object.id === ctx.state.config.community)
groupsToLeave.push(...ctx.state.config.groupsToLeave)
// other remove person from the specified group only
else groupsToLeave.push(ctx.request.body.object.id)

// attempt all defined removals
const settledResults = await Promise.allSettled(
groupsToLeave.map(async (group: string) => {
const authFetch = await getAuthenticatedFetch(ctx.state.config.webId)
return 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,
)
// process results
const results = settledResults.reduce<{
// successful removals
success: { group: string; response: Response }[]
// http error responses
httpError: { group: string; response: Response }[]
// thrown errors
error: { group: string; error: unknown }[]
}>(
(results, result, i) => {
const group = groupsToLeave[i]
// this shouldn't happen, but skip just in case
if (!group) return results

ctx.status = 200
if (result.status === 'rejected') {
results.error.push({ group, error: result.reason })
} else if (result.value.ok) {
results.success.push({ group, response: result.value })
} else results.httpError.push({ group, response: result.value })

return results
},
{ success: [], httpError: [], error: [] },
)

ctx.body = {
successes: results.success.map(r => r.group),
conflicts: results.httpError
.filter(r => r.response.status === 409)
.map(r => r.group),
errors: [
...results.httpError
.filter(r => r.response.status !== 409)
.map(r => r.group),
...results.error.map(r => r.group),
],
}

// if some requests succeeded, return success
if (results.success.length > 0) ctx.status = 200
// if there is some unexpected failure, return internal server error
else if (
results.error.length > 0 ||
results.httpError.some(result => result.response.status !== 409)
)
ctx.status = 500
// if all errors are 409 Conflict, return 409 Conflict
// this is typically a result of trying to remove missing triple(s)
// https://solidproject.org/TR/protocol#server-patch-n3-semantics-deletions-non-empty-all-triples
else if (
results.httpError.length > 0 &&
results.httpError.every(result => result.response.status === 409)
)
ctx.status = 409
// and nothing else should be left over
else throw new Error('Unexpected error: unexpected condition')
}
10 changes: 6 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
/* eslint-disable no-console */
import { createApp } from './app.js'
import {
community,
groupsToLeave,
groupToJoin,
isBehindProxy,
port,
webId,
} from './config/index.js'

createApp({ webId, isBehindProxy, groupToJoin, groupsToLeave }).then(app =>
app.listen(port, async () => {
console.log(`community inbox service is listening on port ${port}`)
}),
createApp({ webId, isBehindProxy, groupToJoin, groupsToLeave, community }).then(
app =>
app.listen(port, async () => {
console.log(`community inbox service is listening on port ${port}`)
}),
)
28 changes: 28 additions & 0 deletions src/test/join.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,32 @@ describe('Joining the community', () => {
// check that a Location header is present
expect(response.headers.get('location')).toBe(ctx.community.group)
})

test<TestContext>('[community instead of group] 200 should add the actor to the predefined group', async ctx => {
const person = ctx.people[0]

// check that the person is not in the group
expect(await checkMembership(person.webId, ctx.community.group)).toBe(false)

// send request to the app
const response = await person.fetch(`${ctx.app.origin}/inbox`, {
method: 'POST',
headers: { 'content-type': 'application/ld+json' },
body: JSON.stringify({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Join',
actor: { type: 'Person', id: person.webId },
object: { type: 'Group', id: ctx.community.community },
}),
})

// receive response 200
expect(response.status).toBe(200)

// check that the person is in the group now
expect(await checkMembership(person.webId, ctx.community.group)).toBe(true)

// check that a Location header is present
expect(response.headers.get('location')).toBe(ctx.community.group)
})
})
25 changes: 25 additions & 0 deletions src/test/leave.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,29 @@ describe('Leaving the community', () => {
// check that the person is not in the group
expect(await checkMembership(person.webId, ctx.community.group)).toBe(false)
})

test<TestContext>("[object is community] should remove the actor from all predefined community's groups", async ctx => {
const person = ctx.people[2]

// check that the person is in the group
expect(await checkMembership(person.webId, ctx.community.group)).toBe(true)

// send request to the app
const response = await person.fetch(`${ctx.app.origin}/inbox`, {
method: 'POST',
headers: { 'content-type': 'application/ld+json' },
body: JSON.stringify({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Leave',
actor: { type: 'Person', id: person.webId },
object: { type: 'Group', id: ctx.community.community },
}),
})

// receive response 200
expect(response.status).toBe(200)

// check that the person is not in the group
expect(await checkMembership(person.webId, ctx.community.group)).toBe(false)
})
})
1 change: 1 addition & 0 deletions src/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ beforeEach<TestContext>(async ctx => {
webId: ctx.app.webId,
groupToJoin: ctx.community.group,
groupsToLeave: [ctx.community.group],
community: ctx.community.community,
})

server = await new Promise(resolve => {
Expand Down

0 comments on commit 5bd4cc3

Please sign in to comment.