@meith/web 0.16.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 (511) hide show
  1. package/LICENSE.md +165 -0
  2. package/app/(auth)/layout.tsx +6 -0
  3. package/app/(auth)/login/page.tsx +106 -0
  4. package/app/(auth)/login/verify/page.tsx +55 -0
  5. package/app/(auth)/register/page.tsx +83 -0
  6. package/app/(auth)/reset/confirm/page.tsx +45 -0
  7. package/app/(auth)/reset/page.tsx +22 -0
  8. package/app/(auth)/verify/page.tsx +44 -0
  9. package/app/(auth)/verify/resend/page.tsx +51 -0
  10. package/app/(board)/[slug]/feed.xml/route.ts +48 -0
  11. package/app/(board)/[slug]/new/page.tsx +112 -0
  12. package/app/(board)/[slug]/page.tsx +403 -0
  13. package/app/(board)/discover/[view]/page.tsx +115 -0
  14. package/app/(board)/discover/page.tsx +5 -0
  15. package/app/(board)/layout.tsx +22 -0
  16. package/app/(board)/member/[id]/page.tsx +187 -0
  17. package/app/(board)/member/[id]/reputation/page.tsx +195 -0
  18. package/app/(board)/member/by-name/[name]/page.tsx +32 -0
  19. package/app/(board)/messages/[id]/page.tsx +103 -0
  20. package/app/(board)/messages/compose/page.tsx +60 -0
  21. package/app/(board)/messages/layout.tsx +5 -0
  22. package/app/(board)/messages/page.tsx +179 -0
  23. package/app/(board)/modcp/forums/page.tsx +59 -0
  24. package/app/(board)/modcp/ip/page.tsx +158 -0
  25. package/app/(board)/modcp/layout.tsx +5 -0
  26. package/app/(board)/modcp/log/page.tsx +98 -0
  27. package/app/(board)/modcp/page.tsx +112 -0
  28. package/app/(board)/moderation/layout.tsx +5 -0
  29. package/app/(board)/moderation/page.tsx +111 -0
  30. package/app/(board)/moderation/reports/page.tsx +193 -0
  31. package/app/(board)/moderation/warn/page.tsx +158 -0
  32. package/app/(board)/notifications/layout.tsx +5 -0
  33. package/app/(board)/notifications/page.tsx +144 -0
  34. package/app/(board)/notifications/preferences/page.tsx +65 -0
  35. package/app/(board)/online/page.tsx +119 -0
  36. package/app/(board)/page.tsx +195 -0
  37. package/app/(board)/plugins/[key]/[[...path]]/page.tsx +72 -0
  38. package/app/(board)/privacy/page.tsx +12 -0
  39. package/app/(board)/report/page.tsx +71 -0
  40. package/app/(board)/rules/page.tsx +12 -0
  41. package/app/(board)/search/[token]/page.tsx +130 -0
  42. package/app/(board)/search/page.tsx +289 -0
  43. package/app/(board)/stats/page.tsx +202 -0
  44. package/app/(board)/subscriptions/layout.tsx +5 -0
  45. package/app/(board)/subscriptions/page.tsx +142 -0
  46. package/app/(board)/terms/page.tsx +12 -0
  47. package/app/(board)/thread/[slug]/edit/page.tsx +107 -0
  48. package/app/(board)/thread/[slug]/feed.xml/route.ts +42 -0
  49. package/app/(board)/thread/[slug]/page.tsx +707 -0
  50. package/app/(board)/thread/[slug]/poll/page.tsx +41 -0
  51. package/app/(board)/thread/[slug]/post/[postId]/history/page.tsx +160 -0
  52. package/app/(board)/thread/[slug]/reply/page.tsx +136 -0
  53. package/app/(board)/unsubscribe/page.tsx +73 -0
  54. package/app/(board)/usercp/avatar/page.tsx +56 -0
  55. package/app/(board)/usercp/contacts/page.tsx +141 -0
  56. package/app/(board)/usercp/email/confirm/page.tsx +52 -0
  57. package/app/(board)/usercp/layout.tsx +23 -0
  58. package/app/(board)/usercp/options/page.tsx +56 -0
  59. package/app/(board)/usercp/page.tsx +71 -0
  60. package/app/(board)/usercp/profile/page.tsx +81 -0
  61. package/app/(board)/usercp/security/page.tsx +238 -0
  62. package/app/(board)/usercp/security/verify/page.tsx +92 -0
  63. package/app/(board)/usercp/signature/page.tsx +67 -0
  64. package/app/admin/antispam/page.tsx +155 -0
  65. package/app/admin/api/marketplace/screenshot/route.ts +68 -0
  66. package/app/admin/api/plugins/[key]/[...path]/route.ts +27 -0
  67. package/app/admin/api-tokens/page.tsx +129 -0
  68. package/app/admin/content/announcements/page.tsx +110 -0
  69. package/app/admin/content/attachments/page.tsx +199 -0
  70. package/app/admin/content/navigation/page.tsx +123 -0
  71. package/app/admin/content/page.tsx +170 -0
  72. package/app/admin/forums/[id]/page.tsx +96 -0
  73. package/app/admin/forums/[id]/permissions/page.tsx +108 -0
  74. package/app/admin/forums/page.tsx +45 -0
  75. package/app/admin/groups/[id]/page.tsx +133 -0
  76. package/app/admin/groups/memberships/page.tsx +56 -0
  77. package/app/admin/groups/page.tsx +104 -0
  78. package/app/admin/groups/promotions/page.tsx +143 -0
  79. package/app/admin/layout.tsx +86 -0
  80. package/app/admin/log/page.tsx +116 -0
  81. package/app/admin/page.tsx +206 -0
  82. package/app/admin/plugins/[key]/[[...path]]/page.tsx +276 -0
  83. package/app/admin/plugins/browse/page.tsx +70 -0
  84. package/app/admin/plugins/page.tsx +165 -0
  85. package/app/admin/security/page.tsx +124 -0
  86. package/app/admin/settings/page.tsx +190 -0
  87. package/app/admin/system/page.tsx +303 -0
  88. package/app/admin/themes/[key]/page.tsx +110 -0
  89. package/app/admin/themes/browse/page.tsx +70 -0
  90. package/app/admin/themes/page.tsx +163 -0
  91. package/app/admin/users/[id]/merge/page.tsx +136 -0
  92. package/app/admin/users/[id]/page.tsx +208 -0
  93. package/app/admin/users/mail/page.tsx +68 -0
  94. package/app/admin/users/page.tsx +260 -0
  95. package/app/admin/users/prune/page.tsx +186 -0
  96. package/app/api/health/route.ts +11 -0
  97. package/app/api/metrics/route.ts +66 -0
  98. package/app/api/plugins/[key]/[...path]/route.ts +27 -0
  99. package/app/api/read/all/route.ts +15 -0
  100. package/app/api/read/forum/[id]/route.ts +29 -0
  101. package/app/api/read/thread/[id]/route.ts +40 -0
  102. package/app/api/ready/route.ts +63 -0
  103. package/app/api/system/tick/route.ts +77 -0
  104. package/app/api/v1/[...path]/route.ts +208 -0
  105. package/app/api/v1/openapi.json/route.ts +12 -0
  106. package/app/apple-icon.ts +11 -0
  107. package/app/atom.xml/route.ts +7 -0
  108. package/app/attachment/[id]/route.ts +10 -0
  109. package/app/attachment/[id]/thumb/route.ts +10 -0
  110. package/app/auth/passkey/options/route.ts +145 -0
  111. package/app/auth/passkey/verify/route.ts +241 -0
  112. package/app/auth/resume/route.ts +55 -0
  113. package/app/auth/sso/[provider]/callback/route.ts +173 -0
  114. package/app/auth/sso/[provider]/go/route.ts +57 -0
  115. package/app/auth/sso/[provider]/route.ts +105 -0
  116. package/app/avatar/[id]/route.ts +31 -0
  117. package/app/brand/icon-192.png/route.ts +7 -0
  118. package/app/brand/icon-512.png/route.ts +7 -0
  119. package/app/brand/icon-maskable.png/route.ts +7 -0
  120. package/app/demo/stripe/[[...path]]/route.ts +27 -0
  121. package/app/error.tsx +15 -0
  122. package/app/feed.xml/route.ts +7 -0
  123. package/app/forumdisplay.php/page.tsx +11 -0
  124. package/app/group/[id]/badge/[scheme]/route.ts +26 -0
  125. package/app/icon.ts +13 -0
  126. package/app/install/page.tsx +221 -0
  127. package/app/jump/page.tsx +28 -0
  128. package/app/layout.tsx +124 -0
  129. package/app/logo/[scheme]/route.ts +24 -0
  130. package/app/manifest.ts +43 -0
  131. package/app/member.php/page.tsx +11 -0
  132. package/app/memberlist.php/page.tsx +11 -0
  133. package/app/not-found.tsx +14 -0
  134. package/app/opengraph-image.ts +11 -0
  135. package/app/redirect/page.tsx +44 -0
  136. package/app/robots.txt/route.ts +35 -0
  137. package/app/showthread.php/page.tsx +11 -0
  138. package/app/sitemap/[page]/route.ts +64 -0
  139. package/app/sitemap.xml/route.ts +31 -0
  140. package/app/twitter-image.ts +11 -0
  141. package/app/viewforum.php/page.tsx +11 -0
  142. package/app/viewtopic.php/page.tsx +11 -0
  143. package/bin/forum-web.mjs +241 -0
  144. package/components.json +21 -0
  145. package/instrumentation.ts +18 -0
  146. package/next.config.mjs +182 -0
  147. package/package.json +99 -0
  148. package/postcss.config.mjs +7 -0
  149. package/proxy.ts +160 -0
  150. package/src/components/account/active-sessions.tsx +87 -0
  151. package/src/components/account/avatar-form.tsx +119 -0
  152. package/src/components/account/credential-proof-form.tsx +76 -0
  153. package/src/components/account/logout-form.tsx +14 -0
  154. package/src/components/account/notification-forms.tsx +157 -0
  155. package/src/components/account/passkey-enrol.tsx +81 -0
  156. package/src/components/account/passkey-proof-button.tsx +47 -0
  157. package/src/components/account/push-device-form.tsx +193 -0
  158. package/src/components/account/relation-forms.tsx +69 -0
  159. package/src/components/account/reputation-forms.tsx +124 -0
  160. package/src/components/account/security-activity.tsx +46 -0
  161. package/src/components/account/sign-in-methods.tsx +166 -0
  162. package/src/components/account/subscription-forms.tsx +151 -0
  163. package/src/components/account/two-factor-forms.tsx +226 -0
  164. package/src/components/account/usercp-forms.tsx +387 -0
  165. package/src/components/account/usercp-nav.tsx +14 -0
  166. package/src/components/account/usercp-shell.tsx +34 -0
  167. package/src/components/admin/admin-forms.tsx +92 -0
  168. package/src/components/admin/admin-nav.tsx +52 -0
  169. package/src/components/admin/admin-undo.tsx +32 -0
  170. package/src/components/admin/api-token-forms.tsx +89 -0
  171. package/src/components/admin/badge-forms.tsx +97 -0
  172. package/src/components/admin/branding-forms.tsx +102 -0
  173. package/src/components/admin/content-forms.tsx +712 -0
  174. package/src/components/admin/form-bits.tsx +11 -0
  175. package/src/components/admin/forum-forms.tsx +524 -0
  176. package/src/components/admin/forum-tree.tsx +454 -0
  177. package/src/components/admin/group-forms.tsx +646 -0
  178. package/src/components/admin/mail-test-card.tsx +90 -0
  179. package/src/components/admin/marketplace-forms.tsx +30 -0
  180. package/src/components/admin/marketplace-listing.tsx +123 -0
  181. package/src/components/admin/navigation-forms.tsx +229 -0
  182. package/src/components/admin/navigation-tree.tsx +442 -0
  183. package/src/components/admin/oklch-picker.tsx +180 -0
  184. package/src/components/admin/plugin-forms.tsx +225 -0
  185. package/src/components/admin/settings-form.tsx +148 -0
  186. package/src/components/admin/system-forms.tsx +169 -0
  187. package/src/components/admin/theme-changes.tsx +354 -0
  188. package/src/components/admin/theme-forms.tsx +636 -0
  189. package/src/components/admin/theme-palette.tsx +119 -0
  190. package/src/components/admin/theme-preview.tsx +461 -0
  191. package/src/components/admin/user-bulk-toolbar.tsx +99 -0
  192. package/src/components/admin/user-forms.tsx +488 -0
  193. package/src/components/auth/auth-page.tsx +45 -0
  194. package/src/components/auth/form-controls.tsx +117 -0
  195. package/src/components/auth/login-form.tsx +47 -0
  196. package/src/components/auth/passkey-client.ts +171 -0
  197. package/src/components/auth/passkey-second-factor.tsx +69 -0
  198. package/src/components/auth/passkey-sign-in.tsx +61 -0
  199. package/src/components/auth/register-form.tsx +136 -0
  200. package/src/components/auth/resend-verification-form.tsx +34 -0
  201. package/src/components/auth/reset-confirm-form.tsx +43 -0
  202. package/src/components/auth/reset-request-form.tsx +35 -0
  203. package/src/components/auth/second-factor-form.tsx +51 -0
  204. package/src/components/auth/sso-buttons.tsx +47 -0
  205. package/src/components/board/live-region.tsx +74 -0
  206. package/src/components/board/search-off-notice.tsx +18 -0
  207. package/src/components/board/section-page.tsx +114 -0
  208. package/src/components/content/attachment-field.tsx +51 -0
  209. package/src/components/content/composer-field.ts +31 -0
  210. package/src/components/content/composer-ids.ts +6 -0
  211. package/src/components/content/composer-intents.tsx +27 -0
  212. package/src/components/content/composer-recovery.tsx +215 -0
  213. package/src/components/content/edit-post-form.tsx +121 -0
  214. package/src/components/content/markdown-editor.tsx +590 -0
  215. package/src/components/content/markdown-syntax.ts +53 -0
  216. package/src/components/content/mention-token.ts +23 -0
  217. package/src/components/content/multiquote-button.tsx +32 -0
  218. package/src/components/content/multiquote-selection.tsx +105 -0
  219. package/src/components/content/multiquote.ts +125 -0
  220. package/src/components/content/new-thread-form.tsx +169 -0
  221. package/src/components/content/poll-edit-form.tsx +96 -0
  222. package/src/components/content/poll.tsx +89 -0
  223. package/src/components/content/quote-in-place.tsx +51 -0
  224. package/src/components/content/reply-form.tsx +113 -0
  225. package/src/components/content/thanks-button.tsx +65 -0
  226. package/src/components/content/thread-rating.tsx +143 -0
  227. package/src/components/install/install-form.tsx +497 -0
  228. package/src/components/legal/legal-document.tsx +22 -0
  229. package/src/components/messages/message-forms.tsx +152 -0
  230. package/src/components/moderation/inline-moderation-form.tsx +158 -0
  231. package/src/components/moderation/modcp-nav.tsx +15 -0
  232. package/src/components/moderation/modcp-shell.tsx +41 -0
  233. package/src/components/moderation/queue-form.tsx +96 -0
  234. package/src/components/moderation/report-forms.tsx +120 -0
  235. package/src/components/moderation/signature-lock-form.tsx +83 -0
  236. package/src/components/moderation/thread-surgery-form.tsx +96 -0
  237. package/src/components/moderation/thread-tools-form.tsx +137 -0
  238. package/src/components/moderation/warning-forms.tsx +125 -0
  239. package/src/components/profile/custom-field.tsx +79 -0
  240. package/src/components/shell/appearance-icons.tsx +35 -0
  241. package/src/components/shell/board-notice.tsx +21 -0
  242. package/src/components/shell/copy-record.ts +20 -0
  243. package/src/components/shell/copy.tsx +17 -0
  244. package/src/components/shell/crash-notice.tsx +86 -0
  245. package/src/components/shell/demo-banner.tsx +49 -0
  246. package/src/components/shell/group-name-style.tsx +6 -0
  247. package/src/components/shell/measure.ts +1 -0
  248. package/src/components/shell/notification-menu.tsx +361 -0
  249. package/src/components/shell/offline-notice.tsx +29 -0
  250. package/src/components/shell/onboarding-banner.tsx +39 -0
  251. package/src/components/shell/page-shell.tsx +167 -0
  252. package/src/components/shell/panel-list.tsx +95 -0
  253. package/src/components/shell/panel-nav.tsx +40 -0
  254. package/src/components/shell/panel-overview.tsx +81 -0
  255. package/src/components/shell/panel-page.tsx +96 -0
  256. package/src/components/shell/panel-pagination.tsx +28 -0
  257. package/src/components/shell/panel-shell.tsx +31 -0
  258. package/src/components/shell/theme-runtime-style.tsx +6 -0
  259. package/src/components/shell/theme-switcher.tsx +97 -0
  260. package/src/components/shell/timezone-probe.tsx +45 -0
  261. package/src/components/shell/view-tabs.tsx +60 -0
  262. package/src/config.ts +13 -0
  263. package/src/server/absent-services.ts +29 -0
  264. package/src/server/admin-actions.ts +171 -0
  265. package/src/server/admin-settings-actions.ts +83 -0
  266. package/src/server/admin-undo-actions.ts +70 -0
  267. package/src/server/admin-undo.ts +75 -0
  268. package/src/server/admin.ts +112 -0
  269. package/src/server/announcements.ts +82 -0
  270. package/src/server/antispam.ts +310 -0
  271. package/src/server/api/content.ts +486 -0
  272. package/src/server/api/http.ts +119 -0
  273. package/src/server/api/members.ts +109 -0
  274. package/src/server/api/messages.ts +113 -0
  275. package/src/server/api/registry.ts +30 -0
  276. package/src/server/api/search.ts +98 -0
  277. package/src/server/api/subscriptions.ts +106 -0
  278. package/src/server/api-auth.ts +66 -0
  279. package/src/server/api-token-actions.ts +85 -0
  280. package/src/server/api-tokens-admin.ts +95 -0
  281. package/src/server/appearance-actions.ts +39 -0
  282. package/src/server/attachment-download.ts +43 -0
  283. package/src/server/attachment-embed.ts +63 -0
  284. package/src/server/attachment-upload-actions.ts +92 -0
  285. package/src/server/attachments.ts +257 -0
  286. package/src/server/auth-actions.ts +479 -0
  287. package/src/server/auth-config.ts +61 -0
  288. package/src/server/auth-events.ts +92 -0
  289. package/src/server/auth-form-state.ts +14 -0
  290. package/src/server/auth-mail.ts +104 -0
  291. package/src/server/avatars.ts +83 -0
  292. package/src/server/board-latest-actions.ts +7 -0
  293. package/src/server/board-latest.tsx +103 -0
  294. package/src/server/board-offline.ts +23 -0
  295. package/src/server/board-url.ts +14 -0
  296. package/src/server/brand-assets.ts +151 -0
  297. package/src/server/brand-mark.tsx +110 -0
  298. package/src/server/branding-actions.ts +53 -0
  299. package/src/server/branding.ts +101 -0
  300. package/src/server/cache-targets.ts +13 -0
  301. package/src/server/code-highlighting.ts +33 -0
  302. package/src/server/container.ts +402 -0
  303. package/src/server/content-actions.ts +459 -0
  304. package/src/server/content-admin-actions.ts +492 -0
  305. package/src/server/content-admin.ts +95 -0
  306. package/src/server/content-security-policy.ts +35 -0
  307. package/src/server/context.ts +32 -0
  308. package/src/server/cookies.ts +139 -0
  309. package/src/server/credential-proof-actions.ts +42 -0
  310. package/src/server/credential-proof.ts +35 -0
  311. package/src/server/current-location.ts +19 -0
  312. package/src/server/demo-stripe.ts +104 -0
  313. package/src/server/demo-uploads.ts +28 -0
  314. package/src/server/demo.ts +80 -0
  315. package/src/server/discovery.ts +104 -0
  316. package/src/server/embeds.ts +113 -0
  317. package/src/server/error-notice.tsx +41 -0
  318. package/src/server/federation-actions.ts +113 -0
  319. package/src/server/federation.ts +138 -0
  320. package/src/server/feed-builder.ts +72 -0
  321. package/src/server/feed-routes.ts +122 -0
  322. package/src/server/fixture-actor-source.ts +62 -0
  323. package/src/server/fixture-forum-repo.ts +59 -0
  324. package/src/server/fixture-member-profile-repo.ts +30 -0
  325. package/src/server/fixture-post-repo.ts +108 -0
  326. package/src/server/fixture-thread-repo.ts +98 -0
  327. package/src/server/form-state-reporter.ts +34 -0
  328. package/src/server/form-values.ts +23 -0
  329. package/src/server/forum-admin-actions.ts +353 -0
  330. package/src/server/forum-admin.ts +98 -0
  331. package/src/server/group-admin-actions.ts +341 -0
  332. package/src/server/group-admin.ts +98 -0
  333. package/src/server/group-badge-actions.ts +61 -0
  334. package/src/server/group-badge.ts +62 -0
  335. package/src/server/group-identity.ts +104 -0
  336. package/src/server/i18n-catalogs.ts +42 -0
  337. package/src/server/i18n.ts +69 -0
  338. package/src/server/image-upload.ts +109 -0
  339. package/src/server/inline-moderation-actions.ts +129 -0
  340. package/src/server/install-actions.ts +124 -0
  341. package/src/server/install.ts +281 -0
  342. package/src/server/legacy-redirect.ts +83 -0
  343. package/src/server/legal.ts +43 -0
  344. package/src/server/location-header.ts +37 -0
  345. package/src/server/mail-brand.ts +43 -0
  346. package/src/server/mail-health.ts +41 -0
  347. package/src/server/mail-send.ts +44 -0
  348. package/src/server/mail-test-actions.ts +67 -0
  349. package/src/server/mail-test.ts +56 -0
  350. package/src/server/markdown-pipeline.ts +31 -0
  351. package/src/server/marketplace-actions.ts +47 -0
  352. package/src/server/marketplace-admin.ts +260 -0
  353. package/src/server/mention-actions.ts +21 -0
  354. package/src/server/mention-search.ts +35 -0
  355. package/src/server/message-actions.ts +110 -0
  356. package/src/server/messages.ts +141 -0
  357. package/src/server/modcp.ts +125 -0
  358. package/src/server/moderation-actions.ts +165 -0
  359. package/src/server/navigation-admin-actions.ts +217 -0
  360. package/src/server/navigation.ts +112 -0
  361. package/src/server/nonce.ts +13 -0
  362. package/src/server/notification-actions.ts +84 -0
  363. package/src/server/notification-audience.ts +10 -0
  364. package/src/server/notification-menu-actions.ts +82 -0
  365. package/src/server/notification-menu.ts +96 -0
  366. package/src/server/notifications.ts +93 -0
  367. package/src/server/onboarding-actions.ts +27 -0
  368. package/src/server/onboarding.ts +89 -0
  369. package/src/server/page-metadata.ts +14 -0
  370. package/src/server/passkey-challenge.ts +42 -0
  371. package/src/server/plugin-admin-actions.ts +232 -0
  372. package/src/server/plugin-admin.ts +276 -0
  373. package/src/server/plugin-host.ts +143 -0
  374. package/src/server/plugin-pages.ts +192 -0
  375. package/src/server/plugin-panel.ts +49 -0
  376. package/src/server/plugin-routes.ts +298 -0
  377. package/src/server/plugin-view.tsx +58 -0
  378. package/src/server/poll-actions.ts +104 -0
  379. package/src/server/poll-scope.ts +52 -0
  380. package/src/server/post-notifications.ts +71 -0
  381. package/src/server/post-scope.ts +86 -0
  382. package/src/server/presence.ts +177 -0
  383. package/src/server/profile-fields.ts +112 -0
  384. package/src/server/push-actions.ts +113 -0
  385. package/src/server/push.ts +40 -0
  386. package/src/server/redirect-back.ts +15 -0
  387. package/src/server/registration.ts +7 -0
  388. package/src/server/relation-actions.ts +89 -0
  389. package/src/server/relations.ts +45 -0
  390. package/src/server/reply-core.ts +173 -0
  391. package/src/server/report-actions.ts +165 -0
  392. package/src/server/report-scope.ts +21 -0
  393. package/src/server/reputation-actions.ts +146 -0
  394. package/src/server/reputation-target.ts +49 -0
  395. package/src/server/reputation.ts +70 -0
  396. package/src/server/request-fingerprint.ts +65 -0
  397. package/src/server/safe-path.ts +16 -0
  398. package/src/server/same-origin.ts +45 -0
  399. package/src/server/search-page.ts +313 -0
  400. package/src/server/search.ts +45 -0
  401. package/src/server/secret-auth.ts +20 -0
  402. package/src/server/see-other.ts +11 -0
  403. package/src/server/seed-board.ts +417 -0
  404. package/src/server/session-actions.ts +78 -0
  405. package/src/server/session-cookies.ts +124 -0
  406. package/src/server/session-key.ts +17 -0
  407. package/src/server/settings.ts +47 -0
  408. package/src/server/signatures.ts +62 -0
  409. package/src/server/sso-handshake.ts +79 -0
  410. package/src/server/stats.ts +63 -0
  411. package/src/server/subscription-actions.ts +161 -0
  412. package/src/server/surgery-actions.ts +180 -0
  413. package/src/server/syndication.ts +77 -0
  414. package/src/server/system-admin-actions.ts +134 -0
  415. package/src/server/system-admin.ts +90 -0
  416. package/src/server/test-container.ts +114 -0
  417. package/src/server/theme-admin-actions.ts +242 -0
  418. package/src/server/theme-admin.ts +203 -0
  419. package/src/server/theme-runtime.ts +89 -0
  420. package/src/server/theme-style.ts +279 -0
  421. package/src/server/theme.ts +52 -0
  422. package/src/server/thread-core.ts +207 -0
  423. package/src/server/thread-rating-actions.ts +55 -0
  424. package/src/server/thread-tool-actions.ts +154 -0
  425. package/src/server/two-factor-actions.ts +178 -0
  426. package/src/server/two-factor.ts +86 -0
  427. package/src/server/upgrade-notice.ts +41 -0
  428. package/src/server/user-admin-actions.ts +572 -0
  429. package/src/server/user-admin.ts +165 -0
  430. package/src/server/usercp-actions.ts +263 -0
  431. package/src/server/usercp-mail.ts +120 -0
  432. package/src/server/viewer-preferences.ts +64 -0
  433. package/src/server/warning-actions.ts +122 -0
  434. package/src/styles/globals.css +969 -0
  435. package/src/theme/contract.fixture.ts +793 -0
  436. package/src/view/account-copy.ts +351 -0
  437. package/src/view/admin-content-copy.ts +66 -0
  438. package/src/view/admin-copy.ts +106 -0
  439. package/src/view/admin-forum-tree-copy.ts +40 -0
  440. package/src/view/admin-group-copy.ts +80 -0
  441. package/src/view/admin-log.ts +58 -0
  442. package/src/view/admin-nav.ts +164 -0
  443. package/src/view/admin-navigation-copy.ts +58 -0
  444. package/src/view/admin-panel-copy.ts +194 -0
  445. package/src/view/admin-settings.ts +173 -0
  446. package/src/view/admin-theme-copy.ts +229 -0
  447. package/src/view/admin-user-copy.ts +96 -0
  448. package/src/view/attachments.ts +51 -0
  449. package/src/view/auth-copy.ts +147 -0
  450. package/src/view/board-index.ts +167 -0
  451. package/src/view/board-latest.ts +101 -0
  452. package/src/view/breadcrumb.ts +50 -0
  453. package/src/view/contacts.ts +85 -0
  454. package/src/view/content-copy.ts +118 -0
  455. package/src/view/contrast.ts +286 -0
  456. package/src/view/copy.ts +101 -0
  457. package/src/view/count.ts +8 -0
  458. package/src/view/discovery-view.ts +74 -0
  459. package/src/view/editor-toolbar.ts +84 -0
  460. package/src/view/error-notice.ts +52 -0
  461. package/src/view/feed.ts +130 -0
  462. package/src/view/forum-cursor.ts +54 -0
  463. package/src/view/forum-display.ts +152 -0
  464. package/src/view/forum-jump.ts +60 -0
  465. package/src/view/forum-notice.ts +24 -0
  466. package/src/view/inline-moderation.ts +87 -0
  467. package/src/view/install-copy.ts +95 -0
  468. package/src/view/legal.ts +58 -0
  469. package/src/view/locale.ts +40 -0
  470. package/src/view/marketplace-panel.ts +51 -0
  471. package/src/view/member-identity.ts +23 -0
  472. package/src/view/member-profile.ts +65 -0
  473. package/src/view/messages.ts +222 -0
  474. package/src/view/metadata.ts +75 -0
  475. package/src/view/modcp-nav.ts +76 -0
  476. package/src/view/moderation-copy.ts +84 -0
  477. package/src/view/moderation-queue.ts +67 -0
  478. package/src/view/navigation-arrange.ts +188 -0
  479. package/src/view/navigation.ts +173 -0
  480. package/src/view/notification-menu.ts +202 -0
  481. package/src/view/notifications.ts +84 -0
  482. package/src/view/onboarding.ts +26 -0
  483. package/src/view/pager.ts +191 -0
  484. package/src/view/panel-nav.ts +203 -0
  485. package/src/view/plugin-panel.ts +42 -0
  486. package/src/view/post-form.ts +61 -0
  487. package/src/view/post-link.ts +29 -0
  488. package/src/view/presence.ts +99 -0
  489. package/src/view/promotion-rules.ts +52 -0
  490. package/src/view/redirect-notice.ts +21 -0
  491. package/src/view/reputation.ts +107 -0
  492. package/src/view/search-controls.ts +103 -0
  493. package/src/view/search-results.ts +291 -0
  494. package/src/view/security-activity.ts +73 -0
  495. package/src/view/setting-groups.ts +43 -0
  496. package/src/view/shell.ts +164 -0
  497. package/src/view/slug-id.ts +12 -0
  498. package/src/view/sso-hand-off.ts +44 -0
  499. package/src/view/sso-notices.ts +48 -0
  500. package/src/view/subscriptions.ts +76 -0
  501. package/src/view/theme-draft.ts +167 -0
  502. package/src/view/theme-preference.ts +30 -0
  503. package/src/view/theme-tokens.ts +333 -0
  504. package/src/view/thread-view.ts +280 -0
  505. package/src/view/time.ts +31 -0
  506. package/src/view/timezone.ts +35 -0
  507. package/src/view/two-factor.ts +6 -0
  508. package/src/view/usercp-nav.ts +103 -0
  509. package/src/view/usercp.ts +196 -0
  510. package/src/view/warnings.ts +128 -0
  511. package/src/view/word-filter.ts +5 -0
