@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,139 @@
|
|
|
1
|
+
export const SESSION_COOKIE = '__Host-fs_session'
|
|
2
|
+
export const REMEMBER_COOKIE = '__Host-fs_remember'
|
|
3
|
+
export const DEV_SESSION_COOKIE = 'fs_session'
|
|
4
|
+
export const DEV_REMEMBER_COOKIE = 'fs_remember'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Counts one reader once, and carries nothing else.
|
|
8
|
+
*
|
|
9
|
+
* An opaque random value, first-party, readable by nobody but this board. It
|
|
10
|
+
* exists because "37 guests reading" is not derivable from a stateless request:
|
|
11
|
+
* without something that comes back, every page view is a stranger. It is never
|
|
12
|
+
* an identity — no code path turns it into an actor, and `locateSession()`
|
|
13
|
+
* refuses a row with no user — so the worst it can say about the person holding
|
|
14
|
+
* it is that they were here.
|
|
15
|
+
*/
|
|
16
|
+
export const GUEST_COOKIE = '__Host-fs_guest'
|
|
17
|
+
export const DEV_GUEST_COOKIE = 'fs_guest'
|
|
18
|
+
|
|
19
|
+
/** Long enough that a reader is one guest across a visit, short enough to expire. */
|
|
20
|
+
export const GUEST_COOKIE_DAYS = 1
|
|
21
|
+
|
|
22
|
+
export const ADMIN_COOKIE = 'fs_admin'
|
|
23
|
+
export const ADMIN_COOKIE_PATH = '/admin'
|
|
24
|
+
|
|
25
|
+
export const SSO_COOKIE = '__Host-fs_sso'
|
|
26
|
+
export const DEV_SSO_COOKIE = 'fs_sso'
|
|
27
|
+
|
|
28
|
+
export const PASSKEY_COOKIE = '__Host-fs_passkey'
|
|
29
|
+
export const DEV_PASSKEY_COOKIE = 'fs_passkey'
|
|
30
|
+
|
|
31
|
+
export const SECOND_FACTOR_COOKIE = '__Host-fs_2fa'
|
|
32
|
+
export const DEV_SECOND_FACTOR_COOKIE = 'fs_2fa'
|
|
33
|
+
|
|
34
|
+
/** Long enough to read a consent screen, short enough that a stale one is gone. */
|
|
35
|
+
export const HANDSHAKE_MINUTES = 10
|
|
36
|
+
|
|
37
|
+
export function sessionCookieName(secure: boolean): string {
|
|
38
|
+
return secure ? SESSION_COOKIE : DEV_SESSION_COOKIE
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function rememberCookieName(secure: boolean): string {
|
|
42
|
+
return secure ? REMEMBER_COOKIE : DEV_REMEMBER_COOKIE
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function guestCookieName(secure: boolean): string {
|
|
46
|
+
return secure ? GUEST_COOKIE : DEV_GUEST_COOKIE
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CookieAttrs {
|
|
50
|
+
readonly httpOnly: boolean
|
|
51
|
+
readonly secure: boolean
|
|
52
|
+
readonly sameSite: 'lax' | 'strict' | 'none'
|
|
53
|
+
readonly path: string
|
|
54
|
+
readonly expires?: Date
|
|
55
|
+
readonly maxAge?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function base(secure: boolean): Omit<CookieAttrs, 'expires'> {
|
|
59
|
+
return { httpOnly: true, secure, sameSite: 'lax', path: '/' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function sessionCookie(expires: Date, secure: boolean): CookieAttrs {
|
|
63
|
+
return { ...base(secure), expires }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function rememberCookie(expires: Date, secure: boolean): CookieAttrs {
|
|
67
|
+
return { ...base(secure), expires }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function guestCookie(expires: Date, secure: boolean): CookieAttrs {
|
|
71
|
+
return { ...base(secure), expires }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function adminCookie(expires: Date, secure: boolean): CookieAttrs {
|
|
75
|
+
return {
|
|
76
|
+
httpOnly: true,
|
|
77
|
+
secure,
|
|
78
|
+
sameSite: 'strict',
|
|
79
|
+
path: ADMIN_COOKIE_PATH,
|
|
80
|
+
expires,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function clearedAdminCookie(secure: boolean): CookieAttrs {
|
|
85
|
+
return {
|
|
86
|
+
httpOnly: true,
|
|
87
|
+
secure,
|
|
88
|
+
sameSite: 'strict',
|
|
89
|
+
path: ADMIN_COOKIE_PATH,
|
|
90
|
+
expires: new Date(0),
|
|
91
|
+
maxAge: 0,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function clearedCookie(secure: boolean): CookieAttrs {
|
|
96
|
+
return { ...base(secure), expires: new Date(0), maxAge: 0 }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function ssoCookieName(secure: boolean): string {
|
|
100
|
+
return secure ? SSO_COOKIE : DEV_SSO_COOKIE
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function passkeyCookieName(secure: boolean): string {
|
|
104
|
+
return secure ? PASSKEY_COOKIE : DEV_PASSKEY_COOKIE
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function secondFactorCookieName(secure: boolean): string {
|
|
108
|
+
return secure ? SECOND_FACTOR_COOKIE : DEV_SECOND_FACTOR_COOKIE
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A sign-in that has proved its password and nothing else. Strict, because
|
|
113
|
+
* every step that finishes it starts on this board — and short, because it
|
|
114
|
+
* stands in for a password already given.
|
|
115
|
+
*/
|
|
116
|
+
export function secondFactorCookie(expires: Date, secure: boolean): CookieAttrs {
|
|
117
|
+
return { httpOnly: true, secure, sameSite: 'strict', path: '/', expires }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Lax, not Strict: the identity provider sends the member back with a top-level
|
|
122
|
+
* GET, and a Strict cookie is not attached to a navigation that started on
|
|
123
|
+
* another site — the handshake would arrive with nothing to check the state
|
|
124
|
+
* against and every sign-in would fail.
|
|
125
|
+
*/
|
|
126
|
+
export function ssoCookie(secure: boolean): CookieAttrs {
|
|
127
|
+
return { ...base(secure), maxAge: HANDSHAKE_MINUTES * 60 }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Strict: nothing in the passkey exchange ever leaves this board. */
|
|
131
|
+
export function passkeyCookie(secure: boolean): CookieAttrs {
|
|
132
|
+
return {
|
|
133
|
+
httpOnly: true,
|
|
134
|
+
secure,
|
|
135
|
+
sameSite: 'strict',
|
|
136
|
+
path: '/',
|
|
137
|
+
maxAge: HANDSHAKE_MINUTES * 60,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { redirect } from 'next/navigation'
|
|
4
|
+
|
|
5
|
+
import { verifyPassword } from '@meith/accounts'
|
|
6
|
+
|
|
7
|
+
import type { FormState } from './auth-form-state'
|
|
8
|
+
import { getContainer } from './container'
|
|
9
|
+
import { getActor } from './context'
|
|
10
|
+
import { markCurrentSessionCredentialProved } from './credential-proof'
|
|
11
|
+
import { text } from './form-values'
|
|
12
|
+
import { tr } from './i18n'
|
|
13
|
+
import { isSafeLocalPath } from './safe-path'
|
|
14
|
+
import { twoFactorService } from './two-factor'
|
|
15
|
+
|
|
16
|
+
export async function proveCredentialAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
17
|
+
const actor = await getActor()
|
|
18
|
+
if (actor.userId === null) return { error: await tr('credentialProof.signIn') }
|
|
19
|
+
|
|
20
|
+
const method = text(form, 'method')
|
|
21
|
+
let proved = false
|
|
22
|
+
|
|
23
|
+
if (method === 'password') {
|
|
24
|
+
const account = await getContainer().accountStore.accounts.findById(actor.userId)
|
|
25
|
+
proved = await verifyPassword(text(form, 'password'), account?.passwordHash)
|
|
26
|
+
} else if (method === 'code') {
|
|
27
|
+
const service = twoFactorService()
|
|
28
|
+
const outcome =
|
|
29
|
+
service === null
|
|
30
|
+
? null
|
|
31
|
+
: await service.verify({ userId: actor.userId, code: text(form, 'code') })
|
|
32
|
+
proved = outcome?.status === 'ok'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!proved) return { error: await tr('credentialProof.incorrect') }
|
|
36
|
+
if (!(await markCurrentSessionCredentialProved(actor.userId))) {
|
|
37
|
+
return { error: await tr('credentialProof.expired') }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const next = text(form, 'next')
|
|
41
|
+
redirect(isSafeLocalPath(next) ? next : '/usercp/security?verified=1')
|
|
42
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { hasFreshCredentialProof, hashToken, type SessionRecord } from '@meith/accounts'
|
|
4
|
+
|
|
5
|
+
import { getContainer } from './container'
|
|
6
|
+
import { readSessionToken } from './session-cookies'
|
|
7
|
+
|
|
8
|
+
export interface CurrentCredentialProof {
|
|
9
|
+
readonly session: SessionRecord
|
|
10
|
+
readonly provedAt: Date
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function currentCredentialProof(
|
|
14
|
+
userId: number,
|
|
15
|
+
now = new Date(),
|
|
16
|
+
): Promise<CurrentCredentialProof | null> {
|
|
17
|
+
const session = await currentSessionRecord()
|
|
18
|
+
if (session === null || !hasFreshCredentialProof(session, userId, now)) return null
|
|
19
|
+
return { session, provedAt: session.credentialProvedAt! }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function markCurrentSessionCredentialProved(
|
|
23
|
+
userId: number,
|
|
24
|
+
at = new Date(),
|
|
25
|
+
): Promise<boolean> {
|
|
26
|
+
const session = await currentSessionRecord()
|
|
27
|
+
if (session === null || session.userId !== userId) return false
|
|
28
|
+
return getContainer().accountStore.sessions.markCredentialProved(session.id, userId, at)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function currentSessionRecord(): Promise<SessionRecord | null> {
|
|
32
|
+
const token = await readSessionToken()
|
|
33
|
+
if (token === undefined || token === '') return null
|
|
34
|
+
return getContainer().accountStore.sessions.findByTokenHash(await hashToken(token))
|
|
35
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { headers } from 'next/headers'
|
|
4
|
+
import { cache } from 'react'
|
|
5
|
+
|
|
6
|
+
import { PATH_HEADER, QUERY_HEADER } from './location-header'
|
|
7
|
+
|
|
8
|
+
export const currentLocation = cache(async (): Promise<string> => {
|
|
9
|
+
try {
|
|
10
|
+
const jar = await headers()
|
|
11
|
+
const path = jar.get(PATH_HEADER)
|
|
12
|
+
if (typeof path !== 'string' || !path.startsWith('/')) return ''
|
|
13
|
+
|
|
14
|
+
const query = jar.get(QUERY_HEADER)
|
|
15
|
+
return typeof query === 'string' && query.startsWith('?') ? `${path}${query}` : path
|
|
16
|
+
} catch {
|
|
17
|
+
return ''
|
|
18
|
+
}
|
|
19
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { env, logger, readPluginEnv } from '@meith/core'
|
|
4
|
+
import {
|
|
5
|
+
FAKE_STRIPE_MOUNT,
|
|
6
|
+
type FakeStripeRequest,
|
|
7
|
+
type FakeStripeResponse,
|
|
8
|
+
fakeStripe,
|
|
9
|
+
} from '@meith/demo'
|
|
10
|
+
import { signStripePayload } from '@meith/plugin-dues'
|
|
11
|
+
import { resolvePluginSettings } from '@meith/plugin-kit'
|
|
12
|
+
|
|
13
|
+
import { boardUrl } from './board-url'
|
|
14
|
+
import { activeDefinitions } from './plugin-host'
|
|
15
|
+
import { dispatchPluginRoute } from './plugin-routes'
|
|
16
|
+
import { getSettingOverrides } from './settings'
|
|
17
|
+
|
|
18
|
+
const DUES = 'dues'
|
|
19
|
+
|
|
20
|
+
const SETTLED = new Set(['granted', 'already-settled', 'paid-but-grant-refused'])
|
|
21
|
+
|
|
22
|
+
interface Global {
|
|
23
|
+
__meithFakeStripe?: Map<string, ReturnType<typeof fakeStripe>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function instance(publicBase: string): ReturnType<typeof fakeStripe> {
|
|
27
|
+
const store = (globalThis as Global).__meithFakeStripe ?? new Map()
|
|
28
|
+
;(globalThis as Global).__meithFakeStripe = store
|
|
29
|
+
|
|
30
|
+
const existing = store.get(publicBase)
|
|
31
|
+
if (existing !== undefined) return existing
|
|
32
|
+
|
|
33
|
+
const made = fakeStripe({ publicBase })
|
|
34
|
+
store.set(publicBase, made)
|
|
35
|
+
return made
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function handleFakeStripe(request: Request, path: string): Promise<Response> {
|
|
39
|
+
if (!env.DEMO_MODE) return new Response('Not found', { status: 404 })
|
|
40
|
+
|
|
41
|
+
const url = new URL(request.url)
|
|
42
|
+
const call: FakeStripeRequest = {
|
|
43
|
+
method: request.method,
|
|
44
|
+
path: path === '' ? '/' : `/${path}`,
|
|
45
|
+
body: request.method === 'POST' ? await request.text() : '',
|
|
46
|
+
query: url.searchParams,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const outcome = instance(`${await boardUrl()}${FAKE_STRIPE_MOUNT}`).handle(call)
|
|
50
|
+
if (outcome.event !== null) await deliver(outcome.event)
|
|
51
|
+
return respond(outcome.response)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function respond(response: FakeStripeResponse): Response {
|
|
55
|
+
if (response.kind === 'redirect') {
|
|
56
|
+
return new Response(null, { status: 303, headers: { location: response.to } })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const contentType =
|
|
60
|
+
response.kind === 'json' ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8'
|
|
61
|
+
const body = response.kind === 'json' ? JSON.stringify(response.body) : response.body
|
|
62
|
+
|
|
63
|
+
return new Response(body, {
|
|
64
|
+
status: response.status,
|
|
65
|
+
headers: { 'content-type': contentType, 'cache-control': 'no-store' },
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function deliver(event: unknown): Promise<void> {
|
|
70
|
+
const definition = activeDefinitions().find((candidate) => candidate.key === DUES)
|
|
71
|
+
if (definition === undefined) return
|
|
72
|
+
|
|
73
|
+
const settings = resolvePluginSettings(definition, await getSettingOverrides(), readPluginEnv)
|
|
74
|
+
const secret = String(settings.stripe_webhook_secret ?? '')
|
|
75
|
+
if (secret === '') {
|
|
76
|
+
logger().warn('the demo Stripe has no webhook secret to sign with')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const body = JSON.stringify(event)
|
|
81
|
+
const signature = signStripePayload(body, secret, Math.floor(Date.now() / 1000))
|
|
82
|
+
const board = await boardUrl()
|
|
83
|
+
|
|
84
|
+
const response = await dispatchPluginRoute(
|
|
85
|
+
new Request(`${board}/api/plugins/${DUES}/hook/stripe`, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
body,
|
|
88
|
+
headers: { 'content-type': 'application/json', 'stripe-signature': signature },
|
|
89
|
+
}),
|
|
90
|
+
DUES,
|
|
91
|
+
['hook', 'stripe'],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const outcome = response.ok
|
|
95
|
+
? String(((await response.json()) as { outcome?: unknown }).outcome ?? 'unknown')
|
|
96
|
+
: 'rejected'
|
|
97
|
+
|
|
98
|
+
if (!SETTLED.has(outcome)) {
|
|
99
|
+
logger().warn(
|
|
100
|
+
{ status: response.status, outcome },
|
|
101
|
+
'the demo Stripe delivered a webhook the board did not act on',
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { rm } from 'node:fs/promises'
|
|
4
|
+
|
|
5
|
+
import { env, logger } from '@meith/core'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Everything a visitor uploaded, thrown away with the board that referenced it.
|
|
9
|
+
*
|
|
10
|
+
* Only local storage is cleared. A demo pointed at an object store is not a
|
|
11
|
+
* configuration this project asks anyone to run, and quietly issuing bulk
|
|
12
|
+
* deletes against a bucket somebody else's board might share is a worse failure
|
|
13
|
+
* than leaving files behind.
|
|
14
|
+
*
|
|
15
|
+
* Kept apart from the rest of the demo helpers because the container calls this
|
|
16
|
+
* one, and the others call the container.
|
|
17
|
+
*/
|
|
18
|
+
export async function clearUploadedFiles(): Promise<void> {
|
|
19
|
+
if (env.FILESTORE_DRIVER !== 'local') {
|
|
20
|
+
logger({ module: 'demo' }).warn(
|
|
21
|
+
{ driver: env.FILESTORE_DRIVER },
|
|
22
|
+
'demo reset left uploads in place: only the local file store is cleared',
|
|
23
|
+
)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await rm(env.UPLOADS_DIR, { recursive: true, force: true })
|
|
28
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { env, logger } from '@meith/core'
|
|
4
|
+
import { getDb } from '@meith/db'
|
|
5
|
+
import {
|
|
6
|
+
assertDemoAccountIsChangeable,
|
|
7
|
+
type DemoBanner,
|
|
8
|
+
demoBanner,
|
|
9
|
+
type FrozenField,
|
|
10
|
+
nextDemoResetAt,
|
|
11
|
+
} from '@meith/demo'
|
|
12
|
+
|
|
13
|
+
import { getContainer } from './container'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Refuses a change to a published demo login's password or email.
|
|
17
|
+
*
|
|
18
|
+
* Checked by user id, resolved to a username here, because the demo package
|
|
19
|
+
* cannot know the ids — they are handed out by Postgres at seed time and differ
|
|
20
|
+
* every reset. The username is the stable thing.
|
|
21
|
+
*/
|
|
22
|
+
export async function assertDemoAccountChangeable(
|
|
23
|
+
userId: number,
|
|
24
|
+
what: FrozenField,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
if (!env.DEMO_MODE) return
|
|
27
|
+
|
|
28
|
+
const account = await getContainer().accountStore.accounts.findById(userId)
|
|
29
|
+
if (account === null) return
|
|
30
|
+
|
|
31
|
+
assertDemoAccountIsChangeable(account.username, what)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The administrator's own edit screen can rename a member, and renaming
|
|
36
|
+
* `admin` is the same lockout as changing its password by a longer route — the
|
|
37
|
+
* banner would go on naming a login that no longer exists. Only refused when
|
|
38
|
+
* the value actually differs, so an admin can still open the demo login's
|
|
39
|
+
* account screen and change its groups.
|
|
40
|
+
*/
|
|
41
|
+
export async function assertDemoIdentityUnchanged(
|
|
42
|
+
userId: number,
|
|
43
|
+
submitted: { readonly username: string; readonly email: string },
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
if (!env.DEMO_MODE) return
|
|
46
|
+
|
|
47
|
+
const account = await getContainer().accountStore.accounts.findById(userId)
|
|
48
|
+
if (account === null) return
|
|
49
|
+
|
|
50
|
+
if (submitted.username.trim().toLowerCase() !== account.usernameLower) {
|
|
51
|
+
assertDemoAccountIsChangeable(account.username, 'username')
|
|
52
|
+
}
|
|
53
|
+
if (submitted.email.trim().toLowerCase() !== account.emailLower) {
|
|
54
|
+
assertDemoAccountIsChangeable(account.username, 'email')
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The banner's content, or `null` on any board that is not a demo.
|
|
60
|
+
*
|
|
61
|
+
* Failure here is deliberately quiet. The banner is an aid, and a board that
|
|
62
|
+
* refuses to render because it could not work out when it next resets is worse
|
|
63
|
+
* than a board that renders without the countdown.
|
|
64
|
+
*/
|
|
65
|
+
export async function demoBannerModel(): Promise<DemoBanner | null> {
|
|
66
|
+
if (!env.DEMO_MODE) return null
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
return demoBanner({
|
|
70
|
+
nextResetAt: await nextDemoResetAt(getDb()),
|
|
71
|
+
now: new Date(),
|
|
72
|
+
})
|
|
73
|
+
} catch (error) {
|
|
74
|
+
logger({ module: 'demo' }).warn(
|
|
75
|
+
{ err: String(error) },
|
|
76
|
+
'could not read the next demo reset time',
|
|
77
|
+
)
|
|
78
|
+
return demoBanner({ nextResetAt: null, now: new Date() })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { msg } from '@meith/i18n'
|
|
2
|
+
import 'server-only'
|
|
3
|
+
|
|
4
|
+
import type { Actor } from '@meith/authorization'
|
|
5
|
+
import { contentScopeFrom, ForbiddenError } from '@meith/core'
|
|
6
|
+
import {
|
|
7
|
+
type DiscoveryPage,
|
|
8
|
+
type DiscoveryScope,
|
|
9
|
+
getDb,
|
|
10
|
+
PostgresDiscoveryRepository,
|
|
11
|
+
} from '@meith/db'
|
|
12
|
+
|
|
13
|
+
import { getContainer } from './container'
|
|
14
|
+
|
|
15
|
+
export const DISCOVER_PAGE = 20
|
|
16
|
+
|
|
17
|
+
export const DISCOVERY_VIEWS = ['new', 'today', 'mine', 'participated', 'unanswered'] as const
|
|
18
|
+
export type DiscoveryView = (typeof DISCOVERY_VIEWS)[number]
|
|
19
|
+
|
|
20
|
+
export function isDiscoveryView(value: string): value is DiscoveryView {
|
|
21
|
+
return (DISCOVERY_VIEWS as readonly string[]).includes(value)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function discoveryRepository(): PostgresDiscoveryRepository | null {
|
|
25
|
+
return getContainer().dataSource === 'postgres' ? new PostgresDiscoveryRepository(getDb()) : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function discoveryScopeFor(actor: Actor): Promise<DiscoveryScope> {
|
|
29
|
+
const { authorizer } = getContainer()
|
|
30
|
+
const staff = actor.global.isAdministrator === true || actor.global.isSuperModerator === true
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
...(await authorizer.threadAudience(actor)),
|
|
34
|
+
content: contentScopeFrom({ seesUnapproved: staff, seesDeleted: staff }),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function runDiscovery(input: {
|
|
39
|
+
readonly actor: Actor
|
|
40
|
+
readonly view: DiscoveryView
|
|
41
|
+
readonly now: Date
|
|
42
|
+
readonly timeZone: string
|
|
43
|
+
readonly after: DiscoveryPage['nextCursor']
|
|
44
|
+
}): Promise<DiscoveryPage> {
|
|
45
|
+
const repo = discoveryRepository()
|
|
46
|
+
if (repo === null) throw new ForbiddenError(msg('error.app.board-thread-index'))
|
|
47
|
+
|
|
48
|
+
const scope = await discoveryScopeFor(input.actor)
|
|
49
|
+
const query = { limit: DISCOVER_PAGE, after: input.after }
|
|
50
|
+
|
|
51
|
+
switch (input.view) {
|
|
52
|
+
case 'today':
|
|
53
|
+
return repo.activeSince(startOfDay(input.now, input.timeZone), query, scope)
|
|
54
|
+
case 'unanswered':
|
|
55
|
+
return repo.unanswered(query, scope)
|
|
56
|
+
case 'mine':
|
|
57
|
+
if (input.actor.userId === null) throw new ForbiddenError(msg('error.app.sign-see-threads'))
|
|
58
|
+
return repo.startedBy(input.actor.userId, query, scope)
|
|
59
|
+
case 'participated':
|
|
60
|
+
if (input.actor.userId === null) throw new ForbiddenError(msg('error.app.sign-see-threads'))
|
|
61
|
+
return repo.participatedIn(input.actor.userId, query, scope)
|
|
62
|
+
// biome-ignore lint/complexity/noUselessSwitchCase: naming the view keeps the switch exhaustive against the DiscoveryView union
|
|
63
|
+
case 'new':
|
|
64
|
+
default:
|
|
65
|
+
return repo.activeSince(new Date(input.now.getTime() - 86_400_000), query, scope)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function startOfDay(now: Date, timeZone: string): Date {
|
|
70
|
+
try {
|
|
71
|
+
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
72
|
+
timeZone,
|
|
73
|
+
year: 'numeric',
|
|
74
|
+
month: '2-digit',
|
|
75
|
+
day: '2-digit',
|
|
76
|
+
}).formatToParts(now)
|
|
77
|
+
|
|
78
|
+
const get = (type: string): string => parts.find((part) => part.type === type)?.value ?? '01'
|
|
79
|
+
|
|
80
|
+
const local = new Date(`${get('year')}-${get('month')}-${get('day')}T00:00:00Z`)
|
|
81
|
+
const offset = local.getTime() - zonedInstant(local, timeZone)
|
|
82
|
+
return new Date(local.getTime() + offset)
|
|
83
|
+
} catch {
|
|
84
|
+
return new Date(now.getTime() - 86_400_000)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function zonedInstant(date: Date, timeZone: string): number {
|
|
89
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
90
|
+
timeZone,
|
|
91
|
+
hour12: false,
|
|
92
|
+
year: 'numeric',
|
|
93
|
+
month: '2-digit',
|
|
94
|
+
day: '2-digit',
|
|
95
|
+
hour: '2-digit',
|
|
96
|
+
minute: '2-digit',
|
|
97
|
+
second: '2-digit',
|
|
98
|
+
}).formatToParts(date)
|
|
99
|
+
|
|
100
|
+
const get = (type: string): string => parts.find((part) => part.type === type)?.value ?? '00'
|
|
101
|
+
return Date.parse(
|
|
102
|
+
`${get('year')}-${get('month')}-${get('day')}T${get('hour') === '24' ? '00' : get('hour')}:${get('minute')}:${get('second')}Z`,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { env } from '@meith/core'
|
|
4
|
+
import { escapeAttribute, escapeHtml, safeImageUrl, safeUrl, unescapeHtml } from '@meith/markdown'
|
|
5
|
+
|
|
6
|
+
interface Provider {
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly match: (url: URL) => boolean
|
|
9
|
+
readonly oembedUrl: (url: URL) => string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const PROVIDERS: readonly Provider[] = [
|
|
13
|
+
{
|
|
14
|
+
name: 'YouTube',
|
|
15
|
+
match: (url) =>
|
|
16
|
+
['www.youtube.com', 'youtube.com', 'youtu.be'].includes(url.hostname) && url.pathname !== '/',
|
|
17
|
+
oembedUrl: (url) =>
|
|
18
|
+
`https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(url.href)}`,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'Vimeo',
|
|
22
|
+
match: (url) => ['www.vimeo.com', 'vimeo.com'].includes(url.hostname) && url.pathname !== '/',
|
|
23
|
+
oembedUrl: (url) => `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(url.href)}`,
|
|
24
|
+
},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const MAX_EMBEDS_PER_POST = 3
|
|
28
|
+
const FETCH_TIMEOUT_MS = 2_000
|
|
29
|
+
|
|
30
|
+
interface OEmbedResponse {
|
|
31
|
+
readonly title?: unknown
|
|
32
|
+
readonly author_name?: unknown
|
|
33
|
+
readonly thumbnail_url?: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LONE_LINK =
|
|
37
|
+
/<p><a href="([^"]*)" rel="nofollow ugc noopener noreferrer"(?: title="[^"]*")?>[^<]*<\/a><\/p>/g
|
|
38
|
+
|
|
39
|
+
async function fetchOEmbed(provider: Provider, url: URL): Promise<OEmbedResponse | null> {
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(provider.oembedUrl(url), {
|
|
42
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
43
|
+
headers: { accept: 'application/json' },
|
|
44
|
+
})
|
|
45
|
+
if (!response.ok) return null
|
|
46
|
+
if (!(response.headers.get('content-type') ?? '').includes('application/json')) return null
|
|
47
|
+
return (await response.json()) as OEmbedResponse
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderCard(provider: Provider, href: string, data: OEmbedResponse): string | null {
|
|
54
|
+
const link = safeUrl(href)
|
|
55
|
+
if (link === null) return null
|
|
56
|
+
|
|
57
|
+
const title = typeof data.title === 'string' && data.title !== '' ? data.title : provider.name
|
|
58
|
+
const author = typeof data.author_name === 'string' ? data.author_name : null
|
|
59
|
+
const thumbnail = typeof data.thumbnail_url === 'string' ? safeImageUrl(data.thumbnail_url) : null
|
|
60
|
+
|
|
61
|
+
const image =
|
|
62
|
+
thumbnail === null
|
|
63
|
+
? ''
|
|
64
|
+
: `<img src="${escapeAttribute(thumbnail)}" alt="" loading="lazy" class="md-embed-thumb">`
|
|
65
|
+
const byline = author === null ? '' : `<span class="md-embed-author">${escapeHtml(author)}</span>`
|
|
66
|
+
const linkAttr = escapeAttribute(link)
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
`<figure class="md-embed" data-provider="${escapeAttribute(provider.name.toLowerCase())}">` +
|
|
70
|
+
`<a href="${linkAttr}" rel="nofollow ugc noopener noreferrer">${image}</a>` +
|
|
71
|
+
`<figcaption>` +
|
|
72
|
+
`<a href="${linkAttr}" rel="nofollow ugc noopener noreferrer" class="md-embed-title">${escapeHtml(title)}</a>` +
|
|
73
|
+
`${byline}<span class="md-embed-provider">${escapeHtml(provider.name)}</span>` +
|
|
74
|
+
`</figcaption></figure>`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function unfurlEmbeds(html: string): Promise<string> {
|
|
79
|
+
if (!env.REMOTE_IMAGES || !html.includes('<a href=')) return html
|
|
80
|
+
|
|
81
|
+
const matches = [...html.matchAll(LONE_LINK)].slice(0, MAX_EMBEDS_PER_POST)
|
|
82
|
+
if (matches.length === 0) return html
|
|
83
|
+
|
|
84
|
+
const replacements = new Map<string, string>()
|
|
85
|
+
|
|
86
|
+
for (const match of matches) {
|
|
87
|
+
const whole = match[0]
|
|
88
|
+
if (replacements.has(whole)) continue
|
|
89
|
+
|
|
90
|
+
const href = unescapeHtml(match[1] ?? '')
|
|
91
|
+
let url: URL
|
|
92
|
+
try {
|
|
93
|
+
url = new URL(href)
|
|
94
|
+
} catch {
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if (url.protocol !== 'https:') continue
|
|
98
|
+
|
|
99
|
+
const provider = PROVIDERS.find((candidate) => candidate.match(url))
|
|
100
|
+
if (provider === undefined) continue
|
|
101
|
+
|
|
102
|
+
const data = await fetchOEmbed(provider, url)
|
|
103
|
+
if (data === null) continue
|
|
104
|
+
|
|
105
|
+
const card = renderCard(provider, href, data)
|
|
106
|
+
if (card === null) continue
|
|
107
|
+
|
|
108
|
+
replacements.set(whole, `${whole}${card}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (replacements.size === 0) return html
|
|
112
|
+
return html.replace(LONE_LINK, (whole) => replacements.get(whole) ?? whole)
|
|
113
|
+
}
|