@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,171 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { redirect } from 'next/navigation'
|
|
4
|
+
|
|
5
|
+
import { generateToken, hashToken, verifyPassword } from '@meith/accounts'
|
|
6
|
+
import { ipAllowed } from '@meith/admin'
|
|
7
|
+
import { ForbiddenError, logger, ValidationError } from '@meith/core'
|
|
8
|
+
import { msg } from '@meith/i18n'
|
|
9
|
+
|
|
10
|
+
import { adminAllowlist, adminService, recordAdminAction, resolveAdmin } from './admin'
|
|
11
|
+
import { boardAuthConfig } from './auth-config'
|
|
12
|
+
import type { FormState } from './auth-form-state'
|
|
13
|
+
import { getContainer } from './container'
|
|
14
|
+
import { getActor } from './context'
|
|
15
|
+
import { formStateReporter } from './form-state-reporter'
|
|
16
|
+
import { text } from './form-values'
|
|
17
|
+
import { remoteAddress, retainedIpPrefix } from './request-fingerprint'
|
|
18
|
+
import { isSafeLocalPath } from './safe-path'
|
|
19
|
+
import { clearAdminCookie, readAdminToken, setAdminCookie } from './session-cookies'
|
|
20
|
+
import { twoFactorRequiredForStaff, twoFactorService } from './two-factor'
|
|
21
|
+
|
|
22
|
+
const toFormState = formStateReporter('admin-actions', 'unexpected error in an admin action')
|
|
23
|
+
|
|
24
|
+
export async function adminSignInAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
25
|
+
let target: string
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
if (!ipAllowed(await remoteAddress(), await adminAllowlist())) {
|
|
29
|
+
throw new ForbiddenError(msg('error.app.control-panel-available-from-address'))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const service = adminService()
|
|
33
|
+
if (service === null) {
|
|
34
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-25'))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const actor = await getActor()
|
|
38
|
+
const { authorizer, accountStore } = getContainer()
|
|
39
|
+
if (actor.userId === null || !authorizer.can(actor, 'admincp.access')) {
|
|
40
|
+
throw new ForbiddenError(msg('error.app.reach-control-panel'))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const account = await accountStore.accounts.findById(actor.userId)
|
|
44
|
+
if (account === null) throw new ForbiddenError(msg('error.app.reach-control-panel'))
|
|
45
|
+
|
|
46
|
+
const config = await boardAuthConfig()
|
|
47
|
+
const attemptBucket = await adminReauthenticationBucket(actor.userId)
|
|
48
|
+
const attemptSince = new Date(Date.now() - config.lockoutMinutes * 60_000)
|
|
49
|
+
if (
|
|
50
|
+
config.maxLoginAttempts > 0 &&
|
|
51
|
+
(await accountStore.loginAttempts.countFailuresSince(attemptBucket, attemptSince)) >=
|
|
52
|
+
config.maxLoginAttempts
|
|
53
|
+
) {
|
|
54
|
+
throw new ForbiddenError(msg('error.accounts.too-many-failed-attempts-please'))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const failAttempt = async (): Promise<void> => {
|
|
58
|
+
await accountStore.loginAttempts.record(attemptBucket, false, new Date())
|
|
59
|
+
await recordAdminAction({ action: 'admin.signin_failed' })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const password = text(form, 'password')
|
|
63
|
+
if (password === '') {
|
|
64
|
+
await failAttempt()
|
|
65
|
+
throw new ValidationError(msg('error.app.enter-password'))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ok = await verifyPassword(password, account.passwordHash)
|
|
69
|
+
if (!ok) {
|
|
70
|
+
await failAttempt()
|
|
71
|
+
throw new ForbiddenError(msg('error.app.password-right'))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await assertSecondFactorGiven({ userId: actor.userId, form })
|
|
76
|
+
} catch (err) {
|
|
77
|
+
await failAttempt()
|
|
78
|
+
throw err
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await accountStore.loginAttempts.clear(attemptBucket)
|
|
82
|
+
|
|
83
|
+
const existing = await resolveAdmin()
|
|
84
|
+
if ('context' in existing) {
|
|
85
|
+
await service.markReauthenticated(existing.context.session.id)
|
|
86
|
+
await recordAdminAction({ action: 'admin.reauthenticated' })
|
|
87
|
+
} else {
|
|
88
|
+
const token = generateToken()
|
|
89
|
+
const session = await service.start({
|
|
90
|
+
userId: actor.userId,
|
|
91
|
+
tokenHash: await hashToken(token),
|
|
92
|
+
ipPrefix: await retainedIpPrefix(),
|
|
93
|
+
})
|
|
94
|
+
await setAdminCookie(token, session.expiresAt)
|
|
95
|
+
await recordAdminAction({ action: 'admin.signed_in' })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
target = safeAdminReturn(text(form, 'next'))
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return toFormState(err)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
redirect(target)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The panel's door asks for the second factor as well as the password. The
|
|
108
|
+
* panel can rewrite the whole board, so re-proving only the thing most likely
|
|
109
|
+
* to have leaked would be re-proving the wrong one.
|
|
110
|
+
*/
|
|
111
|
+
async function adminReauthenticationBucket(userId: number): Promise<string> {
|
|
112
|
+
const prefix = await retainedIpPrefix()
|
|
113
|
+
return prefix === null ? `admin-reauth:${userId}` : `admin-reauth:${userId}@${prefix}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function assertSecondFactorGiven(input: {
|
|
117
|
+
readonly userId: number
|
|
118
|
+
readonly form: FormData
|
|
119
|
+
}): Promise<void> {
|
|
120
|
+
const service = twoFactorService()
|
|
121
|
+
if (service === null) return
|
|
122
|
+
|
|
123
|
+
const enrolled = await service.isEnrolled(input.userId)
|
|
124
|
+
|
|
125
|
+
if (!enrolled) {
|
|
126
|
+
if (await twoFactorRequiredForStaff()) {
|
|
127
|
+
throw new ForbiddenError(msg('adminAction.twoFactorRequired'))
|
|
128
|
+
}
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const code = text(input.form, 'code')
|
|
133
|
+
if (code === '') {
|
|
134
|
+
throw new ValidationError(msg('error.app.enter-code-from-authenticator-app'))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const outcome = await service.verify({ userId: input.userId, code })
|
|
138
|
+
if (outcome.status !== 'ok') {
|
|
139
|
+
if (outcome.status === 'replayed') {
|
|
140
|
+
throw new ForbiddenError(msg('adminAction.codeReplayed'))
|
|
141
|
+
}
|
|
142
|
+
throw new ForbiddenError(msg('adminAction.codeWrong'))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function adminSignOutAction(): Promise<void> {
|
|
147
|
+
try {
|
|
148
|
+
const token = await readAdminToken()
|
|
149
|
+
const service = adminService()
|
|
150
|
+
|
|
151
|
+
if (token !== null && token !== '' && service !== null) {
|
|
152
|
+
const resolved = await resolveAdmin()
|
|
153
|
+
if ('context' in resolved) {
|
|
154
|
+
await recordAdminAction({ action: 'admin.signed_out' })
|
|
155
|
+
await service.end(resolved.context.session.id)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
logger({ module: 'admin-actions' }).warn({ err }, 'admin sign-out could not revoke')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await clearAdminCookie()
|
|
163
|
+
redirect('/')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function safeAdminReturn(raw: string): string {
|
|
167
|
+
const trimmed = raw.trim()
|
|
168
|
+
if (!isSafeLocalPath(trimmed) || !trimmed.startsWith('/admin')) return '/admin'
|
|
169
|
+
if (/^\/admin[^/?#]/.test(trimmed)) return '/admin'
|
|
170
|
+
return trimmed
|
|
171
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from 'next/cache'
|
|
4
|
+
|
|
5
|
+
import { CacheTags, isAppError, logger, ValidationError } from '@meith/core'
|
|
6
|
+
import { getDb, PostgresSettingsRepository } from '@meith/db'
|
|
7
|
+
import { drivers } from '@meith/drivers'
|
|
8
|
+
import { msg } from '@meith/i18n'
|
|
9
|
+
import {
|
|
10
|
+
coerceFormValue,
|
|
11
|
+
SETTING_DEFINITIONS,
|
|
12
|
+
type SettingDefinition,
|
|
13
|
+
saveSettings,
|
|
14
|
+
secretClearField,
|
|
15
|
+
} from '@meith/settings'
|
|
16
|
+
|
|
17
|
+
import { recordAdminAction, requireAdmin } from './admin'
|
|
18
|
+
import type { FormState } from './auth-form-state'
|
|
19
|
+
import { tr } from './i18n'
|
|
20
|
+
import { emitEvent } from './plugin-view'
|
|
21
|
+
import { getSettings } from './settings'
|
|
22
|
+
|
|
23
|
+
function submittedKeys(form: FormData): readonly SettingDefinition[] {
|
|
24
|
+
const declared = new Set(
|
|
25
|
+
(form.get('keys') ?? '')
|
|
26
|
+
.toString()
|
|
27
|
+
.split(',')
|
|
28
|
+
.map((key) => key.trim())
|
|
29
|
+
.filter((key) => key !== ''),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return SETTING_DEFINITIONS.filter((definition) => declared.has(definition.key))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function saveAdminSettingsAction(
|
|
36
|
+
_prev: FormState,
|
|
37
|
+
form: FormData,
|
|
38
|
+
): Promise<FormState> {
|
|
39
|
+
try {
|
|
40
|
+
const admin = await requireAdmin()
|
|
41
|
+
|
|
42
|
+
const definitions = submittedKeys(form)
|
|
43
|
+
if (definitions.length === 0) {
|
|
44
|
+
throw new ValidationError(msg('error.app.form-submitted-settings'))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const updates: Record<string, unknown> = {}
|
|
48
|
+
for (const definition of definitions) {
|
|
49
|
+
const raw = form.get(definition.key)
|
|
50
|
+
const value = coerceFormValue(definition, typeof raw === 'string' ? raw : undefined, {
|
|
51
|
+
clear: form.get(secretClearField(definition.key)) === '1',
|
|
52
|
+
})
|
|
53
|
+
if (value !== undefined) updates[definition.key] = value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const result = await saveSettings(
|
|
57
|
+
new PostgresSettingsRepository(getDb()),
|
|
58
|
+
updates,
|
|
59
|
+
await getSettings(),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if (result.changed.length > 0) {
|
|
63
|
+
const tags = [CacheTags.settings(), ...result.invalidates]
|
|
64
|
+
await drivers().cache.invalidateTags(tags)
|
|
65
|
+
for (const tag of tags) await emitEvent('cache.invalidated', { tag }, {})
|
|
66
|
+
|
|
67
|
+
revalidatePath('/admin/settings')
|
|
68
|
+
|
|
69
|
+
await emitEvent('settings.saved', { keys: result.changed }, { adminId: admin.session.userId })
|
|
70
|
+
|
|
71
|
+
await recordAdminAction({
|
|
72
|
+
action: 'settings.changed',
|
|
73
|
+
detail: { keys: result.changed },
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { notice: result.changed.length === 0 ? 'unchanged' : 'saved' }
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (isAppError(err)) return { error: err.message }
|
|
80
|
+
logger({ module: 'admin-settings' }).error({ err }, 'failed to save settings')
|
|
81
|
+
return { error: await tr('notice.app.something-went-wrong-please-try') }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { refresh, revalidatePath } from 'next/cache'
|
|
4
|
+
|
|
5
|
+
import { ValidationError } from '@meith/core'
|
|
6
|
+
import { msg } from '@meith/i18n'
|
|
7
|
+
|
|
8
|
+
import { recordAdminAction, requireAdmin } from './admin'
|
|
9
|
+
import { claimAdminUndo } from './admin-undo'
|
|
10
|
+
import type { FormState } from './auth-form-state'
|
|
11
|
+
import { formStateReporter } from './form-state-reporter'
|
|
12
|
+
import { trimmedText } from './form-values'
|
|
13
|
+
import { banService, requireUserAdmin } from './user-admin'
|
|
14
|
+
|
|
15
|
+
const toFormState = formStateReporter('admin-undo', 'admin undo failed')
|
|
16
|
+
|
|
17
|
+
function positive(value: unknown): number {
|
|
18
|
+
const parsed = Number(value)
|
|
19
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0)
|
|
20
|
+
throw new ValidationError(msg('admin.undoInvalid'))
|
|
21
|
+
return parsed
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function numbers(value: unknown): number[] {
|
|
25
|
+
if (!Array.isArray(value)) throw new ValidationError(msg('admin.undoInvalid'))
|
|
26
|
+
return value.map(positive)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function undoAdminAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
30
|
+
try {
|
|
31
|
+
const context = await requireAdmin()
|
|
32
|
+
const claimed = await claimAdminUndo({
|
|
33
|
+
token: trimmedText(form, 'undoToken'),
|
|
34
|
+
actorUserId: context.session.userId,
|
|
35
|
+
})
|
|
36
|
+
const snapshot = claimed.snapshot
|
|
37
|
+
|
|
38
|
+
if (claimed.operation === 'user.state') {
|
|
39
|
+
const state = snapshot.state
|
|
40
|
+
if (state !== 'active' && state !== 'awaiting_activation') {
|
|
41
|
+
throw new ValidationError(msg('admin.undoInvalid'))
|
|
42
|
+
}
|
|
43
|
+
await requireUserAdmin().setState(positive(snapshot.userId), state)
|
|
44
|
+
} else if (claimed.operation === 'user.groups') {
|
|
45
|
+
await requireUserAdmin().setSecondaryGroups(
|
|
46
|
+
positive(snapshot.userId),
|
|
47
|
+
numbers(snapshot.memberships),
|
|
48
|
+
context.session.userId,
|
|
49
|
+
)
|
|
50
|
+
} else if (claimed.operation === 'user.ban' || claimed.operation === 'user.bulk_ban') {
|
|
51
|
+
const ids =
|
|
52
|
+
claimed.operation === 'user.ban' ? [positive(snapshot.userId)] : numbers(snapshot.userIds)
|
|
53
|
+
for (const id of ids) await banService().lift(id)
|
|
54
|
+
} else {
|
|
55
|
+
throw new ValidationError(msg('admin.undoInvalid'))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
revalidatePath('/admin')
|
|
59
|
+
revalidatePath('/admin/users')
|
|
60
|
+
revalidatePath('/admin/users/[id]', 'page')
|
|
61
|
+
refresh()
|
|
62
|
+
await recordAdminAction({
|
|
63
|
+
action: `${claimed.operation}.undone`,
|
|
64
|
+
detail: { undoOperationId: claimed.id },
|
|
65
|
+
})
|
|
66
|
+
return { notice: 'undone' }
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return toFormState(err)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
import { hashToken } from '@meith/accounts'
|
|
6
|
+
import { ForbiddenError, ValidationError } from '@meith/core'
|
|
7
|
+
import { getDb, PostgresAdminUndoRepository } from '@meith/db'
|
|
8
|
+
import { msg } from '@meith/i18n'
|
|
9
|
+
|
|
10
|
+
import type { UndoState } from './auth-form-state'
|
|
11
|
+
import { getContainer } from './container'
|
|
12
|
+
|
|
13
|
+
export const ADMIN_UNDO_WINDOW_MS = 10 * 60_000
|
|
14
|
+
|
|
15
|
+
function repository(): PostgresAdminUndoRepository {
|
|
16
|
+
if (getContainer().dataSource !== 'postgres') {
|
|
17
|
+
throw new ForbiddenError(msg('admin.undoUnavailable'))
|
|
18
|
+
}
|
|
19
|
+
return new PostgresAdminUndoRepository(getDb())
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function issueAdminUndo(input: {
|
|
23
|
+
readonly actorUserId: number
|
|
24
|
+
readonly operation: string
|
|
25
|
+
readonly snapshot: Readonly<Record<string, unknown>>
|
|
26
|
+
readonly now?: Date
|
|
27
|
+
}): Promise<UndoState | undefined> {
|
|
28
|
+
if (getContainer().dataSource !== 'postgres') return undefined
|
|
29
|
+
|
|
30
|
+
const now = input.now ?? new Date()
|
|
31
|
+
const expiresAt = new Date(now.getTime() + ADMIN_UNDO_WINDOW_MS)
|
|
32
|
+
const token = randomUUID()
|
|
33
|
+
|
|
34
|
+
await repository().create({
|
|
35
|
+
tokenHash: await hashToken(token),
|
|
36
|
+
actorUserId: input.actorUserId,
|
|
37
|
+
operation: input.operation,
|
|
38
|
+
snapshot: input.snapshot,
|
|
39
|
+
createdAt: now,
|
|
40
|
+
expiresAt,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return { token, expiresAt: expiresAt.toISOString() }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readAdminUndo(input: {
|
|
47
|
+
readonly token: string
|
|
48
|
+
readonly actorUserId: number
|
|
49
|
+
}) {
|
|
50
|
+
const token = input.token.trim()
|
|
51
|
+
if (token === '') return null
|
|
52
|
+
return repository().findLive({
|
|
53
|
+
tokenHash: await hashToken(token),
|
|
54
|
+
actorUserId: input.actorUserId,
|
|
55
|
+
now: new Date(),
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function claimAdminUndo(input: {
|
|
60
|
+
readonly token: string
|
|
61
|
+
readonly actorUserId: number
|
|
62
|
+
}) {
|
|
63
|
+
const token = input.token.trim()
|
|
64
|
+
if (token === '') throw new ValidationError(msg('admin.undoInvalid'))
|
|
65
|
+
|
|
66
|
+
const operation = await repository().claim({
|
|
67
|
+
tokenHash: await hashToken(token),
|
|
68
|
+
actorUserId: input.actorUserId,
|
|
69
|
+
now: new Date(),
|
|
70
|
+
})
|
|
71
|
+
if (operation === null) {
|
|
72
|
+
throw new ValidationError(msg('admin.undoExpired'))
|
|
73
|
+
}
|
|
74
|
+
return operation
|
|
75
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { msg } from '@meith/i18n'
|
|
2
|
+
import 'server-only'
|
|
3
|
+
|
|
4
|
+
import { notFound } from 'next/navigation'
|
|
5
|
+
import { cache } from 'react'
|
|
6
|
+
|
|
7
|
+
import { hashToken } from '@meith/accounts'
|
|
8
|
+
import { type AdminContext, AdminService, ipAllowed, parseAllowlist } from '@meith/admin'
|
|
9
|
+
import { env, ForbiddenError } from '@meith/core'
|
|
10
|
+
|
|
11
|
+
import { getContainer } from './container'
|
|
12
|
+
import { getActor } from './context'
|
|
13
|
+
import { remoteAddress, retainedIpPrefix } from './request-fingerprint'
|
|
14
|
+
import { readAdminToken } from './session-cookies'
|
|
15
|
+
|
|
16
|
+
export type AdminDenial = 'address' | 'permission' | 'signin' | 'expired' | 'unavailable'
|
|
17
|
+
|
|
18
|
+
export function adminService(): AdminService | null {
|
|
19
|
+
const { adminSessions } = getContainer()
|
|
20
|
+
return adminSessions === null ? null : new AdminService({ sessions: adminSessions })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const adminAllowlist = cache(
|
|
24
|
+
async (): Promise<readonly string[]> => parseAllowlist(env.ADMIN_IP_ALLOWLIST),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
export const resolveAdmin = cache(
|
|
28
|
+
async (): Promise<{ context: AdminContext } | { denied: AdminDenial }> => {
|
|
29
|
+
const allowlist = await adminAllowlist()
|
|
30
|
+
if (!ipAllowed(await remoteAddress(), allowlist)) return { denied: 'address' }
|
|
31
|
+
|
|
32
|
+
const service = adminService()
|
|
33
|
+
if (service === null) return { denied: 'unavailable' }
|
|
34
|
+
|
|
35
|
+
const actor = await getActor()
|
|
36
|
+
const { authorizer } = getContainer()
|
|
37
|
+
if (actor.userId === null || !authorizer.can(actor, 'admincp.access')) {
|
|
38
|
+
return { denied: 'permission' }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const token = await readAdminToken()
|
|
42
|
+
if (token === null || token === '') return { denied: 'signin' }
|
|
43
|
+
|
|
44
|
+
const context = await service.resolve(await hashToken(token))
|
|
45
|
+
if (context === null) return { denied: 'expired' }
|
|
46
|
+
|
|
47
|
+
if (context.userId !== actor.userId) return { denied: 'expired' }
|
|
48
|
+
|
|
49
|
+
return { context }
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
export async function requireAdmin(): Promise<AdminContext> {
|
|
54
|
+
const resolved = await resolveAdmin()
|
|
55
|
+
if ('context' in resolved) return resolved.context
|
|
56
|
+
|
|
57
|
+
throw new ForbiddenError(
|
|
58
|
+
resolved.denied === 'address'
|
|
59
|
+
? msg('error.app.control-panel-available-from-address')
|
|
60
|
+
: resolved.denied === 'unavailable'
|
|
61
|
+
? msg('error.app.board-running-in-memory-sample-data-25')
|
|
62
|
+
: resolved.denied === 'permission'
|
|
63
|
+
? msg('error.app.reach-control-panel')
|
|
64
|
+
: msg('error.app.control-panel-sign-in'),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function askForPassword(denied: AdminDenial): boolean {
|
|
69
|
+
return denied === 'signin' || denied === 'expired'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function adminPageContext(): Promise<AdminContext | null> {
|
|
73
|
+
const resolved = await resolveAdmin()
|
|
74
|
+
if ('context' in resolved) return resolved.context
|
|
75
|
+
if (askForPassword(resolved.denied)) return null
|
|
76
|
+
|
|
77
|
+
notFound()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function requireFreshAdmin(): Promise<AdminContext> {
|
|
81
|
+
const context = await requireAdmin()
|
|
82
|
+
const service = adminService()
|
|
83
|
+
if (service === null) throw new ForbiddenError(msg('error.app.control-panel-board'))
|
|
84
|
+
|
|
85
|
+
service.requireFreshProof(context)
|
|
86
|
+
return context
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function recordAdminAction(input: {
|
|
90
|
+
readonly action: string
|
|
91
|
+
readonly detail?: Readonly<Record<string, unknown>>
|
|
92
|
+
}): Promise<void> {
|
|
93
|
+
const { adminLog } = getContainer()
|
|
94
|
+
if (adminLog === null) return
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const actor = await getActor()
|
|
98
|
+
const { assertLogAction } = await import('@meith/admin')
|
|
99
|
+
assertLogAction(input.action)
|
|
100
|
+
|
|
101
|
+
await adminLog.record({
|
|
102
|
+
userId: actor.userId,
|
|
103
|
+
action: input.action,
|
|
104
|
+
detail: input.detail ?? {},
|
|
105
|
+
ipPrefix: await retainedIpPrefix(),
|
|
106
|
+
at: new Date(),
|
|
107
|
+
})
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const { logger } = await import('@meith/core')
|
|
110
|
+
logger({ module: 'admin' }).error({ err, action: input.action }, 'admin log write failed')
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { env, logger } from '@meith/core'
|
|
4
|
+
import { type AnnouncementRow, getDb, PostgresAnnouncementRepository } from '@meith/db'
|
|
5
|
+
import type { Translator } from '@meith/i18n'
|
|
6
|
+
import { type BoardVocabulary, renderMarkdown, vocabularyOptions } from '@meith/markdown'
|
|
7
|
+
import type { AnnouncementModel } from '@meith/theme-kit'
|
|
8
|
+
|
|
9
|
+
import { forumHref } from '../view/board-index'
|
|
10
|
+
import { distinctUserIds, type MemberIdentity, nameClassOf } from '../view/member-identity'
|
|
11
|
+
import { formatTime } from '../view/time'
|
|
12
|
+
import { activeVocabulary } from './content-admin'
|
|
13
|
+
import { identitiesFor } from './group-identity'
|
|
14
|
+
|
|
15
|
+
export function announcementRepository(): PostgresAnnouncementRepository | null {
|
|
16
|
+
return env.DATA_SOURCE === 'postgres' ? new PostgresAnnouncementRepository(getDb()) : null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function liveAnnouncements(input: {
|
|
20
|
+
readonly visibleForumIds: readonly number[]
|
|
21
|
+
readonly scope?: number | null
|
|
22
|
+
readonly now: Date
|
|
23
|
+
readonly t?: Translator | undefined
|
|
24
|
+
}): Promise<readonly AnnouncementModel[]> {
|
|
25
|
+
const repository = announcementRepository()
|
|
26
|
+
if (repository === null) return []
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const [rows, vocabulary] = await Promise.all([
|
|
30
|
+
repository.live({
|
|
31
|
+
now: input.now,
|
|
32
|
+
visibleForumIds: input.visibleForumIds,
|
|
33
|
+
scope: input.scope ?? null,
|
|
34
|
+
}),
|
|
35
|
+
activeVocabulary(),
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
const identities = await identitiesFor(distinctUserIds(rows.map((row) => row.authorUserId)))
|
|
39
|
+
|
|
40
|
+
return rows.map((row) => toModel(row, input.now, input.t, vocabulary, identities))
|
|
41
|
+
} catch (error) {
|
|
42
|
+
logger().warn({ err: String(error) }, 'could not read announcements')
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toModel(
|
|
48
|
+
row: AnnouncementRow,
|
|
49
|
+
now: Date,
|
|
50
|
+
translator: Translator | undefined,
|
|
51
|
+
vocabulary: BoardVocabulary | undefined,
|
|
52
|
+
identities: ReadonlyMap<number, MemberIdentity>,
|
|
53
|
+
): AnnouncementModel {
|
|
54
|
+
return {
|
|
55
|
+
title: row.title,
|
|
56
|
+
bodyHtml: renderMarkdown(row.message, {
|
|
57
|
+
...vocabularyOptions(vocabulary),
|
|
58
|
+
...(translator === undefined
|
|
59
|
+
? {}
|
|
60
|
+
: {
|
|
61
|
+
quoteAttribution: (author) => translator.t('markdown.quote.attribution', { author }),
|
|
62
|
+
}),
|
|
63
|
+
}).html,
|
|
64
|
+
postedBy:
|
|
65
|
+
row.authorUsername === ''
|
|
66
|
+
? null
|
|
67
|
+
: {
|
|
68
|
+
userId: row.authorUserId,
|
|
69
|
+
username: row.authorUsername,
|
|
70
|
+
profileHref: row.authorUserId === null ? null : `/member/${row.authorUserId}`,
|
|
71
|
+
nameClass: nameClassOf(identities, row.authorUserId),
|
|
72
|
+
},
|
|
73
|
+
postedAt: formatTime(row.startsAt, now, translator),
|
|
74
|
+
forum:
|
|
75
|
+
row.forumId === null || row.forumTitle === null || row.forumSlug === null
|
|
76
|
+
? null
|
|
77
|
+
: {
|
|
78
|
+
label: row.forumTitle,
|
|
79
|
+
href: forumHref({ id: row.forumId, slug: row.forumSlug }),
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|