@@ -0,0 +1,171 @@
1
+ 'use server'
2
+
3
+ import { redirect } from 'next/navigation'
4
+
5
+ import { generateToken, hashToken, verifyPassword } from '@meith/accounts'
6
+ import { ipAllowed } from '@meith/admin'
7
+ import { ForbiddenError, logger, ValidationError } from '@meith/core'
8
+ import { msg } from '@meith/i18n'
9
+
10
+ import { adminAllowlist, adminService, recordAdminAction, resolveAdmin } from './admin'
11
+ import { boardAuthConfig } from './auth-config'
12
+ import type { FormState } from './auth-form-state'
13
+ import { getContainer } from './container'
14
+ import { getActor } from './context'
15
+ import { formStateReporter } from './form-state-reporter'
16
+ import { text } from './form-values'
17
+ import { remoteAddress, retainedIpPrefix } from './request-fingerprint'
18
+ import { isSafeLocalPath } from './safe-path'
19
+ import { clearAdminCookie, readAdminToken, setAdminCookie } from './session-cookies'
20
+ import { twoFactorRequiredForStaff, twoFactorService } from './two-factor'
21
+
22
+ const toFormState = formStateReporter('admin-actions', 'unexpected error in an admin action')
23
+
24
+ export async function adminSignInAction(_prev: FormState, form: FormData): Promise<FormState> {
25
+ let target: string
26
+
27
+ try {
28
+ if (!ipAllowed(await remoteAddress(), await adminAllowlist())) {
29
+ throw new ForbiddenError(msg('error.app.control-panel-available-from-address'))
30
+ }
31
+
32
+ const service = adminService()
33
+ if (service === null) {
34
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-25'))
35
+ }
36
+
37
+ const actor = await getActor()
38
+ const { authorizer, accountStore } = getContainer()
39
+ if (actor.userId === null || !authorizer.can(actor, 'admincp.access')) {
40
+ throw new ForbiddenError(msg('error.app.reach-control-panel'))
41
+ }
42
+
43
+ const account = await accountStore.accounts.findById(actor.userId)
44
+ if (account === null) throw new ForbiddenError(msg('error.app.reach-control-panel'))
45
+
46
+ const config = await boardAuthConfig()
47
+ const attemptBucket = await adminReauthenticationBucket(actor.userId)
48
+ const attemptSince = new Date(Date.now() - config.lockoutMinutes * 60_000)
49
+ if (
50
+ config.maxLoginAttempts > 0 &&
51
+ (await accountStore.loginAttempts.countFailuresSince(attemptBucket, attemptSince)) >=
52
+ config.maxLoginAttempts
53
+ ) {
54
+ throw new ForbiddenError(msg('error.accounts.too-many-failed-attempts-please'))
55
+ }
56
+
57
+ const failAttempt = async (): Promise<void> => {
58
+ await accountStore.loginAttempts.record(attemptBucket, false, new Date())
59
+ await recordAdminAction({ action: 'admin.signin_failed' })
60
+ }
61
+
62
+ const password = text(form, 'password')
63
+ if (password === '') {
64
+ await failAttempt()
65
+ throw new ValidationError(msg('error.app.enter-password'))
66
+ }
67
+
68
+ const ok = await verifyPassword(password, account.passwordHash)
69
+ if (!ok) {
70
+ await failAttempt()
71
+ throw new ForbiddenError(msg('error.app.password-right'))
72
+ }
73
+
74
+ try {
75
+ await assertSecondFactorGiven({ userId: actor.userId, form })
76
+ } catch (err) {
77
+ await failAttempt()
78
+ throw err
79
+ }
80
+
81
+ await accountStore.loginAttempts.clear(attemptBucket)
82
+
83
+ const existing = await resolveAdmin()
84
+ if ('context' in existing) {
85
+ await service.markReauthenticated(existing.context.session.id)
86
+ await recordAdminAction({ action: 'admin.reauthenticated' })
87
+ } else {
88
+ const token = generateToken()
89
+ const session = await service.start({
90
+ userId: actor.userId,
91
+ tokenHash: await hashToken(token),
92
+ ipPrefix: await retainedIpPrefix(),
93
+ })
94
+ await setAdminCookie(token, session.expiresAt)
95
+ await recordAdminAction({ action: 'admin.signed_in' })
96
+ }
97
+
98
+ target = safeAdminReturn(text(form, 'next'))
99
+ } catch (err) {
100
+ return toFormState(err)
101
+ }
102
+
103
+ redirect(target)
104
+ }
105
+
106
+ /**
107
+ * The panel's door asks for the second factor as well as the password. The
108
+ * panel can rewrite the whole board, so re-proving only the thing most likely
109
+ * to have leaked would be re-proving the wrong one.
110
+ */
111
+ async function adminReauthenticationBucket(userId: number): Promise<string> {
112
+ const prefix = await retainedIpPrefix()
113
+ return prefix === null ? `admin-reauth:${userId}` : `admin-reauth:${userId}@${prefix}`
114
+ }
115
+
116
+ async function assertSecondFactorGiven(input: {
117
+ readonly userId: number
118
+ readonly form: FormData
119
+ }): Promise<void> {
120
+ const service = twoFactorService()
121
+ if (service === null) return
122
+
123
+ const enrolled = await service.isEnrolled(input.userId)
124
+
125
+ if (!enrolled) {
126
+ if (await twoFactorRequiredForStaff()) {
127
+ throw new ForbiddenError(msg('adminAction.twoFactorRequired'))
128
+ }
129
+ return
130
+ }
131
+
132
+ const code = text(input.form, 'code')
133
+ if (code === '') {
134
+ throw new ValidationError(msg('error.app.enter-code-from-authenticator-app'))
135
+ }
136
+
137
+ const outcome = await service.verify({ userId: input.userId, code })
138
+ if (outcome.status !== 'ok') {
139
+ if (outcome.status === 'replayed') {
140
+ throw new ForbiddenError(msg('adminAction.codeReplayed'))
141
+ }
142
+ throw new ForbiddenError(msg('adminAction.codeWrong'))
143
+ }
144
+ }
145
+
146
+ export async function adminSignOutAction(): Promise<void> {
147
+ try {
148
+ const token = await readAdminToken()
149
+ const service = adminService()
150
+
151
+ if (token !== null && token !== '' && service !== null) {
152
+ const resolved = await resolveAdmin()
153
+ if ('context' in resolved) {
154
+ await recordAdminAction({ action: 'admin.signed_out' })
155
+ await service.end(resolved.context.session.id)
156
+ }
157
+ }
158
+ } catch (err) {
159
+ logger({ module: 'admin-actions' }).warn({ err }, 'admin sign-out could not revoke')
160
+ }
161
+
162
+ await clearAdminCookie()
163
+ redirect('/')
164
+ }
165
+
166
+ function safeAdminReturn(raw: string): string {
167
+ const trimmed = raw.trim()
168
+ if (!isSafeLocalPath(trimmed) || !trimmed.startsWith('/admin')) return '/admin'
169
+ if (/^\/admin[^/?#]/.test(trimmed)) return '/admin'
170
+ return trimmed
171
+ }
@@ -0,0 +1,83 @@
1
+ 'use server'
2
+
3
+ import { revalidatePath } from 'next/cache'
4
+
5
+ import { CacheTags, isAppError, logger, ValidationError } from '@meith/core'
6
+ import { getDb, PostgresSettingsRepository } from '@meith/db'
7
+ import { drivers } from '@meith/drivers'
8
+ import { msg } from '@meith/i18n'
9
+ import {
10
+ coerceFormValue,
11
+ SETTING_DEFINITIONS,
12
+ type SettingDefinition,
13
+ saveSettings,
14
+ secretClearField,
15
+ } from '@meith/settings'
16
+
17
+ import { recordAdminAction, requireAdmin } from './admin'
18
+ import type { FormState } from './auth-form-state'
19
+ import { tr } from './i18n'
20
+ import { emitEvent } from './plugin-view'
21
+ import { getSettings } from './settings'
22
+
23
+ function submittedKeys(form: FormData): readonly SettingDefinition[] {
24
+ const declared = new Set(
25
+ (form.get('keys') ?? '')
26
+ .toString()
27
+ .split(',')
28
+ .map((key) => key.trim())
29
+ .filter((key) => key !== ''),
30
+ )
31
+
32
+ return SETTING_DEFINITIONS.filter((definition) => declared.has(definition.key))
33
+ }
34
+
35
+ export async function saveAdminSettingsAction(
36
+ _prev: FormState,
37
+ form: FormData,
38
+ ): Promise<FormState> {
39
+ try {
40
+ const admin = await requireAdmin()
41
+
42
+ const definitions = submittedKeys(form)
43
+ if (definitions.length === 0) {
44
+ throw new ValidationError(msg('error.app.form-submitted-settings'))
45
+ }
46
+
47
+ const updates: Record<string, unknown> = {}
48
+ for (const definition of definitions) {
49
+ const raw = form.get(definition.key)
50
+ const value = coerceFormValue(definition, typeof raw === 'string' ? raw : undefined, {
51
+ clear: form.get(secretClearField(definition.key)) === '1',
52
+ })
53
+ if (value !== undefined) updates[definition.key] = value
54
+ }
55
+
56
+ const result = await saveSettings(
57
+ new PostgresSettingsRepository(getDb()),
58
+ updates,
59
+ await getSettings(),
60
+ )
61
+
62
+ if (result.changed.length > 0) {
63
+ const tags = [CacheTags.settings(), ...result.invalidates]
64
+ await drivers().cache.invalidateTags(tags)
65
+ for (const tag of tags) await emitEvent('cache.invalidated', { tag }, {})
66
+
67
+ revalidatePath('/admin/settings')
68
+
69
+ await emitEvent('settings.saved', { keys: result.changed }, { adminId: admin.session.userId })
70
+
71
+ await recordAdminAction({
72
+ action: 'settings.changed',
73
+ detail: { keys: result.changed },
74
+ })
75
+ }
76
+
77
+ return { notice: result.changed.length === 0 ? 'unchanged' : 'saved' }
78
+ } catch (err) {
79
+ if (isAppError(err)) return { error: err.message }
80
+ logger({ module: 'admin-settings' }).error({ err }, 'failed to save settings')
81
+ return { error: await tr('notice.app.something-went-wrong-please-try') }
82
+ }
83
+ }
@@ -0,0 +1,70 @@
1
+ 'use server'
2
+
3
+ import { refresh, revalidatePath } from 'next/cache'
4
+
5
+ import { ValidationError } from '@meith/core'
6
+ import { msg } from '@meith/i18n'
7
+
8
+ import { recordAdminAction, requireAdmin } from './admin'
9
+ import { claimAdminUndo } from './admin-undo'
10
+ import type { FormState } from './auth-form-state'
11
+ import { formStateReporter } from './form-state-reporter'
12
+ import { trimmedText } from './form-values'
13
+ import { banService, requireUserAdmin } from './user-admin'
14
+
15
+ const toFormState = formStateReporter('admin-undo', 'admin undo failed')
16
+
17
+ function positive(value: unknown): number {
18
+ const parsed = Number(value)
19
+ if (!Number.isSafeInteger(parsed) || parsed <= 0)
20
+ throw new ValidationError(msg('admin.undoInvalid'))
21
+ return parsed
22
+ }
23
+
24
+ function numbers(value: unknown): number[] {
25
+ if (!Array.isArray(value)) throw new ValidationError(msg('admin.undoInvalid'))
26
+ return value.map(positive)
27
+ }
28
+
29
+ export async function undoAdminAction(_prev: FormState, form: FormData): Promise<FormState> {
30
+ try {
31
+ const context = await requireAdmin()
32
+ const claimed = await claimAdminUndo({
33
+ token: trimmedText(form, 'undoToken'),
34
+ actorUserId: context.session.userId,
35
+ })
36
+ const snapshot = claimed.snapshot
37
+
38
+ if (claimed.operation === 'user.state') {
39
+ const state = snapshot.state
40
+ if (state !== 'active' && state !== 'awaiting_activation') {
41
+ throw new ValidationError(msg('admin.undoInvalid'))
42
+ }
43
+ await requireUserAdmin().setState(positive(snapshot.userId), state)
44
+ } else if (claimed.operation === 'user.groups') {
45
+ await requireUserAdmin().setSecondaryGroups(
46
+ positive(snapshot.userId),
47
+ numbers(snapshot.memberships),
48
+ context.session.userId,
49
+ )
50
+ } else if (claimed.operation === 'user.ban' || claimed.operation === 'user.bulk_ban') {
51
+ const ids =
52
+ claimed.operation === 'user.ban' ? [positive(snapshot.userId)] : numbers(snapshot.userIds)
53
+ for (const id of ids) await banService().lift(id)
54
+ } else {
55
+ throw new ValidationError(msg('admin.undoInvalid'))
56
+ }
57
+
58
+ revalidatePath('/admin')
59
+ revalidatePath('/admin/users')
60
+ revalidatePath('/admin/users/[id]', 'page')
61
+ refresh()
62
+ await recordAdminAction({
63
+ action: `${claimed.operation}.undone`,
64
+ detail: { undoOperationId: claimed.id },
65
+ })
66
+ return { notice: 'undone' }
67
+ } catch (err) {
68
+ return toFormState(err)
69
+ }
70
+ }
@@ -0,0 +1,75 @@
1
+ import 'server-only'
2
+
3
+ import { randomUUID } from 'node:crypto'
4
+
5
+ import { hashToken } from '@meith/accounts'
6
+ import { ForbiddenError, ValidationError } from '@meith/core'
7
+ import { getDb, PostgresAdminUndoRepository } from '@meith/db'
8
+ import { msg } from '@meith/i18n'
9
+
10
+ import type { UndoState } from './auth-form-state'
11
+ import { getContainer } from './container'
12
+
13
+ export const ADMIN_UNDO_WINDOW_MS = 10 * 60_000
14
+
15
+ function repository(): PostgresAdminUndoRepository {
16
+ if (getContainer().dataSource !== 'postgres') {
17
+ throw new ForbiddenError(msg('admin.undoUnavailable'))
18
+ }
19
+ return new PostgresAdminUndoRepository(getDb())
20
+ }
21
+
22
+ export async function issueAdminUndo(input: {
23
+ readonly actorUserId: number
24
+ readonly operation: string
25
+ readonly snapshot: Readonly<Record<string, unknown>>
26
+ readonly now?: Date
27
+ }): Promise<UndoState | undefined> {
28
+ if (getContainer().dataSource !== 'postgres') return undefined
29
+
30
+ const now = input.now ?? new Date()
31
+ const expiresAt = new Date(now.getTime() + ADMIN_UNDO_WINDOW_MS)
32
+ const token = randomUUID()
33
+
34
+ await repository().create({
35
+ tokenHash: await hashToken(token),
36
+ actorUserId: input.actorUserId,
37
+ operation: input.operation,
38
+ snapshot: input.snapshot,
39
+ createdAt: now,
40
+ expiresAt,
41
+ })
42
+
43
+ return { token, expiresAt: expiresAt.toISOString() }
44
+ }
45
+
46
+ export async function readAdminUndo(input: {
47
+ readonly token: string
48
+ readonly actorUserId: number
49
+ }) {
50
+ const token = input.token.trim()
51
+ if (token === '') return null
52
+ return repository().findLive({
53
+ tokenHash: await hashToken(token),
54
+ actorUserId: input.actorUserId,
55
+ now: new Date(),
56
+ })
57
+ }
58
+
59
+ export async function claimAdminUndo(input: {
60
+ readonly token: string
61
+ readonly actorUserId: number
62
+ }) {
63
+ const token = input.token.trim()
64
+ if (token === '') throw new ValidationError(msg('admin.undoInvalid'))
65
+
66
+ const operation = await repository().claim({
67
+ tokenHash: await hashToken(token),
68
+ actorUserId: input.actorUserId,
69
+ now: new Date(),
70
+ })
71
+ if (operation === null) {
72
+ throw new ValidationError(msg('admin.undoExpired'))
73
+ }
74
+ return operation
75
+ }
@@ -0,0 +1,112 @@
1
+ import { msg } from '@meith/i18n'
2
+ import 'server-only'
3
+
4
+ import { notFound } from 'next/navigation'
5
+ import { cache } from 'react'
6
+
7
+ import { hashToken } from '@meith/accounts'
8
+ import { type AdminContext, AdminService, ipAllowed, parseAllowlist } from '@meith/admin'
9
+ import { env, ForbiddenError } from '@meith/core'
10
+
11
+ import { getContainer } from './container'
12
+ import { getActor } from './context'
13
+ import { remoteAddress, retainedIpPrefix } from './request-fingerprint'
14
+ import { readAdminToken } from './session-cookies'
15
+
16
+ export type AdminDenial = 'address' | 'permission' | 'signin' | 'expired' | 'unavailable'
17
+
18
+ export function adminService(): AdminService | null {
19
+ const { adminSessions } = getContainer()
20
+ return adminSessions === null ? null : new AdminService({ sessions: adminSessions })
21
+ }
22
+
23
+ export const adminAllowlist = cache(
24
+ async (): Promise<readonly string[]> => parseAllowlist(env.ADMIN_IP_ALLOWLIST),
25
+ )
26
+
27
+ export const resolveAdmin = cache(
28
+ async (): Promise<{ context: AdminContext } | { denied: AdminDenial }> => {
29
+ const allowlist = await adminAllowlist()
30
+ if (!ipAllowed(await remoteAddress(), allowlist)) return { denied: 'address' }
31
+
32
+ const service = adminService()
33
+ if (service === null) return { denied: 'unavailable' }
34
+
35
+ const actor = await getActor()
36
+ const { authorizer } = getContainer()
37
+ if (actor.userId === null || !authorizer.can(actor, 'admincp.access')) {
38
+ return { denied: 'permission' }
39
+ }
40
+
41
+ const token = await readAdminToken()
42
+ if (token === null || token === '') return { denied: 'signin' }
43
+
44
+ const context = await service.resolve(await hashToken(token))
45
+ if (context === null) return { denied: 'expired' }
46
+
47
+ if (context.userId !== actor.userId) return { denied: 'expired' }
48
+
49
+ return { context }
50
+ },
51
+ )
52
+
53
+ export async function requireAdmin(): Promise<AdminContext> {
54
+ const resolved = await resolveAdmin()
55
+ if ('context' in resolved) return resolved.context
56
+
57
+ throw new ForbiddenError(
58
+ resolved.denied === 'address'
59
+ ? msg('error.app.control-panel-available-from-address')
60
+ : resolved.denied === 'unavailable'
61
+ ? msg('error.app.board-running-in-memory-sample-data-25')
62
+ : resolved.denied === 'permission'
63
+ ? msg('error.app.reach-control-panel')
64
+ : msg('error.app.control-panel-sign-in'),
65
+ )
66
+ }
67
+
68
+ export function askForPassword(denied: AdminDenial): boolean {
69
+ return denied === 'signin' || denied === 'expired'
70
+ }
71
+
72
+ export async function adminPageContext(): Promise<AdminContext | null> {
73
+ const resolved = await resolveAdmin()
74
+ if ('context' in resolved) return resolved.context
75
+ if (askForPassword(resolved.denied)) return null
76
+
77
+ notFound()
78
+ }
79
+
80
+ export async function requireFreshAdmin(): Promise<AdminContext> {
81
+ const context = await requireAdmin()
82
+ const service = adminService()
83
+ if (service === null) throw new ForbiddenError(msg('error.app.control-panel-board'))
84
+
85
+ service.requireFreshProof(context)
86
+ return context
87
+ }
88
+
89
+ export async function recordAdminAction(input: {
90
+ readonly action: string
91
+ readonly detail?: Readonly<Record<string, unknown>>
92
+ }): Promise<void> {
93
+ const { adminLog } = getContainer()
94
+ if (adminLog === null) return
95
+
96
+ try {
97
+ const actor = await getActor()
98
+ const { assertLogAction } = await import('@meith/admin')
99
+ assertLogAction(input.action)
100
+
101
+ await adminLog.record({
102
+ userId: actor.userId,
103
+ action: input.action,
104
+ detail: input.detail ?? {},
105
+ ipPrefix: await retainedIpPrefix(),
106
+ at: new Date(),
107
+ })
108
+ } catch (err) {
109
+ const { logger } = await import('@meith/core')
110
+ logger({ module: 'admin' }).error({ err, action: input.action }, 'admin log write failed')
111
+ }
112
+ }
@@ -0,0 +1,82 @@
1
+ import 'server-only'
2
+
3
+ import { env, logger } from '@meith/core'
4
+ import { type AnnouncementRow, getDb, PostgresAnnouncementRepository } from '@meith/db'
5
+ import type { Translator } from '@meith/i18n'
6
+ import { type BoardVocabulary, renderMarkdown, vocabularyOptions } from '@meith/markdown'
7
+ import type { AnnouncementModel } from '@meith/theme-kit'
8
+
9
+ import { forumHref } from '../view/board-index'
10
+ import { distinctUserIds, type MemberIdentity, nameClassOf } from '../view/member-identity'
11
+ import { formatTime } from '../view/time'
12
+ import { activeVocabulary } from './content-admin'
13
+ import { identitiesFor } from './group-identity'
14
+
15
+ export function announcementRepository(): PostgresAnnouncementRepository | null {
16
+ return env.DATA_SOURCE === 'postgres' ? new PostgresAnnouncementRepository(getDb()) : null
17
+ }
18
+
19
+ export async function liveAnnouncements(input: {
20
+ readonly visibleForumIds: readonly number[]
21
+ readonly scope?: number | null
22
+ readonly now: Date
23
+ readonly t?: Translator | undefined
24
+ }): Promise<readonly AnnouncementModel[]> {
25
+ const repository = announcementRepository()
26
+ if (repository === null) return []
27
+
28
+ try {
29
+ const [rows, vocabulary] = await Promise.all([
30
+ repository.live({
31
+ now: input.now,
32
+ visibleForumIds: input.visibleForumIds,
33
+ scope: input.scope ?? null,
34
+ }),
35
+ activeVocabulary(),
36
+ ])
37
+
38
+ const identities = await identitiesFor(distinctUserIds(rows.map((row) => row.authorUserId)))
39
+
40
+ return rows.map((row) => toModel(row, input.now, input.t, vocabulary, identities))
41
+ } catch (error) {
42
+ logger().warn({ err: String(error) }, 'could not read announcements')
43
+ return []
44
+ }
45
+ }
46
+
47
+ function toModel(
48
+ row: AnnouncementRow,
49
+ now: Date,
50
+ translator: Translator | undefined,
51
+ vocabulary: BoardVocabulary | undefined,
52
+ identities: ReadonlyMap<number, MemberIdentity>,
53
+ ): AnnouncementModel {
54
+ return {
55
+ title: row.title,
56
+ bodyHtml: renderMarkdown(row.message, {
57
+ ...vocabularyOptions(vocabulary),
58
+ ...(translator === undefined
59
+ ? {}
60
+ : {
61
+ quoteAttribution: (author) => translator.t('markdown.quote.attribution', { author }),
62
+ }),
63
+ }).html,
64
+ postedBy:
65
+ row.authorUsername === ''
66
+ ? null
67
+ : {
68
+ userId: row.authorUserId,
69
+ username: row.authorUsername,
70
+ profileHref: row.authorUserId === null ? null : `/member/${row.authorUserId}`,
71
+ nameClass: nameClassOf(identities, row.authorUserId),
72
+ },
73
+ postedAt: formatTime(row.startsAt, now, translator),
74
+ forum:
75
+ row.forumId === null || row.forumTitle === null || row.forumSlug === null
76
+ ? null
77
+ : {
78
+ label: row.forumTitle,
79
+ href: forumHref({ id: row.forumId, slug: row.forumSlug }),
80
+ },
81
+ }
82
+ }