@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,139 @@
1
+ export const SESSION_COOKIE = '__Host-fs_session'
2
+ export const REMEMBER_COOKIE = '__Host-fs_remember'
3
+ export const DEV_SESSION_COOKIE = 'fs_session'
4
+ export const DEV_REMEMBER_COOKIE = 'fs_remember'
5
+
6
+ /**
7
+ * Counts one reader once, and carries nothing else.
8
+ *
9
+ * An opaque random value, first-party, readable by nobody but this board. It
10
+ * exists because "37 guests reading" is not derivable from a stateless request:
11
+ * without something that comes back, every page view is a stranger. It is never
12
+ * an identity — no code path turns it into an actor, and `locateSession()`
13
+ * refuses a row with no user — so the worst it can say about the person holding
14
+ * it is that they were here.
15
+ */
16
+ export const GUEST_COOKIE = '__Host-fs_guest'
17
+ export const DEV_GUEST_COOKIE = 'fs_guest'
18
+
19
+ /** Long enough that a reader is one guest across a visit, short enough to expire. */
20
+ export const GUEST_COOKIE_DAYS = 1
21
+
22
+ export const ADMIN_COOKIE = 'fs_admin'
23
+ export const ADMIN_COOKIE_PATH = '/admin'
24
+
25
+ export const SSO_COOKIE = '__Host-fs_sso'
26
+ export const DEV_SSO_COOKIE = 'fs_sso'
27
+
28
+ export const PASSKEY_COOKIE = '__Host-fs_passkey'
29
+ export const DEV_PASSKEY_COOKIE = 'fs_passkey'
30
+
31
+ export const SECOND_FACTOR_COOKIE = '__Host-fs_2fa'
32
+ export const DEV_SECOND_FACTOR_COOKIE = 'fs_2fa'
33
+
34
+ /** Long enough to read a consent screen, short enough that a stale one is gone. */
35
+ export const HANDSHAKE_MINUTES = 10
36
+
37
+ export function sessionCookieName(secure: boolean): string {
38
+ return secure ? SESSION_COOKIE : DEV_SESSION_COOKIE
39
+ }
40
+
41
+ export function rememberCookieName(secure: boolean): string {
42
+ return secure ? REMEMBER_COOKIE : DEV_REMEMBER_COOKIE
43
+ }
44
+
45
+ export function guestCookieName(secure: boolean): string {
46
+ return secure ? GUEST_COOKIE : DEV_GUEST_COOKIE
47
+ }
48
+
49
+ export interface CookieAttrs {
50
+ readonly httpOnly: boolean
51
+ readonly secure: boolean
52
+ readonly sameSite: 'lax' | 'strict' | 'none'
53
+ readonly path: string
54
+ readonly expires?: Date
55
+ readonly maxAge?: number
56
+ }
57
+
58
+ function base(secure: boolean): Omit<CookieAttrs, 'expires'> {
59
+ return { httpOnly: true, secure, sameSite: 'lax', path: '/' }
60
+ }
61
+
62
+ export function sessionCookie(expires: Date, secure: boolean): CookieAttrs {
63
+ return { ...base(secure), expires }
64
+ }
65
+
66
+ export function rememberCookie(expires: Date, secure: boolean): CookieAttrs {
67
+ return { ...base(secure), expires }
68
+ }
69
+
70
+ export function guestCookie(expires: Date, secure: boolean): CookieAttrs {
71
+ return { ...base(secure), expires }
72
+ }
73
+
74
+ export function adminCookie(expires: Date, secure: boolean): CookieAttrs {
75
+ return {
76
+ httpOnly: true,
77
+ secure,
78
+ sameSite: 'strict',
79
+ path: ADMIN_COOKIE_PATH,
80
+ expires,
81
+ }
82
+ }
83
+
84
+ export function clearedAdminCookie(secure: boolean): CookieAttrs {
85
+ return {
86
+ httpOnly: true,
87
+ secure,
88
+ sameSite: 'strict',
89
+ path: ADMIN_COOKIE_PATH,
90
+ expires: new Date(0),
91
+ maxAge: 0,
92
+ }
93
+ }
94
+
95
+ export function clearedCookie(secure: boolean): CookieAttrs {
96
+ return { ...base(secure), expires: new Date(0), maxAge: 0 }
97
+ }
98
+
99
+ export function ssoCookieName(secure: boolean): string {
100
+ return secure ? SSO_COOKIE : DEV_SSO_COOKIE
101
+ }
102
+
103
+ export function passkeyCookieName(secure: boolean): string {
104
+ return secure ? PASSKEY_COOKIE : DEV_PASSKEY_COOKIE
105
+ }
106
+
107
+ export function secondFactorCookieName(secure: boolean): string {
108
+ return secure ? SECOND_FACTOR_COOKIE : DEV_SECOND_FACTOR_COOKIE
109
+ }
110
+
111
+ /**
112
+ * A sign-in that has proved its password and nothing else. Strict, because
113
+ * every step that finishes it starts on this board — and short, because it
114
+ * stands in for a password already given.
115
+ */
116
+ export function secondFactorCookie(expires: Date, secure: boolean): CookieAttrs {
117
+ return { httpOnly: true, secure, sameSite: 'strict', path: '/', expires }
118
+ }
119
+
120
+ /**
121
+ * Lax, not Strict: the identity provider sends the member back with a top-level
122
+ * GET, and a Strict cookie is not attached to a navigation that started on
123
+ * another site — the handshake would arrive with nothing to check the state
124
+ * against and every sign-in would fail.
125
+ */
126
+ export function ssoCookie(secure: boolean): CookieAttrs {
127
+ return { ...base(secure), maxAge: HANDSHAKE_MINUTES * 60 }
128
+ }
129
+
130
+ /** Strict: nothing in the passkey exchange ever leaves this board. */
131
+ export function passkeyCookie(secure: boolean): CookieAttrs {
132
+ return {
133
+ httpOnly: true,
134
+ secure,
135
+ sameSite: 'strict',
136
+ path: '/',
137
+ maxAge: HANDSHAKE_MINUTES * 60,
138
+ }
139
+ }
@@ -0,0 +1,42 @@
1
+ 'use server'
2
+
3
+ import { redirect } from 'next/navigation'
4
+
5
+ import { verifyPassword } from '@meith/accounts'
6
+
7
+ import type { FormState } from './auth-form-state'
8
+ import { getContainer } from './container'
9
+ import { getActor } from './context'
10
+ import { markCurrentSessionCredentialProved } from './credential-proof'
11
+ import { text } from './form-values'
12
+ import { tr } from './i18n'
13
+ import { isSafeLocalPath } from './safe-path'
14
+ import { twoFactorService } from './two-factor'
15
+
16
+ export async function proveCredentialAction(_prev: FormState, form: FormData): Promise<FormState> {
17
+ const actor = await getActor()
18
+ if (actor.userId === null) return { error: await tr('credentialProof.signIn') }
19
+
20
+ const method = text(form, 'method')
21
+ let proved = false
22
+
23
+ if (method === 'password') {
24
+ const account = await getContainer().accountStore.accounts.findById(actor.userId)
25
+ proved = await verifyPassword(text(form, 'password'), account?.passwordHash)
26
+ } else if (method === 'code') {
27
+ const service = twoFactorService()
28
+ const outcome =
29
+ service === null
30
+ ? null
31
+ : await service.verify({ userId: actor.userId, code: text(form, 'code') })
32
+ proved = outcome?.status === 'ok'
33
+ }
34
+
35
+ if (!proved) return { error: await tr('credentialProof.incorrect') }
36
+ if (!(await markCurrentSessionCredentialProved(actor.userId))) {
37
+ return { error: await tr('credentialProof.expired') }
38
+ }
39
+
40
+ const next = text(form, 'next')
41
+ redirect(isSafeLocalPath(next) ? next : '/usercp/security?verified=1')
42
+ }
@@ -0,0 +1,35 @@
1
+ import 'server-only'
2
+
3
+ import { hasFreshCredentialProof, hashToken, type SessionRecord } from '@meith/accounts'
4
+
5
+ import { getContainer } from './container'
6
+ import { readSessionToken } from './session-cookies'
7
+
8
+ export interface CurrentCredentialProof {
9
+ readonly session: SessionRecord
10
+ readonly provedAt: Date
11
+ }
12
+
13
+ export async function currentCredentialProof(
14
+ userId: number,
15
+ now = new Date(),
16
+ ): Promise<CurrentCredentialProof | null> {
17
+ const session = await currentSessionRecord()
18
+ if (session === null || !hasFreshCredentialProof(session, userId, now)) return null
19
+ return { session, provedAt: session.credentialProvedAt! }
20
+ }
21
+
22
+ export async function markCurrentSessionCredentialProved(
23
+ userId: number,
24
+ at = new Date(),
25
+ ): Promise<boolean> {
26
+ const session = await currentSessionRecord()
27
+ if (session === null || session.userId !== userId) return false
28
+ return getContainer().accountStore.sessions.markCredentialProved(session.id, userId, at)
29
+ }
30
+
31
+ async function currentSessionRecord(): Promise<SessionRecord | null> {
32
+ const token = await readSessionToken()
33
+ if (token === undefined || token === '') return null
34
+ return getContainer().accountStore.sessions.findByTokenHash(await hashToken(token))
35
+ }
@@ -0,0 +1,19 @@
1
+ import 'server-only'
2
+
3
+ import { headers } from 'next/headers'
4
+ import { cache } from 'react'
5
+
6
+ import { PATH_HEADER, QUERY_HEADER } from './location-header'
7
+
8
+ export const currentLocation = cache(async (): Promise<string> => {
9
+ try {
10
+ const jar = await headers()
11
+ const path = jar.get(PATH_HEADER)
12
+ if (typeof path !== 'string' || !path.startsWith('/')) return ''
13
+
14
+ const query = jar.get(QUERY_HEADER)
15
+ return typeof query === 'string' && query.startsWith('?') ? `${path}${query}` : path
16
+ } catch {
17
+ return ''
18
+ }
19
+ })
@@ -0,0 +1,104 @@
1
+ import 'server-only'
2
+
3
+ import { env, logger, readPluginEnv } from '@meith/core'
4
+ import {
5
+ FAKE_STRIPE_MOUNT,
6
+ type FakeStripeRequest,
7
+ type FakeStripeResponse,
8
+ fakeStripe,
9
+ } from '@meith/demo'
10
+ import { signStripePayload } from '@meith/plugin-dues'
11
+ import { resolvePluginSettings } from '@meith/plugin-kit'
12
+
13
+ import { boardUrl } from './board-url'
14
+ import { activeDefinitions } from './plugin-host'
15
+ import { dispatchPluginRoute } from './plugin-routes'
16
+ import { getSettingOverrides } from './settings'
17
+
18
+ const DUES = 'dues'
19
+
20
+ const SETTLED = new Set(['granted', 'already-settled', 'paid-but-grant-refused'])
21
+
22
+ interface Global {
23
+ __meithFakeStripe?: Map<string, ReturnType<typeof fakeStripe>>
24
+ }
25
+
26
+ function instance(publicBase: string): ReturnType<typeof fakeStripe> {
27
+ const store = (globalThis as Global).__meithFakeStripe ?? new Map()
28
+ ;(globalThis as Global).__meithFakeStripe = store
29
+
30
+ const existing = store.get(publicBase)
31
+ if (existing !== undefined) return existing
32
+
33
+ const made = fakeStripe({ publicBase })
34
+ store.set(publicBase, made)
35
+ return made
36
+ }
37
+
38
+ export async function handleFakeStripe(request: Request, path: string): Promise<Response> {
39
+ if (!env.DEMO_MODE) return new Response('Not found', { status: 404 })
40
+
41
+ const url = new URL(request.url)
42
+ const call: FakeStripeRequest = {
43
+ method: request.method,
44
+ path: path === '' ? '/' : `/${path}`,
45
+ body: request.method === 'POST' ? await request.text() : '',
46
+ query: url.searchParams,
47
+ }
48
+
49
+ const outcome = instance(`${await boardUrl()}${FAKE_STRIPE_MOUNT}`).handle(call)
50
+ if (outcome.event !== null) await deliver(outcome.event)
51
+ return respond(outcome.response)
52
+ }
53
+
54
+ function respond(response: FakeStripeResponse): Response {
55
+ if (response.kind === 'redirect') {
56
+ return new Response(null, { status: 303, headers: { location: response.to } })
57
+ }
58
+
59
+ const contentType =
60
+ response.kind === 'json' ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8'
61
+ const body = response.kind === 'json' ? JSON.stringify(response.body) : response.body
62
+
63
+ return new Response(body, {
64
+ status: response.status,
65
+ headers: { 'content-type': contentType, 'cache-control': 'no-store' },
66
+ })
67
+ }
68
+
69
+ async function deliver(event: unknown): Promise<void> {
70
+ const definition = activeDefinitions().find((candidate) => candidate.key === DUES)
71
+ if (definition === undefined) return
72
+
73
+ const settings = resolvePluginSettings(definition, await getSettingOverrides(), readPluginEnv)
74
+ const secret = String(settings.stripe_webhook_secret ?? '')
75
+ if (secret === '') {
76
+ logger().warn('the demo Stripe has no webhook secret to sign with')
77
+ return
78
+ }
79
+
80
+ const body = JSON.stringify(event)
81
+ const signature = signStripePayload(body, secret, Math.floor(Date.now() / 1000))
82
+ const board = await boardUrl()
83
+
84
+ const response = await dispatchPluginRoute(
85
+ new Request(`${board}/api/plugins/${DUES}/hook/stripe`, {
86
+ method: 'POST',
87
+ body,
88
+ headers: { 'content-type': 'application/json', 'stripe-signature': signature },
89
+ }),
90
+ DUES,
91
+ ['hook', 'stripe'],
92
+ )
93
+
94
+ const outcome = response.ok
95
+ ? String(((await response.json()) as { outcome?: unknown }).outcome ?? 'unknown')
96
+ : 'rejected'
97
+
98
+ if (!SETTLED.has(outcome)) {
99
+ logger().warn(
100
+ { status: response.status, outcome },
101
+ 'the demo Stripe delivered a webhook the board did not act on',
102
+ )
103
+ }
104
+ }
@@ -0,0 +1,28 @@
1
+ import 'server-only'
2
+
3
+ import { rm } from 'node:fs/promises'
4
+
5
+ import { env, logger } from '@meith/core'
6
+
7
+ /**
8
+ * Everything a visitor uploaded, thrown away with the board that referenced it.
9
+ *
10
+ * Only local storage is cleared. A demo pointed at an object store is not a
11
+ * configuration this project asks anyone to run, and quietly issuing bulk
12
+ * deletes against a bucket somebody else's board might share is a worse failure
13
+ * than leaving files behind.
14
+ *
15
+ * Kept apart from the rest of the demo helpers because the container calls this
16
+ * one, and the others call the container.
17
+ */
18
+ export async function clearUploadedFiles(): Promise<void> {
19
+ if (env.FILESTORE_DRIVER !== 'local') {
20
+ logger({ module: 'demo' }).warn(
21
+ { driver: env.FILESTORE_DRIVER },
22
+ 'demo reset left uploads in place: only the local file store is cleared',
23
+ )
24
+ return
25
+ }
26
+
27
+ await rm(env.UPLOADS_DIR, { recursive: true, force: true })
28
+ }
@@ -0,0 +1,80 @@
1
+ import 'server-only'
2
+
3
+ import { env, logger } from '@meith/core'
4
+ import { getDb } from '@meith/db'
5
+ import {
6
+ assertDemoAccountIsChangeable,
7
+ type DemoBanner,
8
+ demoBanner,
9
+ type FrozenField,
10
+ nextDemoResetAt,
11
+ } from '@meith/demo'
12
+
13
+ import { getContainer } from './container'
14
+
15
+ /**
16
+ * Refuses a change to a published demo login's password or email.
17
+ *
18
+ * Checked by user id, resolved to a username here, because the demo package
19
+ * cannot know the ids — they are handed out by Postgres at seed time and differ
20
+ * every reset. The username is the stable thing.
21
+ */
22
+ export async function assertDemoAccountChangeable(
23
+ userId: number,
24
+ what: FrozenField,
25
+ ): Promise<void> {
26
+ if (!env.DEMO_MODE) return
27
+
28
+ const account = await getContainer().accountStore.accounts.findById(userId)
29
+ if (account === null) return
30
+
31
+ assertDemoAccountIsChangeable(account.username, what)
32
+ }
33
+
34
+ /**
35
+ * The administrator's own edit screen can rename a member, and renaming
36
+ * `admin` is the same lockout as changing its password by a longer route — the
37
+ * banner would go on naming a login that no longer exists. Only refused when
38
+ * the value actually differs, so an admin can still open the demo login's
39
+ * account screen and change its groups.
40
+ */
41
+ export async function assertDemoIdentityUnchanged(
42
+ userId: number,
43
+ submitted: { readonly username: string; readonly email: string },
44
+ ): Promise<void> {
45
+ if (!env.DEMO_MODE) return
46
+
47
+ const account = await getContainer().accountStore.accounts.findById(userId)
48
+ if (account === null) return
49
+
50
+ if (submitted.username.trim().toLowerCase() !== account.usernameLower) {
51
+ assertDemoAccountIsChangeable(account.username, 'username')
52
+ }
53
+ if (submitted.email.trim().toLowerCase() !== account.emailLower) {
54
+ assertDemoAccountIsChangeable(account.username, 'email')
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The banner's content, or `null` on any board that is not a demo.
60
+ *
61
+ * Failure here is deliberately quiet. The banner is an aid, and a board that
62
+ * refuses to render because it could not work out when it next resets is worse
63
+ * than a board that renders without the countdown.
64
+ */
65
+ export async function demoBannerModel(): Promise<DemoBanner | null> {
66
+ if (!env.DEMO_MODE) return null
67
+
68
+ try {
69
+ return demoBanner({
70
+ nextResetAt: await nextDemoResetAt(getDb()),
71
+ now: new Date(),
72
+ })
73
+ } catch (error) {
74
+ logger({ module: 'demo' }).warn(
75
+ { err: String(error) },
76
+ 'could not read the next demo reset time',
77
+ )
78
+ return demoBanner({ nextResetAt: null, now: new Date() })
79
+ }
80
+ }
@@ -0,0 +1,104 @@
1
+ import { msg } from '@meith/i18n'
2
+ import 'server-only'
3
+
4
+ import type { Actor } from '@meith/authorization'
5
+ import { contentScopeFrom, ForbiddenError } from '@meith/core'
6
+ import {
7
+ type DiscoveryPage,
8
+ type DiscoveryScope,
9
+ getDb,
10
+ PostgresDiscoveryRepository,
11
+ } from '@meith/db'
12
+
13
+ import { getContainer } from './container'
14
+
15
+ export const DISCOVER_PAGE = 20
16
+
17
+ export const DISCOVERY_VIEWS = ['new', 'today', 'mine', 'participated', 'unanswered'] as const
18
+ export type DiscoveryView = (typeof DISCOVERY_VIEWS)[number]
19
+
20
+ export function isDiscoveryView(value: string): value is DiscoveryView {
21
+ return (DISCOVERY_VIEWS as readonly string[]).includes(value)
22
+ }
23
+
24
+ export function discoveryRepository(): PostgresDiscoveryRepository | null {
25
+ return getContainer().dataSource === 'postgres' ? new PostgresDiscoveryRepository(getDb()) : null
26
+ }
27
+
28
+ export async function discoveryScopeFor(actor: Actor): Promise<DiscoveryScope> {
29
+ const { authorizer } = getContainer()
30
+ const staff = actor.global.isAdministrator === true || actor.global.isSuperModerator === true
31
+
32
+ return {
33
+ ...(await authorizer.threadAudience(actor)),
34
+ content: contentScopeFrom({ seesUnapproved: staff, seesDeleted: staff }),
35
+ }
36
+ }
37
+
38
+ export async function runDiscovery(input: {
39
+ readonly actor: Actor
40
+ readonly view: DiscoveryView
41
+ readonly now: Date
42
+ readonly timeZone: string
43
+ readonly after: DiscoveryPage['nextCursor']
44
+ }): Promise<DiscoveryPage> {
45
+ const repo = discoveryRepository()
46
+ if (repo === null) throw new ForbiddenError(msg('error.app.board-thread-index'))
47
+
48
+ const scope = await discoveryScopeFor(input.actor)
49
+ const query = { limit: DISCOVER_PAGE, after: input.after }
50
+
51
+ switch (input.view) {
52
+ case 'today':
53
+ return repo.activeSince(startOfDay(input.now, input.timeZone), query, scope)
54
+ case 'unanswered':
55
+ return repo.unanswered(query, scope)
56
+ case 'mine':
57
+ if (input.actor.userId === null) throw new ForbiddenError(msg('error.app.sign-see-threads'))
58
+ return repo.startedBy(input.actor.userId, query, scope)
59
+ case 'participated':
60
+ if (input.actor.userId === null) throw new ForbiddenError(msg('error.app.sign-see-threads'))
61
+ return repo.participatedIn(input.actor.userId, query, scope)
62
+ // biome-ignore lint/complexity/noUselessSwitchCase: naming the view keeps the switch exhaustive against the DiscoveryView union
63
+ case 'new':
64
+ default:
65
+ return repo.activeSince(new Date(input.now.getTime() - 86_400_000), query, scope)
66
+ }
67
+ }
68
+
69
+ export function startOfDay(now: Date, timeZone: string): Date {
70
+ try {
71
+ const parts = new Intl.DateTimeFormat('en-CA', {
72
+ timeZone,
73
+ year: 'numeric',
74
+ month: '2-digit',
75
+ day: '2-digit',
76
+ }).formatToParts(now)
77
+
78
+ const get = (type: string): string => parts.find((part) => part.type === type)?.value ?? '01'
79
+
80
+ const local = new Date(`${get('year')}-${get('month')}-${get('day')}T00:00:00Z`)
81
+ const offset = local.getTime() - zonedInstant(local, timeZone)
82
+ return new Date(local.getTime() + offset)
83
+ } catch {
84
+ return new Date(now.getTime() - 86_400_000)
85
+ }
86
+ }
87
+
88
+ function zonedInstant(date: Date, timeZone: string): number {
89
+ const parts = new Intl.DateTimeFormat('en-US', {
90
+ timeZone,
91
+ hour12: false,
92
+ year: 'numeric',
93
+ month: '2-digit',
94
+ day: '2-digit',
95
+ hour: '2-digit',
96
+ minute: '2-digit',
97
+ second: '2-digit',
98
+ }).formatToParts(date)
99
+
100
+ const get = (type: string): string => parts.find((part) => part.type === type)?.value ?? '00'
101
+ return Date.parse(
102
+ `${get('year')}-${get('month')}-${get('day')}T${get('hour') === '24' ? '00' : get('hour')}:${get('minute')}:${get('second')}Z`,
103
+ )
104
+ }
@@ -0,0 +1,113 @@
1
+ import 'server-only'
2
+
3
+ import { env } from '@meith/core'
4
+ import { escapeAttribute, escapeHtml, safeImageUrl, safeUrl, unescapeHtml } from '@meith/markdown'
5
+
6
+ interface Provider {
7
+ readonly name: string
8
+ readonly match: (url: URL) => boolean
9
+ readonly oembedUrl: (url: URL) => string
10
+ }
11
+
12
+ const PROVIDERS: readonly Provider[] = [
13
+ {
14
+ name: 'YouTube',
15
+ match: (url) =>
16
+ ['www.youtube.com', 'youtube.com', 'youtu.be'].includes(url.hostname) && url.pathname !== '/',
17
+ oembedUrl: (url) =>
18
+ `https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(url.href)}`,
19
+ },
20
+ {
21
+ name: 'Vimeo',
22
+ match: (url) => ['www.vimeo.com', 'vimeo.com'].includes(url.hostname) && url.pathname !== '/',
23
+ oembedUrl: (url) => `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(url.href)}`,
24
+ },
25
+ ]
26
+
27
+ const MAX_EMBEDS_PER_POST = 3
28
+ const FETCH_TIMEOUT_MS = 2_000
29
+
30
+ interface OEmbedResponse {
31
+ readonly title?: unknown
32
+ readonly author_name?: unknown
33
+ readonly thumbnail_url?: unknown
34
+ }
35
+
36
+ const LONE_LINK =
37
+ /<p><a href="([^"]*)" rel="nofollow ugc noopener noreferrer"(?: title="[^"]*")?>[^<]*<\/a><\/p>/g
38
+
39
+ async function fetchOEmbed(provider: Provider, url: URL): Promise<OEmbedResponse | null> {
40
+ try {
41
+ const response = await fetch(provider.oembedUrl(url), {
42
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
43
+ headers: { accept: 'application/json' },
44
+ })
45
+ if (!response.ok) return null
46
+ if (!(response.headers.get('content-type') ?? '').includes('application/json')) return null
47
+ return (await response.json()) as OEmbedResponse
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ function renderCard(provider: Provider, href: string, data: OEmbedResponse): string | null {
54
+ const link = safeUrl(href)
55
+ if (link === null) return null
56
+
57
+ const title = typeof data.title === 'string' && data.title !== '' ? data.title : provider.name
58
+ const author = typeof data.author_name === 'string' ? data.author_name : null
59
+ const thumbnail = typeof data.thumbnail_url === 'string' ? safeImageUrl(data.thumbnail_url) : null
60
+
61
+ const image =
62
+ thumbnail === null
63
+ ? ''
64
+ : `<img src="${escapeAttribute(thumbnail)}" alt="" loading="lazy" class="md-embed-thumb">`
65
+ const byline = author === null ? '' : `<span class="md-embed-author">${escapeHtml(author)}</span>`
66
+ const linkAttr = escapeAttribute(link)
67
+
68
+ return (
69
+ `<figure class="md-embed" data-provider="${escapeAttribute(provider.name.toLowerCase())}">` +
70
+ `<a href="${linkAttr}" rel="nofollow ugc noopener noreferrer">${image}</a>` +
71
+ `<figcaption>` +
72
+ `<a href="${linkAttr}" rel="nofollow ugc noopener noreferrer" class="md-embed-title">${escapeHtml(title)}</a>` +
73
+ `${byline}<span class="md-embed-provider">${escapeHtml(provider.name)}</span>` +
74
+ `</figcaption></figure>`
75
+ )
76
+ }
77
+
78
+ export async function unfurlEmbeds(html: string): Promise<string> {
79
+ if (!env.REMOTE_IMAGES || !html.includes('<a href=')) return html
80
+
81
+ const matches = [...html.matchAll(LONE_LINK)].slice(0, MAX_EMBEDS_PER_POST)
82
+ if (matches.length === 0) return html
83
+
84
+ const replacements = new Map<string, string>()
85
+
86
+ for (const match of matches) {
87
+ const whole = match[0]
88
+ if (replacements.has(whole)) continue
89
+
90
+ const href = unescapeHtml(match[1] ?? '')
91
+ let url: URL
92
+ try {
93
+ url = new URL(href)
94
+ } catch {
95
+ continue
96
+ }
97
+ if (url.protocol !== 'https:') continue
98
+
99
+ const provider = PROVIDERS.find((candidate) => candidate.match(url))
100
+ if (provider === undefined) continue
101
+
102
+ const data = await fetchOEmbed(provider, url)
103
+ if (data === null) continue
104
+
105
+ const card = renderCard(provider, href, data)
106
+ if (card === null) continue
107
+
108
+ replacements.set(whole, `${whole}${card}`)
109
+ }
110
+
111
+ if (replacements.size === 0) return html
112
+ return html.replace(LONE_LINK, (whole) => replacements.get(whole) ?? whole)
113
+ }