@meith/web 0.31.0 → 0.33.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/layout.tsx +23 -8
- 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/admin-forms.tsx +57 -9
- 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/components/shell/view-tabs.tsx +1 -1
- package/src/server/admin-actions.ts +118 -49
- package/src/server/admin-settings-actions.ts +5 -0
- package/src/server/admin.ts +70 -2
- 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/cookies.ts +18 -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/push-actions.ts +9 -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/session-cookies.ts +18 -0
- 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/styles/globals.css +1 -1
- 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 +29 -1
- 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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { authenticateFeedToken, FEED_TOKEN_PREFIX, issueToken } from '@meith/api'
|
|
4
|
+
import { PUBLIC_CONTENT, ValidationError } from '@meith/core'
|
|
5
|
+
import {
|
|
6
|
+
type FeedScope,
|
|
7
|
+
type FeedTokenSummary,
|
|
8
|
+
getDb,
|
|
9
|
+
PostgresFeedTokenRepository,
|
|
10
|
+
} from '@meith/db'
|
|
11
|
+
import { msg } from '@meith/i18n'
|
|
12
|
+
|
|
13
|
+
import { getContainer } from './container'
|
|
14
|
+
import { publicScope } from './syndication'
|
|
15
|
+
|
|
16
|
+
export type { FeedTokenSummary }
|
|
17
|
+
|
|
18
|
+
export function feedTokenStore(): PostgresFeedTokenRepository | null {
|
|
19
|
+
return getContainer().dataSource === 'postgres' ? new PostgresFeedTokenRepository(getDb()) : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function resolveFeedToken(presented: string): Promise<number | null> {
|
|
23
|
+
const store = feedTokenStore()
|
|
24
|
+
if (store === null) return null
|
|
25
|
+
|
|
26
|
+
const outcome = await authenticateFeedToken(presented, store)
|
|
27
|
+
if (!outcome.ok) return null
|
|
28
|
+
|
|
29
|
+
await store.touch(outcome.record.id, new Date()).catch(() => {})
|
|
30
|
+
return outcome.record.userId
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function memberFeedScope(userId: number): Promise<FeedScope | null> {
|
|
34
|
+
const { authorizer, actorSource } = getContainer()
|
|
35
|
+
const actor = await actorSource.buildForUser(userId)
|
|
36
|
+
if (actor === null) return null
|
|
37
|
+
|
|
38
|
+
return { ...(await authorizer.threadAudience(actor)), content: PUBLIC_CONTENT }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ResolvedFeedScope {
|
|
42
|
+
readonly scope: FeedScope
|
|
43
|
+
readonly tokened: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function feedScopeForRequest(request: Request): Promise<ResolvedFeedScope> {
|
|
47
|
+
const presented = new URL(request.url).searchParams.get('token')
|
|
48
|
+
if (presented === null || presented === '') {
|
|
49
|
+
return { scope: await publicScope(), tokened: false }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const userId = await resolveFeedToken(presented)
|
|
53
|
+
if (userId === null) return { scope: await publicScope(), tokened: true }
|
|
54
|
+
|
|
55
|
+
const member = await memberFeedScope(userId)
|
|
56
|
+
return { scope: member ?? (await publicScope()), tokened: true }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function issueFeedToken(userId: number): Promise<string> {
|
|
60
|
+
const store = feedTokenStore()
|
|
61
|
+
if (store === null) throw new ValidationError(msg('error.app.feed-token-needs-database'))
|
|
62
|
+
|
|
63
|
+
const issued = issueToken(FEED_TOKEN_PREFIX)
|
|
64
|
+
await store.regenerate({ userId, lookup: issued.lookup, secretHash: issued.secretHash })
|
|
65
|
+
return issued.token
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function revokeFeedToken(userId: number): Promise<void> {
|
|
69
|
+
const store = feedTokenStore()
|
|
70
|
+
if (store === null) return
|
|
71
|
+
await store.revokeForUser(userId)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function feedTokenSummary(userId: number): Promise<FeedTokenSummary | null> {
|
|
75
|
+
const store = feedTokenStore()
|
|
76
|
+
if (store === null) return null
|
|
77
|
+
return store.summaryForUser(userId)
|
|
78
|
+
}
|
|
@@ -82,6 +82,24 @@ export class FixturePostRepository implements PostRepository {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
async locateFirstUnread(
|
|
86
|
+
threadId: number,
|
|
87
|
+
after: { readonly postId: number; readonly since: Date | null },
|
|
88
|
+
options: { readonly scope: ContentScope; readonly pageSize: number },
|
|
89
|
+
): Promise<PostLocation | null> {
|
|
90
|
+
const first = this.rows
|
|
91
|
+
.filter(
|
|
92
|
+
(row) =>
|
|
93
|
+
row.threadId === threadId &&
|
|
94
|
+
options.scope.states.includes(row.visibility) &&
|
|
95
|
+
row.id > after.postId &&
|
|
96
|
+
(after.since === null || row.createdAt > after.since),
|
|
97
|
+
)
|
|
98
|
+
.sort((a, b) => a.id - b.id)[0]
|
|
99
|
+
|
|
100
|
+
return first === undefined ? null : this.locate(threadId, first.id, options)
|
|
101
|
+
}
|
|
102
|
+
|
|
85
103
|
async listThread(
|
|
86
104
|
threadId: number,
|
|
87
105
|
options: {
|
|
@@ -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
|
|
@@ -15,6 +15,8 @@ const toFormState = formStateReporter('push-actions', 'unexpected error in web p
|
|
|
15
15
|
|
|
16
16
|
const ENDPOINT_MAX = 1000
|
|
17
17
|
|
|
18
|
+
const SUBSCRIPTIONS_PER_USER_MAX = 20
|
|
19
|
+
|
|
18
20
|
const BASE64URL = /^[A-Za-z0-9_-]+$/
|
|
19
21
|
|
|
20
22
|
const IP_LITERAL = /^\[|^\d+\.\d+\.\d+\.\d+$/
|
|
@@ -77,6 +79,13 @@ export async function subscribeToPushAction(input: PushSubscriptionInput): Promi
|
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
const { service, userId } = await requirePushCentre()
|
|
82
|
+
|
|
83
|
+
const existing = await service.pushSubscriptions(userId)
|
|
84
|
+
const known = existing.some((subscription) => subscription.endpoint === input.endpoint)
|
|
85
|
+
if (!known && existing.length >= SUBSCRIPTIONS_PER_USER_MAX) {
|
|
86
|
+
return { error: await tr('notice.app.push-subscription-limit') }
|
|
87
|
+
}
|
|
88
|
+
|
|
80
89
|
await service.subscribeToPush({
|
|
81
90
|
userId,
|
|
82
91
|
endpoint: input.endpoint,
|
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'),
|