@meith/web 0.21.2 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/(board)/[slug]/page.tsx +2 -0
- package/app/(board)/discover/[view]/page.tsx +2 -0
- package/app/(board)/notifications/preferences/page.tsx +15 -1
- package/app/(board)/online/page.tsx +3 -1
- package/app/(board)/page.tsx +5 -0
- package/app/(board)/subscriptions/page.tsx +7 -1
- package/app/(board)/thread/[slug]/page.tsx +11 -4
- package/app/(board)/thread/[slug]/reply/page.tsx +2 -0
- package/app/(board)/unsubscribe/page.tsx +5 -3
- package/app/admin/api/marketplace/screenshot/route.ts +0 -8
- package/app/admin/users/ban-filters/page.tsx +94 -0
- package/app/admin/users/mail/page.tsx +1 -1
- package/app/layout.tsx +1 -3
- package/app/manifest.ts +1 -3
- package/bin/forum-web.mjs +1 -182
- package/bin/forum-web.test.ts +0 -22
- package/next.config.mjs +15 -29
- package/package.json +50 -49
- package/proxy.ts +0 -14
- package/src/components/account/notification-forms.tsx +45 -0
- package/src/components/admin/admin-nav.tsx +0 -4
- package/src/components/admin/ban-filter-forms.tsx +104 -0
- package/src/components/admin/marketplace-listing.tsx +0 -9
- package/src/components/auth/passkey-client.ts +0 -5
- package/src/components/auth/register-form.tsx +18 -0
- package/src/components/board/section-page.tsx +2 -0
- package/src/components/content/composer-ids.ts +0 -1
- package/src/components/content/markdown-editor.tsx +0 -7
- package/src/components/shell/demo-banner.tsx +0 -8
- package/src/config.ts +0 -11
- package/src/server/admin-actions.ts +0 -5
- package/src/server/antispam.ts +27 -14
- package/src/server/api/search.ts +1 -1
- package/src/server/api/subscriptions.ts +5 -1
- package/src/server/auth-actions.ts +12 -1
- package/src/server/auth-config.ts +0 -11
- package/src/server/auth-events.ts +0 -5
- package/src/server/ban-filter-admin-actions.ts +112 -0
- package/src/server/ban-filter-admin.ts +25 -0
- package/src/server/brand-assets.ts +2 -6
- package/src/server/container.ts +13 -11
- package/src/server/cookies.ts +0 -24
- package/src/server/demo-uploads.ts +0 -11
- package/src/server/demo.ts +0 -21
- package/src/server/federation-actions.ts +0 -6
- package/src/server/federation.ts +0 -7
- package/src/server/feed-builder.ts +2 -2
- package/src/server/feed-routes.ts +0 -5
- package/src/server/i18n.ts +1 -3
- package/src/server/install.ts +6 -1
- package/src/server/location-header.ts +0 -30
- package/src/server/mail-send.ts +0 -6
- package/src/server/marketplace-actions.ts +0 -6
- package/src/server/marketplace-admin.ts +0 -33
- package/src/server/navigation.ts +0 -5
- package/src/server/notification-actions.ts +25 -0
- package/src/server/onboarding.ts +0 -5
- package/src/server/plugin-admin-actions.ts +0 -11
- package/src/server/plugin-admin.ts +0 -3
- package/src/server/plugin-host.ts +15 -6
- package/src/server/plugin-pages.ts +3 -68
- package/src/server/plugin-routes.ts +1 -1
- package/src/server/plugin-runtime.ts +78 -0
- package/src/server/plugin-view.tsx +2 -2
- package/src/server/presence.ts +3 -34
- package/src/server/relations.ts +1 -3
- package/src/server/reply-core.ts +1 -0
- package/src/server/request-fingerprint.ts +0 -1
- package/src/server/search-page.ts +0 -4
- package/src/server/session-actions.ts +0 -1
- package/src/server/settings.ts +0 -11
- package/src/server/subscription-actions.ts +12 -4
- package/src/server/syndication.ts +0 -4
- package/src/server/test-container.ts +10 -1
- package/src/server/theme-admin.ts +2 -6
- package/src/server/thread-core.ts +6 -1
- package/src/server/two-factor-actions.ts +0 -5
- package/src/server/two-factor.ts +0 -6
- package/src/server/upgrade-notice.ts +1 -1
- package/src/view/account-copy.ts +5 -0
- package/src/view/admin-ban-filter-copy.ts +34 -0
- package/src/view/admin-nav.ts +4 -0
- package/src/view/auth-copy.ts +2 -0
- package/src/view/board-index.ts +10 -2
- package/src/view/board-latest.ts +2 -2
- package/src/view/discovery-view.ts +4 -1
- package/src/view/editor-toolbar.ts +0 -1
- package/src/view/forum-display.ts +16 -4
- package/src/view/navigation.ts +0 -4
- package/src/view/notification-menu.ts +0 -2
- package/src/view/notifications.ts +1 -0
- package/src/view/pager.ts +0 -6
- package/src/view/panel-nav.ts +0 -1
- package/src/view/post-form.ts +4 -1
- package/src/view/presence.ts +6 -2
- package/src/view/search-results.ts +1 -1
- package/src/view/security-activity.ts +0 -5
- package/src/view/subscriptions.ts +12 -3
- package/src/view/thread-view.ts +1 -1
- package/src/view/two-factor.ts +0 -5
- package/src/view/usercp.ts +1 -3
|
@@ -6,7 +6,10 @@ import { ValidationError } from '@meith/core'
|
|
|
6
6
|
import { msg } from '@meith/i18n'
|
|
7
7
|
import { parseSubscriptionTarget, SubscriptionService } from '@meith/subscriptions'
|
|
8
8
|
|
|
9
|
+
import { filterWords } from '@/view/word-filter'
|
|
10
|
+
|
|
9
11
|
import { getContainer } from '../container'
|
|
12
|
+
import { activeWordFilter } from '../content-admin'
|
|
10
13
|
import { type ApiResult, type ApiRoutes, bodyId, bodyText, notFound, requireUserId } from './http'
|
|
11
14
|
|
|
12
15
|
async function mayView(
|
|
@@ -48,6 +51,7 @@ export const SUBSCRIPTION_HANDLERS: ApiRoutes = [
|
|
|
48
51
|
const userId = requireUserId(actor)
|
|
49
52
|
const visibleForumIds = await getContainer().authorizer.visibleForumIds(actor)
|
|
50
53
|
const rows = await requireSubscriptions().list(userId, visibleForumIds)
|
|
54
|
+
const wordFilter = await activeWordFilter()
|
|
51
55
|
|
|
52
56
|
return {
|
|
53
57
|
status: 200,
|
|
@@ -55,7 +59,7 @@ export const SUBSCRIPTION_HANDLERS: ApiRoutes = [
|
|
|
55
59
|
data: rows.map((row) => ({
|
|
56
60
|
target: row.target,
|
|
57
61
|
targetId: row.targetId,
|
|
58
|
-
title: row.title,
|
|
62
|
+
title: row.target === 'thread' ? filterWords(row.title, wordFilter) : row.title,
|
|
59
63
|
href: row.href,
|
|
60
64
|
mode: row.mode,
|
|
61
65
|
createdAt: row.createdAt.toISOString(),
|
|
@@ -93,7 +93,13 @@ export async function registerAction(_prev: FormState, form: FormData): Promise<
|
|
|
93
93
|
const email = field(form, 'email')
|
|
94
94
|
const password = field(form, 'password')
|
|
95
95
|
const accepted = field(form, 'terms') !== ''
|
|
96
|
-
const
|
|
96
|
+
const announcements = field(form, 'announcements') !== ''
|
|
97
|
+
const values = {
|
|
98
|
+
username,
|
|
99
|
+
email,
|
|
100
|
+
...(accepted ? { terms: '1' } : {}),
|
|
101
|
+
...(announcements ? { announcements: '1' } : {}),
|
|
102
|
+
}
|
|
97
103
|
|
|
98
104
|
const identity = await configuredIdentity()
|
|
99
105
|
|
|
@@ -142,6 +148,11 @@ export async function registerAction(_prev: FormState, form: FormData): Promise<
|
|
|
142
148
|
|
|
143
149
|
if (fields !== null) await fields.applyRegistration(result.account.id, fieldValues)
|
|
144
150
|
|
|
151
|
+
const { memberSettings } = getContainer()
|
|
152
|
+
if (announcements && memberSettings !== null) {
|
|
153
|
+
await memberSettings.saveMassMailOptIn({ userId: result.account.id, optIn: true })
|
|
154
|
+
}
|
|
155
|
+
|
|
145
156
|
if (result.verificationToken !== undefined) {
|
|
146
157
|
verification = {
|
|
147
158
|
token: result.verificationToken,
|
|
@@ -38,17 +38,6 @@ export async function boardSessionConfig(): Promise<BoardSessionConfig> {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
42
|
-
* Applied last, over the stored settings, because on a demo the stored settings
|
|
43
|
-
* are whatever the last visitor left behind.
|
|
44
|
-
*
|
|
45
|
-
* The lockout exists to make guessing a password expensive. A demo's passwords
|
|
46
|
-
* are printed on the page, so there is nothing left to guess — and the counter
|
|
47
|
-
* is per account, which means one visitor mistyping `admin` five times locks the
|
|
48
|
-
* published login for everyone else for a quarter of an hour. Not disabled
|
|
49
|
-
* outright: this is still a login form on the open internet, and something
|
|
50
|
-
* hammering it should still meet a wall.
|
|
51
|
-
*/
|
|
52
41
|
function demoOverrides(config: AuthConfig): AuthConfig {
|
|
53
42
|
if (!env.DEMO_MODE) return config
|
|
54
43
|
|
|
@@ -8,11 +8,6 @@ import { requestFingerprint } from './request-fingerprint'
|
|
|
8
8
|
|
|
9
9
|
export const MEMBER_ACTIVITY_LIMIT = 20
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
* Never lets the log stop the thing it is recording. A sign-in that worked and
|
|
13
|
-
* went unrecorded is a gap in an audit trail; a sign-in refused because the
|
|
14
|
-
* audit trail was unwritable is an outage.
|
|
15
|
-
*/
|
|
16
11
|
export async function recordAuthEvent(input: {
|
|
17
12
|
readonly userId: number | null
|
|
18
13
|
readonly kind: AuthEventKind
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from 'next/cache'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
assertUsableFilter,
|
|
7
|
+
BAN_FILTER_TYPES,
|
|
8
|
+
type BanFilterType,
|
|
9
|
+
matchBanFilter,
|
|
10
|
+
} from '@meith/accounts'
|
|
11
|
+
import { ValidationError } from '@meith/core'
|
|
12
|
+
import { msg } from '@meith/i18n'
|
|
13
|
+
|
|
14
|
+
import { recordAdminAction, requireAdmin } from './admin'
|
|
15
|
+
import type { FormState } from './auth-form-state'
|
|
16
|
+
import { boardBanFilters } from './ban-filter-admin'
|
|
17
|
+
import { getContainer } from './container'
|
|
18
|
+
import { formStateReporter } from './form-state-reporter'
|
|
19
|
+
import { trimmedText } from './form-values'
|
|
20
|
+
import { remoteAddress } from './request-fingerprint'
|
|
21
|
+
|
|
22
|
+
const BAN_FILTER_SCREEN = '/admin/users/ban-filters'
|
|
23
|
+
|
|
24
|
+
const toFormState = formStateReporter('ban-filter-admin', 'ban filter write failed')
|
|
25
|
+
|
|
26
|
+
function refreshBanFilterScreen(): void {
|
|
27
|
+
revalidatePath(BAN_FILTER_SCREEN)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function filterType(form: FormData): BanFilterType {
|
|
31
|
+
const value = trimmedText(form, 'type')
|
|
32
|
+
if (!(BAN_FILTER_TYPES as readonly string[]).includes(value)) {
|
|
33
|
+
throw new ValidationError(msg('error.app.kind-ban-filter'))
|
|
34
|
+
}
|
|
35
|
+
return value as BanFilterType
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function filterId(form: FormData): number {
|
|
39
|
+
const id = Number(trimmedText(form, 'id'))
|
|
40
|
+
if (!Number.isSafeInteger(id) || id <= 0) {
|
|
41
|
+
throw new ValidationError(msg('error.app.such-ban-filter'))
|
|
42
|
+
}
|
|
43
|
+
return id
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function assertItWouldNotLockYouOut(
|
|
47
|
+
userId: number,
|
|
48
|
+
type: BanFilterType,
|
|
49
|
+
pattern: string,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const account = await getContainer().accountStore.accounts.findById(userId)
|
|
52
|
+
const ip = await remoteAddress()
|
|
53
|
+
|
|
54
|
+
const matched = matchBanFilter([{ id: 0, type, pattern }], {
|
|
55
|
+
...(account === null ? {} : { username: account.username, email: account.email }),
|
|
56
|
+
...(ip === null ? {} : { ip }),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
if (matched !== null) throw new ValidationError(msg('error.app.filter-match-you-lock-out'))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function addBanFilterAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
63
|
+
const values = {
|
|
64
|
+
type: trimmedText(form, 'type'),
|
|
65
|
+
pattern: trimmedText(form, 'pattern'),
|
|
66
|
+
note: trimmedText(form, 'note'),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const context = await requireAdmin()
|
|
71
|
+
|
|
72
|
+
const type = filterType(form)
|
|
73
|
+
const pattern = trimmedText(form, 'pattern')
|
|
74
|
+
const note = trimmedText(form, 'note')
|
|
75
|
+
|
|
76
|
+
assertUsableFilter(type, pattern)
|
|
77
|
+
await assertItWouldNotLockYouOut(context.userId, type, pattern)
|
|
78
|
+
|
|
79
|
+
const id = await boardBanFilters().create({
|
|
80
|
+
type,
|
|
81
|
+
pattern,
|
|
82
|
+
note: note === '' ? null : note,
|
|
83
|
+
createdByUserId: context.userId,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
refreshBanFilterScreen()
|
|
87
|
+
await recordAdminAction({
|
|
88
|
+
action: 'user.ban_filter_added',
|
|
89
|
+
detail: { filterId: id, type, pattern },
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
return { notice: 'created' }
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return toFormState(err, values)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function removeBanFilterAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
99
|
+
try {
|
|
100
|
+
await requireAdmin()
|
|
101
|
+
const id = filterId(form)
|
|
102
|
+
|
|
103
|
+
await boardBanFilters().remove(id)
|
|
104
|
+
|
|
105
|
+
refreshBanFilterScreen()
|
|
106
|
+
await recordAdminAction({ action: 'user.ban_filter_removed', detail: { filterId: id } })
|
|
107
|
+
|
|
108
|
+
return { notice: 'removed' }
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return toFormState(err)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import type { BanFilterAdminRepository, BanFilterRecord } from '@meith/accounts'
|
|
4
|
+
|
|
5
|
+
import { getContainer } from './container'
|
|
6
|
+
|
|
7
|
+
export function boardBanFilters(): BanFilterAdminRepository {
|
|
8
|
+
return getContainer().banFilters
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function banFilterAuthors(
|
|
12
|
+
filters: readonly BanFilterRecord[],
|
|
13
|
+
): Promise<ReadonlyMap<number, string>> {
|
|
14
|
+
const { accountStore } = getContainer()
|
|
15
|
+
const names = new Map<number, string>()
|
|
16
|
+
|
|
17
|
+
for (const id of new Set(filters.map((row) => row.createdByUserId))) {
|
|
18
|
+
if (id === null || names.has(id)) continue
|
|
19
|
+
|
|
20
|
+
const account = await accountStore.accounts.findById(id)
|
|
21
|
+
if (account !== null) names.set(id, account.username)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return names
|
|
25
|
+
}
|
|
@@ -89,15 +89,11 @@ export async function loadBrandInfo(): Promise<BrandInfo> {
|
|
|
89
89
|
const settings = await getSettings()
|
|
90
90
|
title = settings.get('board.name').trim() || BOARD_TITLE
|
|
91
91
|
description = settings.get('board.description').trim()
|
|
92
|
-
} catch {
|
|
93
|
-
/* ignore */
|
|
94
|
-
}
|
|
92
|
+
} catch {}
|
|
95
93
|
|
|
96
94
|
try {
|
|
97
95
|
tokens = (await getBoardThemeStyle()).defaultTokens
|
|
98
|
-
} catch {
|
|
99
|
-
/* ignore */
|
|
100
|
-
}
|
|
96
|
+
} catch {}
|
|
101
97
|
|
|
102
98
|
return {
|
|
103
99
|
title,
|
package/src/server/container.ts
CHANGED
|
@@ -4,6 +4,7 @@ import forumConfig from '@board/config'
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
type AccountStore,
|
|
7
|
+
type BanFilterAdminRepository,
|
|
7
8
|
type BanLookup,
|
|
8
9
|
BanService,
|
|
9
10
|
createMemoryStore,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
IdentityService,
|
|
12
13
|
type MemberProfileRepository,
|
|
13
14
|
type MemberSettingsRepository,
|
|
15
|
+
MemoryBanFilters,
|
|
14
16
|
SessionService,
|
|
15
17
|
} from '@meith/accounts'
|
|
16
18
|
import type { AdminLogRepository, AdminSessionRepository } from '@meith/admin'
|
|
@@ -33,6 +35,7 @@ import {
|
|
|
33
35
|
PostgresAttachmentRepository,
|
|
34
36
|
PostgresAuthorizationSource,
|
|
35
37
|
PostgresAvatarRepository,
|
|
38
|
+
PostgresBanFilterRepository,
|
|
36
39
|
PostgresBanRepository,
|
|
37
40
|
PostgresDraftRepository,
|
|
38
41
|
PostgresForumRepository,
|
|
@@ -118,6 +121,7 @@ export interface Container {
|
|
|
118
121
|
readonly identity: IdentityService
|
|
119
122
|
readonly sessions: SessionService
|
|
120
123
|
readonly banLookup: BanLookup | null
|
|
124
|
+
readonly banFilters: BanFilterAdminRepository
|
|
121
125
|
readonly forums: ForumRepository
|
|
122
126
|
readonly threads: ThreadRepository
|
|
123
127
|
readonly threadWrites: (ThreadWriteRepository & ReplyWriteRepository) | null
|
|
@@ -204,7 +208,7 @@ function buildFixture(onBypass: (e: BypassEvent) => void): Container {
|
|
|
204
208
|
posts: new FixturePostRepository(),
|
|
205
209
|
memberProfiles: new FixtureMemberProfileRepository(),
|
|
206
210
|
fixtureDataVersion: FIXTURE_DATA_VERSION,
|
|
207
|
-
...identityServices(store),
|
|
211
|
+
...identityServices(store, new MemoryBanFilters()),
|
|
208
212
|
accountStore: store,
|
|
209
213
|
dataSource: 'fixture',
|
|
210
214
|
}
|
|
@@ -216,16 +220,19 @@ function cached(inner: ForumRepository): ForumRepository {
|
|
|
216
220
|
|
|
217
221
|
function identityServices(
|
|
218
222
|
store: AccountStore,
|
|
223
|
+
banFilters: BanFilterAdminRepository,
|
|
219
224
|
bans?: BanLookup,
|
|
220
225
|
): {
|
|
221
226
|
identity: IdentityService
|
|
222
227
|
sessions: SessionService
|
|
223
228
|
banLookup: BanLookup | null
|
|
229
|
+
banFilters: BanFilterAdminRepository
|
|
224
230
|
} {
|
|
225
231
|
return {
|
|
226
232
|
identity: new IdentityService({
|
|
227
233
|
store,
|
|
228
234
|
config: AUTH_CONFIG,
|
|
235
|
+
banFilters,
|
|
229
236
|
secondFactor: enrolmentLookup(store.twoFactor),
|
|
230
237
|
...(bans === undefined ? {} : { bans }),
|
|
231
238
|
}),
|
|
@@ -235,6 +242,7 @@ function identityServices(
|
|
|
235
242
|
sessionLifetimeDays: SESSION_LIFETIME_DAYS,
|
|
236
243
|
}),
|
|
237
244
|
banLookup: bans ?? null,
|
|
245
|
+
banFilters,
|
|
238
246
|
}
|
|
239
247
|
}
|
|
240
248
|
|
|
@@ -287,7 +295,7 @@ function buildPostgres(onBypass: (e: BypassEvent) => void): Container {
|
|
|
287
295
|
threadViews,
|
|
288
296
|
fixtureDataVersion: null,
|
|
289
297
|
accountStore: store,
|
|
290
|
-
...identityServices(store, new PostgresBanRepository(db)),
|
|
298
|
+
...identityServices(store, new PostgresBanFilterRepository(db), new PostgresBanRepository(db)),
|
|
291
299
|
scheduler: withDemoReset(
|
|
292
300
|
buildSchedulerBundle({
|
|
293
301
|
queue: drivers().queue,
|
|
@@ -308,14 +316,6 @@ function buildPostgres(onBypass: (e: BypassEvent) => void): Container {
|
|
|
308
316
|
}
|
|
309
317
|
}
|
|
310
318
|
|
|
311
|
-
/**
|
|
312
|
-
* The reset is registered here rather than in `buildSchedulerBundle` because
|
|
313
|
-
* this is the only composition root with a cache to clear afterwards. The
|
|
314
|
-
* worker's `NextCacheDriver` is a different process holding a different map, so
|
|
315
|
-
* a reset it ran would leave this process serving the forum tree of a board
|
|
316
|
-
* that no longer exists — for up to the tree's 60-second TTL, on the one page
|
|
317
|
-
* every visitor lands on.
|
|
318
|
-
*/
|
|
319
319
|
function withDemoReset(bundle: SchedulerBundle, db: ReturnType<typeof getDb>): SchedulerBundle {
|
|
320
320
|
if (!env.DEMO_MODE) return bundle
|
|
321
321
|
|
|
@@ -367,6 +367,7 @@ export function getContainer(): Container {
|
|
|
367
367
|
cached.adminLog === undefined ||
|
|
368
368
|
cached.attachments === undefined ||
|
|
369
369
|
cached.avatars === undefined ||
|
|
370
|
+
cached.banFilters === undefined ||
|
|
370
371
|
typeof cached.memberProfiles?.findPublicById !== 'function' ||
|
|
371
372
|
typeof cached.accountStore?.identities?.listForUser !== 'function' ||
|
|
372
373
|
typeof cached.accountStore?.passkeys?.listForUser !== 'function' ||
|
|
@@ -383,10 +384,11 @@ export function getAuthorizer(): Authorizer {
|
|
|
383
384
|
}
|
|
384
385
|
|
|
385
386
|
export async function configuredIdentity(): Promise<IdentityService> {
|
|
386
|
-
const { accountStore, banLookup } = getContainer()
|
|
387
|
+
const { accountStore, banLookup, banFilters } = getContainer()
|
|
387
388
|
return new IdentityService({
|
|
388
389
|
store: accountStore,
|
|
389
390
|
config: await boardAuthConfig(),
|
|
391
|
+
banFilters,
|
|
390
392
|
secondFactor: enrolmentLookup(accountStore.twoFactor),
|
|
391
393
|
...(banLookup === null ? {} : { bans: banLookup }),
|
|
392
394
|
})
|
package/src/server/cookies.ts
CHANGED
|
@@ -3,20 +3,9 @@ export const REMEMBER_COOKIE = '__Host-fs_remember'
|
|
|
3
3
|
export const DEV_SESSION_COOKIE = 'fs_session'
|
|
4
4
|
export const DEV_REMEMBER_COOKIE = 'fs_remember'
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Counts one reader once, and carries nothing else.
|
|
8
|
-
*
|
|
9
|
-
* An opaque random value, first-party, readable by nobody but this board. It
|
|
10
|
-
* exists because "37 guests reading" is not derivable from a stateless request:
|
|
11
|
-
* without something that comes back, every page view is a stranger. It is never
|
|
12
|
-
* an identity — no code path turns it into an actor, and `locateSession()`
|
|
13
|
-
* refuses a row with no user — so the worst it can say about the person holding
|
|
14
|
-
* it is that they were here.
|
|
15
|
-
*/
|
|
16
6
|
export const GUEST_COOKIE = '__Host-fs_guest'
|
|
17
7
|
export const DEV_GUEST_COOKIE = 'fs_guest'
|
|
18
8
|
|
|
19
|
-
/** Long enough that a reader is one guest across a visit, short enough to expire. */
|
|
20
9
|
export const GUEST_COOKIE_DAYS = 1
|
|
21
10
|
|
|
22
11
|
export const ADMIN_COOKIE = 'fs_admin'
|
|
@@ -31,7 +20,6 @@ export const DEV_PASSKEY_COOKIE = 'fs_passkey'
|
|
|
31
20
|
export const SECOND_FACTOR_COOKIE = '__Host-fs_2fa'
|
|
32
21
|
export const DEV_SECOND_FACTOR_COOKIE = 'fs_2fa'
|
|
33
22
|
|
|
34
|
-
/** Long enough to read a consent screen, short enough that a stale one is gone. */
|
|
35
23
|
export const HANDSHAKE_MINUTES = 10
|
|
36
24
|
|
|
37
25
|
export function sessionCookieName(secure: boolean): string {
|
|
@@ -108,26 +96,14 @@ export function secondFactorCookieName(secure: boolean): string {
|
|
|
108
96
|
return secure ? SECOND_FACTOR_COOKIE : DEV_SECOND_FACTOR_COOKIE
|
|
109
97
|
}
|
|
110
98
|
|
|
111
|
-
/**
|
|
112
|
-
* A sign-in that has proved its password and nothing else. Strict, because
|
|
113
|
-
* every step that finishes it starts on this board — and short, because it
|
|
114
|
-
* stands in for a password already given.
|
|
115
|
-
*/
|
|
116
99
|
export function secondFactorCookie(expires: Date, secure: boolean): CookieAttrs {
|
|
117
100
|
return { httpOnly: true, secure, sameSite: 'strict', path: '/', expires }
|
|
118
101
|
}
|
|
119
102
|
|
|
120
|
-
/**
|
|
121
|
-
* Lax, not Strict: the identity provider sends the member back with a top-level
|
|
122
|
-
* GET, and a Strict cookie is not attached to a navigation that started on
|
|
123
|
-
* another site — the handshake would arrive with nothing to check the state
|
|
124
|
-
* against and every sign-in would fail.
|
|
125
|
-
*/
|
|
126
103
|
export function ssoCookie(secure: boolean): CookieAttrs {
|
|
127
104
|
return { ...base(secure), maxAge: HANDSHAKE_MINUTES * 60 }
|
|
128
105
|
}
|
|
129
106
|
|
|
130
|
-
/** Strict: nothing in the passkey exchange ever leaves this board. */
|
|
131
107
|
export function passkeyCookie(secure: boolean): CookieAttrs {
|
|
132
108
|
return {
|
|
133
109
|
httpOnly: true,
|
|
@@ -4,17 +4,6 @@ import { rm } from 'node:fs/promises'
|
|
|
4
4
|
|
|
5
5
|
import { env, logger } from '@meith/core'
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* Everything a visitor uploaded, thrown away with the board that referenced it.
|
|
9
|
-
*
|
|
10
|
-
* Only local storage is cleared. A demo pointed at an object store is not a
|
|
11
|
-
* configuration this project asks anyone to run, and quietly issuing bulk
|
|
12
|
-
* deletes against a bucket somebody else's board might share is a worse failure
|
|
13
|
-
* than leaving files behind.
|
|
14
|
-
*
|
|
15
|
-
* Kept apart from the rest of the demo helpers because the container calls this
|
|
16
|
-
* one, and the others call the container.
|
|
17
|
-
*/
|
|
18
7
|
export async function clearUploadedFiles(): Promise<void> {
|
|
19
8
|
if (env.FILESTORE_DRIVER !== 'local') {
|
|
20
9
|
logger({ module: 'demo' }).warn(
|
package/src/server/demo.ts
CHANGED
|
@@ -12,13 +12,6 @@ import {
|
|
|
12
12
|
|
|
13
13
|
import { getContainer } from './container'
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
* Refuses a change to a published demo login's password or email.
|
|
17
|
-
*
|
|
18
|
-
* Checked by user id, resolved to a username here, because the demo package
|
|
19
|
-
* cannot know the ids — they are handed out by Postgres at seed time and differ
|
|
20
|
-
* every reset. The username is the stable thing.
|
|
21
|
-
*/
|
|
22
15
|
export async function assertDemoAccountChangeable(
|
|
23
16
|
userId: number,
|
|
24
17
|
what: FrozenField,
|
|
@@ -31,13 +24,6 @@ export async function assertDemoAccountChangeable(
|
|
|
31
24
|
assertDemoAccountIsChangeable(account.username, what)
|
|
32
25
|
}
|
|
33
26
|
|
|
34
|
-
/**
|
|
35
|
-
* The administrator's own edit screen can rename a member, and renaming
|
|
36
|
-
* `admin` is the same lockout as changing its password by a longer route — the
|
|
37
|
-
* banner would go on naming a login that no longer exists. Only refused when
|
|
38
|
-
* the value actually differs, so an admin can still open the demo login's
|
|
39
|
-
* account screen and change its groups.
|
|
40
|
-
*/
|
|
41
27
|
export async function assertDemoIdentityUnchanged(
|
|
42
28
|
userId: number,
|
|
43
29
|
submitted: { readonly username: string; readonly email: string },
|
|
@@ -55,13 +41,6 @@ export async function assertDemoIdentityUnchanged(
|
|
|
55
41
|
}
|
|
56
42
|
}
|
|
57
43
|
|
|
58
|
-
/**
|
|
59
|
-
* The banner's content, or `null` on any board that is not a demo.
|
|
60
|
-
*
|
|
61
|
-
* Failure here is deliberately quiet. The banner is an aid, and a board that
|
|
62
|
-
* refuses to render because it could not work out when it next resets is worse
|
|
63
|
-
* than a board that renders without the countdown.
|
|
64
|
-
*/
|
|
65
44
|
export async function demoBannerModel(): Promise<DemoBanner | null> {
|
|
66
45
|
if (!env.DEMO_MODE) return null
|
|
67
46
|
|
|
@@ -26,12 +26,6 @@ const toFormState = formStateReporter(
|
|
|
26
26
|
'unexpected error while changing a sign-in',
|
|
27
27
|
)
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
* Only a credential the board would actually accept today counts as a way back
|
|
31
|
-
* in. A passkey on a board that has since switched passkeys off, or a link to a
|
|
32
|
-
* provider an operator has turned off, opens nothing — counting it would let a
|
|
33
|
-
* member unlink their way out of their own account in one click.
|
|
34
|
-
*/
|
|
35
29
|
async function usableWaysIn(userId: number): Promise<{
|
|
36
30
|
readonly usablePasskeys: number
|
|
37
31
|
readonly usableProviders: readonly string[]
|
package/src/server/federation.ts
CHANGED
|
@@ -50,13 +50,6 @@ export function federationOptions(settings: SettingsSnapshot): FederationOptions
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
54
|
-
* Every read here goes past the settings cache, because every one of them
|
|
55
|
-
* decides whether a sign-in may start rather than what a page displays. The
|
|
56
|
-
* cached snapshot is held per module instance, so a route handler can be a
|
|
57
|
-
* minute behind the render that invalidated it — long enough for a provider an
|
|
58
|
-
* operator has just switched on to answer that it is switched off.
|
|
59
|
-
*/
|
|
60
53
|
export async function federationProvider(id: string): Promise<IdentityProvider | null> {
|
|
61
54
|
if (!isProviderKind(id)) return null
|
|
62
55
|
if (getContainer().dataSource !== 'postgres') return null
|
|
@@ -27,7 +27,7 @@ export function feedFor(
|
|
|
27
27
|
function threadEntry(thread: FeedThread): FeedEntry {
|
|
28
28
|
return {
|
|
29
29
|
id: tagId('thread', thread.threadId),
|
|
30
|
-
title: thread.title,
|
|
30
|
+
title: filterWords(thread.title, wordFilter),
|
|
31
31
|
href: absolute(`/thread/${thread.threadId}-${thread.slug}`),
|
|
32
32
|
author: thread.authorUsername,
|
|
33
33
|
published: thread.createdAt,
|
|
@@ -39,7 +39,7 @@ export function feedFor(
|
|
|
39
39
|
function postEntry(post: FeedPost): FeedEntry {
|
|
40
40
|
return {
|
|
41
41
|
id: tagId('post', post.postId),
|
|
42
|
-
title: post.threadTitle,
|
|
42
|
+
title: filterWords(post.threadTitle, wordFilter),
|
|
43
43
|
href: absolute(postLink(`/thread/${post.threadId}-${post.threadSlug}`, post.postId)),
|
|
44
44
|
author: post.authorUsername,
|
|
45
45
|
published: post.createdAt,
|
|
@@ -15,11 +15,6 @@ export type FeedFormat = 'rss' | 'atom'
|
|
|
15
15
|
|
|
16
16
|
export type FeedScope = 'board' | 'forum' | 'thread'
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
* A plugin may reorder, drop, or add. An entry it adds keeps the board's own
|
|
20
|
-
* fields where one matches by href, and is otherwise taken as given — a feed
|
|
21
|
-
* is rendered as a guest, so everything in it is already public.
|
|
22
|
-
*/
|
|
23
18
|
async function filterEntries(
|
|
24
19
|
entries: readonly FeedEntry[],
|
|
25
20
|
feed: FeedScope,
|
package/src/server/i18n.ts
CHANGED
|
@@ -31,9 +31,7 @@ export const getLocale = cache(async (): Promise<ResolvedLocale> => {
|
|
|
31
31
|
let boardDefault: string | null = null
|
|
32
32
|
try {
|
|
33
33
|
boardDefault = (await getSettings()).get('display.default_locale')
|
|
34
|
-
} catch {
|
|
35
|
-
/* ignore */
|
|
36
|
-
}
|
|
34
|
+
} catch {}
|
|
37
35
|
|
|
38
36
|
return resolveLocale({
|
|
39
37
|
stored: preferences?.locale ?? null,
|
package/src/server/install.ts
CHANGED
|
@@ -2,6 +2,7 @@ import 'server-only'
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
type AuthConfig,
|
|
5
|
+
BAN_FILTERS_NOT_CONSULTED,
|
|
5
6
|
DEFAULT_AUTH_POLICY,
|
|
6
7
|
IdentityService,
|
|
7
8
|
rejectedField,
|
|
@@ -247,7 +248,11 @@ async function performInstall(input: InstallInput): Promise<readonly StepOutcome
|
|
|
247
248
|
defaultMemberGroupId: registered.id,
|
|
248
249
|
}
|
|
249
250
|
|
|
250
|
-
const identity = new IdentityService({
|
|
251
|
+
const identity = new IdentityService({
|
|
252
|
+
store: createPostgresAccountStore(db),
|
|
253
|
+
config,
|
|
254
|
+
banFilters: BAN_FILTERS_NOT_CONSULTED,
|
|
255
|
+
})
|
|
251
256
|
const result = await identity.register({
|
|
252
257
|
username: input.username,
|
|
253
258
|
email: input.email,
|
|
@@ -1,37 +1,7 @@
|
|
|
1
1
|
export const PATH_HEADER = 'x-forum-path'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* The query string that came with `PATH_HEADER`, leading `?` included, or
|
|
5
|
-
* absent when there was none.
|
|
6
|
-
*
|
|
7
|
-
* Separate from the path because the path has readers that must not see a
|
|
8
|
-
* query: a presence row records where somebody is, and `/admin/settings` is one
|
|
9
|
-
* place however many groups it can be filtered by. The panel rail is the reader
|
|
10
|
-
* that needs both — its settings sections differ only by `?group=`, so a rail
|
|
11
|
-
* given the path alone cannot say which one is open.
|
|
12
|
-
*/
|
|
13
3
|
export const QUERY_HEADER = 'x-forum-query'
|
|
14
4
|
|
|
15
|
-
/**
|
|
16
|
-
* Set by the middleware when it has just minted the guest cookie, meaning the
|
|
17
|
-
* client has not sent one back yet.
|
|
18
|
-
*
|
|
19
|
-
* It exists because the obvious test does not work: a cookie set on a
|
|
20
|
-
* `NextResponse.next()` response is reflected into the request the render sees,
|
|
21
|
-
* so neither `cookies()` nor the raw `Cookie` header can tell "the client kept
|
|
22
|
-
* this" from "we made it up a millisecond ago". Without the distinction every
|
|
23
|
-
* request from something that discards cookies wrote a presence row, which
|
|
24
|
-
* measured as twelve rows from twelve crawler requests.
|
|
25
|
-
*
|
|
26
|
-
* The middleware sets it or deletes it on every request, so a client cannot
|
|
27
|
-
* supply its own.
|
|
28
|
-
*/
|
|
29
5
|
export const FRESH_GUEST_HEADER = 'x-forum-fresh-guest'
|
|
30
6
|
|
|
31
|
-
/**
|
|
32
|
-
* The per-request Content-Security-Policy nonce minted by `proxy.ts`, for the
|
|
33
|
-
* handful of inline `<script>` tags the app emits itself. Next reads the nonce
|
|
34
|
-
* out of the policy header on its own; this one is for our tags. See
|
|
35
|
-
* `docs/operating.md`.
|
|
36
|
-
*/
|
|
37
7
|
export const NONCE_HEADER = 'x-nonce'
|
package/src/server/mail-send.ts
CHANGED
|
@@ -6,12 +6,6 @@ import { drivers } from '@meith/drivers'
|
|
|
6
6
|
|
|
7
7
|
import { emitEvent, filterView } from './plugin-view'
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* Every message the board sends from a request, past the plugins first. A
|
|
11
|
-
* plugin may rewrite the recipient, the subject or either body, or return null
|
|
12
|
-
* to drop the message — which is silent, because the member asked the board to
|
|
13
|
-
* do something and the board's answer is the page, not the mail.
|
|
14
|
-
*/
|
|
15
9
|
export async function sendBoardMail(template: string, mail: OutgoingMail): Promise<boolean> {
|
|
16
10
|
const proposed = await filterView(
|
|
17
11
|
'mail.send.before',
|
|
@@ -9,12 +9,6 @@ import type { FormState } from './auth-form-state'
|
|
|
9
9
|
import { tr } from './i18n'
|
|
10
10
|
import { refreshMarketplaceNow } from './marketplace-admin'
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* The Browse tab's "Refresh" button. Runs the exact same fetch, validate,
|
|
14
|
-
* cache, notify pass as the daily task (`refreshMarketplaceNow`, shared with
|
|
15
|
-
* `packages/runtime`'s task worker) — this is the "on demand" half of that
|
|
16
|
-
* one behaviour, not a second implementation of it.
|
|
17
|
-
*/
|
|
18
12
|
export async function refreshMarketplaceAction(
|
|
19
13
|
_prev: FormState,
|
|
20
14
|
_form: FormData,
|