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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ The table below is generated from `apps/web/content/docs.manifest.json` by `pnpm
| Configuration & guides | [`guides/community/reputation.md`](./docs/guides/community/reputation.md) | Members rating each other: what a rating is worth, the four settings, the two that decide between a one-press thanks and a form, and why the totals cannot drift. |
| Configuration & guides | [`guides/community/search.md`](./docs/guides/community/search.md) | The four controls over the most expensive thing a visitor can ask for: switching search off without losing the index, the shortest word a query may rest on, and the two different limits on how often. |
| Configuration & guides | [`guides/community/word-filter.md`](./docs/guides/community/word-filter.md) | Rewriting words as a page renders, never in the stored post: what a rule is, why a pattern is a literal rather than a wildcard, and exactly which surfaces it covers. |
| Configuration & guides | [`guides/community/ban-filters.md`](./docs/guides/community/ban-filters.md) | Turning somebody away before an account exists: the three things a filter matches on, why a pattern is a glob rather than a regular expression, and every route into an account that consults one. |
| Configuration & guides | [`guides/operations/operating.md`](./docs/guides/operations/operating.md) | Routine checks, configuration, maintenance commands, backups, recovery, and troubleshooting for a running board. |
| Configuration & guides | [`guides/operations/monitoring.md`](./docs/guides/operations/monitoring.md) | Liveness versus readiness, the optional Prometheus metrics endpoint and what to alert on, distributed tracing, and shipping logs. |
| Configuration & guides | [`guides/operations/cookies-and-headers.md`](./docs/guides/operations/cookies-and-headers.md) | What the board puts in a visitor's browser and what it tells the browser to refuse: every cookie and why it exists, the nonce Content Security Policy, and the one environment variable that changes it. |
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type AccountStore,
type AuthConfig,
BAN_FILTERS_NOT_CONSULTED,
DEFAULT_AUTH_POLICY,
IdentityService,
resolveAuthPolicy,
Expand Down Expand Up @@ -69,7 +70,7 @@ export async function createContext(): Promise<CliContext> {
return {
db,
accounts: store,
identity: new IdentityService({ store, config }),
identity: new IdentityService({ store, config, banFilters: BAN_FILTERS_NOT_CONSULTED }),
forums: new PostgresForumRepository(db),
settings,
admin,
Expand Down
2 changes: 2 additions & 0 deletions apps/community/app/(board)/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { BOARD_MEASURE } from '@/components/shell/measure'
import { ViewTabs } from '@/components/shell/view-tabs'
import { liveAnnouncements } from '@/server/announcements'
import { getContainer } from '@/server/container'
import { activeWordFilter } from '@/server/content-admin'
import { getActor } from '@/server/context'
import { identitiesFor } from '@/server/group-identity'
import { getTranslator } from '@/server/i18n'
Expand Down Expand Up @@ -239,6 +240,7 @@ export default async function ForumPage({
now: new Date(),
t: await getTranslator(),
identities,
wordFilter: await activeWordFilter(),
})

const { subscriptions } = getContainer()
Expand Down
2 changes: 2 additions & 0 deletions apps/community/app/(board)/discover/[view]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { notFound } from 'next/navigation'
import { isAppError } from '@meith/core'
import { type DiscoveryViewModel, requireSlot, slotCopy } from '@meith/theme-kit'

import { activeWordFilter } from '@/server/content-admin'
import { getActor } from '@/server/context'
import { DISCOVER_PAGE, DISCOVERY_VIEWS, isDiscoveryView, runDiscovery } from '@/server/discovery'
import { getTranslator } from '@/server/i18n'
Expand Down Expand Up @@ -65,6 +66,7 @@ export default async function DiscoverPage({
isFirstPage: after === null,
now,
timeZone: preferences.timezone,
wordFilter: await activeWordFilter(),
} as const

let page: Awaited<ReturnType<typeof runDiscovery>>
Expand Down
4 changes: 3 additions & 1 deletion apps/community/app/(board)/online/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Metadata } from 'next'
import { Card, CardFooter, CardRows, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'

import { PanelPage } from '@/components/shell/panel-page'
import { activeWordFilter } from '@/server/content-admin'
import { getActor } from '@/server/context'
import { identitiesFor } from '@/server/group-identity'
import { getTranslator, tr } from '@/server/i18n'
Expand Down Expand Up @@ -37,6 +38,7 @@ export default async function OnlinePage() {
}

const identities = await identitiesFor(snapshot.members.map((member) => member.userId))
const wordFilter = await activeWordFilter()

return (
<PanelPage
Expand All @@ -63,7 +65,7 @@ export default async function OnlinePage() {
) : (
<CardRows>
{snapshot.members.map((member) => {
const where = locationOf(member, translator)
const where = locationOf(member, translator, wordFilter)
return (
<li
key={member.userId}
Expand Down
5 changes: 5 additions & 0 deletions apps/community/app/(board)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { liveAnnouncements } from '@/server/announcements'
import { LATEST_REFRESH_SECONDS, renderLatestPanels } from '@/server/board-latest'
import { refreshLatestPanels } from '@/server/board-latest-actions'
import { getContainer } from '@/server/container'
import { activeWordFilter } from '@/server/content-admin'
import { getActor } from '@/server/context'
import { identitiesFor } from '@/server/group-identity'
import { getTranslator, tr } from '@/server/i18n'
Expand Down Expand Up @@ -54,6 +55,8 @@ export default async function BoardIndexPage() {
]),
)

const wordFilter = await activeWordFilter()

const view = buildBoardIndexView({
rows,
visibleForumIds: new Set(listing.visibleForumIds),
Expand All @@ -63,6 +66,7 @@ export default async function BoardIndexPage() {
now,
t: translator,
identities,
wordFilter,
})

const announcements = await liveAnnouncements({
Expand Down Expand Up @@ -127,6 +131,7 @@ export default async function BoardIndexPage() {
now,
t: translator,
identities,
wordFilter,
}),
pluginContext,
)
Expand Down
8 changes: 7 additions & 1 deletion apps/community/app/(board)/subscriptions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { SubscriptionRowForm } from '@/components/account/subscription-forms'
import { BoardNotice } from '@/components/shell/board-notice'
import { PanelPage } from '@/components/shell/panel-page'
import { getContainer } from '@/server/container'
import { activeWordFilter } from '@/server/content-admin'
import { getActor } from '@/server/context'
import { getTranslator, tr } from '@/server/i18n'
import { followFormCopy } from '@/view/account-copy'
Expand Down Expand Up @@ -37,7 +38,12 @@ export default async function SubscriptionsPage({

const translator = await getTranslator()

const view = buildSubscriptionsView({ rows, now: new Date(), t: translator })
const view = buildSubscriptionsView({
rows,
now: new Date(),
t: translator,
wordFilter: await activeWordFilter(),
})
const notice = subscriptionNotice(query)

return (
Expand Down
7 changes: 4 additions & 3 deletions apps/community/app/(board)/thread/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { locatedHref } from '@/view/post-link'
import { leadingId } from '@/view/slug-id'
import { buildSubscriptionsView } from '@/view/subscriptions'
import { buildThreadView, revealedFrom, threadToolsHeading } from '@/view/thread-view'
import { filterWords } from '@/view/word-filter'

export async function generateMetadata({
params,
Expand Down Expand Up @@ -102,7 +103,7 @@ export async function generateMetadata({
})

const meta = await pageMetadata('/thread/[slug]', {
title: thread.title,
title: filterWords(thread.title, await activeWordFilter()),
description: t.t('threadPage.discussion', { forum: forum.title }),
canonical: links.canonical,
imageUrl: null,
Expand Down Expand Up @@ -424,7 +425,7 @@ export default async function ThreadPage({
opening === null
? null
: threadJsonLd({
title: thread.title,
title: filterWords(thread.title, wordFilter),
url: `/thread/${thread.id}-${thread.slug}`,
author: thread.authorUsername,
published: opening.createdAt,
Expand Down Expand Up @@ -662,7 +663,7 @@ export default async function ThreadPage({
forums: allForums,
forumId: forum.id,
visibleForumIds: new Set(visibleIds),
leaf: thread.title,
leaf: filterWords(thread.title, wordFilter),
homeLabel: (await getSettings()).get('board.name'),
})

Expand Down
2 changes: 2 additions & 0 deletions apps/community/app/(board)/thread/[slug]/reply/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ReplyForm } from '@/components/content/reply-form'
import { OnboardingBanner } from '@/components/shell/onboarding-banner'
import { attachmentLimits, canAttach } from '@/server/attachments'
import { getContainer } from '@/server/container'
import { activeWordFilter } from '@/server/content-admin'
import { getActor } from '@/server/context'
import { getTranslator, tr } from '@/server/i18n'
import { firstPostGuidance } from '@/server/onboarding'
Expand Down Expand Up @@ -76,6 +77,7 @@ export default async function ReplyPage({
const view = buildReplyView({
t: await getTranslator(),
thread: { id: target.threadId, title: target.title, slug: target.slug },
wordFilter: await activeWordFilter(),
errorMessage: locked ? (await getTranslator()).t('board.reply.locked') : null,
})

Expand Down
94 changes: 94 additions & 0 deletions apps/community/app/admin/users/ban-filters/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { Metadata } from 'next'

import { BAN_FILTER_TYPES } from '@meith/accounts'

import { NewBanFilterForm, RemoveBanFilterForm } from '@/components/admin/ban-filter-forms'
import { PANEL_CARD, PANEL_NOTE } from '@/components/shell/panel-list'
import { PanelPage } from '@/components/shell/panel-page'
import { adminPageContext } from '@/server/admin'
import { banFilterAuthors, boardBanFilters } from '@/server/ban-filter-admin'
import { getTranslator, tr } from '@/server/i18n'
import { BAN_FILTER_TYPE_KEY, banFilterAdminCopy } from '@/view/admin-ban-filter-copy'
import { formatDate } from '@/view/time'

export async function generateMetadata(): Promise<Metadata> {
return { title: await tr('page.ban-filters') }
}

export default async function AdminBanFiltersPage() {
if ((await adminPageContext()) === null) return null

const filters = await boardBanFilters().listForAdmin()
const authors = await banFilterAuthors(filters)
const translator = await getTranslator()
const copy = banFilterAdminCopy(translator)

const types = BAN_FILTER_TYPES.map((type) => ({
value: type,
label: translator.t(BAN_FILTER_TYPE_KEY[type]),
}))

return (
<PanelPage
back={{ href: '/admin/users', label: translator.t('adminUsers.allMembers') }}
title={await tr('page.ban-filters')}
lede={translator.t('adminBanFilters.lede')}
gap="loose"
>
<section className={PANEL_CARD}>
<h2 className="font-heading text-lg font-semibold">
{translator.t('adminBanFilters.held')}
</h2>

{filters.length === 0 ? (
<p className="text-sm text-muted-foreground">{translator.t('adminBanFilters.none')}</p>
) : (
<div className="flex flex-col divide-y divide-border">
{filters.map((filter) => (
<div
key={filter.id}
className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1 py-3"
>
<div className="flex flex-col gap-1">
<p className="text-sm">
<span className="font-medium">
{translator.t(BAN_FILTER_TYPE_KEY[filter.type])}
</span>{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
{filter.pattern}
</code>
</p>
{filter.note === null ? null : (
<p className="text-xs text-muted-foreground">{filter.note}</p>
)}
<p className="text-xs text-muted-foreground">
{filter.createdByUserId === null ||
authors.get(filter.createdByUserId) === undefined
? translator.t('adminBanFilters.addedOn', {
date: formatDate(filter.createdAt, translator).label,
})
: translator.t('adminBanFilters.addedBy', {
author: authors.get(filter.createdByUserId) ?? '',
date: formatDate(filter.createdAt, translator).label,
})}
</p>
</div>

<RemoveBanFilterForm id={filter.id} pattern={filter.pattern} copy={copy} />
</div>
))}
</div>
)}
</section>

<section className={PANEL_CARD}>
<h2 className="font-heading text-lg font-semibold">
{translator.t('adminBanFilters.add')}
</h2>
<NewBanFilterForm types={types} copy={copy} />
</section>

<p className={PANEL_NOTE}>{translator.t('adminBanFilters.globNote')}</p>
</PanelPage>
)
}
104 changes: 104 additions & 0 deletions apps/community/src/components/admin/ban-filter-forms.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
'use client'

import { useActionState } from 'react'

import { EMPTY_STATE } from '@/server/auth-form-state'
import { addBanFilterAction, removeBanFilterAction } from '@/server/ban-filter-admin-actions'

import { FormError, SubmitButton } from '../auth/form-controls'
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
import { INPUT, Saved } from './form-bits'

export interface BanFilterTypeOption {
readonly value: string
readonly label: string
}

export function NewBanFilterForm({
types,
copy,
}: {
types: readonly BanFilterTypeOption[]
copy: Copy
}) {
const [state, action] = useActionState(addBanFilterAction, EMPTY_STATE)

return (
<form action={action} className="flex flex-col gap-3" noValidate>
<FormError message={state.error} />
<Saved when={state.notice === 'created'}>{fromCopy(copy, 'adminBanFilter.added')}</Saved>

<div className="grid gap-3 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">{fromCopy(copy, 'adminBanFilter.type')}</span>
<select
name="type"
defaultValue={state.values?.type ?? types[0]?.value ?? ''}
className={INPUT}
>
{types.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">
{fromCopy(copy, 'adminBanFilter.typeHint')}
</span>
</label>

<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">{fromCopy(copy, 'adminBanFilter.pattern')}</span>
<input
name="pattern"
defaultValue={state.values?.pattern ?? ''}
className={INPUT}
placeholder={fromCopy(copy, 'adminBanFilter.patternPlaceholder')}
required
/>
<span className="text-xs text-muted-foreground">
{fromCopy(copy, 'adminBanFilter.patternHint')}
</span>
</label>
</div>

<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">{fromCopy(copy, 'adminBanFilter.note')}</span>
<input name="note" defaultValue={state.values?.note ?? ''} className={INPUT} />
<span className="text-xs text-muted-foreground">
{fromCopy(copy, 'adminBanFilter.noteHint')}
</span>
</label>

<div>
<SubmitButton>{fromCopy(copy, 'adminBanFilter.addFilter')}</SubmitButton>
</div>
</form>
)
}

export function RemoveBanFilterForm({
id,
pattern,
copy,
}: {
id: number
pattern: string
copy: Copy
}) {
const [state, action] = useActionState(removeBanFilterAction, EMPTY_STATE)

return (
<form action={action} className="flex flex-col items-end gap-1">
<FormError message={state.error} />
<input type="hidden" name="id" value={id} />
<button
type="submit"
className="text-xs text-destructive hover:underline"
aria-label={formatFromCopy(copy, 'adminBanFilter.removeFilter', { pattern })}
>
{fromCopy(copy, 'admin.remove')}
</button>
</form>
)
}
Loading