@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
|
@@ -27,20 +27,10 @@ const BUILD_INFO = {
|
|
|
27
27
|
themeApiMajor: THEME_API_MAJOR,
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
* Whether this process is the stock image rather than a graduated custom
|
|
32
|
-
* board — both run identical @meith/web code, so an env var set only by
|
|
33
|
-
* docker/Dockerfile is the only signal available: `BOARD_PLUGINS_MANIFEST`,
|
|
34
|
-
* baked in for `community board:eject` (see apps/cli/src/board-eject.ts). A
|
|
35
|
-
* graduated board's own scaffolded Dockerfile never sets it. Read fresh
|
|
36
|
-
* rather than cached at module load, so it is not fixed by whichever test
|
|
37
|
-
* happens to import this module first.
|
|
38
|
-
*/
|
|
39
30
|
function onStockImage(): boolean {
|
|
40
31
|
return readPluginEnv('BOARD_PLUGINS_MANIFEST') !== undefined
|
|
41
32
|
}
|
|
42
33
|
|
|
43
|
-
/** Caps against untrusted feed content — a self-hosted mirror can serve anything. */
|
|
44
34
|
const MAX_NAME_LENGTH = 120
|
|
45
35
|
const MAX_DESCRIPTION_LENGTH = 500
|
|
46
36
|
const MAX_LICENCE_LENGTH = 100
|
|
@@ -50,7 +40,6 @@ function truncate(value: string, max: number): string {
|
|
|
50
40
|
return value.length > max ? `${value.slice(0, max - 1)}…` : value
|
|
51
41
|
}
|
|
52
42
|
|
|
53
|
-
/** https only, and a real URL — never rendered as a link otherwise. */
|
|
54
43
|
function safeHttpsUrl(value: string): string | null {
|
|
55
44
|
try {
|
|
56
45
|
const url = new URL(value)
|
|
@@ -85,27 +74,17 @@ export interface MarketplaceListingRow {
|
|
|
85
74
|
readonly package: string
|
|
86
75
|
readonly licence: string
|
|
87
76
|
readonly repositoryUrl: string | null
|
|
88
|
-
/** Same-origin URLs — the browser never fetches the feed's own host directly. */
|
|
89
77
|
readonly screenshotHrefs: readonly string[]
|
|
90
78
|
readonly status: ListingStatus
|
|
91
79
|
readonly incompatibleReason: string | null
|
|
92
80
|
readonly installedVersion: string | null
|
|
93
|
-
/** Set only for 'not-installed': the exact steps this board would need. Never an affordance that acts. */
|
|
94
81
|
readonly installSteps: readonly string[] | null
|
|
95
|
-
/**
|
|
96
|
-
* True only for 'not-installed' on the stock image, where installSteps'
|
|
97
|
-
* own `community plugin:add`/`community.config.ts` line cannot actually
|
|
98
|
-
* run — the image is fixed at build time. Signposts
|
|
99
|
-
* docs/marketplace.md's "Moving to a custom board" walkthrough; never a
|
|
100
|
-
* claim this board can graduate itself.
|
|
101
|
-
*/
|
|
102
82
|
readonly onStockImage: boolean
|
|
103
83
|
}
|
|
104
84
|
|
|
105
85
|
export interface MarketplaceCatalogView {
|
|
106
86
|
readonly listings: readonly MarketplaceListingRow[]
|
|
107
87
|
readonly fetchedAt: Date | null
|
|
108
|
-
/** True once a fetch has ever succeeded — an empty `listings` before that is "not fetched yet", not "empty catalog". */
|
|
109
88
|
readonly hasEverFetched: boolean
|
|
110
89
|
readonly unreachable: boolean
|
|
111
90
|
readonly errorMessage: string | null
|
|
@@ -171,12 +150,6 @@ function toRow(
|
|
|
171
150
|
}
|
|
172
151
|
}
|
|
173
152
|
|
|
174
|
-
/**
|
|
175
|
-
* The Browse tab's whole read model for one kind — reads the cached feed
|
|
176
|
-
* only (never the network) and computes status against what this build
|
|
177
|
-
* actually contains. Renders honestly when the cache is empty: `listings`
|
|
178
|
-
* is `[]` either way, and `hasEverFetched`/`unreachable` say why.
|
|
179
|
-
*/
|
|
180
153
|
export async function marketplaceCatalog(kind: ListingKind): Promise<MarketplaceCatalogView> {
|
|
181
154
|
const cache = await readCache()
|
|
182
155
|
const installed =
|
|
@@ -195,7 +168,6 @@ export async function marketplaceCatalog(kind: ListingKind): Promise<Marketplace
|
|
|
195
168
|
}
|
|
196
169
|
}
|
|
197
170
|
|
|
198
|
-
/** One listing's screenshot, resolved against the feed's own host — never a client-supplied URL. */
|
|
199
171
|
export async function marketplaceScreenshotUrl(key: string, index: number): Promise<string | null> {
|
|
200
172
|
const cache = await readCache()
|
|
201
173
|
if (cache.feed === null || cache.sourceUrl === null) return null
|
|
@@ -211,11 +183,6 @@ export async function marketplaceScreenshotUrl(key: string, index: number): Prom
|
|
|
211
183
|
}
|
|
212
184
|
}
|
|
213
185
|
|
|
214
|
-
/**
|
|
215
|
-
* Runs the same fetch, validate, cache, notify pass the daily task does — the
|
|
216
|
-
* admin panel's "Refresh" button and the task call the exact same function,
|
|
217
|
-
* per docs/marketplace.md.
|
|
218
|
-
*/
|
|
219
186
|
export async function refreshMarketplaceNow(): Promise<{
|
|
220
187
|
readonly ok: boolean
|
|
221
188
|
readonly listingCount: number
|
package/src/server/navigation.ts
CHANGED
|
@@ -44,11 +44,6 @@ export function pluginNavigationNames(): NavigationNames {
|
|
|
44
44
|
)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
* A row belongs to a plugin that is switched off, or has failed its way off, or
|
|
49
|
-
* is no longer in the build. It is left in place — an operator's ordering is
|
|
50
|
-
* worth keeping — but it is not a link anybody should be offered.
|
|
51
|
-
*/
|
|
52
47
|
async function pluginRowIsLive(key: string | null): Promise<boolean> {
|
|
53
48
|
const pluginKey = pluginKeyOfNavigation(key)
|
|
54
49
|
if (pluginKey === null) return true
|
|
@@ -7,6 +7,7 @@ import { msg } from '@meith/i18n'
|
|
|
7
7
|
import type { NotificationService } from '@meith/notifications'
|
|
8
8
|
|
|
9
9
|
import type { FormState } from './auth-form-state'
|
|
10
|
+
import { getContainer } from './container'
|
|
10
11
|
import { getActor } from './context'
|
|
11
12
|
import { formStateReporter } from './form-state-reporter'
|
|
12
13
|
import { positiveInt } from './form-values'
|
|
@@ -62,6 +63,30 @@ export async function markAllNotificationsReadAction(
|
|
|
62
63
|
redirect('/notifications?read=all')
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
export async function saveMassMailOptInAction(
|
|
67
|
+
_prev: FormState,
|
|
68
|
+
form: FormData,
|
|
69
|
+
): Promise<FormState> {
|
|
70
|
+
try {
|
|
71
|
+
const actor = await getActor()
|
|
72
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
|
|
73
|
+
|
|
74
|
+
const { memberSettings } = getContainer()
|
|
75
|
+
if (memberSettings === null) {
|
|
76
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-9'))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await memberSettings.saveMassMailOptIn({
|
|
80
|
+
userId: actor.userId,
|
|
81
|
+
optIn: form.get('announcements') !== null,
|
|
82
|
+
})
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return toFormState(err)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
redirect('/notifications/preferences?saved=announcements')
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
export async function saveNotificationPreferencesAction(
|
|
66
91
|
_prev: FormState,
|
|
67
92
|
form: FormData,
|
package/src/server/onboarding.ts
CHANGED
|
@@ -28,11 +28,6 @@ async function dismissed(panel: OnboardingPanel): Promise<boolean> {
|
|
|
28
28
|
return (await cookies()).get(onboardingCookieName(panel))?.value === '1'
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
* At most one of the welcome banner or the profile-completion nudge, never
|
|
33
|
-
* both — stacking unrelated notices in the same board-wide slot is the
|
|
34
|
-
* "heavy multi-step tour" the feature is meant to avoid.
|
|
35
|
-
*/
|
|
36
31
|
export async function onboardingBanner(actor: Actor): Promise<OnboardingNoticeModel | null> {
|
|
37
32
|
if (actor.userId === null) return null
|
|
38
33
|
|
|
@@ -74,11 +74,6 @@ export async function setPluginEnabledAction(_prev: FormState, form: FormData):
|
|
|
74
74
|
await syncPluginEnablement()
|
|
75
75
|
await syncPluginNavigation()
|
|
76
76
|
|
|
77
|
-
/*
|
|
78
|
-
* After the switch is written, so the callback sees the state it is being
|
|
79
|
-
* told about, and never for the host's own failure switch: a plugin that
|
|
80
|
-
* has just failed five times is not one to hand more work to.
|
|
81
|
-
*/
|
|
82
77
|
await announceSwitch(definition, enabled)
|
|
83
78
|
|
|
84
79
|
await recordAdminAction({
|
|
@@ -94,12 +89,6 @@ export async function setPluginEnabledAction(_prev: FormState, form: FormData):
|
|
|
94
89
|
}
|
|
95
90
|
}
|
|
96
91
|
|
|
97
|
-
/**
|
|
98
|
-
* The switch has already been thrown by the time this runs, and it stays
|
|
99
|
-
* thrown: a callback that throws is the plugin's fault, so it is counted and
|
|
100
|
-
* reported like any other plugin failure rather than being reported as the
|
|
101
|
-
* operator's action having failed.
|
|
102
|
-
*/
|
|
103
92
|
async function announceSwitch(definition: PluginDefinition, enabled: boolean): Promise<void> {
|
|
104
93
|
if (enabled) {
|
|
105
94
|
await emitEvent('plugin.enabled', { pluginKey: definition.key }, {})
|
|
@@ -33,12 +33,10 @@ export interface PluginSettingRow {
|
|
|
33
33
|
readonly description: string | null
|
|
34
34
|
readonly advanced: boolean
|
|
35
35
|
readonly kind: PluginSettingKind
|
|
36
|
-
/** '' for a secret, whatever is stored or not: the value never leaves the server. */
|
|
37
36
|
readonly default: PluginSettingValue
|
|
38
37
|
readonly value: PluginSettingValue
|
|
39
38
|
readonly overridden: boolean
|
|
40
39
|
readonly source: PluginSettingSource
|
|
41
|
-
/** For secrets: whether a non-empty value is in force, without saying what it is. */
|
|
42
40
|
readonly set: boolean
|
|
43
41
|
readonly options: readonly { readonly value: string; readonly label: string }[]
|
|
44
42
|
readonly envName: string | null
|
|
@@ -81,7 +79,6 @@ export interface PluginRow {
|
|
|
81
79
|
readonly running: boolean
|
|
82
80
|
|
|
83
81
|
readonly health: PluginHealth | null
|
|
84
|
-
/** The record every instance reconciles against, not this process's tally. */
|
|
85
82
|
readonly durableHealth: PluginDurableHealthRow | null
|
|
86
83
|
readonly hooks: readonly string[]
|
|
87
84
|
readonly regions: readonly string[]
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
PluginHost,
|
|
15
15
|
} from '@meith/plugin-kit'
|
|
16
16
|
|
|
17
|
+
import { runtimeContextFor } from './plugin-runtime'
|
|
17
18
|
import { getSettingOverrides } from './settings'
|
|
18
19
|
|
|
19
20
|
const HEALTH_TTL_SECONDS = 30
|
|
@@ -47,9 +48,23 @@ export function activeDefinitions(): readonly PluginDefinition[] {
|
|
|
47
48
|
.map((entry) => entry.plugin as PluginDefinition)
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
async function hookRuntimeContext(pluginKey: string) {
|
|
52
|
+
const definition = activeDefinitions().find((candidate) => candidate.key === pluginKey)
|
|
53
|
+
if (definition === undefined) {
|
|
54
|
+
throw new Error(`plugin "${pluginKey}" is not installed on this board.`)
|
|
55
|
+
}
|
|
56
|
+
return runtimeContextFor(
|
|
57
|
+
pluginKey,
|
|
58
|
+
definition,
|
|
59
|
+
await getSettingOverrides(),
|
|
60
|
+
logger({ component: 'plugin-hook', plugin: pluginKey }),
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
export const pluginHost = new PluginHost({
|
|
51
65
|
plugins: activeDefinitions(),
|
|
52
66
|
failureThreshold: FAILURE_THRESHOLD,
|
|
67
|
+
runtime: hookRuntimeContext,
|
|
53
68
|
logger: {
|
|
54
69
|
warn: (message, detail) => logger().warn(detail, message),
|
|
55
70
|
error: (message, detail) => logger().error(detail, message),
|
|
@@ -61,12 +76,6 @@ export const pluginHost = new PluginHost({
|
|
|
61
76
|
},
|
|
62
77
|
})
|
|
63
78
|
|
|
64
|
-
/**
|
|
65
|
-
* A plugin failing somewhere the host does not itself dispatch — a lifecycle
|
|
66
|
-
* callback — counted and reported exactly as a failing hook is. There is one
|
|
67
|
-
* failure record per plugin, and an operator reading it should not have to know
|
|
68
|
-
* which door the plugin was behind when it broke.
|
|
69
|
-
*/
|
|
70
79
|
export async function recordPluginFault(
|
|
71
80
|
pluginKey: string,
|
|
72
81
|
surface: string,
|
|
@@ -3,78 +3,13 @@ import 'server-only'
|
|
|
3
3
|
import forumConfig from '@board/config'
|
|
4
4
|
import type { ReactNode } from 'react'
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
type PluginData,
|
|
10
|
-
type PluginDefinition,
|
|
11
|
-
type PluginGrants,
|
|
12
|
-
type PluginNotify,
|
|
13
|
-
type PluginRuntimeContext,
|
|
14
|
-
type PluginUsers,
|
|
15
|
-
pluginNotify,
|
|
16
|
-
resolvePluginSettings,
|
|
17
|
-
unavailablePluginData,
|
|
18
|
-
unavailablePluginGrants,
|
|
19
|
-
unavailablePluginNotify,
|
|
20
|
-
unavailablePluginUsers,
|
|
21
|
-
} from '@meith/plugin-kit'
|
|
6
|
+
import { logger } from '@meith/core'
|
|
7
|
+
import type { PluginDefinition } from '@meith/plugin-kit'
|
|
22
8
|
|
|
23
9
|
import { getTranslator } from './i18n'
|
|
24
|
-
import {
|
|
10
|
+
import { runtimeContextFor } from './plugin-runtime'
|
|
25
11
|
import { getSettingOverrides } from './settings'
|
|
26
12
|
|
|
27
|
-
const REQUEST_STATEMENT_TIMEOUT_MS = 3_000
|
|
28
|
-
|
|
29
|
-
export function grantsFor(pluginKey: string): PluginGrants {
|
|
30
|
-
return env.DATA_SOURCE === 'postgres'
|
|
31
|
-
? pluginGrants(getDb(), pluginKey)
|
|
32
|
-
: unavailablePluginGrants('this board is running on in-memory sample data')
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function dataFor(pluginKey: string): PluginData {
|
|
36
|
-
return env.DATA_SOURCE === 'postgres'
|
|
37
|
-
? pluginData(getDb(), pluginKey, { statementTimeoutMs: REQUEST_STATEMENT_TIMEOUT_MS })
|
|
38
|
-
: unavailablePluginData('this board is running on in-memory sample data')
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function usersFor(): PluginUsers {
|
|
42
|
-
return env.DATA_SOURCE === 'postgres'
|
|
43
|
-
? pluginUsers(getDb())
|
|
44
|
-
: unavailablePluginUsers('this board is running on in-memory sample data')
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function notifyFor(pluginKey: string): PluginNotify {
|
|
48
|
-
const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
|
|
49
|
-
const definition = entry?.plugin as PluginDefinition | undefined
|
|
50
|
-
const service = notificationService()
|
|
51
|
-
|
|
52
|
-
if (definition === undefined || service === null) {
|
|
53
|
-
return unavailablePluginNotify('this board is running on in-memory sample data')
|
|
54
|
-
}
|
|
55
|
-
return pluginNotify(pluginKey, definition.notifications ?? [], service)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function runtimeContextFor(
|
|
59
|
-
pluginKey: string,
|
|
60
|
-
definition: PluginDefinition,
|
|
61
|
-
overrides: ReadonlyMap<string, string>,
|
|
62
|
-
log: ReturnType<typeof logger>,
|
|
63
|
-
): PluginRuntimeContext {
|
|
64
|
-
return {
|
|
65
|
-
settings: resolvePluginSettings(definition, overrides, readPluginEnv),
|
|
66
|
-
logger: {
|
|
67
|
-
info: (message, detail) => log.info(detail ?? {}, message),
|
|
68
|
-
warn: (message, detail) => log.warn(detail ?? {}, message),
|
|
69
|
-
error: (message, detail) => log.error(detail ?? {}, message),
|
|
70
|
-
},
|
|
71
|
-
grants: grantsFor(pluginKey),
|
|
72
|
-
data: dataFor(pluginKey),
|
|
73
|
-
users: usersFor(),
|
|
74
|
-
notify: notifyFor(pluginKey),
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
13
|
export interface RenderedPluginPage {
|
|
79
14
|
readonly title: string
|
|
80
15
|
readonly node: ReactNode | null
|
|
@@ -17,7 +17,7 @@ import { recordAdminAction, requireAdmin } from './admin'
|
|
|
17
17
|
import { boardUrl } from './board-url'
|
|
18
18
|
import { getActor } from './context'
|
|
19
19
|
import { activeDefinitions, pluginHost, syncPluginEnablement } from './plugin-host'
|
|
20
|
-
import { runtimeContextFor } from './plugin-
|
|
20
|
+
import { runtimeContextFor } from './plugin-runtime'
|
|
21
21
|
import { viewerRef } from './plugin-view'
|
|
22
22
|
import { isSafeLocalPath } from './safe-path'
|
|
23
23
|
import { isSameOrigin } from './same-origin'
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import forumConfig from '@board/config'
|
|
4
|
+
|
|
5
|
+
import { env, type logger, readPluginEnv } from '@meith/core'
|
|
6
|
+
import { getDb, pluginData, pluginGrants, pluginUsers } from '@meith/db'
|
|
7
|
+
import {
|
|
8
|
+
type PluginData,
|
|
9
|
+
type PluginDefinition,
|
|
10
|
+
type PluginGrants,
|
|
11
|
+
type PluginNotify,
|
|
12
|
+
type PluginRuntimeContext,
|
|
13
|
+
type PluginUsers,
|
|
14
|
+
pluginNotify,
|
|
15
|
+
resolvePluginSettings,
|
|
16
|
+
unavailablePluginData,
|
|
17
|
+
unavailablePluginGrants,
|
|
18
|
+
unavailablePluginNotify,
|
|
19
|
+
unavailablePluginUsers,
|
|
20
|
+
} from '@meith/plugin-kit'
|
|
21
|
+
|
|
22
|
+
const REQUEST_STATEMENT_TIMEOUT_MS = 3_000
|
|
23
|
+
|
|
24
|
+
export function grantsFor(pluginKey: string): PluginGrants {
|
|
25
|
+
return env.DATA_SOURCE === 'postgres'
|
|
26
|
+
? pluginGrants(getDb(), pluginKey)
|
|
27
|
+
: unavailablePluginGrants('this board is running on in-memory sample data')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function dataFor(pluginKey: string): PluginData {
|
|
31
|
+
return env.DATA_SOURCE === 'postgres'
|
|
32
|
+
? pluginData(getDb(), pluginKey, { statementTimeoutMs: REQUEST_STATEMENT_TIMEOUT_MS })
|
|
33
|
+
: unavailablePluginData('this board is running on in-memory sample data')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function usersFor(): PluginUsers {
|
|
37
|
+
return env.DATA_SOURCE === 'postgres'
|
|
38
|
+
? pluginUsers(getDb())
|
|
39
|
+
: unavailablePluginUsers('this board is running on in-memory sample data')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function notifyFor(pluginKey: string): PluginNotify {
|
|
43
|
+
return {
|
|
44
|
+
async send(input) {
|
|
45
|
+
const { notificationService } = await import('./notifications')
|
|
46
|
+
const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
|
|
47
|
+
const definition = entry?.plugin as PluginDefinition | undefined
|
|
48
|
+
const service = notificationService()
|
|
49
|
+
|
|
50
|
+
const notify =
|
|
51
|
+
definition === undefined || service === null
|
|
52
|
+
? unavailablePluginNotify('this board is running on in-memory sample data')
|
|
53
|
+
: pluginNotify(pluginKey, definition.notifications ?? [], service)
|
|
54
|
+
|
|
55
|
+
return notify.send(input)
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function runtimeContextFor(
|
|
61
|
+
pluginKey: string,
|
|
62
|
+
definition: PluginDefinition,
|
|
63
|
+
overrides: ReadonlyMap<string, string>,
|
|
64
|
+
log: ReturnType<typeof logger>,
|
|
65
|
+
): PluginRuntimeContext {
|
|
66
|
+
return {
|
|
67
|
+
settings: resolvePluginSettings(definition, overrides, readPluginEnv),
|
|
68
|
+
logger: {
|
|
69
|
+
info: (message, detail) => log.info(detail ?? {}, message),
|
|
70
|
+
warn: (message, detail) => log.warn(detail ?? {}, message),
|
|
71
|
+
error: (message, detail) => log.error(detail ?? {}, message),
|
|
72
|
+
},
|
|
73
|
+
grants: grantsFor(pluginKey),
|
|
74
|
+
data: dataFor(pluginKey),
|
|
75
|
+
users: usersFor(),
|
|
76
|
+
notify: notifyFor(pluginKey),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -37,11 +37,11 @@ export async function emitEvent<K extends HookName>(
|
|
|
37
37
|
|
|
38
38
|
export async function pluginRegion(
|
|
39
39
|
region: PluginRegion,
|
|
40
|
-
context: Omit<PluginRegionContext, 'region'>,
|
|
40
|
+
context: Omit<PluginRegionContext, 'region' | 'runtime'>,
|
|
41
41
|
): Promise<React.ReactNode> {
|
|
42
42
|
await syncPluginEnablement()
|
|
43
43
|
|
|
44
|
-
const nodes = pluginHost.renderRegion(region, { ...context, region })
|
|
44
|
+
const nodes = await pluginHost.renderRegion(region, { ...context, region })
|
|
45
45
|
if (nodes.length === 0) return null
|
|
46
46
|
|
|
47
47
|
return (
|
package/src/server/presence.ts
CHANGED
|
@@ -40,9 +40,7 @@ export async function touchCurrentLocation(): Promise<void> {
|
|
|
40
40
|
const location = parseLocation((await headers()).get(PATH_HEADER))
|
|
41
41
|
if (location === null) return
|
|
42
42
|
await touchLocation(location)
|
|
43
|
-
} catch {
|
|
44
|
-
/* ignore */
|
|
45
|
-
}
|
|
43
|
+
} catch {}
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
export function presenceRepository(): PostgresPresenceRepository | null {
|
|
@@ -78,22 +76,9 @@ export async function touchLocation(location: BoardLocation): Promise<void> {
|
|
|
78
76
|
new Date(),
|
|
79
77
|
LOCATION_WINDOW_SECONDS,
|
|
80
78
|
)
|
|
81
|
-
} catch {
|
|
82
|
-
/* ignore */
|
|
83
|
-
}
|
|
79
|
+
} catch {}
|
|
84
80
|
}
|
|
85
81
|
|
|
86
|
-
/**
|
|
87
|
-
* The guest token, but only once the client has proved it keeps cookies.
|
|
88
|
-
*
|
|
89
|
-
* The freshness header is the proof, and it has to come from the middleware
|
|
90
|
-
* because nothing downstream can supply it: Next reflects a cookie set on a
|
|
91
|
-
* `NextResponse.next()` into the request the render sees, so on a first-ever
|
|
92
|
-
* visit both `cookies()` and the raw `Cookie` header already carry a token the
|
|
93
|
-
* client has never seen. Counting on that wrote one presence row per request
|
|
94
|
-
* from anything that discards cookies — measured at twelve rows from twelve
|
|
95
|
-
* crawler requests, and a sessions table that grows without bound.
|
|
96
|
-
*/
|
|
97
82
|
async function returnedGuestToken(secure: boolean): Promise<string | undefined> {
|
|
98
83
|
if ((await headers()).get(FRESH_GUEST_HEADER) === '1') return undefined
|
|
99
84
|
|
|
@@ -101,13 +86,6 @@ async function returnedGuestToken(secure: boolean): Promise<string | undefined>
|
|
|
101
86
|
return value === '' ? undefined : value
|
|
102
87
|
}
|
|
103
88
|
|
|
104
|
-
/**
|
|
105
|
-
* The same reader, one row.
|
|
106
|
-
*
|
|
107
|
-
* Only reached when there is no usable member session, which is what keeps a
|
|
108
|
-
* logged-in member from being counted twice — and signing in drops the guest
|
|
109
|
-
* row outright, so the two never overlap.
|
|
110
|
-
*/
|
|
111
89
|
async function touchGuestLocation(
|
|
112
90
|
guestToken: string | undefined,
|
|
113
91
|
location: BoardLocation,
|
|
@@ -131,13 +109,6 @@ async function touchGuestLocation(
|
|
|
131
109
|
})
|
|
132
110
|
}
|
|
133
111
|
|
|
134
|
-
/**
|
|
135
|
-
* Retires the guest identity of somebody who has just become a member.
|
|
136
|
-
*
|
|
137
|
-
* Called wherever a session cookie is set, rather than only at the login form,
|
|
138
|
-
* because that is every route into being signed in — the form, the remember-me
|
|
139
|
-
* resume, and a password change that reissues the session.
|
|
140
|
-
*/
|
|
141
112
|
export async function retireGuestPresence(): Promise<void> {
|
|
142
113
|
try {
|
|
143
114
|
const secure = env.NODE_ENV !== 'development'
|
|
@@ -149,9 +120,7 @@ export async function retireGuestPresence(): Promise<void> {
|
|
|
149
120
|
}
|
|
150
121
|
|
|
151
122
|
jar.set(guestCookieName(secure), '', clearedCookie(secure))
|
|
152
|
-
} catch {
|
|
153
|
-
/* ignore */
|
|
154
|
-
}
|
|
123
|
+
} catch {}
|
|
155
124
|
}
|
|
156
125
|
|
|
157
126
|
export async function onlineScopeFor(actor: Actor): Promise<OnlineScope> {
|
package/src/server/relations.ts
CHANGED
|
@@ -39,7 +39,5 @@ export async function touchActivity(userId: number | null): Promise<void> {
|
|
|
39
39
|
try {
|
|
40
40
|
const { accountStore } = getContainer()
|
|
41
41
|
await accountStore.accounts.touchLastActive(userId, new Date(), ACTIVITY_WINDOW_SECONDS)
|
|
42
|
-
} catch {
|
|
43
|
-
/* ignore */
|
|
44
|
-
}
|
|
42
|
+
} catch {}
|
|
45
43
|
}
|
package/src/server/reply-core.ts
CHANGED
|
@@ -53,7 +53,6 @@ export async function countingAddress(): Promise<string | null> {
|
|
|
53
53
|
return demoDiscardsAddresses() ? demoAddressToken(address) : address
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/** Where a request came from and what made it, as much as is kept about either. */
|
|
57
56
|
export async function requestFingerprint(): Promise<RequestFingerprint> {
|
|
58
57
|
const incoming = await headers()
|
|
59
58
|
const agent = incoming.get('user-agent') ?? ''
|
|
@@ -185,10 +185,6 @@ export async function openSearch(input: {
|
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
/**
|
|
189
|
-
* A plugin may reorder or drop; a row it invents has no hit behind it and is
|
|
190
|
-
* dropped, because the hits arrived permission-filtered in SQL.
|
|
191
|
-
*/
|
|
192
188
|
async function filterHits(
|
|
193
189
|
results: SearchResults,
|
|
194
190
|
terms: string,
|
|
@@ -25,7 +25,6 @@ async function requireViewer(): Promise<number> {
|
|
|
25
25
|
return actor.userId
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/** The row the member is reading the page on, so it can be marked and kept. */
|
|
29
28
|
export async function currentSessionId(): Promise<number | null> {
|
|
30
29
|
const token = await readSessionToken()
|
|
31
30
|
if (token === undefined || token === '') return null
|
package/src/server/settings.ts
CHANGED
|
@@ -28,17 +28,6 @@ export async function getSettings(): Promise<SettingsSnapshot> {
|
|
|
28
28
|
return SettingsSnapshot.fromOverrides(new Map(await getSettingOverrides()))
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
* The same settings, read past the cache.
|
|
33
|
-
*
|
|
34
|
-
* The cached snapshot is held per module instance, and a route handler is not
|
|
35
|
-
* always the same instance as the render that invalidated it — so a route can
|
|
36
|
-
* act on a value up to a minute out of date. That is harmless for anything the
|
|
37
|
-
* page merely displays, and wrong for a decision an operator has just changed:
|
|
38
|
-
* turning a sign-in provider on and having the button answer "that is not
|
|
39
|
-
* switched on" reads as a broken board, not as a stale cache. Sign-in happens
|
|
40
|
-
* once per member per session, so the query is cheaper than the confusion.
|
|
41
|
-
*/
|
|
42
31
|
export async function getSettingsUncached(): Promise<SettingsSnapshot> {
|
|
43
32
|
if (env.DATA_SOURCE !== 'postgres') return SettingsSnapshot.fromOverrides(new Map())
|
|
44
33
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
parseSubscriptionTarget,
|
|
10
10
|
readUnsubscribeToken,
|
|
11
11
|
SubscriptionService,
|
|
12
|
+
type UnsubscribeScope,
|
|
12
13
|
} from '@meith/subscriptions'
|
|
13
14
|
|
|
14
15
|
import type { FormState } from './auth-form-state'
|
|
@@ -127,12 +128,14 @@ export async function unsubscribeByTokenAction(
|
|
|
127
128
|
if (claim === null) return { error: await tr('notice.app.unsubscribe-link-valid') }
|
|
128
129
|
|
|
129
130
|
try {
|
|
130
|
-
const { subscriptions, notifications } = getContainer()
|
|
131
|
-
if (subscriptions === null || notifications === null) {
|
|
131
|
+
const { subscriptions, notifications, memberSettings } = getContainer()
|
|
132
|
+
if (subscriptions === null || notifications === null || memberSettings === null) {
|
|
132
133
|
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-17'))
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
if (claim.scope === '
|
|
136
|
+
if (claim.scope === 'mass-mail') {
|
|
137
|
+
await memberSettings.saveMassMailOptIn({ userId: claim.userId, optIn: false })
|
|
138
|
+
} else if (claim.scope === 'email') {
|
|
136
139
|
await notifications.savePreferences(
|
|
137
140
|
claim.userId,
|
|
138
141
|
'email',
|
|
@@ -152,7 +155,12 @@ export async function unsubscribeByTokenAction(
|
|
|
152
155
|
return toFormState(err)
|
|
153
156
|
}
|
|
154
157
|
|
|
155
|
-
redirect(`/unsubscribe?done=${claim.scope
|
|
158
|
+
redirect(`/unsubscribe?done=${doneFor(claim.scope)}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function doneFor(scope: UnsubscribeScope): string {
|
|
162
|
+
if (scope === 'mass-mail') return 'announcements'
|
|
163
|
+
return scope === 'email' ? 'email' : 'one'
|
|
156
164
|
}
|
|
157
165
|
|
|
158
166
|
function safeReturn(value: string, fallback: string): string {
|
|
@@ -50,10 +50,6 @@ export async function isIndexable(): Promise<boolean> {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
54
|
-
* One chunk of the sitemap, after the plugins have had it. Everything here is
|
|
55
|
-
* public by definition — the sitemap is what the board offers a crawler.
|
|
56
|
-
*/
|
|
57
53
|
export async function filterSitemapEntries(
|
|
58
54
|
urls: readonly SitemapUrl[],
|
|
59
55
|
chunk: number,
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createMemoryStore,
|
|
3
|
+
IdentityService,
|
|
4
|
+
MemoryBanFilters,
|
|
5
|
+
SessionService,
|
|
6
|
+
} from '@meith/accounts'
|
|
2
7
|
import type { MemoryAppointment, MemoryBoard } from '@meith/authorization'
|
|
3
8
|
import { Authorizer, InMemoryAuthorizationSource } from '@meith/authorization'
|
|
4
9
|
|
|
@@ -59,11 +64,15 @@ export function installTestContainer(options: TestContainerOptions = {}): Record
|
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
function identityOver(store: ReturnType<typeof createMemoryStore>) {
|
|
67
|
+
const banFilters = new MemoryBanFilters()
|
|
68
|
+
|
|
62
69
|
return {
|
|
63
70
|
accountStore: store,
|
|
64
71
|
actorSource: new FixtureActorSource(store),
|
|
72
|
+
banFilters,
|
|
65
73
|
identity: new IdentityService({
|
|
66
74
|
store,
|
|
75
|
+
banFilters,
|
|
67
76
|
config: {
|
|
68
77
|
registrationEnabled: true,
|
|
69
78
|
minPasswordLength: 8,
|
|
@@ -106,9 +106,7 @@ export async function buildThemeAdminView(key: string): Promise<ThemeAdminView |
|
|
|
106
106
|
let overrides: TokenOverrides = { light: {}, dark: {} }
|
|
107
107
|
try {
|
|
108
108
|
overrides = validateTokenOverrides(installed.tokens, record?.tokenOverrides)
|
|
109
|
-
} catch {
|
|
110
|
-
/* ignore */
|
|
111
|
-
}
|
|
109
|
+
} catch {}
|
|
112
110
|
|
|
113
111
|
const listing = (await themeListing()).find((entry) => entry.key === key)
|
|
114
112
|
|
|
@@ -179,9 +177,7 @@ export async function boardSampleSurfaces(): Promise<{
|
|
|
179
177
|
.catch(() => null)
|
|
180
178
|
try {
|
|
181
179
|
overrides = validateTokenOverrides(theme.tokens, record?.tokenOverrides)
|
|
182
|
-
} catch {
|
|
183
|
-
/* ignore */
|
|
184
|
-
}
|
|
180
|
+
} catch {}
|
|
185
181
|
}
|
|
186
182
|
|
|
187
183
|
const pick = (
|