@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.
Files changed (174) hide show
  1. package/app/(board)/[slug]/feed.xml/route.ts +5 -3
  2. package/app/(board)/[slug]/new/page.tsx +5 -0
  3. package/app/(board)/[slug]/page.tsx +7 -1
  4. package/app/(board)/discover/[view]/page.tsx +6 -1
  5. package/app/(board)/member/[id]/page.tsx +3 -5
  6. package/app/(board)/member/[id]/reputation/page.tsx +8 -15
  7. package/app/(board)/members/page.tsx +3 -8
  8. package/app/(board)/messages/[id]/page.tsx +3 -7
  9. package/app/(board)/messages/page.tsx +3 -5
  10. package/app/(board)/modcp/ip/page.tsx +3 -5
  11. package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
  12. package/app/(board)/moderation/reports/page.tsx +53 -15
  13. package/app/(board)/moderation/warn/page.tsx +3 -6
  14. package/app/(board)/notifications/page.tsx +5 -10
  15. package/app/(board)/notifications/preferences/page.tsx +8 -0
  16. package/app/(board)/online/page.tsx +12 -6
  17. package/app/(board)/staff/page.tsx +4 -5
  18. package/app/(board)/stats/page.tsx +10 -3
  19. package/app/(board)/subscriptions/page.tsx +5 -10
  20. package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
  21. package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
  22. package/app/(board)/thread/[slug]/page.tsx +49 -11
  23. package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
  24. package/app/(board)/unsubscribe/page.tsx +8 -11
  25. package/app/(board)/usercp/contacts/page.tsx +3 -5
  26. package/app/(board)/usercp/drafts/page.tsx +70 -0
  27. package/app/(board)/usercp/options/page.tsx +8 -1
  28. package/app/(board)/usercp/security/page.tsx +16 -0
  29. package/app/admin/antispam/page.tsx +4 -12
  30. package/app/admin/content/attachments/page.tsx +3 -6
  31. package/app/admin/content/page.tsx +3 -13
  32. package/app/admin/forums/[id]/permissions/page.tsx +9 -12
  33. package/app/admin/groups/page.tsx +7 -9
  34. package/app/admin/layout.tsx +23 -8
  35. package/app/admin/page.tsx +12 -6
  36. package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
  37. package/app/admin/plugins/page.tsx +3 -6
  38. package/app/admin/users/[id]/page.tsx +5 -11
  39. package/app/admin/users/prune/page.tsx +4 -12
  40. package/app/admin/webhooks/page.tsx +144 -0
  41. package/app/api/subscribe/thread/[id]/route.ts +53 -0
  42. package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
  43. package/app/atom.xml/route.ts +2 -2
  44. package/app/auth/sso/[provider]/callback/route.ts +10 -0
  45. package/app/feed.xml/route.ts +2 -2
  46. package/app/offline/route.ts +98 -0
  47. package/next.config.mjs +1 -0
  48. package/package.json +49 -48
  49. package/public/sw.js +42 -2
  50. package/src/components/account/draft-forms.tsx +39 -0
  51. package/src/components/account/feed-token.tsx +95 -0
  52. package/src/components/account/notification-forms.tsx +37 -2
  53. package/src/components/account/relation-forms.tsx +3 -2
  54. package/src/components/account/subscription-forms.tsx +20 -6
  55. package/src/components/account/usercp-forms.tsx +44 -0
  56. package/src/components/admin/admin-forms.tsx +57 -9
  57. package/src/components/admin/api-token-forms.tsx +11 -7
  58. package/src/components/admin/ban-filter-forms.tsx +15 -11
  59. package/src/components/admin/content-forms.tsx +31 -14
  60. package/src/components/admin/forum-forms.tsx +241 -129
  61. package/src/components/admin/forum-tree.tsx +5 -7
  62. package/src/components/admin/navigation-forms.tsx +43 -26
  63. package/src/components/admin/navigation-tree.tsx +8 -13
  64. package/src/components/admin/oklch-picker.tsx +2 -1
  65. package/src/components/admin/theme-changes.tsx +3 -2
  66. package/src/components/admin/theme-forms.tsx +3 -2
  67. package/src/components/admin/webhook-forms.tsx +137 -0
  68. package/src/components/auth/register-form.tsx +4 -7
  69. package/src/components/board/live-region.tsx +3 -1
  70. package/src/components/content/attachment-field.tsx +93 -22
  71. package/src/components/content/combobox-keys.ts +38 -0
  72. package/src/components/content/edit-post-form.tsx +75 -1
  73. package/src/components/content/markdown-editor.tsx +29 -86
  74. package/src/components/content/mention-combobox.tsx +137 -0
  75. package/src/components/content/multiquote-button.tsx +15 -12
  76. package/src/components/content/new-thread-form.tsx +95 -17
  77. package/src/components/content/poll-edit-form.tsx +61 -39
  78. package/src/components/content/poll-vote-form.tsx +91 -0
  79. package/src/components/content/poll.tsx +12 -48
  80. package/src/components/content/progressive-marker.tsx +13 -0
  81. package/src/components/content/recipient-segment.ts +39 -0
  82. package/src/components/content/reply-form.tsx +9 -1
  83. package/src/components/content/thanks-button.tsx +12 -7
  84. package/src/components/messages/message-forms.tsx +53 -43
  85. package/src/components/messages/recipient-field.tsx +88 -0
  86. package/src/components/moderation/inline-moderation-form.tsx +2 -0
  87. package/src/components/moderation/modcp-shell.tsx +17 -3
  88. package/src/components/moderation/queue-form.tsx +3 -6
  89. package/src/components/moderation/report-forms.tsx +24 -5
  90. package/src/components/moderation/thread-tools-form.tsx +2 -0
  91. package/src/components/shell/board-notice.tsx +22 -1
  92. package/src/components/shell/confirm-dialog.tsx +93 -0
  93. package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
  94. package/src/components/shell/notice-toast.tsx +40 -0
  95. package/src/components/shell/notification-menu.tsx +5 -3
  96. package/src/components/shell/page-shell.tsx +3 -0
  97. package/src/components/shell/scheme-toggle.tsx +72 -0
  98. package/src/components/shell/theme-switcher.tsx +16 -36
  99. package/src/components/shell/view-tabs.tsx +1 -1
  100. package/src/server/admin-actions.ts +118 -49
  101. package/src/server/admin-settings-actions.ts +5 -0
  102. package/src/server/admin.ts +70 -2
  103. package/src/server/api/search.ts +8 -2
  104. package/src/server/api-token-actions.ts +4 -0
  105. package/src/server/appearance-actions.ts +21 -0
  106. package/src/server/attachments.ts +35 -0
  107. package/src/server/auth-actions.ts +2 -0
  108. package/src/server/auth-form-state.ts +28 -0
  109. package/src/server/auto-watch.ts +31 -0
  110. package/src/server/ban-filter-admin-actions.ts +5 -0
  111. package/src/server/confirm.ts +19 -0
  112. package/src/server/content-actions.ts +129 -19
  113. package/src/server/content-admin-actions.ts +31 -0
  114. package/src/server/cookies.ts +18 -0
  115. package/src/server/discovery.ts +11 -1
  116. package/src/server/feed-routes.ts +13 -4
  117. package/src/server/feed-token-actions.ts +61 -0
  118. package/src/server/feed-token.ts +78 -0
  119. package/src/server/fixture-post-repo.ts +18 -0
  120. package/src/server/forum-admin-actions.ts +23 -16
  121. package/src/server/forum-admin.ts +10 -1
  122. package/src/server/inline-moderation-actions.ts +6 -0
  123. package/src/server/message-actions.ts +9 -0
  124. package/src/server/navigation-admin-actions.ts +6 -0
  125. package/src/server/plugin-admin.ts +7 -2
  126. package/src/server/plugin-pages.ts +50 -0
  127. package/src/server/plugin-panel.ts +34 -1
  128. package/src/server/plugin-routes.ts +8 -0
  129. package/src/server/plugin-view.tsx +46 -7
  130. package/src/server/poll-actions.ts +13 -1
  131. package/src/server/presence.ts +23 -0
  132. package/src/server/push-actions.ts +9 -0
  133. package/src/server/reply-core.ts +8 -1
  134. package/src/server/report-actions.ts +13 -2
  135. package/src/server/reputation-actions.ts +12 -1
  136. package/src/server/search-page.ts +8 -2
  137. package/src/server/search.ts +18 -5
  138. package/src/server/session-cookies.ts +18 -0
  139. package/src/server/subscription-actions.ts +9 -0
  140. package/src/server/thread-core.ts +10 -2
  141. package/src/server/thread-tool-actions.ts +6 -0
  142. package/src/server/unread-goto.ts +28 -0
  143. package/src/server/upgrade-notice.ts +1 -1
  144. package/src/server/usercp-actions.ts +31 -1
  145. package/src/server/viewer-preferences.ts +5 -0
  146. package/src/server/webhook-actions.ts +98 -0
  147. package/src/server/webhooks-admin.ts +122 -0
  148. package/src/styles/globals.css +1 -1
  149. package/src/theme/contract.fixture.ts +36 -5
  150. package/src/view/account-copy.ts +39 -0
  151. package/src/view/admin-copy.ts +4 -9
  152. package/src/view/admin-nav.ts +6 -0
  153. package/src/view/admin-panel-copy.ts +29 -1
  154. package/src/view/attachments.ts +33 -1
  155. package/src/view/board-index.ts +31 -2
  156. package/src/view/board-title.ts +1 -0
  157. package/src/view/content-copy.ts +22 -12
  158. package/src/view/copy.ts +17 -0
  159. package/src/view/discovery-view.ts +2 -1
  160. package/src/view/drafts.ts +45 -0
  161. package/src/view/editor-toolbar.ts +14 -0
  162. package/src/view/forum-display.ts +39 -7
  163. package/src/view/modcp-nav.ts +32 -0
  164. package/src/view/moderation-copy.ts +20 -0
  165. package/src/view/notifications.ts +1 -0
  166. package/src/view/plugin-panel.ts +13 -0
  167. package/src/view/poll-vote.ts +46 -0
  168. package/src/view/post-link.ts +8 -1
  169. package/src/view/progressive-enhancement.ts +5 -0
  170. package/src/view/shell.ts +2 -1
  171. package/src/view/subscriptions.ts +1 -0
  172. package/src/view/thread-view.ts +41 -2
  173. package/src/view/usercp-nav.ts +6 -0
  174. 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 { attachStaged, claimAttachments, stageAttachments, submittedFiles } from './attachments'
