@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,192 @@
1
+ import 'server-only'
2
+
3
+ import forumConfig from '@board/config'
4
+ import type { ReactNode } from 'react'
5
+
6
+ import { env, logger, readPluginEnv } from '@meith/core'
7
+ import { getDb, pluginData, pluginGrants, pluginUsers } from '@meith/db'
8
+ import {
9
+ type PluginData,
10
+ type PluginDefinition,
11
+ type PluginGrants,
12
+ type PluginNotify,
13
+ type PluginRuntimeContext,
14
+ type PluginUsers,
15
+ pluginNotify,
16
+ resolvePluginSettings,
17
+ unavailablePluginData,
18
+ unavailablePluginGrants,
19
+ unavailablePluginNotify,
20
+ unavailablePluginUsers,
21
+ } from '@meith/plugin-kit'
22
+
23
+ import { getTranslator } from './i18n'
24
+ import { notificationService } from './notifications'
25
+ import { getSettingOverrides } from './settings'
26
+
27
+ const REQUEST_STATEMENT_TIMEOUT_MS = 3_000
28
+
29
+ export function grantsFor(pluginKey: string): PluginGrants {
30
+ return env.DATA_SOURCE === 'postgres'
31
+ ? pluginGrants(getDb(), pluginKey)
32
+ : unavailablePluginGrants('this board is running on in-memory sample data')
33
+ }
34
+
35
+ export function dataFor(pluginKey: string): PluginData {
36
+ return env.DATA_SOURCE === 'postgres'
37
+ ? pluginData(getDb(), pluginKey, { statementTimeoutMs: REQUEST_STATEMENT_TIMEOUT_MS })
38
+ : unavailablePluginData('this board is running on in-memory sample data')
39
+ }
40
+
41
+ export function usersFor(): PluginUsers {
42
+ return env.DATA_SOURCE === 'postgres'
43
+ ? pluginUsers(getDb())
44
+ : unavailablePluginUsers('this board is running on in-memory sample data')
45
+ }
46
+
47
+ export function notifyFor(pluginKey: string): PluginNotify {
48
+ const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
49
+ const definition = entry?.plugin as PluginDefinition | undefined
50
+ const service = notificationService()
51
+
52
+ if (definition === undefined || service === null) {
53
+ return unavailablePluginNotify('this board is running on in-memory sample data')
54
+ }
55
+ return pluginNotify(pluginKey, definition.notifications ?? [], service)
56
+ }
57
+
58
+ export function runtimeContextFor(
59
+ pluginKey: string,
60
+ definition: PluginDefinition,
61
+ overrides: ReadonlyMap<string, string>,
62
+ log: ReturnType<typeof logger>,
63
+ ): PluginRuntimeContext {
64
+ return {
65
+ settings: resolvePluginSettings(definition, overrides, readPluginEnv),
66
+ logger: {
67
+ info: (message, detail) => log.info(detail ?? {}, message),
68
+ warn: (message, detail) => log.warn(detail ?? {}, message),
69
+ error: (message, detail) => log.error(detail ?? {}, message),
70
+ },
71
+ grants: grantsFor(pluginKey),
72
+ data: dataFor(pluginKey),
73
+ users: usersFor(),
74
+ notify: notifyFor(pluginKey),
75
+ }
76
+ }
77
+
78
+ export interface RenderedPluginPage {
79
+ readonly title: string
80
+ readonly node: ReactNode | null
81
+ }
82
+
83
+ export interface BoardPageRequest {
84
+ readonly viewer: { readonly userId: number | null; readonly isGuest: boolean }
85
+ readonly query: Readonly<Record<string, string>>
86
+ readonly boardUrl: string
87
+ }
88
+
89
+ export type BoardPageResult =
90
+ | { readonly outcome: 'rendered'; readonly title: string; readonly node: ReactNode | null }
91
+ | { readonly outcome: 'missing' }
92
+ | { readonly outcome: 'sign-in-first'; readonly title: string }
93
+
94
+ function translated(
95
+ t: Awaited<ReturnType<typeof getTranslator>> | undefined,
96
+ key: string | undefined,
97
+ fallback: string,
98
+ args?: Parameters<Awaited<ReturnType<typeof getTranslator>>['t']>[1],
99
+ ): string {
100
+ return t !== undefined && key !== undefined && t.has(key) ? t.t(key, args) : fallback
101
+ }
102
+
103
+ export function pluginBoardPageTitle(
104
+ pluginKey: string,
105
+ path: string,
106
+ t?: Awaited<ReturnType<typeof getTranslator>>,
107
+ ): string | null {
108
+ const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
109
+ const definition = entry?.plugin as PluginDefinition | undefined
110
+ const page = (definition?.pages ?? []).find((candidate) => candidate.path === path)
111
+ return page === undefined ? null : translated(t, page.titleKey, page.title, page.titleArgs)
112
+ }
113
+
114
+ export async function renderPluginBoardPage(
115
+ pluginKey: string,
116
+ path: string,
117
+ request: BoardPageRequest,
118
+ ): Promise<BoardPageResult> {
119
+ const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
120
+ const definition = entry?.plugin as PluginDefinition | undefined
121
+
122
+ if (entry === undefined || definition === undefined) return { outcome: 'missing' }
123
+ if (entry.enabled === false) return { outcome: 'missing' }
124
+
125
+ const page = (definition.pages ?? []).find((candidate) => candidate.path === path)
126
+ if (page === undefined) return { outcome: 'missing' }
127
+
128
+ const overrides = await getSettingOverrides()
129
+ if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return { outcome: 'missing' }
130
+
131
+ const [log, t] = [logger({ component: 'plugin-page', plugin: pluginKey }), await getTranslator()]
132
+ const title = translated(t, page.titleKey, page.title, page.titleArgs)
133
+
134
+ if (page.access === 'member' && request.viewer.userId === null) {
135
+ return { outcome: 'sign-in-first', title }
136
+ }
137
+
138
+ try {
139
+ return {
140
+ outcome: 'rendered',
141
+ title,
142
+ node: await page.render({
143
+ ...runtimeContextFor(pluginKey, definition, overrides, log),
144
+ viewer: request.viewer,
145
+ path,
146
+ query: request.query,
147
+ boardUrl: request.boardUrl,
148
+ locale: t.locale,
149
+ t,
150
+ }),
151
+ }
152
+ } catch (error) {
153
+ log.error({ err: error, path }, 'plugin board page failed to render')
154
+ return { outcome: 'rendered', title, node: null }
155
+ }
156
+ }
157
+
158
+ export async function renderPluginAdminPage(
159
+ pluginKey: string,
160
+ path: string,
161
+ query: Readonly<Record<string, string>> = {},
162
+ ): Promise<RenderedPluginPage | null> {
163
+ const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
164
+ const definition = entry?.plugin as PluginDefinition | undefined
165
+
166
+ if (entry === undefined || definition === undefined) return null
167
+ if (entry.enabled === false) return null
168
+
169
+ const page = (definition.adminPages ?? []).find((candidate) => candidate.path === path)
170
+ if (page === undefined) return null
171
+
172
+ const overrides = await getSettingOverrides()
173
+ if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return null
174
+
175
+ const [log, t] = [logger({ component: 'plugin-page', plugin: pluginKey }), await getTranslator()]
176
+ const title = translated(t, page.titleKey, page.title, page.titleArgs)
177
+
178
+ try {
179
+ return {
180
+ title,
181
+ node: await page.render({
182
+ ...runtimeContextFor(pluginKey, definition, overrides, log),
183
+ query,
184
+ locale: t.locale,
185
+ t,
186
+ }),
187
+ }
188
+ } catch (error) {
189
+ log.error({ err: error, path }, 'plugin admin page failed to render')
190
+ return { title, node: null }
191
+ }
192
+ }
@@ -0,0 +1,49 @@
1
+ import 'server-only'
2
+
3
+ import forumConfig from '@board/config'
4
+
5
+ import type { Translator } from '@meith/i18n'
6
+ import { type PluginDefinition, pluginAdminPath } from '@meith/plugin-kit'
7
+
8
+ import type { PluginPage } from '@/view/plugin-panel'
9
+
10
+ import { getSettingOverrides } from './settings'
11
+
12
+ export interface PluginPanelSection {
13
+ readonly key: string
14
+ readonly name: string
15
+ readonly pages: readonly PluginPage[]
16
+ }
17
+
18
+ export async function pluginPanelSection(
19
+ pluginKey: string,
20
+ t?: Translator,
21
+ ): Promise<PluginPanelSection | null> {
22
+ const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
23
+ const definition = entry?.plugin as PluginDefinition | undefined
24
+
25
+ if (entry === undefined || definition === undefined) return null
26
+ if (entry.enabled === false) return null
27
+
28
+ const pages = definition.adminPages ?? []
29
+ if (pages.length === 0) return null
30
+
31
+ const overrides = await getSettingOverrides()
32
+ if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return null
33
+
34
+ return {
35
+ key: pluginKey,
36
+ name:
37
+ t !== undefined && definition.nameKey !== undefined && t.has(definition.nameKey)
38
+ ? t.t(definition.nameKey)
39
+ : definition.name,
40
+ pages: pages.map((page) => ({
41
+ path: page.path,
42
+ title:
43
+ t !== undefined && page.titleKey !== undefined && t.has(page.titleKey)
44
+ ? t.t(page.titleKey, page.titleArgs)
45
+ : page.title,
46
+ href: pluginAdminPath(pluginKey, page.path),
47
+ })),
48
+ }
49
+ }
@@ -0,0 +1,298 @@
1
+ import { tr } from './i18n'
2
+ import 'server-only'
3
+
4
+ import { env, logger, resolveClientAddress } from '@meith/core'
5
+ import { currentRequestId } from '@meith/core/logger'
6
+ import { demoAddressToken, demoDiscardsAddresses } from '@meith/demo'
7
+ import {
8
+ createRouteRateLimiter,
9
+ DEFAULT_ROUTE_BODY_BYTES,
10
+ type PluginDefinition,
11
+ type PluginRequest,
12
+ type PluginResponse,
13
+ type PluginRoute,
14
+ } from '@meith/plugin-kit'
15
+
16
+ import { recordAdminAction, requireAdmin } from './admin'
17
+ import { boardUrl } from './board-url'
18
+ import { getActor } from './context'
19
+ import { activeDefinitions, pluginHost, syncPluginEnablement } from './plugin-host'
20
+ import { runtimeContextFor } from './plugin-pages'
21
+ import { viewerRef } from './plugin-view'
22
+ import { isSafeLocalPath } from './safe-path'
23
+ import { isSameOrigin } from './same-origin'
24
+ import { getSettingOverrides } from './settings'
25
+
26
+ const STRIPPED_HEADERS = new Set(['cookie', 'authorization'])
27
+
28
+ function json(body: unknown, status: number): Response {
29
+ return new Response(JSON.stringify(body), {
30
+ status,
31
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
32
+ })
33
+ }
34
+
35
+ function fail(status: number, code: string, message: string): Response {
36
+ return json({ error: { code, message, requestId: currentRequestId() ?? null } }, status)
37
+ }
38
+
39
+ function passedHeaders(request: Request): Readonly<Record<string, string>> {
40
+ const out: Record<string, string> = {}
41
+ request.headers.forEach((value, name) => {
42
+ const lower = name.toLowerCase()
43
+ if (STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forum-')) return
44
+ out[lower] = value
45
+ })
46
+ return out
47
+ }
48
+
49
+ function queryOf(url: URL): Readonly<Record<string, string>> {
50
+ const out: Record<string, string> = {}
51
+ for (const [name, value] of url.searchParams) {
52
+ if (!(name in out)) out[name] = value
53
+ }
54
+ return out
55
+ }
56
+
57
+ interface ParsedBody {
58
+ readonly rawBody: Uint8Array | null
59
+ readonly form: Readonly<Record<string, string>> | null
60
+ readonly json: unknown
61
+ }
62
+
63
+ async function readBody(
64
+ request: Request,
65
+ route: PluginRoute,
66
+ ): Promise<ParsedBody | 'too-large' | 'unreadable'> {
67
+ if (route.method !== 'POST') return { rawBody: null, form: null, json: null }
68
+
69
+ const limit = route.maxBodyBytes ?? DEFAULT_ROUTE_BODY_BYTES
70
+
71
+ const declared = Number(request.headers.get('content-length') ?? '0')
72
+ if (Number.isFinite(declared) && declared > limit) return 'too-large'
73
+
74
+ let bytes: Uint8Array
75
+ try {
76
+ bytes = new Uint8Array(await request.arrayBuffer())
77
+ } catch {
78
+ return 'unreadable'
79
+ }
80
+ if (bytes.byteLength > limit) return 'too-large'
81
+
82
+ if (route.rawBody === true) return { rawBody: bytes, form: null, json: null }
83
+
84
+ const contentType = (request.headers.get('content-type') ?? '').toLowerCase()
85
+ const text = () => new TextDecoder().decode(bytes)
86
+
87
+ if (contentType.includes('application/json')) {
88
+ try {
89
+ return { rawBody: null, form: null, json: JSON.parse(text()) as unknown }
90
+ } catch {
91
+ return 'unreadable'
92
+ }
93
+ }
94
+
95
+ if (contentType.includes('application/x-www-form-urlencoded')) {
96
+ const form: Record<string, string> = {}
97
+ for (const [name, value] of new URLSearchParams(text())) {
98
+ if (!(name in form)) form[name] = value
99
+ }
100
+ return { rawBody: null, form, json: null }
101
+ }
102
+
103
+ return { rawBody: null, form: null, json: null }
104
+ }
105
+
106
+ function redirectAllowed(to: string, definition: PluginDefinition): boolean {
107
+ if (isSafeLocalPath(to)) return true
108
+
109
+ let url: URL
110
+ try {
111
+ url = new URL(to)
112
+ } catch {
113
+ return false
114
+ }
115
+
116
+ const hostname = url.hostname.toLowerCase()
117
+ const loopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'
118
+ const scheme = url.protocol === 'https:' || (url.protocol === 'http:' && loopback)
119
+
120
+ return scheme && (definition.allowedRedirectHosts ?? []).includes(hostname)
121
+ }
122
+
123
+ function toResponse(
124
+ answer: PluginResponse,
125
+ method: 'GET' | 'POST',
126
+ definition: PluginDefinition,
127
+ pluginKey: string,
128
+ ): Response {
129
+ switch (answer.kind) {
130
+ case 'json':
131
+ return json(answer.body, answer.status ?? 200)
132
+
133
+ case 'text':
134
+ return new Response(answer.body, {
135
+ status: answer.status ?? 200,
136
+ headers: {
137
+ 'content-type': answer.contentType ?? 'text/plain; charset=utf-8',
138
+ 'cache-control': 'no-store',
139
+ },
140
+ })
141
+
142
+ case 'redirect': {
143
+ if (!redirectAllowed(answer.to, definition)) {
144
+ logger({ component: 'plugin-route', plugin: pluginKey }).error(
145
+ { to: answer.to },
146
+ 'refused a redirect outside the plugin’s allowed hosts',
147
+ )
148
+ return fail(502, 'redirect_refused', 'The plugin answered with a redirect it may not make.')
149
+ }
150
+ return new Response(null, {
151
+ status: method === 'POST' ? 303 : 307,
152
+ headers: { location: answer.to, 'cache-control': 'no-store' },
153
+ })
154
+ }
155
+ }
156
+ }
157
+
158
+ const routeLimiter = createRouteRateLimiter()
159
+
160
+ function callerKey(request: Request, userId: number | null): string {
161
+ if (userId !== null) return `u:${userId}`
162
+ const address = resolveClientAddress(
163
+ {
164
+ forwardedFor: request.headers.get('x-forwarded-for'),
165
+ realIp: request.headers.get('x-real-ip'),
166
+ },
167
+ env.TRUSTED_PROXY_HOPS,
168
+ )
169
+ if (address === null) return 'a:unknown'
170
+
171
+ return `a:${demoDiscardsAddresses() ? demoAddressToken(address) : address}`
172
+ }
173
+
174
+ export type PluginRouteSurface = 'board' | 'admin'
175
+
176
+ export async function dispatchPluginRoute(
177
+ request: Request,
178
+ pluginKey: string,
179
+ segments: readonly string[],
180
+ surface: PluginRouteSurface = 'board',
181
+ ): Promise<Response> {
182
+ const definition = activeDefinitions().find((candidate) => candidate.key === pluginKey)
183
+ if (definition === undefined) {
184
+ return fail(404, 'no_such_route', 'No such endpoint.')
185
+ }
186
+
187
+ await syncPluginEnablement()
188
+ if (!pluginHost.isEnabled(pluginKey)) {
189
+ return fail(404, 'no_such_route', 'No such endpoint.')
190
+ }
191
+
192
+ const path = segments.join('/')
193
+ const method = request.method === 'POST' ? 'POST' : request.method === 'GET' ? 'GET' : null
194
+ if (method === null) {
195
+ return fail(405, 'method_not_allowed', 'Plugin routes answer GET and POST.')
196
+ }
197
+
198
+ const declared = (definition.routes ?? []).filter(
199
+ (candidate) =>
200
+ candidate.path === path && (candidate.access === 'admin') === (surface === 'admin'),
201
+ )
202
+ if (declared.length === 0) {
203
+ return fail(404, 'no_such_route', 'No such endpoint.')
204
+ }
205
+
206
+ const route = declared.find((candidate) => candidate.method === method)
207
+ if (route === undefined) {
208
+ return fail(405, 'method_not_allowed', `This endpoint answers ${declared[0]!.method}.`)
209
+ }
210
+
211
+ const actor = await getActor()
212
+ if (route.access === 'member' && actor.userId === null) {
213
+ return fail(401, 'unauthenticated', 'Sign in to use this.')
214
+ }
215
+
216
+ if (route.access === 'admin') {
217
+ try {
218
+ await requireAdmin()
219
+ } catch {
220
+ return fail(403, 'administrators_only', 'Enter the control panel and try again.')
221
+ }
222
+ }
223
+
224
+ if (route.access !== 'anonymous' && method === 'POST' && !isSameOrigin(request)) {
225
+ return fail(403, 'cross_origin', 'This form was posted from somewhere that is not the board.')
226
+ }
227
+
228
+ if (route.rateLimit !== undefined) {
229
+ const verdict = await routeLimiter.consume(
230
+ `${pluginKey}:${path}:${callerKey(request, actor.userId)}`,
231
+ route.rateLimit.limit,
232
+ route.rateLimit.windowSeconds,
233
+ Date.now(),
234
+ )
235
+ if (!verdict.allowed) {
236
+ return new Response(
237
+ JSON.stringify({
238
+ error: {
239
+ code: 'rate_limited',
240
+ message: await tr('notice.app.too-many-calls-endpoint-give'),
241
+ requestId: currentRequestId() ?? null,
242
+ },
243
+ }),
244
+ {
245
+ status: 429,
246
+ headers: {
247
+ 'content-type': 'application/json; charset=utf-8',
248
+ 'cache-control': 'no-store',
249
+ 'retry-after': String(verdict.retryAfterSeconds),
250
+ },
251
+ },
252
+ )
253
+ }
254
+ }
255
+
256
+ const body = await readBody(request, route)
257
+ if (body === 'too-large') {
258
+ return fail(413, 'body_too_large', 'The request body is over this endpoint’s limit.')
259
+ }
260
+ if (body === 'unreadable') {
261
+ return fail(400, 'unreadable_body', 'The request body could not be read as declared.')
262
+ }
263
+
264
+ const url = new URL(request.url)
265
+ const pluginRequest: PluginRequest = {
266
+ viewer: viewerRef(actor),
267
+ method,
268
+ path,
269
+ query: queryOf(url),
270
+ headers: passedHeaders(request),
271
+ rawBody: body.rawBody,
272
+ form: body.form,
273
+ json: body.json,
274
+ boardUrl: await boardUrl(),
275
+ }
276
+
277
+ const overrides = await getSettingOverrides()
278
+ const log = logger({ component: 'plugin-route', plugin: pluginKey })
279
+
280
+ const outcome = await pluginHost.run(pluginKey, `route ${method} ${path}`, () =>
281
+ route.handler(pluginRequest, {
282
+ ...runtimeContextFor(pluginKey, definition, overrides, log),
283
+ }),
284
+ )
285
+
286
+ if (outcome.status === 'disabled') {
287
+ return fail(404, 'no_such_route', 'No such endpoint.')
288
+ }
289
+ if (outcome.status === 'failed' || outcome.value === undefined || outcome.value === null) {
290
+ return fail(500, 'plugin_failed', 'The plugin could not answer. The error is in the log.')
291
+ }
292
+
293
+ if (route.access === 'admin' && method === 'POST') {
294
+ await recordAdminAction({ action: 'plugin.route', detail: { plugin: pluginKey, path } })
295
+ }
296
+
297
+ return toResponse(outcome.value, method, definition, pluginKey)
298
+ }
@@ -0,0 +1,58 @@
1
+ import 'server-only'
2
+
3
+ import { Fragment } from 'react'
4
+
5
+ import type { Actor } from '@meith/authorization'
6
+ import type {
7
+ HookContext,
8
+ HookName,
9
+ HookValue,
10
+ PluginRegion,
11
+ PluginRegionContext,
12
+ } from '@meith/plugin-kit'
13
+
14
+ import { pluginHost, syncPluginEnablement } from './plugin-host'
15
+
16
+ export function viewerRef(actor: Actor): { userId: number | null; isGuest: boolean } {
17
+ return { userId: actor.userId, isGuest: actor.userId === null }
18
+ }
19
+
20
+ export async function filterView<K extends HookName>(
21
+ name: K,
22
+ value: HookValue<K>,
23
+ context: HookContext<K>,
24
+ ): Promise<HookValue<K>> {
25
+ await syncPluginEnablement()
26
+ return pluginHost.applyFilter(name, value, context)
27
+ }
28
+
29
+ export async function emitEvent<K extends HookName>(
30
+ name: K,
31
+ value: HookValue<K>,
32
+ context: HookContext<K>,
33
+ ): Promise<void> {
34
+ await syncPluginEnablement()
35
+ await pluginHost.emit(name, value, context)
36
+ }
37
+
38
+ export async function pluginRegion(
39
+ region: PluginRegion,
40
+ context: Omit<PluginRegionContext, 'region'>,
41
+ ): Promise<React.ReactNode> {
42
+ await syncPluginEnablement()
43
+
44
+ const nodes = pluginHost.renderRegion(region, { ...context, region })
45
+ if (nodes.length === 0) return null
46
+
47
+ return (
48
+ <>
49
+ {nodes.map((entry) => (
50
+ <Fragment key={entry.key}>{entry.node}</Fragment>
51
+ ))}
52
+ </>
53
+ )
54
+ }
55
+
56
+ export async function boardRegion(region: PluginRegion, actor: Actor): Promise<React.ReactNode> {
57
+ return pluginRegion(region, { viewer: viewerRef(actor), subjectId: null, authorId: null })
58
+ }
@@ -0,0 +1,104 @@
1
+ 'use server'
2
+
3
+ import { redirect } from 'next/navigation'
4
+
5
+ import { ForbiddenError, ValidationError } from '@meith/core'
6
+ import { msg } from '@meith/i18n'
7
+ import { type PollEditOption, PollService } from '@meith/polls'
8
+
9
+ import { getContainer } from './container'
10
+ import { getActor } from './context'
11
+ import { emitEvent, viewerRef } from './plugin-view'
12
+ import { resolvePollScope } from './poll-scope'
13
+
14
+ function id(form: FormData, name: string): number {
15
+ const value = Number(form.get(name))
16
+ if (!Number.isSafeInteger(value) || value <= 0)
17
+ throw new ValidationError(msg('error.app.poll-exist'))
18
+ return value
19
+ }
20
+
21
+ function optionIds(form: FormData): readonly number[] {
22
+ return form
23
+ .getAll('optionId')
24
+ .map((value) => Number(value))
25
+ .filter((value) => Number.isSafeInteger(value) && value > 0)
26
+ }
27
+
28
+ function text(form: FormData, name: string): string {
29
+ const value = form.get(name)
30
+ return typeof value === 'string' ? value : ''
31
+ }
32
+
33
+ function closingTime(form: FormData): Date | null {
34
+ const value = text(form, 'closesAt').trim()
35
+ if (value === '') return null
36
+
37
+ const at = new Date(`${value}Z`)
38
+ if (Number.isNaN(at.getTime())) throw new ValidationError(msg('error.app.valid-date'))
39
+ return at
40
+ }
41
+
42
+ function editedOptions(form: FormData): readonly PollEditOption[] {
43
+ const labels = form.getAll('optionLabel')
44
+ return form.getAll('optionRef').flatMap((reference, index) => {
45
+ const label = labels[index]
46
+ if (typeof label !== 'string') return []
47
+ const raw = Number(reference)
48
+ const existing = Number.isSafeInteger(raw) && raw > 0 ? raw : null
49
+ return [{ id: existing, label }]
50
+ })
51
+ }
52
+
53
+ export async function votePollAction(form: FormData): Promise<void> {
54
+ const threadId = id(form, 'threadId')
55
+ const pollId = id(form, 'pollId')
56
+ const actor = await getActor()
57
+ const { polls } = getContainer()
58
+ if (polls === null || actor.userId === null)
59
+ throw new ForbiddenError(msg('error.app.must-logged-vote'))
60
+
61
+ const scope = await resolvePollScope(actor, threadId)
62
+ if (scope === null) throw new ValidationError(msg('error.app.poll-exist'))
63
+
64
+ const chosen = optionIds(form)
65
+ await new PollService(polls).vote({
66
+ threadId,
67
+ pollId,
68
+ optionIds: chosen,
69
+ userId: actor.userId,
70
+ mayVote: scope.mayVote,
71
+ })
72
+ for (const optionId of chosen) {
73
+ await emitEvent('poll.voted', { pollId, optionId }, viewerRef(actor))
74
+ }
75
+ redirect(`/thread/${threadId}`)
76
+ }
77
+
78
+ export async function editPollAction(form: FormData): Promise<void> {
79
+ const threadId = id(form, 'threadId')
80
+ const pollId = id(form, 'pollId')
81
+ const actor = await getActor()
82
+ const { polls } = getContainer()
83
+ if (polls === null || actor.userId === null)
84
+ throw new ForbiddenError(msg('error.app.must-logged-vote'))
85
+
86
+ const scope = await resolvePollScope(actor, threadId)
87
+ if (scope === null) throw new ValidationError(msg('error.app.poll-exist'))
88
+
89
+ await new PollService(polls).edit({
90
+ threadId,
91
+ pollId,
92
+ edit: {
93
+ question: text(form, 'question'),
94
+ options: editedOptions(form),
95
+ closesAt: closingTime(form),
96
+ maxOptions: Number(text(form, 'maxOptions')),
97
+ allowRevote: form.get('allowRevote') !== null,
98
+ publicVotes: form.get('publicVotes') !== null,
99
+ },
100
+ capabilities: scope.capabilities,
101
+ })
102
+
103
+ redirect(`/thread/${threadId}`)
104
+ }