@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,62 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
renderStoredSignature,
|
|
5
|
+
type SignatureLimits,
|
|
6
|
+
type StoredSignature,
|
|
7
|
+
} from '@meith/signatures'
|
|
8
|
+
|
|
9
|
+
import { getContainer } from './container'
|
|
10
|
+
import { getActor } from './context'
|
|
11
|
+
import { boardRendering } from './markdown-pipeline'
|
|
12
|
+
import { filterView, viewerRef } from './plugin-view'
|
|
13
|
+
|
|
14
|
+
export function signatureStore() {
|
|
15
|
+
return getContainer().signatures
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function viewerSignatureLimits(): Promise<SignatureLimits> {
|
|
19
|
+
const actor = await getActor()
|
|
20
|
+
const { authorizer } = getContainer()
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
canUse: authorizer.can(actor, 'signature.use'),
|
|
24
|
+
maxLength: authorizer.globalLimit(actor, 'maxSignatureLength'),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function renderSignature(
|
|
29
|
+
authorId: number,
|
|
30
|
+
stored: StoredSignature,
|
|
31
|
+
): Promise<string | null> {
|
|
32
|
+
const html = await renderStoredSignature(stored, { authorId, rendering: boardRendering })
|
|
33
|
+
if (html === null) return null
|
|
34
|
+
|
|
35
|
+
return filterView('signature.html', html, { ...viewerRef(await getActor()), authorId })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function signaturesFor(
|
|
39
|
+
userIds: readonly number[],
|
|
40
|
+
): Promise<ReadonlyMap<number, string>> {
|
|
41
|
+
const store = signatureStore()
|
|
42
|
+
if (store === null || userIds.length === 0) return new Map()
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const viewer = viewerRef(await getActor())
|
|
46
|
+
const stored = await store.readMany(userIds)
|
|
47
|
+
const out = new Map<number, string>()
|
|
48
|
+
|
|
49
|
+
for (const [userId, signature] of stored) {
|
|
50
|
+
const html = await renderStoredSignature(signature, {
|
|
51
|
+
authorId: userId,
|
|
52
|
+
rendering: boardRendering,
|
|
53
|
+
})
|
|
54
|
+
if (html === null) continue
|
|
55
|
+
|
|
56
|
+
out.set(userId, await filterView('signature.html', html, { ...viewer, authorId: userId }))
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
} catch {
|
|
60
|
+
return new Map()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { decodeBase64UrlText, encodeBase64Url, isProviderKind } from '@meith/accounts'
|
|
2
|
+
|
|
3
|
+
import { isSafeLocalPath } from './safe-path'
|
|
4
|
+
|
|
5
|
+
export type HandshakeMode = 'sign-in' | 'link' | 'credential-proof'
|
|
6
|
+
|
|
7
|
+
export interface Handshake {
|
|
8
|
+
readonly provider: string
|
|
9
|
+
readonly mode: HandshakeMode
|
|
10
|
+
readonly state: string
|
|
11
|
+
readonly nonce: string
|
|
12
|
+
readonly codeVerifier: string
|
|
13
|
+
readonly next: string
|
|
14
|
+
readonly authorizationUrl: string
|
|
15
|
+
readonly userId?: number
|
|
16
|
+
readonly sessionId?: number
|
|
17
|
+
readonly provedAt?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isProviderAddress(value: string): boolean {
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(value)
|
|
23
|
+
return url.protocol === 'https:' || url.protocol === 'http:'
|
|
24
|
+
} catch {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function encodeHandshake(handshake: Handshake): string {
|
|
30
|
+
return encodeBase64Url(new TextEncoder().encode(JSON.stringify(handshake)))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function decodeHandshake(raw: string | undefined): Handshake | null {
|
|
34
|
+
if (raw === undefined || raw === '') return null
|
|
35
|
+
|
|
36
|
+
let parsed: unknown
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(decodeBase64UrlText(raw))
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
44
|
+
const candidate = parsed as Record<string, unknown>
|
|
45
|
+
|
|
46
|
+
const provider = text(candidate.provider)
|
|
47
|
+
const mode = candidate.mode
|
|
48
|
+
const state = text(candidate.state)
|
|
49
|
+
const nonce = text(candidate.nonce)
|
|
50
|
+
const codeVerifier = text(candidate.codeVerifier)
|
|
51
|
+
const next = text(candidate.next)
|
|
52
|
+
const authorizationUrl = text(candidate.authorizationUrl)
|
|
53
|
+
|
|
54
|
+
if (provider === null || !isProviderKind(provider)) return null
|
|
55
|
+
if (mode !== 'sign-in' && mode !== 'link' && mode !== 'credential-proof') return null
|
|
56
|
+
if (state === null || nonce === null || codeVerifier === null) return null
|
|
57
|
+
if (authorizationUrl === null || !isProviderAddress(authorizationUrl)) return null
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
provider,
|
|
61
|
+
mode,
|
|
62
|
+
state,
|
|
63
|
+
nonce,
|
|
64
|
+
codeVerifier,
|
|
65
|
+
authorizationUrl,
|
|
66
|
+
next: next !== null && isSafeLocalPath(next) ? next : '/',
|
|
67
|
+
...(integer(candidate.userId) === null ? {} : { userId: integer(candidate.userId)! }),
|
|
68
|
+
...(integer(candidate.sessionId) === null ? {} : { sessionId: integer(candidate.sessionId)! }),
|
|
69
|
+
...(integer(candidate.provedAt) === null ? {} : { provedAt: integer(candidate.provedAt)! }),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function integer(value: unknown): number | null {
|
|
74
|
+
return typeof value === 'number' && Number.isInteger(value) ? value : null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function text(value: unknown): string | null {
|
|
78
|
+
return typeof value === 'string' && value !== '' ? value : null
|
|
79
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import type { Actor } from '@meith/authorization'
|
|
4
|
+
import { contentScopeFrom } from '@meith/core'
|
|
5
|
+
import {
|
|
6
|
+
type BoardTotals,
|
|
7
|
+
getDb,
|
|
8
|
+
PostgresStatsRepository,
|
|
9
|
+
type StatsScope,
|
|
10
|
+
type TopPoster,
|
|
11
|
+
type TopThread,
|
|
12
|
+
} from '@meith/db'
|
|
13
|
+
|
|
14
|
+
import { getContainer } from './container'
|
|
15
|
+
|
|
16
|
+
export const LEADERBOARD_SIZE = 10
|
|
17
|
+
|
|
18
|
+
export function statsRepository(): PostgresStatsRepository | null {
|
|
19
|
+
return getContainer().dataSource === 'postgres' ? new PostgresStatsRepository(getDb()) : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function statsScopeFor(actor: Actor): Promise<StatsScope> {
|
|
23
|
+
const { authorizer } = getContainer()
|
|
24
|
+
const staff = actor.global.isAdministrator === true || actor.global.isSuperModerator === true
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
...(await authorizer.threadAudience(actor)),
|
|
28
|
+
content: contentScopeFrom({ seesUnapproved: staff, seesDeleted: staff }),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface StatsView {
|
|
33
|
+
readonly totals: BoardTotals
|
|
34
|
+
readonly topPosters: readonly TopPoster[]
|
|
35
|
+
readonly mostViewed: readonly TopThread[]
|
|
36
|
+
readonly mostReplied: readonly TopThread[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function buildStatsView(actor: Actor): Promise<StatsView | null> {
|
|
40
|
+
const repo = statsRepository()
|
|
41
|
+
if (repo === null) return null
|
|
42
|
+
|
|
43
|
+
const scope = await statsScopeFor(actor)
|
|
44
|
+
const [totals, topPosters, mostViewed, mostReplied] = await Promise.all([
|
|
45
|
+
repo.readTotals(),
|
|
46
|
+
repo.topPosters(LEADERBOARD_SIZE),
|
|
47
|
+
repo.mostViewed(LEADERBOARD_SIZE, scope),
|
|
48
|
+
repo.mostReplied(LEADERBOARD_SIZE, scope),
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
return { totals, topPosters, mostViewed, mostReplied }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function readTotals(): Promise<BoardTotals | null> {
|
|
55
|
+
const repo = statsRepository()
|
|
56
|
+
if (repo === null) return null
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return await repo.readTotals()
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { redirect } from 'next/navigation'
|
|
4
|
+
|
|
5
|
+
import { env, ForbiddenError } from '@meith/core'
|
|
6
|
+
import { currentRequestId } from '@meith/core/logger'
|
|
7
|
+
import { msg } from '@meith/i18n'
|
|
8
|
+
import {
|
|
9
|
+
parseSubscriptionTarget,
|
|
10
|
+
readUnsubscribeToken,
|
|
11
|
+
SubscriptionService,
|
|
12
|
+
} from '@meith/subscriptions'
|
|
13
|
+
|
|
14
|
+
import type { FormState } from './auth-form-state'
|
|
15
|
+
import { getContainer } from './container'
|
|
16
|
+
import { getActor } from './context'
|
|
17
|
+
import { formStateReporter } from './form-state-reporter'
|
|
18
|
+
import { positiveInt, text } from './form-values'
|
|
19
|
+
import { tr } from './i18n'
|
|
20
|
+
import { emitEvent } from './plugin-view'
|
|
21
|
+
import { isSafeLocalPath } from './safe-path'
|
|
22
|
+
|
|
23
|
+
const toFormState = formStateReporter('subscription-actions', 'unexpected error in subscriptions')
|
|
24
|
+
|
|
25
|
+
async function mayView(target: 'thread' | 'forum', targetId: number): Promise<boolean> {
|
|
26
|
+
const actor = await getActor()
|
|
27
|
+
const { authorizer, threads, forums } = getContainer()
|
|
28
|
+
|
|
29
|
+
const located = target === 'forum' ? null : await threads.locate(targetId)
|
|
30
|
+
const forumId = target === 'forum' ? targetId : (located?.forumId ?? null)
|
|
31
|
+
if (forumId === null) return false
|
|
32
|
+
|
|
33
|
+
const forum = await forums.findById(forumId)
|
|
34
|
+
if (!forum) return false
|
|
35
|
+
|
|
36
|
+
const matrix = await authorizer.forumMatrix(actor, forumId)
|
|
37
|
+
return target === 'forum'
|
|
38
|
+
? authorizer.can(actor, 'forum.view', { forumId, forum: matrix })
|
|
39
|
+
: authorizer.can(actor, 'thread.view', {
|
|
40
|
+
...(await authorizer.moderatorTargetIn(actor, forumId, matrix)),
|
|
41
|
+
threadAuthorId: located?.authorUserId ?? null,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function subscribeAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
46
|
+
const target = parseSubscriptionTarget(text(form, 'target'))
|
|
47
|
+
const targetId = positiveInt(form, 'targetId')
|
|
48
|
+
const back = text(form, 'back')
|
|
49
|
+
|
|
50
|
+
if (target === null || targetId === null) return { error: await tr('notice.app.exist') }
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const actor = await getActor()
|
|
54
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
|
|
55
|
+
|
|
56
|
+
const { subscriptions } = getContainer()
|
|
57
|
+
if (subscriptions === null) {
|
|
58
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-17'))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await new SubscriptionService({ subscriptions }).subscribe({
|
|
62
|
+
userId: actor.userId,
|
|
63
|
+
target,
|
|
64
|
+
targetId,
|
|
65
|
+
mode: text(form, 'mode'),
|
|
66
|
+
mayView: await mayView(target, targetId),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
await emitEvent(
|
|
70
|
+
'subscription.changed',
|
|
71
|
+
{ userId: actor.userId, target, targetId, subscribed: true },
|
|
72
|
+
{ requestId: currentRequestId() ?? null },
|
|
73
|
+
)
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return toFormState(err)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
redirect(safeReturn(back, '/subscriptions?followed=1'))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function unsubscribeAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
82
|
+
const target = parseSubscriptionTarget(text(form, 'target'))
|
|
83
|
+
const targetId = positiveInt(form, 'targetId')
|
|
84
|
+
const back = text(form, 'back')
|
|
85
|
+
|
|
86
|
+
if (target === null || targetId === null) return { error: await tr('notice.app.exist') }
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const actor = await getActor()
|
|
90
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
|
|
91
|
+
|
|
92
|
+
const { subscriptions } = getContainer()
|
|
93
|
+
if (subscriptions === null) {
|
|
94
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-17'))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await new SubscriptionService({ subscriptions }).unsubscribe({
|
|
98
|
+
userId: actor.userId,
|
|
99
|
+
target,
|
|
100
|
+
targetId,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
await emitEvent(
|
|
104
|
+
'subscription.changed',
|
|
105
|
+
{ userId: actor.userId, target, targetId, subscribed: false },
|
|
106
|
+
{ requestId: currentRequestId() ?? null },
|
|
107
|
+
)
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return toFormState(err)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
redirect(safeReturn(back, '/subscriptions?stopped=1'))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function unsubscribeByTokenAction(
|
|
116
|
+
_prev: FormState,
|
|
117
|
+
form: FormData,
|
|
118
|
+
): Promise<FormState> {
|
|
119
|
+
const token = text(form, 'token')
|
|
120
|
+
const secret = env.AUTH_SECRET
|
|
121
|
+
|
|
122
|
+
if (secret === undefined || token === '') {
|
|
123
|
+
return { error: await tr('notice.app.unsubscribe-link-valid') }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const claim = readUnsubscribeToken(token, secret)
|
|
127
|
+
if (claim === null) return { error: await tr('notice.app.unsubscribe-link-valid') }
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const { subscriptions, notifications } = getContainer()
|
|
131
|
+
if (subscriptions === null || notifications === null) {
|
|
132
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-17'))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (claim.scope === 'email') {
|
|
136
|
+
await notifications.savePreferences(
|
|
137
|
+
claim.userId,
|
|
138
|
+
'email',
|
|
139
|
+
new Map([
|
|
140
|
+
['subscription.reply', false],
|
|
141
|
+
['subscription.digest', false],
|
|
142
|
+
]),
|
|
143
|
+
)
|
|
144
|
+
} else {
|
|
145
|
+
await new SubscriptionService({ subscriptions }).unsubscribe({
|
|
146
|
+
userId: claim.userId,
|
|
147
|
+
target: claim.scope,
|
|
148
|
+
targetId: claim.targetId,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return toFormState(err)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
redirect(`/unsubscribe?done=${claim.scope === 'email' ? 'email' : 'one'}`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function safeReturn(value: string, fallback: string): string {
|
|
159
|
+
if (!isSafeLocalPath(value)) return fallback
|
|
160
|
+
return value
|
|
161
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { redirect } from 'next/navigation'
|
|
4
|
+
|
|
5
|
+
import { ForbiddenError, ValidationError } from '@meith/core'
|
|
6
|
+
import { msg } from '@meith/i18n'
|
|
7
|
+
import { parseSelection, type SurgeryRights, ThreadSurgery } from '@meith/moderation'
|
|
8
|
+
|
|
9
|
+
import type { FormState } from './auth-form-state'
|
|
10
|
+
import { getContainer } from './container'
|
|
11
|
+
import { getActor } from './context'
|
|
12
|
+
import { formStateReporter } from './form-state-reporter'
|
|
13
|
+
import { tr } from './i18n'
|
|
14
|
+
import { emitEvent } from './plugin-view'
|
|
15
|
+
|
|
16
|
+
function positiveInt(form: FormData, name: string): number | null {
|
|
17
|
+
const value = form.get(name)
|
|
18
|
+
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value.trim())) return null
|
|
19
|
+
const n = Number(value.trim())
|
|
20
|
+
return Number.isSafeInteger(n) ? n : null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const toFormState = formStateReporter('surgery-actions', 'unexpected error in thread surgery')
|
|
24
|
+
|
|
25
|
+
const NO_STORE = 'This board is running on in-memory sample data, so it has no thread tools.'
|
|
26
|
+
|
|
27
|
+
export async function splitThreadAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
28
|
+
const threadId = positiveInt(form, 'threadId')
|
|
29
|
+
const fromPostId = positiveInt(form, 'fromPostId')
|
|
30
|
+
const title = typeof form.get('title') === 'string' ? (form.get('title') as string) : ''
|
|
31
|
+
|
|
32
|
+
if (threadId === null) return { error: await tr('notice.app.thread-exist') }
|
|
33
|
+
if (fromPostId === null) return { error: await tr('notice.app.choose-post-split-from') }
|
|
34
|
+
|
|
35
|
+
const { threadSurgery } = getContainer()
|
|
36
|
+
if (threadSurgery === null) return { error: NO_STORE }
|
|
37
|
+
|
|
38
|
+
let outcome: Awaited<ReturnType<ThreadSurgery['split']>>
|
|
39
|
+
try {
|
|
40
|
+
const actor = await getActor()
|
|
41
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
|
|
42
|
+
|
|
43
|
+
const forumId = await visibleForumOf(threadId)
|
|
44
|
+
|
|
45
|
+
outcome = await new ThreadSurgery({ threads: threadSurgery }).split({
|
|
46
|
+
threadId,
|
|
47
|
+
fromPostId,
|
|
48
|
+
title,
|
|
49
|
+
actorUserId: actor.userId,
|
|
50
|
+
rights: await resolveRights(forumId),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
await emitEvent(
|
|
54
|
+
'thread.split',
|
|
55
|
+
{ sourceThreadId: threadId, newThreadId: outcome.threadId, postCount: outcome.posts },
|
|
56
|
+
{ moderatorId: actor.userId, reason: null },
|
|
57
|
+
)
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return toFormState(err)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
redirect(`/thread/${outcome.threadId}-${outcome.slug}?tool=split&n=${outcome.posts}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function splitSelectedAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
66
|
+
const threadId = positiveInt(form, 'threadId')
|
|
67
|
+
const title = typeof form.get('title') === 'string' ? (form.get('title') as string) : ''
|
|
68
|
+
if (threadId === null) return { error: await tr('notice.app.thread-exist') }
|
|
69
|
+
|
|
70
|
+
const { threadSurgery } = getContainer()
|
|
71
|
+
if (threadSurgery === null) return { error: NO_STORE }
|
|
72
|
+
|
|
73
|
+
const postIds = parseSelection(
|
|
74
|
+
form.getAll('item').filter((v): v is string => typeof v === 'string'),
|
|
75
|
+
)
|
|
76
|
+
.filter((item) => item.kind === 'post')
|
|
77
|
+
.map((item) => item.id)
|
|
78
|
+
|
|
79
|
+
let outcome: Awaited<ReturnType<ThreadSurgery['splitPosts']>>
|
|
80
|
+
try {
|
|
81
|
+
const actor = await getActor()
|
|
82
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
|
|
83
|
+
|
|
84
|
+
const forumId = await visibleForumOf(threadId)
|
|
85
|
+
|
|
86
|
+
outcome = await new ThreadSurgery({ threads: threadSurgery }).splitPosts({
|
|
87
|
+
threadId,
|
|
88
|
+
postIds,
|
|
89
|
+
title,
|
|
90
|
+
actorUserId: actor.userId,
|
|
91
|
+
rights: await resolveRights(forumId),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
await emitEvent(
|
|
95
|
+
'thread.split',
|
|
96
|
+
{ sourceThreadId: threadId, newThreadId: outcome.threadId, postCount: outcome.posts },
|
|
97
|
+
{ moderatorId: actor.userId, reason: null },
|
|
98
|
+
)
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return toFormState(err)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const dropped = outcome.dropped > 0 ? `&dropped=${outcome.dropped}` : ''
|
|
104
|
+
redirect(`/thread/${outcome.threadId}-${outcome.slug}?tool=split&n=${outcome.posts}${dropped}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function mergeThreadAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
108
|
+
const sourceThreadId = positiveInt(form, 'threadId')
|
|
109
|
+
const targetThreadId = positiveInt(form, 'targetThreadId')
|
|
110
|
+
|
|
111
|
+
if (sourceThreadId === null) return { error: await tr('notice.app.thread-exist') }
|
|
112
|
+
if (targetThreadId === null) {
|
|
113
|
+
return { error: await tr('notice.app.enter-number-thread-merge-into') }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const { threadSurgery } = getContainer()
|
|
117
|
+
if (threadSurgery === null) return { error: NO_STORE }
|
|
118
|
+
|
|
119
|
+
let outcome: Awaited<ReturnType<ThreadSurgery['merge']>>
|
|
120
|
+
try {
|
|
121
|
+
const actor = await getActor()
|
|
122
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
|
|
123
|
+
|
|
124
|
+
const [sourceForumId, targetForumId] = await Promise.all([
|
|
125
|
+
visibleForumOf(sourceThreadId),
|
|
126
|
+
visibleForumOf(targetThreadId),
|
|
127
|
+
])
|
|
128
|
+
|
|
129
|
+
outcome = await new ThreadSurgery({ threads: threadSurgery }).merge({
|
|
130
|
+
sourceThreadId,
|
|
131
|
+
targetThreadId,
|
|
132
|
+
actorUserId: actor.userId,
|
|
133
|
+
rights: await resolveRights(sourceForumId),
|
|
134
|
+
targetRights: await resolveRights(targetForumId),
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
await emitEvent(
|
|
138
|
+
'thread.merged',
|
|
139
|
+
{
|
|
140
|
+
keptThreadId: outcome.threadId,
|
|
141
|
+
mergedThreadId: sourceThreadId,
|
|
142
|
+
postCount: outcome.posts,
|
|
143
|
+
},
|
|
144
|
+
{ moderatorId: actor.userId, reason: null },
|
|
145
|
+
)
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return toFormState(err)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
redirect(`/thread/${outcome.threadId}-${outcome.slug}?tool=merge&n=${outcome.posts}`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function visibleForumOf(threadId: number): Promise<number> {
|
|
154
|
+
const { threads, authorizer } = getContainer()
|
|
155
|
+
const actor = await getActor()
|
|
156
|
+
|
|
157
|
+
const forumId = (await threads.locate(threadId))?.forumId ?? null
|
|
158
|
+
if (forumId === null) throw new ValidationError(msg('error.app.thread-exist'))
|
|
159
|
+
|
|
160
|
+
const forum = await authorizer.forumMatrix(actor, forumId)
|
|
161
|
+
if (!authorizer.can(actor, 'thread.view', { forumId, forum })) {
|
|
162
|
+
throw new ValidationError(msg('error.app.thread-exist'))
|
|
163
|
+
}
|
|
164
|
+
return forumId
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function resolveRights(forumId: number): Promise<SurgeryRights> {
|
|
168
|
+
const actor = await getActor()
|
|
169
|
+
const { authorizer } = getContainer()
|
|
170
|
+
const [forum, moderatorRights] = await Promise.all([
|
|
171
|
+
authorizer.forumMatrix(actor, forumId),
|
|
172
|
+
authorizer.moderatorRightsIn(actor, forumId),
|
|
173
|
+
])
|
|
174
|
+
const target = { forumId, forum, moderatorRights }
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
merge: authorizer.can(actor, 'thread.merge', target),
|
|
178
|
+
split: authorizer.can(actor, 'thread.split', target),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { env, PUBLIC_CONTENT } from '@meith/core'
|
|
4
|
+
import { type FeedScope, getDb, PostgresFeedRepository } from '@meith/db'
|
|
5
|
+
|
|
6
|
+
import type { SitemapUrl } from '@/view/feed'
|
|
7
|
+
|
|
8
|
+
import { boardUrl } from './board-url'
|
|
9
|
+
import { getContainer } from './container'
|
|
10
|
+
import { filterView } from './plugin-view'
|
|
11
|
+
import { getSettings } from './settings'
|
|
12
|
+
|
|
13
|
+
export const FEED_LIMIT = 30
|
|
14
|
+
|
|
15
|
+
export const SITEMAP_CHUNK = 5_000
|
|
16
|
+
|
|
17
|
+
export function feedRepository(): PostgresFeedRepository | null {
|
|
18
|
+
return getContainer().dataSource === 'postgres' ? new PostgresFeedRepository(getDb()) : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function origin(): Promise<string> {
|
|
22
|
+
return (await boardUrl()) || 'http://localhost:3000'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function absoluteTo(base: string, path: string): string {
|
|
26
|
+
return `${base}${path}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function absolute(path: string): Promise<string> {
|
|
30
|
+
return absoluteTo(await origin(), path)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function publicScope(): Promise<FeedScope> {
|
|
34
|
+
const { authorizer, actorSource } = getContainer()
|
|
35
|
+
const guest = await actorSource.buildGuest()
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
...(await authorizer.threadAudience(guest)),
|
|
39
|
+
content: PUBLIC_CONTENT,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function isIndexable(): Promise<boolean> {
|
|
44
|
+
if (env.DEMO_MODE) return false
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return (await getSettings()).get('board.offline') !== true
|
|
48
|
+
} catch {
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One chunk of the sitemap, after the plugins have had it. Everything here is
|
|
55
|
+
* public by definition — the sitemap is what the board offers a crawler.
|
|
56
|
+
*/
|
|
57
|
+
export async function filterSitemapEntries(
|
|
58
|
+
urls: readonly SitemapUrl[],
|
|
59
|
+
chunk: number,
|
|
60
|
+
): Promise<readonly SitemapUrl[]> {
|
|
61
|
+
const filtered = await filterView(
|
|
62
|
+
'sitemap.entries',
|
|
63
|
+
urls.map((url) => ({
|
|
64
|
+
href: url.loc,
|
|
65
|
+
lastModified: url.lastmod?.toISOString() ?? null,
|
|
66
|
+
})),
|
|
67
|
+
{ chunk },
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return filtered.map((row) => {
|
|
71
|
+
const lastmod = row.lastModified === null ? undefined : new Date(row.lastModified)
|
|
72
|
+
return {
|
|
73
|
+
loc: row.href,
|
|
74
|
+
...(lastmod === undefined || Number.isNaN(lastmod.getTime()) ? {} : { lastmod }),
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
}
|