18
- import type { FormState } from './auth-form-state'
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 pollQuestion = field(form, 'pollQuestion')
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
- if (field(form, 'intent') === 'preview') {
168
- return { notice: 'preview', values, preview: await previewHtml(message) }
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 (field(form, 'intent') === 'save_draft') {
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
- pollQuestion === '' && pollOptions.every((option) => option.trim() === '')
272
+ poll.question === '' && poll.options.every((option) => option.trim() === '')
195
273
  ? undefined
196
274
  : {
197
- question: pollQuestion,
198
- options: pollOptions,
275
+ question: poll.question,
276
+ options: poll.options,
277
+ closesAt: pollClosesAt,
199
278
  maxOptions: pollChoiceLimit(form),
200
- allowRevote: checkbox(form, 'pollAllowRevote'),
201
- publicVotes: checkbox(form, 'pollPublicVotes'),
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
- created = await submitReply(actor, resolved, { message, subscribe, seenLastPostId })
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: scope.target.forum.id,
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 } })
@@ -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
  }
@@ -14,7 +14,14 @@ import { getContainer } from './container'
14
14
 
15
15
  export const DISCOVER_PAGE = 20
16
16
 
17
- export const DISCOVERY_VIEWS = ['new', 'today', 'mine', 'participated', 'unanswered'] as const
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, publicScope } from './syndication'
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(format: FeedFormat, selfPath: string): Promise<Response> {
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 publicScope()
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
+ }