@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,41 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { logger } from '@meith/core'
|
|
4
|
+
import { currentRequestId } from '@meith/core/logger'
|
|
5
|
+
import { requireSlot, slotCopy } from '@meith/theme-kit'
|
|
6
|
+
|
|
7
|
+
import { getTranslator } from '@/server/i18n'
|
|
8
|
+
import { buildErrorNotice, CRASH_NOTICE, type ErrorNoticeCopy } from '@/view/error-notice'
|
|
9
|
+
|
|
10
|
+
import { filterView } from './plugin-view'
|
|
11
|
+
import { currentTheme } from './theme'
|
|
12
|
+
|
|
13
|
+
export async function renderErrorNotice(copy: ErrorNoticeCopy): Promise<React.ReactNode> {
|
|
14
|
+
const requestId = currentRequestId() ?? null
|
|
15
|
+
const ErrorNotice = requireSlot(await currentTheme(), 'ErrorNotice')
|
|
16
|
+
const translator = await getTranslator()
|
|
17
|
+
|
|
18
|
+
const model = await filterView(
|
|
19
|
+
'view.error-notice',
|
|
20
|
+
buildErrorNotice(copy, requestId, translator),
|
|
21
|
+
{
|
|
22
|
+
userId: null,
|
|
23
|
+
isGuest: true,
|
|
24
|
+
requestId,
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return <ErrorNotice {...model} copy={slotCopy(await currentTheme(), 'ErrorNotice', translator)} />
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function crashNotice(): Promise<React.ReactNode> {
|
|
32
|
+
try {
|
|
33
|
+
return await renderErrorNotice(CRASH_NOTICE)
|
|
34
|
+
} catch (error) {
|
|
35
|
+
logger({ module: 'error-notice' }).warn(
|
|
36
|
+
{ err: String(error) },
|
|
37
|
+
'could not resolve the themed crash notice',
|
|
38
|
+
)
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { redirect } from 'next/navigation'
|
|
4
|
+
|
|
5
|
+
import { ForbiddenError } from '@meith/core'
|
|
6
|
+
import { msg } from '@meith/i18n'
|
|
7
|
+
|
|
8
|
+
import { recordAuthEvent } from './auth-events'
|
|
9
|
+
import type { FormState } from './auth-form-state'
|
|
10
|
+
import { getContainer } from './container'
|
|
11
|
+
import { getActor } from './context'
|
|
12
|
+
import { assertDemoAccountChangeable } from './demo'
|
|
13
|
+
import {
|
|
14
|
+
federationService,
|
|
15
|
+
memberManagedSignIns,
|
|
16
|
+
passkeyService,
|
|
17
|
+
passkeysEnabled,
|
|
18
|
+
signInProviders,
|
|
19
|
+
} from './federation'
|
|
20
|
+
import { formStateReporter } from './form-state-reporter'
|
|
21
|
+
import { text } from './form-values'
|
|
22
|
+
import { tr } from './i18n'
|
|
23
|
+
|
|
24
|
+
const toFormState = formStateReporter(
|
|
25
|
+
'federation-actions',
|
|
26
|
+
'unexpected error while changing a sign-in',
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Only a credential the board would actually accept today counts as a way back
|
|
31
|
+
* in. A passkey on a board that has since switched passkeys off, or a link to a
|
|
32
|
+
* provider an operator has turned off, opens nothing — counting it would let a
|
|
33
|
+
* member unlink their way out of their own account in one click.
|
|
34
|
+
*/
|
|
35
|
+
async function usableWaysIn(userId: number): Promise<{
|
|
36
|
+
readonly usablePasskeys: number
|
|
37
|
+
readonly usableProviders: readonly string[]
|
|
38
|
+
}> {
|
|
39
|
+
const providers = await signInProviders()
|
|
40
|
+
const passkeys = (await passkeysEnabled())
|
|
41
|
+
? (await getContainer().accountStore.passkeys.listForUser(userId)).length
|
|
42
|
+
: 0
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
usablePasskeys: passkeys,
|
|
46
|
+
usableProviders: providers.map((provider) => provider.id),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function requireOwnAccount(): Promise<{
|
|
51
|
+
readonly userId: number
|
|
52
|
+
readonly hasPassword: boolean
|
|
53
|
+
}> {
|
|
54
|
+
const actor = await getActor()
|
|
55
|
+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
|
|
56
|
+
|
|
57
|
+
if (!(await memberManagedSignIns())) {
|
|
58
|
+
throw new ForbiddenError(msg('error.app.board-manages-sign-ins-for-its'))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await assertDemoAccountChangeable(actor.userId, 'password')
|
|
62
|
+
|
|
63
|
+
const account = await getContainer().accountStore.accounts.findById(actor.userId)
|
|
64
|
+
if (account === null) throw new ForbiddenError(msg('error.app.account-longer-exists'))
|
|
65
|
+
|
|
66
|
+
return { userId: actor.userId, hasPassword: account.passwordHash !== null }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function unlinkIdentityAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
70
|
+
try {
|
|
71
|
+
const { userId, hasPassword } = await requireOwnAccount()
|
|
72
|
+
const identityId = Number(text(form, 'identityId'))
|
|
73
|
+
if (!Number.isInteger(identityId)) {
|
|
74
|
+
return { error: await tr('notice.app.sign-in-could-found') }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await (await federationService()).unlink({
|
|
78
|
+
userId,
|
|
79
|
+
identityId,
|
|
80
|
+
hasPassword,
|
|
81
|
+
...(await usableWaysIn(userId)),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
await recordAuthEvent({ userId, kind: 'identity_unlinked' })
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return toFormState(err)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
redirect('/usercp/security?sso=unlinked')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function removePasskeyAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
93
|
+
try {
|
|
94
|
+
const { userId, hasPassword } = await requireOwnAccount()
|
|
95
|
+
const passkeyId = Number(text(form, 'passkeyId'))
|
|
96
|
+
if (!Number.isInteger(passkeyId)) {
|
|
97
|
+
return { error: await tr('notice.app.passkey-could-found') }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await (await passkeyService()).remove({
|
|
101
|
+
userId,
|
|
102
|
+
passkeyId,
|
|
103
|
+
hasPassword,
|
|
104
|
+
usableProviders: (await usableWaysIn(userId)).usableProviders,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
await recordAuthEvent({ userId, kind: 'passkey_removed' })
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return toFormState(err)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
redirect('/usercp/security?passkey=removed')
|
|
113
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { headers } from 'next/headers'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
configuredProviders,
|
|
7
|
+
type FederationOptions,
|
|
8
|
+
FederationService,
|
|
9
|
+
type IdentityProvider,
|
|
10
|
+
isProviderKind,
|
|
11
|
+
PasskeyService,
|
|
12
|
+
type ProviderKind,
|
|
13
|
+
providerFor,
|
|
14
|
+
type RelyingParty,
|
|
15
|
+
} from '@meith/accounts'
|
|
16
|
+
import { env } from '@meith/core'
|
|
17
|
+
import type { SettingsSnapshot } from '@meith/settings'
|
|
18
|
+
|
|
19
|
+
import { boardUrl } from './board-url'
|
|
20
|
+
import { configuredIdentity, getContainer } from './container'
|
|
21
|
+
import { getSettings, getSettingsUncached } from './settings'
|
|
22
|
+
|
|
23
|
+
export const SSO_CALLBACK_PREFIX = '/auth/sso'
|
|
24
|
+
|
|
25
|
+
export interface ProviderButton {
|
|
26
|
+
readonly id: ProviderKind
|
|
27
|
+
readonly label: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function federationOptions(settings: SettingsSnapshot): FederationOptions {
|
|
31
|
+
return {
|
|
32
|
+
github: {
|
|
33
|
+
enabled: settings.get('federation.github_enabled'),
|
|
34
|
+
clientId: settings.get('federation.github_client_id'),
|
|
35
|
+
clientSecret: settings.get('federation.github_client_secret'),
|
|
36
|
+
},
|
|
37
|
+
google: {
|
|
38
|
+
enabled: settings.get('federation.google_enabled'),
|
|
39
|
+
clientId: settings.get('federation.google_client_id'),
|
|
40
|
+
clientSecret: settings.get('federation.google_client_secret'),
|
|
41
|
+
},
|
|
42
|
+
oidc: {
|
|
43
|
+
enabled: settings.get('federation.oidc_enabled'),
|
|
44
|
+
clientId: settings.get('federation.oidc_client_id'),
|
|
45
|
+
clientSecret: settings.get('federation.oidc_client_secret'),
|
|
46
|
+
issuer: settings.get('federation.oidc_issuer'),
|
|
47
|
+
label: settings.get('federation.oidc_label'),
|
|
48
|
+
scopes: settings.get('federation.oidc_scopes'),
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every read here goes past the settings cache, because every one of them
|
|
55
|
+
* decides whether a sign-in may start rather than what a page displays. The
|
|
56
|
+
* cached snapshot is held per module instance, so a route handler can be a
|
|
57
|
+
* minute behind the render that invalidated it — long enough for a provider an
|
|
58
|
+
* operator has just switched on to answer that it is switched off.
|
|
59
|
+
*/
|
|
60
|
+
export async function federationProvider(id: string): Promise<IdentityProvider | null> {
|
|
61
|
+
if (!isProviderKind(id)) return null
|
|
62
|
+
if (getContainer().dataSource !== 'postgres') return null
|
|
63
|
+
|
|
64
|
+
return providerFor(id, federationOptions(await getSettingsUncached()))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function signInProviders(): Promise<readonly ProviderButton[]> {
|
|
68
|
+
if (getContainer().dataSource !== 'postgres') return []
|
|
69
|
+
|
|
70
|
+
return configuredProviders(federationOptions(await getSettingsUncached())).map((provider) => ({
|
|
71
|
+
id: provider.id,
|
|
72
|
+
label: provider.label,
|
|
73
|
+
}))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function passkeysEnabled(): Promise<boolean> {
|
|
77
|
+
if (getContainer().dataSource !== 'postgres') return false
|
|
78
|
+
return (await getSettingsUncached()).get('federation.passkeys_enabled')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function memberManagedSignIns(): Promise<boolean> {
|
|
82
|
+
return (await getSettingsUncached()).get('federation.link_from_usercp')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function federationService(): Promise<FederationService> {
|
|
86
|
+
const { accountStore } = getContainer()
|
|
87
|
+
|
|
88
|
+
return new FederationService({
|
|
89
|
+
identity: await configuredIdentity(),
|
|
90
|
+
accounts: accountStore.accounts,
|
|
91
|
+
identities: accountStore.identities,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function passkeyService(): Promise<PasskeyService> {
|
|
96
|
+
const { accountStore } = getContainer()
|
|
97
|
+
|
|
98
|
+
return new PasskeyService({
|
|
99
|
+
identity: await configuredIdentity(),
|
|
100
|
+
accounts: accountStore.accounts,
|
|
101
|
+
passkeys: accountStore.passkeys,
|
|
102
|
+
identities: accountStore.identities,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function callbackPath(provider: ProviderKind): string {
|
|
107
|
+
return `${SSO_CALLBACK_PREFIX}/${provider}/callback`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function callbackUrl(provider: ProviderKind): Promise<string> {
|
|
111
|
+
return new URL(callbackPath(provider), await requestOrigin()).toString()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function relyingParty(): Promise<RelyingParty> {
|
|
115
|
+
const origin = await requestOrigin()
|
|
116
|
+
return { id: new URL(origin).hostname, origin }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function passkeyRelyingPartyName(): Promise<string> {
|
|
120
|
+
const name = (await getSettings()).get('board.name')
|
|
121
|
+
return name.trim() === '' ? 'this board' : name
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function requestOrigin(): Promise<string> {
|
|
125
|
+
const configured = await boardUrl()
|
|
126
|
+
if (configured !== '') return configured
|
|
127
|
+
|
|
128
|
+
const incoming = await headers()
|
|
129
|
+
const host = incoming.get('x-forwarded-host') ?? incoming.get('host')
|
|
130
|
+
if (host === null || host.trim() === '') {
|
|
131
|
+
throw new Error('the board has no address to send an identity provider back to')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const scheme =
|
|
135
|
+
incoming.get('x-forwarded-proto') ?? (env.NODE_ENV === 'development' ? 'http' : 'https')
|
|
136
|
+
|
|
137
|
+
return `${scheme.split(',')[0]!.trim()}://${host.trim()}`
|
|
138
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import type { FeedPost, FeedThread } from '@meith/db'
|
|
4
|
+
import type { CompiledWordFilter } from '@meith/markdown'
|
|
5
|
+
|
|
6
|
+
import { type FeedChannel, type FeedEntry, summarise } from '@/view/feed'
|
|
7
|
+
import { postLink } from '@/view/post-link'
|
|
8
|
+
import { filterWords } from '@/view/word-filter'
|
|
9
|
+
|
|
10
|
+
import { absoluteTo } from './syndication'
|
|
11
|
+
|
|
12
|
+
export function feedFor(
|
|
13
|
+
origin: string,
|
|
14
|
+
wordFilter: CompiledWordFilter | undefined,
|
|
15
|
+
): {
|
|
16
|
+
threadEntry: (thread: FeedThread) => FeedEntry
|
|
17
|
+
postEntry: (post: FeedPost) => FeedEntry
|
|
18
|
+
channel: (input: ChannelInput) => FeedChannel
|
|
19
|
+
} {
|
|
20
|
+
const absolute = (path: string): string => absoluteTo(origin, path)
|
|
21
|
+
|
|
22
|
+
const tagId = (kind: 'thread' | 'post', id: number): string => {
|
|
23
|
+
const host = origin.replace(/^https?:\/\//, '')
|
|
24
|
+
return `tag:${host},2026:${kind}/${id}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function threadEntry(thread: FeedThread): FeedEntry {
|
|
28
|
+
return {
|
|
29
|
+
id: tagId('thread', thread.threadId),
|
|
30
|
+
title: thread.title,
|
|
31
|
+
href: absolute(`/thread/${thread.threadId}-${thread.slug}`),
|
|
32
|
+
author: thread.authorUsername,
|
|
33
|
+
published: thread.createdAt,
|
|
34
|
+
updated: thread.lastPostAt,
|
|
35
|
+
summary: filterWords(summarise(thread.excerptSource), wordFilter),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function postEntry(post: FeedPost): FeedEntry {
|
|
40
|
+
return {
|
|
41
|
+
id: tagId('post', post.postId),
|
|
42
|
+
title: post.threadTitle,
|
|
43
|
+
href: absolute(postLink(`/thread/${post.threadId}-${post.threadSlug}`, post.postId)),
|
|
44
|
+
author: post.authorUsername,
|
|
45
|
+
published: post.createdAt,
|
|
46
|
+
updated: post.createdAt,
|
|
47
|
+
summary: filterWords(summarise(post.messageSource), wordFilter),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function channel(input: ChannelInput): FeedChannel {
|
|
52
|
+
return {
|
|
53
|
+
title: input.title,
|
|
54
|
+
description: input.description,
|
|
55
|
+
href: absolute(input.path),
|
|
56
|
+
selfHref: absolute(input.selfPath),
|
|
57
|
+
updated: input.entries[0]?.updated ?? input.now,
|
|
58
|
+
entries: input.entries,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { threadEntry, postEntry, channel }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ChannelInput {
|
|
66
|
+
readonly title: string
|
|
67
|
+
readonly description: string
|
|
68
|
+
readonly path: string
|
|
69
|
+
readonly selfPath: string
|
|
70
|
+
readonly entries: readonly FeedEntry[]
|
|
71
|
+
readonly now: Date
|
|
72
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { type FeedChannel, type FeedEntry, renderAtom, renderRss } from '@/view/feed'
|
|
4
|
+
|
|
5
|
+
import { boardOffline } from './board-offline'
|
|
6
|
+
import { activeWordFilter } from './content-admin'
|
|
7
|
+
import { feedFor } from './feed-builder'
|
|
8
|
+
import { filterView } from './plugin-view'
|
|
9
|
+
import { getSettings } from './settings'
|
|
10
|
+
import { FEED_LIMIT, feedRepository, origin, publicScope } from './syndication'
|
|
11
|
+
|
|
12
|
+
const CACHE = 'public, max-age=300, stale-while-revalidate=3600'
|
|
13
|
+
|
|
14
|
+
export type FeedFormat = 'rss' | 'atom'
|
|
15
|
+
|
|
16
|
+
export type FeedScope = 'board' | 'forum' | 'thread'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A plugin may reorder, drop, or add. An entry it adds keeps the board's own
|
|
20
|
+
* fields where one matches by href, and is otherwise taken as given — a feed
|
|
21
|
+
* is rendered as a guest, so everything in it is already public.
|
|
22
|
+
*/
|
|
23
|
+
async function filterEntries(
|
|
24
|
+
entries: readonly FeedEntry[],
|
|
25
|
+
feed: FeedScope,
|
|
26
|
+
): Promise<readonly FeedEntry[]> {
|
|
27
|
+
const filtered = await filterView(
|
|
28
|
+
'feed.items',
|
|
29
|
+
entries.map((entry) => ({
|
|
30
|
+
title: entry.title,
|
|
31
|
+
href: entry.href,
|
|
32
|
+
publishedAt: entry.published.toISOString(),
|
|
33
|
+
summary: entry.summary,
|
|
34
|
+
})),
|
|
35
|
+
{ feed },
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const byHref = new Map(entries.map((entry) => [entry.href, entry]))
|
|
39
|
+
return filtered.map((row) => {
|
|
40
|
+
const original = byHref.get(row.href)
|
|
41
|
+
const published = new Date(row.publishedAt)
|
|
42
|
+
return {
|
|
43
|
+
id: original?.id ?? row.href,
|
|
44
|
+
title: row.title,
|
|
45
|
+
href: row.href,
|
|
46
|
+
author: original?.author ?? '',
|
|
47
|
+
published: Number.isNaN(published.getTime())
|
|
48
|
+
? (original?.published ?? new Date())
|
|
49
|
+
: published,
|
|
50
|
+
updated: original?.updated ?? published,
|
|
51
|
+
summary: row.summary,
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function feedResponse(
|
|
57
|
+
channel: FeedChannel,
|
|
58
|
+
format: FeedFormat,
|
|
59
|
+
feed: FeedScope,
|
|
60
|
+
): Promise<Response> {
|
|
61
|
+
const withEntries = { ...channel, entries: await filterEntries(channel.entries, feed) }
|
|
62
|
+
const body = format === 'atom' ? renderAtom(withEntries) : renderRss(withEntries)
|
|
63
|
+
return new Response(body, {
|
|
64
|
+
headers: {
|
|
65
|
+
'content-type':
|
|
66
|
+
format === 'atom'
|
|
67
|
+
? 'application/atom+xml; charset=utf-8'
|
|
68
|
+
: 'application/rss+xml; charset=utf-8',
|
|
69
|
+
'cache-control': CACHE,
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function xmlResponse(body: string): Response {
|
|
75
|
+
return new Response(body, {
|
|
76
|
+
headers: { 'content-type': 'application/xml; charset=utf-8', 'cache-control': CACHE },
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function offlineFeed(): Promise<Response | null> {
|
|
81
|
+
const offline = await boardOffline()
|
|
82
|
+
if (offline === null) return null
|
|
83
|
+
|
|
84
|
+
return new Response(offline.message, {
|
|
85
|
+
status: 503,
|
|
86
|
+
headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function boardFeed(format: FeedFormat, selfPath: string): Promise<Response> {
|
|
91
|
+
const offline = await offlineFeed()
|
|
92
|
+
if (offline !== null) return offline
|
|
93
|
+
|
|
94
|
+
const repo = feedRepository()
|
|
95
|
+
if (repo === null) return noFeed()
|
|
96
|
+
|
|
97
|
+
const scope = await publicScope()
|
|
98
|
+
const threads = await repo.recentThreads(FEED_LIMIT, scope)
|
|
99
|
+
const settings = await getSettings()
|
|
100
|
+
|
|
101
|
+
const feed = feedFor(await origin(), await activeWordFilter())
|
|
102
|
+
|
|
103
|
+
return feedResponse(
|
|
104
|
+
feed.channel({
|
|
105
|
+
title: settings.get('board.name'),
|
|
106
|
+
description: settings.get('board.description'),
|
|
107
|
+
path: '/',
|
|
108
|
+
selfPath,
|
|
109
|
+
entries: threads.map(feed.threadEntry),
|
|
110
|
+
now: new Date(),
|
|
111
|
+
}),
|
|
112
|
+
format,
|
|
113
|
+
'board',
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function noFeed(): Response {
|
|
118
|
+
return new Response('Not found', {
|
|
119
|
+
status: 404,
|
|
120
|
+
headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
|
|
121
|
+
})
|
|
122
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import type { AccountStore } from '@meith/accounts'
|
|
4
|
+
import {
|
|
5
|
+
type Actor,
|
|
6
|
+
type ActorSource,
|
|
7
|
+
type ActorState,
|
|
8
|
+
combinePermissionSets,
|
|
9
|
+
type GroupDefaults,
|
|
10
|
+
} from '@meith/authorization'
|
|
11
|
+
import { emptyPermissionSet } from '@meith/core'
|
|
12
|
+
|
|
13
|
+
import { SEED_BOARD, SEED_GROUP } from './seed-board'
|
|
14
|
+
|
|
15
|
+
function mapState(state: 'active' | 'awaiting_activation' | 'banned'): ActorState {
|
|
16
|
+
return state
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class FixtureActorSource implements ActorSource {
|
|
20
|
+
private readonly groupsById: Map<number, GroupDefaults>
|
|
21
|
+
|
|
22
|
+
constructor(private readonly store: AccountStore) {
|
|
23
|
+
this.groupsById = new Map(SEED_BOARD.groups.map((g) => [g.groupId, g]))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async buildGuest(): Promise<Actor> {
|
|
27
|
+
const groupIds = [SEED_GROUP.guest]
|
|
28
|
+
return {
|
|
29
|
+
userId: null,
|
|
30
|
+
groupIds,
|
|
31
|
+
primaryGroupId: SEED_GROUP.guest,
|
|
32
|
+
state: 'guest',
|
|
33
|
+
global: this.combine(groupIds),
|
|
34
|
+
permissionVersion: 1,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async buildForUser(userId: number): Promise<Actor | null> {
|
|
39
|
+
const account = await this.store.accounts.findById(userId)
|
|
40
|
+
if (!account) return null
|
|
41
|
+
|
|
42
|
+
const primaryGroupId = account.primaryGroupId ?? SEED_GROUP.registered
|
|
43
|
+
const groupIds = [primaryGroupId]
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
userId: account.id,
|
|
47
|
+
groupIds,
|
|
48
|
+
primaryGroupId,
|
|
49
|
+
state: mapState(account.state),
|
|
50
|
+
global: this.combine(groupIds),
|
|
51
|
+
permissionVersion: 1,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private combine(groupIds: readonly number[]) {
|
|
56
|
+
const sets = groupIds
|
|
57
|
+
.map((id) => this.groupsById.get(id)?.permissions)
|
|
58
|
+
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
|
59
|
+
if (sets.length === 0) return emptyPermissionSet()
|
|
60
|
+
return combinePermissionSets(sets)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { ConfigurationError } from '@meith/core'
|
|
4
|
+
import type {
|
|
5
|
+
ForumListingRow,
|
|
6
|
+
ForumRepository,
|
|
7
|
+
ForumRow,
|
|
8
|
+
MovePlan,
|
|
9
|
+
MoveTarget,
|
|
10
|
+
NewForum,
|
|
11
|
+
} from '@meith/forums'
|
|
12
|
+
|
|
13
|
+
import { SEED_FORUM_ROWS } from './seed-board'
|
|
14
|
+
|
|
15
|
+
function structural(row: ForumListingRow): ForumRow {
|
|
16
|
+
const { threadCount: _t, postCount: _p, lastPost: _l, ...rest } = row
|
|
17
|
+
return rest
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function unsupported(operation: string): never {
|
|
21
|
+
throw new ConfigurationError(
|
|
22
|
+
`Cannot ${operation} in fixture mode: the board is in-memory sample data and ` +
|
|
23
|
+
'any change is lost when the process exits. Set DATABASE_URL and ' +
|
|
24
|
+
'DATA_SOURCE=postgres to manage a real board.',
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class FixtureForumRepository implements ForumRepository {
|
|
29
|
+
constructor(private readonly rows: readonly ForumListingRow[] = SEED_FORUM_ROWS) {}
|
|
30
|
+
|
|
31
|
+
async listAll(): Promise<ForumRow[]> {
|
|
32
|
+
return this.ordered().map(structural)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async listListing(): Promise<ForumListingRow[]> {
|
|
36
|
+
return this.ordered().map((row) => ({ ...row }))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async findById(id: number): Promise<ForumRow | null> {
|
|
40
|
+
const row = this.rows.find((r) => r.id === id)
|
|
41
|
+
return row ? structural(row) : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private ordered(): ForumListingRow[] {
|
|
45
|
+
return [...this.rows].sort((a, b) => a.displayOrder - b.displayOrder || a.id - b.id)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async create(_input: NewForum): Promise<ForumRow> {
|
|
49
|
+
unsupported('create a forum')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async move(_forumId: number, _target: MoveTarget): Promise<void> {
|
|
53
|
+
unsupported('move a forum')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async applyMove(_plan: MovePlan): Promise<void> {
|
|
57
|
+
unsupported('move a forum')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
MemberProfileRecord,
|
|
5
|
+
MemberProfileRepository,
|
|
6
|
+
MemberSuggestion,
|
|
7
|
+
} from '@meith/accounts'
|
|
8
|
+
|
|
9
|
+
import { SEED_MEMBER_PROFILES } from './seed-board'
|
|
10
|
+
|
|
11
|
+
export class FixtureMemberProfileRepository implements MemberProfileRepository {
|
|
12
|
+
constructor(private readonly rows: readonly MemberProfileRecord[] = SEED_MEMBER_PROFILES) {}
|
|
13
|
+
|
|
14
|
+
async findPublicById(id: number): Promise<MemberProfileRecord | null> {
|
|
15
|
+
return this.rows.find((row) => row.id === id) ?? null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async searchByUsernamePrefix(
|
|
19
|
+
prefix: string,
|
|
20
|
+
limit: number,
|
|
21
|
+
): Promise<readonly MemberSuggestion[]> {
|
|
22
|
+
if (prefix === '') return []
|
|
23
|
+
const lower = prefix.toLowerCase()
|
|
24
|
+
return this.rows
|
|
25
|
+
.filter((row) => row.username.toLowerCase().startsWith(lower))
|
|
26
|
+
.sort((a, b) => a.username.localeCompare(b.username))
|
|
27
|
+
.slice(0, limit)
|
|
28
|
+
.map((row) => ({ id: row.id, username: row.username }))
|
|
29
|
+
}
|
|
30
|
+
}
|