@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,192 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import forumConfig from '@board/config'
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
|
|
6
|
+
import { env, logger, readPluginEnv } from '@meith/core'
|
|
7
|
+
import { getDb, pluginData, pluginGrants, pluginUsers } from '@meith/db'
|
|
8
|
+
import {
|
|
9
|
+
type PluginData,
|
|
10
|
+
type PluginDefinition,
|
|
11
|
+
type PluginGrants,
|
|
12
|
+
type PluginNotify,
|
|
13
|
+
type PluginRuntimeContext,
|
|
14
|
+
type PluginUsers,
|
|
15
|
+
pluginNotify,
|
|
16
|
+
resolvePluginSettings,
|
|
17
|
+
unavailablePluginData,
|
|
18
|
+
unavailablePluginGrants,
|
|
19
|
+
unavailablePluginNotify,
|
|
20
|
+
unavailablePluginUsers,
|
|
21
|
+
} from '@meith/plugin-kit'
|
|
22
|
+
|
|
23
|
+
import { getTranslator } from './i18n'
|
|
24
|
+
import { notificationService } from './notifications'
|
|
25
|
+
import { getSettingOverrides } from './settings'
|
|
26
|
+
|
|
27
|
+
const REQUEST_STATEMENT_TIMEOUT_MS = 3_000
|
|
28
|
+
|
|
29
|
+
export function grantsFor(pluginKey: string): PluginGrants {
|
|
30
|
+
return env.DATA_SOURCE === 'postgres'
|
|
31
|
+
? pluginGrants(getDb(), pluginKey)
|
|
32
|
+
: unavailablePluginGrants('this board is running on in-memory sample data')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function dataFor(pluginKey: string): PluginData {
|
|
36
|
+
return env.DATA_SOURCE === 'postgres'
|
|
37
|
+
? pluginData(getDb(), pluginKey, { statementTimeoutMs: REQUEST_STATEMENT_TIMEOUT_MS })
|
|
38
|
+
: unavailablePluginData('this board is running on in-memory sample data')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function usersFor(): PluginUsers {
|
|
42
|
+
return env.DATA_SOURCE === 'postgres'
|
|
43
|
+
? pluginUsers(getDb())
|
|
44
|
+
: unavailablePluginUsers('this board is running on in-memory sample data')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function notifyFor(pluginKey: string): PluginNotify {
|
|
48
|
+
const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
|
|
49
|
+
const definition = entry?.plugin as PluginDefinition | undefined
|
|
50
|
+
const service = notificationService()
|
|
51
|
+
|
|
52
|
+
if (definition === undefined || service === null) {
|
|
53
|
+
return unavailablePluginNotify('this board is running on in-memory sample data')
|
|
54
|
+
}
|
|
55
|
+
return pluginNotify(pluginKey, definition.notifications ?? [], service)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function runtimeContextFor(
|
|
59
|
+
pluginKey: string,
|
|
60
|
+
definition: PluginDefinition,
|
|
61
|
+
overrides: ReadonlyMap<string, string>,
|
|
62
|
+
log: ReturnType<typeof logger>,
|
|
63
|
+
): PluginRuntimeContext {
|
|
64
|
+
return {
|
|
65
|
+
settings: resolvePluginSettings(definition, overrides, readPluginEnv),
|
|
66
|
+
logger: {
|
|
67
|
+
info: (message, detail) => log.info(detail ?? {}, message),
|
|
68
|
+
warn: (message, detail) => log.warn(detail ?? {}, message),
|
|
69
|
+
error: (message, detail) => log.error(detail ?? {}, message),
|
|
70
|
+
},
|
|
71
|
+
grants: grantsFor(pluginKey),
|
|
72
|
+
data: dataFor(pluginKey),
|
|
73
|
+
users: usersFor(),
|
|
74
|
+
notify: notifyFor(pluginKey),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface RenderedPluginPage {
|
|
79
|
+
readonly title: string
|
|
80
|
+
readonly node: ReactNode | null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface BoardPageRequest {
|
|
84
|
+
readonly viewer: { readonly userId: number | null; readonly isGuest: boolean }
|
|
85
|
+
readonly query: Readonly<Record<string, string>>
|
|
86
|
+
readonly boardUrl: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type BoardPageResult =
|
|
90
|
+
| { readonly outcome: 'rendered'; readonly title: string; readonly node: ReactNode | null }
|
|
91
|
+
| { readonly outcome: 'missing' }
|
|
92
|
+
| { readonly outcome: 'sign-in-first'; readonly title: string }
|
|
93
|
+
|
|
94
|
+
function translated(
|
|
95
|
+
t: Awaited<ReturnType<typeof getTranslator>> | undefined,
|
|
96
|
+
key: string | undefined,
|
|
97
|
+
fallback: string,
|
|
98
|
+
args?: Parameters<Awaited<ReturnType<typeof getTranslator>>['t']>[1],
|
|
99
|
+
): string {
|
|
100
|
+
return t !== undefined && key !== undefined && t.has(key) ? t.t(key, args) : fallback
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function pluginBoardPageTitle(
|
|
104
|
+
pluginKey: string,
|
|
105
|
+
path: string,
|
|
106
|
+
t?: Awaited<ReturnType<typeof getTranslator>>,
|
|
107
|
+
): string | null {
|
|
108
|
+
const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
|
|
109
|
+
const definition = entry?.plugin as PluginDefinition | undefined
|
|
110
|
+
const page = (definition?.pages ?? []).find((candidate) => candidate.path === path)
|
|
111
|
+
return page === undefined ? null : translated(t, page.titleKey, page.title, page.titleArgs)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function renderPluginBoardPage(
|
|
115
|
+
pluginKey: string,
|
|
116
|
+
path: string,
|
|
117
|
+
request: BoardPageRequest,
|
|
118
|
+
): Promise<BoardPageResult> {
|
|
119
|
+
const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
|
|
120
|
+
const definition = entry?.plugin as PluginDefinition | undefined
|
|
121
|
+
|
|
122
|
+
if (entry === undefined || definition === undefined) return { outcome: 'missing' }
|
|
123
|
+
if (entry.enabled === false) return { outcome: 'missing' }
|
|
124
|
+
|
|
125
|
+
const page = (definition.pages ?? []).find((candidate) => candidate.path === path)
|
|
126
|
+
if (page === undefined) return { outcome: 'missing' }
|
|
127
|
+
|
|
128
|
+
const overrides = await getSettingOverrides()
|
|
129
|
+
if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return { outcome: 'missing' }
|
|
130
|
+
|
|
131
|
+
const [log, t] = [logger({ component: 'plugin-page', plugin: pluginKey }), await getTranslator()]
|
|
132
|
+
const title = translated(t, page.titleKey, page.title, page.titleArgs)
|
|
133
|
+
|
|
134
|
+
if (page.access === 'member' && request.viewer.userId === null) {
|
|
135
|
+
return { outcome: 'sign-in-first', title }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
return {
|
|
140
|
+
outcome: 'rendered',
|
|
141
|
+
title,
|
|
142
|
+
node: await page.render({
|
|
143
|
+
...runtimeContextFor(pluginKey, definition, overrides, log),
|
|
144
|
+
viewer: request.viewer,
|
|
145
|
+
path,
|
|
146
|
+
query: request.query,
|
|
147
|
+
boardUrl: request.boardUrl,
|
|
148
|
+
locale: t.locale,
|
|
149
|
+
t,
|
|
150
|
+
}),
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
log.error({ err: error, path }, 'plugin board page failed to render')
|
|
154
|
+
return { outcome: 'rendered', title, node: null }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function renderPluginAdminPage(
|
|
159
|
+
pluginKey: string,
|
|
160
|
+
path: string,
|
|
161
|
+
query: Readonly<Record<string, string>> = {},
|
|
162
|
+
): Promise<RenderedPluginPage | null> {
|
|
163
|
+
const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
|
|
164
|
+
const definition = entry?.plugin as PluginDefinition | undefined
|
|
165
|
+
|
|
166
|
+
if (entry === undefined || definition === undefined) return null
|
|
167
|
+
if (entry.enabled === false) return null
|
|
168
|
+
|
|
169
|
+
const page = (definition.adminPages ?? []).find((candidate) => candidate.path === path)
|
|
170
|
+
if (page === undefined) return null
|
|
171
|
+
|
|
172
|
+
const overrides = await getSettingOverrides()
|
|
173
|
+
if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return null
|
|
174
|
+
|
|
175
|
+
const [log, t] = [logger({ component: 'plugin-page', plugin: pluginKey }), await getTranslator()]
|
|
176
|
+
const title = translated(t, page.titleKey, page.title, page.titleArgs)
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
return {
|
|
180
|
+
title,
|
|
181
|
+
node: await page.render({
|
|
182
|
+
...runtimeContextFor(pluginKey, definition, overrides, log),
|
|
183
|
+
query,
|
|
184
|
+
locale: t.locale,
|
|
185
|
+
t,
|
|
186
|
+
}),
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
log.error({ err: error, path }, 'plugin admin page failed to render')
|
|
190
|
+
return { title, node: null }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import forumConfig from '@board/config'
|
|
4
|
+
|
|
5
|
+
import type { Translator } from '@meith/i18n'
|
|
6
|
+
import { type PluginDefinition, pluginAdminPath } from '@meith/plugin-kit'
|
|
7
|
+
|
|
8
|
+
import type { PluginPage } from '@/view/plugin-panel'
|
|
9
|
+
|
|
10
|
+
import { getSettingOverrides } from './settings'
|
|
11
|
+
|
|
12
|
+
export interface PluginPanelSection {
|
|
13
|
+
readonly key: string
|
|
14
|
+
readonly name: string
|
|
15
|
+
readonly pages: readonly PluginPage[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function pluginPanelSection(
|
|
19
|
+
pluginKey: string,
|
|
20
|
+
t?: Translator,
|
|
21
|
+
): Promise<PluginPanelSection | null> {
|
|
22
|
+
const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
|
|
23
|
+
const definition = entry?.plugin as PluginDefinition | undefined
|
|
24
|
+
|
|
25
|
+
if (entry === undefined || definition === undefined) return null
|
|
26
|
+
if (entry.enabled === false) return null
|
|
27
|
+
|
|
28
|
+
const pages = definition.adminPages ?? []
|
|
29
|
+
if (pages.length === 0) return null
|
|
30
|
+
|
|
31
|
+
const overrides = await getSettingOverrides()
|
|
32
|
+
if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return null
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
key: pluginKey,
|
|
36
|
+
name:
|
|
37
|
+
t !== undefined && definition.nameKey !== undefined && t.has(definition.nameKey)
|
|
38
|
+
? t.t(definition.nameKey)
|
|
39
|
+
: definition.name,
|
|
40
|
+
pages: pages.map((page) => ({
|
|
41
|
+
path: page.path,
|
|
42
|
+
title:
|
|
43
|
+
t !== undefined && page.titleKey !== undefined && t.has(page.titleKey)
|
|
44
|
+
? t.t(page.titleKey, page.titleArgs)
|
|
45
|
+
: page.title,
|
|
46
|
+
href: pluginAdminPath(pluginKey, page.path),
|
|
47
|
+
})),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { tr } from './i18n'
|
|
2
|
+
import 'server-only'
|
|
3
|
+
|
|
4
|
+
import { env, logger, resolveClientAddress } from '@meith/core'
|
|
5
|
+
import { currentRequestId } from '@meith/core/logger'
|
|
6
|
+
import { demoAddressToken, demoDiscardsAddresses } from '@meith/demo'
|
|
7
|
+
import {
|
|
8
|
+
createRouteRateLimiter,
|
|
9
|
+
DEFAULT_ROUTE_BODY_BYTES,
|
|
10
|
+
type PluginDefinition,
|
|
11
|
+
type PluginRequest,
|
|
12
|
+
type PluginResponse,
|
|
13
|
+
type PluginRoute,
|
|
14
|
+
} from '@meith/plugin-kit'
|
|
15
|
+
|
|
16
|
+
import { recordAdminAction, requireAdmin } from './admin'
|
|
17
|
+
import { boardUrl } from './board-url'
|
|
18
|
+
import { getActor } from './context'
|
|
19
|
+
import { activeDefinitions, pluginHost, syncPluginEnablement } from './plugin-host'
|
|
20
|
+
import { runtimeContextFor } from './plugin-pages'
|
|
21
|
+
import { viewerRef } from './plugin-view'
|
|
22
|
+
import { isSafeLocalPath } from './safe-path'
|
|
23
|
+
import { isSameOrigin } from './same-origin'
|
|
24
|
+
import { getSettingOverrides } from './settings'
|
|
25
|
+
|
|
26
|
+
const STRIPPED_HEADERS = new Set(['cookie', 'authorization'])
|
|
27
|
+
|
|
28
|
+
function json(body: unknown, status: number): Response {
|
|
29
|
+
return new Response(JSON.stringify(body), {
|
|
30
|
+
status,
|
|
31
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fail(status: number, code: string, message: string): Response {
|
|
36
|
+
return json({ error: { code, message, requestId: currentRequestId() ?? null } }, status)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function passedHeaders(request: Request): Readonly<Record<string, string>> {
|
|
40
|
+
const out: Record<string, string> = {}
|
|
41
|
+
request.headers.forEach((value, name) => {
|
|
42
|
+
const lower = name.toLowerCase()
|
|
43
|
+
if (STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forum-')) return
|
|
44
|
+
out[lower] = value
|
|
45
|
+
})
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function queryOf(url: URL): Readonly<Record<string, string>> {
|
|
50
|
+
const out: Record<string, string> = {}
|
|
51
|
+
for (const [name, value] of url.searchParams) {
|
|
52
|
+
if (!(name in out)) out[name] = value
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ParsedBody {
|
|
58
|
+
readonly rawBody: Uint8Array | null
|
|
59
|
+
readonly form: Readonly<Record<string, string>> | null
|
|
60
|
+
readonly json: unknown
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readBody(
|
|
64
|
+
request: Request,
|
|
65
|
+
route: PluginRoute,
|
|
66
|
+
): Promise<ParsedBody | 'too-large' | 'unreadable'> {
|
|
67
|
+
if (route.method !== 'POST') return { rawBody: null, form: null, json: null }
|
|
68
|
+
|
|
69
|
+
const limit = route.maxBodyBytes ?? DEFAULT_ROUTE_BODY_BYTES
|
|
70
|
+
|
|
71
|
+
const declared = Number(request.headers.get('content-length') ?? '0')
|
|
72
|
+
if (Number.isFinite(declared) && declared > limit) return 'too-large'
|
|
73
|
+
|
|
74
|
+
let bytes: Uint8Array
|
|
75
|
+
try {
|
|
76
|
+
bytes = new Uint8Array(await request.arrayBuffer())
|
|
77
|
+
} catch {
|
|
78
|
+
return 'unreadable'
|
|
79
|
+
}
|
|
80
|
+
if (bytes.byteLength > limit) return 'too-large'
|
|
81
|
+
|
|
82
|
+
if (route.rawBody === true) return { rawBody: bytes, form: null, json: null }
|
|
83
|
+
|
|
84
|
+
const contentType = (request.headers.get('content-type') ?? '').toLowerCase()
|
|
85
|
+
const text = () => new TextDecoder().decode(bytes)
|
|
86
|
+
|
|
87
|
+
if (contentType.includes('application/json')) {
|
|
88
|
+
try {
|
|
89
|
+
return { rawBody: null, form: null, json: JSON.parse(text()) as unknown }
|
|
90
|
+
} catch {
|
|
91
|
+
return 'unreadable'
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
96
|
+
const form: Record<string, string> = {}
|
|
97
|
+
for (const [name, value] of new URLSearchParams(text())) {
|
|
98
|
+
if (!(name in form)) form[name] = value
|
|
99
|
+
}
|
|
100
|
+
return { rawBody: null, form, json: null }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { rawBody: null, form: null, json: null }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function redirectAllowed(to: string, definition: PluginDefinition): boolean {
|
|
107
|
+
if (isSafeLocalPath(to)) return true
|
|
108
|
+
|
|
109
|
+
let url: URL
|
|
110
|
+
try {
|
|
111
|
+
url = new URL(to)
|
|
112
|
+
} catch {
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const hostname = url.hostname.toLowerCase()
|
|
117
|
+
const loopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'
|
|
118
|
+
const scheme = url.protocol === 'https:' || (url.protocol === 'http:' && loopback)
|
|
119
|
+
|
|
120
|
+
return scheme && (definition.allowedRedirectHosts ?? []).includes(hostname)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toResponse(
|
|
124
|
+
answer: PluginResponse,
|
|
125
|
+
method: 'GET' | 'POST',
|
|
126
|
+
definition: PluginDefinition,
|
|
127
|
+
pluginKey: string,
|
|
128
|
+
): Response {
|
|
129
|
+
switch (answer.kind) {
|
|
130
|
+
case 'json':
|
|
131
|
+
return json(answer.body, answer.status ?? 200)
|
|
132
|
+
|
|
133
|
+
case 'text':
|
|
134
|
+
return new Response(answer.body, {
|
|
135
|
+
status: answer.status ?? 200,
|
|
136
|
+
headers: {
|
|
137
|
+
'content-type': answer.contentType ?? 'text/plain; charset=utf-8',
|
|
138
|
+
'cache-control': 'no-store',
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
case 'redirect': {
|
|
143
|
+
if (!redirectAllowed(answer.to, definition)) {
|
|
144
|
+
logger({ component: 'plugin-route', plugin: pluginKey }).error(
|
|
145
|
+
{ to: answer.to },
|
|
146
|
+
'refused a redirect outside the plugin’s allowed hosts',
|
|
147
|
+
)
|
|
148
|
+
return fail(502, 'redirect_refused', 'The plugin answered with a redirect it may not make.')
|
|
149
|
+
}
|
|
150
|
+
return new Response(null, {
|
|
151
|
+
status: method === 'POST' ? 303 : 307,
|
|
152
|
+
headers: { location: answer.to, 'cache-control': 'no-store' },
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const routeLimiter = createRouteRateLimiter()
|
|
159
|
+
|
|
160
|
+
function callerKey(request: Request, userId: number | null): string {
|
|
161
|
+
if (userId !== null) return `u:${userId}`
|
|
162
|
+
const address = resolveClientAddress(
|
|
163
|
+
{
|
|
164
|
+
forwardedFor: request.headers.get('x-forwarded-for'),
|
|
165
|
+
realIp: request.headers.get('x-real-ip'),
|
|
166
|
+
},
|
|
167
|
+
env.TRUSTED_PROXY_HOPS,
|
|
168
|
+
)
|
|
169
|
+
if (address === null) return 'a:unknown'
|
|
170
|
+
|
|
171
|
+
return `a:${demoDiscardsAddresses() ? demoAddressToken(address) : address}`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type PluginRouteSurface = 'board' | 'admin'
|
|
175
|
+
|
|
176
|
+
export async function dispatchPluginRoute(
|
|
177
|
+
request: Request,
|
|
178
|
+
pluginKey: string,
|
|
179
|
+
segments: readonly string[],
|
|
180
|
+
surface: PluginRouteSurface = 'board',
|
|
181
|
+
): Promise<Response> {
|
|
182
|
+
const definition = activeDefinitions().find((candidate) => candidate.key === pluginKey)
|
|
183
|
+
if (definition === undefined) {
|
|
184
|
+
return fail(404, 'no_such_route', 'No such endpoint.')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await syncPluginEnablement()
|
|
188
|
+
if (!pluginHost.isEnabled(pluginKey)) {
|
|
189
|
+
return fail(404, 'no_such_route', 'No such endpoint.')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const path = segments.join('/')
|
|
193
|
+
const method = request.method === 'POST' ? 'POST' : request.method === 'GET' ? 'GET' : null
|
|
194
|
+
if (method === null) {
|
|
195
|
+
return fail(405, 'method_not_allowed', 'Plugin routes answer GET and POST.')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const declared = (definition.routes ?? []).filter(
|
|
199
|
+
(candidate) =>
|
|
200
|
+
candidate.path === path && (candidate.access === 'admin') === (surface === 'admin'),
|
|
201
|
+
)
|
|
202
|
+
if (declared.length === 0) {
|
|
203
|
+
return fail(404, 'no_such_route', 'No such endpoint.')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const route = declared.find((candidate) => candidate.method === method)
|
|
207
|
+
if (route === undefined) {
|
|
208
|
+
return fail(405, 'method_not_allowed', `This endpoint answers ${declared[0]!.method}.`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const actor = await getActor()
|
|
212
|
+
if (route.access === 'member' && actor.userId === null) {
|
|
213
|
+
return fail(401, 'unauthenticated', 'Sign in to use this.')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (route.access === 'admin') {
|
|
217
|
+
try {
|
|
218
|
+
await requireAdmin()
|
|
219
|
+
} catch {
|
|
220
|
+
return fail(403, 'administrators_only', 'Enter the control panel and try again.')
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (route.access !== 'anonymous' && method === 'POST' && !isSameOrigin(request)) {
|
|
225
|
+
return fail(403, 'cross_origin', 'This form was posted from somewhere that is not the board.')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (route.rateLimit !== undefined) {
|
|
229
|
+
const verdict = await routeLimiter.consume(
|
|
230
|
+
`${pluginKey}:${path}:${callerKey(request, actor.userId)}`,
|
|
231
|
+
route.rateLimit.limit,
|
|
232
|
+
route.rateLimit.windowSeconds,
|
|
233
|
+
Date.now(),
|
|
234
|
+
)
|
|
235
|
+
if (!verdict.allowed) {
|
|
236
|
+
return new Response(
|
|
237
|
+
JSON.stringify({
|
|
238
|
+
error: {
|
|
239
|
+
code: 'rate_limited',
|
|
240
|
+
message: await tr('notice.app.too-many-calls-endpoint-give'),
|
|
241
|
+
requestId: currentRequestId() ?? null,
|
|
242
|
+
},
|
|
243
|
+
}),
|
|
244
|
+
{
|
|
245
|
+
status: 429,
|
|
246
|
+
headers: {
|
|
247
|
+
'content-type': 'application/json; charset=utf-8',
|
|
248
|
+
'cache-control': 'no-store',
|
|
249
|
+
'retry-after': String(verdict.retryAfterSeconds),
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const body = await readBody(request, route)
|
|
257
|
+
if (body === 'too-large') {
|
|
258
|
+
return fail(413, 'body_too_large', 'The request body is over this endpoint’s limit.')
|
|
259
|
+
}
|
|
260
|
+
if (body === 'unreadable') {
|
|
261
|
+
return fail(400, 'unreadable_body', 'The request body could not be read as declared.')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const url = new URL(request.url)
|
|
265
|
+
const pluginRequest: PluginRequest = {
|
|
266
|
+
viewer: viewerRef(actor),
|
|
267
|
+
method,
|
|
268
|
+
path,
|
|
269
|
+
query: queryOf(url),
|
|
270
|
+
headers: passedHeaders(request),
|
|
271
|
+
rawBody: body.rawBody,
|
|
272
|
+
form: body.form,
|
|
273
|
+
json: body.json,
|
|
274
|
+
boardUrl: await boardUrl(),
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const overrides = await getSettingOverrides()
|
|
278
|
+
const log = logger({ component: 'plugin-route', plugin: pluginKey })
|
|
279
|
+
|
|
280
|
+
const outcome = await pluginHost.run(pluginKey, `route ${method} ${path}`, () =>
|
|
281
|
+
route.handler(pluginRequest, {
|
|
282
|
+
...runtimeContextFor(pluginKey, definition, overrides, log),
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if (outcome.status === 'disabled') {
|
|
287
|
+
return fail(404, 'no_such_route', 'No such endpoint.')
|
|
288
|
+
}
|
|
289
|
+
if (outcome.status === 'failed' || outcome.value === undefined || outcome.value === null) {
|
|
290
|
+
return fail(500, 'plugin_failed', 'The plugin could not answer. The error is in the log.')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (route.access === 'admin' && method === 'POST') {
|
|
294
|
+
await recordAdminAction({ action: 'plugin.route', detail: { plugin: pluginKey, path } })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return toResponse(outcome.value, method, definition, pluginKey)
|
|
298
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { Fragment } from 'react'
|
|
4
|
+
|
|
5
|
+
import type { Actor } from '@meith/authorization'
|
|
6
|
+
import type {
|
|
7
|
+
HookContext,
|
|
8
|
+
HookName,
|
|
9
|
+
HookValue,
|
|
10
|
+
PluginRegion,
|
|
11
|
+
PluginRegionContext,
|
|
12
|
+
} from '@meith/plugin-kit'
|
|
13
|
+
|
|
14
|
+
import { pluginHost, syncPluginEnablement } from './plugin-host'
|
|
15
|
+
|
|
16
|
+
export function viewerRef(actor: Actor): { userId: number | null; isGuest: boolean } {
|
|
17
|
+
return { userId: actor.userId, isGuest: actor.userId === null }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function filterView<K extends HookName>(
|
|
21
|
+
name: K,
|
|
22
|
+
value: HookValue<K>,
|
|
23
|
+
context: HookContext<K>,
|
|
24
|
+
): Promise<HookValue<K>> {
|
|
25
|
+
await syncPluginEnablement()
|
|
26
|
+
return pluginHost.applyFilter(name, value, context)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function emitEvent<K extends HookName>(
|
|
30
|
+
name: K,
|
|
31
|
+
value: HookValue<K>,
|
|
32
|
+
context: HookContext<K>,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
await syncPluginEnablement()
|
|
35
|
+
await pluginHost.emit(name, value, context)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function pluginRegion(
|
|
39
|
+
region: PluginRegion,
|
|
40
|
+
context: Omit<PluginRegionContext, 'region'>,
|
|
41
|
+
): Promise<React.ReactNode> {
|
|
42
|
+
await syncPluginEnablement()
|
|
43
|
+
|
|
44
|
+
const nodes = pluginHost.renderRegion(region, { ...context, region })
|
|
45
|
+
if (nodes.length === 0) return null
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
{nodes.map((entry) => (
|
|
50
|
+
<Fragment key={entry.key}>{entry.node}</Fragment>
|
|
51
|
+
))}
|
|
52
|
+
</>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function boardRegion(region: PluginRegion, actor: Actor): Promise<React.ReactNode> {
|
|
57
|
+
return pluginRegion(region, { viewer: viewerRef(actor), subjectId: null, authorId: null })
|
|
58
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { redirect } from 'next/navigation'
|
|
4
|
+
|
|
5
|
+
import { ForbiddenError, ValidationError } from '@meith/core'
|
|
6
|
+
import { msg } from '@meith/i18n'
|
|
7
|
+
import { type PollEditOption, PollService } from '@meith/polls'
|
|
8
|
+
|
|
9
|
+
import { getContainer } from './container'
|
|
10
|
+
import { getActor } from './context'
|
|
11
|
+
import { emitEvent, viewerRef } from './plugin-view'
|
|
12
|
+
import { resolvePollScope } from './poll-scope'
|
|
13
|
+
|
|
14
|
+
function id(form: FormData, name: string): number {
|
|
15
|
+
const value = Number(form.get(name))
|
|
16
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
17
|
+
throw new ValidationError(msg('error.app.poll-exist'))
|
|
18
|
+
return value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function optionIds(form: FormData): readonly number[] {
|
|
22
|
+
return form
|
|
23
|
+
.getAll('optionId')
|
|
24
|
+
.map((value) => Number(value))
|
|
25
|
+
.filter((value) => Number.isSafeInteger(value) && value > 0)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function text(form: FormData, name: string): string {
|
|
29
|
+
const value = form.get(name)
|
|
30
|
+
return typeof value === 'string' ? value : ''
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function closingTime(form: FormData): Date | null {
|
|
34
|
+
const value = text(form, 'closesAt').trim()
|
|
35
|
+
if (value === '') return null
|
|
36
|
+
|
|
37
|
+
const at = new Date(`${value}Z`)
|
|
38
|
+
if (Number.isNaN(at.getTime())) throw new ValidationError(msg('error.app.valid-date'))
|
|
39
|
+
return at
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function editedOptions(form: FormData): readonly PollEditOption[] {
|
|
43
|
+
const labels = form.getAll('optionLabel')
|
|
44
|
+
return form.getAll('optionRef').flatMap((reference, index) => {
|
|
45
|
+
const label = labels[index]
|
|
46
|
+
if (typeof label !== 'string') return []
|
|
47
|
+
const raw = Number(reference)
|
|
48
|
+
const existing = Number.isSafeInteger(raw) && raw > 0 ? raw : null
|
|
49
|
+
return [{ id: existing, label }]
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function votePollAction(form: FormData): Promise<void> {
|
|
54
|
+
const threadId = id(form, 'threadId')
|
|
55
|
+
const pollId = id(form, 'pollId')
|
|
56
|
+
const actor = await getActor()
|
|
57
|
+
const { polls } = getContainer()
|
|
58
|
+
if (polls === null || actor.userId === null)
|
|
59
|
+
throw new ForbiddenError(msg('error.app.must-logged-vote'))
|
|
60
|
+
|
|
61
|
+
const scope = await resolvePollScope(actor, threadId)
|
|
62
|
+
if (scope === null) throw new ValidationError(msg('error.app.poll-exist'))
|
|
63
|
+
|
|
64
|
+
const chosen = optionIds(form)
|
|
65
|
+
await new PollService(polls).vote({
|
|
66
|
+
threadId,
|
|
67
|
+
pollId,
|
|
68
|
+
optionIds: chosen,
|
|
69
|
+
userId: actor.userId,
|
|
70
|
+
mayVote: scope.mayVote,
|
|
71
|
+
})
|
|
72
|
+
for (const optionId of chosen) {
|
|
73
|
+
await emitEvent('poll.voted', { pollId, optionId }, viewerRef(actor))
|
|
74
|
+
}
|
|
75
|
+
redirect(`/thread/${threadId}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function editPollAction(form: FormData): Promise<void> {
|
|
79
|
+
const threadId = id(form, 'threadId')
|
|
80
|
+
const pollId = id(form, 'pollId')
|
|
81
|
+
const actor = await getActor()
|
|
82
|
+
const { polls } = getContainer()
|
|
83
|
+
if (polls === null || actor.userId === null)
|
|
84
|
+
throw new ForbiddenError(msg('error.app.must-logged-vote'))
|
|
85
|
+
|
|
86
|
+
const scope = await resolvePollScope(actor, threadId)
|
|
87
|
+
if (scope === null) throw new ValidationError(msg('error.app.poll-exist'))
|
|
88
|
+
|
|
89
|
+
await new PollService(polls).edit({
|
|
90
|
+
threadId,
|
|
91
|
+
pollId,
|
|
92
|
+
edit: {
|
|
93
|
+
question: text(form, 'question'),
|
|
94
|
+
options: editedOptions(form),
|
|
95
|
+
closesAt: closingTime(form),
|
|
96
|
+
maxOptions: Number(text(form, 'maxOptions')),
|
|
97
|
+
allowRevote: form.get('allowRevote') !== null,
|
|
98
|
+
publicVotes: form.get('publicVotes') !== null,
|
|
99
|
+
},
|
|
100
|
+
capabilities: scope.capabilities,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
redirect(`/thread/${threadId}`)
|
|
104
|
+
}
|