@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
package/bin/forum-web.test.ts
CHANGED
|
@@ -36,14 +36,6 @@ describe('rebaseGlobalsCssSources', () => {
|
|
|
36
36
|
expect(rewritten).toContain('.foo { color: red; }')
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
* Against the real shipped file, not a fixture: this rebase now runs on
|
|
41
|
-
* every materialization including the default one, where it must be a
|
|
42
|
-
* no-op. Every `@source` in that file is written relative to the workspace
|
|
43
|
-
* root, and this only stays a no-op while that holds — a `@source` meaning
|
|
44
|
-
* anything else (`../fonts`, for `src/fonts`) would be silently retargeted
|
|
45
|
-
* at the workspace root, and fails here instead.
|
|
46
|
-
*/
|
|
47
39
|
const shippedGlobalsCss = readFileSync(
|
|
48
40
|
new URL('../src/styles/globals.css', import.meta.url),
|
|
49
41
|
'utf8',
|
|
@@ -87,13 +79,6 @@ describe('rebaseGlobalsCssSources', () => {
|
|
|
87
79
|
).toBe(css)
|
|
88
80
|
})
|
|
89
81
|
|
|
90
|
-
/**
|
|
91
|
-
* MEI-131. A scaffolded board has none of the directories these sources
|
|
92
|
-
* name — the same code is installed under `node_modules/@meith` — and
|
|
93
|
-
* Tailwind emits no utilities for a source it cannot scan while still
|
|
94
|
-
* exiting 0, so the failure is a board that serves an unstyled page rather
|
|
95
|
-
* than a build that stops.
|
|
96
|
-
*/
|
|
97
82
|
describe('in a board, where none of those directories exist', () => {
|
|
98
83
|
const inBoard = (path: string) => path.includes('node_modules')
|
|
99
84
|
|
|
@@ -157,13 +142,6 @@ describe('rebaseGlobalsCssSources', () => {
|
|
|
157
142
|
})
|
|
158
143
|
})
|
|
159
144
|
|
|
160
|
-
/**
|
|
161
|
-
* `forum-web start --at-root` materializes and then stops, because there is
|
|
162
|
-
* no standalone build for it to exec — which makes it the whole
|
|
163
|
-
* materialization path in under a tenth of a second, with no `next build`
|
|
164
|
-
* anywhere near it. Materialization is the part that has been wrong; the
|
|
165
|
-
* build is not.
|
|
166
|
-
*/
|
|
167
145
|
const BIN = fileURLToPath(new URL('./forum-web.mjs', import.meta.url))
|
|
168
146
|
|
|
169
147
|
function materializeAtRoot(dir: string) {
|
package/next.config.mjs
CHANGED
|
@@ -1,33 +1,13 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
|
|
5
5
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* Two directories up from this file's own location by default — correct
|
|
9
|
-
* where `forum-web` materializes at `.meith/app` and where this file sits in
|
|
10
|
-
* `apps/community` unmaterialized. `boards/stock` and `forum-web --at-root`
|
|
11
|
-
* are both at other depths, and neither relies on this default:
|
|
12
|
-
* `FORUM_WORKSPACE_ROOT` is set for the first by that board's own scripts
|
|
13
|
-
* and passed on by `forum-web` in every case, so this fallback only applies
|
|
14
|
-
* when the file is read outside that bin entirely (see
|
|
15
|
-
* docs/architecture.md, "The stock board"; docs/development.md, "Consuming
|
|
16
|
-
* the board from a workspace").
|
|
17
|
-
*/
|
|
18
7
|
const workspaceRoot = process.env.FORUM_WORKSPACE_ROOT
|
|
19
8
|
? path.resolve(process.env.FORUM_WORKSPACE_ROOT)
|
|
20
9
|
: path.join(here, '../../')
|
|
21
10
|
|
|
22
|
-
/**
|
|
23
|
-
* The relative equivalent of `workspaceRoot`, for `outputFileTracingIncludes`
|
|
24
|
-
* below — see docs/development.md, "Consuming the board from a workspace",
|
|
25
|
-
* for why that option needs a path relative to this file rather than an
|
|
26
|
-
* absolute one. It is `.` rather than the empty string when this file already
|
|
27
|
-
* sits at the workspace root, which is what `forum-web build --at-root`
|
|
28
|
-
* materializes (same section) — an empty prefix would make the glob below
|
|
29
|
-
* read as an absolute path and match nothing.
|
|
30
|
-
*/
|
|
31
11
|
const upToWorkspaceRoot = path.relative(here, workspaceRoot).split(path.sep).join('/') || '.'
|
|
32
12
|
|
|
33
13
|
const loadedEnvFiles = []
|
|
@@ -42,6 +22,8 @@ if (process.env.NODE_ENV !== 'production' && loadedEnvFiles.length > 0) {
|
|
|
42
22
|
console.log(`- Environments: ${loadedEnvFiles.join(', ')} (${workspaceRoot})`)
|
|
43
23
|
}
|
|
44
24
|
|
|
25
|
+
const FRAMEWORK_PACKAGES = ['next', 'react', 'react-dom']
|
|
26
|
+
|
|
45
27
|
const nextConfig = {
|
|
46
28
|
...(process.env.VERCEL ? {} : { output: 'standalone' }),
|
|
47
29
|
poweredByHeader: false,
|
|
@@ -58,11 +40,9 @@ const nextConfig = {
|
|
|
58
40
|
'nodemailer',
|
|
59
41
|
],
|
|
60
42
|
outputFileTracingRoot: workspaceRoot,
|
|
61
|
-
/** See docs/development.md, "Consuming the board from a workspace", for why this is set. */
|
|
62
43
|
turbopack: {
|
|
63
44
|
root: workspaceRoot,
|
|
64
45
|
},
|
|
65
|
-
/** Works around a gap in Next's own tracing — see docs/development.md, "Consuming the board from a workspace". */
|
|
66
46
|
outputFileTracingIncludes: {
|
|
67
47
|
'/**': [
|
|
68
48
|
`${upToWorkspaceRoot}/node_modules/.pnpm/@swc+helpers@0.5.23/node_modules/@swc/helpers/**/*`,
|
|
@@ -72,12 +52,6 @@ const nextConfig = {
|
|
|
72
52
|
images: {
|
|
73
53
|
unoptimized: true,
|
|
74
54
|
},
|
|
75
|
-
/**
|
|
76
|
-
* Every `@meith/*` package this app's dependency graph reaches, not just the
|
|
77
|
-
* ones apps/community imports directly — see docs/development.md,
|
|
78
|
-
* "Consuming the board from a workspace", and docs/release.md, "They ship
|
|
79
|
-
* TypeScript source, deliberately".
|
|
80
|
-
*/
|
|
81
55
|
transpilePackages: [
|
|
82
56
|
'@meith/accounts',
|
|
83
57
|
'@meith/admin',
|
|
@@ -103,6 +77,7 @@ const nextConfig = {
|
|
|
103
77
|
'@meith/messages',
|
|
104
78
|
'@meith/moderation',
|
|
105
79
|
'@meith/notifications',
|
|
80
|
+
'@meith/plugin-calendar',
|
|
106
81
|
'@meith/plugin-dues',
|
|
107
82
|
'@meith/plugin-kit',
|
|
108
83
|
'@meith/polls',
|
|
@@ -146,4 +121,15 @@ const nextConfig = {
|
|
|
146
121
|
},
|
|
147
122
|
}
|
|
148
123
|
|
|
124
|
+
const boardManifestFile = path.join(workspaceRoot, 'package.json')
|
|
125
|
+
if (existsSync(boardManifestFile)) {
|
|
126
|
+
const boardManifest = JSON.parse(readFileSync(boardManifestFile, 'utf8'))
|
|
127
|
+
for (const name of Object.keys(boardManifest.dependencies ?? {})) {
|
|
128
|
+
if (FRAMEWORK_PACKAGES.includes(name)) continue
|
|
129
|
+
if (nextConfig.serverExternalPackages.includes(name)) continue
|
|
130
|
+
if (nextConfig.transpilePackages.includes(name)) continue
|
|
131
|
+
nextConfig.transpilePackages.push(name)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
149
135
|
export default nextConfig
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,60 +35,61 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@swc/helpers": "0.5.23",
|
|
37
37
|
"@tailwindcss/postcss": "^4.3.3",
|
|
38
|
+
"@types/react": "^19",
|
|
39
|
+
"@types/react-dom": "^19",
|
|
38
40
|
"next": "16.3.1",
|
|
39
41
|
"postcss": "^8.5",
|
|
40
42
|
"react": "19.2.8",
|
|
41
43
|
"react-dom": "19.2.8",
|
|
42
44
|
"tailwindcss": "^4.3.3",
|
|
43
45
|
"typescript": "7.0.2",
|
|
44
|
-
"@
|
|
45
|
-
"@
|
|
46
|
-
"@meith/
|
|
47
|
-
"@meith/
|
|
48
|
-
"@meith/
|
|
49
|
-
"@meith/authorization": "0.
|
|
50
|
-
"@meith/
|
|
51
|
-
"@meith/
|
|
52
|
-
"@meith/
|
|
53
|
-
"@meith/
|
|
54
|
-
"@meith/
|
|
55
|
-
"@meith/
|
|
56
|
-
"@meith/
|
|
57
|
-
"@meith/
|
|
58
|
-
"@meith/
|
|
59
|
-
"@meith/
|
|
60
|
-
"@meith/
|
|
61
|
-
"@meith/
|
|
62
|
-
"@meith/
|
|
63
|
-
"@meith/
|
|
64
|
-
"@meith/
|
|
65
|
-
"@meith/
|
|
66
|
-
"@meith/
|
|
67
|
-
"@meith/
|
|
68
|
-
"@meith/
|
|
69
|
-
"@meith/plugin-dues": "0.
|
|
70
|
-
"@meith/
|
|
71
|
-
"@meith/polls": "0.
|
|
72
|
-
"@meith/
|
|
73
|
-
"@meith/
|
|
74
|
-
"@meith/relations": "0.
|
|
75
|
-
"@meith/
|
|
76
|
-
"@meith/
|
|
77
|
-
"@meith/
|
|
78
|
-
"@meith/
|
|
79
|
-
"@meith/
|
|
80
|
-
"@meith/
|
|
81
|
-
"@meith/
|
|
82
|
-
"@meith/theme-clubhouse": "0.
|
|
83
|
-
"@meith/theme-default": "0.
|
|
84
|
-
"@meith/
|
|
85
|
-
"@meith/theme-
|
|
86
|
-
"@meith/theme-phasebook": "0.
|
|
87
|
-
"@meith/theme-raidframe": "0.
|
|
88
|
-
"@meith/
|
|
89
|
-
"@meith/
|
|
90
|
-
"@meith/
|
|
91
|
-
"@meith/upgrade": "0.21.2"
|
|
46
|
+
"@meith/accounts": "0.23.0",
|
|
47
|
+
"@meith/admin": "0.23.0",
|
|
48
|
+
"@meith/antispam": "0.23.0",
|
|
49
|
+
"@meith/api": "0.23.0",
|
|
50
|
+
"@meith/attachments": "0.23.0",
|
|
51
|
+
"@meith/authorization": "0.23.0",
|
|
52
|
+
"@meith/avatars": "0.23.0",
|
|
53
|
+
"@meith/core": "0.23.0",
|
|
54
|
+
"@meith/db": "0.23.0",
|
|
55
|
+
"@meith/demo": "0.23.0",
|
|
56
|
+
"@meith/drafts": "0.23.0",
|
|
57
|
+
"@meith/drivers": "0.23.0",
|
|
58
|
+
"@meith/events": "0.23.0",
|
|
59
|
+
"@meith/forums": "0.23.0",
|
|
60
|
+
"@meith/groups": "0.23.0",
|
|
61
|
+
"@meith/i18n": "0.23.0",
|
|
62
|
+
"@meith/import": "0.23.0",
|
|
63
|
+
"@meith/install": "0.23.0",
|
|
64
|
+
"@meith/mail": "0.23.0",
|
|
65
|
+
"@meith/markdown": "0.23.0",
|
|
66
|
+
"@meith/marketplace": "0.23.0",
|
|
67
|
+
"@meith/messages": "0.23.0",
|
|
68
|
+
"@meith/moderation": "0.23.0",
|
|
69
|
+
"@meith/notifications": "0.23.0",
|
|
70
|
+
"@meith/plugin-calendar": "0.23.0",
|
|
71
|
+
"@meith/plugin-dues": "0.23.0",
|
|
72
|
+
"@meith/plugin-kit": "0.23.0",
|
|
73
|
+
"@meith/polls": "0.23.0",
|
|
74
|
+
"@meith/posts": "0.23.0",
|
|
75
|
+
"@meith/profile-fields": "0.23.0",
|
|
76
|
+
"@meith/relations": "0.23.0",
|
|
77
|
+
"@meith/reputation": "0.23.0",
|
|
78
|
+
"@meith/search": "0.23.0",
|
|
79
|
+
"@meith/runtime": "0.23.0",
|
|
80
|
+
"@meith/settings": "0.23.0",
|
|
81
|
+
"@meith/signatures": "0.23.0",
|
|
82
|
+
"@meith/subscriptions": "0.23.0",
|
|
83
|
+
"@meith/tasks": "0.23.0",
|
|
84
|
+
"@meith/theme-clubhouse": "0.23.0",
|
|
85
|
+
"@meith/theme-default": "0.23.0",
|
|
86
|
+
"@meith/theme-kit": "0.23.0",
|
|
87
|
+
"@meith/theme-midnight": "0.23.0",
|
|
88
|
+
"@meith/theme-phasebook": "0.23.0",
|
|
89
|
+
"@meith/theme-raidframe": "0.23.0",
|
|
90
|
+
"@meith/threads": "0.23.0",
|
|
91
|
+
"@meith/ui": "0.23.0",
|
|
92
|
+
"@meith/upgrade": "0.23.0"
|
|
92
93
|
},
|
|
93
94
|
"scripts": {
|
|
94
95
|
"dev": "next dev",
|
package/proxy.ts
CHANGED
|
@@ -48,20 +48,6 @@ interface Policy {
|
|
|
48
48
|
readonly value: string
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
52
|
-
* Passes the request through, and hands a reader something to be counted by.
|
|
53
|
-
*
|
|
54
|
-
* The cookie is minted here because the Edge is the only place that can set one
|
|
55
|
-
* on an ordinary page response — a render cannot. The row it stands for is
|
|
56
|
-
* written by the render, which has the database this runtime does not.
|
|
57
|
-
*
|
|
58
|
-
* Whether the client *returned* the cookie is the load-bearing part, and it
|
|
59
|
-
* cannot be read downstream: Next reflects a cookie set here into the request
|
|
60
|
-
* the render sees, so a first-ever visit is indistinguishable from a returning
|
|
61
|
-
* one by cookie alone. Hence the header — set when the cookie is new, deleted
|
|
62
|
-
* otherwise, so a crawler that never keeps one is never counted and a client
|
|
63
|
-
* cannot supply its own.
|
|
64
|
-
*/
|
|
65
51
|
function withPath(req: NextRequest, policy: Policy): NextResponse {
|
|
66
52
|
const fresh = !req.cookies.has(GUEST_COOKIE) && !req.cookies.has(DEV_GUEST_COOKIE)
|
|
67
53
|
|
|
@@ -6,6 +6,7 @@ import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
|
6
6
|
import {
|
|
7
7
|
markAllNotificationsReadAction,
|
|
8
8
|
markNotificationReadAction,
|
|
9
|
+
saveMassMailOptInAction,
|
|
9
10
|
saveNotificationPreferencesAction,
|
|
10
11
|
} from '@/server/notification-actions'
|
|
11
12
|
|
|
@@ -155,3 +156,47 @@ export function NotificationPreferencesForm({
|
|
|
155
156
|
</form>
|
|
156
157
|
)
|
|
157
158
|
}
|
|
159
|
+
|
|
160
|
+
export function AnnouncementsOptInForm({ optedIn, copy }: { optedIn: boolean; copy: Copy }) {
|
|
161
|
+
const [state, action] = useActionState(saveMassMailOptInAction, EMPTY_STATE)
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<form
|
|
165
|
+
action={action}
|
|
166
|
+
className="flex flex-col gap-4 rounded-lg border border-border bg-card p-5"
|
|
167
|
+
>
|
|
168
|
+
<FormError message={state.error} />
|
|
169
|
+
|
|
170
|
+
<fieldset className="flex flex-col gap-3">
|
|
171
|
+
<legend className="text-sm font-medium">
|
|
172
|
+
{fromCopy(copy, 'accountForm.announcements.legend')}
|
|
173
|
+
</legend>
|
|
174
|
+
|
|
175
|
+
<label className="flex items-start gap-3 text-sm">
|
|
176
|
+
<input
|
|
177
|
+
type="checkbox"
|
|
178
|
+
name="announcements"
|
|
179
|
+
defaultChecked={optedIn}
|
|
180
|
+
className={`mt-1 ${CHECKBOX}`}
|
|
181
|
+
/>
|
|
182
|
+
<span>
|
|
183
|
+
<span className="font-medium">{fromCopy(copy, 'accountForm.announcements.title')}</span>
|
|
184
|
+
<span className="block text-muted-foreground">
|
|
185
|
+
{fromCopy(copy, 'accountForm.announcements.description')}
|
|
186
|
+
</span>
|
|
187
|
+
</span>
|
|
188
|
+
</label>
|
|
189
|
+
</fieldset>
|
|
190
|
+
|
|
191
|
+
<p className="text-xs text-muted-foreground">
|
|
192
|
+
{fromCopy(copy, 'accountForm.announcements.blurb')}
|
|
193
|
+
</p>
|
|
194
|
+
|
|
195
|
+
<div>
|
|
196
|
+
<button type="submit" className={BUTTON}>
|
|
197
|
+
{fromCopy(copy, 'accountForm.announcements.submit')}
|
|
198
|
+
</button>
|
|
199
|
+
</div>
|
|
200
|
+
</form>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
@@ -8,10 +8,6 @@ import { ADMIN_OVERVIEW, adminNavWithPlugin, pluginKeyAt } from '@/view/admin-na
|
|
|
8
8
|
import type { PanelNav, PanelSection } from '@/view/panel-nav'
|
|
9
9
|
import { pluginNavChildren } from '@/view/plugin-panel'
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
* A plugin may reorder, drop, or add a section here. A link it adds reaches a
|
|
13
|
-
* page it declared and nothing else — the panel re-checks who is asking.
|
|
14
|
-
*/
|
|
15
11
|
async function withPluginSections(nav: PanelNav, t: Awaited<ReturnType<typeof getTranslator>>) {
|
|
16
12
|
const links = await filterView(
|
|
17
13
|
'admin.navigation',
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useActionState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
6
|
+
import { addBanFilterAction, removeBanFilterAction } from '@/server/ban-filter-admin-actions'
|
|
7
|
+
|
|
8
|
+
import { FormError, SubmitButton } from '../auth/form-controls'
|
|
9
|
+
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
10
|
+
import { INPUT, Saved } from './form-bits'
|
|
11
|
+
|
|
12
|
+
export interface BanFilterTypeOption {
|
|
13
|
+
readonly value: string
|
|
14
|
+
readonly label: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function NewBanFilterForm({
|
|
18
|
+
types,
|
|
19
|
+
copy,
|
|
20
|
+
}: {
|
|
21
|
+
types: readonly BanFilterTypeOption[]
|
|
22
|
+
copy: Copy
|
|
23
|
+
}) {
|
|
24
|
+
const [state, action] = useActionState(addBanFilterAction, EMPTY_STATE)
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<form action={action} className="flex flex-col gap-3" noValidate>
|
|
28
|
+
<FormError message={state.error} />
|
|
29
|
+
<Saved when={state.notice === 'created'}>{fromCopy(copy, 'adminBanFilter.added')}</Saved>
|
|
30
|
+
|
|
31
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
32
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
33
|
+
<span className="font-medium">{fromCopy(copy, 'adminBanFilter.type')}</span>
|
|
34
|
+
<select
|
|
35
|
+
name="type"
|
|
36
|
+
defaultValue={state.values?.type ?? types[0]?.value ?? ''}
|
|
37
|
+
className={INPUT}
|
|
38
|
+
>
|
|
39
|
+
{types.map((type) => (
|
|
40
|
+
<option key={type.value} value={type.value}>
|
|
41
|
+
{type.label}
|
|
42
|
+
</option>
|
|
43
|
+
))}
|
|
44
|
+
</select>
|
|
45
|
+
<span className="text-xs text-muted-foreground">
|
|
46
|
+
{fromCopy(copy, 'adminBanFilter.typeHint')}
|
|
47
|
+
</span>
|
|
48
|
+
</label>
|
|
49
|
+
|
|
50
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
51
|
+
<span className="font-medium">{fromCopy(copy, 'adminBanFilter.pattern')}</span>
|
|
52
|
+
<input
|
|
53
|
+
name="pattern"
|
|
54
|
+
defaultValue={state.values?.pattern ?? ''}
|
|
55
|
+
className={INPUT}
|
|
56
|
+
placeholder={fromCopy(copy, 'adminBanFilter.patternPlaceholder')}
|
|
57
|
+
required
|
|
58
|
+
/>
|
|
59
|
+
<span className="text-xs text-muted-foreground">
|
|
60
|
+
{fromCopy(copy, 'adminBanFilter.patternHint')}
|
|
61
|
+
</span>
|
|
62
|
+
</label>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
66
|
+
<span className="font-medium">{fromCopy(copy, 'adminBanFilter.note')}</span>
|
|
67
|
+
<input name="note" defaultValue={state.values?.note ?? ''} className={INPUT} />
|
|
68
|
+
<span className="text-xs text-muted-foreground">
|
|
69
|
+
{fromCopy(copy, 'adminBanFilter.noteHint')}
|
|
70
|
+
</span>
|
|
71
|
+
</label>
|
|
72
|
+
|
|
73
|
+
<div>
|
|
74
|
+
<SubmitButton>{fromCopy(copy, 'adminBanFilter.addFilter')}</SubmitButton>
|
|
75
|
+
</div>
|
|
76
|
+
</form>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function RemoveBanFilterForm({
|
|
81
|
+
id,
|
|
82
|
+
pattern,
|
|
83
|
+
copy,
|
|
84
|
+
}: {
|
|
85
|
+
id: number
|
|
86
|
+
pattern: string
|
|
87
|
+
copy: Copy
|
|
88
|
+
}) {
|
|
89
|
+
const [state, action] = useActionState(removeBanFilterAction, EMPTY_STATE)
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<form action={action} className="flex flex-col items-end gap-1">
|
|
93
|
+
<FormError message={state.error} />
|
|
94
|
+
<input type="hidden" name="id" value={id} />
|
|
95
|
+
<button
|
|
96
|
+
type="submit"
|
|
97
|
+
className="text-xs text-destructive hover:underline"
|
|
98
|
+
aria-label={formatFromCopy(copy, 'adminBanFilter.removeFilter', { pattern })}
|
|
99
|
+
>
|
|
100
|
+
{fromCopy(copy, 'admin.remove')}
|
|
101
|
+
</button>
|
|
102
|
+
</form>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -12,15 +12,6 @@ const TONE_CLASS: Readonly<Record<StatusTone, string>> = {
|
|
|
12
12
|
destructive: 'bg-destructive/10 text-destructive',
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
* One catalog entry, plugin or theme alike — the row model already carries
|
|
17
|
-
* everything both browse pages need, so this is the one place that renders
|
|
18
|
-
* it. Every string here is either our own catalog copy or a value from
|
|
19
|
-
* `MarketplaceListingRow`, which has already capped lengths and validated
|
|
20
|
-
* URLs against the untrusted feed (see `apps/community/src/server/marketplace-admin.ts`);
|
|
21
|
-
* nothing here uses `dangerouslySetInnerHTML`, so the feed's own text is
|
|
22
|
-
* rendered as plain text no matter what it contains.
|
|
23
|
-
*/
|
|
24
15
|
export function MarketplaceListingCard({
|
|
25
16
|
listing,
|
|
26
17
|
t,
|
|
@@ -105,11 +105,6 @@ export async function signInWithPasskey(next: string | undefined, copy: Copy): P
|
|
|
105
105
|
return assertPasskey('authenticate', next, copy)
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/**
|
|
109
|
-
* The same exchange, against a sign-in that has already given its password.
|
|
110
|
-
* The board scopes the challenge to that member's own credentials, so the
|
|
111
|
-
* browser offers only the keys that could finish this particular sign-in.
|
|
112
|
-
*/
|
|
113
108
|
export async function confirmWithPasskey(next: string | undefined, copy: Copy): Promise<string> {
|
|
114
109
|
return assertPasskey('second-factor', next, copy)
|
|
115
110
|
}
|
|
@@ -105,6 +105,24 @@ export function RegisterForm({
|
|
|
105
105
|
</>
|
|
106
106
|
)}
|
|
107
107
|
|
|
108
|
+
<label className="flex items-start gap-2 text-sm text-muted-foreground">
|
|
109
|
+
<input
|
|
110
|
+
type="checkbox"
|
|
111
|
+
name="announcements"
|
|
112
|
+
value="1"
|
|
113
|
+
defaultChecked={state.values?.announcements === '1'}
|
|
114
|
+
className="mt-0.5 size-4 rounded border-input accent-primary"
|
|
115
|
+
/>
|
|
116
|
+
<span>
|
|
117
|
+
<span className="block text-foreground">
|
|
118
|
+
{fromCopy(copy, 'authForm.register.announcements')}
|
|
119
|
+
</span>
|
|
120
|
+
<span className="block text-xs">
|
|
121
|
+
{fromCopy(copy, 'authForm.register.announcementsHint')}
|
|
122
|
+
</span>
|
|
123
|
+
</span>
|
|
124
|
+
</label>
|
|
125
|
+
|
|
108
126
|
{terms !== null && (
|
|
109
127
|
<label className="flex items-start gap-2 text-sm text-muted-foreground">
|
|
110
128
|
<input
|
|
@@ -4,6 +4,7 @@ import { currentRequestId } from '@meith/core/logger'
|
|
|
4
4
|
import type { ForumListingRow } from '@meith/forums'
|
|
5
5
|
import { requireSlot, slotCopy } from '@meith/theme-kit'
|
|
6
6
|
|
|
7
|
+
import { activeWordFilter } from '@/server/content-admin'
|
|
7
8
|
import { getActor } from '@/server/context'
|
|
8
9
|
import { identitiesFor } from '@/server/group-identity'
|
|
9
10
|
import { getTranslator } from '@/server/i18n'
|
|
@@ -44,6 +45,7 @@ export async function SectionPage({
|
|
|
44
45
|
now: new Date(),
|
|
45
46
|
t: await getTranslator(),
|
|
46
47
|
identities,
|
|
48
|
+
wordFilter: await activeWordFilter(),
|
|
47
49
|
})
|
|
48
50
|
|
|
49
51
|
const pluginContext = viewerRef(actor)
|
|
@@ -84,13 +84,6 @@ export interface MarkdownEditorProps {
|
|
|
84
84
|
readonly hint?: React.ReactNode
|
|
85
85
|
readonly scope?: PreviewScope
|
|
86
86
|
readonly attachTo?: AttachmentTarget | undefined
|
|
87
|
-
/**
|
|
88
|
-
* Whether the formatting buttons render inside this component (`'inline'`,
|
|
89
|
-
* the default — every composer outside the post/reply/edit pages) or are
|
|
90
|
-
* left to the page's `EditorToolbar` slot (`'external'`), which addresses
|
|
91
|
-
* this textarea by `id` from a different part of the tree. Either way the
|
|
92
|
-
* textarea works with none of them: the buttons are the enhancement.
|
|
93
|
-
*/
|
|
94
87
|
readonly toolbar?: 'inline' | 'external' | undefined
|
|
95
88
|
}
|
|
96
89
|
|
|
@@ -2,14 +2,6 @@ import { demoBannerModel } from '@/server/demo'
|
|
|
2
2
|
import { getTranslator } from '@/server/i18n'
|
|
3
3
|
import { splitAround } from '@/view/copy'
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* The strip at the top of a demo board.
|
|
7
|
-
*
|
|
8
|
-
* Rendered from the root layout rather than through a theme slot, on purpose:
|
|
9
|
-
* the slot contract is frozen, and a visiting administrator can switch themes.
|
|
10
|
-
* A banner a theme could decline to render is a banner that stops telling the
|
|
11
|
-
* next visitor how to log in, on the first theme that has not heard of it.
|
|
12
|
-
*/
|
|
13
5
|
export async function DemoBanner() {
|
|
14
6
|
const banner = await demoBannerModel()
|
|
15
7
|
if (banner === null) return null
|
package/src/config.ts
CHANGED
|
@@ -1,13 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `@meith/web/config` — the surface a board's own `community.config.ts`
|
|
3
|
-
* builds against.
|
|
4
|
-
*
|
|
5
|
-
* A scaffolded board depends on `@meith/web` for the app itself, and imports
|
|
6
|
-
* its board-time registry through this subpath rather than reaching into
|
|
7
|
-
* `@meith/core` directly. That indirection is what lets `@meith/web` change
|
|
8
|
-
* which package actually defines `defineForumConfig` without every board's
|
|
9
|
-
* `community.config.ts` following along.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
1
|
export type { ForumConfig, InstalledPlugin, InstalledTheme, MessageBundle } from '@meith/core'
|
|
13
2
|
export { defineForumConfig } from '@meith/core'
|
|
@@ -103,11 +103,6 @@ export async function adminSignInAction(_prev: FormState, form: FormData): Promi
|
|
|
103
103
|
redirect(target)
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
/**
|
|
107
|
-
* The panel's door asks for the second factor as well as the password. The
|
|
108
|
-
* panel can rewrite the whole board, so re-proving only the thing most likely
|
|
109
|
-
* to have leaked would be re-proving the wrong one.
|
|
110
|
-
*/
|
|
111
106
|
async function adminReauthenticationBucket(userId: number): Promise<string> {
|
|
112
107
|
const prefix = await retainedIpPrefix()
|
|
113
108
|
return prefix === null ? `admin-reauth:${userId}` : `admin-reauth:${userId}@${prefix}`
|
package/src/server/antispam.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type RateLimitOutcome,
|
|
17
17
|
subjectFor,
|
|
18
18
|
} from '@meith/antispam'
|
|
19
|
-
import type { Actor, NumericGlobalPermission } from '@meith/authorization'
|
|
19
|
+
import type { Actor, NumericGlobalPermission, Target } from '@meith/authorization'
|
|
20
20
|
import { env, logger } from '@meith/core'
|
|
21
21
|
import { getDb, PostgresCaptchaQuestionRepository, PostgresRateLimitBucketStore } from '@meith/db'
|
|
22
22
|
import type { SettingsSnapshot } from '@meith/settings'
|
|
@@ -280,29 +280,42 @@ export async function verifyChallenge(form: FormData): Promise<ChallengeVerdict>
|
|
|
280
280
|
}
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
const FIRST_POST_UNRESOLVED = 'could not resolve first-post moderation'
|
|
284
|
+
|
|
285
|
+
function bypassesModeration(actor: Actor, target: Target): boolean {
|
|
286
|
+
try {
|
|
287
|
+
return getContainer().authorizer.can(actor, 'content.viewUnapproved', target)
|
|
288
|
+
} catch (error) {
|
|
289
|
+
logger().warn({ err: String(error), stage: 'bypass', held: true }, FIRST_POST_UNRESOLVED)
|
|
290
|
+
return false
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
283
294
|
export async function holdsNewMember(input: {
|
|
284
295
|
readonly actor: Actor
|
|
285
296
|
readonly postCount: number
|
|
297
|
+
readonly target: Target
|
|
286
298
|
readonly settings?: SettingsSnapshot
|
|
287
299
|
}): Promise<boolean> {
|
|
300
|
+
let threshold = 0
|
|
288
301
|
try {
|
|
289
302
|
const settings = input.settings ?? (await getSettings())
|
|
290
|
-
|
|
291
|
-
if (threshold <= 0) return false
|
|
292
|
-
|
|
293
|
-
const { authorizer } = getContainer()
|
|
294
|
-
return holdsForReview(
|
|
295
|
-
{
|
|
296
|
-
userId: input.actor.userId,
|
|
297
|
-
postCount: input.postCount,
|
|
298
|
-
bypassesModeration: authorizer.can(input.actor, 'content.viewUnapproved'),
|
|
299
|
-
},
|
|
300
|
-
{ threshold },
|
|
301
|
-
)
|
|
303
|
+
threshold = Number(settings.get('antispam.moderate_first_posts') ?? 0)
|
|
302
304
|
} catch (error) {
|
|
303
|
-
logger().warn({ err: String(error)
|
|
305
|
+
logger().warn({ err: String(error), stage: 'threshold', held: false }, FIRST_POST_UNRESOLVED)
|
|
304
306
|
return false
|
|
305
307
|
}
|
|
308
|
+
|
|
309
|
+
if (threshold <= 0) return false
|
|
310
|
+
|
|
311
|
+
return holdsForReview(
|
|
312
|
+
{
|
|
313
|
+
userId: input.actor.userId,
|
|
314
|
+
postCount: input.postCount,
|
|
315
|
+
bypassesModeration: bypassesModeration(input.actor, input.target),
|
|
316
|
+
},
|
|
317
|
+
{ threshold },
|
|
318
|
+
)
|
|
306
319
|
}
|
|
307
320
|
|
|
308
321
|
export function antispamAvailable(): boolean {
|
package/src/server/api/search.ts
CHANGED
|
@@ -84,7 +84,7 @@ export const SEARCH_HANDLERS: ApiRoutes = [
|
|
|
84
84
|
postId: hit.postId,
|
|
85
85
|
threadId: hit.threadId,
|
|
86
86
|
forumId: hit.forumId,
|
|
87
|
-
threadTitle: hit.threadTitle,
|
|
87
|
+
threadTitle: filterWords(hit.threadTitle, wordFilter),
|
|
88
88
|
authorUserId: hit.authorUserId,
|
|
89
89
|
authorUsername: hit.authorUsername,
|
|
90
90
|
postedAt: hit.postedAt.toISOString(),
|