@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
|
@@ -129,6 +129,41 @@ export async function stageAttachments(
|
|
|
129
129
|
return service.stage(accepted)
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
export async function resolveEditAttachmentScope(
|
|
133
|
+
actor: Actor,
|
|
134
|
+
forumId: number,
|
|
135
|
+
): Promise<AttachmentScope> {
|
|
136
|
+
const { authorizer, threadWrites } = getContainer()
|
|
137
|
+
const rules = threadWrites === null ? null : await threadWrites.postingRules(forumId)
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
forumId,
|
|
141
|
+
forum: await authorizer.forumMatrix(actor, forumId),
|
|
142
|
+
allowsAttachments: rules?.allowAttachments === true,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function removeAttachmentsFromPost(
|
|
147
|
+
ids: readonly number[],
|
|
148
|
+
post: { readonly postId: number; readonly userId: number },
|
|
149
|
+
): Promise<readonly AttachmentRecord[]> {
|
|
150
|
+
const service = attachmentService()
|
|
151
|
+
if (service === null || ids.length === 0) return []
|
|
152
|
+
|
|
153
|
+
const removed: AttachmentRecord[] = []
|
|
154
|
+
for (const id of ids) {
|
|
155
|
+
const record = await service.removeFromPost(id, post.postId)
|
|
156
|
+
if (record === null) continue
|
|
157
|
+
removed.push(record)
|
|
158
|
+
await emitEvent(
|
|
159
|
+
'attachment.deleted',
|
|
160
|
+
{ attachmentId: record.id },
|
|
161
|
+
{ userId: post.userId, isGuest: false },
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
return removed
|
|
165
|
+
}
|
|
166
|
+
|
|
132
167
|
export async function attachStaged(
|
|
133
168
|
staged: readonly StagedUpload[],
|
|
134
169
|
post: { readonly postId: number; readonly forumId: number; readonly userId: number },
|
|
@@ -20,6 +20,7 @@ import { recordAuthEvent } from './auth-events'
|
|
|
20
20
|
import type { FormState } from './auth-form-state'
|
|
21
21
|
import { sendPasswordResetEmail, sendVerificationEmail } from './auth-mail'
|
|
22
22
|
import { configuredIdentity, configuredSessions, getContainer } from './container'
|
|
23
|
+
import { revokeFeedToken } from './feed-token'
|
|
23
24
|
import { formStateReporter } from './form-state-reporter'
|
|
24
25
|
import { getTranslator, tr } from './i18n'
|
|
25
26
|
import { termsAcceptance } from './legal'
|
|
@@ -477,6 +478,7 @@ export async function confirmResetAction(_prev: FormState, form: FormData): Prom
|
|
|
477
478
|
const identity = await configuredIdentity()
|
|
478
479
|
try {
|
|
479
480
|
const { userId } = await identity.redeemPasswordReset(token, password)
|
|
481
|
+
await revokeFeedToken(userId)
|
|
480
482
|
await recordAuthEvent({ userId, kind: 'password_reset' })
|
|
481
483
|
} catch (err) {
|
|
482
484
|
return toFormState(err, { token })
|
|
@@ -3,12 +3,40 @@ export interface UndoState {
|
|
|
3
3
|
readonly expiresAt: string
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
export interface ConfirmField {
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly value: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ConfirmRequest {
|
|
12
|
+
readonly message: string
|
|
13
|
+
readonly fields: readonly ConfirmField[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PollDraftValues {
|
|
17
|
+
readonly question: string
|
|
18
|
+
readonly options: readonly string[]
|
|
19
|
+
readonly closesAt: string
|
|
20
|
+
readonly maxOptions: string
|
|
21
|
+
readonly allowRevote: boolean
|
|
22
|
+
readonly publicVotes: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ThanksResult {
|
|
26
|
+
readonly thanked: boolean
|
|
27
|
+
readonly count: number
|
|
28
|
+
}
|
|
29
|
+
|
|
6
30
|
export interface FormState {
|
|
7
31
|
readonly error?: string | undefined
|
|
8
32
|
readonly notice?: string | undefined
|
|
9
33
|
readonly values?: Record<string, string> | undefined
|
|
34
|
+
readonly poll?: PollDraftValues | undefined
|
|
10
35
|
readonly preview?: string | undefined
|
|
11
36
|
readonly undo?: UndoState | undefined
|
|
37
|
+
readonly confirm?: ConfirmRequest | undefined
|
|
38
|
+
readonly thanks?: ThanksResult | undefined
|
|
39
|
+
readonly subscribed?: boolean | undefined
|
|
12
40
|
}
|
|
13
41
|
|
|
14
42
|
export const EMPTY_STATE: FormState = {}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import type { SubscriptionMode } from '@meith/subscriptions'
|
|
4
|
+
import type { SubscriptionCadence } from '@meith/threads'
|
|
5
|
+
|
|
6
|
+
import { getContainer } from './container'
|
|
7
|
+
|
|
8
|
+
export type AutoWatchKind = 'create' | 'reply'
|
|
9
|
+
|
|
10
|
+
export async function autoWatchPreference(
|
|
11
|
+
userId: number | null,
|
|
12
|
+
kind: AutoWatchKind,
|
|
13
|
+
): Promise<SubscriptionMode> {
|
|
14
|
+
if (userId === null) return 'none'
|
|
15
|
+
|
|
16
|
+
const { memberSettings } = getContainer()
|
|
17
|
+
if (memberSettings === null) return 'none'
|
|
18
|
+
|
|
19
|
+
const settings = await memberSettings.read(userId)
|
|
20
|
+
if (settings === null) return 'none'
|
|
21
|
+
|
|
22
|
+
return kind === 'create' ? settings.autoWatchOwnThreads : settings.autoWatchRepliedThreads
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function autoWatchChecksByDefault(preference: SubscriptionMode): boolean {
|
|
26
|
+
return preference !== 'none'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function autoWatchCadence(preference: SubscriptionMode): SubscriptionCadence {
|
|
30
|
+
return preference === 'none' ? 'instant' : preference
|
|
31
|
+
}
|
|
@@ -14,9 +14,11 @@ import { msg } from '@meith/i18n'
|
|
|
14
14
|
import { recordAdminAction, requireAdmin } from './admin'
|
|
15
15
|
import type { FormState } from './auth-form-state'
|
|
16
16
|
import { boardBanFilters } from './ban-filter-admin'
|
|
17
|
+
import { requireConfirmation } from './confirm'
|
|
17
18
|
import { getContainer } from './container'
|
|
18
19
|
import { formStateReporter } from './form-state-reporter'
|
|
19
20
|
import { trimmedText } from './form-values'
|
|
21
|
+
import { tr } from './i18n'
|
|
20
22
|
import { remoteAddress } from './request-fingerprint'
|
|
21
23
|
|
|
22
24
|
const BAN_FILTER_SCREEN = '/admin/users/ban-filters'
|
|
@@ -100,6 +102,9 @@ export async function removeBanFilterAction(_prev: FormState, form: FormData): P
|
|
|
100
102
|
await requireAdmin()
|
|
101
103
|
const id = filterId(form)
|
|
102
104
|
|
|
105
|
+
const confirm = requireConfirmation(form, await tr('adminBanFilter.confirm.remove'))
|
|
106
|
+
if (confirm !== null) return confirm
|
|
107
|
+
|
|
103
108
|
await boardBanFilters().remove(id)
|
|
104
109
|
|
|
105
110
|
refreshBanFilterScreen()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ConfirmField, FormState } from './auth-form-state'
|
|
2
|
+
|
|
3
|
+
const CONFIRMED = 'confirmed'
|
|
4
|
+
|
|
5
|
+
export function isConfirmed(form: FormData): boolean {
|
|
6
|
+
return form.get(CONFIRMED) === '1'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function requireConfirmation(form: FormData, message: string): FormState | null {
|
|
10
|
+
if (isConfirmed(form)) return null
|
|
11
|
+
|
|
12
|
+
const fields: ConfirmField[] = []
|
|
13
|
+
form.forEach((value, name) => {
|
|
14
|
+
if (name === CONFIRMED) return
|
|
15
|
+
if (typeof value === 'string') fields.push({ name, value })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
return { confirm: { message, fields } }
|
|
19
|
+
}
|
|
@@ -11,11 +11,22 @@ import {
|
|
|
11
11
|
SIGNATURE_FEATURES,
|
|
12
12
|
vocabularyOptions,
|
|
13
13
|
} from '@meith/markdown'
|
|
14
|
+
import { POLL_OPTION_MAX } from '@meith/polls'
|
|
14
15
|
import type { PostEditor } from '@meith/posts'
|
|
15
16
|
|
|
16
17
|
import { postLink } from '../view/post-link'
|
|
17
|
-
import {
|
|
18
|
-
|
|
18
|
+
import {
|
|
19
|
+
attachmentsForPosts,
|
|
20
|
+
attachStaged,
|
|
21
|
+
claimAttachments,
|
|
22
|
+
removeAttachmentsFromPost,
|
|
23
|
+
resolveEditAttachmentScope,
|
|
24
|
+
stageAttachments,
|
|
25
|
+
submittedFiles,
|
|
26
|
+
} from './attachments'
|
|
27
|
+
import type { FormState, PollDraftValues } from './auth-form-state'
|
|
28
|
+
import { autoWatchCadence, autoWatchPreference } from './auto-watch'
|
|
29
|
+
import { requireConfirmation } from './confirm'
|
|
19
30
|
import { getContainer } from './container'
|
|
20
31
|
import { activeVocabulary } from './content-admin'
|
|
21
32
|
import { getActor } from './context'
|
|
@@ -103,6 +114,41 @@ function pollChoiceLimit(form: FormData): number {
|
|
|
103
114
|
return Number.isSafeInteger(limit) && limit >= 0 ? limit : 1
|
|
104
115
|
}
|
|
105
116
|
|
|
117
|
+
const POLL_OPTION_ADD_STEP = 4
|
|
118
|
+
|
|
119
|
+
function pollOptionValues(form: FormData): readonly string[] {
|
|
120
|
+
return form.getAll('pollOption').filter((value): value is string => typeof value === 'string')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function pollDraftFrom(form: FormData): PollDraftValues {
|
|
124
|
+
return {
|
|
125
|
+
question: field(form, 'pollQuestion'),
|
|
126
|
+
options: pollOptionValues(form),
|
|
127
|
+
closesAt: field(form, 'pollClosesAt'),
|
|
128
|
+
maxOptions: field(form, 'pollMaxOptions'),
|
|
129
|
+
allowRevote: checkbox(form, 'pollAllowRevote'),
|
|
130
|
+
publicVotes: checkbox(form, 'pollPublicVotes'),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function grownPollDraft(form: FormData): PollDraftValues {
|
|
135
|
+
const draft = pollDraftFrom(form)
|
|
136
|
+
const slots = Math.min(draft.options.length + POLL_OPTION_ADD_STEP, POLL_OPTION_MAX)
|
|
137
|
+
return {
|
|
138
|
+
...draft,
|
|
139
|
+
options: Array.from({ length: slots }, (_, index) => draft.options[index] ?? ''),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function pollClosingTime(value: string): Date | null {
|
|
144
|
+
const trimmed = value.trim()
|
|
145
|
+
if (trimmed === '') return null
|
|
146
|
+
|
|
147
|
+
const at = new Date(`${trimmed}Z`)
|
|
148
|
+
if (Number.isNaN(at.getTime())) throw new ValidationError(msg('error.app.valid-date'))
|
|
149
|
+
return at
|
|
150
|
+
}
|
|
151
|
+
|
|
106
152
|
const toFormState = formStateReporter('content-actions', 'unexpected error writing content')
|
|
107
153
|
|
|
108
154
|
export interface ComposerAutosaveInput {
|
|
@@ -150,22 +196,47 @@ export async function autosaveComposerAction(
|
|
|
150
196
|
return { savedAt: Date.now() }
|
|
151
197
|
}
|
|
152
198
|
|
|
199
|
+
export async function deleteDraftAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
200
|
+
try {
|
|
201
|
+
const actor = await getActor()
|
|
202
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged-2'))
|
|
203
|
+
|
|
204
|
+
const forumId = positiveIntIn(field(form, 'forumId'))
|
|
205
|
+
if (forumId === null) throw new ValidationError(msg('error.app.forum-exist'))
|
|
206
|
+
const threadIdRaw = field(form, 'threadId')
|
|
207
|
+
const threadId = threadIdRaw === '' ? null : positiveIntIn(threadIdRaw)
|
|
208
|
+
|
|
209
|
+
const confirm = requireConfirmation(form, await tr('draftsPage.confirm.delete'))
|
|
210
|
+
if (confirm !== null) return confirm
|
|
211
|
+
|
|
212
|
+
const { drafts } = getContainer()
|
|
213
|
+
if (drafts === null) throw new ValidationError(msg('error.app.drafts-unavailable-board'))
|
|
214
|
+
|
|
215
|
+
await drafts.remove(actor.userId, forumId, threadId)
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return toFormState(err)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
redirect('/usercp/drafts')
|
|
221
|
+
}
|
|
222
|
+
|
|
153
223
|
export async function createThreadAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
154
224
|
const forumId = positiveIntIn(field(form, 'forumId'))
|
|
155
225
|
const title = field(form, 'title')
|
|
156
226
|
const message = field(form, 'message')
|
|
157
227
|
const prefixId = field(form, 'prefixId') === '' ? null : positiveIntIn(field(form, 'prefixId'))
|
|
158
228
|
const subscribe = checkbox(form, 'subscribe')
|
|
159
|
-
const
|
|
160
|
-
const pollOptions = form
|
|
161
|
-
.getAll('pollOption')
|
|
162
|
-
.filter((value): value is string => typeof value === 'string')
|
|
229
|
+
const poll = pollDraftFrom(form)
|
|
163
230
|
const values = { title, message, prefixId: field(form, 'prefixId') }
|
|
164
231
|
|
|
165
|
-
if (forumId === null) return { error: await tr('notice.app.forum-exist'), values }
|
|
232
|
+
if (forumId === null) return { error: await tr('notice.app.forum-exist'), values, poll }
|
|
166
233
|
|
|
167
|
-
|
|
168
|
-
|
|
234
|
+
const intent = field(form, 'intent')
|
|
235
|
+
if (intent === 'preview') {
|
|
236
|
+
return { notice: 'preview', values, poll, preview: await previewHtml(message) }
|
|
237
|
+
}
|
|
238
|
+
if (intent === 'more_options') {
|
|
239
|
+
return { values, poll: grownPollDraft(form) }
|
|
169
240
|
}
|
|
170
241
|
|
|
171
242
|
const actor = await getActor()
|
|
@@ -177,28 +248,36 @@ export async function createThreadAction(_prev: FormState, form: FormData): Prom
|
|
|
177
248
|
resolved = await resolveThreadTarget(actor, forumId)
|
|
178
249
|
const userId = actor.userId!
|
|
179
250
|
|
|
180
|
-
if (
|
|
251
|
+
if (intent === 'save_draft') {
|
|
181
252
|
if (drafts === null) throw new ValidationError(msg('error.app.drafts-unavailable-board'))
|
|
182
253
|
await drafts.save(userId, { forumId, threadId: null, title, message, prefixId })
|
|
183
|
-
return { notice: 'saved', values }
|
|
254
|
+
return { notice: 'saved', values, poll }
|
|
184
255
|
}
|
|
185
256
|
|
|
257
|
+
const pollClosesAt = pollClosingTime(poll.closesAt)
|
|
258
|
+
|
|
186
259
|
const staged = await stageAttachments(actor, resolved.scope, await submittedFiles(form))
|
|
187
260
|
|
|
261
|
+
const subscribeMode = subscribe
|
|
262
|
+
? autoWatchCadence(await autoWatchPreference(userId, 'create'))
|
|
263
|
+
: undefined
|
|
264
|
+
|
|
188
265
|
created = await submitThread(actor, resolved, {
|
|
189
266
|
title,
|
|
190
267
|
message,
|
|
191
268
|
prefixId,
|
|
192
269
|
subscribe,
|
|
270
|
+
...(subscribeMode === undefined ? {} : { subscribeMode }),
|
|
193
271
|
poll:
|
|
194
|
-
|
|
272
|
+
poll.question === '' && poll.options.every((option) => option.trim() === '')
|
|
195
273
|
? undefined
|
|
196
274
|
: {
|
|
197
|
-
question:
|
|
198
|
-
options:
|
|
275
|
+
question: poll.question,
|
|
276
|
+
options: poll.options,
|
|
277
|
+
closesAt: pollClosesAt,
|
|
199
278
|
maxOptions: pollChoiceLimit(form),
|
|
200
|
-
allowRevote:
|
|
201
|
-
publicVotes:
|
|
279
|
+
allowRevote: poll.allowRevote,
|
|
280
|
+
publicVotes: poll.publicVotes,
|
|
202
281
|
},
|
|
203
282
|
})
|
|
204
283
|
|
|
@@ -211,7 +290,7 @@ export async function createThreadAction(_prev: FormState, form: FormData): Prom
|
|
|
211
290
|
)
|
|
212
291
|
await drafts?.remove(userId, forumId, null)
|
|
213
292
|
} catch (err) {
|
|
214
|
-
return toFormState(err, values)
|
|
293
|
+
return { ...(await toFormState(err, values)), poll }
|
|
215
294
|
}
|
|
216
295
|
|
|
217
296
|
if (created.visibility === 'unapproved') {
|
|
@@ -251,7 +330,16 @@ export async function createReplyAction(_prev: FormState, form: FormData): Promi
|
|
|
251
330
|
|
|
252
331
|
const staged = await stageAttachments(actor, scope, await submittedFiles(form))
|
|
253
332
|
|
|
254
|
-
|
|
333
|
+
const subscribeMode = subscribe
|
|
334
|
+
? autoWatchCadence(await autoWatchPreference(userId, 'reply'))
|
|
335
|
+
: undefined
|
|
336
|
+
|
|
337
|
+
created = await submitReply(actor, resolved, {
|
|
338
|
+
message,
|
|
339
|
+
subscribe,
|
|
340
|
+
...(subscribeMode === undefined ? {} : { subscribeMode }),
|
|
341
|
+
seenLastPostId,
|
|
342
|
+
})
|
|
255
343
|
|
|
256
344
|
const attached = await attachStaged(staged, { postId: created.postId, forumId, userId })
|
|
257
345
|
await claimAttachments(
|
|
@@ -312,6 +400,22 @@ export async function editPostAction(_prev: FormState, form: FormData): Promise<
|
|
|
312
400
|
throw new ForbiddenError(msg('error.app.must-logged-edit-post'))
|
|
313
401
|
}
|
|
314
402
|
|
|
403
|
+
const forumId = scope.target.forum.id
|
|
404
|
+
const current = await attachmentsForPosts([postId])
|
|
405
|
+
const removeIds = form
|
|
406
|
+
.getAll('removeAttachmentIds')
|
|
407
|
+
.map((value) => (typeof value === 'string' ? positiveIntIn(value) : null))
|
|
408
|
+
.filter((id): id is number => id !== null)
|
|
409
|
+
const toRemove = current.filter((record) => removeIds.includes(record.id))
|
|
410
|
+
|
|
411
|
+
const attachmentScope = await resolveEditAttachmentScope(actor, forumId)
|
|
412
|
+
const staged = await stageAttachments(
|
|
413
|
+
actor,
|
|
414
|
+
attachmentScope,
|
|
415
|
+
await submittedFiles(form),
|
|
416
|
+
current.length - toRemove.length,
|
|
417
|
+
)
|
|
418
|
+
|
|
315
419
|
const revised = await filterView(
|
|
316
420
|
'post.edit.before',
|
|
317
421
|
{ body: message, reason },
|
|
@@ -319,7 +423,7 @@ export async function editPostAction(_prev: FormState, form: FormData): Promise<
|
|
|
319
423
|
...viewerRef(actor),
|
|
320
424
|
postId,
|
|
321
425
|
threadId,
|
|
322
|
-
forumId
|
|
426
|
+
forumId,
|
|
323
427
|
},
|
|
324
428
|
)
|
|
325
429
|
|
|
@@ -329,6 +433,12 @@ export async function editPostAction(_prev: FormState, form: FormData): Promise<
|
|
|
329
433
|
actor.userId,
|
|
330
434
|
scope.target,
|
|
331
435
|
)
|
|
436
|
+
|
|
437
|
+
await attachStaged(staged, { postId, forumId, userId: actor.userId })
|
|
438
|
+
await removeAttachmentsFromPost(
|
|
439
|
+
toRemove.map((record) => record.id),
|
|
440
|
+
{ postId, userId: actor.userId },
|
|
441
|
+
)
|
|
332
442
|
} catch (err) {
|
|
333
443
|
return toFormState(err, values)
|
|
334
444
|
}
|
|
@@ -17,10 +17,12 @@ import { recordAdminAction, requireAdmin } from './admin'
|
|
|
17
17
|
import { announcementRepository } from './announcements'
|
|
18
18
|
import { captchaQuestionRepository } from './antispam'
|
|
19
19
|
import type { FormState } from './auth-form-state'
|
|
20
|
+
import { requireConfirmation } from './confirm'
|
|
20
21
|
import { requireAttachmentAdmin, requireContentAdmin } from './content-admin'
|
|
21
22
|
import { getActor } from './context'
|
|
22
23
|
import { formStateReporter } from './form-state-reporter'
|
|
23
24
|
import { trimmedText } from './form-values'
|
|
25
|
+
import { tr } from './i18n'
|
|
24
26
|
import { emitEvent, viewerRef } from './plugin-view'
|
|
25
27
|
|
|
26
28
|
function id(form: FormData, name = 'id'): number {
|
|
@@ -88,6 +90,9 @@ export async function deleteWordFilterAction(_prev: FormState, form: FormData):
|
|
|
88
90
|
await requireAdmin()
|
|
89
91
|
const filterId = id(form)
|
|
90
92
|
|
|
93
|
+
const confirm = requireConfirmation(form, await tr('adminContent.confirm.deleteWordFilter'))
|
|
94
|
+
if (confirm !== null) return confirm
|
|
95
|
+
|
|
91
96
|
await requireContentAdmin().deleteWordFilter(filterId)
|
|
92
97
|
|
|
93
98
|
await invalidateWordFilters()
|
|
@@ -131,6 +136,9 @@ export async function deletePrefixAction(_prev: FormState, form: FormData): Prom
|
|
|
131
136
|
await requireAdmin()
|
|
132
137
|
const prefixId = id(form)
|
|
133
138
|
|
|
139
|
+
const confirm = requireConfirmation(form, await tr('adminContent.confirm.deletePrefix'))
|
|
140
|
+
if (confirm !== null) return confirm
|
|
141
|
+
|
|
134
142
|
await requireContentAdmin().deletePrefix(prefixId)
|
|
135
143
|
|
|
136
144
|
await drivers().cache.invalidateTags([CacheTags.prefixes()])
|
|
@@ -223,6 +231,10 @@ export async function deleteSmileyAction(_prev: FormState, form: FormData): Prom
|
|
|
223
231
|
await requireAdmin()
|
|
224
232
|
|
|
225
233
|
const smileyId = id(form)
|
|
234
|
+
|
|
235
|
+
const confirm = requireConfirmation(form, await tr('adminContent.confirm.deleteSmiley'))
|
|
236
|
+
if (confirm !== null) return confirm
|
|
237
|
+
|
|
226
238
|
await requireContentAdmin().deleteSmiley(smileyId)
|
|
227
239
|
|
|
228
240
|
await invalidateVocabulary()
|
|
@@ -299,6 +311,10 @@ export async function deleteDirectiveAction(_prev: FormState, form: FormData): P
|
|
|
299
311
|
await requireAdmin()
|
|
300
312
|
|
|
301
313
|
const tagId = id(form)
|
|
314
|
+
|
|
315
|
+
const confirm = requireConfirmation(form, await tr('adminContent.confirm.deleteDirective'))
|
|
316
|
+
if (confirm !== null) return confirm
|
|
317
|
+
|
|
302
318
|
await requireContentAdmin().deleteDirective(tagId)
|
|
303
319
|
|
|
304
320
|
await invalidateVocabulary()
|
|
@@ -315,6 +331,10 @@ export async function deleteAttachmentAction(_prev: FormState, form: FormData):
|
|
|
315
331
|
await requireAdmin()
|
|
316
332
|
|
|
317
333
|
const attachmentId = id(form)
|
|
334
|
+
|
|
335
|
+
const confirm = requireConfirmation(form, await tr('adminContent.confirm.deleteAttachment'))
|
|
336
|
+
if (confirm !== null) return confirm
|
|
337
|
+
|
|
318
338
|
const removed = await requireAttachmentAdmin().delete(attachmentId)
|
|
319
339
|
if (!removed) return { notice: 'deleted' }
|
|
320
340
|
|
|
@@ -404,6 +424,10 @@ export async function deleteAnnouncementAction(
|
|
|
404
424
|
await requireAdmin()
|
|
405
425
|
|
|
406
426
|
const announcementId = id(form)
|
|
427
|
+
|
|
428
|
+
const confirm = requireConfirmation(form, await tr('adminContent.confirm.deleteAnnouncement'))
|
|
429
|
+
if (confirm !== null) return confirm
|
|
430
|
+
|
|
407
431
|
await requireAnnouncements().delete(announcementId)
|
|
408
432
|
|
|
409
433
|
await recordAdminAction({ action: 'content.announcement_removed', detail: { announcementId } })
|
|
@@ -480,6 +504,13 @@ export async function deleteCaptchaQuestionAction(
|
|
|
480
504
|
await requireAdmin()
|
|
481
505
|
|
|
482
506
|
const questionId = id(form)
|
|
507
|
+
|
|
508
|
+
const confirm = requireConfirmation(
|
|
509
|
+
form,
|
|
510
|
+
await tr('adminContent.confirm.deleteCaptchaQuestion'),
|
|
511
|
+
)
|
|
512
|
+
if (confirm !== null) return confirm
|
|
513
|
+
|
|
483
514
|
await requireCaptcha().delete(questionId)
|
|
484
515
|
|
|
485
516
|
await recordAdminAction({ action: 'content.captcha_removed', detail: { questionId } })
|
package/src/server/cookies.ts
CHANGED
|
@@ -11,6 +11,9 @@ export const GUEST_COOKIE_DAYS = 1
|
|
|
11
11
|
export const ADMIN_COOKIE = 'fs_admin'
|
|
12
12
|
export const ADMIN_COOKIE_PATH = '/admin'
|
|
13
13
|
|
|
14
|
+
export const ADMIN_SECOND_FACTOR_COOKIE = 'fs_admin_2fa'
|
|
15
|
+
export const ADMIN_SECOND_FACTOR_MINUTES = 10
|
|
16
|
+
|
|
14
17
|
export const SSO_COOKIE = '__Host-fs_sso'
|
|
15
18
|
export const DEV_SSO_COOKIE = 'fs_sso'
|
|
16
19
|
|
|
@@ -80,6 +83,21 @@ export function clearedAdminCookie(secure: boolean): CookieAttrs {
|
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
export function adminSecondFactorCookie(expires: Date, secure: boolean): CookieAttrs {
|
|
87
|
+
return { httpOnly: true, secure, sameSite: 'strict', path: ADMIN_COOKIE_PATH, expires }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function clearedAdminSecondFactorCookie(secure: boolean): CookieAttrs {
|
|
91
|
+
return {
|
|
92
|
+
httpOnly: true,
|
|
93
|
+
secure,
|
|
94
|
+
sameSite: 'strict',
|
|
95
|
+
path: ADMIN_COOKIE_PATH,
|
|
96
|
+
expires: new Date(0),
|
|
97
|
+
maxAge: 0,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
export function clearedCookie(secure: boolean): CookieAttrs {
|
|
84
102
|
return { ...base(secure), expires: new Date(0), maxAge: 0 }
|
|
85
103
|
}
|
package/src/server/discovery.ts
CHANGED
|
@@ -14,7 +14,14 @@ import { getContainer } from './container'
|
|
|
14
14
|
|
|
15
15
|
export const DISCOVER_PAGE = 20
|
|
16
16
|
|
|
17
|
-
export const DISCOVERY_VIEWS = [
|
|
17
|
+
export const DISCOVERY_VIEWS = [
|
|
18
|
+
'new',
|
|
19
|
+
'today',
|
|
20
|
+
'unread',
|
|
21
|
+
'mine',
|
|
22
|
+
'participated',
|
|
23
|
+
'unanswered',
|
|
24
|
+
] as const
|
|
18
25
|
export type DiscoveryView = (typeof DISCOVERY_VIEWS)[number]
|
|
19
26
|
|
|
20
27
|
export function isDiscoveryView(value: string): value is DiscoveryView {
|
|
@@ -53,6 +60,9 @@ export async function runDiscovery(input: {
|
|
|
53
60
|
return repo.activeSince(startOfDay(input.now, input.timeZone), query, scope)
|
|
54
61
|
case 'unanswered':
|
|
55
62
|
return repo.unanswered(query, scope)
|
|
63
|
+
case 'unread':
|
|
64
|
+
if (input.actor.userId === null) throw new ForbiddenError(msg('error.app.sign-see-threads'))
|
|
65
|
+
return repo.unread(input.actor.userId, query, scope)
|
|
56
66
|
case 'mine':
|
|
57
67
|
if (input.actor.userId === null) throw new ForbiddenError(msg('error.app.sign-see-threads'))
|
|
58
68
|
return repo.startedBy(input.actor.userId, query, scope)
|
|
@@ -5,12 +5,15 @@ import { type FeedChannel, type FeedEntry, renderAtom, renderRss } from '@/view/
|
|
|
5
5
|
import { boardOffline } from './board-offline'
|
|
6
6
|
import { activeWordFilter } from './content-admin'
|
|
7
7
|
import { feedFor } from './feed-builder'
|
|
8
|
+
import { feedScopeForRequest } from './feed-token'
|
|
8
9
|
import { filterView } from './plugin-view'
|
|
9
10
|
import { getSettings } from './settings'
|
|
10
|
-
import { FEED_LIMIT, feedRepository, origin
|
|
11
|
+
import { FEED_LIMIT, feedRepository, origin } from './syndication'
|
|
11
12
|
|
|
12
13
|
const CACHE = 'public, max-age=300, stale-while-revalidate=3600'
|
|
13
14
|
|
|
15
|
+
const PRIVATE_CACHE = ['private', 'no-store'].join(', ')
|
|
16
|
+
|
|
14
17
|
export type FeedFormat = 'rss' | 'atom'
|
|
15
18
|
|
|
16
19
|
export type FeedScope = 'board' | 'forum' | 'thread'
|
|
@@ -52,6 +55,7 @@ export async function feedResponse(
|
|
|
52
55
|
channel: FeedChannel,
|
|
53
56
|
format: FeedFormat,
|
|
54
57
|
feed: FeedScope,
|
|
58
|
+
options: { readonly private?: boolean } = {},
|
|
55
59
|
): Promise<Response> {
|
|
56
60
|
const withEntries = { ...channel, entries: await filterEntries(channel.entries, feed) }
|
|
57
61
|
const body = format === 'atom' ? renderAtom(withEntries) : renderRss(withEntries)
|
|
@@ -61,7 +65,7 @@ export async function feedResponse(
|
|
|
61
65
|
format === 'atom'
|
|
62
66
|
? 'application/atom+xml; charset=utf-8'
|
|
63
67
|
: 'application/rss+xml; charset=utf-8',
|
|
64
|
-
'cache-control': CACHE,
|
|
68
|
+
'cache-control': options.private === true ? PRIVATE_CACHE : CACHE,
|
|
65
69
|
},
|
|
66
70
|
})
|
|
67
71
|
}
|
|
@@ -82,14 +86,18 @@ export async function offlineFeed(): Promise<Response | null> {
|
|
|
82
86
|
})
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
export async function boardFeed(
|
|
89
|
+
export async function boardFeed(
|
|
90
|
+
format: FeedFormat,
|
|
91
|
+
selfPath: string,
|
|
92
|
+
request: Request,
|
|
93
|
+
): Promise<Response> {
|
|
86
94
|
const offline = await offlineFeed()
|
|
87
95
|
if (offline !== null) return offline
|
|
88
96
|
|
|
89
97
|
const repo = feedRepository()
|
|
90
98
|
if (repo === null) return noFeed()
|
|
91
99
|
|
|
92
|
-
const scope = await
|
|
100
|
+
const { scope, tokened } = await feedScopeForRequest(request)
|
|
93
101
|
const threads = await repo.recentThreads(FEED_LIMIT, scope)
|
|
94
102
|
const settings = await getSettings()
|
|
95
103
|
|
|
@@ -106,6 +114,7 @@ export async function boardFeed(format: FeedFormat, selfPath: string): Promise<R
|
|
|
106
114
|
}),
|
|
107
115
|
format,
|
|
108
116
|
'board',
|
|
117
|
+
{ private: tokened },
|
|
109
118
|
)
|
|
110
119
|
}
|
|
111
120
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from 'next/cache'
|
|
4
|
+
|
|
5
|
+
import { ForbiddenError, isAppError, logger, publicMessageOf } from '@meith/core'
|
|
6
|
+
import { msg } from '@meith/i18n'
|
|
7
|
+
|
|
8
|
+
import type { FormState } from './auth-form-state'
|
|
9
|
+
import { getActor } from './context'
|
|
10
|
+
import { issueFeedToken, revokeFeedToken } from './feed-token'
|
|
11
|
+
import { getMessageResolver, tr } from './i18n'
|
|
12
|
+
import { origin } from './syndication'
|
|
13
|
+
|
|
14
|
+
async function currentMemberId(): Promise<number> {
|
|
15
|
+
const actor = await getActor()
|
|
16
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
|
|
17
|
+
return actor.userId
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function toState(err: unknown): Promise<FormState> {
|
|
21
|
+
if (isAppError(err)) return { error: publicMessageOf(err, await getMessageResolver()) }
|
|
22
|
+
logger({ module: 'feed-token-actions' }).error({ err }, 'feed token action failed')
|
|
23
|
+
return { error: await tr('notice.app.something-went-wrong-please-try') }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function regenerateFeedTokenAction(
|
|
27
|
+
_prev: FormState,
|
|
28
|
+
_form: FormData,
|
|
29
|
+
): Promise<FormState> {
|
|
30
|
+
try {
|
|
31
|
+
const userId = await currentMemberId()
|
|
32
|
+
const token = await issueFeedToken(userId)
|
|
33
|
+
const base = await origin()
|
|
34
|
+
|
|
35
|
+
revalidatePath('/usercp/security')
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
notice: 'feed:issued',
|
|
39
|
+
values: {
|
|
40
|
+
token,
|
|
41
|
+
rssUrl: `${base}/feed.xml?token=${token}`,
|
|
42
|
+
atomUrl: `${base}/atom.xml?token=${token}`,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return toState(err)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function revokeFeedTokenAction(_prev: FormState, _form: FormData): Promise<FormState> {
|
|
51
|
+
try {
|
|
52
|
+
const userId = await currentMemberId()
|
|
53
|
+
await revokeFeedToken(userId)
|
|
54
|
+
|
|
55
|
+
revalidatePath('/usercp/security')
|
|
56
|
+
|
|
57
|
+
return { notice: 'feed:revoked' }
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return toState(err)
|
|
60
|
+
}
|
|
61
|
+
}
|