@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,281 @@
1
+ import 'server-only'
2
+
3
+ import {
4
+ type AuthConfig,
5
+ DEFAULT_AUTH_POLICY,
6
+ IdentityService,
7
+ rejectedField,
8
+ resolveAuthPolicy,
9
+ } from '@meith/accounts'
10
+ import { env, GLOBAL_TAGS, logger } from '@meith/core'
11
+ import {
12
+ canConnect as canConnectTo,
13
+ countUsers,
14
+ createPostgresAccountStore,
15
+ getDb,
16
+ isInstalled,
17
+ markInstalled,
18
+ PostgresAdminRepository,
19
+ PostgresForumRepository,
20
+ PostgresSettingsRepository,
21
+ runMigrations,
22
+ SEED_GROUP_KEY,
23
+ withInstallLock,
24
+ } from '@meith/db'
25
+ import { currentMailConfig, drivers } from '@meith/drivers'
26
+ import {
27
+ type Check,
28
+ defaultForumSlug,
29
+ INSTALL_STEPS,
30
+ type InstallInput,
31
+ type MailProbe,
32
+ mailConfigFromInstallInput,
33
+ preflight,
34
+ type StepOutcome,
35
+ } from '@meith/install'
36
+ import {
37
+ canSendMail,
38
+ describeMailConfig,
39
+ mailConfigFromEnvironment,
40
+ SettingsSnapshot,
41
+ saveSettings,
42
+ } from '@meith/settings'
43
+
44
+ const INSTALLED_VERSION = '0.1.0'
45
+
46
+ const INSTALL_IN_FLIGHT = 'install.raced.inFlight'
47
+
48
+ const INSTALL_RACED = 'install.raced.sealed'
49
+
50
+ export async function gatherPreflight(): Promise<readonly Check[]> {
51
+ const dataSource = env.DATA_SOURCE === 'postgres' ? 'postgres' : 'fixture'
52
+ const databaseUrl = env.DATABASE_URL ?? null
53
+
54
+ let connected: boolean | null = null
55
+ let users: number | null = null
56
+ let installed = false
57
+
58
+ if (dataSource === 'postgres' && databaseUrl !== null && databaseUrl !== '') {
59
+ try {
60
+ const db = getDb()
61
+ connected = await canConnectTo(db)
62
+ if (connected) {
63
+ users = await countUsers(db)
64
+ installed = await isInstalled(db)
65
+ }
66
+ } catch (error) {
67
+ logger().warn({ err: String(error) }, 'install preflight could not reach the database')
68
+ connected = false
69
+ }
70
+ }
71
+
72
+ return preflight({
73
+ dataSource,
74
+ databaseUrl,
75
+ hasAuthSecret: (env.AUTH_SECRET ?? '') !== '',
76
+ hasTickSecret: (env.TICK_SECRET ?? '') !== '',
77
+ publicUrl: env.APP_URL ?? null,
78
+ mail: await probeMail(),
79
+ canConnect: connected,
80
+ pendingMigrations: null,
81
+ userCount: users,
82
+ alreadyInstalled: installed,
83
+ })
84
+ }
85
+
86
+ export async function probeMail(): Promise<MailProbe> {
87
+ const fromEnvironment = mailConfigFromEnvironment(env)
88
+
89
+ try {
90
+ const config = fromEnvironment ?? (await currentMailConfig())
91
+ return {
92
+ configured: canSendMail(config),
93
+ source: fromEnvironment === null ? 'board' : 'environment',
94
+ summary: describeMailConfig(config),
95
+ }
96
+ } catch (error) {
97
+ logger().warn({ err: String(error) }, 'install preflight could not read mail settings')
98
+ return {
99
+ configured: false,
100
+ source: fromEnvironment === null ? 'board' : 'environment',
101
+ summary: 'Not sending — messages are written to the server log',
102
+ }
103
+ }
104
+ }
105
+
106
+ export async function installerIsSealed(): Promise<boolean> {
107
+ if (env.DATA_SOURCE !== 'postgres') return false
108
+ try {
109
+ return await isInstalled(getDb())
110
+ } catch {
111
+ return false
112
+ }
113
+ }
114
+
115
+ async function forgetCachedBoard(): Promise<void> {
116
+ try {
117
+ await drivers().cache.invalidateTags(GLOBAL_TAGS)
118
+ } catch (error) {
119
+ logger().warn(
120
+ { err: String(error) },
121
+ 'install could not clear the cache; restart the server if the board looks empty',
122
+ )
123
+ }
124
+ }
125
+
126
+ export type InstallRun = { readonly sealed: true } | { readonly report: readonly StepOutcome[] }
127
+
128
+ export async function runInstall(input: InstallInput): Promise<InstallRun> {
129
+ const url = env.DATABASE_URL ?? ''
130
+
131
+ try {
132
+ const run = await withInstallLock(url, async (): Promise<InstallRun> => {
133
+ if (await isInstalled(getDb())) return { sealed: true }
134
+ return { report: await performInstall(input) }
135
+ })
136
+
137
+ return run ?? { report: [{ id: 'migrate', status: 'failed', error: INSTALL_IN_FLIGHT }] }
138
+ } finally {
139
+ await forgetCachedBoard()
140
+ }
141
+ }
142
+
143
+ async function performInstall(input: InstallInput): Promise<readonly StepOutcome[]> {
144
+ const report: StepOutcome[] = []
145
+ const db = getDb()
146
+
147
+ const step = async (id: string, body: () => Promise<void>): Promise<boolean> => {
148
+ try {
149
+ await body()
150
+ report.push({ id, status: 'done' })
151
+ return true
152
+ } catch (error) {
153
+ const message = error instanceof Error ? error.message : String(error)
154
+ logger().error({ step: id, err: message }, 'install step failed')
155
+ const field = rejectedField(error)
156
+ report.push({
157
+ id,
158
+ status: 'failed',
159
+ error: message.slice(0, 300),
160
+ ...(field === null ? {} : { field }),
161
+ })
162
+ return false
163
+ }
164
+ }
165
+
166
+ if (!(await step('migrate', async () => void (await runMigrations())))) return report
167
+
168
+ const settings = new PostgresSettingsRepository(db)
169
+ if (
170
+ !(await step('settings', async () => {
171
+ await settings.save(
172
+ new Map([
173
+ ['board.name', input.boardName],
174
+ ['board.url', input.boardUrl],
175
+ ]),
176
+ )
177
+
178
+ const mail = mailConfigFromInstallInput(input)
179
+ if (mail.transport === 'log') return
180
+
181
+ await saveSettings(
182
+ settings,
183
+ mail.transport === 'http'
184
+ ? {
185
+ 'mail.transport': 'http',
186
+ 'mail.from': mail.from,
187
+ 'mail.http_endpoint': mail.endpoint,
188
+ 'mail.http_token': mail.token,
189
+ }
190
+ : {
191
+ 'mail.transport': 'smtp',
192
+ 'mail.from': mail.from,
193
+ 'mail.smtp_host': mail.host,
194
+ 'mail.smtp_port': mail.port,
195
+ 'mail.smtp_security': mail.security,
196
+ 'mail.smtp_username': mail.username,
197
+ 'mail.smtp_password': mail.password,
198
+ },
199
+ SettingsSnapshot.fromOverrides(await settings.loadAll()),
200
+ )
201
+ }))
202
+ ) {
203
+ return report
204
+ }
205
+
206
+ const admin = new PostgresAdminRepository(db)
207
+ let adminUserId: number | null = null
208
+
209
+ if (
210
+ !(await step('admin', async () => {
211
+ const registered = await admin.findGroup(SEED_GROUP_KEY.registered)
212
+ const administrator = await admin.findGroup(SEED_GROUP_KEY.administrators)
213
+ if (registered === null || administrator === null) {
214
+ const missing =
215
+ registered === null ? SEED_GROUP_KEY.registered : SEED_GROUP_KEY.administrators
216
+ const present = await admin.listGroupKeys()
217
+ throw new Error(
218
+ `No usergroup has the key "${missing}". ` +
219
+ (present.length === 0
220
+ ? 'The usergroups table is empty, so the seed migration did not run.'
221
+ : `The board has: ${present.join(', ')}.`),
222
+ )
223
+ }
224
+
225
+ const stored = SettingsSnapshot.fromOverrides(await settings.loadAll())
226
+
227
+ const config: AuthConfig = {
228
+ ...DEFAULT_AUTH_POLICY,
229
+ ...resolveAuthPolicy((key) => stored.get(key as never), {
230
+ ...DEFAULT_AUTH_POLICY,
231
+ activationMethod: 'none',
232
+ }),
233
+ registrationEnabled: true,
234
+ activationMethod: 'none',
235
+ defaultMemberGroupId: registered.id,
236
+ }
237
+
238
+ const identity = new IdentityService({ store: createPostgresAccountStore(db), config })
239
+ const result = await identity.register({
240
+ username: input.username,
241
+ email: input.email,
242
+ password: input.password,
243
+ })
244
+
245
+ await admin.setPrimaryGroup(result.account.id, administrator.id)
246
+ adminUserId = result.account.id
247
+ }))
248
+ ) {
249
+ return report
250
+ }
251
+
252
+ const forums = new PostgresForumRepository(db)
253
+ if (
254
+ !(await step('forum', async () => {
255
+ const category = await forums.create({
256
+ type: 'category',
257
+ title: input.boardName,
258
+ slug: defaultForumSlug(input.boardName),
259
+ parentId: null,
260
+ })
261
+ await forums.create({
262
+ type: 'forum',
263
+ title: 'General discussion',
264
+ slug: 'general-discussion',
265
+ description: 'Anything that does not fit elsewhere.',
266
+ parentId: category.id,
267
+ })
268
+ }))
269
+ ) {
270
+ return report
271
+ }
272
+
273
+ await step('seal', async () => {
274
+ if (!(await markInstalled(db, INSTALLED_VERSION))) throw new Error(INSTALL_RACED)
275
+ })
276
+
277
+ logger().info({ adminUserId, steps: report.length }, 'board installed')
278
+ return report
279
+ }
280
+
281
+ export { INSTALL_STEPS }
@@ -0,0 +1,83 @@
1
+ import 'server-only'
2
+
3
+ import { notFound, permanentRedirect } from 'next/navigation'
4
+
5
+ import { env } from '@meith/core'
6
+ import { getDb, resolveLegacyId } from '@meith/db'
7
+ import { type LegacyTarget, legacyRedirectPath, resolveLegacyUrl } from '@meith/import'
8
+
9
+ import { getContainer } from './container'
10
+ import { getSettings } from './settings'
11
+
12
+ type Kind = 'thread' | 'forum' | 'post' | 'user'
13
+
14
+ const LEGACY_KIND: Readonly<Record<Kind, 'thread' | 'forum' | 'post' | 'user'>> = {
15
+ thread: 'thread',
16
+ forum: 'forum',
17
+ post: 'post',
18
+ user: 'user',
19
+ }
20
+
21
+ export type LegacyScript =
22
+ | 'showthread.php'
23
+ | 'forumdisplay.php'
24
+ | 'member.php'
25
+ | 'viewtopic.php'
26
+ | 'viewforum.php'
27
+ | 'memberlist.php'
28
+
29
+ function searchStringOf(params: Record<string, string | string[] | undefined>): string {
30
+ const search = new URLSearchParams()
31
+
32
+ for (const [key, value] of Object.entries(params)) {
33
+ if (value === undefined) continue
34
+ if (Array.isArray(value)) for (const item of value) search.append(key, item)
35
+ else search.append(key, value)
36
+ }
37
+
38
+ const rendered = search.toString()
39
+ return rendered === '' ? '' : `?${rendered}`
40
+ }
41
+
42
+ export async function legacyDestination(
43
+ pathname: string,
44
+ searchParams: Record<string, string | string[] | undefined>,
45
+ ): Promise<string | null> {
46
+ if (env.DATA_SOURCE !== 'postgres') return null
47
+
48
+ const settings = await getSettings()
49
+ if (settings.get('board.legacy_redirects') !== true) return null
50
+
51
+ const target = resolveLegacyUrl(pathname, searchStringOf(searchParams))
52
+ if (target === null) return null
53
+
54
+ if (target.kind === 'home') return '/'
55
+
56
+ const newId = await resolveLegacyId(getDb(), LEGACY_KIND[target.kind], target.legacyId)
57
+ if (newId === null) return null
58
+
59
+ return legacyRedirectPath(target, newId, await slugFor(target, newId))
60
+ }
61
+
62
+ export async function serveLegacyUrl(
63
+ script: LegacyScript,
64
+ searchParams: Record<string, string | string[] | undefined>,
65
+ ): Promise<never> {
66
+ const path = await legacyDestination(`/${script}`, searchParams)
67
+ if (path === null) notFound()
68
+
69
+ permanentRedirect(path)
70
+ }
71
+
72
+ async function slugFor(target: LegacyTarget, newId: number): Promise<string | null> {
73
+ try {
74
+ const { forums } = getContainer()
75
+ if (target.kind === 'forum') {
76
+ const all = await forums.listAll()
77
+ return all.find((forum) => forum.id === newId)?.slug ?? null
78
+ }
79
+ return null
80
+ } catch {
81
+ return null
82
+ }
83
+ }
@@ -0,0 +1,43 @@
1
+ import 'server-only'
2
+
3
+ import { renderMarkdown } from '@meith/markdown'
4
+ import type { LinkModel } from '@meith/theme-kit'
5
+
6
+ import { buildLegalLinks, isPublished, type LegalPage, legalPage, TERMS_PAGE } from '../view/legal'
7
+ import { getTranslator } from './i18n'
8
+ import { getSettings } from './settings'
9
+
10
+ export interface LegalDocument {
11
+ readonly title: string
12
+ readonly href: string
13
+ readonly bodyHtml: string
14
+ }
15
+
16
+ async function bodyOf(page: LegalPage): Promise<string> {
17
+ return (await getSettings()).get(page.settingKey)
18
+ }
19
+
20
+ export async function legalDocument(slug: string): Promise<LegalDocument | null> {
21
+ const page = legalPage(slug)
22
+ if (page === null) return null
23
+
24
+ const body = await bodyOf(page)
25
+ if (!isPublished(body)) return null
26
+
27
+ return {
28
+ title: (await getTranslator()).t(page.titleKey),
29
+ href: page.href,
30
+ bodyHtml: renderMarkdown(body, { headingOffset: 0 }).html,
31
+ }
32
+ }
33
+
34
+ export async function legalFooterLinks(): Promise<readonly LinkModel[]> {
35
+ const settings = await getSettings()
36
+ return buildLegalLinks((page) => settings.get(page.settingKey), await getTranslator())
37
+ }
38
+
39
+ export async function termsAcceptance(): Promise<LinkModel | null> {
40
+ return isPublished(await bodyOf(TERMS_PAGE))
41
+ ? { label: (await getTranslator()).t(TERMS_PAGE.titleKey), href: TERMS_PAGE.href }
42
+ : null
43
+ }
@@ -0,0 +1,37 @@
1
+ export const PATH_HEADER = 'x-forum-path'
2
+
3
+ /**
4
+ * The query string that came with `PATH_HEADER`, leading `?` included, or
5
+ * absent when there was none.
6
+ *
7
+ * Separate from the path because the path has readers that must not see a
8
+ * query: a presence row records where somebody is, and `/admin/settings` is one
9
+ * place however many groups it can be filtered by. The panel rail is the reader
10
+ * that needs both — its settings sections differ only by `?group=`, so a rail
11
+ * given the path alone cannot say which one is open.
12
+ */
13
+ export const QUERY_HEADER = 'x-forum-query'
14
+
15
+ /**
16
+ * Set by the middleware when it has just minted the guest cookie, meaning the
17
+ * client has not sent one back yet.
18
+ *
19
+ * It exists because the obvious test does not work: a cookie set on a
20
+ * `NextResponse.next()` response is reflected into the request the render sees,
21
+ * so neither `cookies()` nor the raw `Cookie` header can tell "the client kept
22
+ * this" from "we made it up a millisecond ago". Without the distinction every
23
+ * request from something that discards cookies wrote a presence row, which
24
+ * measured as twelve rows from twelve crawler requests.
25
+ *
26
+ * The middleware sets it or deletes it on every request, so a client cannot
27
+ * supply its own.
28
+ */
29
+ export const FRESH_GUEST_HEADER = 'x-forum-fresh-guest'
30
+
31
+ /**
32
+ * The per-request Content-Security-Policy nonce minted by `proxy.ts`, for the
33
+ * handful of inline `<script>` tags the app emits itself. Next reads the nonce
34
+ * out of the policy header on its own; this one is for our tags. See
35
+ * `docs/operating.md`.
36
+ */
37
+ export const NONCE_HEADER = 'x-nonce'
@@ -0,0 +1,43 @@
1
+ import 'server-only'
2
+
3
+ import { cache } from 'react'
4
+
5
+ import type { Translator } from '@meith/i18n'
6
+ import { buildMailBrand, type MailBrand } from '@meith/mail'
7
+
8
+ import { boardUrl } from './board-url'
9
+ import { getSettings } from './settings'
10
+ import { getBoardThemeStyle } from './theme-runtime'
11
+
12
+ export const mailBrand = cache(async (): Promise<MailBrand> => {
13
+ const [settings, origin, theme] = await Promise.all([
14
+ getSettings(),
15
+ boardUrl(),
16
+ getBoardThemeStyle(),
17
+ ])
18
+
19
+ const boardName = settings.get('board.name')
20
+ const alt = settings.get('board.logo_alt').trim()
21
+
22
+ return buildMailBrand({
23
+ boardName,
24
+ fromName: settings.get('mail.from_name'),
25
+ boardUrl: origin,
26
+ tokens: theme.defaultTokens,
27
+ logo: {
28
+ lightKey: settings.get('board.logo_light'),
29
+ darkKey: settings.get('board.logo_dark'),
30
+ alt: alt === '' ? boardName : alt,
31
+ },
32
+ })
33
+ })
34
+
35
+ export async function brandedFor(
36
+ t: Translator,
37
+ fallbackKey: string,
38
+ ): Promise<{ readonly brand: MailBrand; readonly boardName: string }> {
39
+ const brand = await mailBrand()
40
+ const boardName = brand.boardName === '' ? t.t(fallbackKey) : brand.boardName
41
+
42
+ return { brand: { ...brand, boardName }, boardName }
43
+ }
@@ -0,0 +1,41 @@
1
+ import 'server-only'
2
+
3
+ import { env } from '@meith/core'
4
+ import { currentMailConfig } from '@meith/drivers'
5
+ import { canSendMail, describeMailConfig, type MailConfig, type MailSource } from '@meith/settings'
6
+
7
+ import { boardAuthConfig } from './auth-config'
8
+
9
+ export interface MailReadiness {
10
+ readonly config: MailConfig
11
+ readonly source: MailSource
12
+ readonly summary: string
13
+ readonly sends: boolean
14
+ readonly activationMethod: 'none' | 'email' | 'admin' | 'both'
15
+ readonly unactivatable: boolean
16
+ }
17
+
18
+ export async function assessMailReadiness(): Promise<MailReadiness> {
19
+ const { activationMethod } = await boardAuthConfig()
20
+
21
+ let config: MailConfig
22
+ try {
23
+ config = await currentMailConfig()
24
+ } catch {
25
+ config = { transport: 'log' }
26
+ }
27
+
28
+ const sends = canSendMail(config)
29
+
30
+ return {
31
+ config,
32
+ source: env.MAIL_DRIVER === 'log' ? 'board' : 'environment',
33
+ summary: describeMailConfig(config),
34
+ sends,
35
+ activationMethod,
36
+ unactivatable:
37
+ !sends &&
38
+ env.NODE_ENV !== 'development' &&
39
+ (activationMethod === 'email' || activationMethod === 'both'),
40
+ }
41
+ }
@@ -0,0 +1,44 @@
1
+ import 'server-only'
2
+
3
+ import type { OutgoingMail } from '@meith/core'
4
+ import { currentRequestId } from '@meith/core/logger'
5
+ import { drivers } from '@meith/drivers'
6
+
7
+ import { emitEvent, filterView } from './plugin-view'
8
+
9
+ /**
10
+ * Every message the board sends from a request, past the plugins first. A
11
+ * plugin may rewrite the recipient, the subject or either body, or return null
12
+ * to drop the message — which is silent, because the member asked the board to
13
+ * do something and the board's answer is the page, not the mail.
14
+ */
15
+ export async function sendBoardMail(template: string, mail: OutgoingMail): Promise<boolean> {
16
+ const proposed = await filterView(
17
+ 'mail.send.before',
18
+ {
19
+ to: mail.to,
20
+ subject: mail.subject,
21
+ textBody: mail.text,
22
+ htmlBody: mail.html ?? null,
23
+ },
24
+ { template },
25
+ )
26
+
27
+ if (proposed === null) return false
28
+
29
+ await drivers().mail.send({
30
+ ...mail,
31
+ to: proposed.to,
32
+ subject: proposed.subject,
33
+ text: proposed.textBody,
34
+ ...(proposed.htmlBody === null ? {} : { html: proposed.htmlBody }),
35
+ })
36
+
37
+ await emitEvent(
38
+ 'mail.sent',
39
+ { to: proposed.to, template },
40
+ { requestId: currentRequestId() ?? null },
41
+ )
42
+
43
+ return true
44
+ }
@@ -0,0 +1,67 @@
1
+ 'use server'
2
+
3
+ import { isAppError, logger } from '@meith/core'
4
+ import { currentMailConfig } from '@meith/drivers'
5
+ import { canSendMail, describeMailConfig } from '@meith/settings'
6
+
7
+ import { recordAdminAction, requireAdmin } from './admin'
8
+ import type { FormState } from './auth-form-state'
9
+ import { getContainer } from './container'
10
+ import { getTranslator, tr } from './i18n'
11
+ import { sendTestMail } from './mail-test'
12
+ import { getSettings } from './settings'
13
+
14
+ export async function sendTestMailAction(
15
+ _previous: FormState,
16
+ _form: FormData,
17
+ ): Promise<FormState> {
18
+ try {
19
+ const admin = await requireAdmin()
20
+ const t = await getTranslator()
21
+
22
+ const config = await currentMailConfig()
23
+ if (!canSendMail(config)) {
24
+ return {
25
+ error:
26
+ (await tr('notice.app.board-working-mail-configuration-there')) +
27
+ t.t('mailTest.currentConfiguration', { configuration: describeMailConfig(config) }),
28
+ }
29
+ }
30
+
31
+ const account = await getContainer().accountStore.accounts.findById(admin.userId)
32
+ if (account === null || account.email === '') {
33
+ return { error: await tr('notice.app.account-e-mail-address-send-test') }
34
+ }
35
+
36
+ const settings = await getSettings()
37
+ const result = await sendTestMail({
38
+ config,
39
+ to: account.email,
40
+ boardName: settings.get('board.name'),
41
+ t,
42
+ fromName: settings.get('mail.from_name'),
43
+ })
44
+
45
+ await recordAdminAction({
46
+ action: 'mail.tested',
47
+ detail: { ok: result.ok },
48
+ })
49
+
50
+ if (!result.ok) {
51
+ return {
52
+ error: t.t('mailTest.action.failed', {
53
+ error: result.error ?? '',
54
+ configuration: describeMailConfig(config),
55
+ }),
56
+ }
57
+ }
58
+
59
+ return {
60
+ notice: t.t('mailTest.action.sent', { email: account.email }),
61
+ }
62
+ } catch (error) {
63
+ if (isAppError(error)) return { error: error.message }
64
+ logger({ module: 'mail-test' }).error({ err: String(error) }, 'test send failed')
65
+ return { error: await tr('notice.app.test-could-run') }
66
+ }
67
+ }
@@ -0,0 +1,56 @@
1
+ import 'server-only'
2
+
3
+ import { isAppError, logger } from '@meith/core'
4
+ import { createMailDriver } from '@meith/drivers'
5
+ import type { Translator } from '@meith/i18n'
6
+ import { describeMailConfig, type MailConfig } from '@meith/settings'
7
+
8
+ export interface MailTestResult {
9
+ readonly ok: boolean
10
+ readonly error?: string
11
+ }
12
+
13
+ export async function sendTestMail(input: {
14
+ readonly config: MailConfig
15
+ readonly to: string
16
+ readonly boardName: string
17
+ readonly t: Translator
18
+ readonly fromName?: string
19
+ }): Promise<MailTestResult> {
20
+ const board =
21
+ input.boardName.trim() === '' ? input.t.t('mailTest.boardFallback') : input.boardName.trim()
22
+
23
+ try {
24
+ const driver = createMailDriver(input.config)
25
+
26
+ await driver.send({
27
+ to: input.to,
28
+ subject: `[${board}] ${input.t.t('mailTest.subject')}`,
29
+ text: [
30
+ input.t.t('mailTest.intro', { board }),
31
+ '',
32
+ input.t.t('mailTest.body'),
33
+ '',
34
+ input.t.t('mailTest.sentThrough', { configuration: describeMailConfig(input.config) }),
35
+ ].join('\n'),
36
+ ...(input.fromName === undefined || input.fromName.trim() === ''
37
+ ? {}
38
+ : { fromName: input.fromName }),
39
+ })
40
+
41
+ return { ok: true }
42
+ } catch (error) {
43
+ const message = isAppError(error)
44
+ ? error.message
45
+ : error instanceof Error
46
+ ? error.message
47
+ : String(error)
48
+
49
+ logger({ module: 'mail-test' }).warn(
50
+ { err: message, config: describeMailConfig(input.config) },
51
+ 'test message could not be sent',
52
+ )
53
+
54
+ return { ok: false, error: message.slice(0, 400) }
55
+ }
56
+ }