@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,62 @@
1
+ import 'server-only'
2
+
3
+ import {
4
+ renderStoredSignature,
5
+ type SignatureLimits,
6
+ type StoredSignature,
7
+ } from '@meith/signatures'
8
+
9
+ import { getContainer } from './container'
10
+ import { getActor } from './context'
11
+ import { boardRendering } from './markdown-pipeline'
12
+ import { filterView, viewerRef } from './plugin-view'
13
+
14
+ export function signatureStore() {
15
+ return getContainer().signatures
16
+ }
17
+
18
+ export async function viewerSignatureLimits(): Promise<SignatureLimits> {
19
+ const actor = await getActor()
20
+ const { authorizer } = getContainer()
21
+
22
+ return {
23
+ canUse: authorizer.can(actor, 'signature.use'),
24
+ maxLength: authorizer.globalLimit(actor, 'maxSignatureLength'),
25
+ }
26
+ }
27
+
28
+ export async function renderSignature(
29
+ authorId: number,
30
+ stored: StoredSignature,
31
+ ): Promise<string | null> {
32
+ const html = await renderStoredSignature(stored, { authorId, rendering: boardRendering })
33
+ if (html === null) return null
34
+
35
+ return filterView('signature.html', html, { ...viewerRef(await getActor()), authorId })
36
+ }
37
+
38
+ export async function signaturesFor(
39
+ userIds: readonly number[],
40
+ ): Promise<ReadonlyMap<number, string>> {
41
+ const store = signatureStore()
42
+ if (store === null || userIds.length === 0) return new Map()
43
+
44
+ try {
45
+ const viewer = viewerRef(await getActor())
46
+ const stored = await store.readMany(userIds)
47
+ const out = new Map<number, string>()
48
+
49
+ for (const [userId, signature] of stored) {
50
+ const html = await renderStoredSignature(signature, {
51
+ authorId: userId,
52
+ rendering: boardRendering,
53
+ })
54
+ if (html === null) continue
55
+
56
+ out.set(userId, await filterView('signature.html', html, { ...viewer, authorId: userId }))
57
+ }
58
+ return out
59
+ } catch {
60
+ return new Map()
61
+ }
62
+ }
@@ -0,0 +1,79 @@
1
+ import { decodeBase64UrlText, encodeBase64Url, isProviderKind } from '@meith/accounts'
2
+
3
+ import { isSafeLocalPath } from './safe-path'
4
+
5
+ export type HandshakeMode = 'sign-in' | 'link' | 'credential-proof'
6
+
7
+ export interface Handshake {
8
+ readonly provider: string
9
+ readonly mode: HandshakeMode
10
+ readonly state: string
11
+ readonly nonce: string
12
+ readonly codeVerifier: string
13
+ readonly next: string
14
+ readonly authorizationUrl: string
15
+ readonly userId?: number
16
+ readonly sessionId?: number
17
+ readonly provedAt?: number
18
+ }
19
+
20
+ export function isProviderAddress(value: string): boolean {
21
+ try {
22
+ const url = new URL(value)
23
+ return url.protocol === 'https:' || url.protocol === 'http:'
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ export function encodeHandshake(handshake: Handshake): string {
30
+ return encodeBase64Url(new TextEncoder().encode(JSON.stringify(handshake)))
31
+ }
32
+
33
+ export function decodeHandshake(raw: string | undefined): Handshake | null {
34
+ if (raw === undefined || raw === '') return null
35
+
36
+ let parsed: unknown
37
+ try {
38
+ parsed = JSON.parse(decodeBase64UrlText(raw))
39
+ } catch {
40
+ return null
41
+ }
42
+
43
+ if (typeof parsed !== 'object' || parsed === null) return null
44
+ const candidate = parsed as Record<string, unknown>
45
+
46
+ const provider = text(candidate.provider)
47
+ const mode = candidate.mode
48
+ const state = text(candidate.state)
49
+ const nonce = text(candidate.nonce)
50
+ const codeVerifier = text(candidate.codeVerifier)
51
+ const next = text(candidate.next)
52
+ const authorizationUrl = text(candidate.authorizationUrl)
53
+
54
+ if (provider === null || !isProviderKind(provider)) return null
55
+ if (mode !== 'sign-in' && mode !== 'link' && mode !== 'credential-proof') return null
56
+ if (state === null || nonce === null || codeVerifier === null) return null
57
+ if (authorizationUrl === null || !isProviderAddress(authorizationUrl)) return null
58
+
59
+ return {
60
+ provider,
61
+ mode,
62
+ state,
63
+ nonce,
64
+ codeVerifier,
65
+ authorizationUrl,
66
+ next: next !== null && isSafeLocalPath(next) ? next : '/',
67
+ ...(integer(candidate.userId) === null ? {} : { userId: integer(candidate.userId)! }),
68
+ ...(integer(candidate.sessionId) === null ? {} : { sessionId: integer(candidate.sessionId)! }),
69
+ ...(integer(candidate.provedAt) === null ? {} : { provedAt: integer(candidate.provedAt)! }),
70
+ }
71
+ }
72
+
73
+ function integer(value: unknown): number | null {
74
+ return typeof value === 'number' && Number.isInteger(value) ? value : null
75
+ }
76
+
77
+ function text(value: unknown): string | null {
78
+ return typeof value === 'string' && value !== '' ? value : null
79
+ }
@@ -0,0 +1,63 @@
1
+ import 'server-only'
2
+
3
+ import type { Actor } from '@meith/authorization'
4
+ import { contentScopeFrom } from '@meith/core'
5
+ import {
6
+ type BoardTotals,
7
+ getDb,
8
+ PostgresStatsRepository,
9
+ type StatsScope,
10
+ type TopPoster,
11
+ type TopThread,
12
+ } from '@meith/db'
13
+
14
+ import { getContainer } from './container'
15
+
16
+ export const LEADERBOARD_SIZE = 10
17
+
18
+ export function statsRepository(): PostgresStatsRepository | null {
19
+ return getContainer().dataSource === 'postgres' ? new PostgresStatsRepository(getDb()) : null
20
+ }
21
+
22
+ export async function statsScopeFor(actor: Actor): Promise<StatsScope> {
23
+ const { authorizer } = getContainer()
24
+ const staff = actor.global.isAdministrator === true || actor.global.isSuperModerator === true
25
+
26
+ return {
27
+ ...(await authorizer.threadAudience(actor)),
28
+ content: contentScopeFrom({ seesUnapproved: staff, seesDeleted: staff }),
29
+ }
30
+ }
31
+
32
+ export interface StatsView {
33
+ readonly totals: BoardTotals
34
+ readonly topPosters: readonly TopPoster[]
35
+ readonly mostViewed: readonly TopThread[]
36
+ readonly mostReplied: readonly TopThread[]
37
+ }
38
+
39
+ export async function buildStatsView(actor: Actor): Promise<StatsView | null> {
40
+ const repo = statsRepository()
41
+ if (repo === null) return null
42
+
43
+ const scope = await statsScopeFor(actor)
44
+ const [totals, topPosters, mostViewed, mostReplied] = await Promise.all([
45
+ repo.readTotals(),
46
+ repo.topPosters(LEADERBOARD_SIZE),
47
+ repo.mostViewed(LEADERBOARD_SIZE, scope),
48
+ repo.mostReplied(LEADERBOARD_SIZE, scope),
49
+ ])
50
+
51
+ return { totals, topPosters, mostViewed, mostReplied }
52
+ }
53
+
54
+ export async function readTotals(): Promise<BoardTotals | null> {
55
+ const repo = statsRepository()
56
+ if (repo === null) return null
57
+
58
+ try {
59
+ return await repo.readTotals()
60
+ } catch {
61
+ return null
62
+ }
63
+ }
@@ -0,0 +1,161 @@
1
+ 'use server'
2
+
3
+ import { redirect } from 'next/navigation'
4
+
5
+ import { env, ForbiddenError } from '@meith/core'
6
+ import { currentRequestId } from '@meith/core/logger'
7
+ import { msg } from '@meith/i18n'
8
+ import {
9
+ parseSubscriptionTarget,
10
+ readUnsubscribeToken,
11
+ SubscriptionService,
12
+ } from '@meith/subscriptions'
13
+
14
+ import type { FormState } from './auth-form-state'
15
+ import { getContainer } from './container'
16
+ import { getActor } from './context'
17
+ import { formStateReporter } from './form-state-reporter'
18
+ import { positiveInt, text } from './form-values'
19
+ import { tr } from './i18n'
20
+ import { emitEvent } from './plugin-view'
21
+ import { isSafeLocalPath } from './safe-path'
22
+
23
+ const toFormState = formStateReporter('subscription-actions', 'unexpected error in subscriptions')
24
+
25
+ async function mayView(target: 'thread' | 'forum', targetId: number): Promise<boolean> {
26
+ const actor = await getActor()
27
+ const { authorizer, threads, forums } = getContainer()
28
+
29
+ const located = target === 'forum' ? null : await threads.locate(targetId)
30
+ const forumId = target === 'forum' ? targetId : (located?.forumId ?? null)
31
+ if (forumId === null) return false
32
+
33
+ const forum = await forums.findById(forumId)
34
+ if (!forum) return false
35
+
36
+ const matrix = await authorizer.forumMatrix(actor, forumId)
37
+ return target === 'forum'
38
+ ? authorizer.can(actor, 'forum.view', { forumId, forum: matrix })
39
+ : authorizer.can(actor, 'thread.view', {
40
+ ...(await authorizer.moderatorTargetIn(actor, forumId, matrix)),
41
+ threadAuthorId: located?.authorUserId ?? null,
42
+ })
43
+ }
44
+
45
+ export async function subscribeAction(_prev: FormState, form: FormData): Promise<FormState> {
46
+ const target = parseSubscriptionTarget(text(form, 'target'))
47
+ const targetId = positiveInt(form, 'targetId')
48
+ const back = text(form, 'back')
49
+
50
+ if (target === null || targetId === null) return { error: await tr('notice.app.exist') }
51
+
52
+ try {
53
+ const actor = await getActor()
54
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
55
+
56
+ const { subscriptions } = getContainer()
57
+ if (subscriptions === null) {
58
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-17'))
59
+ }
60
+
61
+ await new SubscriptionService({ subscriptions }).subscribe({
62
+ userId: actor.userId,
63
+ target,
64
+ targetId,
65
+ mode: text(form, 'mode'),
66
+ mayView: await mayView(target, targetId),
67
+ })
68
+
69
+ await emitEvent(
70
+ 'subscription.changed',
71
+ { userId: actor.userId, target, targetId, subscribed: true },
72
+ { requestId: currentRequestId() ?? null },
73
+ )
74
+ } catch (err) {
75
+ return toFormState(err)
76
+ }
77
+
78
+ redirect(safeReturn(back, '/subscriptions?followed=1'))
79
+ }
80
+
81
+ export async function unsubscribeAction(_prev: FormState, form: FormData): Promise<FormState> {
82
+ const target = parseSubscriptionTarget(text(form, 'target'))
83
+ const targetId = positiveInt(form, 'targetId')
84
+ const back = text(form, 'back')
85
+
86
+ if (target === null || targetId === null) return { error: await tr('notice.app.exist') }
87
+
88
+ try {
89
+ const actor = await getActor()
90
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
91
+
92
+ const { subscriptions } = getContainer()
93
+ if (subscriptions === null) {
94
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-17'))
95
+ }
96
+
97
+ await new SubscriptionService({ subscriptions }).unsubscribe({
98
+ userId: actor.userId,
99
+ target,
100
+ targetId,
101
+ })
102
+
103
+ await emitEvent(
104
+ 'subscription.changed',
105
+ { userId: actor.userId, target, targetId, subscribed: false },
106
+ { requestId: currentRequestId() ?? null },
107
+ )
108
+ } catch (err) {
109
+ return toFormState(err)
110
+ }
111
+
112
+ redirect(safeReturn(back, '/subscriptions?stopped=1'))
113
+ }
114
+
115
+ export async function unsubscribeByTokenAction(
116
+ _prev: FormState,
117
+ form: FormData,
118
+ ): Promise<FormState> {
119
+ const token = text(form, 'token')
120
+ const secret = env.AUTH_SECRET
121
+
122
+ if (secret === undefined || token === '') {
123
+ return { error: await tr('notice.app.unsubscribe-link-valid') }
124
+ }
125
+
126
+ const claim = readUnsubscribeToken(token, secret)
127
+ if (claim === null) return { error: await tr('notice.app.unsubscribe-link-valid') }
128
+
129
+ try {
130
+ const { subscriptions, notifications } = getContainer()
131
+ if (subscriptions === null || notifications === null) {
132
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-17'))
133
+ }
134
+
135
+ if (claim.scope === 'email') {
136
+ await notifications.savePreferences(
137
+ claim.userId,
138
+ 'email',
139
+ new Map([
140
+ ['subscription.reply', false],
141
+ ['subscription.digest', false],
142
+ ]),
143
+ )
144
+ } else {
145
+ await new SubscriptionService({ subscriptions }).unsubscribe({
146
+ userId: claim.userId,
147
+ target: claim.scope,
148
+ targetId: claim.targetId,
149
+ })
150
+ }
151
+ } catch (err) {
152
+ return toFormState(err)
153
+ }
154
+
155
+ redirect(`/unsubscribe?done=${claim.scope === 'email' ? 'email' : 'one'}`)
156
+ }
157
+
158
+ function safeReturn(value: string, fallback: string): string {
159
+ if (!isSafeLocalPath(value)) return fallback
160
+ return value
161
+ }
@@ -0,0 +1,180 @@
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 { parseSelection, type SurgeryRights, ThreadSurgery } from '@meith/moderation'
8
+
9
+ import type { FormState } from './auth-form-state'
10
+ import { getContainer } from './container'
11
+ import { getActor } from './context'
12
+ import { formStateReporter } from './form-state-reporter'
13
+ import { tr } from './i18n'
14
+ import { emitEvent } from './plugin-view'
15
+
16
+ function positiveInt(form: FormData, name: string): number | null {
17
+ const value = form.get(name)
18
+ if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value.trim())) return null
19
+ const n = Number(value.trim())
20
+ return Number.isSafeInteger(n) ? n : null
21
+ }
22
+
23
+ const toFormState = formStateReporter('surgery-actions', 'unexpected error in thread surgery')
24
+
25
+ const NO_STORE = 'This board is running on in-memory sample data, so it has no thread tools.'
26
+
27
+ export async function splitThreadAction(_prev: FormState, form: FormData): Promise<FormState> {
28
+ const threadId = positiveInt(form, 'threadId')
29
+ const fromPostId = positiveInt(form, 'fromPostId')
30
+ const title = typeof form.get('title') === 'string' ? (form.get('title') as string) : ''
31
+
32
+ if (threadId === null) return { error: await tr('notice.app.thread-exist') }
33
+ if (fromPostId === null) return { error: await tr('notice.app.choose-post-split-from') }
34
+
35
+ const { threadSurgery } = getContainer()
36
+ if (threadSurgery === null) return { error: NO_STORE }
37
+
38
+ let outcome: Awaited<ReturnType<ThreadSurgery['split']>>
39
+ try {
40
+ const actor = await getActor()
41
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
42
+
43
+ const forumId = await visibleForumOf(threadId)
44
+
45
+ outcome = await new ThreadSurgery({ threads: threadSurgery }).split({
46
+ threadId,
47
+ fromPostId,
48
+ title,
49
+ actorUserId: actor.userId,
50
+ rights: await resolveRights(forumId),
51
+ })
52
+
53
+ await emitEvent(
54
+ 'thread.split',
55
+ { sourceThreadId: threadId, newThreadId: outcome.threadId, postCount: outcome.posts },
56
+ { moderatorId: actor.userId, reason: null },
57
+ )
58
+ } catch (err) {
59
+ return toFormState(err)
60
+ }
61
+
62
+ redirect(`/thread/${outcome.threadId}-${outcome.slug}?tool=split&n=${outcome.posts}`)
63
+ }
64
+
65
+ export async function splitSelectedAction(_prev: FormState, form: FormData): Promise<FormState> {
66
+ const threadId = positiveInt(form, 'threadId')
67
+ const title = typeof form.get('title') === 'string' ? (form.get('title') as string) : ''
68
+ if (threadId === null) return { error: await tr('notice.app.thread-exist') }
69
+
70
+ const { threadSurgery } = getContainer()
71
+ if (threadSurgery === null) return { error: NO_STORE }
72
+
73
+ const postIds = parseSelection(
74
+ form.getAll('item').filter((v): v is string => typeof v === 'string'),
75
+ )
76
+ .filter((item) => item.kind === 'post')
77
+ .map((item) => item.id)
78
+
79
+ let outcome: Awaited<ReturnType<ThreadSurgery['splitPosts']>>
80
+ try {
81
+ const actor = await getActor()
82
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
83
+
84
+ const forumId = await visibleForumOf(threadId)
85
+
86
+ outcome = await new ThreadSurgery({ threads: threadSurgery }).splitPosts({
87
+ threadId,
88
+ postIds,
89
+ title,
90
+ actorUserId: actor.userId,
91
+ rights: await resolveRights(forumId),
92
+ })
93
+
94
+ await emitEvent(
95
+ 'thread.split',
96
+ { sourceThreadId: threadId, newThreadId: outcome.threadId, postCount: outcome.posts },
97
+ { moderatorId: actor.userId, reason: null },
98
+ )
99
+ } catch (err) {
100
+ return toFormState(err)
101
+ }
102
+
103
+ const dropped = outcome.dropped > 0 ? `&dropped=${outcome.dropped}` : ''
104
+ redirect(`/thread/${outcome.threadId}-${outcome.slug}?tool=split&n=${outcome.posts}${dropped}`)
105
+ }
106
+
107
+ export async function mergeThreadAction(_prev: FormState, form: FormData): Promise<FormState> {
108
+ const sourceThreadId = positiveInt(form, 'threadId')
109
+ const targetThreadId = positiveInt(form, 'targetThreadId')
110
+
111
+ if (sourceThreadId === null) return { error: await tr('notice.app.thread-exist') }
112
+ if (targetThreadId === null) {
113
+ return { error: await tr('notice.app.enter-number-thread-merge-into') }
114
+ }
115
+
116
+ const { threadSurgery } = getContainer()
117
+ if (threadSurgery === null) return { error: NO_STORE }
118
+
119
+ let outcome: Awaited<ReturnType<ThreadSurgery['merge']>>
120
+ try {
121
+ const actor = await getActor()
122
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
123
+
124
+ const [sourceForumId, targetForumId] = await Promise.all([
125
+ visibleForumOf(sourceThreadId),
126
+ visibleForumOf(targetThreadId),
127
+ ])
128
+
129
+ outcome = await new ThreadSurgery({ threads: threadSurgery }).merge({
130
+ sourceThreadId,
131
+ targetThreadId,
132
+ actorUserId: actor.userId,
133
+ rights: await resolveRights(sourceForumId),
134
+ targetRights: await resolveRights(targetForumId),
135
+ })
136
+
137
+ await emitEvent(
138
+ 'thread.merged',
139
+ {
140
+ keptThreadId: outcome.threadId,
141
+ mergedThreadId: sourceThreadId,
142
+ postCount: outcome.posts,
143
+ },
144
+ { moderatorId: actor.userId, reason: null },
145
+ )
146
+ } catch (err) {
147
+ return toFormState(err)
148
+ }
149
+
150
+ redirect(`/thread/${outcome.threadId}-${outcome.slug}?tool=merge&n=${outcome.posts}`)
151
+ }
152
+
153
+ async function visibleForumOf(threadId: number): Promise<number> {
154
+ const { threads, authorizer } = getContainer()
155
+ const actor = await getActor()
156
+
157
+ const forumId = (await threads.locate(threadId))?.forumId ?? null
158
+ if (forumId === null) throw new ValidationError(msg('error.app.thread-exist'))
159
+
160
+ const forum = await authorizer.forumMatrix(actor, forumId)
161
+ if (!authorizer.can(actor, 'thread.view', { forumId, forum })) {
162
+ throw new ValidationError(msg('error.app.thread-exist'))
163
+ }
164
+ return forumId
165
+ }
166
+
167
+ async function resolveRights(forumId: number): Promise<SurgeryRights> {
168
+ const actor = await getActor()
169
+ const { authorizer } = getContainer()
170
+ const [forum, moderatorRights] = await Promise.all([
171
+ authorizer.forumMatrix(actor, forumId),
172
+ authorizer.moderatorRightsIn(actor, forumId),
173
+ ])
174
+ const target = { forumId, forum, moderatorRights }
175
+
176
+ return {
177
+ merge: authorizer.can(actor, 'thread.merge', target),
178
+ split: authorizer.can(actor, 'thread.split', target),
179
+ }
180
+ }
@@ -0,0 +1,77 @@
1
+ import 'server-only'
2
+
3
+ import { env, PUBLIC_CONTENT } from '@meith/core'
4
+ import { type FeedScope, getDb, PostgresFeedRepository } from '@meith/db'
5
+
6
+ import type { SitemapUrl } from '@/view/feed'
7
+
8
+ import { boardUrl } from './board-url'
9
+ import { getContainer } from './container'
10
+ import { filterView } from './plugin-view'
11
+ import { getSettings } from './settings'
12
+
13
+ export const FEED_LIMIT = 30
14
+
15
+ export const SITEMAP_CHUNK = 5_000
16
+
17
+ export function feedRepository(): PostgresFeedRepository | null {
18
+ return getContainer().dataSource === 'postgres' ? new PostgresFeedRepository(getDb()) : null
19
+ }
20
+
21
+ export async function origin(): Promise<string> {
22
+ return (await boardUrl()) || 'http://localhost:3000'
23
+ }
24
+
25
+ export function absoluteTo(base: string, path: string): string {
26
+ return `${base}${path}`
27
+ }
28
+
29
+ export async function absolute(path: string): Promise<string> {
30
+ return absoluteTo(await origin(), path)
31
+ }
32
+
33
+ export async function publicScope(): Promise<FeedScope> {
34
+ const { authorizer, actorSource } = getContainer()
35
+ const guest = await actorSource.buildGuest()
36
+
37
+ return {
38
+ ...(await authorizer.threadAudience(guest)),
39
+ content: PUBLIC_CONTENT,
40
+ }
41
+ }
42
+
43
+ export async function isIndexable(): Promise<boolean> {
44
+ if (env.DEMO_MODE) return false
45
+
46
+ try {
47
+ return (await getSettings()).get('board.offline') !== true
48
+ } catch {
49
+ return true
50
+ }
51
+ }
52
+
53
+ /**
54
+ * One chunk of the sitemap, after the plugins have had it. Everything here is
55
+ * public by definition — the sitemap is what the board offers a crawler.
56
+ */
57
+ export async function filterSitemapEntries(
58
+ urls: readonly SitemapUrl[],
59
+ chunk: number,
60
+ ): Promise<readonly SitemapUrl[]> {
61
+ const filtered = await filterView(
62
+ 'sitemap.entries',
63
+ urls.map((url) => ({
64
+ href: url.loc,
65
+ lastModified: url.lastmod?.toISOString() ?? null,
66
+ })),
67
+ { chunk },
68
+ )
69
+
70
+ return filtered.map((row) => {
71
+ const lastmod = row.lastModified === null ? undefined : new Date(row.lastModified)
72
+ return {
73
+ loc: row.href,
74
+ ...(lastmod === undefined || Number.isNaN(lastmod.getTime()) ? {} : { lastmod }),
75
+ }
76
+ })
77
+ }