@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,207 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import type { Actor } from '@meith/authorization'
|
|
4
|
+
import { ForbiddenError, type ForumPermissions, ValidationError } from '@meith/core'
|
|
5
|
+
import { msg } from '@meith/i18n'
|
|
6
|
+
import { restrictsPosting } from '@meith/moderation'
|
|
7
|
+
import { type AuthorRestriction, type ForumPostingTarget, ThreadComposer } from '@meith/threads'
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
dailyLimitMessage,
|
|
11
|
+
holdsNewMember,
|
|
12
|
+
limitMessage,
|
|
13
|
+
spendDailyLimit,
|
|
14
|
+
spendLimit,
|
|
15
|
+
} from './antispam'
|
|
16
|
+
import type { AttachmentScope } from './attachments'
|
|
17
|
+
import { getContainer } from './container'
|
|
18
|
+
import { emitEvent, filterView, viewerRef } from './plugin-view'
|
|
19
|
+
import { notifyPostAudience } from './post-notifications'
|
|
20
|
+
import { getSettings } from './settings'
|
|
21
|
+
|
|
22
|
+
export interface ResolvedThreadTarget {
|
|
23
|
+
readonly forum: ForumPostingTarget
|
|
24
|
+
readonly matrix: ForumPermissions
|
|
25
|
+
readonly scope: AttachmentScope
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function resolveThreadTarget(
|
|
29
|
+
actor: Actor,
|
|
30
|
+
forumId: number,
|
|
31
|
+
): Promise<ResolvedThreadTarget> {
|
|
32
|
+
const { authorizer, threadWrites } = getContainer()
|
|
33
|
+
|
|
34
|
+
if (threadWrites === null) {
|
|
35
|
+
throw new ValidationError(msg('error.app.board-running-in-memory-sample-data-8'))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const forum = await threadWrites.postingRules(forumId)
|
|
39
|
+
if (!forum) throw new ValidationError(msg('error.app.forum-exist'))
|
|
40
|
+
|
|
41
|
+
const matrix = await authorizer.forumMatrix(actor, forumId)
|
|
42
|
+
if (!authorizer.can(actor, 'thread.view', { forumId, forum: matrix })) {
|
|
43
|
+
throw new ValidationError(msg('error.app.forum-exist'))
|
|
44
|
+
}
|
|
45
|
+
authorizer.require(actor, 'thread.post', { forumId, forum: matrix })
|
|
46
|
+
|
|
47
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged-post'))
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
forum,
|
|
51
|
+
matrix,
|
|
52
|
+
scope: { forumId, forum: matrix, allowsAttachments: forum.allowAttachments },
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SubmitThreadInput {
|
|
57
|
+
readonly title: string
|
|
58
|
+
readonly message: string
|
|
59
|
+
readonly prefixId?: number | null
|
|
60
|
+
readonly subscribe?: boolean
|
|
61
|
+
readonly poll?:
|
|
62
|
+
| {
|
|
63
|
+
readonly question: string
|
|
64
|
+
readonly options: readonly string[]
|
|
65
|
+
readonly maxOptions?: number
|
|
66
|
+
readonly allowRevote?: boolean
|
|
67
|
+
readonly publicVotes?: boolean
|
|
68
|
+
}
|
|
69
|
+
| undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function submitThread(
|
|
73
|
+
actor: Actor,
|
|
74
|
+
resolved: ResolvedThreadTarget,
|
|
75
|
+
input: SubmitThreadInput,
|
|
76
|
+
): Promise<Awaited<ReturnType<ThreadComposer['create']>>> {
|
|
77
|
+
const { authorizer, threadWrites, memberProfiles, warnings } = getContainer()
|
|
78
|
+
if (threadWrites === null) {
|
|
79
|
+
throw new ValidationError(msg('error.app.board-running-in-memory-sample-data-8'))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const userId = actor.userId
|
|
83
|
+
if (userId === null) throw new ForbiddenError(msg('error.app.must-logged-post'))
|
|
84
|
+
|
|
85
|
+
const settings = await getSettings()
|
|
86
|
+
const { forum, matrix, scope } = resolved
|
|
87
|
+
const target = { forumId: forum.id, forum: matrix }
|
|
88
|
+
|
|
89
|
+
const limited = await spendLimit({ scope: 'post', actor, settings })
|
|
90
|
+
if (limited !== null && !limited.allowed) throw new ValidationError(limitMessage(limited))
|
|
91
|
+
|
|
92
|
+
const daily = await spendDailyLimit({ scope: 'post_day', actor })
|
|
93
|
+
if (daily !== null && !daily.allowed) {
|
|
94
|
+
throw new ValidationError(dailyLimitMessage('post_day', daily))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const profile = await memberProfiles.findPublicById(userId)
|
|
98
|
+
if (!profile) throw new ForbiddenError(msg('error.app.account-longer-post'))
|
|
99
|
+
|
|
100
|
+
const draft = await filterView(
|
|
101
|
+
'thread.create.before',
|
|
102
|
+
{
|
|
103
|
+
subject: input.title,
|
|
104
|
+
body: input.message,
|
|
105
|
+
prefixId: input.prefixId ?? null,
|
|
106
|
+
forumId: forum.id,
|
|
107
|
+
authorId: userId,
|
|
108
|
+
},
|
|
109
|
+
viewerRef(actor),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const objections = await filterView('thread.create.validate', [], { draft })
|
|
113
|
+
if (objections.length > 0) throw new ValidationError(objections[0]!)
|
|
114
|
+
|
|
115
|
+
const composer = new ThreadComposer({
|
|
116
|
+
threads: threadWrites,
|
|
117
|
+
config: {
|
|
118
|
+
floodSeconds: settings.get('posting.flood_seconds'),
|
|
119
|
+
maxLength: settings.get('posting.max_length'),
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const created = await composer.create(
|
|
124
|
+
{
|
|
125
|
+
title: draft.subject ?? input.title,
|
|
126
|
+
message: draft.body,
|
|
127
|
+
prefixId: draft.prefixId,
|
|
128
|
+
subscribe: input.subscribe ?? false,
|
|
129
|
+
heldAsNewMember: await holdsNewMember({ actor, postCount: profile.postCount, settings }),
|
|
130
|
+
requiresApproval: scope.forum.requiresThreadApproval === true,
|
|
131
|
+
...(input.poll === undefined
|
|
132
|
+
? {}
|
|
133
|
+
: {
|
|
134
|
+
poll: {
|
|
135
|
+
question: input.poll.question,
|
|
136
|
+
options: input.poll.options,
|
|
137
|
+
closesAt: null,
|
|
138
|
+
...(input.poll.maxOptions === undefined ? {} : { maxOptions: input.poll.maxOptions }),
|
|
139
|
+
...(input.poll.allowRevote === undefined
|
|
140
|
+
? {}
|
|
141
|
+
: { allowRevote: input.poll.allowRevote }),
|
|
142
|
+
...(input.poll.publicVotes === undefined
|
|
143
|
+
? {}
|
|
144
|
+
: { publicVotes: input.poll.publicVotes }),
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
mayPostPoll: authorizer.can(actor, 'poll.post', target),
|
|
148
|
+
bypassesModeration: authorizer.can(actor, 'content.viewUnapproved', target),
|
|
149
|
+
bypassesFlood: authorizer.can(actor, 'flood.bypass'),
|
|
150
|
+
restriction: await authorRestriction(warnings, userId),
|
|
151
|
+
},
|
|
152
|
+
{ userId, username: profile.username },
|
|
153
|
+
forum,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if (created.visibility === 'unapproved') {
|
|
157
|
+
await emitEvent('approval.queued', { kind: 'thread', id: created.threadId }, viewerRef(actor))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (input.poll !== undefined) {
|
|
161
|
+
const { polls } = getContainer()
|
|
162
|
+
const poll = polls === null ? null : await polls.find(created.threadId, null)
|
|
163
|
+
if (poll !== null) {
|
|
164
|
+
await emitEvent(
|
|
165
|
+
'poll.created',
|
|
166
|
+
{
|
|
167
|
+
threadId: created.threadId,
|
|
168
|
+
forumId: forum.id,
|
|
169
|
+
pollId: poll.id,
|
|
170
|
+
optionCount: poll.options.length,
|
|
171
|
+
},
|
|
172
|
+
viewerRef(actor),
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await emitEvent(
|
|
178
|
+
'thread.created',
|
|
179
|
+
{
|
|
180
|
+
threadId: created.threadId,
|
|
181
|
+
forumId: forum.id,
|
|
182
|
+
authorId: userId,
|
|
183
|
+
subject: draft.subject ?? input.title,
|
|
184
|
+
},
|
|
185
|
+
viewerRef(actor),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
await notifyPostAudience({
|
|
189
|
+
postId: created.postId,
|
|
190
|
+
threadId: created.threadId,
|
|
191
|
+
threadSlug: created.slug,
|
|
192
|
+
threadTitle: draft.subject ?? input.title,
|
|
193
|
+
message: draft.body,
|
|
194
|
+
authorUsername: profile.username,
|
|
195
|
+
visibility: created.visibility,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
return created
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function authorRestriction(
|
|
202
|
+
warnings: ReturnType<typeof getContainer>['warnings'],
|
|
203
|
+
userId: number,
|
|
204
|
+
): Promise<AuthorRestriction> {
|
|
205
|
+
if (warnings === null) return { suspended: false, moderated: false }
|
|
206
|
+
return restrictsPosting(await warnings.readRestriction(userId), new Date())
|
|
207
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { redirect } from 'next/navigation'
|
|
4
|
+
|
|
5
|
+
import { ForbiddenError, ValidationError } from '@meith/core'
|
|
6
|
+
import { canHoldThreads } from '@meith/forums'
|
|
7
|
+
import { msg } from '@meith/i18n'
|
|
8
|
+
import { ThreadRatingService } from '@meith/polls'
|
|
9
|
+
|
|
10
|
+
import { getContainer } from './container'
|
|
11
|
+
import { getActor } from './context'
|
|
12
|
+
import { emitEvent, viewerRef } from './plugin-view'
|
|
13
|
+
import { getSettings } from './settings'
|
|
14
|
+
|
|
15
|
+
const number = (form: FormData, name: string) => {
|
|
16
|
+
const value = Number(form.get(name))
|
|
17
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
18
|
+
throw new ValidationError(msg('error.app.thread-exist'))
|
|
19
|
+
return value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function rateThreadAction(form: FormData): Promise<void> {
|
|
23
|
+
const threadId = number(form, 'threadId')
|
|
24
|
+
const rating = Number(form.get('rating'))
|
|
25
|
+
const actor = await getActor()
|
|
26
|
+
const { authorizer, forums, polls, threads } = getContainer()
|
|
27
|
+
if (actor.userId === null || polls === null)
|
|
28
|
+
throw new ForbiddenError(msg('error.app.must-logged-rate-thread'))
|
|
29
|
+
const located = await threads.locate(threadId)
|
|
30
|
+
const forumId = located?.forumId ?? null
|
|
31
|
+
const forum = forumId === null ? null : await forums.findById(forumId)
|
|
32
|
+
if (forumId === null || forum === null || !canHoldThreads(forum.type))
|
|
33
|
+
throw new ValidationError(msg('error.app.thread-exist'))
|
|
34
|
+
const target = {
|
|
35
|
+
forumId,
|
|
36
|
+
forum: await authorizer.forumMatrix(actor, forumId),
|
|
37
|
+
threadAuthorId: located?.authorUserId ?? null,
|
|
38
|
+
}
|
|
39
|
+
if (!authorizer.can(actor, 'thread.view', target))
|
|
40
|
+
throw new ValidationError(msg('error.app.thread-exist'))
|
|
41
|
+
const recorded = await new ThreadRatingService(polls).rate({
|
|
42
|
+
threadId,
|
|
43
|
+
userId: actor.userId,
|
|
44
|
+
rating,
|
|
45
|
+
enabled:
|
|
46
|
+
(await getSettings()).get('posting.thread_ratings_enabled') &&
|
|
47
|
+
authorizer.can(actor, 'thread.rate', target),
|
|
48
|
+
})
|
|
49
|
+
await emitEvent(
|
|
50
|
+
'rating.recorded',
|
|
51
|
+
{ threadId, rating, average: recorded.average },
|
|
52
|
+
viewerRef(actor),
|
|
53
|
+
)
|
|
54
|
+
redirect(`/thread/${threadId}`)
|
|
55
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { redirect } from 'next/navigation'
|
|
4
|
+
|
|
5
|
+
import type { Action } from '@meith/authorization'
|
|
6
|
+
import { ForbiddenError, ValidationError } from '@meith/core'
|
|
7
|
+
import { msg } from '@meith/i18n'
|
|
8
|
+
import { parseThreadTool, type ThreadToolRights, ThreadTools } from '@meith/moderation'
|
|
9
|
+
|
|
10
|
+
import type { FormState } from './auth-form-state'
|
|
11
|
+
import { getContainer } from './container'
|
|
12
|
+
import { getActor } from './context'
|
|
13
|
+
import { formStateReporter } from './form-state-reporter'
|
|
14
|
+
import { positiveInt } from './form-values'
|
|
15
|
+
import { tr } from './i18n'
|
|
16
|
+
import { emitEvent } from './plugin-view'
|
|
17
|
+
|
|
18
|
+
const toFormState = formStateReporter('thread-tool-actions', 'unexpected error in thread tools')
|
|
19
|
+
|
|
20
|
+
const TOOL_ACTIONS: Readonly<Record<keyof ThreadToolRights, Action>> = {
|
|
21
|
+
lock: 'thread.lock',
|
|
22
|
+
stick: 'thread.stick',
|
|
23
|
+
move: 'thread.move',
|
|
24
|
+
delete: 'thread.delete',
|
|
25
|
+
restore: 'thread.restore',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function threadToolAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
29
|
+
const threadId = positiveInt(form, 'threadId')
|
|
30
|
+
const tool = parseThreadTool(
|
|
31
|
+
typeof form.get('tool') === 'string' ? (form.get('tool') as string) : undefined,
|
|
32
|
+
)
|
|
33
|
+
const toForumId = positiveInt(form, 'toForumId')
|
|
34
|
+
|
|
35
|
+
if (threadId === null || tool === null) {
|
|
36
|
+
return { error: await tr('notice.app.thread-exist') }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { authorizer, threadTools, threads } = getContainer()
|
|
40
|
+
if (threadTools === null) {
|
|
41
|
+
return {
|
|
42
|
+
error: await tr('notice.app.board-running-in-memory-sample-data-3'),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let outcome: Awaited<ReturnType<ThreadTools['apply']>>
|
|
47
|
+
try {
|
|
48
|
+
const actor = await getActor()
|
|
49
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
|
|
50
|
+
|
|
51
|
+
const target = await threadTools.find(threadId)
|
|
52
|
+
if (target === null) throw new ValidationError(msg('error.app.thread-exist'))
|
|
53
|
+
|
|
54
|
+
const matrix = await authorizer.forumMatrix(actor, target.forumId)
|
|
55
|
+
if (!authorizer.can(actor, 'thread.view', { forumId: target.forumId, forum: matrix })) {
|
|
56
|
+
throw new ValidationError(msg('error.app.thread-exist'))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const threadAuthorId = (await threads.locate(threadId))?.authorUserId ?? null
|
|
60
|
+
const rights = await resolveRights(target.forumId, threadAuthorId)
|
|
61
|
+
const destinationRights =
|
|
62
|
+
(tool === 'move' || tool === 'copy') && toForumId !== null
|
|
63
|
+
? await resolveRights(toForumId, threadAuthorId)
|
|
64
|
+
: undefined
|
|
65
|
+
|
|
66
|
+
outcome = await new ThreadTools({ threads: threadTools }).apply({
|
|
67
|
+
threadId,
|
|
68
|
+
tool,
|
|
69
|
+
...(toForumId === null ? {} : { toForumId }),
|
|
70
|
+
actorUserId: actor.userId,
|
|
71
|
+
rights,
|
|
72
|
+
...(destinationRights === undefined ? {} : { destinationRights }),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
if (outcome.changed) {
|
|
76
|
+
await announceTool(outcome.tool, {
|
|
77
|
+
threadId,
|
|
78
|
+
fromForumId: target.forumId,
|
|
79
|
+
toForumId,
|
|
80
|
+
moderatorId: actor.userId,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return toFormState(err)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (outcome.tool === 'copy') {
|
|
88
|
+
redirect(`/thread/${outcome.threadId}-${outcome.slug}?tool=copy`)
|
|
89
|
+
}
|
|
90
|
+
if (outcome.tool === 'delete') {
|
|
91
|
+
redirect(`/${(await threadTools.find(threadId))?.forumId ?? ''}?thread=deleted`)
|
|
92
|
+
}
|
|
93
|
+
redirect(`/thread/${outcome.threadId}-${outcome.slug}?tool=${outcome.tool}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function announceTool(
|
|
97
|
+
tool: Awaited<ReturnType<ThreadTools['apply']>>['tool'],
|
|
98
|
+
input: {
|
|
99
|
+
readonly threadId: number
|
|
100
|
+
readonly fromForumId: number
|
|
101
|
+
readonly toForumId: number | null
|
|
102
|
+
readonly moderatorId: number
|
|
103
|
+
},
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
const moderation = { moderatorId: input.moderatorId, reason: null }
|
|
106
|
+
const thread = { threadId: input.threadId, forumId: input.fromForumId }
|
|
107
|
+
|
|
108
|
+
await emitEvent(
|
|
109
|
+
'moderation.logged',
|
|
110
|
+
{ action: `thread.${tool}`, targetId: input.threadId },
|
|
111
|
+
moderation,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if (tool === 'lock' || tool === 'unlock') {
|
|
115
|
+
await emitEvent('thread.locked', { ...thread, isLocked: tool === 'lock' }, moderation)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
if (tool === 'stick' || tool === 'unstick') {
|
|
119
|
+
await emitEvent('thread.stickied', { ...thread, isSticky: tool === 'stick' }, moderation)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
if (tool === 'move' && input.toForumId !== null) {
|
|
123
|
+
await emitEvent(
|
|
124
|
+
'thread.moved',
|
|
125
|
+
{
|
|
126
|
+
threadId: input.threadId,
|
|
127
|
+
fromForumId: input.fromForumId,
|
|
128
|
+
toForumId: input.toForumId,
|
|
129
|
+
},
|
|
130
|
+
moderation,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function resolveRights(
|
|
136
|
+
forumId: number,
|
|
137
|
+
threadAuthorId: number | null,
|
|
138
|
+
): Promise<ThreadToolRights> {
|
|
139
|
+
const actor = await getActor()
|
|
140
|
+
const { authorizer } = getContainer()
|
|
141
|
+
const [forum, moderatorRights] = await Promise.all([
|
|
142
|
+
authorizer.forumMatrix(actor, forumId),
|
|
143
|
+
authorizer.moderatorRightsIn(actor, forumId),
|
|
144
|
+
])
|
|
145
|
+
const target = { forumId, forum, moderatorRights, threadAuthorId }
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
lock: authorizer.can(actor, TOOL_ACTIONS.lock, target),
|
|
149
|
+
stick: authorizer.can(actor, TOOL_ACTIONS.stick, target),
|
|
150
|
+
move: authorizer.can(actor, TOOL_ACTIONS.move, target),
|
|
151
|
+
delete: authorizer.can(actor, TOOL_ACTIONS.delete, target),
|
|
152
|
+
restore: authorizer.can(actor, TOOL_ACTIONS.restore, target),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { redirect } from 'next/navigation'
|
|
4
|
+
|
|
5
|
+
import { verifyPassword } from '@meith/accounts'
|
|
6
|
+
import { ForbiddenError, ValidationError } from '@meith/core'
|
|
7
|
+
import { msg } from '@meith/i18n'
|
|
8
|
+
|
|
9
|
+
import { RECOVERY_CODES_FIELD } from '@/view/two-factor'
|
|
10
|
+
|
|
11
|
+
import { recordAuthEvent } from './auth-events'
|
|
12
|
+
import type { FormState } from './auth-form-state'
|
|
13
|
+
import { getContainer } from './container'
|
|
14
|
+
import { getActor } from './context'
|
|
15
|
+
import { assertDemoAccountChangeable } from './demo'
|
|
16
|
+
import { formStateReporter } from './form-state-reporter'
|
|
17
|
+
import { text } from './form-values'
|
|
18
|
+
import { getSettingsUncached } from './settings'
|
|
19
|
+
import { secondFactorPosture, twoFactorOffered, twoFactorService } from './two-factor'
|
|
20
|
+
|
|
21
|
+
const toFormState = formStateReporter(
|
|
22
|
+
'two-factor-actions',
|
|
23
|
+
'unexpected error while changing two-factor authentication',
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
interface Owner {
|
|
27
|
+
readonly userId: number
|
|
28
|
+
readonly hasPassword: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function ownAccount(): Promise<Owner> {
|
|
32
|
+
const actor = await getActor()
|
|
33
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
|
|
34
|
+
|
|
35
|
+
await assertDemoAccountChangeable(actor.userId, 'sign-in method')
|
|
36
|
+
|
|
37
|
+
const account = await getContainer().accountStore.accounts.findById(actor.userId)
|
|
38
|
+
if (account === null) throw new ForbiddenError(msg('error.app.account-longer-exists'))
|
|
39
|
+
|
|
40
|
+
return { userId: actor.userId, hasPassword: account.passwordHash !== null }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function requireOwnAccount(): Promise<Owner> {
|
|
44
|
+
if (!(await twoFactorOffered())) {
|
|
45
|
+
throw new ForbiddenError(msg('error.app.board-offer-two-factor-authentication'))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return ownAccount()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function requireOwnFactor(): Promise<Owner> {
|
|
52
|
+
const owner = await ownAccount()
|
|
53
|
+
if (await twoFactorOffered()) return owner
|
|
54
|
+
|
|
55
|
+
const service = twoFactorService()
|
|
56
|
+
if (service !== null && (await service.isEnrolled(owner.userId))) return owner
|
|
57
|
+
|
|
58
|
+
throw new ForbiddenError(msg('error.app.board-offer-two-factor-authentication'))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function beginTwoFactorAction(_prev: FormState, _form: FormData): Promise<FormState> {
|
|
62
|
+
try {
|
|
63
|
+
const { userId } = await requireOwnAccount()
|
|
64
|
+
|
|
65
|
+
const service = twoFactorService()
|
|
66
|
+
if (service === null) throw new ForbiddenError(msg('error.app.board-seal-secret'))
|
|
67
|
+
|
|
68
|
+
const name = (await getSettingsUncached()).get('board.name')
|
|
69
|
+
await service.beginEnrolment(userId, name.trim() === '' ? 'Meith' : name)
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return toFormState(err)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
redirect('/usercp/security?factor=setup')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function abandonTwoFactorAction(
|
|
78
|
+
_prev: FormState,
|
|
79
|
+
_form: FormData,
|
|
80
|
+
): Promise<FormState> {
|
|
81
|
+
try {
|
|
82
|
+
const { userId } = await requireOwnAccount()
|
|
83
|
+
|
|
84
|
+
const service = twoFactorService()
|
|
85
|
+
if (service !== null) await service.abandonEnrolment(userId)
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return toFormState(err)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
redirect('/usercp/security')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function confirmTwoFactorAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
94
|
+
try {
|
|
95
|
+
const { userId } = await requireOwnAccount()
|
|
96
|
+
|
|
97
|
+
const service = twoFactorService()
|
|
98
|
+
if (service === null) throw new ForbiddenError(msg('error.app.board-seal-secret'))
|
|
99
|
+
|
|
100
|
+
const codes = await service.confirmEnrolment({ userId, code: text(form, 'code') })
|
|
101
|
+
await recordAuthEvent({ userId, kind: 'second_factor_enabled' })
|
|
102
|
+
|
|
103
|
+
return { notice: 'saved', values: { [RECOVERY_CODES_FIELD]: codes.join('\n') } }
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return toFormState(err)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function replaceRecoveryCodesAction(
|
|
110
|
+
_prev: FormState,
|
|
111
|
+
form: FormData,
|
|
112
|
+
): Promise<FormState> {
|
|
113
|
+
try {
|
|
114
|
+
const { userId, hasPassword } = await requireOwnFactor()
|
|
115
|
+
|
|
116
|
+
const service = twoFactorService()
|
|
117
|
+
if (service === null) throw new ForbiddenError(msg('error.app.board-seal-secret'))
|
|
118
|
+
|
|
119
|
+
await assertStillThem({ userId, hasPassword, form, service })
|
|
120
|
+
|
|
121
|
+
const codes = await service.replaceRecoveryCodes(userId)
|
|
122
|
+
await recordAuthEvent({ userId, kind: 'recovery_codes_replaced' })
|
|
123
|
+
|
|
124
|
+
return { notice: 'saved', values: { [RECOVERY_CODES_FIELD]: codes.join('\n') } }
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return toFormState(err)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function disableTwoFactorAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
131
|
+
try {
|
|
132
|
+
const { userId, hasPassword } = await requireOwnFactor()
|
|
133
|
+
|
|
134
|
+
const service = twoFactorService()
|
|
135
|
+
if (service === null) throw new ForbiddenError(msg('error.app.board-seal-secret'))
|
|
136
|
+
|
|
137
|
+
await assertStillThem({ userId, hasPassword, form, service })
|
|
138
|
+
|
|
139
|
+
const posture = await secondFactorPosture(userId)
|
|
140
|
+
await service.disable({ userId, required: posture.required })
|
|
141
|
+
await recordAuthEvent({ userId, kind: 'second_factor_disabled' })
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return toFormState(err)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
redirect('/usercp/security?factor=off')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Weakening an account asks for the thing being weakened. An account with a
|
|
151
|
+
* password gives it; one without — signed in by a provider or a passkey —
|
|
152
|
+
* gives a current code instead, which proves the same possession.
|
|
153
|
+
*/
|
|
154
|
+
async function assertStillThem(input: {
|
|
155
|
+
readonly userId: number
|
|
156
|
+
readonly hasPassword: boolean
|
|
157
|
+
readonly form: FormData
|
|
158
|
+
readonly service: NonNullable<ReturnType<typeof twoFactorService>>
|
|
159
|
+
}): Promise<void> {
|
|
160
|
+
if (input.hasPassword) {
|
|
161
|
+
const account = await getContainer().accountStore.accounts.findById(input.userId)
|
|
162
|
+
const password = text(input.form, 'password')
|
|
163
|
+
|
|
164
|
+
if (password === '' || !(await verifyPassword(password, account?.passwordHash))) {
|
|
165
|
+
throw new ValidationError(msg('error.app.current-password'))
|
|
166
|
+
}
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const outcome = await input.service.verify({
|
|
171
|
+
userId: input.userId,
|
|
172
|
+
code: text(input.form, 'code'),
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
if (outcome.status !== 'ok') {
|
|
176
|
+
throw new ValidationError(msg('error.app.enter-current-code-from-authenticator'))
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
clearSecondFactor,
|
|
5
|
+
type Enrolment,
|
|
6
|
+
holdsSecondFactor,
|
|
7
|
+
TwoFactorService,
|
|
8
|
+
type TwoFactorState,
|
|
9
|
+
} from '@meith/accounts'
|
|
10
|
+
import { env } from '@meith/core'
|
|
11
|
+
|
|
12
|
+
import { getContainer } from './container'
|
|
13
|
+
import { getActor } from './context'
|
|
14
|
+
import { getSettingsUncached } from './settings'
|
|
15
|
+
|
|
16
|
+
export interface SecondFactorPosture {
|
|
17
|
+
readonly offered: boolean
|
|
18
|
+
readonly required: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function twoFactorService(): TwoFactorService | null {
|
|
22
|
+
const key = env.AUTH_SECRET
|
|
23
|
+
if (key === undefined || key.trim() === '') return null
|
|
24
|
+
|
|
25
|
+
const { accountStore } = getContainer()
|
|
26
|
+
|
|
27
|
+
return new TwoFactorService({
|
|
28
|
+
accounts: accountStore.accounts,
|
|
29
|
+
twoFactor: accountStore.twoFactor,
|
|
30
|
+
recoveryCodes: accountStore.recoveryCodes,
|
|
31
|
+
sealingKey: key,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Offered when the board says so and the board has a key to seal a secret
|
|
37
|
+
* with. Without AUTH_SECRET there is nowhere safe to put the secret, so the
|
|
38
|
+
* surface is hidden rather than half-built.
|
|
39
|
+
*/
|
|
40
|
+
export async function twoFactorOffered(): Promise<boolean> {
|
|
41
|
+
if (getContainer().dataSource !== 'postgres') return false
|
|
42
|
+
if (twoFactorService() === null) return false
|
|
43
|
+
|
|
44
|
+
return (await getSettingsUncached()).get('security.two_factor_enabled')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function twoFactorRequiredForStaff(): Promise<boolean> {
|
|
48
|
+
if (!(await twoFactorOffered())) return false
|
|
49
|
+
return (await getSettingsUncached()).get('security.two_factor_required_for_staff')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether this member must hold a second factor, and may not drop it. */
|
|
53
|
+
export async function secondFactorPosture(userId: number | null): Promise<SecondFactorPosture> {
|
|
54
|
+
const offered = await twoFactorOffered()
|
|
55
|
+
if (!offered || userId === null) return { offered, required: false }
|
|
56
|
+
|
|
57
|
+
if (!(await twoFactorRequiredForStaff())) return { offered, required: false }
|
|
58
|
+
|
|
59
|
+
const actor = await getActor()
|
|
60
|
+
if (actor.userId !== userId) return { offered, required: false }
|
|
61
|
+
|
|
62
|
+
return { offered, required: getContainer().authorizer.can(actor, 'admincp.access') }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function memberHoldsSecondFactor(userId: number): Promise<boolean> {
|
|
66
|
+
return holdsSecondFactor(getContainer().accountStore.twoFactor, userId)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function clearMemberSecondFactor(userId: number): Promise<boolean> {
|
|
70
|
+
return clearSecondFactor(getContainer().accountStore, userId)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function pendingEnrolment(userId: number): Promise<Enrolment | null> {
|
|
74
|
+
const service = twoFactorService()
|
|
75
|
+
if (service === null) return null
|
|
76
|
+
|
|
77
|
+
const name = (await getSettingsUncached()).get('board.name')
|
|
78
|
+
return service.pendingEnrolment(userId, name.trim() === '' ? 'Meith' : name)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function twoFactorState(userId: number): Promise<TwoFactorState> {
|
|
82
|
+
const service = twoFactorService()
|
|
83
|
+
if (service === null) return { enrolled: false, pending: false, recoveryCodesLeft: 0 }
|
|
84
|
+
|
|
85
|
+
return service.state(userId)
|
|
86
|
+
}
|