@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,52 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import type { Actor } from '@meith/authorization'
|
|
4
|
+
import { canHoldThreads } from '@meith/forums'
|
|
5
|
+
import type { PollEditCapabilities } from '@meith/polls'
|
|
6
|
+
|
|
7
|
+
import { getContainer } from './container'
|
|
8
|
+
import { moderatorTargetFor } from './modcp'
|
|
9
|
+
|
|
10
|
+
export interface PollScope {
|
|
11
|
+
readonly forumId: number
|
|
12
|
+
readonly threadAuthorId: number | null
|
|
13
|
+
readonly mayVote: boolean
|
|
14
|
+
readonly mayEdit: boolean
|
|
15
|
+
readonly capabilities: PollEditCapabilities
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function resolvePollScope(actor: Actor, threadId: number): Promise<PollScope | null> {
|
|
19
|
+
const { authorizer, forums, threads } = getContainer()
|
|
20
|
+
|
|
21
|
+
const located = await threads.locate(threadId)
|
|
22
|
+
if (located === null) return null
|
|
23
|
+
|
|
24
|
+
const forum = await forums.findById(located.forumId)
|
|
25
|
+
if (forum === null || !canHoldThreads(forum.type)) return null
|
|
26
|
+
|
|
27
|
+
const matrix = await authorizer.forumMatrix(actor, forum.id)
|
|
28
|
+
const scope = {
|
|
29
|
+
...(await moderatorTargetFor(actor, forum.id, matrix)),
|
|
30
|
+
threadAuthorId: located.authorUserId,
|
|
31
|
+
ownerId: located.authorUserId,
|
|
32
|
+
}
|
|
33
|
+
if (!authorizer.can(actor, 'thread.view', scope)) return null
|
|
34
|
+
|
|
35
|
+
const wroteIt = actor.userId !== null && located.authorUserId === actor.userId
|
|
36
|
+
const editsOwn = wroteIt && authorizer.can(actor, 'post.editOwn', scope)
|
|
37
|
+
const mayEditOthers = authorizer.can(actor, 'post.editOthers', scope)
|
|
38
|
+
const moderates = authorizer.can(actor, 'content.viewUnapproved', scope)
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
forumId: forum.id,
|
|
42
|
+
threadAuthorId: located.authorUserId,
|
|
43
|
+
mayVote: authorizer.can(actor, 'poll.vote', scope),
|
|
44
|
+
mayEdit: editsOwn || mayEditOthers,
|
|
45
|
+
capabilities: {
|
|
46
|
+
isAuthor: editsOwn,
|
|
47
|
+
mayEditOthers,
|
|
48
|
+
editWindowMinutes: Number(matrix.editTimeLimitMinutes ?? 0),
|
|
49
|
+
bypassesWindow: mayEditOthers || moderates,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { foldIdentifier } from '@meith/accounts'
|
|
4
|
+
import { logger } from '@meith/core'
|
|
5
|
+
import { extractMentions, extractQuotedAuthors, vocabularyOptions } from '@meith/markdown'
|
|
6
|
+
|
|
7
|
+
import { postLink } from '@/view/post-link'
|
|
8
|
+
|
|
9
|
+
import { getContainer } from './container'
|
|
10
|
+
import { activeVocabulary } from './content-admin'
|
|
11
|
+
import { notificationService } from './notifications'
|
|
12
|
+
|
|
13
|
+
const MAX_RECIPIENTS_PER_KIND = 10
|
|
14
|
+
|
|
15
|
+
export interface NewPostNotice {
|
|
16
|
+
readonly postId: number
|
|
17
|
+
readonly threadId: number
|
|
18
|
+
readonly threadSlug: string
|
|
19
|
+
readonly threadTitle: string
|
|
20
|
+
readonly message: string
|
|
21
|
+
readonly authorUsername: string
|
|
22
|
+
readonly visibility: 'visible' | 'unapproved'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function notifyPostAudience(notice: NewPostNotice): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
const service = notificationService()
|
|
28
|
+
if (service === null) return
|
|
29
|
+
if (notice.visibility !== 'visible') return
|
|
30
|
+
|
|
31
|
+
const options = vocabularyOptions(await activeVocabulary())
|
|
32
|
+
const quoted = extractQuotedAuthors(notice.message, options)
|
|
33
|
+
const mentioned = extractMentions(notice.message, options)
|
|
34
|
+
|
|
35
|
+
const href = postLink(`/thread/${notice.threadId}-${notice.threadSlug}`, notice.postId)
|
|
36
|
+
const data = { byUsername: notice.authorUsername, threadTitle: notice.threadTitle }
|
|
37
|
+
|
|
38
|
+
const { accountStore } = getContainer()
|
|
39
|
+
const told = new Set<string>([foldIdentifier(notice.authorUsername)])
|
|
40
|
+
|
|
41
|
+
for (const [kind, names] of [
|
|
42
|
+
['post.quoted', quoted],
|
|
43
|
+
['post.mentioned', mentioned],
|
|
44
|
+
] as const) {
|
|
45
|
+
let raised = 0
|
|
46
|
+
for (const name of names) {
|
|
47
|
+
if (raised >= MAX_RECIPIENTS_PER_KIND) break
|
|
48
|
+
const folded = foldIdentifier(name)
|
|
49
|
+
if (told.has(folded)) continue
|
|
50
|
+
told.add(folded)
|
|
51
|
+
|
|
52
|
+
const account = await accountStore.accounts.findByUsernameLower(folded)
|
|
53
|
+
if (account === null) continue
|
|
54
|
+
|
|
55
|
+
await service.raise({
|
|
56
|
+
userId: account.id,
|
|
57
|
+
kind,
|
|
58
|
+
data,
|
|
59
|
+
href,
|
|
60
|
+
dedupeKey: `${kind}:${notice.postId}`,
|
|
61
|
+
})
|
|
62
|
+
raised += 1
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
logger({ module: 'post-notifications' }).error(
|
|
67
|
+
{ err, postId: notice.postId },
|
|
68
|
+
'failed to raise mention/quote notifications',
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import type { Actor } from '@meith/authorization'
|
|
4
|
+
import { type EditCapabilities, PostEditor, type PostWriteRepository } from '@meith/posts'
|
|
5
|
+
|
|
6
|
+
import { getContainer } from './container'
|
|
7
|
+
import { getActor } from './context'
|
|
8
|
+
import { moderatorTargetFor } from './modcp'
|
|
9
|
+
import { getSettings } from './settings'
|
|
10
|
+
|
|
11
|
+
export interface PostManageScope {
|
|
12
|
+
readonly target: NonNullable<Awaited<ReturnType<PostWriteRepository['findEditTarget']>>>
|
|
13
|
+
readonly isOwn: boolean
|
|
14
|
+
readonly mayEdit: boolean
|
|
15
|
+
readonly mayDelete: boolean
|
|
16
|
+
readonly mayRestore: boolean
|
|
17
|
+
readonly mayViewHistory: boolean
|
|
18
|
+
readonly mayRollback: boolean
|
|
19
|
+
readonly capabilities: EditCapabilities
|
|
20
|
+
readonly bypassesLock: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function resolvePostScope(
|
|
24
|
+
threadId: number,
|
|
25
|
+
postId: number,
|
|
26
|
+
viewer?: Actor,
|
|
27
|
+
): Promise<PostManageScope | null> {
|
|
28
|
+
const actor = viewer ?? (await getActor())
|
|
29
|
+
const { authorizer, postWrites } = getContainer()
|
|
30
|
+
if (postWrites === null) return null
|
|
31
|
+
|
|
32
|
+
const target = await postWrites.findEditTarget(threadId, postId)
|
|
33
|
+
if (!target) return null
|
|
34
|
+
|
|
35
|
+
const matrix = await authorizer.forumMatrix(actor, target.forum.id)
|
|
36
|
+
const scope = {
|
|
37
|
+
...(await moderatorTargetFor(actor, target.forum.id, matrix)),
|
|
38
|
+
ownerId: target.post.authorUserId,
|
|
39
|
+
}
|
|
40
|
+
if (!authorizer.can(actor, 'thread.view', scope)) return null
|
|
41
|
+
|
|
42
|
+
const isOwn = actor.userId !== null && target.post.authorUserId === actor.userId
|
|
43
|
+
const editsOthers = authorizer.can(actor, 'post.editOthers', scope)
|
|
44
|
+
const moderates = authorizer.can(actor, 'content.viewUnapproved', scope)
|
|
45
|
+
const softDeletes = authorizer.can(actor, 'post.softDelete', scope)
|
|
46
|
+
const seesUnapproved = authorizer.can(actor, 'content.viewUnapproved', scope)
|
|
47
|
+
const seesDeleted = authorizer.can(actor, 'content.viewDeleted', scope)
|
|
48
|
+
const staffCanSeePost =
|
|
49
|
+
editsOthers &&
|
|
50
|
+
(target.post.visibility === 'visible' ||
|
|
51
|
+
(target.post.visibility === 'unapproved' && seesUnapproved) ||
|
|
52
|
+
(target.post.visibility === 'deleted' && seesDeleted))
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
target,
|
|
56
|
+
isOwn,
|
|
57
|
+
mayEdit: isOwn ? authorizer.can(actor, 'post.editOwn', scope) : editsOthers,
|
|
58
|
+
mayDelete: isOwn ? authorizer.can(actor, 'post.deleteOwn', scope) || softDeletes : softDeletes,
|
|
59
|
+
mayRestore:
|
|
60
|
+
authorizer.can(actor, 'post.restore', scope) &&
|
|
61
|
+
authorizer.can(actor, 'content.viewDeleted', scope),
|
|
62
|
+
mayViewHistory: (isOwn && target.post.visibility === 'visible') || staffCanSeePost,
|
|
63
|
+
mayRollback: staffCanSeePost && target.post.visibility !== 'deleted',
|
|
64
|
+
bypassesLock: moderates,
|
|
65
|
+
capabilities: {
|
|
66
|
+
isOwn,
|
|
67
|
+
editWindowMinutes: Number(scope.forum.editTimeLimitMinutes ?? 0),
|
|
68
|
+
bypassesWindow: editsOthers || moderates,
|
|
69
|
+
bypassesLock: moderates,
|
|
70
|
+
requiresApprovalOnEdit: scope.forum.requiresApprovalOnEdit === true,
|
|
71
|
+
bypassesModeration: moderates,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function postEditor(posts: PostWriteRepository): Promise<PostEditor> {
|
|
77
|
+
const settings = await getSettings()
|
|
78
|
+
|
|
79
|
+
return new PostEditor({
|
|
80
|
+
posts,
|
|
81
|
+
config: {
|
|
82
|
+
maxLength: settings.get('posting.max_length'),
|
|
83
|
+
editGraceSeconds: settings.get('posting.edit_grace_seconds'),
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
import { cookies, headers } from 'next/headers'
|
|
6
|
+
|
|
7
|
+
import type { Actor } from '@meith/authorization'
|
|
8
|
+
import { contentScopeFrom, env } from '@meith/core'
|
|
9
|
+
import { getDb, type OnlineScope, type OnlineSnapshot, PostgresPresenceRepository } from '@meith/db'
|
|
10
|
+
|
|
11
|
+
import { getContainer } from './container'
|
|
12
|
+
import { clearedCookie, GUEST_COOKIE_DAYS, guestCookieName, sessionCookieName } from './cookies'
|
|
13
|
+
import { FRESH_GUEST_HEADER, PATH_HEADER } from './location-header'
|
|
14
|
+
|
|
15
|
+
const LOCATION_WINDOW_SECONDS = 60
|
|
16
|
+
|
|
17
|
+
export interface BoardLocation {
|
|
18
|
+
readonly path: string
|
|
19
|
+
readonly forumId: number | null
|
|
20
|
+
readonly threadId: number | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseLocation(path: string | null): BoardLocation | null {
|
|
24
|
+
if (path === null || path === '') return null
|
|
25
|
+
|
|
26
|
+
const clean = path.split('?')[0] ?? path
|
|
27
|
+
|
|
28
|
+
const forum = /^\/(\d+)(?:-|\/|$)/.exec(clean)
|
|
29
|
+
const thread = /^\/thread\/(\d+)-/.exec(clean)
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
path: clean,
|
|
33
|
+
forumId: forum === null ? null : Number(forum[1]),
|
|
34
|
+
threadId: thread === null ? null : Number(thread[1]),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function touchCurrentLocation(): Promise<void> {
|
|
39
|
+
try {
|
|
40
|
+
const location = parseLocation((await headers()).get(PATH_HEADER))
|
|
41
|
+
if (location === null) return
|
|
42
|
+
await touchLocation(location)
|
|
43
|
+
} catch {
|
|
44
|
+
/* ignore */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function presenceRepository(): PostgresPresenceRepository | null {
|
|
49
|
+
return getContainer().dataSource === 'postgres' ? new PostgresPresenceRepository(getDb()) : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function touchLocation(location: BoardLocation): Promise<void> {
|
|
53
|
+
try {
|
|
54
|
+
const { identity, accountStore } = getContainer()
|
|
55
|
+
const secure = env.NODE_ENV !== 'development'
|
|
56
|
+
|
|
57
|
+
const jar = await cookies()
|
|
58
|
+
const token = jar.get(sessionCookieName(secure))?.value
|
|
59
|
+
|
|
60
|
+
if (token === undefined || token === '') {
|
|
61
|
+
await touchGuestLocation(await returnedGuestToken(secure), location)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const session = await identity.locateSession(token)
|
|
66
|
+
if (session === null) {
|
|
67
|
+
await touchGuestLocation(await returnedGuestToken(secure), location)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await accountStore.sessions.touchLocation(
|
|
72
|
+
session.sessionId,
|
|
73
|
+
{
|
|
74
|
+
path: location.path,
|
|
75
|
+
forumId: location.forumId,
|
|
76
|
+
threadId: location.threadId,
|
|
77
|
+
},
|
|
78
|
+
new Date(),
|
|
79
|
+
LOCATION_WINDOW_SECONDS,
|
|
80
|
+
)
|
|
81
|
+
} catch {
|
|
82
|
+
/* ignore */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The guest token, but only once the client has proved it keeps cookies.
|
|
88
|
+
*
|
|
89
|
+
* The freshness header is the proof, and it has to come from the middleware
|
|
90
|
+
* because nothing downstream can supply it: Next reflects a cookie set on a
|
|
91
|
+
* `NextResponse.next()` into the request the render sees, so on a first-ever
|
|
92
|
+
* visit both `cookies()` and the raw `Cookie` header already carry a token the
|
|
93
|
+
* client has never seen. Counting on that wrote one presence row per request
|
|
94
|
+
* from anything that discards cookies — measured at twelve rows from twelve
|
|
95
|
+
* crawler requests, and a sessions table that grows without bound.
|
|
96
|
+
*/
|
|
97
|
+
async function returnedGuestToken(secure: boolean): Promise<string | undefined> {
|
|
98
|
+
if ((await headers()).get(FRESH_GUEST_HEADER) === '1') return undefined
|
|
99
|
+
|
|
100
|
+
const value = (await cookies()).get(guestCookieName(secure))?.value
|
|
101
|
+
return value === '' ? undefined : value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The same reader, one row.
|
|
106
|
+
*
|
|
107
|
+
* Only reached when there is no usable member session, which is what keeps a
|
|
108
|
+
* logged-in member from being counted twice — and signing in drops the guest
|
|
109
|
+
* row outright, so the two never overlap.
|
|
110
|
+
*/
|
|
111
|
+
async function touchGuestLocation(
|
|
112
|
+
guestToken: string | undefined,
|
|
113
|
+
location: BoardLocation,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
if (guestToken === undefined || guestToken === '') return
|
|
116
|
+
|
|
117
|
+
const repo = presenceRepository()
|
|
118
|
+
if (repo === null) return
|
|
119
|
+
|
|
120
|
+
const now = new Date()
|
|
121
|
+
await repo.touchGuest({
|
|
122
|
+
tokenHash: createHash('sha256').update(guestToken).digest('hex'),
|
|
123
|
+
location: {
|
|
124
|
+
path: location.path,
|
|
125
|
+
forumId: location.forumId,
|
|
126
|
+
threadId: location.threadId,
|
|
127
|
+
},
|
|
128
|
+
now,
|
|
129
|
+
windowSeconds: LOCATION_WINDOW_SECONDS,
|
|
130
|
+
expiresAt: new Date(now.getTime() + GUEST_COOKIE_DAYS * 86_400_000),
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Retires the guest identity of somebody who has just become a member.
|
|
136
|
+
*
|
|
137
|
+
* Called wherever a session cookie is set, rather than only at the login form,
|
|
138
|
+
* because that is every route into being signed in — the form, the remember-me
|
|
139
|
+
* resume, and a password change that reissues the session.
|
|
140
|
+
*/
|
|
141
|
+
export async function retireGuestPresence(): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
const secure = env.NODE_ENV !== 'development'
|
|
144
|
+
const jar = await cookies()
|
|
145
|
+
const token = jar.get(guestCookieName(secure))?.value
|
|
146
|
+
|
|
147
|
+
if (token !== undefined && token !== '') {
|
|
148
|
+
await presenceRepository()?.dropGuest(createHash('sha256').update(token).digest('hex'))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
jar.set(guestCookieName(secure), '', clearedCookie(secure))
|
|
152
|
+
} catch {
|
|
153
|
+
/* ignore */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function onlineScopeFor(actor: Actor): Promise<OnlineScope> {
|
|
158
|
+
const { authorizer } = getContainer()
|
|
159
|
+
const staff = actor.global.isAdministrator === true || actor.global.isSuperModerator === true
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
...(await authorizer.threadAudience(actor)),
|
|
163
|
+
content: contentScopeFrom({ seesUnapproved: staff, seesDeleted: staff }),
|
|
164
|
+
seesInvisible: await authorizer.can(actor, 'modcp.access'),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function readOnline(actor: Actor, now: Date): Promise<OnlineSnapshot | null> {
|
|
169
|
+
const repo = presenceRepository()
|
|
170
|
+
if (repo === null) return null
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
return await repo.onlineNow(now, await onlineScopeFor(actor))
|
|
174
|
+
} catch {
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { cache } from 'react'
|
|
4
|
+
|
|
5
|
+
import { maxLengthFor, type ProfileFieldContext, ProfileFieldService } from '@meith/profile-fields'
|
|
6
|
+
|
|
7
|
+
import { AUTH_CONFIG } from './auth-config'
|
|
8
|
+
import { getContainer } from './container'
|
|
9
|
+
import { getActor } from './context'
|
|
10
|
+
|
|
11
|
+
export const FIELD_PREFIX = 'field:'
|
|
12
|
+
|
|
13
|
+
export function submittedFields(form: FormData): ReadonlyMap<string, string> {
|
|
14
|
+
const submitted = new Map<string, string>()
|
|
15
|
+
for (const [name, value] of form.entries()) {
|
|
16
|
+
if (name.startsWith(FIELD_PREFIX) && typeof value === 'string') {
|
|
17
|
+
submitted.set(name.slice(FIELD_PREFIX.length), value)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return submitted
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function profileFieldService(): ProfileFieldService | null {
|
|
24
|
+
const { profileFields } = getContainer()
|
|
25
|
+
return profileFields === null ? null : new ProfileFieldService({ fields: profileFields })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const viewerFieldContext = cache(async (): Promise<ProfileFieldContext | null> => {
|
|
29
|
+
const service = profileFieldService()
|
|
30
|
+
if (service === null) return null
|
|
31
|
+
|
|
32
|
+
const { authorizer } = getContainer()
|
|
33
|
+
const actor = await getActor()
|
|
34
|
+
|
|
35
|
+
const rules = await service.listGroupRules()
|
|
36
|
+
return { applicable: authorizer.applicableGroupRows(actor, rules) }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export async function registrationFieldContext(): Promise<ProfileFieldContext | null> {
|
|
40
|
+
const service = profileFieldService()
|
|
41
|
+
if (service === null) return null
|
|
42
|
+
|
|
43
|
+
const { authorizer } = getContainer()
|
|
44
|
+
const rules = await service.listGroupRules()
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
applicable: authorizer.applicableGroupRowsForGroups([AUTH_CONFIG.defaultMemberGroupId], rules),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function registrationFields(): Promise<
|
|
52
|
+
readonly {
|
|
53
|
+
key: string
|
|
54
|
+
label: string
|
|
55
|
+
description: string | null
|
|
56
|
+
type: string | null
|
|
57
|
+
options: readonly string[]
|
|
58
|
+
maxLength: number
|
|
59
|
+
}[]
|
|
60
|
+
> {
|
|
61
|
+
const service = profileFieldService()
|
|
62
|
+
if (service === null) return []
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const context = await registrationFieldContext()
|
|
66
|
+
if (context === null) return []
|
|
67
|
+
|
|
68
|
+
const fields = await service.requiredAtRegistration(context)
|
|
69
|
+
return fields.map((field) => ({
|
|
70
|
+
key: field.key,
|
|
71
|
+
label: field.label,
|
|
72
|
+
description: field.description,
|
|
73
|
+
type: field.type,
|
|
74
|
+
options: field.options,
|
|
75
|
+
maxLength: maxLengthFor(field),
|
|
76
|
+
}))
|
|
77
|
+
} catch {
|
|
78
|
+
return []
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function visibleProfileFields(
|
|
83
|
+
ownerUserId: number,
|
|
84
|
+
): Promise<readonly { label: string; value: string }[]> {
|
|
85
|
+
const service = profileFieldService()
|
|
86
|
+
const context = await viewerFieldContext()
|
|
87
|
+
if (service === null || context === null) return []
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const resolved = await service.visibleFor(ownerUserId, context)
|
|
91
|
+
return resolved.map((entry) => ({ label: entry.field.label, value: entry.value }))
|
|
92
|
+
} catch {
|
|
93
|
+
return []
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function postbitProfileFields(
|
|
98
|
+
authorUserId: number,
|
|
99
|
+
): Promise<readonly { label: string; value: string }[]> {
|
|
100
|
+
const service = profileFieldService()
|
|
101
|
+
const context = await viewerFieldContext()
|
|
102
|
+
if (service === null || context === null) return []
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const resolved = await service.visibleFor(authorUserId, context)
|
|
106
|
+
return resolved
|
|
107
|
+
.filter((entry) => entry.field.showInPostbit)
|
|
108
|
+
.map((entry) => ({ label: entry.field.label, value: entry.value }))
|
|
109
|
+
} catch {
|
|
110
|
+
return []
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { ForbiddenError, ValidationError } from '@meith/core'
|
|
4
|
+
import { msg } from '@meith/i18n'
|
|
5
|
+
import type { NotificationService } from '@meith/notifications'
|
|
6
|
+
|
|
7
|
+
import type { FormState } from './auth-form-state'
|
|
8
|
+
import { getActor } from './context'
|
|
9
|
+
import { formStateReporter } from './form-state-reporter'
|
|
10
|
+
import { tr } from './i18n'
|
|
11
|
+
import { notificationService } from './notifications'
|
|
12
|
+
import { pushAvailability } from './push'
|
|
13
|
+
|
|
14
|
+
const toFormState = formStateReporter('push-actions', 'unexpected error in web push')
|
|
15
|
+
|
|
16
|
+
const ENDPOINT_MAX = 1000
|
|
17
|
+
|
|
18
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/
|
|
19
|
+
|
|
20
|
+
const IP_LITERAL = /^\[|^\d+\.\d+\.\d+\.\d+$/
|
|
21
|
+
|
|
22
|
+
const PRIVATE_SUFFIX = /\.(?:local|internal|localdomain|home|lan)$/
|
|
23
|
+
|
|
24
|
+
export interface PushSubscriptionInput {
|
|
25
|
+
readonly endpoint: string
|
|
26
|
+
readonly p256dh: string
|
|
27
|
+
readonly auth: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function keyBytes(value: string): number {
|
|
31
|
+
return BASE64URL.test(value) ? Buffer.from(value, 'base64url').length : 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isUsableEndpoint(endpoint: string): boolean {
|
|
35
|
+
if (endpoint.length === 0 || endpoint.length > ENDPOINT_MAX) return false
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(endpoint)
|
|
39
|
+
if (url.protocol !== 'https:') return false
|
|
40
|
+
|
|
41
|
+
const host = url.hostname.toLowerCase()
|
|
42
|
+
return (
|
|
43
|
+
host.includes('.') &&
|
|
44
|
+
!IP_LITERAL.test(host) &&
|
|
45
|
+
!PRIVATE_SUFFIX.test(host) &&
|
|
46
|
+
host !== 'localhost'
|
|
47
|
+
)
|
|
48
|
+
} catch {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function requirePushCentre(): Promise<{ service: NotificationService; userId: number }> {
|
|
54
|
+
const actor = await getActor()
|
|
55
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
|
|
56
|
+
|
|
57
|
+
const service = notificationService()
|
|
58
|
+
if (service === null) {
|
|
59
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-16'))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!(await pushAvailability()).enabled) {
|
|
63
|
+
throw new ValidationError(msg('notice.app.board-offering-web-push'))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { service, userId: actor.userId }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function subscribeToPushAction(input: PushSubscriptionInput): Promise<FormState> {
|
|
70
|
+
try {
|
|
71
|
+
if (
|
|
72
|
+
!isUsableEndpoint(input.endpoint) ||
|
|
73
|
+
keyBytes(input.p256dh) !== 65 ||
|
|
74
|
+
keyBytes(input.auth) !== 16
|
|
75
|
+
) {
|
|
76
|
+
return { error: await tr('notice.app.push-subscription-valid') }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { service, userId } = await requirePushCentre()
|
|
80
|
+
await service.subscribeToPush({
|
|
81
|
+
userId,
|
|
82
|
+
endpoint: input.endpoint,
|
|
83
|
+
p256dh: input.p256dh,
|
|
84
|
+
auth: input.auth,
|
|
85
|
+
})
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return toFormState(err)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { notice: await tr('notice.pushSubscribed') }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function unsubscribeFromPushAction(endpoint: string): Promise<FormState> {
|
|
94
|
+
try {
|
|
95
|
+
const actor = await getActor()
|
|
96
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
|
|
97
|
+
|
|
98
|
+
const service = notificationService()
|
|
99
|
+
if (service === null) {
|
|
100
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-16'))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!isUsableEndpoint(endpoint)) {
|
|
104
|
+
return { error: await tr('notice.app.push-subscription-valid') }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await service.unsubscribeFromPush(actor.userId, endpoint)
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return toFormState(err)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { notice: await tr('notice.pushUnsubscribed') }
|
|
113
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { env } from '@meith/core'
|
|
4
|
+
import { isVapidPublicKey } from '@meith/notifications'
|
|
5
|
+
import { type PushProblem, resolvePushConfig } from '@meith/settings'
|
|
6
|
+
|
|
7
|
+
import { getSettings } from './settings'
|
|
8
|
+
|
|
9
|
+
export interface PushAvailability {
|
|
10
|
+
readonly enabled: boolean
|
|
11
|
+
readonly publicKey: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const OFF: PushAvailability = { enabled: false, publicKey: '' }
|
|
15
|
+
|
|
16
|
+
export interface PushReadiness {
|
|
17
|
+
readonly live: boolean
|
|
18
|
+
readonly problem: PushProblem | 'bad-key' | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function pushAvailability(): Promise<PushAvailability> {
|
|
22
|
+
try {
|
|
23
|
+
const { config } = resolvePushConfig({ environment: env, settings: await getSettings() })
|
|
24
|
+
if (config === null || !isVapidPublicKey(config.publicKey)) return OFF
|
|
25
|
+
return { enabled: true, publicKey: config.publicKey }
|
|
26
|
+
} catch {
|
|
27
|
+
return OFF
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function pushReadiness(): Promise<PushReadiness> {
|
|
32
|
+
const { config, problem } = resolvePushConfig({
|
|
33
|
+
environment: env,
|
|
34
|
+
settings: await getSettings(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
if (config === null) return { live: false, problem }
|
|
38
|
+
if (!isVapidPublicKey(config.publicKey)) return { live: false, problem: 'bad-key' }
|
|
39
|
+
return { live: true, problem: null }
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { headers } from 'next/headers'
|
|
4
|
+
import { redirect } from 'next/navigation'
|
|
5
|
+
|
|
6
|
+
import { PATH_HEADER } from './location-header'
|
|
7
|
+
import { isSafeLocalPath } from './safe-path'
|
|
8
|
+
|
|
9
|
+
export async function redirectToCurrentPath(): Promise<void> {
|
|
10
|
+
const path = (await headers()).get(PATH_HEADER)
|
|
11
|
+
|
|
12
|
+
if (typeof path === 'string' && isSafeLocalPath(path)) {
|
|
13
|
+
redirect(path)
|
|
14
|
+
}
|
|
15
|
+
}
|