@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,232 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import forumConfig from '@board/config'
|
|
4
|
+
import { revalidatePath } from 'next/cache'
|
|
5
|
+
|
|
6
|
+
import { CacheTags, env, isAppError, logger, readPluginEnv, ValidationError } from '@meith/core'
|
|
7
|
+
import { clearPluginHealth, getDb, PostgresSettingsRepository } from '@meith/db'
|
|
8
|
+
import { drivers } from '@meith/drivers'
|
|
9
|
+
import {
|
|
10
|
+
type PluginDefinition,
|
|
11
|
+
parsePluginSetting,
|
|
12
|
+
pluginEnabledKey,
|
|
13
|
+
pluginSettingType,
|
|
14
|
+
resolvePluginSettingDetails,
|
|
15
|
+
serialisePluginSetting,
|
|
16
|
+
} from '@meith/plugin-kit'
|
|
17
|
+
import { runPluginLifecycle } from '@meith/runtime'
|
|
18
|
+
|
|
19
|
+
import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
|
|
20
|
+
import type { FormState } from './auth-form-state'
|
|
21
|
+
import { tr } from './i18n'
|
|
22
|
+
import { syncPluginNavigation } from './navigation'
|
|
23
|
+
import {
|
|
24
|
+
invalidatePluginHealth,
|
|
25
|
+
reconcilePluginHealth,
|
|
26
|
+
recordPluginFault,
|
|
27
|
+
syncPluginEnablement,
|
|
28
|
+
} from './plugin-host'
|
|
29
|
+
import { emitEvent } from './plugin-view'
|
|
30
|
+
import { getSettingOverrides } from './settings'
|
|
31
|
+
|
|
32
|
+
function requireDefinition(key: string): PluginDefinition {
|
|
33
|
+
const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === key)
|
|
34
|
+
const definition = entry?.plugin as PluginDefinition | undefined
|
|
35
|
+
|
|
36
|
+
if (entry === undefined) {
|
|
37
|
+
throw new ValidationError(`No plugin named "${key}" is installed on this board.`)
|
|
38
|
+
}
|
|
39
|
+
if (definition === undefined) {
|
|
40
|
+
throw new ValidationError(
|
|
41
|
+
`"${key}" is registered without a definition, so there is nothing to configure.`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
return definition
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function invalidateSettings(): Promise<void> {
|
|
48
|
+
await drivers().cache.invalidateTags([CacheTags.settings()])
|
|
49
|
+
revalidatePath('/admin/plugins')
|
|
50
|
+
revalidatePath('/admin/plugins/[key]/[[...path]]', 'page')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function setPluginEnabledAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
54
|
+
try {
|
|
55
|
+
const key = String(form.get('key') ?? '')
|
|
56
|
+
const enabled = form.get('enabled') === '1'
|
|
57
|
+
|
|
58
|
+
if (enabled) {
|
|
59
|
+
await requireAdmin()
|
|
60
|
+
} else {
|
|
61
|
+
await requireFreshAdmin()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const definition = requireDefinition(key)
|
|
65
|
+
|
|
66
|
+
const repository = new PostgresSettingsRepository(getDb())
|
|
67
|
+
if (enabled) {
|
|
68
|
+
await repository.delete([pluginEnabledKey(key)])
|
|
69
|
+
} else {
|
|
70
|
+
await repository.save(new Map([[pluginEnabledKey(key), '0']]))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await invalidateSettings()
|
|
74
|
+
await syncPluginEnablement()
|
|
75
|
+
await syncPluginNavigation()
|
|
76
|
+
|
|
77
|
+
/*
|
|
78
|
+
* After the switch is written, so the callback sees the state it is being
|
|
79
|
+
* told about, and never for the host's own failure switch: a plugin that
|
|
80
|
+
* has just failed five times is not one to hand more work to.
|
|
81
|
+
*/
|
|
82
|
+
await announceSwitch(definition, enabled)
|
|
83
|
+
|
|
84
|
+
await recordAdminAction({
|
|
85
|
+
action: enabled ? 'plugin.enabled' : 'plugin.disabled',
|
|
86
|
+
detail: { plugin: key },
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return { notice: enabled ? 'enabled' : 'disabled' }
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (isAppError(err)) return { error: err.message }
|
|
92
|
+
logger({ module: 'plugin-admin' }).error({ err }, 'failed to change plugin enablement')
|
|
93
|
+
return { error: await tr('notice.app.something-went-wrong-please-try') }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The switch has already been thrown by the time this runs, and it stays
|
|
99
|
+
* thrown: a callback that throws is the plugin's fault, so it is counted and
|
|
100
|
+
* reported like any other plugin failure rather than being reported as the
|
|
101
|
+
* operator's action having failed.
|
|
102
|
+
*/
|
|
103
|
+
async function announceSwitch(definition: PluginDefinition, enabled: boolean): Promise<void> {
|
|
104
|
+
if (enabled) {
|
|
105
|
+
await emitEvent('plugin.enabled', { pluginKey: definition.key }, {})
|
|
106
|
+
} else {
|
|
107
|
+
await emitEvent('plugin.disabled', { pluginKey: definition.key, reason: 'operator' }, {})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (env.DATA_SOURCE !== 'postgres') return
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await runPluginLifecycle({
|
|
114
|
+
db: getDb(),
|
|
115
|
+
plugin: definition,
|
|
116
|
+
phase: enabled ? 'enable' : 'disable',
|
|
117
|
+
})
|
|
118
|
+
} catch (err) {
|
|
119
|
+
logger({ module: 'plugin-admin' }).error(
|
|
120
|
+
{ err, plugin: definition.key },
|
|
121
|
+
`plugin ${enabled ? 'onEnable' : 'onDisable'} failed`,
|
|
122
|
+
)
|
|
123
|
+
await recordPluginFault(definition.key, enabled ? 'onEnable' : 'onDisable', err)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function clearPluginHealthAction(
|
|
128
|
+
_prev: FormState,
|
|
129
|
+
form: FormData,
|
|
130
|
+
): Promise<FormState> {
|
|
131
|
+
try {
|
|
132
|
+
await requireFreshAdmin()
|
|
133
|
+
|
|
134
|
+
const key = String(form.get('key') ?? '')
|
|
135
|
+
requireDefinition(key)
|
|
136
|
+
|
|
137
|
+
await clearPluginHealth(getDb(), key)
|
|
138
|
+
await invalidatePluginHealth()
|
|
139
|
+
await reconcilePluginHealth()
|
|
140
|
+
|
|
141
|
+
revalidatePath('/admin/plugins')
|
|
142
|
+
revalidatePath('/admin/plugins/[key]/[[...path]]', 'page')
|
|
143
|
+
|
|
144
|
+
await recordAdminAction({ action: 'plugin.health-cleared', detail: { plugin: key } })
|
|
145
|
+
|
|
146
|
+
return { notice: 'cleared' }
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (isAppError(err)) return { error: err.message }
|
|
149
|
+
logger({ module: 'plugin-admin' }).error({ err }, 'failed to clear plugin health')
|
|
150
|
+
return { error: await tr('notice.app.something-went-wrong-please-try') }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function savePluginSettingsAction(
|
|
155
|
+
_prev: FormState,
|
|
156
|
+
form: FormData,
|
|
157
|
+
): Promise<FormState> {
|
|
158
|
+
try {
|
|
159
|
+
await requireAdmin()
|
|
160
|
+
|
|
161
|
+
const key = String(form.get('key') ?? '')
|
|
162
|
+
const definition = requireDefinition(key)
|
|
163
|
+
const declared = definition.settings ?? []
|
|
164
|
+
|
|
165
|
+
if (declared.length === 0) {
|
|
166
|
+
throw new ValidationError(`"${key}" declares no settings.`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const resolved = resolvePluginSettingDetails(
|
|
170
|
+
definition,
|
|
171
|
+
await getSettingOverrides(),
|
|
172
|
+
readPluginEnv,
|
|
173
|
+
)
|
|
174
|
+
const environmentOwned = new Set(
|
|
175
|
+
resolved
|
|
176
|
+
.filter((detail) => detail.source === 'environment')
|
|
177
|
+
.map((detail) => detail.setting.key),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const updates = new Map<string, string>()
|
|
181
|
+
for (const setting of declared) {
|
|
182
|
+
const field = `setting.${setting.key}`
|
|
183
|
+
const type = pluginSettingType(setting)
|
|
184
|
+
const raw = form.get(field)
|
|
185
|
+
|
|
186
|
+
if (environmentOwned.has(setting.key)) continue
|
|
187
|
+
if (raw === null && type !== 'boolean') continue
|
|
188
|
+
|
|
189
|
+
if (type === 'boolean') {
|
|
190
|
+
updates.set(`plugin.${key}.${setting.key}`, raw === '1' ? '1' : '0')
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (type === 'secret') {
|
|
195
|
+
if (typeof raw !== 'string' || raw === '') continue
|
|
196
|
+
updates.set(`plugin.${key}.${setting.key}`, raw)
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (type === 'select') {
|
|
201
|
+
const value = typeof raw === 'string' ? raw : ''
|
|
202
|
+
if (!(setting.options ?? []).some((option) => option.value === value)) {
|
|
203
|
+
throw new ValidationError(`“${setting.label}” must be one of its listed options.`)
|
|
204
|
+
}
|
|
205
|
+
updates.set(`plugin.${key}.${setting.key}`, value)
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const parsed = parsePluginSetting(setting, typeof raw === 'string' ? raw : '')
|
|
210
|
+
if (parsed === null) {
|
|
211
|
+
throw new ValidationError(
|
|
212
|
+
`“${setting.label}” needs ${typeof setting.default === 'number' ? 'a number' : 'a value'}.`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
updates.set(`plugin.${key}.${setting.key}`, serialisePluginSetting(parsed))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await new PostgresSettingsRepository(getDb()).save(updates)
|
|
219
|
+
await invalidateSettings()
|
|
220
|
+
|
|
221
|
+
await recordAdminAction({
|
|
222
|
+
action: 'plugin.configured',
|
|
223
|
+
detail: { plugin: key, keys: [...updates.keys()] },
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
return { notice: 'saved' }
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if (isAppError(err)) return { error: err.message }
|
|
229
|
+
logger({ module: 'plugin-admin' }).error({ err }, 'failed to save plugin settings')
|
|
230
|
+
return { error: await tr('notice.app.something-went-wrong-please-try') }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import forumConfig from '@board/config'
|
|
4
|
+
|
|
5
|
+
import { env, logger, readPluginEnv } from '@meith/core'
|
|
6
|
+
import {
|
|
7
|
+
appliedPluginMigrations,
|
|
8
|
+
getDb,
|
|
9
|
+
type PluginHealthRecord,
|
|
10
|
+
readPluginHealth,
|
|
11
|
+
} from '@meith/db'
|
|
12
|
+
import type { Translator } from '@meith/i18n'
|
|
13
|
+
import {
|
|
14
|
+
type PluginDefinition,
|
|
15
|
+
type PluginHealth,
|
|
16
|
+
type PluginSettingSource,
|
|
17
|
+
type PluginSettingType,
|
|
18
|
+
type PluginSettingValue,
|
|
19
|
+
pluginAdminPath,
|
|
20
|
+
pluginSettingType,
|
|
21
|
+
pluginTaskId,
|
|
22
|
+
resolvePluginSettingDetails,
|
|
23
|
+
} from '@meith/plugin-kit'
|
|
24
|
+
|
|
25
|
+
import { configuredPlugins, pluginHost, syncPluginEnablement } from './plugin-host'
|
|
26
|
+
import { getSettingOverrides } from './settings'
|
|
27
|
+
|
|
28
|
+
export type PluginSettingKind = PluginSettingType
|
|
29
|
+
|
|
30
|
+
export interface PluginSettingRow {
|
|
31
|
+
readonly key: string
|
|
32
|
+
readonly label: string
|
|
33
|
+
readonly description: string | null
|
|
34
|
+
readonly advanced: boolean
|
|
35
|
+
readonly kind: PluginSettingKind
|
|
36
|
+
/** '' for a secret, whatever is stored or not: the value never leaves the server. */
|
|
37
|
+
readonly default: PluginSettingValue
|
|
38
|
+
readonly value: PluginSettingValue
|
|
39
|
+
readonly overridden: boolean
|
|
40
|
+
readonly source: PluginSettingSource
|
|
41
|
+
/** For secrets: whether a non-empty value is in force, without saying what it is. */
|
|
42
|
+
readonly set: boolean
|
|
43
|
+
readonly options: readonly { readonly value: string; readonly label: string }[]
|
|
44
|
+
readonly envName: string | null
|
|
45
|
+
readonly problem: string | null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PluginDurableHealthRow {
|
|
49
|
+
readonly failures: number
|
|
50
|
+
readonly disabledAt: string | null
|
|
51
|
+
readonly reason: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PluginMigrationRow {
|
|
55
|
+
readonly id: string
|
|
56
|
+
readonly applied: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PluginTaskRow {
|
|
60
|
+
readonly id: string
|
|
61
|
+
readonly registeredId: string
|
|
62
|
+
readonly intervalSeconds: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface PluginPageRow {
|
|
66
|
+
readonly path: string
|
|
67
|
+
readonly title: string
|
|
68
|
+
readonly href: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface PluginRow {
|
|
72
|
+
readonly key: string
|
|
73
|
+
readonly name: string | null
|
|
74
|
+
readonly version: string | null
|
|
75
|
+
readonly description: string | null
|
|
76
|
+
readonly dependsOn: readonly string[]
|
|
77
|
+
readonly hasDefinition: boolean
|
|
78
|
+
|
|
79
|
+
readonly configuredEnabled: boolean
|
|
80
|
+
readonly operatorEnabled: boolean
|
|
81
|
+
readonly running: boolean
|
|
82
|
+
|
|
83
|
+
readonly health: PluginHealth | null
|
|
84
|
+
/** The record every instance reconciles against, not this process's tally. */
|
|
85
|
+
readonly durableHealth: PluginDurableHealthRow | null
|
|
86
|
+
readonly hooks: readonly string[]
|
|
87
|
+
readonly regions: readonly string[]
|
|
88
|
+
readonly settings: readonly PluginSettingRow[]
|
|
89
|
+
readonly migrations: readonly PluginMigrationRow[]
|
|
90
|
+
readonly tasks: readonly PluginTaskRow[]
|
|
91
|
+
readonly pages: readonly PluginPageRow[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface PluginInventory {
|
|
95
|
+
readonly plugins: readonly PluginRow[]
|
|
96
|
+
readonly migrationsKnown: boolean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function translated(
|
|
100
|
+
t: Translator | undefined,
|
|
101
|
+
key: string | undefined,
|
|
102
|
+
fallback: string,
|
|
103
|
+
args?: Parameters<Translator['t']>[1],
|
|
104
|
+
): string {
|
|
105
|
+
return t !== undefined && key !== undefined && t.has(key) ? t.t(key, args) : fallback
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function definitionsByKey(): ReadonlyMap<string, PluginDefinition | undefined> {
|
|
109
|
+
return new Map(
|
|
110
|
+
(forumConfig.plugins ?? []).map((entry) => [
|
|
111
|
+
entry.key,
|
|
112
|
+
entry.plugin as PluginDefinition | undefined,
|
|
113
|
+
]),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function durableHealthByPlugin(): Promise<ReadonlyMap<string, PluginHealthRecord>> {
|
|
118
|
+
if (env.DATA_SOURCE !== 'postgres') return new Map()
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
return new Map((await readPluginHealth(getDb())).map((row) => [row.pluginKey, row]))
|
|
122
|
+
} catch (error) {
|
|
123
|
+
logger().warn({ err: String(error) }, 'could not read plugin health')
|
|
124
|
+
return new Map()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function appliedByPlugin(
|
|
129
|
+
keys: readonly string[],
|
|
130
|
+
): Promise<ReadonlyMap<string, readonly string[]> | null> {
|
|
131
|
+
if (keys.length === 0) return new Map()
|
|
132
|
+
if (env.DATA_SOURCE !== 'postgres') return null
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const db = getDb()
|
|
136
|
+
const applied = new Map<string, readonly string[]>()
|
|
137
|
+
for (const key of keys) {
|
|
138
|
+
applied.set(key, await appliedPluginMigrations(db, key))
|
|
139
|
+
}
|
|
140
|
+
return applied
|
|
141
|
+
} catch (error) {
|
|
142
|
+
logger().warn({ err: String(error) }, 'could not read applied plugin migrations')
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function pluginInventory(t?: Translator): Promise<PluginInventory> {
|
|
148
|
+
const definitions = definitionsByKey()
|
|
149
|
+
const overrides = await getSettingOverrides()
|
|
150
|
+
await syncPluginEnablement()
|
|
151
|
+
const health = new Map(pluginHost.health().map((entry) => [entry.key, entry]))
|
|
152
|
+
|
|
153
|
+
const withMigrations = [...definitions]
|
|
154
|
+
.filter(([, definition]) => (definition?.migrations ?? []).length > 0)
|
|
155
|
+
.map(([key]) => key)
|
|
156
|
+
const applied = await appliedByPlugin(withMigrations)
|
|
157
|
+
const durable = await durableHealthByPlugin()
|
|
158
|
+
|
|
159
|
+
const plugins = configuredPlugins().map((entry): PluginRow => {
|
|
160
|
+
const definition = definitions.get(entry.key)
|
|
161
|
+
const configuredEnabled = entry.enabled
|
|
162
|
+
const operatorEnabled = overrides.get(`plugin.${entry.key}._enabled`) !== '0'
|
|
163
|
+
const entryHealth = health.get(entry.key) ?? null
|
|
164
|
+
const details =
|
|
165
|
+
definition === undefined
|
|
166
|
+
? []
|
|
167
|
+
: resolvePluginSettingDetails(definition, overrides, readPluginEnv)
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
key: entry.key,
|
|
171
|
+
name:
|
|
172
|
+
definition === undefined
|
|
173
|
+
? entry.name
|
|
174
|
+
: translated(t, definition.nameKey, entry.name ?? definition.name),
|
|
175
|
+
version: entry.version,
|
|
176
|
+
description:
|
|
177
|
+
definition?.description === undefined
|
|
178
|
+
? null
|
|
179
|
+
: translated(
|
|
180
|
+
t,
|
|
181
|
+
definition.descriptionKey,
|
|
182
|
+
definition.description,
|
|
183
|
+
definition.descriptionArgs,
|
|
184
|
+
),
|
|
185
|
+
dependsOn: definition?.dependsOn ?? [],
|
|
186
|
+
hasDefinition: entry.hasDefinition,
|
|
187
|
+
|
|
188
|
+
configuredEnabled,
|
|
189
|
+
operatorEnabled,
|
|
190
|
+
running: configuredEnabled && operatorEnabled && (entryHealth?.enabled ?? false),
|
|
191
|
+
|
|
192
|
+
health: entryHealth,
|
|
193
|
+
durableHealth: toDurableHealth(durable.get(entry.key)),
|
|
194
|
+
hooks: Object.keys(definition?.hooks ?? {}).sort(),
|
|
195
|
+
regions: [...new Set((definition?.contributions ?? []).map((c) => c.region))].sort(),
|
|
196
|
+
|
|
197
|
+
settings: details.map(({ setting, value, source, problem }): PluginSettingRow => {
|
|
198
|
+
const kind = pluginSettingType(setting)
|
|
199
|
+
const secret = kind === 'secret'
|
|
200
|
+
return {
|
|
201
|
+
key: setting.key,
|
|
202
|
+
label: translated(t, setting.labelKey, setting.label),
|
|
203
|
+
description:
|
|
204
|
+
setting.description === undefined
|
|
205
|
+
? null
|
|
206
|
+
: translated(t, setting.descriptionKey, setting.description),
|
|
207
|
+
advanced: setting.advanced === true,
|
|
208
|
+
kind,
|
|
209
|
+
default: secret ? '' : setting.default,
|
|
210
|
+
value: secret ? '' : value,
|
|
211
|
+
overridden: overrides.has(`plugin.${entry.key}.${setting.key}`),
|
|
212
|
+
source,
|
|
213
|
+
set: typeof value === 'string' ? value !== '' : true,
|
|
214
|
+
options: (setting.options ?? []).map((option) => ({
|
|
215
|
+
value: option.value,
|
|
216
|
+
label: translated(t, option.labelKey, option.label),
|
|
217
|
+
})),
|
|
218
|
+
envName: setting.env ?? null,
|
|
219
|
+
problem,
|
|
220
|
+
}
|
|
221
|
+
}),
|
|
222
|
+
|
|
223
|
+
migrations: (definition?.migrations ?? []).map(
|
|
224
|
+
(migration): PluginMigrationRow => ({
|
|
225
|
+
id: migration.id,
|
|
226
|
+
applied: (applied?.get(entry.key) ?? []).includes(migration.id),
|
|
227
|
+
}),
|
|
228
|
+
),
|
|
229
|
+
|
|
230
|
+
tasks: (definition?.tasks ?? []).map(
|
|
231
|
+
(task): PluginTaskRow => ({
|
|
232
|
+
id: task.id,
|
|
233
|
+
registeredId: pluginTaskId(entry.key, task.id),
|
|
234
|
+
intervalSeconds: task.intervalSeconds,
|
|
235
|
+
}),
|
|
236
|
+
),
|
|
237
|
+
|
|
238
|
+
pages: (definition?.adminPages ?? []).map(
|
|
239
|
+
(page): PluginPageRow => ({
|
|
240
|
+
path: page.path,
|
|
241
|
+
title: translated(t, page.titleKey, page.title, page.titleArgs),
|
|
242
|
+
href: pluginAdminPath(entry.key, page.path),
|
|
243
|
+
}),
|
|
244
|
+
),
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
return { plugins, migrationsKnown: withMigrations.length === 0 || applied !== null }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function toDurableHealth(record: PluginHealthRecord | undefined): PluginDurableHealthRow | null {
|
|
252
|
+
if (record === undefined) return null
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
failures: record.failures,
|
|
256
|
+
disabledAt: record.disabledAt === null ? null : record.disabledAt.toISOString(),
|
|
257
|
+
reason: record.reason,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function pluginRow(key: string, t?: Translator): Promise<PluginRow | null> {
|
|
262
|
+
const inventory = await pluginInventory(t)
|
|
263
|
+
return inventory.plugins.find((plugin) => plugin.key === key) ?? null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface HookListenerRow {
|
|
267
|
+
readonly hook: string
|
|
268
|
+
readonly plugins: readonly string[]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function hookListeners(): readonly HookListenerRow[] {
|
|
272
|
+
return Object.entries(pluginHost.listeners())
|
|
273
|
+
.filter(([, plugins]) => plugins.length > 0)
|
|
274
|
+
.map(([hook, plugins]) => ({ hook, plugins }))
|
|
275
|
+
.sort((a, b) => (a.hook < b.hook ? -1 : a.hook > b.hook ? 1 : 0))
|
|
276
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import forumConfig from '@board/config'
|
|
4
|
+
import { cache } from 'react'
|
|
5
|
+
|
|
6
|
+
import { CacheTags, cachedGlobal, env } from '@meith/core'
|
|
7
|
+
import { logger } from '@meith/core/logger'
|
|
8
|
+
import { getDb, readPluginHealth, recordPluginFailure } from '@meith/db'
|
|
9
|
+
import { drivers } from '@meith/drivers'
|
|
10
|
+
import {
|
|
11
|
+
type DurablyDisabledPlugin,
|
|
12
|
+
operatorDisabledPlugins,
|
|
13
|
+
type PluginDefinition,
|
|
14
|
+
PluginHost,
|
|
15
|
+
} from '@meith/plugin-kit'
|
|
16
|
+
|
|
17
|
+
import { getSettingOverrides } from './settings'
|
|
18
|
+
|
|
19
|
+
const HEALTH_TTL_SECONDS = 30
|
|
20
|
+
|
|
21
|
+
export const FAILURE_THRESHOLD = 5
|
|
22
|
+
|
|
23
|
+
export interface ConfiguredPlugin {
|
|
24
|
+
readonly key: string
|
|
25
|
+
readonly enabled: boolean
|
|
26
|
+
readonly hasDefinition: boolean
|
|
27
|
+
readonly name: string | null
|
|
28
|
+
readonly version: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function configuredPlugins(): readonly ConfiguredPlugin[] {
|
|
32
|
+
return (forumConfig.plugins ?? []).map((entry) => {
|
|
33
|
+
const definition = entry.plugin as PluginDefinition | undefined
|
|
34
|
+
return {
|
|
35
|
+
key: entry.key,
|
|
36
|
+
enabled: entry.enabled !== false,
|
|
37
|
+
hasDefinition: definition !== undefined,
|
|
38
|
+
name: definition?.name ?? null,
|
|
39
|
+
version: definition?.version ?? null,
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function activeDefinitions(): readonly PluginDefinition[] {
|
|
45
|
+
return (forumConfig.plugins ?? [])
|
|
46
|
+
.filter((entry) => entry.enabled !== false && entry.plugin !== undefined)
|
|
47
|
+
.map((entry) => entry.plugin as PluginDefinition)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const pluginHost = new PluginHost({
|
|
51
|
+
plugins: activeDefinitions(),
|
|
52
|
+
failureThreshold: FAILURE_THRESHOLD,
|
|
53
|
+
logger: {
|
|
54
|
+
warn: (message, detail) => logger().warn(detail, message),
|
|
55
|
+
error: (message, detail) => logger().error(detail, message),
|
|
56
|
+
},
|
|
57
|
+
health: {
|
|
58
|
+
failed: (failure) => {
|
|
59
|
+
void persistFailure(failure.pluginKey, failure.threshold, failure.hook, failure.message)
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A plugin failing somewhere the host does not itself dispatch — a lifecycle
|
|
66
|
+
* callback — counted and reported exactly as a failing hook is. There is one
|
|
67
|
+
* failure record per plugin, and an operator reading it should not have to know
|
|
68
|
+
* which door the plugin was behind when it broke.
|
|
69
|
+
*/
|
|
70
|
+
export async function recordPluginFault(
|
|
71
|
+
pluginKey: string,
|
|
72
|
+
surface: string,
|
|
73
|
+
error: unknown,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
await persistFailure(
|
|
76
|
+
pluginKey,
|
|
77
|
+
FAILURE_THRESHOLD,
|
|
78
|
+
surface,
|
|
79
|
+
error instanceof Error ? error.message : String(error),
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function persistFailure(
|
|
84
|
+
pluginKey: string,
|
|
85
|
+
threshold: number,
|
|
86
|
+
hook: string,
|
|
87
|
+
message: string,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (env.DATA_SOURCE !== 'postgres') return
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const record = await recordPluginFailure(getDb(), {
|
|
93
|
+
pluginKey,
|
|
94
|
+
threshold,
|
|
95
|
+
reason: `${threshold} failures, most recently in "${hook}": ${message}`,
|
|
96
|
+
})
|
|
97
|
+
if (record.disabledAt !== null) {
|
|
98
|
+
await invalidatePluginHealth()
|
|
99
|
+
await pluginHost.emit('plugin.disabled', { pluginKey, reason: 'failures' }, {})
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
logger().warn({ err: String(error), plugin: pluginKey }, 'could not record plugin failure')
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function invalidatePluginHealth(): Promise<void> {
|
|
107
|
+
await drivers().cache.invalidateTags([CacheTags.pluginHealth()])
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function durablyDisabledPlugins(): Promise<readonly DurablyDisabledPlugin[]> {
|
|
111
|
+
if (env.DATA_SOURCE !== 'postgres') return []
|
|
112
|
+
|
|
113
|
+
return cachedGlobal<readonly DurablyDisabledPlugin[]>(
|
|
114
|
+
drivers().cache,
|
|
115
|
+
{
|
|
116
|
+
key: ['plugin', 'health', 'disabled'],
|
|
117
|
+
tags: [CacheTags.pluginHealth()],
|
|
118
|
+
revalidate: HEALTH_TTL_SECONDS,
|
|
119
|
+
},
|
|
120
|
+
async () =>
|
|
121
|
+
(await readPluginHealth(getDb()))
|
|
122
|
+
.filter((row) => row.disabledAt !== null)
|
|
123
|
+
.map((row) => ({ key: row.pluginKey, reason: row.reason ?? 'repeated failures' })),
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function reconcilePluginHealth(): Promise<void> {
|
|
128
|
+
pluginHost.setDurablyDisabled(await durablyDisabledPlugins())
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const syncPluginEnablement = cache(async (): Promise<void> => {
|
|
132
|
+
try {
|
|
133
|
+
pluginHost.setOperatorDisabled(operatorDisabledPlugins(await getSettingOverrides()))
|
|
134
|
+
} catch (error) {
|
|
135
|
+
logger().warn({ err: String(error) }, 'could not read plugin enablement')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
pluginHost.setDurablyDisabled(await durablyDisabledPlugins())
|
|
140
|
+
} catch (error) {
|
|
141
|
+
logger().warn({ err: String(error) }, 'could not read plugin health')
|
|
142
|
+
}
|
|
143
|
+
})
|