@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,31 @@
1
+ import 'server-only'
2
+
3
+ import type { MarkdownPipeline } from '@meith/markdown'
4
+
5
+ import { resolveInlineAttachments } from './attachment-embed'
6
+ import { highlightCodeBlocks } from './code-highlighting'
7
+ import { unfurlEmbeds } from './embeds'
8
+ import { filterView } from './plugin-view'
9
+
10
+ export const boardRendering: MarkdownPipeline = {
11
+ text: (text, context) =>
12
+ filterView('markdown.parse.text', text, { ...context.viewer, source: context.source }),
13
+
14
+ html: async (html, context) => {
15
+ const withAttachments = await resolveInlineAttachments(html, {
16
+ viewerUserId: context.viewer.userId,
17
+ postId: context.postId ?? null,
18
+ })
19
+ const withEmbeds = await unfurlEmbeds(await highlightCodeBlocks(withAttachments))
20
+ return filterView('markdown.render.html', withEmbeds, {
21
+ ...context.viewer,
22
+ source: context.source,
23
+ })
24
+ },
25
+
26
+ vocabulary: async (source) => ({
27
+ revision: source.revision,
28
+ directives: await filterView('markdown.directives', source.directives, {}),
29
+ smilies: await filterView('smilies.list', source.smilies, {}),
30
+ }),
31
+ }
@@ -0,0 +1,47 @@
1
+ 'use server'
2
+
3
+ import { revalidatePath } from 'next/cache'
4
+
5
+ import { isAppError, logger } from '@meith/core'
6
+
7
+ import { recordAdminAction, requireAdmin } from './admin'
8
+ import type { FormState } from './auth-form-state'
9
+ import { tr } from './i18n'
10
+ import { refreshMarketplaceNow } from './marketplace-admin'
11
+
12
+ /**
13
+ * The Browse tab's "Refresh" button. Runs the exact same fetch, validate,
14
+ * cache, notify pass as the daily task (`refreshMarketplaceNow`, shared with
15
+ * `packages/runtime`'s task worker) — this is the "on demand" half of that
16
+ * one behaviour, not a second implementation of it.
17
+ */
18
+ export async function refreshMarketplaceAction(
19
+ _prev: FormState,
20
+ _form: FormData,
21
+ ): Promise<FormState> {
22
+ try {
23
+ await requireAdmin()
24
+
25
+ const result = await refreshMarketplaceNow()
26
+
27
+ revalidatePath('/admin/plugins/browse')
28
+ revalidatePath('/admin/themes/browse')
29
+
30
+ await recordAdminAction({
31
+ action: 'marketplace.refresh',
32
+ detail: { ok: result.ok, listingCount: result.listingCount },
33
+ })
34
+
35
+ if (result.ok) return { notice: 'refreshed' }
36
+ return {
37
+ error: result.errorMessage ?? (await tr('notice.app.something-went-wrong-please-try')),
38
+ }
39
+ } catch (err) {
40
+ if (isAppError(err)) return { error: err.message }
41
+ logger({ module: 'marketplace-admin' }).error(
42
+ { err },
43
+ 'failed to refresh the marketplace catalog',
44
+ )
45
+ return { error: await tr('notice.app.something-went-wrong-please-try') }
46
+ }
47
+ }
@@ -0,0 +1,260 @@
1
+ import 'server-only'
2
+
3
+ import { env, logger, readPluginEnv } from '@meith/core'
4
+ import { getDb, PostgresMarketplaceCacheRepository } from '@meith/db'
5
+ import {
6
+ type CachedMarketplace,
7
+ computeListingStatus,
8
+ EMPTY_CACHE,
9
+ type InstalledEntry,
10
+ type ListingKind,
11
+ type ListingStatus,
12
+ type MarketplaceListing,
13
+ MEITH_VERSION,
14
+ PLUGIN_API_MAJOR,
15
+ refreshCatalog,
16
+ THEME_API_MAJOR,
17
+ } from '@meith/marketplace'
18
+
19
+ import { notificationService } from './notifications'
20
+ import { configuredPlugins } from './plugin-host'
21
+ import { getSettings, getSettingsUncached } from './settings'
22
+ import { themeListing } from './theme-admin'
23
+
24
+ const BUILD_INFO = {
25
+ meithVersion: MEITH_VERSION,
26
+ pluginApiMajor: PLUGIN_API_MAJOR,
27
+ themeApiMajor: THEME_API_MAJOR,
28
+ }
29
+
30
+ /**
31
+ * Whether this process is the stock image rather than a graduated custom
32
+ * board — both run identical @meith/web code, so an env var set only by
33
+ * docker/Dockerfile is the only signal available: `BOARD_PLUGINS_MANIFEST`,
34
+ * baked in for `community board:eject` (see apps/cli/src/board-eject.ts). A
35
+ * graduated board's own scaffolded Dockerfile never sets it. Read fresh
36
+ * rather than cached at module load, so it is not fixed by whichever test
37
+ * happens to import this module first.
38
+ */
39
+ function onStockImage(): boolean {
40
+ return readPluginEnv('BOARD_PLUGINS_MANIFEST') !== undefined
41
+ }
42
+
43
+ /** Caps against untrusted feed content — a self-hosted mirror can serve anything. */
44
+ const MAX_NAME_LENGTH = 120
45
+ const MAX_DESCRIPTION_LENGTH = 500
46
+ const MAX_LICENCE_LENGTH = 100
47
+ const MAX_REASON_LENGTH = 300
48
+
49
+ function truncate(value: string, max: number): string {
50
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value
51
+ }
52
+
53
+ /** https only, and a real URL — never rendered as a link otherwise. */
54
+ function safeHttpsUrl(value: string): string | null {
55
+ try {
56
+ const url = new URL(value)
57
+ return url.protocol === 'https:' ? url.toString() : null
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ export function marketplaceCacheRepository(): PostgresMarketplaceCacheRepository | null {
64
+ return env.DATA_SOURCE === 'postgres' ? new PostgresMarketplaceCacheRepository(getDb()) : null
65
+ }
66
+
67
+ async function readCache(): Promise<CachedMarketplace> {
68
+ const repository = marketplaceCacheRepository()
69
+ if (repository === null) return EMPTY_CACHE
70
+
71
+ try {
72
+ return await repository.read()
73
+ } catch (error) {
74
+ logger().warn({ err: String(error) }, 'could not read the cached marketplace feed')
75
+ return EMPTY_CACHE
76
+ }
77
+ }
78
+
79
+ export interface MarketplaceListingRow {
80
+ readonly key: string
81
+ readonly kind: ListingKind
82
+ readonly name: string
83
+ readonly description: string
84
+ readonly version: string
85
+ readonly package: string
86
+ readonly licence: string
87
+ readonly repositoryUrl: string | null
88
+ /** Same-origin URLs — the browser never fetches the feed's own host directly. */
89
+ readonly screenshotHrefs: readonly string[]
90
+ readonly status: ListingStatus
91
+ readonly incompatibleReason: string | null
92
+ readonly installedVersion: string | null
93
+ /** Set only for 'not-installed': the exact steps this board would need. Never an affordance that acts. */
94
+ readonly installSteps: readonly string[] | null
95
+ /**
96
+ * True only for 'not-installed' on the stock image, where installSteps'
97
+ * own `community plugin:add`/`community.config.ts` line cannot actually
98
+ * run — the image is fixed at build time. Signposts
99
+ * docs/marketplace.md's "Moving to a custom board" walkthrough; never a
100
+ * claim this board can graduate itself.
101
+ */
102
+ readonly onStockImage: boolean
103
+ }
104
+
105
+ export interface MarketplaceCatalogView {
106
+ readonly listings: readonly MarketplaceListingRow[]
107
+ readonly fetchedAt: Date | null
108
+ /** True once a fetch has ever succeeded — an empty `listings` before that is "not fetched yet", not "empty catalog". */
109
+ readonly hasEverFetched: boolean
110
+ readonly unreachable: boolean
111
+ readonly errorMessage: string | null
112
+ }
113
+
114
+ function installSteps(kind: ListingKind, packageName: string): readonly string[] {
115
+ return kind === 'plugin'
116
+ ? [
117
+ `pnpm add ${packageName} --filter @meith/web`,
118
+ `community plugin:add ${packageName}`,
119
+ 'Rebuild and redeploy for it to take effect.',
120
+ ]
121
+ : [
122
+ `pnpm add ${packageName} --filter @meith/web`,
123
+ 'Register it in community.config.ts (and set it as defaultTheme if it should be the board default).',
124
+ 'Rebuild and redeploy for it to take effect.',
125
+ ]
126
+ }
127
+
128
+ async function pluginInstalledEntries(): Promise<ReadonlyMap<string, InstalledEntry>> {
129
+ const map = new Map<string, InstalledEntry>()
130
+ for (const plugin of configuredPlugins()) {
131
+ if (!plugin.hasDefinition || plugin.version === null) continue
132
+ map.set(plugin.key, { enabled: plugin.enabled, version: plugin.version })
133
+ }
134
+ return map
135
+ }
136
+
137
+ async function themeInstalledEntries(): Promise<ReadonlyMap<string, InstalledEntry>> {
138
+ const map = new Map<string, InstalledEntry>()
139
+ for (const theme of await themeListing()) {
140
+ map.set(theme.key, { enabled: theme.enabled, version: null })
141
+ }
142
+ return map
143
+ }
144
+
145
+ function screenshotHref(listingKey: string, index: number): string {
146
+ return `/admin/api/marketplace/screenshot?key=${encodeURIComponent(listingKey)}&index=${index}`
147
+ }
148
+
149
+ function toRow(
150
+ listing: MarketplaceListing,
151
+ installed: InstalledEntry | null,
152
+ ): MarketplaceListingRow {
153
+ const { status, incompatibleReason } = computeListingStatus({ ...listing, installed }, BUILD_INFO)
154
+
155
+ return {
156
+ key: listing.key,
157
+ kind: listing.kind,
158
+ name: truncate(listing.name, MAX_NAME_LENGTH),
159
+ description: truncate(listing.description, MAX_DESCRIPTION_LENGTH),
160
+ version: listing.version,
161
+ package: listing.package,
162
+ licence: truncate(listing.licence, MAX_LICENCE_LENGTH),
163
+ repositoryUrl: safeHttpsUrl(listing.repository),
164
+ screenshotHrefs: listing.screenshots.map((_, index) => screenshotHref(listing.key, index)),
165
+ status,
166
+ incompatibleReason:
167
+ incompatibleReason === null ? null : truncate(incompatibleReason, MAX_REASON_LENGTH),
168
+ installedVersion: installed?.version ?? null,
169
+ installSteps: status === 'not-installed' ? installSteps(listing.kind, listing.package) : null,
170
+ onStockImage: status === 'not-installed' && onStockImage(),
171
+ }
172
+ }
173
+
174
+ /**
175
+ * The Browse tab's whole read model for one kind — reads the cached feed
176
+ * only (never the network) and computes status against what this build
177
+ * actually contains. Renders honestly when the cache is empty: `listings`
178
+ * is `[]` either way, and `hasEverFetched`/`unreachable` say why.
179
+ */
180
+ export async function marketplaceCatalog(kind: ListingKind): Promise<MarketplaceCatalogView> {
181
+ const cache = await readCache()
182
+ const installed =
183
+ kind === 'plugin' ? await pluginInstalledEntries() : await themeInstalledEntries()
184
+
185
+ const listings = (cache.feed?.listings ?? [])
186
+ .filter((listing) => listing.kind === kind)
187
+ .map((listing) => toRow(listing, installed.get(listing.key) ?? null))
188
+
189
+ return {
190
+ listings,
191
+ fetchedAt: cache.fetchedAt,
192
+ hasEverFetched: cache.fetchedAt !== null,
193
+ unreachable: cache.error !== null,
194
+ errorMessage: cache.error,
195
+ }
196
+ }
197
+
198
+ /** One listing's screenshot, resolved against the feed's own host — never a client-supplied URL. */
199
+ export async function marketplaceScreenshotUrl(key: string, index: number): Promise<string | null> {
200
+ const cache = await readCache()
201
+ if (cache.feed === null || cache.sourceUrl === null) return null
202
+
203
+ const listing = cache.feed.listings.find((entry) => entry.key === key)
204
+ const path = listing?.screenshots[index]
205
+ if (path === undefined) return null
206
+
207
+ try {
208
+ return new URL(path, cache.sourceUrl).toString()
209
+ } catch {
210
+ return null
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Runs the same fetch, validate, cache, notify pass the daily task does — the
216
+ * admin panel's "Refresh" button and the task call the exact same function,
217
+ * per docs/marketplace.md.
218
+ */
219
+ export async function refreshMarketplaceNow(): Promise<{
220
+ readonly ok: boolean
221
+ readonly listingCount: number
222
+ readonly errorMessage: string | null
223
+ }> {
224
+ const repository = marketplaceCacheRepository()
225
+ if (repository === null) {
226
+ return { ok: false, listingCount: 0, errorMessage: 'This board is running in fixture mode.' }
227
+ }
228
+
229
+ const url = (await getSettingsUncached()).get('marketplace.feed_url')
230
+ const pluginEntries = await pluginInstalledEntries()
231
+ const notifications = notificationService()
232
+
233
+ const result = await refreshCatalog({
234
+ url,
235
+ repository,
236
+ build: BUILD_INFO,
237
+ resolveInstalled: (listing) =>
238
+ listing.kind === 'plugin' ? (pluginEntries.get(listing.key) ?? null) : null,
239
+ notifyUpdate: async (listing) => {
240
+ if (notifications === null) return
241
+ await notifications.raiseForAdministrators({
242
+ kind: 'marketplace.update_available',
243
+ data: {
244
+ key: listing.key,
245
+ name: listing.name,
246
+ package: listing.package,
247
+ version: listing.version,
248
+ },
249
+ href: '/admin/plugins/browse',
250
+ dedupeKey: `marketplace.update_available:${listing.key}:${listing.version}`,
251
+ })
252
+ },
253
+ })
254
+
255
+ return { ok: result.ok, listingCount: result.listingCount, errorMessage: result.error }
256
+ }
257
+
258
+ export async function currentFeedUrl(): Promise<string> {
259
+ return (await getSettings()).get('marketplace.feed_url')
260
+ }
@@ -0,0 +1,21 @@
1
+ 'use server'
2
+
3
+ import type { MemberSuggestion } from '@meith/accounts'
4
+
5
+ import { getContainer } from './container'
6
+ import { getActor } from './context'
7
+ import { searchMentionCandidates } from './mention-search'
8
+ import { relationService } from './relations'
9
+
10
+ export async function searchMentionCandidatesAction(
11
+ query: string,
12
+ ): Promise<readonly MemberSuggestion[]> {
13
+ const actor = await getActor()
14
+ const { authorizer, memberProfiles } = getContainer()
15
+
16
+ return searchMentionCandidates(actor, typeof query === 'string' ? query : '', {
17
+ authorizer,
18
+ memberProfiles,
19
+ relations: relationService(),
20
+ })
21
+ }
@@ -0,0 +1,35 @@
1
+ import type { MemberProfileRepository, MemberSuggestion } from '@meith/accounts'
2
+ import type { Actor, Authorizer } from '@meith/authorization'
3
+ import type { RelationService } from '@meith/relations'
4
+
5
+ const MIN_QUERY_LENGTH = 1
6
+ const MAX_QUERY_LENGTH = 32
7
+ const SUGGESTION_LIMIT = 8
8
+
9
+ export interface MentionSearchDeps {
10
+ readonly authorizer: Authorizer
11
+ readonly memberProfiles: MemberProfileRepository
12
+ readonly relations: RelationService | null
13
+ }
14
+
15
+ export async function searchMentionCandidates(
16
+ actor: Actor,
17
+ rawQuery: string,
18
+ deps: MentionSearchDeps,
19
+ ): Promise<readonly MemberSuggestion[]> {
20
+ const query = rawQuery.trim()
21
+ if (query.length < MIN_QUERY_LENGTH || query.length > MAX_QUERY_LENGTH) return []
22
+ if (!deps.authorizer.can(actor, 'profile.view')) return []
23
+
24
+ const [matches, ignored] = await Promise.all([
25
+ deps.memberProfiles.searchByUsernamePrefix(query, SUGGESTION_LIMIT * 2),
26
+ actor.userId === null || deps.relations === null
27
+ ? Promise.resolve<readonly number[]>([])
28
+ : deps.relations.ignoredIds(actor.userId),
29
+ ])
30
+
31
+ if (ignored.length === 0) return matches.slice(0, SUGGESTION_LIMIT)
32
+
33
+ const ignoredIds = new Set(ignored)
34
+ return matches.filter((candidate) => !ignoredIds.has(candidate.id)).slice(0, SUGGESTION_LIMIT)
35
+ }
@@ -0,0 +1,110 @@
1
+ 'use server'
2
+
3
+ import { redirect } from 'next/navigation'
4
+
5
+ import { ValidationError } from '@meith/core'
6
+ import { msg } from '@meith/i18n'
7
+ import { parseFolder } from '@meith/messages'
8
+
9
+ import { folderHref } from '../view/messages'
10
+ import { dailyLimitMessage, limitMessage, spendDailyLimit, spendLimit } from './antispam'
11
+ import type { FormState } from './auth-form-state'
12
+ import { getActor } from './context'
13
+ import { formStateReporter } from './form-state-reporter'
14
+ import { text } from './form-values'
15
+ import { requireMessaging } from './messages'
16
+
17
+ const toFormState = formStateReporter('message-actions', 'unexpected error in a message action')
18
+
19
+ export async function sendMessageAction(_prev: FormState, form: FormData): Promise<FormState> {
20
+ const values = {
21
+ to: text(form, 'to'),
22
+ bcc: text(form, 'bcc'),
23
+ subject: text(form, 'subject'),
24
+ message: text(form, 'message'),
25
+ }
26
+
27
+ try {
28
+ const actor = await getActor()
29
+ const { service, userId, username } = await requireMessaging(actor)
30
+
31
+ const limited = await spendLimit({ scope: 'message', actor })
32
+ if (limited !== null && !limited.allowed) {
33
+ return { error: limitMessage(limited), values }
34
+ }
35
+
36
+ const daily = await spendDailyLimit({ scope: 'message_day', actor })
37
+ if (daily !== null && !daily.allowed) {
38
+ return { error: dailyLimitMessage('message_day', daily), values }
39
+ }
40
+
41
+ const replyTo = Number(text(form, 'replyTo'))
42
+ await service.send({
43
+ authorUserId: userId,
44
+ authorUsername: username,
45
+ to: values.to,
46
+ bcc: values.bcc,
47
+ subject: values.subject,
48
+ message: values.message,
49
+ receiptRequested: form.get('receipt') === 'on',
50
+ replyToId: Number.isInteger(replyTo) && replyTo > 0 ? replyTo : null,
51
+ })
52
+ } catch (err) {
53
+ return toFormState(err, values)
54
+ }
55
+
56
+ redirect('/messages?folder=sent&sent=1')
57
+ }
58
+
59
+ export async function messageBulkAction(_prev: FormState, form: FormData): Promise<FormState> {
60
+ const folder = parseFolder(text(form, 'folder')) ?? 'inbox'
61
+ const command = text(form, 'command')
62
+
63
+ let query: string
64
+ try {
65
+ const { service, userId } = await requireMessaging(await getActor())
66
+
67
+ if (command === 'empty') {
68
+ query = `emptied=${await service.emptyTrash(userId)}`
69
+ } else {
70
+ const copyIds = selectedIds(form)
71
+ if (copyIds.length === 0)
72
+ throw new ValidationError(msg('error.app.select-at-least-one-message'))
73
+
74
+ switch (command) {
75
+ case 'read':
76
+ query = `marked=${await service.markRead(userId, copyIds)}`
77
+ break
78
+ case 'unread':
79
+ query = `marked=${await service.markUnread(userId, copyIds)}`
80
+ break
81
+ case 'trash':
82
+ query = `moved=${await service.move(userId, copyIds, 'trash')}`
83
+ break
84
+ case 'restore':
85
+ query = `moved=${await service.move(userId, copyIds, 'inbox')}`
86
+ break
87
+ case 'delete':
88
+ query = `deleted=${await service.remove(userId, copyIds)}`
89
+ break
90
+ default:
91
+ throw new ValidationError(msg('error.app.something-message'))
92
+ }
93
+ }
94
+ } catch (err) {
95
+ return toFormState(err)
96
+ }
97
+
98
+ const base = folderHref(folder)
99
+ redirect(`${base}${base.includes('?') ? '&' : '?'}${query}`)
100
+ }
101
+
102
+ function selectedIds(form: FormData): readonly number[] {
103
+ const ids: number[] = []
104
+ for (const value of form.getAll('copyId')) {
105
+ if (typeof value !== 'string') continue
106
+ const id = Number(value)
107
+ if (Number.isInteger(id) && id > 0) ids.push(id)
108
+ }
109
+ return ids
110
+ }
@@ -0,0 +1,141 @@
1
+ import 'server-only'
2
+
3
+ import { cache } from 'react'
4
+
5
+ import type { Actor } from '@meith/authorization'
6
+ import { ForbiddenError } from '@meith/core'
7
+ import { currentRequestId } from '@meith/core/logger'
8
+ import { msg } from '@meith/i18n'
9
+ import { EMPTY_VOCABULARY } from '@meith/markdown'
10
+ import {
11
+ type MessageAudit,
12
+ type MessageNotifierPort,
13
+ type MessagePolicy,
14
+ MessageService,
15
+ } from '@meith/messages'
16
+
17
+ import { getContainer } from './container'
18
+ import { activeVocabulary } from './content-admin'
19
+ import { boardRendering } from './markdown-pipeline'
20
+ import { notificationService } from './notifications'
21
+ import { emitEvent, filterView } from './plugin-view'
22
+ import { relationService } from './relations'
23
+
24
+ const PLUGIN_AUDIT: MessageAudit = {
25
+ before: (input) => filterView('pm.send.before', input, { requestId: currentRequestId() ?? null }),
26
+
27
+ sent: (input) => emitEvent('pm.sent', input, { requestId: currentRequestId() ?? null }),
28
+ }
29
+
30
+ export function messageService(): MessageService | null {
31
+ const { messages } = getContainer()
32
+ if (messages === null) return null
33
+
34
+ return new MessageService({
35
+ messages,
36
+ policy: messagePolicy(),
37
+ notifier: messageNotifier(),
38
+ vocabulary: async () => (await activeVocabulary()) ?? EMPTY_VOCABULARY,
39
+ rendering: boardRendering,
40
+ audit: PLUGIN_AUDIT,
41
+ })
42
+ }
43
+
44
+ export interface Messaging {
45
+ readonly service: MessageService
46
+ readonly userId: number
47
+ readonly username: string
48
+ }
49
+
50
+ export async function requireMessaging(actor: Actor): Promise<Messaging> {
51
+ const { authorizer, accountStore } = getContainer()
52
+
53
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
54
+ if (!authorizer.can(actor, 'pm.use')) {
55
+ throw new ForbiddenError(msg('error.app.use-private-messages'))
56
+ }
57
+
58
+ const service = messageService()
59
+ if (service === null) {
60
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-21'))
61
+ }
62
+
63
+ const account = await accountStore.accounts.findById(actor.userId)
64
+ if (account === null) throw new ForbiddenError(msg('error.app.must-logged'))
65
+
66
+ return { service, userId: actor.userId, username: account.username }
67
+ }
68
+
69
+ export function messagePolicy(): MessagePolicy {
70
+ const { accountStore, actorSource, authorizer } = getContainer()
71
+
72
+ return {
73
+ async lookup(username) {
74
+ const { foldIdentifier } = await import('@meith/accounts')
75
+ const account = await accountStore.accounts.findByUsernameLower(foldIdentifier(username))
76
+ if (account === null) return null
77
+ return { id: account.id, username: account.username }
78
+ },
79
+
80
+ async limitsFor(userId) {
81
+ const actor = await actorSource.buildForUser(userId)
82
+ if (actor === null) return { quota: 0, canReceive: false }
83
+
84
+ return {
85
+ canReceive: authorizer.can(actor, 'pm.use'),
86
+ quota: authorizer.globalLimit(actor, 'privateMessageQuota'),
87
+ }
88
+ },
89
+
90
+ async blocks(ownerUserId, senderUserId) {
91
+ const service = relationService()
92
+ if (service === null) return false
93
+
94
+ try {
95
+ return await service.ignores(ownerUserId, senderUserId)
96
+ } catch {
97
+ return true
98
+ }
99
+ },
100
+ }
101
+ }
102
+
103
+ export function messageNotifier(): MessageNotifierPort | null {
104
+ const service = notificationService()
105
+ if (service === null) return null
106
+
107
+ return {
108
+ async messageReceived(input) {
109
+ await service.raise({
110
+ userId: input.userId,
111
+ kind: 'pm.received',
112
+ data: { fromUsername: input.fromUsername, subject: input.subject },
113
+ href: `/messages/${input.messageId}`,
114
+ })
115
+ },
116
+
117
+ async receiptRead(input) {
118
+ await service.raise({
119
+ userId: input.userId,
120
+ kind: 'pm.receipt',
121
+ data: { byUsername: input.byUsername, subject: input.subject },
122
+ dedupeKey: `pm.receipt:${input.messageId}`,
123
+ href: `/messages/${input.messageId}`,
124
+ })
125
+ },
126
+ }
127
+ }
128
+
129
+ export const unreadMessageCount = cache(async function unreadMessageCount(
130
+ userId: number | null,
131
+ ): Promise<number> {
132
+ if (userId === null) return 0
133
+ const service = messageService()
134
+ if (service === null) return 0
135
+
136
+ try {
137
+ return (await service.counts(userId)).unread
138
+ } catch {
139
+ return 0
140
+ }
141
+ })