@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,89 @@
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 { parseRelationKind } from '@meith/relations'
8
+
9
+ import type { FormState } from './auth-form-state'
10
+ import { getActor } from './context'
11
+ import { formStateReporter } from './form-state-reporter'
12
+ import { trimmedText } from './form-values'
13
+ import { isStaff, relationService } from './relations'
14
+ import { isSafeLocalPath } from './safe-path'
15
+
16
+ const toFormState = formStateReporter('relation-actions', 'unexpected error in a relation action')
17
+
18
+ function positiveInt(form: FormData, name: string): number | null {
19
+ const value = Number(trimmedText(form, name))
20
+ return Number.isInteger(value) && value > 0 ? value : null
21
+ }
22
+
23
+ async function requireLists(): Promise<{
24
+ service: NonNullable<ReturnType<typeof relationService>>
25
+ userId: number
26
+ }> {
27
+ const actor = await getActor()
28
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
29
+
30
+ const service = relationService()
31
+ if (service === null) {
32
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-15'))
33
+ }
34
+
35
+ return { service, userId: actor.userId }
36
+ }
37
+
38
+ function safeReturn(form: FormData): string {
39
+ const raw = trimmedText(form, 'returnTo')
40
+ return isSafeLocalPath(raw) ? raw : '/usercp/contacts'
41
+ }
42
+
43
+ export async function setRelationAction(_prev: FormState, form: FormData): Promise<FormState> {
44
+ const returnTo = safeReturn(form)
45
+ let query: string
46
+
47
+ try {
48
+ const { service, userId } = await requireLists()
49
+
50
+ const otherUserId = positiveInt(form, 'userId')
51
+ const username = trimmedText(form, 'username')
52
+ const kind = parseRelationKind(trimmedText(form, 'kind'))
53
+
54
+ if (otherUserId === null) throw new ValidationError(msg('error.app.such-member'))
55
+ if (kind === null) throw new ValidationError(msg('error.app.list'))
56
+
57
+ await service.set({
58
+ userId,
59
+ otherUserId,
60
+ kind,
61
+ ...(kind === 'ignore' ? { targetIsStaff: await isStaff(otherUserId) } : {}),
62
+ })
63
+
64
+ query = `${kind === 'ignore' ? 'ignored' : 'added'}=${encodeURIComponent(username)}`
65
+ } catch (err) {
66
+ return toFormState(err)
67
+ }
68
+
69
+ redirect(`${returnTo}${returnTo.includes('?') ? '&' : '?'}${query}`)
70
+ }
71
+
72
+ export async function removeRelationAction(_prev: FormState, form: FormData): Promise<FormState> {
73
+ const returnTo = safeReturn(form)
74
+ let query: string
75
+
76
+ try {
77
+ const { service, userId } = await requireLists()
78
+
79
+ const otherUserId = positiveInt(form, 'userId')
80
+ if (otherUserId === null) throw new ValidationError(msg('error.app.such-member'))
81
+
82
+ await service.remove(userId, otherUserId)
83
+ query = `removed=${encodeURIComponent(trimmedText(form, 'username'))}`
84
+ } catch (err) {
85
+ return toFormState(err)
86
+ }
87
+
88
+ redirect(`${returnTo}${returnTo.includes('?') ? '&' : '?'}${query}`)
89
+ }
@@ -0,0 +1,45 @@
1
+ import 'server-only'
2
+
3
+ import { cache } from 'react'
4
+
5
+ import { RelationService } from '@meith/relations'
6
+
7
+ import { getContainer } from './container'
8
+ import { getActor } from './context'
9
+
10
+ export function relationService(): RelationService | null {
11
+ const { relations } = getContainer()
12
+ return relations === null ? null : new RelationService({ relations })
13
+ }
14
+
15
+ export const viewerIgnoredIds = cache(async (): Promise<ReadonlySet<number>> => {
16
+ const service = relationService()
17
+ const actor = await getActor()
18
+ if (service === null || actor.userId === null) return new Set()
19
+
20
+ try {
21
+ return new Set(await service.ignoredIds(actor.userId))
22
+ } catch {
23
+ return new Set()
24
+ }
25
+ })
26
+
27
+ export async function isStaff(userId: number): Promise<boolean> {
28
+ const { actorSource, authorizer } = getContainer()
29
+ const actor = await actorSource.buildForUser(userId)
30
+ if (actor === null) return false
31
+ return authorizer.can(actor, 'modcp.access')
32
+ }
33
+
34
+ const ACTIVITY_WINDOW_SECONDS = 300
35
+
36
+ export async function touchActivity(userId: number | null): Promise<void> {
37
+ if (userId === null) return
38
+
39
+ try {
40
+ const { accountStore } = getContainer()
41
+ await accountStore.accounts.touchLastActive(userId, new Date(), ACTIVITY_WINDOW_SECONDS)
42
+ } catch {
43
+ /* ignore */
44
+ }
45
+ }
@@ -0,0 +1,173 @@
1
+ import { msg } from '@meith/i18n'
2
+ import 'server-only'
3
+
4
+ import type { Actor } from '@meith/authorization'
5
+ import { ForbiddenError, ValidationError } from '@meith/core'
6
+ import { restrictsPosting } from '@meith/moderation'
7
+ import { type AuthorRestriction, ReplyComposer, type ReplyTarget } from '@meith/threads'
8
+
9
+ import {
10
+ dailyLimitMessage,
11
+ holdsNewMember,
12
+ limitMessage,
13
+ spendDailyLimit,
14
+ spendLimit,
15
+ } from './antispam'
16
+ import type { AttachmentScope } from './attachments'
17
+ import { getContainer } from './container'
18
+ import { emitEvent, filterView, viewerRef } from './plugin-view'
19
+ import { notifyPostAudience } from './post-notifications'
20
+ import { getSettings } from './settings'
21
+
22
+ export interface ResolvedReplyTarget {
23
+ readonly target: ReplyTarget
24
+ readonly forumId: number
25
+ readonly scope: AttachmentScope
26
+ }
27
+
28
+ export async function resolveReplyTarget(
29
+ actor: Actor,
30
+ threadId: number,
31
+ ): Promise<ResolvedReplyTarget> {
32
+ const { authorizer, threadWrites } = getContainer()
33
+
34
+ if (threadWrites === null) {
35
+ throw new ValidationError(msg('error.app.board-running-in-memory-sample-data-8'))
36
+ }
37
+
38
+ const target = await threadWrites.replyTarget(threadId)
39
+ if (!target) throw new ValidationError(msg('error.app.thread-exist'))
40
+
41
+ const forumId = target.forum.id
42
+ const scope = {
43
+ forumId,
44
+ forum: await authorizer.forumMatrix(actor, forumId),
45
+ allowsAttachments: target.forum.allowAttachments,
46
+ }
47
+
48
+ if (
49
+ !authorizer.can(actor, 'thread.view', {
50
+ ...(await authorizer.moderatorTargetIn(actor, forumId, scope.forum)),
51
+ threadAuthorId: target.authorUserId,
52
+ })
53
+ ) {
54
+ throw new ValidationError(msg('error.app.thread-exist'))
55
+ }
56
+ authorizer.require(actor, 'reply.post', scope)
57
+
58
+ if (actor.userId === null) {
59
+ throw new ForbiddenError(msg('error.app.must-logged-post'))
60
+ }
61
+
62
+ return { target, forumId, scope }
63
+ }
64
+
65
+ export interface SubmitReplyInput {
66
+ readonly message: string
67
+ readonly subscribe?: boolean
68
+ readonly seenLastPostId?: number | null
69
+ }
70
+
71
+ export async function submitReply(
72
+ actor: Actor,
73
+ resolved: ResolvedReplyTarget,
74
+ input: SubmitReplyInput,
75
+ ): Promise<Awaited<ReturnType<ReplyComposer['create']>>> {
76
+ const { authorizer, threadWrites, memberProfiles, warnings } = getContainer()
77
+ if (threadWrites === null) {
78
+ throw new ValidationError(msg('error.app.board-accept-posts'))
79
+ }
80
+
81
+ const userId = actor.userId
82
+ if (userId === null) throw new ForbiddenError(msg('error.app.must-logged-post'))
83
+
84
+ const settings = await getSettings()
85
+ const { scope, target, forumId } = resolved
86
+
87
+ const limited = await spendLimit({ scope: 'post', actor, settings })
88
+ if (limited !== null && !limited.allowed) throw new ValidationError(limitMessage(limited))
89
+
90
+ const daily = await spendDailyLimit({ scope: 'post_day', actor })
91
+ if (daily !== null && !daily.allowed) {
92
+ throw new ValidationError(dailyLimitMessage('post_day', daily))
93
+ }
94
+
95
+ const composer = new ReplyComposer({
96
+ posts: threadWrites,
97
+ config: {
98
+ floodSeconds: settings.get('posting.flood_seconds'),
99
+ maxLength: settings.get('posting.max_length'),
100
+ },
101
+ })
102
+
103
+ const profile = await memberProfiles.findPublicById(userId)
104
+ if (!profile) throw new ForbiddenError(msg('error.app.account-longer-post'))
105
+
106
+ const draft = await filterView(
107
+ 'post.create.before',
108
+ {
109
+ subject: null,
110
+ body: input.message,
111
+ prefixId: null,
112
+ forumId,
113
+ authorId: userId,
114
+ },
115
+ { ...viewerRef(actor), threadId: target.threadId },
116
+ )
117
+
118
+ const objections = await filterView('post.create.validate', [], {
119
+ draft,
120
+ threadId: target.threadId,
121
+ })
122
+ if (objections.length > 0) throw new ValidationError(objections[0]!)
123
+
124
+ const created = await composer.create(
125
+ {
126
+ message: draft.body,
127
+ subscribe: input.subscribe ?? false,
128
+ seenLastPostId: input.seenLastPostId ?? null,
129
+ bypassesModeration: authorizer.can(actor, 'content.viewUnapproved', scope),
130
+ bypassesFlood: authorizer.can(actor, 'flood.bypass'),
131
+ heldAsNewMember: await holdsNewMember({
132
+ actor,
133
+ postCount: profile.postCount,
134
+ settings,
135
+ }),
136
+ requiresApproval: scope.forum.requiresPostApproval === true,
137
+ bypassesLock: authorizer.can(actor, 'content.viewUnapproved', scope),
138
+ restriction: await authorRestriction(warnings, userId),
139
+ },
140
+ { userId, username: profile.username },
141
+ target,
142
+ )
143
+
144
+ if (created.visibility === 'unapproved') {
145
+ await emitEvent('approval.queued', { kind: 'post', id: created.postId }, viewerRef(actor))
146
+ }
147
+
148
+ await emitEvent(
149
+ 'post.created',
150
+ { postId: created.postId, threadId: created.threadId, forumId, authorId: userId },
151
+ viewerRef(actor),
152
+ )
153
+
154
+ await notifyPostAudience({
155
+ postId: created.postId,
156
+ threadId: created.threadId,
157
+ threadSlug: created.slug,
158
+ threadTitle: target.title,
159
+ message: draft.body,
160
+ authorUsername: profile.username,
161
+ visibility: created.visibility,
162
+ })
163
+
164
+ return created
165
+ }
166
+
167
+ async function authorRestriction(
168
+ warnings: ReturnType<typeof getContainer>['warnings'],
169
+ userId: number,
170
+ ): Promise<AuthorRestriction> {
171
+ if (warnings === null) return { suspended: false, moderated: false }
172
+ return restrictsPosting(await warnings.readRestriction(userId), new Date())
173
+ }
@@ -0,0 +1,165 @@
1
+ 'use server'
2
+
3
+ import { redirect } from 'next/navigation'
4
+
5
+ import { ForbiddenError, ValidationError } from '@meith/core'
6
+ import { currentRequestId } from '@meith/core/logger'
7
+ import { msg } from '@meith/i18n'
8
+ import { parseTargetKind, ReportService } from '@meith/moderation'
9
+
10
+ import { limitMessage, spendLimit } from './antispam'
11
+ import type { FormState } from './auth-form-state'
12
+ import { getContainer } from './container'
13
+ import { getActor } from './context'
14
+ import { formStateReporter } from './form-state-reporter'
15
+ import { positiveIntIn } from './form-values'
16
+ import { tr } from './i18n'
17
+ import { reportNotifier } from './notifications'
18
+ import { emitEvent } from './plugin-view'
19
+ import { resolveReportScope } from './report-scope'
20
+
21
+ function field(form: FormData, name: string): string {
22
+ const value = form.get(name)
23
+ return typeof value === 'string' ? value.trim() : ''
24
+ }
25
+
26
+ const toFormState = formStateReporter('report-actions', 'unexpected error in reports')
27
+
28
+ export async function fileReportAction(_prev: FormState, form: FormData): Promise<FormState> {
29
+ const kind = parseTargetKind(field(form, 'kind'))
30
+ const targetId = positiveIntIn(field(form, 'targetId'))
31
+ const reason = field(form, 'reason')
32
+ const values = { reason }
33
+
34
+ if (kind === null || targetId === null) {
35
+ return { error: await tr('notice.app.exist'), values }
36
+ }
37
+
38
+ const { authorizer, reports } = getContainer()
39
+ if (reports === null) {
40
+ return {
41
+ error: await tr('notice.app.board-running-in-memory-sample-data-5'),
42
+ values,
43
+ }
44
+ }
45
+
46
+ let target: Awaited<ReturnType<NonNullable<typeof reports>['resolveTarget']>>
47
+ try {
48
+ const actor = await getActor()
49
+ if (actor.userId === null) {
50
+ throw new ForbiddenError(msg('error.app.must-logged-report-anything'))
51
+ }
52
+ authorizer.require(actor, 'content.report')
53
+
54
+ target = await reports.resolveTarget(kind, targetId, actor.userId)
55
+ if (target === null) throw new ValidationError(msg('error.app.exist'))
56
+
57
+ if (target.forumId !== null) {
58
+ const matrix = await authorizer.forumMatrix(actor, target.forumId)
59
+ const scope = {
60
+ ...(await authorizer.moderatorTargetIn(actor, target.forumId, matrix)),
61
+ threadAuthorId: target.threadAuthorUserId,
62
+ }
63
+ if (!authorizer.can(actor, 'thread.view', scope)) {
64
+ throw new ValidationError(msg('error.app.exist'))
65
+ }
66
+ } else if (kind === 'user') {
67
+ authorizer.require(actor, 'profile.view')
68
+ }
69
+
70
+ const limited = await spendLimit({ scope: 'report', actor })
71
+ if (limited !== null && !limited.allowed) {
72
+ return { error: limitMessage(limited), values }
73
+ }
74
+
75
+ const outcome = await new ReportService({ reports }).file({
76
+ kind,
77
+ targetId,
78
+ reason,
79
+ reporterUserId: actor.userId,
80
+ })
81
+
82
+ if (!outcome.duplicate) {
83
+ await emitEvent(
84
+ 'report.created',
85
+ {
86
+ reportId: outcome.reportId,
87
+ target: kind === 'private_message' ? 'pm' : kind,
88
+ targetId,
89
+ reporterId: actor.userId,
90
+ },
91
+ { requestId: currentRequestId() ?? null },
92
+ )
93
+ }
94
+ } catch (err) {
95
+ return toFormState(err, values)
96
+ }
97
+
98
+ const back =
99
+ target.threadId === null
100
+ ? `/member/${target.id}?reported=1`
101
+ : `/thread/${target.threadId}?reported=1`
102
+ redirect(back)
103
+ }
104
+
105
+ export async function assignReportAction(_prev: FormState, form: FormData): Promise<FormState> {
106
+ const reportId = positiveIntIn(field(form, 'reportId'))
107
+ const take = field(form, 'take') === '1'
108
+ if (reportId === null) return { error: await tr('notice.app.report-exist') }
109
+
110
+ const { reports } = getContainer()
111
+ if (reports === null) return { error: await tr('notice.app.board-reports') }
112
+
113
+ try {
114
+ const actor = await getActor()
115
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
116
+
117
+ await new ReportService({ reports }).assign({
118
+ reportId,
119
+ toUserId: take ? actor.userId : null,
120
+ actorUserId: actor.userId,
121
+ scope: await resolveReportScope(),
122
+ })
123
+ } catch (err) {
124
+ return toFormState(err, {})
125
+ }
126
+
127
+ redirect('/moderation/reports')
128
+ }
129
+
130
+ export async function closeReportAction(_prev: FormState, form: FormData): Promise<FormState> {
131
+ const reportId = positiveIntIn(field(form, 'reportId'))
132
+ const status = form.get('status')
133
+ const note = field(form, 'note')
134
+
135
+ if (reportId === null) return { error: await tr('notice.app.report-exist') }
136
+ if (status !== 'resolved' && status !== 'rejected') {
137
+ return { error: await tr('notice.app.choose-resolve-dismiss') }
138
+ }
139
+
140
+ const { reports } = getContainer()
141
+ if (reports === null) return { error: await tr('notice.app.board-reports') }
142
+
143
+ try {
144
+ const actor = await getActor()
145
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
146
+
147
+ await new ReportService({ reports, notifier: reportNotifier() }).close({
148
+ reportId,
149
+ status,
150
+ note,
151
+ actorUserId: actor.userId,
152
+ scope: await resolveReportScope(),
153
+ })
154
+
155
+ await emitEvent(
156
+ 'report.resolved',
157
+ { reportId, resolution: status === 'resolved' ? 'actioned' : 'rejected' },
158
+ { moderatorId: actor.userId, reason: note === '' ? null : note },
159
+ )
160
+ } catch (err) {
161
+ return toFormState(err, { note })
162
+ }
163
+
164
+ redirect(`/moderation/reports?closed=${status}`)
165
+ }
@@ -0,0 +1,21 @@
1
+ import 'server-only'
2
+
3
+ import type { ReportScope } from '@meith/moderation'
4
+
5
+ import { getContainer } from './container'
6
+ import { getActor } from './context'
7
+
8
+ export async function resolveReportScope(): Promise<ReportScope> {
9
+ const actor = await getActor()
10
+ const { authorizer } = getContainer()
11
+ if (actor.userId === null) return { forumIds: [], global: false }
12
+
13
+ return {
14
+ forumIds: await authorizer.moderatedForumIds(actor),
15
+ global: authorizer.can(actor, 'modcp.access'),
16
+ }
17
+ }
18
+
19
+ export function hasReportScope(scope: ReportScope): boolean {
20
+ return scope.forumIds.length > 0 || scope.global
21
+ }
@@ -0,0 +1,146 @@
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 { parseRating } from '@meith/reputation'
8
+
9
+ import { postLink } from '@/view/post-link'
10
+
11
+ import type { FormState } from './auth-form-state'
12
+ import { getActor } from './context'
13
+ import { formStateReporter } from './form-state-reporter'
14
+ import { trimmedText } from './form-values'
15
+ import { emitEvent, viewerRef } from './plugin-view'
16
+ import { reputationService, reputationSettings, viewerRaterLimits } from './reputation'
17
+ import { requireRateablePost } from './reputation-target'
18
+ import { isSafeLocalPath } from './safe-path'
19
+
20
+ const toFormState = formStateReporter('reputation-actions', 'unexpected error rating somebody')
21
+
22
+ function positiveInt(form: FormData, name: string): number | null {
23
+ const value = Number(trimmedText(form, name))
24
+ return Number.isInteger(value) && value > 0 ? value : null
25
+ }
26
+
27
+ function safeReturn(form: FormData, fallback: string): string {
28
+ const raw = trimmedText(form, 'returnTo')
29
+ return isSafeLocalPath(raw) ? raw : fallback
30
+ }
31
+
32
+ async function requireReputation(): Promise<{
33
+ service: NonNullable<ReturnType<typeof reputationService>>
34
+ userId: number
35
+ }> {
36
+ const actor = await getActor()
37
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
38
+
39
+ const service = reputationService()
40
+ if (service === null) {
41
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data'))
42
+ }
43
+
44
+ return { service, userId: actor.userId }
45
+ }
46
+
47
+ async function announceReputation(
48
+ service: Awaited<ReturnType<typeof requireReputation>>['service'],
49
+ userId: number,
50
+ delta: number,
51
+ ): Promise<void> {
52
+ const summary = await service.summary(userId)
53
+ await emitEvent(
54
+ 'reputation.changed',
55
+ { userId, delta, total: summary.total },
56
+ viewerRef(await getActor()),
57
+ )
58
+ }
59
+
60
+ export async function rateMemberAction(_prev: FormState, form: FormData): Promise<FormState> {
61
+ const values = { comment: trimmedText(form, 'comment') }
62
+ const userId = positiveInt(form, 'userId')
63
+ const returnTo = safeReturn(form, userId === null ? '/' : `/member/${userId}`)
64
+
65
+ try {
66
+ const { service, userId: raterId } = await requireReputation()
67
+ if (userId === null) throw new ValidationError(msg('error.app.such-member'))
68
+
69
+ const points = parseRating(trimmedText(form, 'points'))
70
+ if (points === null) throw new ValidationError(msg('error.app.rating'))
71
+
72
+ const postId = positiveInt(form, 'postId')
73
+ await requireRateablePost(await getActor(), userId, postId)
74
+ const [settings, limits] = await Promise.all([reputationSettings(), viewerRaterLimits()])
75
+
76
+ await service.give({
77
+ userId,
78
+ givenByUserId: raterId,
79
+ postId,
80
+ points,
81
+ comment: values.comment,
82
+ settings,
83
+ limits,
84
+ })
85
+
86
+ await announceReputation(service, userId, points)
87
+ } catch (err) {
88
+ return toFormState(err, values)
89
+ }
90
+
91
+ redirect(`${returnTo}${returnTo.includes('?') ? '&' : '?'}rated=1`)
92
+ }
93
+
94
+ export async function withdrawRatingAction(_prev: FormState, form: FormData): Promise<FormState> {
95
+ const userId = positiveInt(form, 'userId')
96
+ const returnTo = safeReturn(form, userId === null ? '/' : `/member/${userId}/reputation`)
97
+
98
+ try {
99
+ const { service, userId: raterId } = await requireReputation()
100
+
101
+ const ratingId = positiveInt(form, 'ratingId')
102
+ if (ratingId === null) throw new ValidationError(msg('error.app.such-rating'))
103
+
104
+ const withdrawn = await service.withdraw(ratingId, raterId)
105
+ if (withdrawn && userId !== null) await announceReputation(service, userId, 0)
106
+ } catch (err) {
107
+ return toFormState(err)
108
+ }
109
+
110
+ redirect(`${returnTo}${returnTo.includes('?') ? '&' : '?'}withdrawn=1`)
111
+ }
112
+
113
+ export async function thankForPostAction(_prev: FormState, form: FormData): Promise<FormState> {
114
+ const userId = positiveInt(form, 'userId')
115
+ const postId = positiveInt(form, 'postId')
116
+ const returnTo = safeReturn(form, userId === null ? '/' : `/member/${userId}`)
117
+
118
+ try {
119
+ const { service, userId: raterId } = await requireReputation()
120
+ if (userId === null || postId === null) throw new ValidationError(msg('error.app.such-post'))
121
+ await requireRateablePost(await getActor(), userId, postId)
122
+
123
+ const held = await service.existing({ givenByUserId: raterId, userId, postId })
124
+
125
+ if (held !== null && held.points > 0) {
126
+ await service.withdraw(held.id, raterId)
127
+ await announceReputation(service, userId, -held.points)
128
+ } else {
129
+ const [settings, limits] = await Promise.all([reputationSettings(), viewerRaterLimits()])
130
+ await service.give({
131
+ userId,
132
+ givenByUserId: raterId,
133
+ postId,
134
+ points: 1,
135
+ comment: '',
136
+ settings,
137
+ limits,
138
+ })
139
+ await announceReputation(service, userId, 1)
140
+ }
141
+ } catch (err) {
142
+ return toFormState(err)
143
+ }
144
+
145
+ redirect(postLink(returnTo, postId))
146
+ }
@@ -0,0 +1,49 @@
1
+ import 'server-only'
2
+
3
+ import type { Actor } from '@meith/authorization'
4
+ import { ValidationError } from '@meith/core'
5
+ import { canHoldThreads } from '@meith/forums'
6
+ import { msg } from '@meith/i18n'
7
+
8
+ import { getContainer } from './container'
9
+
10
+ export async function requireRateablePost(
11
+ actor: Actor,
12
+ userId: number,
13
+ postId: number | null,
14
+ ): Promise<void> {
15
+ if (postId === null) return
16
+
17
+ const { authorizer, forums, posts, threads } = getContainer()
18
+ const post = await posts.findRatingTarget(postId)
19
+ if (post === null || post.authorUserId !== userId) {
20
+ throw new ValidationError(msg('error.app.such-post'))
21
+ }
22
+
23
+ const located = await threads.locate(post.threadId)
24
+ if (located === null) throw new ValidationError(msg('error.app.such-post'))
25
+
26
+ const forum = await forums.findById(located.forumId)
27
+ if (forum === null || !canHoldThreads(forum.type)) {
28
+ throw new ValidationError(msg('error.app.such-post'))
29
+ }
30
+
31
+ const matrix = await authorizer.forumMatrix(actor, forum.id)
32
+ const target = await authorizer.moderatorTargetIn(actor, forum.id, matrix)
33
+ if (!authorizer.can(actor, 'thread.view', { ...target, threadAuthorId: located.authorUserId })) {
34
+ throw new ValidationError(msg('error.app.such-post'))
35
+ }
36
+
37
+ const thread = await threads.findById(
38
+ post.threadId,
39
+ authorizer.contentScope(actor, target),
40
+ authorizer.authorFilter(actor, target),
41
+ )
42
+ if (thread === null) throw new ValidationError(msg('error.app.such-post'))
43
+
44
+ const visible = await posts.locate(post.threadId, postId, {
45
+ scope: authorizer.contentScope(actor, target),
46
+ pageSize: 1,
47
+ })
48
+ if (visible === null) throw new ValidationError(msg('error.app.such-post'))
49
+ }