@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
|
@@ -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(
|
package/src/server/search.ts
CHANGED
|
@@ -3,7 +3,12 @@ import 'server-only'
|
|
|
3
3
|
|
|
4
4
|
import type { Actor } from '@meith/authorization'
|
|
5
5
|
import { contentScopeFrom, ForbiddenError } from '@meith/core'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_SEARCH_CONFIG,
|
|
8
|
+
getDb,
|
|
9
|
+
PostgresSearchRepository,
|
|
10
|
+
resolveSearchConfig,
|
|
11
|
+
} from '@meith/db'
|
|
7
12
|
import type { SearchScope } from '@meith/search'
|
|
8
13
|
|
|
9
14
|
import { getContainer } from './container'
|
|
@@ -21,12 +26,20 @@ export async function searchMinWordLength(): Promise<number> {
|
|
|
21
26
|
return (await getSettings()).get('search.min_word_length')
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
export function
|
|
25
|
-
return
|
|
29
|
+
export async function searchLanguage(): Promise<string> {
|
|
30
|
+
return resolveSearchConfig((await getSettings()).get('search.language'))
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
export function
|
|
29
|
-
|
|
33
|
+
export function searchProvider(
|
|
34
|
+
config: string = DEFAULT_SEARCH_CONFIG,
|
|
35
|
+
): PostgresSearchRepository | null {
|
|
36
|
+
return getContainer().dataSource === 'postgres'
|
|
37
|
+
? new PostgresSearchRepository(getDb(), config)
|
|
38
|
+
: null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function requireSearch(config: string = DEFAULT_SEARCH_CONFIG): PostgresSearchRepository {
|
|
42
|
+
const provider = searchProvider(config)
|
|
30
43
|
if (provider === null) {
|
|
31
44
|
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-11'))
|
|
32
45
|
}
|
|
@@ -6,8 +6,11 @@ import { env } from '@meith/core'
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
ADMIN_COOKIE,
|
|
9
|
+
ADMIN_SECOND_FACTOR_COOKIE,
|
|
9
10
|
adminCookie,
|
|
11
|
+
adminSecondFactorCookie,
|
|
10
12
|
clearedAdminCookie,
|
|
13
|
+
clearedAdminSecondFactorCookie,
|
|
11
14
|
clearedCookie,
|
|
12
15
|
passkeyCookie,
|
|
13
16
|
passkeyCookieName,
|
|
@@ -55,6 +58,21 @@ export async function readAdminToken(): Promise<string | null> {
|
|
|
55
58
|
return jar.get(ADMIN_COOKIE)?.value ?? null
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
export async function setAdminSecondFactorCookie(token: string, expiresAt: Date): Promise<void> {
|
|
62
|
+
const jar = await cookies()
|
|
63
|
+
jar.set(ADMIN_SECOND_FACTOR_COOKIE, token, adminSecondFactorCookie(expiresAt, secure()))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function readAdminSecondFactorToken(): Promise<string | null> {
|
|
67
|
+
const jar = await cookies()
|
|
68
|
+
return jar.get(ADMIN_SECOND_FACTOR_COOKIE)?.value ?? null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function clearAdminSecondFactorCookie(): Promise<void> {
|
|
72
|
+
const jar = await cookies()
|
|
73
|
+
jar.set(ADMIN_SECOND_FACTOR_COOKIE, '', clearedAdminSecondFactorCookie(secure()))
|
|
74
|
+
}
|
|
75
|
+
|
|
58
76
|
export async function clearSessionCookies(): Promise<void> {
|
|
59
77
|
const jar = await cookies()
|
|
60
78
|
const isSecure = secure()
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
type UnsubscribeScope,
|
|
13
13
|
} from '@meith/subscriptions'
|
|
14
14
|
|
|
15
|
+
import { isEnhancedSubmit } from '@/view/progressive-enhancement'
|
|
16
|
+
|
|
15
17
|
import type { FormState } from './auth-form-state'
|
|
16
18
|
import { getContainer } from './container'
|
|
17
19
|
import { getActor } from './context'
|
|
@@ -72,6 +74,8 @@ export async function subscribeAction(_prev: FormState, form: FormData): Promise
|
|
|
72
74
|
{ userId: actor.userId, target, targetId, subscribed: true },
|
|
73
75
|
{ requestId: currentRequestId() ?? null },
|
|
74
76
|
)
|
|
77
|
+
|
|
78
|
+
if (isEnhancedSubmit(form)) return { subscribed: true }
|
|
75
79
|
} catch (err) {
|
|
76
80
|
return toFormState(err)
|
|
77
81
|
}
|
|
@@ -106,6 +110,8 @@ export async function unsubscribeAction(_prev: FormState, form: FormData): Promi
|
|
|
106
110
|
{ userId: actor.userId, target, targetId, subscribed: false },
|
|
107
111
|
{ requestId: currentRequestId() ?? null },
|
|
108
112
|
)
|
|
113
|
+
|
|
114
|
+
if (isEnhancedSubmit(form)) return { subscribed: false }
|
|
109
115
|
} catch (err) {
|
|
110
116
|
return toFormState(err)
|
|
111
117
|
}
|
|
@@ -144,6 +150,8 @@ export async function unsubscribeByTokenAction(
|
|
|
144
150
|
['subscription.digest', false],
|
|
145
151
|
]),
|
|
146
152
|
)
|
|
153
|
+
} else if (claim.scope === 'board-digest') {
|
|
154
|
+
await notifications.savePreferences(claim.userId, 'email', new Map([['board.digest', false]]))
|
|
147
155
|
} else {
|
|
148
156
|
await new SubscriptionService({ subscriptions }).unsubscribe({
|
|
149
157
|
userId: claim.userId,
|
|
@@ -160,6 +168,7 @@ export async function unsubscribeByTokenAction(
|
|
|
160
168
|
|
|
161
169
|
function doneFor(scope: UnsubscribeScope): string {
|
|
162
170
|
if (scope === 'mass-mail') return 'announcements'
|
|
171
|
+
if (scope === 'board-digest') return 'boardDigest'
|
|
163
172
|
return scope === 'email' ? 'email' : 'one'
|
|
164
173
|
}
|
|
165
174
|
|
|
@@ -4,7 +4,12 @@ import type { Actor } from '@meith/authorization'
|
|
|
4
4
|
import { ForbiddenError, type ForumPermissions, ValidationError } from '@meith/core'
|
|
5
5
|
import { msg } from '@meith/i18n'
|
|
6
6
|
import { restrictsPosting } from '@meith/moderation'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type AuthorRestriction,
|
|
9
|
+
type ForumPostingTarget,
|
|
10
|
+
type SubscriptionCadence,
|
|
11
|
+
ThreadComposer,
|
|
12
|
+
} from '@meith/threads'
|
|
8
13
|
|
|
9
14
|
import {
|
|
10
15
|
dailyLimitMessage,
|
|
@@ -58,10 +63,12 @@ export interface SubmitThreadInput {
|
|
|
58
63
|
readonly message: string
|
|
59
64
|
readonly prefixId?: number | null
|
|
60
65
|
readonly subscribe?: boolean
|
|
66
|
+
readonly subscribeMode?: SubscriptionCadence
|
|
61
67
|
readonly poll?:
|
|
62
68
|
| {
|
|
63
69
|
readonly question: string
|
|
64
70
|
readonly options: readonly string[]
|
|
71
|
+
readonly closesAt?: Date | null
|
|
65
72
|
readonly maxOptions?: number
|
|
66
73
|
readonly allowRevote?: boolean
|
|
67
74
|
readonly publicVotes?: boolean
|
|
@@ -126,6 +133,7 @@ export async function submitThread(
|
|
|
126
133
|
message: draft.body,
|
|
127
134
|
prefixId: draft.prefixId,
|
|
128
135
|
subscribe: input.subscribe ?? false,
|
|
136
|
+
...(input.subscribeMode === undefined ? {} : { subscribeMode: input.subscribeMode }),
|
|
129
137
|
heldAsNewMember: await holdsNewMember({
|
|
130
138
|
actor,
|
|
131
139
|
postCount: profile.postCount,
|
|
@@ -139,7 +147,7 @@ export async function submitThread(
|
|
|
139
147
|
poll: {
|
|
140
148
|
question: input.poll.question,
|
|
141
149
|
options: input.poll.options,
|
|
142
|
-
closesAt: null,
|
|
150
|
+
closesAt: input.poll.closesAt ?? null,
|
|
143
151
|
...(input.poll.maxOptions === undefined ? {} : { maxOptions: input.poll.maxOptions }),
|
|
144
152
|
...(input.poll.allowRevote === undefined
|
|
145
153
|
? {}
|
|
@@ -8,6 +8,7 @@ import { msg } from '@meith/i18n'
|
|
|
8
8
|
import { parseThreadTool, type ThreadToolRights, ThreadTools } from '@meith/moderation'
|
|
9
9
|
|
|
10
10
|
import type { FormState } from './auth-form-state'
|
|
11
|
+
import { requireConfirmation } from './confirm'
|
|
11
12
|
import { getContainer } from './container'
|
|
12
13
|
import { getActor } from './context'
|
|
13
14
|
import { formStateReporter } from './form-state-reporter'
|
|
@@ -63,6 +64,11 @@ export async function threadToolAction(_prev: FormState, form: FormData): Promis
|
|
|
63
64
|
? await resolveRights(toForumId, threadAuthorId)
|
|
64
65
|
: undefined
|
|
65
66
|
|
|
67
|
+
if (tool === 'delete' && rights.delete) {
|
|
68
|
+
const confirm = requireConfirmation(form, await tr('moderationForm.confirm.deleteThread'))
|
|
69
|
+
if (confirm !== null) return confirm
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
outcome = await new ThreadTools({ threads: threadTools }).apply({
|
|
67
73
|
threadId,
|
|
68
74
|
tool,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import type { ContentScope } from '@meith/core'
|
|
4
|
+
import type { PostLocation, PostRepository } from '@meith/posts'
|
|
5
|
+
import type { ReadStateRepository } from '@meith/threads'
|
|
6
|
+
|
|
7
|
+
export interface UnreadGotoThread {
|
|
8
|
+
readonly id: number
|
|
9
|
+
readonly forumId: number
|
|
10
|
+
readonly lastPostId: number | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function resolveUnreadGoto(
|
|
14
|
+
posts: PostRepository,
|
|
15
|
+
readState: ReadStateRepository,
|
|
16
|
+
userId: number,
|
|
17
|
+
thread: UnreadGotoThread,
|
|
18
|
+
options: { readonly scope: ContentScope; readonly pageSize: number },
|
|
19
|
+
): Promise<PostLocation | null> {
|
|
20
|
+
const marker = await readState.markerFor(userId, thread.id, thread.forumId)
|
|
21
|
+
const located = await posts.locateFirstUnread(
|
|
22
|
+
thread.id,
|
|
23
|
+
{ postId: marker.lastReadPostId ?? 0, since: marker.forumReadAt },
|
|
24
|
+
options,
|
|
25
|
+
)
|
|
26
|
+
if (located !== null) return located
|
|
27
|
+
return thread.lastPostId === null ? null : posts.locate(thread.id, thread.lastPostId, options)
|
|
28
|
+
}
|
|
@@ -16,7 +16,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
|
|
|
16
16
|
|
|
17
17
|
import { activeDefinitions } from './plugin-host'
|
|
18
18
|
|
|
19
|
-
export const CODE_VERSION = '0.
|
|
19
|
+
export const CODE_VERSION = '0.33.0'
|
|
20
20
|
|
|
21
21
|
export interface UpgradeApplied {
|
|
22
22
|
readonly plugins: readonly string[]
|
|
@@ -18,6 +18,7 @@ import { AVATAR_FIELD, canUploadAvatar, requireAvatarService } from './avatars'
|
|
|
18
18
|
import { configuredSessions, getContainer } from './container'
|
|
19
19
|
import { getActor } from './context'
|
|
20
20
|
import { assertDemoAccountChangeable } from './demo'
|
|
21
|
+
import { revokeFeedToken } from './feed-token'
|
|
21
22
|
import { formStateReporter } from './form-state-reporter'
|
|
22
23
|
import { text } from './form-values'
|
|
23
24
|
import { getTranslator, tr } from './i18n'
|
|
@@ -103,11 +104,24 @@ export async function saveOptionsAction(_prev: FormState, form: FormData): Promi
|
|
|
103
104
|
postsPerPage: text(form, 'postsPerPage'),
|
|
104
105
|
threadsPerPage: text(form, 'threadsPerPage'),
|
|
105
106
|
invisible: form.get('invisible') !== null,
|
|
107
|
+
autoWatchOwnThreads: text(form, 'autoWatchOwnThreads'),
|
|
108
|
+
autoWatchRepliedThreads: text(form, 'autoWatchRepliedThreads'),
|
|
106
109
|
})
|
|
107
110
|
|
|
108
111
|
await emitEvent(
|
|
109
112
|
'user.profile.updated',
|
|
110
|
-
{
|
|
113
|
+
{
|
|
114
|
+
userId,
|
|
115
|
+
fields: [
|
|
116
|
+
'timezone',
|
|
117
|
+
'locale',
|
|
118
|
+
'postsPerPage',
|
|
119
|
+
'threadsPerPage',
|
|
120
|
+
'invisible',
|
|
121
|
+
'autoWatchOwnThreads',
|
|
122
|
+
'autoWatchRepliedThreads',
|
|
123
|
+
],
|
|
124
|
+
},
|
|
111
125
|
{ requestId: currentRequestId() ?? null },
|
|
112
126
|
)
|
|
113
127
|
} catch (err) {
|
|
@@ -117,6 +131,20 @@ export async function saveOptionsAction(_prev: FormState, form: FormData): Promi
|
|
|
117
131
|
redirect('/usercp/options?saved=1')
|
|
118
132
|
}
|
|
119
133
|
|
|
134
|
+
export async function saveBoardDigestCadenceAction(
|
|
135
|
+
_prev: FormState,
|
|
136
|
+
form: FormData,
|
|
137
|
+
): Promise<FormState> {
|
|
138
|
+
try {
|
|
139
|
+
const { service, userId } = await requireOwnSettings()
|
|
140
|
+
await service.saveBoardDigestCadence({ userId, cadence: text(form, 'cadence') })
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return toFormState(err)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
redirect('/notifications/preferences?saved=digest-cadence')
|
|
146
|
+
}
|
|
147
|
+
|
|
120
148
|
export async function changePasswordAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
121
149
|
try {
|
|
122
150
|
const { service, userId } = await requireOwnSettings()
|
|
@@ -141,6 +169,8 @@ export async function changePasswordAction(_prev: FormState, form: FormData): Pr
|
|
|
141
169
|
const admin = adminService()
|
|
142
170
|
if (admin !== null) await admin.endAllFor(userId)
|
|
143
171
|
|
|
172
|
+
await revokeFeedToken(userId)
|
|
173
|
+
|
|
144
174
|
await recordAuthEvent({ userId, kind: 'password_changed' })
|
|
145
175
|
} catch (err) {
|
|
146
176
|
return toFormState(err)
|
|
@@ -3,6 +3,8 @@ import 'server-only'
|
|
|
3
3
|
import { cookies } from 'next/headers'
|
|
4
4
|
import { cache } from 'react'
|
|
5
5
|
|
|
6
|
+
import type { SubscriptionMode } from '@meith/subscriptions'
|
|
7
|
+
|
|
6
8
|
import { resolveTimezone, TIMEZONE_COOKIE } from '@/view/timezone'
|
|
7
9
|
|
|
8
10
|
import { getContainer } from './container'
|
|
@@ -15,6 +17,7 @@ export interface ViewerPreferences {
|
|
|
15
17
|
readonly locale: string | null
|
|
16
18
|
readonly postsPerPage: number
|
|
17
19
|
readonly threadsPerPage: number
|
|
20
|
+
readonly autoWatchRepliedThreads: SubscriptionMode
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
const detectedZone = cache(async (): Promise<string | null> => {
|
|
@@ -43,6 +46,7 @@ export const getViewerPreferences = cache(async (): Promise<ViewerPreferences> =
|
|
|
43
46
|
locale: stored?.locale ?? null,
|
|
44
47
|
postsPerPage: stored?.postsPerPage ?? defaults.postsPerPage,
|
|
45
48
|
threadsPerPage: stored?.threadsPerPage ?? defaults.threadsPerPage,
|
|
49
|
+
autoWatchRepliedThreads: stored?.autoWatchRepliedThreads ?? 'none',
|
|
46
50
|
}
|
|
47
51
|
})
|
|
48
52
|
|
|
@@ -51,6 +55,7 @@ async function storedSettings(): Promise<{
|
|
|
51
55
|
readonly locale: string
|
|
52
56
|
readonly postsPerPage: number | null
|
|
53
57
|
readonly threadsPerPage: number | null
|
|
58
|
+
readonly autoWatchRepliedThreads: SubscriptionMode
|
|
54
59
|
} | null> {
|
|
55
60
|
const actor = await getActor()
|
|
56
61
|
const { memberSettings } = getContainer()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from 'next/cache'
|
|
4
|
+
|
|
5
|
+
import { isAppError, logger, publicMessageOf } from '@meith/core'
|
|
6
|
+
|
|
7
|
+
import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
|
|
8
|
+
import type { FormState } from './auth-form-state'
|
|
9
|
+
import { getMessageResolver, tr } from './i18n'
|
|
10
|
+
import { createWebhook, webhookStore } from './webhooks-admin'
|
|
11
|
+
|
|
12
|
+
function field(form: FormData, name: string): string {
|
|
13
|
+
const value = form.get(name)
|
|
14
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function refreshWebhookList(): void {
|
|
18
|
+
revalidatePath('/admin/webhooks')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function toState(err: unknown): Promise<FormState> {
|
|
22
|
+
if (isAppError(err)) return { error: publicMessageOf(err, await getMessageResolver()) }
|
|
23
|
+
logger({ module: 'webhook-actions' }).error({ err }, 'webhook action failed')
|
|
24
|
+
return { error: await tr('notice.app.something-went-wrong-please-try') }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function createWebhookAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
28
|
+
try {
|
|
29
|
+
const admin = await requireFreshAdmin()
|
|
30
|
+
|
|
31
|
+
const secret = await createWebhook({
|
|
32
|
+
url: field(form, 'url'),
|
|
33
|
+
topics: form.getAll('topics').filter((value): value is string => typeof value === 'string'),
|
|
34
|
+
format: field(form, 'format'),
|
|
35
|
+
active: field(form, 'active') !== 'false',
|
|
36
|
+
createdBy: admin.userId,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
await recordAdminAction({
|
|
40
|
+
action: 'system.webhook_created',
|
|
41
|
+
detail: { url: field(form, 'url') },
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
refreshWebhookList()
|
|
45
|
+
|
|
46
|
+
return { notice: 'created', values: { secret } }
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return toState(err)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function deleteWebhookAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
53
|
+
try {
|
|
54
|
+
await requireAdmin()
|
|
55
|
+
|
|
56
|
+
const id = Number(field(form, 'webhookId'))
|
|
57
|
+
if (!Number.isSafeInteger(id) || id <= 0) return { error: await tr('notice.app.such-webhook') }
|
|
58
|
+
|
|
59
|
+
const store = webhookStore()
|
|
60
|
+
if (store === null) return { error: await tr('notice.app.board-database-webhooks') }
|
|
61
|
+
|
|
62
|
+
const removed = await store.remove(id)
|
|
63
|
+
if (removed) {
|
|
64
|
+
await recordAdminAction({ action: 'system.webhook_deleted', detail: { webhookId: id } })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
refreshWebhookList()
|
|
68
|
+
return { notice: 'deleted' }
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return toState(err)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function toggleWebhookAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
75
|
+
try {
|
|
76
|
+
await requireAdmin()
|
|
77
|
+
|
|
78
|
+
const id = Number(field(form, 'webhookId'))
|
|
79
|
+
if (!Number.isSafeInteger(id) || id <= 0) return { error: await tr('notice.app.such-webhook') }
|
|
80
|
+
|
|
81
|
+
const store = webhookStore()
|
|
82
|
+
if (store === null) return { error: await tr('notice.app.board-database-webhooks') }
|
|
83
|
+
|
|
84
|
+
const active = field(form, 'active') === 'true'
|
|
85
|
+
const changed = await store.setActive(id, active)
|
|
86
|
+
if (changed) {
|
|
87
|
+
await recordAdminAction({
|
|
88
|
+
action: 'system.webhook_updated',
|
|
89
|
+
detail: { webhookId: id, active },
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
refreshWebhookList()
|
|
94
|
+
return { notice: 'updated' }
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return toState(err)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { msg } from '@meith/i18n'
|
|
2
|
+
import 'server-only'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
generateWebhookSecret,
|
|
6
|
+
isWebhookFormat,
|
|
7
|
+
isWebhookTopic,
|
|
8
|
+
WEBHOOK_FORMATS,
|
|
9
|
+
WEBHOOK_TOPICS,
|
|
10
|
+
type WebhookFormat,
|
|
11
|
+
type WebhookTopic,
|
|
12
|
+
} from '@meith/api'
|
|
13
|
+
import { ValidationError } from '@meith/core'
|
|
14
|
+
import { getDb, PostgresWebhookRepository } from '@meith/db'
|
|
15
|
+
|
|
16
|
+
import { getContainer } from './container'
|
|
17
|
+
|
|
18
|
+
export function webhookStore(): PostgresWebhookRepository | null {
|
|
19
|
+
return getContainer().dataSource === 'postgres' ? new PostgresWebhookRepository(getDb()) : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WebhookDeliveryLogEntry {
|
|
23
|
+
readonly id: number
|
|
24
|
+
readonly deliveryId: string
|
|
25
|
+
readonly topic: string
|
|
26
|
+
readonly status: string
|
|
27
|
+
readonly attempts: number
|
|
28
|
+
readonly lastStatusCode: number | null
|
|
29
|
+
readonly lastError: string | null
|
|
30
|
+
readonly createdAt: Date
|
|
31
|
+
readonly completedAt: Date | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WebhookRow {
|
|
35
|
+
readonly id: number
|
|
36
|
+
readonly url: string
|
|
37
|
+
readonly topics: readonly WebhookTopic[]
|
|
38
|
+
readonly active: boolean
|
|
39
|
+
readonly format: WebhookFormat
|
|
40
|
+
readonly createdAt: Date
|
|
41
|
+
readonly delivered: number
|
|
42
|
+
readonly pending: number
|
|
43
|
+
readonly dead: number
|
|
44
|
+
readonly recent: readonly WebhookDeliveryLogEntry[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WebhookView {
|
|
48
|
+
readonly subscriptions: readonly WebhookRow[]
|
|
49
|
+
readonly topics: readonly WebhookTopic[]
|
|
50
|
+
readonly formats: readonly WebhookFormat[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function buildWebhookView(): Promise<WebhookView | null> {
|
|
54
|
+
const store = webhookStore()
|
|
55
|
+
if (store === null) return null
|
|
56
|
+
|
|
57
|
+
const summaries = await store.listAll()
|
|
58
|
+
const subscriptions = await Promise.all(
|
|
59
|
+
summaries.map(async (summary) => ({
|
|
60
|
+
id: summary.id,
|
|
61
|
+
url: summary.url,
|
|
62
|
+
topics: summary.topics,
|
|
63
|
+
active: summary.active,
|
|
64
|
+
format: summary.format,
|
|
65
|
+
createdAt: summary.createdAt,
|
|
66
|
+
delivered: summary.delivered,
|
|
67
|
+
pending: summary.pending,
|
|
68
|
+
dead: summary.dead,
|
|
69
|
+
recent: await store.recentDeliveries(summary.id, 10),
|
|
70
|
+
})),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return { subscriptions, topics: WEBHOOK_TOPICS, formats: WEBHOOK_FORMATS }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CreateWebhookInput {
|
|
77
|
+
readonly url: string
|
|
78
|
+
readonly topics: readonly string[]
|
|
79
|
+
readonly format: string
|
|
80
|
+
readonly active: boolean
|
|
81
|
+
readonly createdBy: number | null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validUrl(raw: string): string {
|
|
85
|
+
const url = raw.trim()
|
|
86
|
+
let parsed: URL
|
|
87
|
+
try {
|
|
88
|
+
parsed = new URL(url)
|
|
89
|
+
} catch {
|
|
90
|
+
throw new ValidationError(msg('error.app.webhook-url-https'))
|
|
91
|
+
}
|
|
92
|
+
if (parsed.protocol !== 'https:') {
|
|
93
|
+
throw new ValidationError(msg('error.app.webhook-url-https'))
|
|
94
|
+
}
|
|
95
|
+
return url
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function createWebhook(input: CreateWebhookInput): Promise<string> {
|
|
99
|
+
const store = webhookStore()
|
|
100
|
+
if (store === null) throw new ValidationError(msg('error.app.board-database-webhooks'))
|
|
101
|
+
|
|
102
|
+
const url = validUrl(input.url)
|
|
103
|
+
|
|
104
|
+
const topics = input.topics.filter((topic): topic is WebhookTopic => isWebhookTopic(topic))
|
|
105
|
+
if (topics.length === 0) {
|
|
106
|
+
throw new ValidationError(msg('error.app.webhook-needs-at-least-one-topic'))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const format: WebhookFormat = isWebhookFormat(input.format) ? input.format : 'json'
|
|
110
|
+
|
|
111
|
+
const secret = generateWebhookSecret()
|
|
112
|
+
await store.create({
|
|
113
|
+
url,
|
|
114
|
+
secret,
|
|
115
|
+
topics,
|
|
116
|
+
active: input.active,
|
|
117
|
+
format,
|
|
118
|
+
createdBy: input.createdBy,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
return secret
|
|
122
|
+
}
|
package/src/styles/globals.css
CHANGED
|
@@ -666,7 +666,7 @@
|
|
|
666
666
|
/* `em`, so a quote inside a heading scales with the text it is in. */
|
|
667
667
|
margin-block: 0.85em;
|
|
668
668
|
padding: 0.55em 0.85em;
|
|
669
|
-
border-inline-start: 3px solid var(--
|
|
669
|
+
border-inline-start: 3px solid color-mix(in srgb, var(--primary) 55%, transparent);
|
|
670
670
|
background-color: var(--muted);
|
|
671
671
|
border-start-end-radius: var(--radius);
|
|
672
672
|
border-end-end-radius: var(--radius);
|