@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.
- package/LICENSE.md +165 -0
- package/app/(auth)/layout.tsx +6 -0
- package/app/(auth)/login/page.tsx +106 -0
- package/app/(auth)/login/verify/page.tsx +55 -0
- package/app/(auth)/register/page.tsx +83 -0
- package/app/(auth)/reset/confirm/page.tsx +45 -0
- package/app/(auth)/reset/page.tsx +22 -0
- package/app/(auth)/verify/page.tsx +44 -0
- package/app/(auth)/verify/resend/page.tsx +51 -0
- package/app/(board)/[slug]/feed.xml/route.ts +48 -0
- package/app/(board)/[slug]/new/page.tsx +112 -0
- package/app/(board)/[slug]/page.tsx +403 -0
- package/app/(board)/discover/[view]/page.tsx +115 -0
- package/app/(board)/discover/page.tsx +5 -0
- package/app/(board)/layout.tsx +22 -0
- package/app/(board)/member/[id]/page.tsx +187 -0
- package/app/(board)/member/[id]/reputation/page.tsx +195 -0
- package/app/(board)/member/by-name/[name]/page.tsx +32 -0
- package/app/(board)/messages/[id]/page.tsx +103 -0
- package/app/(board)/messages/compose/page.tsx +60 -0
- package/app/(board)/messages/layout.tsx +5 -0
- package/app/(board)/messages/page.tsx +179 -0
- package/app/(board)/modcp/forums/page.tsx +59 -0
- package/app/(board)/modcp/ip/page.tsx +158 -0
- package/app/(board)/modcp/layout.tsx +5 -0
- package/app/(board)/modcp/log/page.tsx +98 -0
- package/app/(board)/modcp/page.tsx +112 -0
- package/app/(board)/moderation/layout.tsx +5 -0
- package/app/(board)/moderation/page.tsx +111 -0
- package/app/(board)/moderation/reports/page.tsx +193 -0
- package/app/(board)/moderation/warn/page.tsx +158 -0
- package/app/(board)/notifications/layout.tsx +5 -0
- package/app/(board)/notifications/page.tsx +144 -0
- package/app/(board)/notifications/preferences/page.tsx +65 -0
- package/app/(board)/online/page.tsx +119 -0
- package/app/(board)/page.tsx +195 -0
- package/app/(board)/plugins/[key]/[[...path]]/page.tsx +72 -0
- package/app/(board)/privacy/page.tsx +12 -0
- package/app/(board)/report/page.tsx +71 -0
- package/app/(board)/rules/page.tsx +12 -0
- package/app/(board)/search/[token]/page.tsx +130 -0
- package/app/(board)/search/page.tsx +289 -0
- package/app/(board)/stats/page.tsx +202 -0
- package/app/(board)/subscriptions/layout.tsx +5 -0
- package/app/(board)/subscriptions/page.tsx +142 -0
- package/app/(board)/terms/page.tsx +12 -0
- package/app/(board)/thread/[slug]/edit/page.tsx +107 -0
- package/app/(board)/thread/[slug]/feed.xml/route.ts +42 -0
- package/app/(board)/thread/[slug]/page.tsx +707 -0
- package/app/(board)/thread/[slug]/poll/page.tsx +41 -0
- package/app/(board)/thread/[slug]/post/[postId]/history/page.tsx +160 -0
- package/app/(board)/thread/[slug]/reply/page.tsx +136 -0
- package/app/(board)/unsubscribe/page.tsx +73 -0
- package/app/(board)/usercp/avatar/page.tsx +56 -0
- package/app/(board)/usercp/contacts/page.tsx +141 -0
- package/app/(board)/usercp/email/confirm/page.tsx +52 -0
- package/app/(board)/usercp/layout.tsx +23 -0
- package/app/(board)/usercp/options/page.tsx +56 -0
- package/app/(board)/usercp/page.tsx +71 -0
- package/app/(board)/usercp/profile/page.tsx +81 -0
- package/app/(board)/usercp/security/page.tsx +238 -0
- package/app/(board)/usercp/security/verify/page.tsx +92 -0
- package/app/(board)/usercp/signature/page.tsx +67 -0
- package/app/admin/antispam/page.tsx +155 -0
- package/app/admin/api/marketplace/screenshot/route.ts +68 -0
- package/app/admin/api/plugins/[key]/[...path]/route.ts +27 -0
- package/app/admin/api-tokens/page.tsx +129 -0
- package/app/admin/content/announcements/page.tsx +110 -0
- package/app/admin/content/attachments/page.tsx +199 -0
- package/app/admin/content/navigation/page.tsx +123 -0
- package/app/admin/content/page.tsx +170 -0
- package/app/admin/forums/[id]/page.tsx +96 -0
- package/app/admin/forums/[id]/permissions/page.tsx +108 -0
- package/app/admin/forums/page.tsx +45 -0
- package/app/admin/groups/[id]/page.tsx +133 -0
- package/app/admin/groups/memberships/page.tsx +56 -0
- package/app/admin/groups/page.tsx +104 -0
- package/app/admin/groups/promotions/page.tsx +143 -0
- package/app/admin/layout.tsx +86 -0
- package/app/admin/log/page.tsx +116 -0
- package/app/admin/page.tsx +206 -0
- package/app/admin/plugins/[key]/[[...path]]/page.tsx +276 -0
- package/app/admin/plugins/browse/page.tsx +70 -0
- package/app/admin/plugins/page.tsx +165 -0
- package/app/admin/security/page.tsx +124 -0
- package/app/admin/settings/page.tsx +190 -0
- package/app/admin/system/page.tsx +303 -0
- package/app/admin/themes/[key]/page.tsx +110 -0
- package/app/admin/themes/browse/page.tsx +70 -0
- package/app/admin/themes/page.tsx +163 -0
- package/app/admin/users/[id]/merge/page.tsx +136 -0
- package/app/admin/users/[id]/page.tsx +208 -0
- package/app/admin/users/mail/page.tsx +68 -0
- package/app/admin/users/page.tsx +260 -0
- package/app/admin/users/prune/page.tsx +186 -0
- package/app/api/health/route.ts +11 -0
- package/app/api/metrics/route.ts +66 -0
- package/app/api/plugins/[key]/[...path]/route.ts +27 -0
- package/app/api/read/all/route.ts +15 -0
- package/app/api/read/forum/[id]/route.ts +29 -0
- package/app/api/read/thread/[id]/route.ts +40 -0
- package/app/api/ready/route.ts +63 -0
- package/app/api/system/tick/route.ts +77 -0
- package/app/api/v1/[...path]/route.ts +208 -0
- package/app/api/v1/openapi.json/route.ts +12 -0
- package/app/apple-icon.ts +11 -0
- package/app/atom.xml/route.ts +7 -0
- package/app/attachment/[id]/route.ts +10 -0
- package/app/attachment/[id]/thumb/route.ts +10 -0
- package/app/auth/passkey/options/route.ts +145 -0
- package/app/auth/passkey/verify/route.ts +241 -0
- package/app/auth/resume/route.ts +55 -0
- package/app/auth/sso/[provider]/callback/route.ts +173 -0
- package/app/auth/sso/[provider]/go/route.ts +57 -0
- package/app/auth/sso/[provider]/route.ts +105 -0
- package/app/avatar/[id]/route.ts +31 -0
- package/app/brand/icon-192.png/route.ts +7 -0
- package/app/brand/icon-512.png/route.ts +7 -0
- package/app/brand/icon-maskable.png/route.ts +7 -0
- package/app/demo/stripe/[[...path]]/route.ts +27 -0
- package/app/error.tsx +15 -0
- package/app/feed.xml/route.ts +7 -0
- package/app/forumdisplay.php/page.tsx +11 -0
- package/app/group/[id]/badge/[scheme]/route.ts +26 -0
- package/app/icon.ts +13 -0
- package/app/install/page.tsx +221 -0
- package/app/jump/page.tsx +28 -0
- package/app/layout.tsx +124 -0
- package/app/logo/[scheme]/route.ts +24 -0
- package/app/manifest.ts +43 -0
- package/app/member.php/page.tsx +11 -0
- package/app/memberlist.php/page.tsx +11 -0
- package/app/not-found.tsx +14 -0
- package/app/opengraph-image.ts +11 -0
- package/app/redirect/page.tsx +44 -0
- package/app/robots.txt/route.ts +35 -0
- package/app/showthread.php/page.tsx +11 -0
- package/app/sitemap/[page]/route.ts +64 -0
- package/app/sitemap.xml/route.ts +31 -0
- package/app/twitter-image.ts +11 -0
- package/app/viewforum.php/page.tsx +11 -0
- package/app/viewtopic.php/page.tsx +11 -0
- package/bin/forum-web.mjs +241 -0
- package/components.json +21 -0
- package/instrumentation.ts +18 -0
- package/next.config.mjs +182 -0
- package/package.json +99 -0
- package/postcss.config.mjs +7 -0
- package/proxy.ts +160 -0
- package/src/components/account/active-sessions.tsx +87 -0
- package/src/components/account/avatar-form.tsx +119 -0
- package/src/components/account/credential-proof-form.tsx +76 -0
- package/src/components/account/logout-form.tsx +14 -0
- package/src/components/account/notification-forms.tsx +157 -0
- package/src/components/account/passkey-enrol.tsx +81 -0
- package/src/components/account/passkey-proof-button.tsx +47 -0
- package/src/components/account/push-device-form.tsx +193 -0
- package/src/components/account/relation-forms.tsx +69 -0
- package/src/components/account/reputation-forms.tsx +124 -0
- package/src/components/account/security-activity.tsx +46 -0
- package/src/components/account/sign-in-methods.tsx +166 -0
- package/src/components/account/subscription-forms.tsx +151 -0
- package/src/components/account/two-factor-forms.tsx +226 -0
- package/src/components/account/usercp-forms.tsx +387 -0
- package/src/components/account/usercp-nav.tsx +14 -0
- package/src/components/account/usercp-shell.tsx +34 -0
- package/src/components/admin/admin-forms.tsx +92 -0
- package/src/components/admin/admin-nav.tsx +52 -0
- package/src/components/admin/admin-undo.tsx +32 -0
- package/src/components/admin/api-token-forms.tsx +89 -0
- package/src/components/admin/badge-forms.tsx +97 -0
- package/src/components/admin/branding-forms.tsx +102 -0
- package/src/components/admin/content-forms.tsx +712 -0
- package/src/components/admin/form-bits.tsx +11 -0
- package/src/components/admin/forum-forms.tsx +524 -0
- package/src/components/admin/forum-tree.tsx +454 -0
- package/src/components/admin/group-forms.tsx +646 -0
- package/src/components/admin/mail-test-card.tsx +90 -0
- package/src/components/admin/marketplace-forms.tsx +30 -0
- package/src/components/admin/marketplace-listing.tsx +123 -0
- package/src/components/admin/navigation-forms.tsx +229 -0
- package/src/components/admin/navigation-tree.tsx +442 -0
- package/src/components/admin/oklch-picker.tsx +180 -0
- package/src/components/admin/plugin-forms.tsx +225 -0
- package/src/components/admin/settings-form.tsx +148 -0
- package/src/components/admin/system-forms.tsx +169 -0
- package/src/components/admin/theme-changes.tsx +354 -0
- package/src/components/admin/theme-forms.tsx +636 -0
- package/src/components/admin/theme-palette.tsx +119 -0
- package/src/components/admin/theme-preview.tsx +461 -0
- package/src/components/admin/user-bulk-toolbar.tsx +99 -0
- package/src/components/admin/user-forms.tsx +488 -0
- package/src/components/auth/auth-page.tsx +45 -0
- package/src/components/auth/form-controls.tsx +117 -0
- package/src/components/auth/login-form.tsx +47 -0
- package/src/components/auth/passkey-client.ts +171 -0
- package/src/components/auth/passkey-second-factor.tsx +69 -0
- package/src/components/auth/passkey-sign-in.tsx +61 -0
- package/src/components/auth/register-form.tsx +136 -0
- package/src/components/auth/resend-verification-form.tsx +34 -0
- package/src/components/auth/reset-confirm-form.tsx +43 -0
- package/src/components/auth/reset-request-form.tsx +35 -0
- package/src/components/auth/second-factor-form.tsx +51 -0
- package/src/components/auth/sso-buttons.tsx +47 -0
- package/src/components/board/live-region.tsx +74 -0
- package/src/components/board/search-off-notice.tsx +18 -0
- package/src/components/board/section-page.tsx +114 -0
- package/src/components/content/attachment-field.tsx +51 -0
- package/src/components/content/composer-field.ts +31 -0
- package/src/components/content/composer-ids.ts +6 -0
- package/src/components/content/composer-intents.tsx +27 -0
- package/src/components/content/composer-recovery.tsx +215 -0
- package/src/components/content/edit-post-form.tsx +121 -0
- package/src/components/content/markdown-editor.tsx +590 -0
- package/src/components/content/markdown-syntax.ts +53 -0
- package/src/components/content/mention-token.ts +23 -0
- package/src/components/content/multiquote-button.tsx +32 -0
- package/src/components/content/multiquote-selection.tsx +105 -0
- package/src/components/content/multiquote.ts +125 -0
- package/src/components/content/new-thread-form.tsx +169 -0
- package/src/components/content/poll-edit-form.tsx +96 -0
- package/src/components/content/poll.tsx +89 -0
- package/src/components/content/quote-in-place.tsx +51 -0
- package/src/components/content/reply-form.tsx +113 -0
- package/src/components/content/thanks-button.tsx +65 -0
- package/src/components/content/thread-rating.tsx +143 -0
- package/src/components/install/install-form.tsx +497 -0
- package/src/components/legal/legal-document.tsx +22 -0
- package/src/components/messages/message-forms.tsx +152 -0
- package/src/components/moderation/inline-moderation-form.tsx +158 -0
- package/src/components/moderation/modcp-nav.tsx +15 -0
- package/src/components/moderation/modcp-shell.tsx +41 -0
- package/src/components/moderation/queue-form.tsx +96 -0
- package/src/components/moderation/report-forms.tsx +120 -0
- package/src/components/moderation/signature-lock-form.tsx +83 -0
- package/src/components/moderation/thread-surgery-form.tsx +96 -0
- package/src/components/moderation/thread-tools-form.tsx +137 -0
- package/src/components/moderation/warning-forms.tsx +125 -0
- package/src/components/profile/custom-field.tsx +79 -0
- package/src/components/shell/appearance-icons.tsx +35 -0
- package/src/components/shell/board-notice.tsx +21 -0
- package/src/components/shell/copy-record.ts +20 -0
- package/src/components/shell/copy.tsx +17 -0
- package/src/components/shell/crash-notice.tsx +86 -0
- package/src/components/shell/demo-banner.tsx +49 -0
- package/src/components/shell/group-name-style.tsx +6 -0
- package/src/components/shell/measure.ts +1 -0
- package/src/components/shell/notification-menu.tsx +361 -0
- package/src/components/shell/offline-notice.tsx +29 -0
- package/src/components/shell/onboarding-banner.tsx +39 -0
- package/src/components/shell/page-shell.tsx +167 -0
- package/src/components/shell/panel-list.tsx +95 -0
- package/src/components/shell/panel-nav.tsx +40 -0
- package/src/components/shell/panel-overview.tsx +81 -0
- package/src/components/shell/panel-page.tsx +96 -0
- package/src/components/shell/panel-pagination.tsx +28 -0
- package/src/components/shell/panel-shell.tsx +31 -0
- package/src/components/shell/theme-runtime-style.tsx +6 -0
- package/src/components/shell/theme-switcher.tsx +97 -0
- package/src/components/shell/timezone-probe.tsx +45 -0
- package/src/components/shell/view-tabs.tsx +60 -0
- package/src/config.ts +13 -0
- package/src/server/absent-services.ts +29 -0
- package/src/server/admin-actions.ts +171 -0
- package/src/server/admin-settings-actions.ts +83 -0
- package/src/server/admin-undo-actions.ts +70 -0
- package/src/server/admin-undo.ts +75 -0
- package/src/server/admin.ts +112 -0
- package/src/server/announcements.ts +82 -0
- package/src/server/antispam.ts +310 -0
- package/src/server/api/content.ts +486 -0
- package/src/server/api/http.ts +119 -0
- package/src/server/api/members.ts +109 -0
- package/src/server/api/messages.ts +113 -0
- package/src/server/api/registry.ts +30 -0
- package/src/server/api/search.ts +98 -0
- package/src/server/api/subscriptions.ts +106 -0
- package/src/server/api-auth.ts +66 -0
- package/src/server/api-token-actions.ts +85 -0
- package/src/server/api-tokens-admin.ts +95 -0
- package/src/server/appearance-actions.ts +39 -0
- package/src/server/attachment-download.ts +43 -0
- package/src/server/attachment-embed.ts +63 -0
- package/src/server/attachment-upload-actions.ts +92 -0
- package/src/server/attachments.ts +257 -0
- package/src/server/auth-actions.ts +479 -0
- package/src/server/auth-config.ts +61 -0
- package/src/server/auth-events.ts +92 -0
- package/src/server/auth-form-state.ts +14 -0
- package/src/server/auth-mail.ts +104 -0
- package/src/server/avatars.ts +83 -0
- package/src/server/board-latest-actions.ts +7 -0
- package/src/server/board-latest.tsx +103 -0
- package/src/server/board-offline.ts +23 -0
- package/src/server/board-url.ts +14 -0
- package/src/server/brand-assets.ts +151 -0
- package/src/server/brand-mark.tsx +110 -0
- package/src/server/branding-actions.ts +53 -0
- package/src/server/branding.ts +101 -0
- package/src/server/cache-targets.ts +13 -0
- package/src/server/code-highlighting.ts +33 -0
- package/src/server/container.ts +402 -0
- package/src/server/content-actions.ts +459 -0
- package/src/server/content-admin-actions.ts +492 -0
- package/src/server/content-admin.ts +95 -0
- package/src/server/content-security-policy.ts +35 -0
- package/src/server/context.ts +32 -0
- package/src/server/cookies.ts +139 -0
- package/src/server/credential-proof-actions.ts +42 -0
- package/src/server/credential-proof.ts +35 -0
- package/src/server/current-location.ts +19 -0
- package/src/server/demo-stripe.ts +104 -0
- package/src/server/demo-uploads.ts +28 -0
- package/src/server/demo.ts +80 -0
- package/src/server/discovery.ts +104 -0
- package/src/server/embeds.ts +113 -0
- package/src/server/error-notice.tsx +41 -0
- package/src/server/federation-actions.ts +113 -0
- package/src/server/federation.ts +138 -0
- package/src/server/feed-builder.ts +72 -0
- package/src/server/feed-routes.ts +122 -0
- package/src/server/fixture-actor-source.ts +62 -0
- package/src/server/fixture-forum-repo.ts +59 -0
- package/src/server/fixture-member-profile-repo.ts +30 -0
- package/src/server/fixture-post-repo.ts +108 -0
- package/src/server/fixture-thread-repo.ts +98 -0
- package/src/server/form-state-reporter.ts +34 -0
- package/src/server/form-values.ts +23 -0
- package/src/server/forum-admin-actions.ts +353 -0
- package/src/server/forum-admin.ts +98 -0
- package/src/server/group-admin-actions.ts +341 -0
- package/src/server/group-admin.ts +98 -0
- package/src/server/group-badge-actions.ts +61 -0
- package/src/server/group-badge.ts +62 -0
- package/src/server/group-identity.ts +104 -0
- package/src/server/i18n-catalogs.ts +42 -0
- package/src/server/i18n.ts +69 -0
- package/src/server/image-upload.ts +109 -0
- package/src/server/inline-moderation-actions.ts +129 -0
- package/src/server/install-actions.ts +124 -0
- package/src/server/install.ts +281 -0
- package/src/server/legacy-redirect.ts +83 -0
- package/src/server/legal.ts +43 -0
- package/src/server/location-header.ts +37 -0
- package/src/server/mail-brand.ts +43 -0
- package/src/server/mail-health.ts +41 -0
- package/src/server/mail-send.ts +44 -0
- package/src/server/mail-test-actions.ts +67 -0
- package/src/server/mail-test.ts +56 -0
- package/src/server/markdown-pipeline.ts +31 -0
- package/src/server/marketplace-actions.ts +47 -0
- package/src/server/marketplace-admin.ts +260 -0
- package/src/server/mention-actions.ts +21 -0
- package/src/server/mention-search.ts +35 -0
- package/src/server/message-actions.ts +110 -0
- package/src/server/messages.ts +141 -0
- package/src/server/modcp.ts +125 -0
- package/src/server/moderation-actions.ts +165 -0
- package/src/server/navigation-admin-actions.ts +217 -0
- package/src/server/navigation.ts +112 -0
- package/src/server/nonce.ts +13 -0
- package/src/server/notification-actions.ts +84 -0
- package/src/server/notification-audience.ts +10 -0
- package/src/server/notification-menu-actions.ts +82 -0
- package/src/server/notification-menu.ts +96 -0
- package/src/server/notifications.ts +93 -0
- package/src/server/onboarding-actions.ts +27 -0
- package/src/server/onboarding.ts +89 -0
- package/src/server/page-metadata.ts +14 -0
- package/src/server/passkey-challenge.ts +42 -0
- package/src/server/plugin-admin-actions.ts +232 -0
- package/src/server/plugin-admin.ts +276 -0
- package/src/server/plugin-host.ts +143 -0
- package/src/server/plugin-pages.ts +192 -0
- package/src/server/plugin-panel.ts +49 -0
- package/src/server/plugin-routes.ts +298 -0
- package/src/server/plugin-view.tsx +58 -0
- package/src/server/poll-actions.ts +104 -0
- package/src/server/poll-scope.ts +52 -0
- package/src/server/post-notifications.ts +71 -0
- package/src/server/post-scope.ts +86 -0
- package/src/server/presence.ts +177 -0
- package/src/server/profile-fields.ts +112 -0
- package/src/server/push-actions.ts +113 -0
- package/src/server/push.ts +40 -0
- package/src/server/redirect-back.ts +15 -0
- package/src/server/registration.ts +7 -0
- package/src/server/relation-actions.ts +89 -0
- package/src/server/relations.ts +45 -0
- package/src/server/reply-core.ts +173 -0
- package/src/server/report-actions.ts +165 -0
- package/src/server/report-scope.ts +21 -0
- package/src/server/reputation-actions.ts +146 -0
- package/src/server/reputation-target.ts +49 -0
- package/src/server/reputation.ts +70 -0
- package/src/server/request-fingerprint.ts +65 -0
- package/src/server/safe-path.ts +16 -0
- package/src/server/same-origin.ts +45 -0
- package/src/server/search-page.ts +313 -0
- package/src/server/search.ts +45 -0
- package/src/server/secret-auth.ts +20 -0
- package/src/server/see-other.ts +11 -0
- package/src/server/seed-board.ts +417 -0
- package/src/server/session-actions.ts +78 -0
- package/src/server/session-cookies.ts +124 -0
- package/src/server/session-key.ts +17 -0
- package/src/server/settings.ts +47 -0
- package/src/server/signatures.ts +62 -0
- package/src/server/sso-handshake.ts +79 -0
- package/src/server/stats.ts +63 -0
- package/src/server/subscription-actions.ts +161 -0
- package/src/server/surgery-actions.ts +180 -0
- package/src/server/syndication.ts +77 -0
- package/src/server/system-admin-actions.ts +134 -0
- package/src/server/system-admin.ts +90 -0
- package/src/server/test-container.ts +114 -0
- package/src/server/theme-admin-actions.ts +242 -0
- package/src/server/theme-admin.ts +203 -0
- package/src/server/theme-runtime.ts +89 -0
- package/src/server/theme-style.ts +279 -0
- package/src/server/theme.ts +52 -0
- package/src/server/thread-core.ts +207 -0
- package/src/server/thread-rating-actions.ts +55 -0
- package/src/server/thread-tool-actions.ts +154 -0
- package/src/server/two-factor-actions.ts +178 -0
- package/src/server/two-factor.ts +86 -0
- package/src/server/upgrade-notice.ts +41 -0
- package/src/server/user-admin-actions.ts +572 -0
- package/src/server/user-admin.ts +165 -0
- package/src/server/usercp-actions.ts +263 -0
- package/src/server/usercp-mail.ts +120 -0
- package/src/server/viewer-preferences.ts +64 -0
- package/src/server/warning-actions.ts +122 -0
- package/src/styles/globals.css +969 -0
- package/src/theme/contract.fixture.ts +793 -0
- package/src/view/account-copy.ts +351 -0
- package/src/view/admin-content-copy.ts +66 -0
- package/src/view/admin-copy.ts +106 -0
- package/src/view/admin-forum-tree-copy.ts +40 -0
- package/src/view/admin-group-copy.ts +80 -0
- package/src/view/admin-log.ts +58 -0
- package/src/view/admin-nav.ts +164 -0
- package/src/view/admin-navigation-copy.ts +58 -0
- package/src/view/admin-panel-copy.ts +194 -0
- package/src/view/admin-settings.ts +173 -0
- package/src/view/admin-theme-copy.ts +229 -0
- package/src/view/admin-user-copy.ts +96 -0
- package/src/view/attachments.ts +51 -0
- package/src/view/auth-copy.ts +147 -0
- package/src/view/board-index.ts +167 -0
- package/src/view/board-latest.ts +101 -0
- package/src/view/breadcrumb.ts +50 -0
- package/src/view/contacts.ts +85 -0
- package/src/view/content-copy.ts +118 -0
- package/src/view/contrast.ts +286 -0
- package/src/view/copy.ts +101 -0
- package/src/view/count.ts +8 -0
- package/src/view/discovery-view.ts +74 -0
- package/src/view/editor-toolbar.ts +84 -0
- package/src/view/error-notice.ts +52 -0
- package/src/view/feed.ts +130 -0
- package/src/view/forum-cursor.ts +54 -0
- package/src/view/forum-display.ts +152 -0
- package/src/view/forum-jump.ts +60 -0
- package/src/view/forum-notice.ts +24 -0
- package/src/view/inline-moderation.ts +87 -0
- package/src/view/install-copy.ts +95 -0
- package/src/view/legal.ts +58 -0
- package/src/view/locale.ts +40 -0
- package/src/view/marketplace-panel.ts +51 -0
- package/src/view/member-identity.ts +23 -0
- package/src/view/member-profile.ts +65 -0
- package/src/view/messages.ts +222 -0
- package/src/view/metadata.ts +75 -0
- package/src/view/modcp-nav.ts +76 -0
- package/src/view/moderation-copy.ts +84 -0
- package/src/view/moderation-queue.ts +67 -0
- package/src/view/navigation-arrange.ts +188 -0
- package/src/view/navigation.ts +173 -0
- package/src/view/notification-menu.ts +202 -0
- package/src/view/notifications.ts +84 -0
- package/src/view/onboarding.ts +26 -0
- package/src/view/pager.ts +191 -0
- package/src/view/panel-nav.ts +203 -0
- package/src/view/plugin-panel.ts +42 -0
- package/src/view/post-form.ts +61 -0
- package/src/view/post-link.ts +29 -0
- package/src/view/presence.ts +99 -0
- package/src/view/promotion-rules.ts +52 -0
- package/src/view/redirect-notice.ts +21 -0
- package/src/view/reputation.ts +107 -0
- package/src/view/search-controls.ts +103 -0
- package/src/view/search-results.ts +291 -0
- package/src/view/security-activity.ts +73 -0
- package/src/view/setting-groups.ts +43 -0
- package/src/view/shell.ts +164 -0
- package/src/view/slug-id.ts +12 -0
- package/src/view/sso-hand-off.ts +44 -0
- package/src/view/sso-notices.ts +48 -0
- package/src/view/subscriptions.ts +76 -0
- package/src/view/theme-draft.ts +167 -0
- package/src/view/theme-preference.ts +30 -0
- package/src/view/theme-tokens.ts +333 -0
- package/src/view/thread-view.ts +280 -0
- package/src/view/time.ts +31 -0
- package/src/view/timezone.ts +35 -0
- package/src/view/two-factor.ts +6 -0
- package/src/view/usercp-nav.ts +103 -0
- package/src/view/usercp.ts +196 -0
- package/src/view/warnings.ts +128 -0
- package/src/view/word-filter.ts +5 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import type { Actor } from '@meith/authorization'
|
|
4
|
+
import type { RaterLimits, ReputationSettings } from '@meith/reputation'
|
|
5
|
+
import { ReputationService } from '@meith/reputation'
|
|
6
|
+
|
|
7
|
+
import { getContainer } from './container'
|
|
8
|
+
import { getActor } from './context'
|
|
9
|
+
import { getSettings } from './settings'
|
|
10
|
+
|
|
11
|
+
export function reputationService(): ReputationService | null {
|
|
12
|
+
const { reputation } = getContainer()
|
|
13
|
+
return reputation === null ? null : new ReputationService({ reputation })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function reputationSettings(): Promise<ReputationSettings> {
|
|
17
|
+
const settings = await getSettings()
|
|
18
|
+
return {
|
|
19
|
+
enabled: settings.get('reputation.enabled'),
|
|
20
|
+
allowNegative: settings.get('reputation.allow_negative'),
|
|
21
|
+
commentRequired: settings.get('reputation.comment_required'),
|
|
22
|
+
minPostsToGive: settings.get('reputation.min_posts_to_give'),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function viewerRaterLimits(viewer?: Actor): Promise<RaterLimits> {
|
|
27
|
+
const actor = viewer ?? (await getActor())
|
|
28
|
+
const { authorizer, memberProfiles } = getContainer()
|
|
29
|
+
|
|
30
|
+
if (actor.userId === null) return { canGive: false, maxPerDay: 0, postCount: 0 }
|
|
31
|
+
|
|
32
|
+
const profile = await memberProfiles.findPublicById(actor.userId).catch(() => null)
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
canGive: authorizer.can(actor, 'reputation.give'),
|
|
36
|
+
maxPerDay: authorizer.globalLimit(actor, 'maxReputationPerDay'),
|
|
37
|
+
postCount: profile?.postCount ?? 0,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PostThanks {
|
|
42
|
+
readonly thanked: boolean
|
|
43
|
+
readonly count: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function thanksForPosts(
|
|
47
|
+
postIds: readonly number[],
|
|
48
|
+
): Promise<ReadonlyMap<number, PostThanks>> {
|
|
49
|
+
const service = reputationService()
|
|
50
|
+
if (service === null || postIds.length === 0) return new Map()
|
|
51
|
+
|
|
52
|
+
const actor = await getActor()
|
|
53
|
+
|
|
54
|
+
const [counts, mine] = await Promise.all([
|
|
55
|
+
service.thanksForPosts(postIds).catch(() => new Map<number, number>()),
|
|
56
|
+
actor.userId === null
|
|
57
|
+
? Promise.resolve(new Map())
|
|
58
|
+
: service.existingForPosts({ givenByUserId: actor.userId, postIds }).catch(() => new Map()),
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
return new Map(
|
|
62
|
+
postIds.map((postId) => [
|
|
63
|
+
postId,
|
|
64
|
+
{
|
|
65
|
+
thanked: (mine.get(postId)?.points ?? 0) > 0,
|
|
66
|
+
count: counts.get(postId) ?? 0,
|
|
67
|
+
},
|
|
68
|
+
]),
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { headers } from 'next/headers'
|
|
4
|
+
|
|
5
|
+
import { env, logger, resolveClientAddress, truncateIp } from '@meith/core'
|
|
6
|
+
import { demoAddressToken, demoDiscardsAddresses } from '@meith/demo'
|
|
7
|
+
|
|
8
|
+
export interface RequestFingerprint {
|
|
9
|
+
readonly ipPrefix: string | null
|
|
10
|
+
readonly userAgent: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let announcedUntrustedForwarding = false
|
|
14
|
+
|
|
15
|
+
export async function remoteAddress(): Promise<string | null> {
|
|
16
|
+
const jar = await headers()
|
|
17
|
+
const forwarded = jar.get('x-forwarded-for')
|
|
18
|
+
|
|
19
|
+
if (env.TRUSTED_PROXY_HOPS === 0 && forwarded !== null && !announcedUntrustedForwarding) {
|
|
20
|
+
announcedUntrustedForwarding = true
|
|
21
|
+
logger({ module: 'request' }).warn(
|
|
22
|
+
'TRUSTED_PROXY_HOPS is 0 and a request arrived carrying X-Forwarded-For; ' +
|
|
23
|
+
'the header is being ignored. Set it to the number of proxies in front ' +
|
|
24
|
+
'of the board, or addresses will not resolve.',
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return resolveClientAddress(
|
|
29
|
+
{ forwardedFor: forwarded, realIp: jar.get('x-real-ip') },
|
|
30
|
+
env.TRUSTED_PROXY_HOPS,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function retainedIpPrefix(): Promise<string | null> {
|
|
35
|
+
if (demoDiscardsAddresses()) return null
|
|
36
|
+
return truncateIp(await remoteAddress()) ?? null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function countingPrefix(): Promise<string | null> {
|
|
40
|
+
const address = await remoteAddress()
|
|
41
|
+
if (address === null || address === '') return null
|
|
42
|
+
|
|
43
|
+
const prefix = truncateIp(address)
|
|
44
|
+
if (prefix === undefined) return null
|
|
45
|
+
|
|
46
|
+
return demoDiscardsAddresses() ? demoAddressToken(prefix) : prefix
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function countingAddress(): Promise<string | null> {
|
|
50
|
+
const address = await remoteAddress()
|
|
51
|
+
if (address === null || address === '') return null
|
|
52
|
+
|
|
53
|
+
return demoDiscardsAddresses() ? demoAddressToken(address) : address
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Where a request came from and what made it, as much as is kept about either. */
|
|
57
|
+
export async function requestFingerprint(): Promise<RequestFingerprint> {
|
|
58
|
+
const incoming = await headers()
|
|
59
|
+
const agent = incoming.get('user-agent') ?? ''
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
ipPrefix: await retainedIpPrefix(),
|
|
63
|
+
userAgent: agent.trim() === '' ? null : agent.slice(0, 300),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
export function isSafeLocalPath(path: string): boolean {
|
|
4
|
+
if (!path.startsWith('/') || path.startsWith('//')) return false
|
|
5
|
+
|
|
6
|
+
for (const character of path) {
|
|
7
|
+
const code = character.charCodeAt(0)
|
|
8
|
+
if (code <= 0x20 || code === 0x7f || character === '\\') return false
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
return new URL(path, 'http://localhost').origin === 'http://localhost'
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
function boardHost(request: Request): string | null {
|
|
4
|
+
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host')
|
|
5
|
+
if (host === null) return null
|
|
6
|
+
|
|
7
|
+
const trimmed = host.trim().toLowerCase()
|
|
8
|
+
return trimmed === '' ? null : trimmed
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isSameOrigin(request: Request): boolean {
|
|
12
|
+
const origin = request.headers.get('origin')?.trim()
|
|
13
|
+
|
|
14
|
+
if (origin !== undefined && origin !== '' && origin !== 'null') {
|
|
15
|
+
const host = boardHost(request)
|
|
16
|
+
if (host === null) return false
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
return new URL(origin).host.toLowerCase() === host
|
|
20
|
+
} catch {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const site = request.headers.get('sec-fetch-site')
|
|
26
|
+
return site === 'same-origin' || site === 'none'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isTopLevelNavigation(request: Request): boolean {
|
|
30
|
+
const mode = request.headers.get('sec-fetch-mode')
|
|
31
|
+
const dest = request.headers.get('sec-fetch-dest')
|
|
32
|
+
|
|
33
|
+
if (mode !== null || dest !== null) {
|
|
34
|
+
return (mode === null || mode === 'navigate') && (dest === null || dest === 'document')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (request.headers.get('accept') ?? '').includes('text/html')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function crossOriginRefusal(): Response {
|
|
41
|
+
return new Response('This request did not come from the board.', {
|
|
42
|
+
status: 403,
|
|
43
|
+
headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
|
|
44
|
+
})
|
|
45
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { msg } from '@meith/i18n'
|
|
2
|
+
import 'server-only'
|
|
3
|
+
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
import { foldIdentifier } from '@meith/accounts'
|
|
7
|
+
import type { Actor } from '@meith/authorization'
|
|
8
|
+
import { ForbiddenError, NotFoundError } from '@meith/core'
|
|
9
|
+
import { getDb, ownsSearch, PostgresSearchStore, SEARCH_WINDOW, type StoredSearch } from '@meith/db'
|
|
10
|
+
import {
|
|
11
|
+
isRefined,
|
|
12
|
+
isRunnable,
|
|
13
|
+
narrowFilters,
|
|
14
|
+
parseSearchInput,
|
|
15
|
+
readGrouping,
|
|
16
|
+
readMatch,
|
|
17
|
+
readPeriod,
|
|
18
|
+
readSort,
|
|
19
|
+
type SearchCursor,
|
|
20
|
+
type SearchFilterSet,
|
|
21
|
+
type SearchRefinement,
|
|
22
|
+
type SearchResults,
|
|
23
|
+
type SearchSummary,
|
|
24
|
+
searchQueryFrom,
|
|
25
|
+
trimRefinement,
|
|
26
|
+
} from '@meith/search'
|
|
27
|
+
|
|
28
|
+
import { REFINE_FIELDS } from '@/view/search-controls'
|
|
29
|
+
|
|
30
|
+
import { limitMessage, spendLimit } from './antispam'
|
|
31
|
+
import { getContainer } from './container'
|
|
32
|
+
import { filterView, viewerRef } from './plugin-view'
|
|
33
|
+
import { requireSearch, requireSearchEnabled, searchMinWordLength, searchScopeFor } from './search'
|
|
34
|
+
import { getSettings } from './settings'
|
|
35
|
+
|
|
36
|
+
export const SEARCH_PAGE = 20
|
|
37
|
+
|
|
38
|
+
export const MAX_AUTHOR_NAMES = 5
|
|
39
|
+
|
|
40
|
+
export const SEARCH_COUNT_CAP = SEARCH_WINDOW
|
|
41
|
+
|
|
42
|
+
export function searchStore(): PostgresSearchStore | null {
|
|
43
|
+
return getContainer().dataSource === 'postgres' ? new PostgresSearchStore(getDb()) : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function newToken(): string {
|
|
47
|
+
return randomBytes(18).toString('base64url')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RunSearchInput {
|
|
51
|
+
readonly actor: Actor
|
|
52
|
+
readonly sessionKey: string | null
|
|
53
|
+
readonly terms: string
|
|
54
|
+
readonly filters: Omit<SearchFilterSet, 'authorUserIds' | 'authorNames'>
|
|
55
|
+
readonly authors: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type RunSearchOutcome =
|
|
59
|
+
| { readonly kind: 'ok'; readonly token: string }
|
|
60
|
+
| {
|
|
61
|
+
readonly kind: 'refused'
|
|
62
|
+
readonly reason: 'empty' | 'too-short' | 'too-long'
|
|
63
|
+
readonly minWordLength: number
|
|
64
|
+
}
|
|
65
|
+
| { readonly kind: 'flooded'; readonly seconds: number }
|
|
66
|
+
| { readonly kind: 'limited'; readonly message: string }
|
|
67
|
+
| { readonly kind: 'unknown-author'; readonly name: string }
|
|
68
|
+
|
|
69
|
+
export async function runSearch(input: RunSearchInput): Promise<RunSearchOutcome> {
|
|
70
|
+
await requireSearchEnabled()
|
|
71
|
+
|
|
72
|
+
const minWordLength = await searchMinWordLength()
|
|
73
|
+
|
|
74
|
+
const parsed = parseSearchInput(input.terms, minWordLength)
|
|
75
|
+
if (!isRunnable(parsed)) {
|
|
76
|
+
return { kind: 'refused', reason: parsed.refusal ?? 'empty', minWordLength }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const store = searchStore()
|
|
80
|
+
if (store === null) throw new ForbiddenError(msg('error.app.board-search-index'))
|
|
81
|
+
|
|
82
|
+
const authors = await resolveAuthors(parseAuthorNames(input.authors))
|
|
83
|
+
if (authors.kind === 'unknown') return { kind: 'unknown-author', name: authors.name }
|
|
84
|
+
|
|
85
|
+
const settings = await getSettings()
|
|
86
|
+
const { authorizer } = getContainer()
|
|
87
|
+
|
|
88
|
+
const floodSeconds = authorizer.can(input.actor, 'flood.bypass')
|
|
89
|
+
? 0
|
|
90
|
+
: Number(settings.get('search.flood_seconds') ?? 0)
|
|
91
|
+
|
|
92
|
+
const limited = await spendLimit({ scope: 'search', actor: input.actor, settings })
|
|
93
|
+
if (limited !== null && !limited.allowed) {
|
|
94
|
+
return { kind: 'limited', message: limitMessage(limited) }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const filters: SearchFilterSet = {
|
|
98
|
+
...input.filters,
|
|
99
|
+
...(authors.ids.length === 0 ? {} : { authorUserIds: authors.ids, authorNames: authors.names }),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const terms = await filterView('search.query.before', parsed.terms, viewerRef(input.actor))
|
|
103
|
+
|
|
104
|
+
const stored = await store.create({
|
|
105
|
+
token: newToken(),
|
|
106
|
+
userId: input.actor.userId,
|
|
107
|
+
sessionKey: input.sessionKey,
|
|
108
|
+
terms,
|
|
109
|
+
filters: { ...filters },
|
|
110
|
+
floodSeconds,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
if (stored === null) return { kind: 'flooded', seconds: floodSeconds }
|
|
114
|
+
|
|
115
|
+
return { kind: 'ok', token: stored.token }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface SearchPageView {
|
|
119
|
+
readonly search: StoredSearch
|
|
120
|
+
readonly results: SearchResults
|
|
121
|
+
readonly summary: SearchSummary
|
|
122
|
+
readonly filters: SearchFilterSet
|
|
123
|
+
readonly effective: SearchFilterSet
|
|
124
|
+
readonly refine: SearchRefinement
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function openSearch(input: {
|
|
128
|
+
readonly actor: Actor
|
|
129
|
+
readonly sessionKey: string | null
|
|
130
|
+
readonly token: string
|
|
131
|
+
readonly after: SearchCursor | null
|
|
132
|
+
readonly offset?: number
|
|
133
|
+
readonly refine: SearchRefinement
|
|
134
|
+
readonly now: Date
|
|
135
|
+
}): Promise<SearchPageView> {
|
|
136
|
+
await requireSearchEnabled()
|
|
137
|
+
|
|
138
|
+
const store = searchStore()
|
|
139
|
+
if (store === null) throw new NotFoundError(msg('error.app.such-search'))
|
|
140
|
+
|
|
141
|
+
const search = await store.findByToken(input.token)
|
|
142
|
+
if (search === null) throw new NotFoundError(msg('error.app.such-search'))
|
|
143
|
+
|
|
144
|
+
if (!ownsSearch(search, { userId: input.actor.userId, sessionKey: input.sessionKey })) {
|
|
145
|
+
throw new NotFoundError(msg('error.app.such-search'))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const filters = readFilters(search.filters)
|
|
149
|
+
const refine = trimRefinement(input.refine, filters)
|
|
150
|
+
const effective = narrowFilters(filters, refine)
|
|
151
|
+
const scope = await searchScopeFor(input.actor)
|
|
152
|
+
const provider = requireSearch()
|
|
153
|
+
|
|
154
|
+
const [results, summary] = await Promise.all([
|
|
155
|
+
provider.search(
|
|
156
|
+
searchQueryFrom(search.terms, effective, {
|
|
157
|
+
limit: SEARCH_PAGE,
|
|
158
|
+
after: input.after,
|
|
159
|
+
...(input.offset === undefined ? {} : { offset: input.offset }),
|
|
160
|
+
now: input.now,
|
|
161
|
+
}),
|
|
162
|
+
scope,
|
|
163
|
+
),
|
|
164
|
+
provider.summarize(
|
|
165
|
+
searchQueryFrom(search.terms, facetFilters(filters, refine), {
|
|
166
|
+
limit: SEARCH_PAGE,
|
|
167
|
+
after: null,
|
|
168
|
+
now: input.now,
|
|
169
|
+
}),
|
|
170
|
+
scope,
|
|
171
|
+
),
|
|
172
|
+
])
|
|
173
|
+
|
|
174
|
+
if (!isRefined(refine) && search.hitCount !== summary.total) {
|
|
175
|
+
await store.recordHitCount(search.id, summary.total)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
search,
|
|
180
|
+
results: await filterHits(results, search.terms, input.actor),
|
|
181
|
+
summary,
|
|
182
|
+
filters,
|
|
183
|
+
effective,
|
|
184
|
+
refine,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A plugin may reorder or drop; a row it invents has no hit behind it and is
|
|
190
|
+
* dropped, because the hits arrived permission-filtered in SQL.
|
|
191
|
+
*/
|
|
192
|
+
async function filterHits(
|
|
193
|
+
results: SearchResults,
|
|
194
|
+
terms: string,
|
|
195
|
+
actor: Actor,
|
|
196
|
+
): Promise<SearchResults> {
|
|
197
|
+
const ordered = await filterView(
|
|
198
|
+
'search.results',
|
|
199
|
+
results.hits.map((hit) => ({ postId: hit.postId, threadId: hit.threadId, rank: hit.rank })),
|
|
200
|
+
{ ...viewerRef(actor), terms },
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
const byPostId = new Map(results.hits.map((hit) => [hit.postId, hit]))
|
|
204
|
+
const hits = ordered
|
|
205
|
+
.map((row) => byPostId.get(row.postId))
|
|
206
|
+
.filter((hit): hit is (typeof results.hits)[number] => hit !== undefined)
|
|
207
|
+
|
|
208
|
+
return { ...results, hits }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function facetFilters(filters: SearchFilterSet, refine: SearchRefinement): SearchFilterSet {
|
|
212
|
+
return narrowFilters(filters, {
|
|
213
|
+
...refine,
|
|
214
|
+
forumIds: undefined,
|
|
215
|
+
authorUserIds: undefined,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function readFilters(raw: Readonly<Record<string, unknown>>): SearchFilterSet {
|
|
220
|
+
const forumIds = ids(raw.forumIds)
|
|
221
|
+
const authorUserIds = ids(raw.authorUserIds)
|
|
222
|
+
const authorNames = names(raw.authorNames)
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
sort: readSort(raw.sort),
|
|
226
|
+
match: readMatch(raw.match),
|
|
227
|
+
grouping: readGrouping(raw.grouping),
|
|
228
|
+
period: readPeriod(raw.period),
|
|
229
|
+
...(forumIds === undefined ? {} : { forumIds }),
|
|
230
|
+
...(authorUserIds === undefined ? {} : { authorUserIds }),
|
|
231
|
+
...(authorNames === undefined ? {} : { authorNames }),
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function readRefinement(
|
|
236
|
+
params: Readonly<Record<string, string | string[] | undefined>>,
|
|
237
|
+
): SearchRefinement {
|
|
238
|
+
const many = (key: string): readonly string[] => {
|
|
239
|
+
const raw = params[key]
|
|
240
|
+
if (raw === undefined) return []
|
|
241
|
+
return (Array.isArray(raw) ? raw : [raw]).filter((value) => value !== '')
|
|
242
|
+
}
|
|
243
|
+
const one = (key: string): string | undefined => many(key)[0]
|
|
244
|
+
|
|
245
|
+
const chosen = (key: string): readonly number[] | undefined => {
|
|
246
|
+
const values = many(key)
|
|
247
|
+
.map((value) => Number(value))
|
|
248
|
+
.filter((value) => Number.isSafeInteger(value) && value > 0)
|
|
249
|
+
|
|
250
|
+
return values.length === 0 ? undefined : values
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const forumIds = chosen(REFINE_FIELDS.forum)
|
|
254
|
+
const authorUserIds = chosen(REFINE_FIELDS.author)
|
|
255
|
+
|
|
256
|
+
const sort = one(REFINE_FIELDS.sort)
|
|
257
|
+
const grouping = one(REFINE_FIELDS.grouping)
|
|
258
|
+
const match = one(REFINE_FIELDS.match)
|
|
259
|
+
const period = one(REFINE_FIELDS.period)
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
...(sort === undefined ? {} : { sort: readSort(sort) }),
|
|
263
|
+
...(grouping === undefined ? {} : { grouping: readGrouping(grouping) }),
|
|
264
|
+
...(match === undefined || readMatch(match) === 'everything'
|
|
265
|
+
? {}
|
|
266
|
+
: { match: readMatch(match) }),
|
|
267
|
+
...(period === undefined || readPeriod(period) === 'any' ? {} : { period: readPeriod(period) }),
|
|
268
|
+
...(forumIds === undefined ? {} : { forumIds }),
|
|
269
|
+
...(authorUserIds === undefined ? {} : { authorUserIds }),
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function parseAuthorNames(raw: string): readonly string[] {
|
|
274
|
+
return raw
|
|
275
|
+
.split(',')
|
|
276
|
+
.map((name) => name.trim())
|
|
277
|
+
.filter((name) => name !== '')
|
|
278
|
+
.slice(0, MAX_AUTHOR_NAMES)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function resolveAuthors(
|
|
282
|
+
wanted: readonly string[],
|
|
283
|
+
): Promise<
|
|
284
|
+
| { readonly kind: 'ok'; readonly ids: readonly number[]; readonly names: readonly string[] }
|
|
285
|
+
| { readonly kind: 'unknown'; readonly name: string }
|
|
286
|
+
> {
|
|
287
|
+
if (wanted.length === 0) return { kind: 'ok', ids: [], names: [] }
|
|
288
|
+
|
|
289
|
+
const { accountStore } = getContainer()
|
|
290
|
+
const ids: number[] = []
|
|
291
|
+
const found: string[] = []
|
|
292
|
+
|
|
293
|
+
for (const name of wanted) {
|
|
294
|
+
const account = await accountStore.accounts.findByUsernameLower(foldIdentifier(name))
|
|
295
|
+
if (account === null) return { kind: 'unknown', name }
|
|
296
|
+
ids.push(account.id)
|
|
297
|
+
found.push(account.username)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { kind: 'ok', ids, names: found }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function ids(value: unknown): readonly number[] | undefined {
|
|
304
|
+
return Array.isArray(value) && value.every((entry) => Number.isSafeInteger(entry))
|
|
305
|
+
? (value as number[])
|
|
306
|
+
: undefined
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function names(value: unknown): readonly string[] | undefined {
|
|
310
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
|
|
311
|
+
? (value as string[])
|
|
312
|
+
: undefined
|
|
313
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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 { getDb, PostgresSearchRepository } from '@meith/db'
|
|
7
|
+
import type { SearchScope } from '@meith/search'
|
|
8
|
+
|
|
9
|
+
import { getContainer } from './container'
|
|
10
|
+
import { getSettings } from './settings'
|
|
11
|
+
|
|
12
|
+
export async function searchEnabled(): Promise<boolean> {
|
|
13
|
+
return (await getSettings()).get('search.enabled') === true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function requireSearchEnabled(): Promise<void> {
|
|
17
|
+
if (!(await searchEnabled())) throw new ForbiddenError(msg('board.search.disabled'))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function searchMinWordLength(): Promise<number> {
|
|
21
|
+
return (await getSettings()).get('search.min_word_length')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function searchProvider(): PostgresSearchRepository | null {
|
|
25
|
+
return getContainer().dataSource === 'postgres' ? new PostgresSearchRepository(getDb()) : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function requireSearch(): PostgresSearchRepository {
|
|
29
|
+
const provider = searchProvider()
|
|
30
|
+
if (provider === null) {
|
|
31
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-11'))
|
|
32
|
+
}
|
|
33
|
+
return provider
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function searchScopeFor(actor: Actor): Promise<SearchScope> {
|
|
37
|
+
const { authorizer } = getContainer()
|
|
38
|
+
|
|
39
|
+
const staff = actor.global.isAdministrator === true || actor.global.isSuperModerator === true
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
...(await authorizer.threadAudience(actor)),
|
|
43
|
+
content: contentScopeFrom({ seesUnapproved: staff, seesDeleted: staff }),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export function secretMatches(presented: string, expected: string): boolean {
|
|
4
|
+
const a = Buffer.from(presented, 'utf8')
|
|
5
|
+
const b = Buffer.from(expected, 'utf8')
|
|
6
|
+
if (a.length !== b.length) {
|
|
7
|
+
timingSafeEqual(b, b)
|
|
8
|
+
return false
|
|
9
|
+
}
|
|
10
|
+
return timingSafeEqual(a, b)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function bearerSecret(request: Request, headerName: string): string {
|
|
14
|
+
const authorization = request.headers.get('authorization')
|
|
15
|
+
if (authorization !== null && /^Bearer\s+/i.test(authorization)) {
|
|
16
|
+
return authorization.replace(/^Bearer\s+/i, '')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return request.headers.get(headerName) ?? ''
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { isSafeLocalPath } from './safe-path'
|
|
4
|
+
|
|
5
|
+
export function seeOther(path: string): Response {
|
|
6
|
+
if (!isSafeLocalPath(path)) {
|
|
7
|
+
throw new Error(`seeOther expects a path on this board, got: ${path}`)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return new Response(null, { status: 303, headers: { location: path } })
|
|
11
|
+
}
|