@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,52 @@
1
+ import 'server-only'
2
+
3
+ import type { Actor } from '@meith/authorization'
4
+ import { canHoldThreads } from '@meith/forums'
5
+ import type { PollEditCapabilities } from '@meith/polls'
6
+
7
+ import { getContainer } from './container'
8
+ import { moderatorTargetFor } from './modcp'
9
+
10
+ export interface PollScope {
11
+ readonly forumId: number
12
+ readonly threadAuthorId: number | null
13
+ readonly mayVote: boolean
14
+ readonly mayEdit: boolean
15
+ readonly capabilities: PollEditCapabilities
16
+ }
17
+
18
+ export async function resolvePollScope(actor: Actor, threadId: number): Promise<PollScope | null> {
19
+ const { authorizer, forums, threads } = getContainer()
20
+
21
+ const located = await threads.locate(threadId)
22
+ if (located === null) return null
23
+
24
+ const forum = await forums.findById(located.forumId)
25
+ if (forum === null || !canHoldThreads(forum.type)) return null
26
+
27
+ const matrix = await authorizer.forumMatrix(actor, forum.id)
28
+ const scope = {
29
+ ...(await moderatorTargetFor(actor, forum.id, matrix)),
30
+ threadAuthorId: located.authorUserId,
31
+ ownerId: located.authorUserId,
32
+ }
33
+ if (!authorizer.can(actor, 'thread.view', scope)) return null
34
+
35
+ const wroteIt = actor.userId !== null && located.authorUserId === actor.userId
36
+ const editsOwn = wroteIt && authorizer.can(actor, 'post.editOwn', scope)
37
+ const mayEditOthers = authorizer.can(actor, 'post.editOthers', scope)
38
+ const moderates = authorizer.can(actor, 'content.viewUnapproved', scope)
39
+
40
+ return {
41
+ forumId: forum.id,
42
+ threadAuthorId: located.authorUserId,
43
+ mayVote: authorizer.can(actor, 'poll.vote', scope),
44
+ mayEdit: editsOwn || mayEditOthers,
45
+ capabilities: {
46
+ isAuthor: editsOwn,
47
+ mayEditOthers,
48
+ editWindowMinutes: Number(matrix.editTimeLimitMinutes ?? 0),
49
+ bypassesWindow: mayEditOthers || moderates,
50
+ },
51
+ }
52
+ }
@@ -0,0 +1,71 @@
1
+ import 'server-only'
2
+
3
+ import { foldIdentifier } from '@meith/accounts'
4
+ import { logger } from '@meith/core'
5
+ import { extractMentions, extractQuotedAuthors, vocabularyOptions } from '@meith/markdown'
6
+
7
+ import { postLink } from '@/view/post-link'
8
+
9
+ import { getContainer } from './container'
10
+ import { activeVocabulary } from './content-admin'
11
+ import { notificationService } from './notifications'
12
+
13
+ const MAX_RECIPIENTS_PER_KIND = 10
14
+
15
+ export interface NewPostNotice {
16
+ readonly postId: number
17
+ readonly threadId: number
18
+ readonly threadSlug: string
19
+ readonly threadTitle: string
20
+ readonly message: string
21
+ readonly authorUsername: string
22
+ readonly visibility: 'visible' | 'unapproved'
23
+ }
24
+
25
+ export async function notifyPostAudience(notice: NewPostNotice): Promise<void> {
26
+ try {
27
+ const service = notificationService()
28
+ if (service === null) return
29
+ if (notice.visibility !== 'visible') return
30
+
31
+ const options = vocabularyOptions(await activeVocabulary())
32
+ const quoted = extractQuotedAuthors(notice.message, options)
33
+ const mentioned = extractMentions(notice.message, options)
34
+
35
+ const href = postLink(`/thread/${notice.threadId}-${notice.threadSlug}`, notice.postId)
36
+ const data = { byUsername: notice.authorUsername, threadTitle: notice.threadTitle }
37
+
38
+ const { accountStore } = getContainer()
39
+ const told = new Set<string>([foldIdentifier(notice.authorUsername)])
40
+
41
+ for (const [kind, names] of [
42
+ ['post.quoted', quoted],
43
+ ['post.mentioned', mentioned],
44
+ ] as const) {
45
+ let raised = 0
46
+ for (const name of names) {
47
+ if (raised >= MAX_RECIPIENTS_PER_KIND) break
48
+ const folded = foldIdentifier(name)
49
+ if (told.has(folded)) continue
50
+ told.add(folded)
51
+
52
+ const account = await accountStore.accounts.findByUsernameLower(folded)
53
+ if (account === null) continue
54
+
55
+ await service.raise({
56
+ userId: account.id,
57
+ kind,
58
+ data,
59
+ href,
60
+ dedupeKey: `${kind}:${notice.postId}`,
61
+ })
62
+ raised += 1
63
+ }
64
+ }
65
+ } catch (err) {
66
+ logger({ module: 'post-notifications' }).error(
67
+ { err, postId: notice.postId },
68
+ 'failed to raise mention/quote notifications',
69
+ )
70
+ }
71
+ }
@@ -0,0 +1,86 @@
1
+ import 'server-only'
2
+
3
+ import type { Actor } from '@meith/authorization'
4
+ import { type EditCapabilities, PostEditor, type PostWriteRepository } from '@meith/posts'
5
+
6
+ import { getContainer } from './container'
7
+ import { getActor } from './context'
8
+ import { moderatorTargetFor } from './modcp'
9
+ import { getSettings } from './settings'
10
+
11
+ export interface PostManageScope {
12
+ readonly target: NonNullable<Awaited<ReturnType<PostWriteRepository['findEditTarget']>>>
13
+ readonly isOwn: boolean
14
+ readonly mayEdit: boolean
15
+ readonly mayDelete: boolean
16
+ readonly mayRestore: boolean
17
+ readonly mayViewHistory: boolean
18
+ readonly mayRollback: boolean
19
+ readonly capabilities: EditCapabilities
20
+ readonly bypassesLock: boolean
21
+ }
22
+
23
+ export async function resolvePostScope(
24
+ threadId: number,
25
+ postId: number,
26
+ viewer?: Actor,
27
+ ): Promise<PostManageScope | null> {
28
+ const actor = viewer ?? (await getActor())
29
+ const { authorizer, postWrites } = getContainer()
30
+ if (postWrites === null) return null
31
+
32
+ const target = await postWrites.findEditTarget(threadId, postId)
33
+ if (!target) return null
34
+
35
+ const matrix = await authorizer.forumMatrix(actor, target.forum.id)
36
+ const scope = {
37
+ ...(await moderatorTargetFor(actor, target.forum.id, matrix)),
38
+ ownerId: target.post.authorUserId,
39
+ }
40
+ if (!authorizer.can(actor, 'thread.view', scope)) return null
41
+
42
+ const isOwn = actor.userId !== null && target.post.authorUserId === actor.userId
43
+ const editsOthers = authorizer.can(actor, 'post.editOthers', scope)
44
+ const moderates = authorizer.can(actor, 'content.viewUnapproved', scope)
45
+ const softDeletes = authorizer.can(actor, 'post.softDelete', scope)
46
+ const seesUnapproved = authorizer.can(actor, 'content.viewUnapproved', scope)
47
+ const seesDeleted = authorizer.can(actor, 'content.viewDeleted', scope)
48
+ const staffCanSeePost =
49
+ editsOthers &&
50
+ (target.post.visibility === 'visible' ||
51
+ (target.post.visibility === 'unapproved' && seesUnapproved) ||
52
+ (target.post.visibility === 'deleted' && seesDeleted))
53
+
54
+ return {
55
+ target,
56
+ isOwn,
57
+ mayEdit: isOwn ? authorizer.can(actor, 'post.editOwn', scope) : editsOthers,
58
+ mayDelete: isOwn ? authorizer.can(actor, 'post.deleteOwn', scope) || softDeletes : softDeletes,
59
+ mayRestore:
60
+ authorizer.can(actor, 'post.restore', scope) &&
61
+ authorizer.can(actor, 'content.viewDeleted', scope),
62
+ mayViewHistory: (isOwn && target.post.visibility === 'visible') || staffCanSeePost,
63
+ mayRollback: staffCanSeePost && target.post.visibility !== 'deleted',
64
+ bypassesLock: moderates,
65
+ capabilities: {
66
+ isOwn,
67
+ editWindowMinutes: Number(scope.forum.editTimeLimitMinutes ?? 0),
68
+ bypassesWindow: editsOthers || moderates,
69
+ bypassesLock: moderates,
70
+ requiresApprovalOnEdit: scope.forum.requiresApprovalOnEdit === true,
71
+ bypassesModeration: moderates,
72
+ },
73
+ }
74
+ }
75
+
76
+ export async function postEditor(posts: PostWriteRepository): Promise<PostEditor> {
77
+ const settings = await getSettings()
78
+
79
+ return new PostEditor({
80
+ posts,
81
+ config: {
82
+ maxLength: settings.get('posting.max_length'),
83
+ editGraceSeconds: settings.get('posting.edit_grace_seconds'),
84
+ },
85
+ })
86
+ }
@@ -0,0 +1,177 @@
1
+ import 'server-only'
2
+
3
+ import { createHash } from 'node:crypto'
4
+
5
+ import { cookies, headers } from 'next/headers'
6
+
7
+ import type { Actor } from '@meith/authorization'
8
+ import { contentScopeFrom, env } from '@meith/core'
9
+ import { getDb, type OnlineScope, type OnlineSnapshot, PostgresPresenceRepository } from '@meith/db'
10
+
11
+ import { getContainer } from './container'
12
+ import { clearedCookie, GUEST_COOKIE_DAYS, guestCookieName, sessionCookieName } from './cookies'
13
+ import { FRESH_GUEST_HEADER, PATH_HEADER } from './location-header'
14
+
15
+ const LOCATION_WINDOW_SECONDS = 60
16
+
17
+ export interface BoardLocation {
18
+ readonly path: string
19
+ readonly forumId: number | null
20
+ readonly threadId: number | null
21
+ }
22
+
23
+ export function parseLocation(path: string | null): BoardLocation | null {
24
+ if (path === null || path === '') return null
25
+
26
+ const clean = path.split('?')[0] ?? path
27
+
28
+ const forum = /^\/(\d+)(?:-|\/|$)/.exec(clean)
29
+ const thread = /^\/thread\/(\d+)-/.exec(clean)
30
+
31
+ return {
32
+ path: clean,
33
+ forumId: forum === null ? null : Number(forum[1]),
34
+ threadId: thread === null ? null : Number(thread[1]),
35
+ }
36
+ }
37
+
38
+ export async function touchCurrentLocation(): Promise<void> {
39
+ try {
40
+ const location = parseLocation((await headers()).get(PATH_HEADER))
41
+ if (location === null) return
42
+ await touchLocation(location)
43
+ } catch {
44
+ /* ignore */
45
+ }
46
+ }
47
+
48
+ export function presenceRepository(): PostgresPresenceRepository | null {
49
+ return getContainer().dataSource === 'postgres' ? new PostgresPresenceRepository(getDb()) : null
50
+ }
51
+
52
+ export async function touchLocation(location: BoardLocation): Promise<void> {
53
+ try {
54
+ const { identity, accountStore } = getContainer()
55
+ const secure = env.NODE_ENV !== 'development'
56
+
57
+ const jar = await cookies()
58
+ const token = jar.get(sessionCookieName(secure))?.value
59
+
60
+ if (token === undefined || token === '') {
61
+ await touchGuestLocation(await returnedGuestToken(secure), location)
62
+ return
63
+ }
64
+
65
+ const session = await identity.locateSession(token)
66
+ if (session === null) {
67
+ await touchGuestLocation(await returnedGuestToken(secure), location)
68
+ return
69
+ }
70
+
71
+ await accountStore.sessions.touchLocation(
72
+ session.sessionId,
73
+ {
74
+ path: location.path,
75
+ forumId: location.forumId,
76
+ threadId: location.threadId,
77
+ },
78
+ new Date(),
79
+ LOCATION_WINDOW_SECONDS,
80
+ )
81
+ } catch {
82
+ /* ignore */
83
+ }
84
+ }
85
+
86
+ /**
87
+ * The guest token, but only once the client has proved it keeps cookies.
88
+ *
89
+ * The freshness header is the proof, and it has to come from the middleware
90
+ * because nothing downstream can supply it: Next reflects a cookie set on a
91
+ * `NextResponse.next()` into the request the render sees, so on a first-ever
92
+ * visit both `cookies()` and the raw `Cookie` header already carry a token the
93
+ * client has never seen. Counting on that wrote one presence row per request
94
+ * from anything that discards cookies — measured at twelve rows from twelve
95
+ * crawler requests, and a sessions table that grows without bound.
96
+ */
97
+ async function returnedGuestToken(secure: boolean): Promise<string | undefined> {
98
+ if ((await headers()).get(FRESH_GUEST_HEADER) === '1') return undefined
99
+
100
+ const value = (await cookies()).get(guestCookieName(secure))?.value
101
+ return value === '' ? undefined : value
102
+ }
103
+
104
+ /**
105
+ * The same reader, one row.
106
+ *
107
+ * Only reached when there is no usable member session, which is what keeps a
108
+ * logged-in member from being counted twice — and signing in drops the guest
109
+ * row outright, so the two never overlap.
110
+ */
111
+ async function touchGuestLocation(
112
+ guestToken: string | undefined,
113
+ location: BoardLocation,
114
+ ): Promise<void> {
115
+ if (guestToken === undefined || guestToken === '') return
116
+
117
+ const repo = presenceRepository()
118
+ if (repo === null) return
119
+
120
+ const now = new Date()
121
+ await repo.touchGuest({
122
+ tokenHash: createHash('sha256').update(guestToken).digest('hex'),
123
+ location: {
124
+ path: location.path,
125
+ forumId: location.forumId,
126
+ threadId: location.threadId,
127
+ },
128
+ now,
129
+ windowSeconds: LOCATION_WINDOW_SECONDS,
130
+ expiresAt: new Date(now.getTime() + GUEST_COOKIE_DAYS * 86_400_000),
131
+ })
132
+ }
133
+
134
+ /**
135
+ * Retires the guest identity of somebody who has just become a member.
136
+ *
137
+ * Called wherever a session cookie is set, rather than only at the login form,
138
+ * because that is every route into being signed in — the form, the remember-me
139
+ * resume, and a password change that reissues the session.
140
+ */
141
+ export async function retireGuestPresence(): Promise<void> {
142
+ try {
143
+ const secure = env.NODE_ENV !== 'development'
144
+ const jar = await cookies()
145
+ const token = jar.get(guestCookieName(secure))?.value
146
+
147
+ if (token !== undefined && token !== '') {
148
+ await presenceRepository()?.dropGuest(createHash('sha256').update(token).digest('hex'))
149
+ }
150
+
151
+ jar.set(guestCookieName(secure), '', clearedCookie(secure))
152
+ } catch {
153
+ /* ignore */
154
+ }
155
+ }
156
+
157
+ export async function onlineScopeFor(actor: Actor): Promise<OnlineScope> {
158
+ const { authorizer } = getContainer()
159
+ const staff = actor.global.isAdministrator === true || actor.global.isSuperModerator === true
160
+
161
+ return {
162
+ ...(await authorizer.threadAudience(actor)),
163
+ content: contentScopeFrom({ seesUnapproved: staff, seesDeleted: staff }),
164
+ seesInvisible: await authorizer.can(actor, 'modcp.access'),
165
+ }
166
+ }
167
+
168
+ export async function readOnline(actor: Actor, now: Date): Promise<OnlineSnapshot | null> {
169
+ const repo = presenceRepository()
170
+ if (repo === null) return null
171
+
172
+ try {
173
+ return await repo.onlineNow(now, await onlineScopeFor(actor))
174
+ } catch {
175
+ return null
176
+ }
177
+ }
@@ -0,0 +1,112 @@
1
+ import 'server-only'
2
+
3
+ import { cache } from 'react'
4
+
5
+ import { maxLengthFor, type ProfileFieldContext, ProfileFieldService } from '@meith/profile-fields'
6
+
7
+ import { AUTH_CONFIG } from './auth-config'
8
+ import { getContainer } from './container'
9
+ import { getActor } from './context'
10
+
11
+ export const FIELD_PREFIX = 'field:'
12
+
13
+ export function submittedFields(form: FormData): ReadonlyMap<string, string> {
14
+ const submitted = new Map<string, string>()
15
+ for (const [name, value] of form.entries()) {
16
+ if (name.startsWith(FIELD_PREFIX) && typeof value === 'string') {
17
+ submitted.set(name.slice(FIELD_PREFIX.length), value)
18
+ }
19
+ }
20
+ return submitted
21
+ }
22
+
23
+ export function profileFieldService(): ProfileFieldService | null {
24
+ const { profileFields } = getContainer()
25
+ return profileFields === null ? null : new ProfileFieldService({ fields: profileFields })
26
+ }
27
+
28
+ export const viewerFieldContext = cache(async (): Promise<ProfileFieldContext | null> => {
29
+ const service = profileFieldService()
30
+ if (service === null) return null
31
+
32
+ const { authorizer } = getContainer()
33
+ const actor = await getActor()
34
+
35
+ const rules = await service.listGroupRules()
36
+ return { applicable: authorizer.applicableGroupRows(actor, rules) }
37
+ })
38
+
39
+ export async function registrationFieldContext(): Promise<ProfileFieldContext | null> {
40
+ const service = profileFieldService()
41
+ if (service === null) return null
42
+
43
+ const { authorizer } = getContainer()
44
+ const rules = await service.listGroupRules()
45
+
46
+ return {
47
+ applicable: authorizer.applicableGroupRowsForGroups([AUTH_CONFIG.defaultMemberGroupId], rules),
48
+ }
49
+ }
50
+
51
+ export async function registrationFields(): Promise<
52
+ readonly {
53
+ key: string
54
+ label: string
55
+ description: string | null
56
+ type: string | null
57
+ options: readonly string[]
58
+ maxLength: number
59
+ }[]
60
+ > {
61
+ const service = profileFieldService()
62
+ if (service === null) return []
63
+
64
+ try {
65
+ const context = await registrationFieldContext()
66
+ if (context === null) return []
67
+
68
+ const fields = await service.requiredAtRegistration(context)
69
+ return fields.map((field) => ({
70
+ key: field.key,
71
+ label: field.label,
72
+ description: field.description,
73
+ type: field.type,
74
+ options: field.options,
75
+ maxLength: maxLengthFor(field),
76
+ }))
77
+ } catch {
78
+ return []
79
+ }
80
+ }
81
+
82
+ export async function visibleProfileFields(
83
+ ownerUserId: number,
84
+ ): Promise<readonly { label: string; value: string }[]> {
85
+ const service = profileFieldService()
86
+ const context = await viewerFieldContext()
87
+ if (service === null || context === null) return []
88
+
89
+ try {
90
+ const resolved = await service.visibleFor(ownerUserId, context)
91
+ return resolved.map((entry) => ({ label: entry.field.label, value: entry.value }))
92
+ } catch {
93
+ return []
94
+ }
95
+ }
96
+
97
+ export async function postbitProfileFields(
98
+ authorUserId: number,
99
+ ): Promise<readonly { label: string; value: string }[]> {
100
+ const service = profileFieldService()
101
+ const context = await viewerFieldContext()
102
+ if (service === null || context === null) return []
103
+
104
+ try {
105
+ const resolved = await service.visibleFor(authorUserId, context)
106
+ return resolved
107
+ .filter((entry) => entry.field.showInPostbit)
108
+ .map((entry) => ({ label: entry.field.label, value: entry.value }))
109
+ } catch {
110
+ return []
111
+ }
112
+ }
@@ -0,0 +1,113 @@
1
+ 'use server'
2
+
3
+ import { ForbiddenError, ValidationError } from '@meith/core'
4
+ import { msg } from '@meith/i18n'
5
+ import type { NotificationService } from '@meith/notifications'
6
+
7
+ import type { FormState } from './auth-form-state'
8
+ import { getActor } from './context'
9
+ import { formStateReporter } from './form-state-reporter'
10
+ import { tr } from './i18n'
11
+ import { notificationService } from './notifications'
12
+ import { pushAvailability } from './push'
13
+
14
+ const toFormState = formStateReporter('push-actions', 'unexpected error in web push')
15
+
16
+ const ENDPOINT_MAX = 1000
17
+
18
+ const BASE64URL = /^[A-Za-z0-9_-]+$/
19
+
20
+ const IP_LITERAL = /^\[|^\d+\.\d+\.\d+\.\d+$/
21
+
22
+ const PRIVATE_SUFFIX = /\.(?:local|internal|localdomain|home|lan)$/
23
+
24
+ export interface PushSubscriptionInput {
25
+ readonly endpoint: string
26
+ readonly p256dh: string
27
+ readonly auth: string
28
+ }
29
+
30
+ function keyBytes(value: string): number {
31
+ return BASE64URL.test(value) ? Buffer.from(value, 'base64url').length : 0
32
+ }
33
+
34
+ function isUsableEndpoint(endpoint: string): boolean {
35
+ if (endpoint.length === 0 || endpoint.length > ENDPOINT_MAX) return false
36
+
37
+ try {
38
+ const url = new URL(endpoint)
39
+ if (url.protocol !== 'https:') return false
40
+
41
+ const host = url.hostname.toLowerCase()
42
+ return (
43
+ host.includes('.') &&
44
+ !IP_LITERAL.test(host) &&
45
+ !PRIVATE_SUFFIX.test(host) &&
46
+ host !== 'localhost'
47
+ )
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ async function requirePushCentre(): Promise<{ service: NotificationService; userId: number }> {
54
+ const actor = await getActor()
55
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
56
+
57
+ const service = notificationService()
58
+ if (service === null) {
59
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-16'))
60
+ }
61
+
62
+ if (!(await pushAvailability()).enabled) {
63
+ throw new ValidationError(msg('notice.app.board-offering-web-push'))
64
+ }
65
+
66
+ return { service, userId: actor.userId }
67
+ }
68
+
69
+ export async function subscribeToPushAction(input: PushSubscriptionInput): Promise<FormState> {
70
+ try {
71
+ if (
72
+ !isUsableEndpoint(input.endpoint) ||
73
+ keyBytes(input.p256dh) !== 65 ||
74
+ keyBytes(input.auth) !== 16
75
+ ) {
76
+ return { error: await tr('notice.app.push-subscription-valid') }
77
+ }
78
+
79
+ const { service, userId } = await requirePushCentre()
80
+ await service.subscribeToPush({
81
+ userId,
82
+ endpoint: input.endpoint,
83
+ p256dh: input.p256dh,
84
+ auth: input.auth,
85
+ })
86
+ } catch (err) {
87
+ return toFormState(err)
88
+ }
89
+
90
+ return { notice: await tr('notice.pushSubscribed') }
91
+ }
92
+
93
+ export async function unsubscribeFromPushAction(endpoint: string): Promise<FormState> {
94
+ try {
95
+ const actor = await getActor()
96
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
97
+
98
+ const service = notificationService()
99
+ if (service === null) {
100
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-16'))
101
+ }
102
+
103
+ if (!isUsableEndpoint(endpoint)) {
104
+ return { error: await tr('notice.app.push-subscription-valid') }
105
+ }
106
+
107
+ await service.unsubscribeFromPush(actor.userId, endpoint)
108
+ } catch (err) {
109
+ return toFormState(err)
110
+ }
111
+
112
+ return { notice: await tr('notice.pushUnsubscribed') }
113
+ }
@@ -0,0 +1,40 @@
1
+ import 'server-only'
2
+
3
+ import { env } from '@meith/core'
4
+ import { isVapidPublicKey } from '@meith/notifications'
5
+ import { type PushProblem, resolvePushConfig } from '@meith/settings'
6
+
7
+ import { getSettings } from './settings'
8
+
9
+ export interface PushAvailability {
10
+ readonly enabled: boolean
11
+ readonly publicKey: string
12
+ }
13
+
14
+ const OFF: PushAvailability = { enabled: false, publicKey: '' }
15
+
16
+ export interface PushReadiness {
17
+ readonly live: boolean
18
+ readonly problem: PushProblem | 'bad-key' | null
19
+ }
20
+
21
+ export async function pushAvailability(): Promise<PushAvailability> {
22
+ try {
23
+ const { config } = resolvePushConfig({ environment: env, settings: await getSettings() })
24
+ if (config === null || !isVapidPublicKey(config.publicKey)) return OFF
25
+ return { enabled: true, publicKey: config.publicKey }
26
+ } catch {
27
+ return OFF
28
+ }
29
+ }
30
+
31
+ export async function pushReadiness(): Promise<PushReadiness> {
32
+ const { config, problem } = resolvePushConfig({
33
+ environment: env,
34
+ settings: await getSettings(),
35
+ })
36
+
37
+ if (config === null) return { live: false, problem }
38
+ if (!isVapidPublicKey(config.publicKey)) return { live: false, problem: 'bad-key' }
39
+ return { live: true, problem: null }
40
+ }
@@ -0,0 +1,15 @@
1
+ import 'server-only'
2
+
3
+ import { headers } from 'next/headers'
4
+ import { redirect } from 'next/navigation'
5
+
6
+ import { PATH_HEADER } from './location-header'
7
+ import { isSafeLocalPath } from './safe-path'
8
+
9
+ export async function redirectToCurrentPath(): Promise<void> {
10
+ const path = (await headers()).get(PATH_HEADER)
11
+
12
+ if (typeof path === 'string' && isSafeLocalPath(path)) {
13
+ redirect(path)
14
+ }
15
+ }
@@ -0,0 +1,7 @@
1
+ import 'server-only'
2
+
3
+ import { boardAuthConfig } from './auth-config'
4
+
5
+ export async function registrationOpen(): Promise<boolean> {
6
+ return (await boardAuthConfig()).registrationEnabled
7
+ }