diff --git a/apps/community/app/(board)/[slug]/page.tsx b/apps/community/app/(board)/[slug]/page.tsx index 42d673b5..253263ab 100644 --- a/apps/community/app/(board)/[slug]/page.tsx +++ b/apps/community/app/(board)/[slug]/page.tsx @@ -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' @@ -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' @@ -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 }), @@ -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 && diff --git a/apps/community/app/(board)/thread/[slug]/page.tsx b/apps/community/app/(board)/thread/[slug]/page.tsx index 33f7e1c7..c05fa9ad 100644 --- a/apps/community/app/(board)/thread/[slug]/page.tsx +++ b/apps/community/app/(board)/thread/[slug]/page.tsx @@ -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' } @@ -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() @@ -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 diff --git a/apps/community/app/api/v1/[...path]/route.ts b/apps/community/app/api/v1/[...path]/route.ts index 79a699a4..1314d143 100644 --- a/apps/community/app/api/v1/[...path]/route.ts +++ b/apps/community/app/api/v1/[...path]/route.ts @@ -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, } } @@ -239,7 +239,7 @@ async function dispatch( const cursor = decodeCursor(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) } }), diff --git a/apps/community/src/server/modcp.ts b/apps/community/src/server/modcp.ts index 4321ce79..8e005bb0 100644 --- a/apps/community/src/server/modcp.ts +++ b/apps/community/src/server/modcp.ts @@ -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' @@ -123,18 +123,6 @@ export async function moderatorTargetFor( forum: Awaited< ReturnType['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 { + return getContainer().authorizer.moderatorTargetIn(actor, forumId, forum) } diff --git a/packages/authorization/src/authorizer.ts b/packages/authorization/src/authorizer.ts index 2a16f321..95d35cc6 100644 --- a/packages/authorization/src/authorizer.ts +++ b/packages/authorization/src/authorizer.ts @@ -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, @@ -287,6 +292,31 @@ export class Authorizer { }) } + async moderatorTargetIn( + actor: Actor, + forumId: number, + forum: ForumPermissions, + ): Promise { + const moderatorRights = await this.moderatorRightsIn(actor, forumId) + return { + forumId, + forum, + moderatorRights, + isForumModerator: hasAnyModeratorRight(moderatorRights), + } + } + + async contentScopeIn( + actor: Actor, + forumId: number, + forum: ForumPermissions, + ): Promise { + 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 diff --git a/packages/authorization/src/content-scope.test.ts b/packages/authorization/src/content-scope.test.ts new file mode 100644 index 00000000..bdeaca2f --- /dev/null +++ b/packages/authorization/src/content-scope.test.ts @@ -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 { + 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 = { + canApproveContent: false, + canEditPosts: false, + canSoftDeletePosts: false, + canRestorePosts: false, + canOpenCloseThreads: false, + canStickThreads: false, + canMoveThreads: false, + canMergeThreads: false, + canSplitThreads: false, +} + +function appointment(over: Partial): 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> { + 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 } +} diff --git a/packages/authorization/src/index.ts b/packages/authorization/src/index.ts index 55121c6f..56d64df2 100644 --- a/packages/authorization/src/index.ts +++ b/packages/authorization/src/index.ts @@ -13,6 +13,7 @@ export type { ContentVisibility, ForumOverride, GroupDefaults, + ModeratedTarget, NumericGlobalPermission, Target, Visible, diff --git a/packages/authorization/src/types.ts b/packages/authorization/src/types.ts index d1fb2953..e1ae654b 100644 --- a/packages/authorization/src/types.ts +++ b/packages/authorization/src/types.ts @@ -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