Skip to content
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
12 changes: 3 additions & 9 deletions apps/community/app/(board)/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'

import { hasAnyModeratorRight } from '@meith/authorization'
import { acceptsThreads, canHoldThreads } from '@meith/forums'
import { requireSlot } from '@meith/theme-kit'

Expand All @@ -12,6 +11,7 @@ import { liveAnnouncements } from '@/server/announcements'
import { getContainer } from '@/server/container'
import { getActor } from '@/server/context'
import { getViewerPreferences } from '@/server/viewer-preferences'
import { moderatorTargetFor } from '@/server/modcp'
import { currentTheme } from '@/server/theme'
import { decodeForumCursor, encodeForumCursor } from '@/view/forum-cursor'
import { FollowForm } from '@/components/account/subscription-forms'
Expand Down Expand Up @@ -159,7 +159,8 @@ export default async function ForumPage({
if (!authorizer.can(actor, 'thread.view', { forumId: id, forum: matrix }))
notFound()

const scope = authorizer.contentScope(actor, { forumId: id, forum: matrix })
const inlineTarget = await moderatorTargetFor(actor, id, matrix)
const scope = authorizer.contentScope(actor, inlineTarget)
const preferences = await getViewerPreferences()
const threadPage = await threads.listForum(id, {
...(after === undefined ? {} : { after }),
Expand All @@ -175,13 +176,6 @@ export default async function ForumPage({
acceptsThreads(forum) &&
authorizer.can(actor, 'thread.post', { forumId: id, forum: matrix })

const moderatorRights = await authorizer.moderatorRightsIn(actor, id)
const inlineTarget = {
forumId: id,
forum: matrix,
moderatorRights,
isForumModerator: hasAnyModeratorRight(moderatorRights),
}
const inlineRights = {
approve:
inlineModeration !== null &&
Expand Down
9 changes: 3 additions & 6 deletions apps/community/app/(board)/thread/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export async function generateMetadata({

const thread = await threads.findById(
id,
authorizer.contentScope(actor, { forumId: forum.id, forum: matrix }),
await authorizer.contentScopeIn(actor, forum.id, matrix),
)
if (!thread) return { title: 'Thread' }

Expand Down Expand Up @@ -195,10 +195,8 @@ export default async function ThreadPage({
)
notFound()

const scope = authorizer.contentScope(actor, {
forumId: forum.id,
forum: matrix,
})
const appointment = await moderatorTargetFor(actor, forum.id, matrix)
const scope = authorizer.contentScope(actor, appointment)
const thread = await threads.findById(id, scope)
if (!thread) notFound()

Expand Down Expand Up @@ -237,7 +235,6 @@ export default async function ThreadPage({
forum: matrix,
}))

const appointment = await moderatorTargetFor(actor, forum.id, matrix)
const own = { ...appointment, ownerId: actor.userId }
const others = { ...appointment, ownerId: -1 }
const writable = postWrites !== null
Expand Down
4 changes: 2 additions & 2 deletions apps/community/app/api/v1/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ async function threadScope(
if (!authorizer.can(actor, 'thread.view', { forumId: forum.id, forum: matrix })) return null

return {
scope: authorizer.contentScope(actor, { forumId: forum.id, forum: matrix }),
scope: await authorizer.contentScopeIn(actor, forum.id, matrix),
forumId: forum.id,
}
}
Expand Down Expand Up @@ -239,7 +239,7 @@ async function dispatch(
const cursor = decodeCursor<ThreadCursor>(url.searchParams.get('after'))
const page = await threads.listForum(forum.id, {
limit: pageLimit(url),
scope: authorizer.contentScope(actor, { forumId: forum.id, forum: matrix }),
scope: await authorizer.contentScopeIn(actor, forum.id, matrix),
...(cursor === null
? {}
: { after: { ...cursor, lastPostAt: new Date(cursor.lastPostAt) } }),
Expand Down
18 changes: 3 additions & 15 deletions apps/community/src/server/modcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import 'server-only'
import { cache } from 'react'

import {
hasAnyModeratorRight,
type Actor,
type ModeratedTarget,
type ModeratorRights,
} from '@meith/authorization'
import { ModerationQueue } from '@meith/moderation'
Expand Down Expand Up @@ -123,18 +123,6 @@ export async function moderatorTargetFor(
forum: Awaited<
ReturnType<ReturnType<typeof getContainer>['authorizer']['forumMatrix']>
>,
): Promise<{
forumId: number
forum: typeof forum
moderatorRights: ModeratorRights
isForumModerator: boolean
}> {
const { authorizer } = getContainer()
const moderatorRights = await authorizer.moderatorRightsIn(actor, forumId)
return {
forumId,
forum,
moderatorRights,
isForumModerator: hasAnyModeratorRight(moderatorRights),
}
): Promise<ModeratedTarget> {
return getContainer().authorizer.moderatorTargetIn(actor, forumId, forum)
}
32 changes: 31 additions & 1 deletion packages/authorization/src/authorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import {
type ForumPermissions,
} from '@meith/core'

import { NO_MODERATOR_RIGHTS, type ModeratorRights } from './types'
import {
NO_MODERATOR_RIGHTS,
hasAnyModeratorRight,
type ModeratorRights,
} from './types'

import { resolveForumMatrix, indexOverrides } from './resolve'
import type {
Action,
Actor,
AuthorizationSource,
ModeratedTarget,
NumericGlobalPermission,
Target,
Visible,
Expand Down Expand Up @@ -287,6 +292,31 @@ export class Authorizer {
})
}

async moderatorTargetIn(
actor: Actor,
forumId: number,
forum: ForumPermissions,
): Promise<ModeratedTarget> {
const moderatorRights = await this.moderatorRightsIn(actor, forumId)
return {
forumId,
forum,
moderatorRights,
isForumModerator: hasAnyModeratorRight(moderatorRights),
}
}

async contentScopeIn(
actor: Actor,
forumId: number,
forum: ForumPermissions,
): Promise<ContentScope> {
return this.contentScope(
actor,
await this.moderatorTargetIn(actor, forumId, forum),
)
}

globalLimit(actor: Actor, key: NumericGlobalPermission): number {
const value = actor.global[key]
return typeof value === 'number' ? value : 0
Expand Down
177 changes: 177 additions & 0 deletions packages/authorization/src/content-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { describe, expect, it } from 'vitest'

import { PUBLIC_CONTENT, emptyPermissionSet, type PermissionSet } from '@meith/core'

import { Authorizer } from './authorizer'
import {
InMemoryAuthorizationSource,
type MemoryAppointment,
type MemoryBoard,
} from './memory-source'
import { combinePermissionSets } from './combine'
import type { Actor } from './types'

const GROUP = { registered: 2, superMod: 3, admin: 4 } as const
const FORUM = { category: 1, moderated: 2, elsewhere: 3, nested: 4 } as const

function set(over: Partial<PermissionSet>): PermissionSet {
return { ...emptyPermissionSet(), ...over }
}

const READ = { canView: true, canViewThreads: true } as const

function board(moderators: readonly MemoryAppointment[] = []): MemoryBoard {
return {
groups: [
{ groupId: GROUP.registered, permissions: set(READ) },
{ groupId: GROUP.superMod, permissions: set({ ...READ, isSuperModerator: true }) },
{ groupId: GROUP.admin, permissions: set({ ...READ, isAdministrator: true }) },
],
chains: {
[FORUM.category]: [FORUM.category],
[FORUM.moderated]: [FORUM.moderated, FORUM.category],
[FORUM.elsewhere]: [FORUM.elsewhere, FORUM.category],
[FORUM.nested]: [FORUM.nested, FORUM.moderated, FORUM.category],
},
overrides: [],
moderators,
}
}

function actor(groupIds: readonly number[], userId: number | null = 10): Actor {
const groups = board().groups.filter((g) => groupIds.includes(g.groupId))
return {
userId,
groupIds: [...groupIds],
primaryGroupId: groupIds[0] ?? null,
state: userId === null ? 'guest' : 'active',
global: combinePermissionSets(groups.map((g) => g.permissions)),
permissionVersion: 1,
}
}

const NONE: Omit<MemoryAppointment, 'forumId' | 'cascadeToSubforums'> = {
canApproveContent: false,
canEditPosts: false,
canSoftDeletePosts: false,
canRestorePosts: false,
canOpenCloseThreads: false,
canStickThreads: false,
canMoveThreads: false,
canMergeThreads: false,
canSplitThreads: false,
}

function appointment(over: Partial<MemoryAppointment>): MemoryAppointment {
return {
...NONE,
userId: 10,
forumId: FORUM.moderated,
cascadeToSubforums: false,
...over,
}
}

function authorizerFor(moderators: readonly MemoryAppointment[]): Authorizer {
return new Authorizer(new InMemoryAuthorizationSource(board(moderators)))
}

async function scopeIn(
who: Actor,
moderators: readonly MemoryAppointment[],
forumId: number = FORUM.moderated,
): Promise<ReturnType<Authorizer['contentScope']>> {
const authorizer = authorizerFor(moderators)
return authorizer.contentScopeIn(who, forumId, await authorizer.forumMatrix(who, forumId))
}

const APPOINTED = [appointment({ canApproveContent: true })]

describe('contentScopeIn', () => {
it('shows a per-forum moderator the held and deleted content of the forum they moderate', async () => {
const scope = await scopeIn(actor([GROUP.registered]), APPOINTED)

expect(scope.seesUnapproved).toBe(true)
expect(scope.seesDeleted).toBe(true)
expect([...scope.states].sort()).toEqual(['deleted', 'unapproved', 'visible'])
})

it('shows that same moderator nothing extra in a forum they do not moderate', async () => {
expect(await scopeIn(actor([GROUP.registered]), APPOINTED, FORUM.elsewhere)).toEqual(
PUBLIC_CONTENT,
)
})

it('follows the appointment into subforums only when it cascades', async () => {
expect(await scopeIn(actor([GROUP.registered]), APPOINTED, FORUM.nested)).toEqual(
PUBLIC_CONTENT,
)

const cascading = [appointment({ canApproveContent: true, cascadeToSubforums: true })]
expect((await scopeIn(actor([GROUP.registered]), cascading, FORUM.nested)).seesDeleted).toBe(
true,
)
})

it('widens for an appointment carrying any right at all, not only approval', async () => {
const scope = await scopeIn(actor([GROUP.registered]), [
appointment({ canSplitThreads: true }),
])

expect(scope.seesUnapproved).toBe(true)
expect(scope.seesDeleted).toBe(true)
})

it('leaves an ordinary member on the public scope', async () => {
expect(await scopeIn(actor([GROUP.registered]), APPOINTED.map(byGroup))).toEqual(
PUBLIC_CONTENT,
)
expect(await scopeIn(actor([GROUP.registered]), [])).toEqual(PUBLIC_CONTENT)
})

it('leaves a guest on the public scope', async () => {
expect(await scopeIn(actor([GROUP.registered], null), APPOINTED)).toEqual(PUBLIC_CONTENT)
expect(await scopeIn(actor([GROUP.registered], null), [])).toEqual(PUBLIC_CONTENT)
})

it('still widens for a group that holds the columns outright, with no appointment', async () => {
const authorizer = new Authorizer(
new InMemoryAuthorizationSource({
...board([]),
groups: [
{
groupId: GROUP.registered,
permissions: set({ ...READ, canViewUnapproved: true, canViewDeleted: true }),
},
],
}),
)
const who = actor([GROUP.registered])
const scope = await authorizer.contentScopeIn(
who,
FORUM.moderated,
await authorizer.forumMatrix(who, FORUM.moderated),
)

expect(scope.seesUnapproved).toBe(true)
expect(scope.seesDeleted).toBe(true)
})

it('shows staff everything, as before', async () => {
for (const group of [GROUP.superMod, GROUP.admin]) {
const scope = await scopeIn(actor([GROUP.registered, group]), [])
expect(scope.seesUnapproved).toBe(true)
expect(scope.seesDeleted).toBe(true)
}
})

it('shows a banned member nothing extra, appointment or not', async () => {
const banned = { ...actor([GROUP.registered]), state: 'banned' as const }
expect(await scopeIn(banned, APPOINTED)).toEqual(PUBLIC_CONTENT)
})
})

function byGroup(row: MemoryAppointment): MemoryAppointment {
const { userId: _userId, ...rest } = row
return { ...rest, groupId: 999 }
}
1 change: 1 addition & 0 deletions packages/authorization/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type {
ContentVisibility,
ForumOverride,
GroupDefaults,
ModeratedTarget,
NumericGlobalPermission,
Target,
Visible,
Expand Down
7 changes: 7 additions & 0 deletions packages/authorization/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ export interface Target {
readonly passwordSatisfied?: boolean
}

export interface ModeratedTarget extends Target {
readonly forumId: number
readonly forum: ForumPermissions
readonly moderatorRights: ModeratorRights
readonly isForumModerator: boolean
}

export interface GroupDefaults {
readonly groupId: number
readonly permissions: PermissionSet
Expand Down
Loading