@meith/web 0.31.0 → 0.32.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]/feed.xml/route.ts +5 -3
- package/app/(board)/[slug]/new/page.tsx +5 -0
- package/app/(board)/[slug]/page.tsx +7 -1
- package/app/(board)/discover/[view]/page.tsx +6 -1
- package/app/(board)/member/[id]/page.tsx +3 -5
- package/app/(board)/member/[id]/reputation/page.tsx +8 -15
- package/app/(board)/members/page.tsx +3 -8
- package/app/(board)/messages/[id]/page.tsx +3 -7
- package/app/(board)/messages/page.tsx +3 -5
- package/app/(board)/modcp/ip/page.tsx +3 -5
- package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
- package/app/(board)/moderation/reports/page.tsx +53 -15
- package/app/(board)/moderation/warn/page.tsx +3 -6
- package/app/(board)/notifications/page.tsx +5 -10
- package/app/(board)/notifications/preferences/page.tsx +8 -0
- package/app/(board)/online/page.tsx +12 -6
- package/app/(board)/staff/page.tsx +4 -5
- package/app/(board)/stats/page.tsx +10 -3
- package/app/(board)/subscriptions/page.tsx +5 -10
- package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
- package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
- package/app/(board)/thread/[slug]/page.tsx +49 -11
- package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
- package/app/(board)/unsubscribe/page.tsx +8 -11
- package/app/(board)/usercp/contacts/page.tsx +3 -5
- package/app/(board)/usercp/drafts/page.tsx +70 -0
- package/app/(board)/usercp/options/page.tsx +8 -1
- package/app/(board)/usercp/security/page.tsx +16 -0
- package/app/admin/antispam/page.tsx +4 -12
- package/app/admin/content/attachments/page.tsx +3 -6
- package/app/admin/content/page.tsx +3 -13
- package/app/admin/forums/[id]/permissions/page.tsx +9 -12
- package/app/admin/groups/page.tsx +7 -9
- package/app/admin/page.tsx +12 -6
- package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
- package/app/admin/plugins/page.tsx +3 -6
- package/app/admin/users/[id]/page.tsx +5 -11
- package/app/admin/users/prune/page.tsx +4 -12
- package/app/admin/webhooks/page.tsx +144 -0
- package/app/api/subscribe/thread/[id]/route.ts +53 -0
- package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
- package/app/atom.xml/route.ts +2 -2
- package/app/auth/sso/[provider]/callback/route.ts +10 -0
- package/app/feed.xml/route.ts +2 -2
- package/app/offline/route.ts +98 -0
- package/next.config.mjs +1 -0
- package/package.json +49 -48
- package/public/sw.js +42 -2
- package/src/components/account/draft-forms.tsx +39 -0
- package/src/components/account/feed-token.tsx +95 -0
- package/src/components/account/notification-forms.tsx +37 -2
- package/src/components/account/relation-forms.tsx +3 -2
- package/src/components/account/subscription-forms.tsx +20 -6
- package/src/components/account/usercp-forms.tsx +44 -0
- package/src/components/admin/api-token-forms.tsx +11 -7
- package/src/components/admin/ban-filter-forms.tsx +15 -11
- package/src/components/admin/content-forms.tsx +31 -14
- package/src/components/admin/forum-forms.tsx +241 -129
- package/src/components/admin/forum-tree.tsx +5 -7
- package/src/components/admin/navigation-forms.tsx +43 -26
- package/src/components/admin/navigation-tree.tsx +8 -13
- package/src/components/admin/oklch-picker.tsx +2 -1
- package/src/components/admin/theme-changes.tsx +3 -2
- package/src/components/admin/theme-forms.tsx +3 -2
- package/src/components/admin/webhook-forms.tsx +137 -0
- package/src/components/auth/register-form.tsx +4 -7
- package/src/components/board/live-region.tsx +3 -1
- package/src/components/content/attachment-field.tsx +93 -22
- package/src/components/content/combobox-keys.ts +38 -0
- package/src/components/content/edit-post-form.tsx +75 -1
- package/src/components/content/markdown-editor.tsx +29 -86
- package/src/components/content/mention-combobox.tsx +137 -0
- package/src/components/content/multiquote-button.tsx +15 -12
- package/src/components/content/new-thread-form.tsx +95 -17
- package/src/components/content/poll-edit-form.tsx +61 -39
- package/src/components/content/poll-vote-form.tsx +91 -0
- package/src/components/content/poll.tsx +12 -48
- package/src/components/content/progressive-marker.tsx +13 -0
- package/src/components/content/recipient-segment.ts +39 -0
- package/src/components/content/reply-form.tsx +9 -1
- package/src/components/content/thanks-button.tsx +12 -7
- package/src/components/messages/message-forms.tsx +53 -43
- package/src/components/messages/recipient-field.tsx +88 -0
- package/src/components/moderation/inline-moderation-form.tsx +2 -0
- package/src/components/moderation/modcp-shell.tsx +17 -3
- package/src/components/moderation/queue-form.tsx +3 -6
- package/src/components/moderation/report-forms.tsx +24 -5
- package/src/components/moderation/thread-tools-form.tsx +2 -0
- package/src/components/shell/board-notice.tsx +22 -1
- package/src/components/shell/confirm-dialog.tsx +93 -0
- package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
- package/src/components/shell/notice-toast.tsx +40 -0
- package/src/components/shell/notification-menu.tsx +5 -3
- package/src/components/shell/page-shell.tsx +3 -0
- package/src/components/shell/scheme-toggle.tsx +72 -0
- package/src/components/shell/theme-switcher.tsx +16 -36
- package/src/server/admin-settings-actions.ts +5 -0
- package/src/server/api/search.ts +8 -2
- package/src/server/api-token-actions.ts +4 -0
- package/src/server/appearance-actions.ts +21 -0
- package/src/server/attachments.ts +35 -0
- package/src/server/auth-actions.ts +2 -0
- package/src/server/auth-form-state.ts +28 -0
- package/src/server/auto-watch.ts +31 -0
- package/src/server/ban-filter-admin-actions.ts +5 -0
- package/src/server/confirm.ts +19 -0
- package/src/server/content-actions.ts +129 -19
- package/src/server/content-admin-actions.ts +31 -0
- package/src/server/discovery.ts +11 -1
- package/src/server/feed-routes.ts +13 -4
- package/src/server/feed-token-actions.ts +61 -0
- package/src/server/feed-token.ts +78 -0
- package/src/server/fixture-post-repo.ts +18 -0
- package/src/server/forum-admin-actions.ts +23 -16
- package/src/server/forum-admin.ts +10 -1
- package/src/server/inline-moderation-actions.ts +6 -0
- package/src/server/message-actions.ts +9 -0
- package/src/server/navigation-admin-actions.ts +6 -0
- package/src/server/plugin-admin.ts +7 -2
- package/src/server/plugin-pages.ts +50 -0
- package/src/server/plugin-panel.ts +34 -1
- package/src/server/plugin-routes.ts +8 -0
- package/src/server/plugin-view.tsx +46 -7
- package/src/server/poll-actions.ts +13 -1
- package/src/server/presence.ts +23 -0
- package/src/server/reply-core.ts +8 -1
- package/src/server/report-actions.ts +13 -2
- package/src/server/reputation-actions.ts +12 -1
- package/src/server/search-page.ts +8 -2
- package/src/server/search.ts +18 -5
- package/src/server/subscription-actions.ts +9 -0
- package/src/server/thread-core.ts +10 -2
- package/src/server/thread-tool-actions.ts +6 -0
- package/src/server/unread-goto.ts +28 -0
- package/src/server/upgrade-notice.ts +1 -1
- package/src/server/usercp-actions.ts +31 -1
- package/src/server/viewer-preferences.ts +5 -0
- package/src/server/webhook-actions.ts +98 -0
- package/src/server/webhooks-admin.ts +122 -0
- package/src/theme/contract.fixture.ts +36 -5
- package/src/view/account-copy.ts +39 -0
- package/src/view/admin-copy.ts +4 -9
- package/src/view/admin-nav.ts +6 -0
- package/src/view/admin-panel-copy.ts +23 -0
- package/src/view/attachments.ts +33 -1
- package/src/view/board-index.ts +31 -2
- package/src/view/board-title.ts +1 -0
- package/src/view/content-copy.ts +22 -12
- package/src/view/copy.ts +17 -0
- package/src/view/discovery-view.ts +2 -1
- package/src/view/drafts.ts +45 -0
- package/src/view/editor-toolbar.ts +14 -0
- package/src/view/forum-display.ts +39 -7
- package/src/view/modcp-nav.ts +32 -0
- package/src/view/moderation-copy.ts +20 -0
- package/src/view/notifications.ts +1 -0
- package/src/view/plugin-panel.ts +13 -0
- package/src/view/poll-vote.ts +46 -0
- package/src/view/post-link.ts +8 -1
- package/src/view/progressive-enhancement.ts +5 -0
- package/src/view/shell.ts +2 -1
- package/src/view/subscriptions.ts +1 -0
- package/src/view/thread-view.ts +41 -2
- package/src/view/usercp-nav.ts +6 -0
- package/src/view/usercp.ts +14 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { revalidatePath } from 'next/cache'
|
|
4
4
|
|
|
5
|
-
import { readMatrixCell } from '@meith/authorization'
|
|
5
|
+
import { diffMatrixSubmission, matrixCellName, readMatrixCell } from '@meith/authorization'
|
|
6
6
|
import { CacheTags, FORUM_PERMISSION_FIELDS, ValidationError } from '@meith/core'
|
|
7
7
|
import { MODERATOR_RIGHTS } from '@meith/db'
|
|
8
8
|
import { drivers } from '@meith/drivers'
|
|
@@ -86,31 +86,38 @@ export async function saveForumOptionsAction(_prev: FormState, form: FormData):
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
export async function
|
|
89
|
+
export async function saveForumPermissionMatrixAction(
|
|
90
90
|
_prev: FormState,
|
|
91
91
|
form: FormData,
|
|
92
92
|
): Promise<FormState> {
|
|
93
93
|
try {
|
|
94
94
|
await requireAdmin()
|
|
95
95
|
const id = forumId(form)
|
|
96
|
-
const
|
|
97
|
-
if (!Number.isSafeInteger(groupId) || groupId <= 0) {
|
|
98
|
-
throw new ValidationError(msg('error.app.such-group'))
|
|
99
|
-
}
|
|
96
|
+
const repository = requireForumAdmin()
|
|
100
97
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
98
|
+
const groups = await repository.listGroups()
|
|
99
|
+
const current = await repository.readOverrides([id])
|
|
100
|
+
|
|
101
|
+
const submissions = groups.map((group) => ({
|
|
102
|
+
groupId: group.id,
|
|
103
|
+
values: Object.fromEntries(
|
|
104
|
+
FORUM_PERMISSION_FIELDS.map((field) => {
|
|
105
|
+
const raw = form.get(matrixCellName(group.id, field.key))
|
|
106
|
+
return [field.key, readMatrixCell(field, typeof raw === 'string' ? raw : undefined)]
|
|
107
|
+
}),
|
|
108
|
+
),
|
|
109
|
+
}))
|
|
106
110
|
|
|
107
|
-
|
|
111
|
+
const changes = diffMatrixSubmission(current, id, submissions)
|
|
112
|
+
await repository.saveOverridesForGroups(id, changes)
|
|
108
113
|
|
|
109
114
|
refreshForumPermissionScreens()
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
for (const change of changes) {
|
|
116
|
+
await recordAdminAction({
|
|
117
|
+
action: 'forum.permissions_changed',
|
|
118
|
+
detail: { forumId: id, groupId: change.groupId },
|
|
119
|
+
})
|
|
120
|
+
}
|
|
114
121
|
|
|
115
122
|
return { notice: 'saved' }
|
|
116
123
|
} catch (err) {
|
|
@@ -2,10 +2,14 @@ import { msg } from '@meith/i18n'
|
|
|
2
2
|
import 'server-only'
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
buildFieldMatrix,
|
|
5
6
|
buildPermissionMatrix,
|
|
6
7
|
type CopyPlan,
|
|
8
|
+
type FieldMatrixRow,
|
|
7
9
|
type ForumOverride,
|
|
10
|
+
type MatrixColumn,
|
|
8
11
|
type MatrixRow,
|
|
12
|
+
matrixColumns,
|
|
9
13
|
planCopyToDescendants,
|
|
10
14
|
} from '@meith/authorization'
|
|
11
15
|
import { ForbiddenError, type PermissionSet } from '@meith/core'
|
|
@@ -37,6 +41,8 @@ export function ancestorChain(forum: ForumRow): readonly number[] {
|
|
|
37
41
|
export interface ForumMatrixView {
|
|
38
42
|
readonly forum: ForumRow
|
|
39
43
|
readonly rows: readonly MatrixRow[]
|
|
44
|
+
readonly columns: readonly MatrixColumn[]
|
|
45
|
+
readonly fields: readonly FieldMatrixRow[]
|
|
40
46
|
readonly chain: readonly number[]
|
|
41
47
|
readonly overrides: readonly ForumOverride[]
|
|
42
48
|
readonly groups: readonly { readonly groupId: number; readonly title: string }[]
|
|
@@ -77,6 +83,7 @@ export async function buildForumMatrixView(forumId: number): Promise<ForumMatrix
|
|
|
77
83
|
const descendants = all.filter((row) => row.path.startsWith(`${forum.path}.`))
|
|
78
84
|
|
|
79
85
|
const overrides = await repository.readOverrides([...chain, ...descendants.map((row) => row.id)])
|
|
86
|
+
const rows = buildPermissionMatrix({ chain, groups, overrides })
|
|
80
87
|
|
|
81
88
|
return {
|
|
82
89
|
forum,
|
|
@@ -84,7 +91,9 @@ export async function buildForumMatrixView(forumId: number): Promise<ForumMatrix
|
|
|
84
91
|
overrides,
|
|
85
92
|
descendants,
|
|
86
93
|
groups: groups.map(({ groupId, title }) => ({ groupId, title })),
|
|
87
|
-
rows
|
|
94
|
+
rows,
|
|
95
|
+
columns: matrixColumns(rows),
|
|
96
|
+
fields: buildFieldMatrix(rows),
|
|
88
97
|
}
|
|
89
98
|
}
|
|
90
99
|
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '@meith/moderation'
|
|
17
17
|
|
|
18
18
|
import type { FormState } from './auth-form-state'
|
|
19
|
+
import { requireConfirmation } from './confirm'
|
|
19
20
|
import { getContainer } from './container'
|
|
20
21
|
import { getActor } from './context'
|
|
21
22
|
import { formStateReporter } from './form-state-reporter'
|
|
@@ -71,6 +72,11 @@ export async function inlineModerateAction(_prev: FormState, form: FormData): Pr
|
|
|
71
72
|
throw new ForbiddenError(msg('error.app.moderate-anything-here'))
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
if (tool === 'delete') {
|
|
76
|
+
const confirm = requireConfirmation(form, await tr('moderationForm.confirm.delete'))
|
|
77
|
+
if (confirm !== null) return confirm
|
|
78
|
+
}
|
|
79
|
+
|
|
74
80
|
outcome = await new InlineModeration({ inline: inlineModeration }).apply({
|
|
75
81
|
selection,
|
|
76
82
|
tool,
|
|
@@ -9,9 +9,11 @@ import { parseFolder } from '@meith/messages'
|
|
|
9
9
|
import { folderHref } from '../view/messages'
|
|
10
10
|
import { dailyLimitMessage, limitMessage, spendDailyLimit, spendLimit } from './antispam'
|
|
11
11
|
import type { FormState } from './auth-form-state'
|
|
12
|
+
import { requireConfirmation } from './confirm'
|
|
12
13
|
import { getActor } from './context'
|
|
13
14
|
import { formStateReporter } from './form-state-reporter'
|
|
14
15
|
import { text } from './form-values'
|
|
16
|
+
import { tr } from './i18n'
|
|
15
17
|
import { requireMessaging } from './messages'
|
|
16
18
|
|
|
17
19
|
const toFormState = formStateReporter('message-actions', 'unexpected error in a message action')
|
|
@@ -65,12 +67,19 @@ export async function messageBulkAction(_prev: FormState, form: FormData): Promi
|
|
|
65
67
|
const { service, userId } = await requireMessaging(await getActor())
|
|
66
68
|
|
|
67
69
|
if (command === 'empty') {
|
|
70
|
+
const confirm = requireConfirmation(form, await tr('messageForm.confirm.emptyTrash'))
|
|
71
|
+
if (confirm !== null) return confirm
|
|
68
72
|
query = `emptied=${await service.emptyTrash(userId)}`
|
|
69
73
|
} else {
|
|
70
74
|
const copyIds = selectedIds(form)
|
|
71
75
|
if (copyIds.length === 0)
|
|
72
76
|
throw new ValidationError(msg('error.app.select-at-least-one-message'))
|
|
73
77
|
|
|
78
|
+
if (command === 'delete') {
|
|
79
|
+
const confirm = requireConfirmation(form, await tr('messageForm.confirm.deleteForever'))
|
|
80
|
+
if (confirm !== null) return confirm
|
|
81
|
+
}
|
|
82
|
+
|
|
74
83
|
switch (command) {
|
|
75
84
|
case 'read':
|
|
76
85
|
query = `marked=${await service.markRead(userId, copyIds)}`
|
|
@@ -16,8 +16,10 @@ import { NAVIGATION_AUDIENCE_VALUES, outlineOf } from '../view/navigation'
|
|
|
16
16
|
import { isNavigationNudge, isWhereItIs, nudgeTarget } from '../view/navigation-arrange'
|
|
17
17
|
import { recordAdminAction, requireAdmin } from './admin'
|
|
18
18
|
import type { FormState } from './auth-form-state'
|
|
19
|
+
import { requireConfirmation } from './confirm'
|
|
19
20
|
import { formStateReporter } from './form-state-reporter'
|
|
20
21
|
import { checkbox, trimmedText } from './form-values'
|
|
22
|
+
import { tr } from './i18n'
|
|
21
23
|
import { navigationRepository } from './navigation'
|
|
22
24
|
import { isSafeLocalPath } from './safe-path'
|
|
23
25
|
|
|
@@ -150,6 +152,10 @@ export async function deleteNavigationItemAction(
|
|
|
150
152
|
await requireAdmin()
|
|
151
153
|
|
|
152
154
|
const navigationItemId = itemId(form)
|
|
155
|
+
|
|
156
|
+
const confirm = requireConfirmation(form, await tr('adminNavigation.confirm.delete'))
|
|
157
|
+
if (confirm !== null) return confirm
|
|
158
|
+
|
|
153
159
|
await requireNavigation().delete(navigationItemId)
|
|
154
160
|
|
|
155
161
|
await refresh()
|
|
@@ -2,7 +2,7 @@ import 'server-only'
|
|
|
2
2
|
|
|
3
3
|
import forumConfig from '@board/config'
|
|
4
4
|
|
|
5
|
-
import { env, logger, readPluginEnv } from '@meith/core'
|
|
5
|
+
import { cronCadenceSeconds, env, logger, parseCron, readPluginEnv } from '@meith/core'
|
|
6
6
|
import {
|
|
7
7
|
appliedPluginMigrations,
|
|
8
8
|
getDb,
|
|
@@ -58,6 +58,7 @@ export interface PluginTaskRow {
|
|
|
58
58
|
readonly id: string
|
|
59
59
|
readonly registeredId: string
|
|
60
60
|
readonly intervalSeconds: number
|
|
61
|
+
readonly schedule: string | null
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
export interface PluginPageRow {
|
|
@@ -228,7 +229,11 @@ export async function pluginInventory(t?: Translator): Promise<PluginInventory>
|
|
|
228
229
|
(task): PluginTaskRow => ({
|
|
229
230
|
id: task.id,
|
|
230
231
|
registeredId: pluginTaskId(entry.key, task.id),
|
|
231
|
-
intervalSeconds:
|
|
232
|
+
intervalSeconds:
|
|
233
|
+
task.schedule === undefined
|
|
234
|
+
? task.intervalSeconds
|
|
235
|
+
: cronCadenceSeconds(parseCron(task.schedule)),
|
|
236
|
+
schedule: task.schedule ?? null,
|
|
232
237
|
}),
|
|
233
238
|
),
|
|
234
239
|
|
|
@@ -7,6 +7,7 @@ import { logger } from '@meith/core'
|
|
|
7
7
|
import type { PluginDefinition } from '@meith/plugin-kit'
|
|
8
8
|
|
|
9
9
|
import { getTranslator } from './i18n'
|
|
10
|
+
import { resolveModCpAccess } from './modcp'
|
|
10
11
|
import { runtimeContextFor } from './plugin-runtime'
|
|
11
12
|
import { getSettingOverrides } from './settings'
|
|
12
13
|
|
|
@@ -26,6 +27,11 @@ export type BoardPageResult =
|
|
|
26
27
|
| { readonly outcome: 'missing' }
|
|
27
28
|
| { readonly outcome: 'sign-in-first'; readonly title: string }
|
|
28
29
|
|
|
30
|
+
export type StaffPageResult =
|
|
31
|
+
| { readonly outcome: 'rendered'; readonly title: string; readonly node: ReactNode | null }
|
|
32
|
+
| { readonly outcome: 'missing' }
|
|
33
|
+
| { readonly outcome: 'forbidden' }
|
|
34
|
+
|
|
29
35
|
function translated(
|
|
30
36
|
t: Awaited<ReturnType<typeof getTranslator>> | undefined,
|
|
31
37
|
key: string | undefined,
|
|
@@ -59,6 +65,7 @@ export async function renderPluginBoardPage(
|
|
|
59
65
|
|
|
60
66
|
const page = (definition.pages ?? []).find((candidate) => candidate.path === path)
|
|
61
67
|
if (page === undefined) return { outcome: 'missing' }
|
|
68
|
+
if (page.access === 'staff') return { outcome: 'missing' }
|
|
62
69
|
|
|
63
70
|
const overrides = await getSettingOverrides()
|
|
64
71
|
if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return { outcome: 'missing' }
|
|
@@ -90,6 +97,49 @@ export async function renderPluginBoardPage(
|
|
|
90
97
|
}
|
|
91
98
|
}
|
|
92
99
|
|
|
100
|
+
export async function renderPluginStaffPage(
|
|
101
|
+
pluginKey: string,
|
|
102
|
+
path: string,
|
|
103
|
+
request: BoardPageRequest,
|
|
104
|
+
): Promise<StaffPageResult> {
|
|
105
|
+
const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
|
|
106
|
+
const definition = entry?.plugin as PluginDefinition | undefined
|
|
107
|
+
|
|
108
|
+
if (entry === undefined || definition === undefined) return { outcome: 'missing' }
|
|
109
|
+
if (entry.enabled === false) return { outcome: 'missing' }
|
|
110
|
+
|
|
111
|
+
const page = (definition.pages ?? []).find((candidate) => candidate.path === path)
|
|
112
|
+
if (page === undefined || page.access !== 'staff') return { outcome: 'missing' }
|
|
113
|
+
|
|
114
|
+
const overrides = await getSettingOverrides()
|
|
115
|
+
if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return { outcome: 'missing' }
|
|
116
|
+
|
|
117
|
+
const access = await resolveModCpAccess()
|
|
118
|
+
if (access === null || !access.hasGroupAccess) return { outcome: 'forbidden' }
|
|
119
|
+
|
|
120
|
+
const [log, t] = [logger({ component: 'plugin-page', plugin: pluginKey }), await getTranslator()]
|
|
121
|
+
const title = translated(t, page.titleKey, page.title, page.titleArgs)
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
return {
|
|
125
|
+
outcome: 'rendered',
|
|
126
|
+
title,
|
|
127
|
+
node: await page.render({
|
|
128
|
+
...runtimeContextFor(pluginKey, definition, overrides, log),
|
|
129
|
+
viewer: request.viewer,
|
|
130
|
+
path,
|
|
131
|
+
query: request.query,
|
|
132
|
+
boardUrl: request.boardUrl,
|
|
133
|
+
locale: t.locale,
|
|
134
|
+
t,
|
|
135
|
+
}),
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
log.error({ err: error, path }, 'plugin staff page failed to render')
|
|
139
|
+
return { outcome: 'rendered', title, node: null }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
93
143
|
export async function renderPluginAdminPage(
|
|
94
144
|
pluginKey: string,
|
|
95
145
|
path: string,
|
|
@@ -3,7 +3,7 @@ import 'server-only'
|
|
|
3
3
|
import forumConfig from '@board/config'
|
|
4
4
|
|
|
5
5
|
import type { Translator } from '@meith/i18n'
|
|
6
|
-
import { type PluginDefinition, pluginAdminPath } from '@meith/plugin-kit'
|
|
6
|
+
import { type PluginDefinition, pluginAdminPath, pluginStaffPagePath } from '@meith/plugin-kit'
|
|
7
7
|
|
|
8
8
|
import type { PluginPage } from '@/view/plugin-panel'
|
|
9
9
|
|
|
@@ -47,3 +47,36 @@ export async function pluginPanelSection(
|
|
|
47
47
|
})),
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
export async function pluginStaffPanelSection(
|
|
52
|
+
pluginKey: string,
|
|
53
|
+
t?: Translator,
|
|
54
|
+
): Promise<PluginPanelSection | null> {
|
|
55
|
+
const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
|
|
56
|
+
const definition = entry?.plugin as PluginDefinition | undefined
|
|
57
|
+
|
|
58
|
+
if (entry === undefined || definition === undefined) return null
|
|
59
|
+
if (entry.enabled === false) return null
|
|
60
|
+
|
|
61
|
+
const pages = (definition.pages ?? []).filter((page) => page.access === 'staff')
|
|
62
|
+
if (pages.length === 0) return null
|
|
63
|
+
|
|
64
|
+
const overrides = await getSettingOverrides()
|
|
65
|
+
if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return null
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
key: pluginKey,
|
|
69
|
+
name:
|
|
70
|
+
t !== undefined && definition.nameKey !== undefined && t.has(definition.nameKey)
|
|
71
|
+
? t.t(definition.nameKey)
|
|
72
|
+
: definition.name,
|
|
73
|
+
pages: pages.map((page) => ({
|
|
74
|
+
path: page.path,
|
|
75
|
+
title:
|
|
76
|
+
t !== undefined && page.titleKey !== undefined && t.has(page.titleKey)
|
|
77
|
+
? t.t(page.titleKey, page.titleArgs)
|
|
78
|
+
: page.title,
|
|
79
|
+
href: pluginStaffPagePath(pluginKey, page.path),
|
|
80
|
+
})),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { recordAdminAction, requireAdmin } from './admin'
|
|
17
17
|
import { boardUrl } from './board-url'
|
|
18
18
|
import { getActor } from './context'
|
|
19
|
+
import { resolveModCpAccess } from './modcp'
|
|
19
20
|
import { activeDefinitions, pluginHost, syncPluginEnablement } from './plugin-host'
|
|
20
21
|
import { runtimeContextFor } from './plugin-runtime'
|
|
21
22
|
import { viewerRef } from './plugin-view'
|
|
@@ -213,6 +214,13 @@ export async function dispatchPluginRoute(
|
|
|
213
214
|
return fail(401, 'unauthenticated', 'Sign in to use this.')
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
if (route.access === 'staff') {
|
|
218
|
+
const access = await resolveModCpAccess()
|
|
219
|
+
if (access === null || !access.hasGroupAccess) {
|
|
220
|
+
return fail(403, 'staff_only', await tr('notice.app.moderators-only-endpoint'))
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
216
224
|
if (route.access === 'admin') {
|
|
217
225
|
try {
|
|
218
226
|
await requireAdmin()
|
|
@@ -9,8 +9,10 @@ import type {
|
|
|
9
9
|
HookValue,
|
|
10
10
|
PluginRegion,
|
|
11
11
|
PluginRegionContext,
|
|
12
|
+
ThreadRowBadgeSubject,
|
|
12
13
|
} from '@meith/plugin-kit'
|
|
13
14
|
|
|
15
|
+
import { getTranslator } from './i18n'
|
|
14
16
|
import { pluginHost, syncPluginEnablement } from './plugin-host'
|
|
15
17
|
|
|
16
18
|
export function viewerRef(actor: Actor): { userId: number | null; isGuest: boolean } {
|
|
@@ -37,22 +39,59 @@ export async function emitEvent<K extends HookName>(
|
|
|
37
39
|
|
|
38
40
|
export async function pluginRegion(
|
|
39
41
|
region: PluginRegion,
|
|
40
|
-
context: Omit<PluginRegionContext, 'region' | 'runtime'>,
|
|
42
|
+
context: Omit<PluginRegionContext, 'region' | 'runtime' | 'locale' | 't'>,
|
|
43
|
+
card?: string,
|
|
41
44
|
): Promise<React.ReactNode> {
|
|
42
45
|
await syncPluginEnablement()
|
|
43
46
|
|
|
44
|
-
const
|
|
47
|
+
const t = await getTranslator()
|
|
48
|
+
const nodes = await pluginHost.renderRegion(region, { ...context, region, locale: t.locale, t })
|
|
45
49
|
if (nodes.length === 0) return null
|
|
46
50
|
|
|
47
51
|
return (
|
|
48
52
|
<>
|
|
49
|
-
{nodes.map((entry) =>
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
{nodes.map((entry) =>
|
|
54
|
+
card === undefined ? (
|
|
55
|
+
<Fragment key={entry.key}>{entry.node}</Fragment>
|
|
56
|
+
) : (
|
|
57
|
+
<section key={entry.key} className={card}>
|
|
58
|
+
{entry.node}
|
|
59
|
+
</section>
|
|
60
|
+
),
|
|
61
|
+
)}
|
|
52
62
|
</>
|
|
53
63
|
)
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
export async function boardRegion(
|
|
57
|
-
|
|
66
|
+
export async function boardRegion(
|
|
67
|
+
region: PluginRegion,
|
|
68
|
+
actor: Actor,
|
|
69
|
+
card?: string,
|
|
70
|
+
): Promise<React.ReactNode> {
|
|
71
|
+
return pluginRegion(region, { viewer: viewerRef(actor), subjectId: null, authorId: null }, card)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function threadRowBadges(
|
|
75
|
+
actor: Actor,
|
|
76
|
+
threads: readonly ThreadRowBadgeSubject[],
|
|
77
|
+
): Promise<ReadonlyMap<number, React.ReactNode>> {
|
|
78
|
+
await syncPluginEnablement()
|
|
79
|
+
if (threads.length === 0) return new Map()
|
|
80
|
+
|
|
81
|
+
const t = await getTranslator()
|
|
82
|
+
const byThread = await pluginHost.renderThreadRowBadges({
|
|
83
|
+
viewer: viewerRef(actor),
|
|
84
|
+
threads,
|
|
85
|
+
locale: t.locale,
|
|
86
|
+
t,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const out = new Map<number, React.ReactNode>()
|
|
90
|
+
for (const [threadId, nodes] of byThread) {
|
|
91
|
+
out.set(
|
|
92
|
+
threadId,
|
|
93
|
+
nodes.map((entry) => <Fragment key={entry.key}>{entry.node}</Fragment>),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
58
97
|
}
|
|
@@ -6,6 +6,9 @@ import { ForbiddenError, ValidationError } from '@meith/core'
|
|
|
6
6
|
import { msg } from '@meith/i18n'
|
|
7
7
|
import { type PollEditOption, PollService } from '@meith/polls'
|
|
8
8
|
|
|
9
|
+
import { type PollVoteView, pollVoteView } from '@/view/poll-vote'
|
|
10
|
+
import { isEnhancedSubmit } from '@/view/progressive-enhancement'
|
|
11
|
+
|
|
9
12
|
import { getContainer } from './container'
|
|
10
13
|
import { getActor } from './context'
|
|
11
14
|
import { emitEvent, viewerRef } from './plugin-view'
|
|
@@ -50,7 +53,10 @@ function editedOptions(form: FormData): readonly PollEditOption[] {
|
|
|
50
53
|
})
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
export async function votePollAction(
|
|
56
|
+
export async function votePollAction(
|
|
57
|
+
_prev: PollVoteView | null,
|
|
58
|
+
form: FormData,
|
|
59
|
+
): Promise<PollVoteView | null> {
|
|
54
60
|
const threadId = id(form, 'threadId')
|
|
55
61
|
const pollId = id(form, 'pollId')
|
|
56
62
|
const actor = await getActor()
|
|
@@ -72,6 +78,12 @@ export async function votePollAction(form: FormData): Promise<void> {
|
|
|
72
78
|
for (const optionId of chosen) {
|
|
73
79
|
await emitEvent('poll.voted', { pollId, optionId }, viewerRef(actor))
|
|
74
80
|
}
|
|
81
|
+
|
|
82
|
+
if (isEnhancedSubmit(form)) {
|
|
83
|
+
const fresh = await polls.find(threadId, actor.userId)
|
|
84
|
+
return fresh === null ? null : pollVoteView(fresh, scope.mayVote, new Date())
|
|
85
|
+
}
|
|
86
|
+
|
|
75
87
|
redirect(`/thread/${threadId}`)
|
|
76
88
|
}
|
|
77
89
|
|
package/src/server/presence.ts
CHANGED
|
@@ -134,6 +134,29 @@ export async function onlineScopeFor(actor: Actor): Promise<OnlineScope> {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
export interface AuthorPresence {
|
|
138
|
+
readonly lastActiveAt: Date | null
|
|
139
|
+
readonly invisible: boolean
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function presenceFor(
|
|
143
|
+
userIds: readonly number[],
|
|
144
|
+
): Promise<ReadonlyMap<number, AuthorPresence>> {
|
|
145
|
+
const repo = presenceRepository()
|
|
146
|
+
if (repo === null || userIds.length === 0) return new Map()
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const rows = await repo.lastActiveFor(userIds)
|
|
150
|
+
const out = new Map<number, AuthorPresence>()
|
|
151
|
+
for (const row of rows) {
|
|
152
|
+
out.set(row.userId, { lastActiveAt: row.lastActiveAt, invisible: row.invisible })
|
|
153
|
+
}
|
|
154
|
+
return out
|
|
155
|
+
} catch {
|
|
156
|
+
return new Map()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
137
160
|
export async function readOnline(actor: Actor, now: Date): Promise<OnlineSnapshot | null> {
|
|
138
161
|
const repo = presenceRepository()
|
|
139
162
|
if (repo === null) return null
|
package/src/server/reply-core.ts
CHANGED
|
@@ -4,7 +4,12 @@ import 'server-only'
|
|
|
4
4
|
import type { Actor } from '@meith/authorization'
|
|
5
5
|
import { ForbiddenError, ValidationError } from '@meith/core'
|
|
6
6
|
import { restrictsPosting } from '@meith/moderation'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type AuthorRestriction,
|
|
9
|
+
ReplyComposer,
|
|
10
|
+
type ReplyTarget,
|
|
11
|
+
type SubscriptionCadence,
|
|
12
|
+
} from '@meith/threads'
|
|
8
13
|
|
|
9
14
|
import {
|
|
10
15
|
dailyLimitMessage,
|
|
@@ -65,6 +70,7 @@ export async function resolveReplyTarget(
|
|
|
65
70
|
export interface SubmitReplyInput {
|
|
66
71
|
readonly message: string
|
|
67
72
|
readonly subscribe?: boolean
|
|
73
|
+
readonly subscribeMode?: SubscriptionCadence
|
|
68
74
|
readonly seenLastPostId?: number | null
|
|
69
75
|
}
|
|
70
76
|
|
|
@@ -125,6 +131,7 @@ export async function submitReply(
|
|
|
125
131
|
{
|
|
126
132
|
message: draft.body,
|
|
127
133
|
subscribe: input.subscribe ?? false,
|
|
134
|
+
...(input.subscribeMode === undefined ? {} : { subscribeMode: input.subscribeMode }),
|
|
128
135
|
seenLastPostId: input.seenLastPostId ?? null,
|
|
129
136
|
bypassesModeration: authorizer.can(actor, 'content.viewUnapproved', scope),
|
|
130
137
|
bypassesFlood: authorizer.can(actor, 'flood.bypass'),
|
|
@@ -5,7 +5,7 @@ import { redirect } from 'next/navigation'
|
|
|
5
5
|
import { ForbiddenError, ValidationError } from '@meith/core'
|
|
6
6
|
import { currentRequestId } from '@meith/core/logger'
|
|
7
7
|
import { msg } from '@meith/i18n'
|
|
8
|
-
import { parseTargetKind, ReportService } from '@meith/moderation'
|
|
8
|
+
import { parseReportCategory, parseTargetKind, ReportService } from '@meith/moderation'
|
|
9
9
|
|
|
10
10
|
import { limitMessage, spendLimit } from './antispam'
|
|
11
11
|
import type { FormState } from './auth-form-state'
|
|
@@ -17,6 +17,7 @@ import { tr } from './i18n'
|
|
|
17
17
|
import { reportNotifier } from './notifications'
|
|
18
18
|
import { emitEvent } from './plugin-view'
|
|
19
19
|
import { resolveReportScope } from './report-scope'
|
|
20
|
+
import { getSettings } from './settings'
|
|
20
21
|
|
|
21
22
|
function field(form: FormData, name: string): string {
|
|
22
23
|
const value = form.get(name)
|
|
@@ -29,12 +30,17 @@ export async function fileReportAction(_prev: FormState, form: FormData): Promis
|
|
|
29
30
|
const kind = parseTargetKind(field(form, 'kind'))
|
|
30
31
|
const targetId = positiveIntIn(field(form, 'targetId'))
|
|
31
32
|
const reason = field(form, 'reason')
|
|
32
|
-
const
|
|
33
|
+
const category = parseReportCategory(field(form, 'category'))
|
|
34
|
+
const values = { reason, ...(category === null ? {} : { category }) }
|
|
33
35
|
|
|
34
36
|
if (kind === null || targetId === null) {
|
|
35
37
|
return { error: await tr('notice.app.exist'), values }
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
if (category === null) {
|
|
41
|
+
return { error: await tr('notice.app.choose-report-category'), values }
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
const { authorizer, reports } = getContainer()
|
|
39
45
|
if (reports === null) {
|
|
40
46
|
return {
|
|
@@ -72,11 +78,16 @@ export async function fileReportAction(_prev: FormState, form: FormData): Promis
|
|
|
72
78
|
return { error: limitMessage(limited), values }
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
const settings = await getSettings()
|
|
82
|
+
const flagThreshold = Number(settings.get('moderation.flag_threshold') ?? 0)
|
|
83
|
+
|
|
75
84
|
const outcome = await new ReportService({ reports }).file({
|
|
76
85
|
kind,
|
|
77
86
|
targetId,
|
|
87
|
+
category,
|
|
78
88
|
reason,
|
|
79
89
|
reporterUserId: actor.userId,
|
|
90
|
+
flagThreshold,
|
|
80
91
|
})
|
|
81
92
|
|
|
82
93
|
if (!outcome.duplicate) {
|
|
@@ -7,13 +7,19 @@ import { msg } from '@meith/i18n'
|
|
|
7
7
|
import { parseRating } from '@meith/reputation'
|
|
8
8
|
|
|
9
9
|
import { postLink } from '@/view/post-link'
|
|
10
|
+
import { isEnhancedSubmit } from '@/view/progressive-enhancement'
|
|
10
11
|
|
|
11
12
|
import type { FormState } from './auth-form-state'
|
|
12
13
|
import { getActor } from './context'
|
|
13
14
|
import { formStateReporter } from './form-state-reporter'
|
|
14
15
|
import { trimmedText } from './form-values'
|
|
15
16
|
import { emitEvent, viewerRef } from './plugin-view'
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
reputationService,
|
|
19
|
+
reputationSettings,
|
|
20
|
+
thanksForPosts,
|
|
21
|
+
viewerRaterLimits,
|
|
22
|
+
} from './reputation'
|
|
17
23
|
import { requireRateablePost } from './reputation-target'
|
|
18
24
|
import { isSafeLocalPath } from './safe-path'
|
|
19
25
|
|
|
@@ -138,6 +144,11 @@ export async function thankForPostAction(_prev: FormState, form: FormData): Prom
|
|
|
138
144
|
})
|
|
139
145
|
await announceReputation(service, userId, 1)
|
|
140
146
|
}
|
|
147
|
+
|
|
148
|
+
if (isEnhancedSubmit(form)) {
|
|
149
|
+
const thanks = (await thanksForPosts([postId])).get(postId)
|
|
150
|
+
return { thanks: thanks ?? { thanked: false, count: 0 } }
|
|
151
|
+
}
|
|
141
152
|
} catch (err) {
|
|
142
153
|
return toFormState(err)
|
|
143
154
|
}
|
|
@@ -30,7 +30,13 @@ import { REFINE_FIELDS } from '@/view/search-controls'
|
|
|
30
30
|
import { limitMessage, spendLimit } from './antispam'
|
|
31
31
|
import { getContainer } from './container'
|
|
32
32
|
import { filterView, viewerRef } from './plugin-view'
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
requireSearch,
|
|
35
|
+
requireSearchEnabled,
|
|
36
|
+
searchLanguage,
|
|
37
|
+
searchMinWordLength,
|
|
38
|
+
searchScopeFor,
|
|
39
|
+
} from './search'
|
|
34
40
|
import { getSettings } from './settings'
|
|
35
41
|
|
|
36
42
|
export const SEARCH_PAGE = 20
|
|
@@ -149,7 +155,7 @@ export async function openSearch(input: {
|
|
|
149
155
|
const refine = trimRefinement(input.refine, filters)
|
|
150
156
|
const effective = narrowFilters(filters, refine)
|
|
151
157
|
const scope = await searchScopeFor(input.actor)
|
|
152
|
-
const provider = requireSearch()
|
|
158
|
+
const provider = requireSearch(await searchLanguage())
|
|
153
159
|
|
|
154
160
|
const [results, summary] = await Promise.all([
|
|
155
161
|
provider.search(
|