@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,208 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
bearerFrom,
|
|
5
|
+
consumeAnonymousRateLimit,
|
|
6
|
+
consumeRateLimit,
|
|
7
|
+
hasScope,
|
|
8
|
+
type Method,
|
|
9
|
+
matchRoute,
|
|
10
|
+
type RateLimitOutcome,
|
|
11
|
+
type RouteSpec,
|
|
12
|
+
rateLimitHeaders,
|
|
13
|
+
routeKey,
|
|
14
|
+
} from '@meith/api'
|
|
15
|
+
import type { Actor } from '@meith/authorization'
|
|
16
|
+
import { isAppError, metrics, statusForError, toPublicError, withSpan } from '@meith/core'
|
|
17
|
+
import { currentRequestId } from '@meith/core/logger'
|
|
18
|
+
|
|
19
|
+
import { JSON_MEDIA_TYPE } from '@/server/api/http'
|
|
20
|
+
import { handlerFor } from '@/server/api/registry'
|
|
21
|
+
import {
|
|
22
|
+
type AuthenticatedToken,
|
|
23
|
+
anonymousLimits,
|
|
24
|
+
anonymousSubject,
|
|
25
|
+
apiActor,
|
|
26
|
+
apiGuest,
|
|
27
|
+
apiToken,
|
|
28
|
+
} from '@/server/api-auth'
|
|
29
|
+
import { boardOffline } from '@/server/board-offline'
|
|
30
|
+
import { getMessageResolver } from '@/server/i18n'
|
|
31
|
+
|
|
32
|
+
export const dynamic = 'force-dynamic'
|
|
33
|
+
|
|
34
|
+
function json(body: unknown, status: number, headers: Record<string, string> = {}): Response {
|
|
35
|
+
return new Response(JSON.stringify(body), {
|
|
36
|
+
status,
|
|
37
|
+
headers: { 'content-type': JSON_MEDIA_TYPE, ...headers },
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fail(status: number, code: string, message: string, headers = {}): Response {
|
|
42
|
+
return json({ error: { code, message, requestId: currentRequestId() ?? null } }, status, headers)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Caller {
|
|
46
|
+
readonly actor: Actor
|
|
47
|
+
readonly authenticated: AuthenticatedToken | null
|
|
48
|
+
readonly limit: RateLimitOutcome | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function resolveCaller(
|
|
52
|
+
route: RouteSpec,
|
|
53
|
+
presented: string | null,
|
|
54
|
+
): Promise<Caller | Response> {
|
|
55
|
+
if (presented !== null) {
|
|
56
|
+
const authenticated = await apiToken(presented)
|
|
57
|
+
if (authenticated === null) return fail(401, 'unauthenticated', 'That token is not valid.')
|
|
58
|
+
|
|
59
|
+
if (!hasScope(authenticated.token, route.scope)) {
|
|
60
|
+
return fail(403, 'missing_scope', `This token does not carry the "${route.scope}" scope.`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const limit = await consumeRateLimit(
|
|
64
|
+
authenticated.limits,
|
|
65
|
+
authenticated.token.id,
|
|
66
|
+
route.cost,
|
|
67
|
+
new Date(),
|
|
68
|
+
)
|
|
69
|
+
if (!limit.allowed) return refused(limit)
|
|
70
|
+
|
|
71
|
+
const actor = await apiActor(authenticated.token.userId)
|
|
72
|
+
if (actor === null) {
|
|
73
|
+
return fail(403, 'owner_unavailable', 'The account this token belongs to cannot act.', {
|
|
74
|
+
...rateLimitHeaders(limit),
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { actor, authenticated, limit }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (route.authenticated) {
|
|
82
|
+
return fail(401, 'unauthenticated', 'Send a bearer token in the Authorization header.')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const store = anonymousLimits()
|
|
86
|
+
const limit =
|
|
87
|
+
store === null
|
|
88
|
+
? null
|
|
89
|
+
: await consumeAnonymousRateLimit(store, await anonymousSubject(), route.cost, new Date())
|
|
90
|
+
|
|
91
|
+
if (limit !== null && !limit.allowed) return refused(limit)
|
|
92
|
+
|
|
93
|
+
return { actor: await apiGuest(), authenticated: null, limit }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function refused(limit: RateLimitOutcome): Response {
|
|
97
|
+
return fail(429, 'rate_limited', 'Too many requests. Slow down and try again.', {
|
|
98
|
+
...rateLimitHeaders(limit),
|
|
99
|
+
'retry-after': String(limit.resetSeconds),
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const httpRequestSeconds = metrics.histogram(
|
|
104
|
+
'meith_http_request_duration_seconds',
|
|
105
|
+
'REST API v1 request duration in seconds, labelled by method, route and status.',
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async function handle(request: NextRequest, method: Method): Promise<Response> {
|
|
109
|
+
const startedAt = Date.now()
|
|
110
|
+
const url = new URL(request.url)
|
|
111
|
+
const path = url.pathname.replace(/^\/api\/v1/, '')
|
|
112
|
+
const matched = matchRoute(method, path)
|
|
113
|
+
const routeLabel = matched === null ? 'unmatched' : matched.route.path
|
|
114
|
+
|
|
115
|
+
const response = await withSpan(
|
|
116
|
+
'http.request',
|
|
117
|
+
{ 'http.method': method, 'http.route': routeLabel },
|
|
118
|
+
async (span) => {
|
|
119
|
+
const result = await respondTo(request, url, matched)
|
|
120
|
+
span.setAttribute('http.status_code', result.status)
|
|
121
|
+
return result
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
httpRequestSeconds.observe((Date.now() - startedAt) / 1000, {
|
|
126
|
+
method,
|
|
127
|
+
route: routeLabel,
|
|
128
|
+
status: String(response.status),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return response
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function respondTo(
|
|
135
|
+
request: NextRequest,
|
|
136
|
+
url: URL,
|
|
137
|
+
matched: ReturnType<typeof matchRoute>,
|
|
138
|
+
): Promise<Response> {
|
|
139
|
+
if (matched === null) {
|
|
140
|
+
return fail(404, 'no_such_route', 'No such endpoint. See /api/v1/openapi.json for the routes.')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const caller = await resolveCaller(
|
|
144
|
+
matched.route,
|
|
145
|
+
bearerFrom(request.headers.get('authorization')),
|
|
146
|
+
)
|
|
147
|
+
if (caller instanceof Response) return caller
|
|
148
|
+
|
|
149
|
+
const headers = caller.limit === null ? {} : rateLimitHeaders(caller.limit)
|
|
150
|
+
|
|
151
|
+
const offline = await boardOffline(caller.actor)
|
|
152
|
+
if (offline !== null) return fail(503, 'board_offline', offline.message, headers)
|
|
153
|
+
|
|
154
|
+
const handler = handlerFor(routeKey(matched.route))
|
|
155
|
+
if (handler === null) {
|
|
156
|
+
return fail(
|
|
157
|
+
501,
|
|
158
|
+
'not_implemented',
|
|
159
|
+
`${matched.route.method} ${matched.route.path} is declared but not yet implemented.`,
|
|
160
|
+
headers,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const result = await handler({
|
|
166
|
+
actor: caller.actor,
|
|
167
|
+
token: caller.authenticated?.token ?? null,
|
|
168
|
+
params: matched.params,
|
|
169
|
+
url,
|
|
170
|
+
body: matched.route.request === undefined ? null : await readJsonBody(request),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return json(result.body, result.status, headers)
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (isAppError(err)) {
|
|
176
|
+
const { error } = toPublicError(err, await getMessageResolver())
|
|
177
|
+
return fail(statusForError(err), error.code, error.message, headers)
|
|
178
|
+
}
|
|
179
|
+
throw err
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function readJsonBody(request: NextRequest): Promise<Record<string, unknown> | null> {
|
|
184
|
+
try {
|
|
185
|
+
const parsed: unknown = await request.json()
|
|
186
|
+
return typeof parsed === 'object' && parsed !== null
|
|
187
|
+
? (parsed as Record<string, unknown>)
|
|
188
|
+
: null
|
|
189
|
+
} catch {
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function GET(request: NextRequest): Promise<Response> {
|
|
195
|
+
return handle(request, 'GET')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function POST(request: NextRequest): Promise<Response> {
|
|
199
|
+
return handle(request, 'POST')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function PATCH(request: NextRequest): Promise<Response> {
|
|
203
|
+
return handle(request, 'PATCH')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function DELETE(request: NextRequest): Promise<Response> {
|
|
207
|
+
return handle(request, 'DELETE')
|
|
208
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { openApiDocument } from '@meith/api'
|
|
2
|
+
|
|
3
|
+
import { JSON_MEDIA_TYPE, SPEC_CACHE } from '@/server/api/http'
|
|
4
|
+
import { CODE_VERSION } from '@/server/upgrade-notice'
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic'
|
|
7
|
+
|
|
8
|
+
export async function GET(): Promise<Response> {
|
|
9
|
+
return new Response(JSON.stringify(openApiDocument(CODE_VERSION)), {
|
|
10
|
+
headers: { 'content-type': JSON_MEDIA_TYPE, 'cache-control': SPEC_CACHE },
|
|
11
|
+
})
|
|
12
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { renderBrandMark } from '@/server/brand-mark'
|
|
2
|
+
|
|
3
|
+
export const dynamic = 'force-dynamic'
|
|
4
|
+
|
|
5
|
+
export const size = { width: 180, height: 180 }
|
|
6
|
+
|
|
7
|
+
export const contentType = 'image/png'
|
|
8
|
+
|
|
9
|
+
export default function AppleIcon(): Promise<Response> {
|
|
10
|
+
return renderBrandMark(size.width)
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { serveAttachment } from '@/server/attachment-download'
|
|
2
|
+
|
|
3
|
+
export const dynamic = 'force-dynamic'
|
|
4
|
+
|
|
5
|
+
export async function GET(
|
|
6
|
+
_request: Request,
|
|
7
|
+
context: { params: Promise<{ id: string }> },
|
|
8
|
+
): Promise<Response> {
|
|
9
|
+
return serveAttachment((await context.params).id, 'file')
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { serveAttachment } from '@/server/attachment-download'
|
|
2
|
+
|
|
3
|
+
export const dynamic = 'force-dynamic'
|
|
4
|
+
|
|
5
|
+
export async function GET(
|
|
6
|
+
_request: Request,
|
|
7
|
+
context: { params: Promise<{ id: string }> },
|
|
8
|
+
): Promise<Response> {
|
|
9
|
+
return serveAttachment((await context.params).id, 'thumb')
|
|
10
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server'
|
|
2
|
+
|
|
3
|
+
import { newChallenge } from '@meith/accounts'
|
|
4
|
+
import { logger, statusForError, toPublicError } from '@meith/core'
|
|
5
|
+
|
|
6
|
+
import { configuredIdentity, getContainer } from '@/server/container'
|
|
7
|
+
import { getActor } from '@/server/context'
|
|
8
|
+
import { currentCredentialProof } from '@/server/credential-proof'
|
|
9
|
+
import {
|
|
10
|
+
memberManagedSignIns,
|
|
11
|
+
passkeyRelyingPartyName,
|
|
12
|
+
passkeyService,
|
|
13
|
+
passkeysEnabled,
|
|
14
|
+
relyingParty,
|
|
15
|
+
} from '@/server/federation'
|
|
16
|
+
import { tr } from '@/server/i18n'
|
|
17
|
+
import { type PasskeyPurpose, packChallenge } from '@/server/passkey-challenge'
|
|
18
|
+
import { crossOriginRefusal, isSameOrigin } from '@/server/same-origin'
|
|
19
|
+
import { readSecondFactorToken, setPasskeyChallengeCookie } from '@/server/session-cookies'
|
|
20
|
+
|
|
21
|
+
export const runtime = 'nodejs'
|
|
22
|
+
export const dynamic = 'force-dynamic'
|
|
23
|
+
|
|
24
|
+
function json(body: unknown, status = 200): Response {
|
|
25
|
+
return new Response(JSON.stringify(body), {
|
|
26
|
+
status,
|
|
27
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function problem(message: string, status: number): Response {
|
|
32
|
+
return json({ error: { code: 'FORBIDDEN', message } }, status)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function POST(request: NextRequest): Promise<Response> {
|
|
36
|
+
if (!isSameOrigin(request)) return crossOriginRefusal()
|
|
37
|
+
|
|
38
|
+
if (!(await passkeysEnabled())) {
|
|
39
|
+
return problem(await tr('authRoute.passkey.disabled'), 404)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const asked = request.nextUrl.searchParams.get('for')
|
|
43
|
+
const purpose: PasskeyPurpose =
|
|
44
|
+
asked === 'register' || asked === 'second-factor' || asked === 'credential-proof'
|
|
45
|
+
? asked
|
|
46
|
+
: 'authenticate'
|
|
47
|
+
|
|
48
|
+
const challenge = newChallenge()
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
if (purpose === 'second-factor') {
|
|
52
|
+
const held = await readSecondFactorToken()
|
|
53
|
+
const pending =
|
|
54
|
+
held === undefined || held === ''
|
|
55
|
+
? null
|
|
56
|
+
: await (await configuredIdentity()).pendingSecondFactor(held)
|
|
57
|
+
|
|
58
|
+
if (pending === null) {
|
|
59
|
+
return problem(await tr('authRoute.passkey.signInExpired'), 403)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const heldPasskeys = await getContainer().accountStore.passkeys.listForUser(pending.userId)
|
|
63
|
+
if (heldPasskeys.length === 0) {
|
|
64
|
+
return problem(await tr('authRoute.passkey.noneForAccount'), 403)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const options = (await passkeyService()).assertionOptions({
|
|
68
|
+
challenge,
|
|
69
|
+
relyingParty: await relyingParty(),
|
|
70
|
+
allowCredentials: heldPasskeys.map((passkey) => passkey.credentialId),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
await setPasskeyChallengeCookie(packChallenge(purpose, challenge))
|
|
74
|
+
return json(options)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (purpose === 'credential-proof') {
|
|
78
|
+
const actor = await getActor()
|
|
79
|
+
if (actor.userId === null) {
|
|
80
|
+
return problem(await tr('authRoute.passkey.signInBeforeAdding'), 403)
|
|
81
|
+
}
|
|
82
|
+
const token = await import('@/server/session-cookies').then((module) =>
|
|
83
|
+
module.readSessionToken(),
|
|
84
|
+
)
|
|
85
|
+
const located = token ? await (await configuredIdentity()).locateSession(token) : null
|
|
86
|
+
if (located === null || located.userId !== actor.userId) {
|
|
87
|
+
return problem(await tr('authRoute.passkey.signInExpired'), 403)
|
|
88
|
+
}
|
|
89
|
+
const heldPasskeys = await getContainer().accountStore.passkeys.listForUser(actor.userId)
|
|
90
|
+
if (heldPasskeys.length === 0) {
|
|
91
|
+
return problem(await tr('authRoute.passkey.noneForAccount'), 403)
|
|
92
|
+
}
|
|
93
|
+
const options = (await passkeyService()).assertionOptions({
|
|
94
|
+
challenge,
|
|
95
|
+
relyingParty: await relyingParty(),
|
|
96
|
+
allowCredentials: heldPasskeys.map((passkey) => passkey.credentialId),
|
|
97
|
+
})
|
|
98
|
+
await setPasskeyChallengeCookie(
|
|
99
|
+
packChallenge(purpose, challenge, { userId: actor.userId, sessionId: located.sessionId }),
|
|
100
|
+
)
|
|
101
|
+
return json(options)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (purpose === 'authenticate') {
|
|
105
|
+
const options = (await passkeyService()).assertionOptions({
|
|
106
|
+
challenge,
|
|
107
|
+
relyingParty: await relyingParty(),
|
|
108
|
+
})
|
|
109
|
+
await setPasskeyChallengeCookie(packChallenge(purpose, challenge))
|
|
110
|
+
return json(options)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const actor = await getActor()
|
|
114
|
+
if (actor.userId === null) {
|
|
115
|
+
return problem(await tr('authRoute.passkey.signInBeforeAdding'), 403)
|
|
116
|
+
}
|
|
117
|
+
if (!(await memberManagedSignIns())) {
|
|
118
|
+
return problem(await tr('authRoute.passkey.managedSignIns'), 403)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const proof = await currentCredentialProof(actor.userId)
|
|
122
|
+
if (proof === null) {
|
|
123
|
+
return problem(await tr('authRoute.passkey.recentProofRequired'), 403)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const options = await (await passkeyService()).registrationOptions({
|
|
127
|
+
userId: actor.userId,
|
|
128
|
+
challenge,
|
|
129
|
+
relyingParty: await relyingParty(),
|
|
130
|
+
boardName: await passkeyRelyingPartyName(),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
await setPasskeyChallengeCookie(
|
|
134
|
+
packChallenge(purpose, challenge, {
|
|
135
|
+
userId: actor.userId,
|
|
136
|
+
sessionId: proof.session.id,
|
|
137
|
+
provedAt: proof.provedAt.getTime(),
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
return json(options)
|
|
141
|
+
} catch (err) {
|
|
142
|
+
logger({ module: 'passkeys' }).warn({ err, purpose }, 'could not issue passkey options')
|
|
143
|
+
return json(toPublicError(err), statusForError(err))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server'
|
|
2
|
+
|
|
3
|
+
import { logger, statusForError, toPublicError } from '@meith/core'
|
|
4
|
+
|
|
5
|
+
import { recordAuthEvent } from '@/server/auth-events'
|
|
6
|
+
import { configuredIdentity, configuredSessions } from '@/server/container'
|
|
7
|
+
import { getActor } from '@/server/context'
|
|
8
|
+
import {
|
|
9
|
+
currentCredentialProof,
|
|
10
|
+
markCurrentSessionCredentialProved,
|
|
11
|
+
} from '@/server/credential-proof'
|
|
12
|
+
import {
|
|
13
|
+
memberManagedSignIns,
|
|
14
|
+
passkeyService,
|
|
15
|
+
passkeysEnabled,
|
|
16
|
+
relyingParty,
|
|
17
|
+
} from '@/server/federation'
|
|
18
|
+
import { tr } from '@/server/i18n'
|
|
19
|
+
import { type PasskeyPurpose, unpackChallenge } from '@/server/passkey-challenge'
|
|
20
|
+
import { retainedIpPrefix } from '@/server/request-fingerprint'
|
|
21
|
+
import { isSafeLocalPath } from '@/server/safe-path'
|
|
22
|
+
import { crossOriginRefusal, isSameOrigin } from '@/server/same-origin'
|
|
23
|
+
import {
|
|
24
|
+
clearPasskeyChallengeCookie,
|
|
25
|
+
clearSecondFactorCookie,
|
|
26
|
+
readPasskeyChallengeCookie,
|
|
27
|
+
readSecondFactorToken,
|
|
28
|
+
readSessionToken,
|
|
29
|
+
setRememberCookie,
|
|
30
|
+
setSessionCookie,
|
|
31
|
+
} from '@/server/session-cookies'
|
|
32
|
+
|
|
33
|
+
export const runtime = 'nodejs'
|
|
34
|
+
export const dynamic = 'force-dynamic'
|
|
35
|
+
|
|
36
|
+
interface Submission {
|
|
37
|
+
readonly id?: unknown
|
|
38
|
+
readonly label?: unknown
|
|
39
|
+
readonly transports?: unknown
|
|
40
|
+
readonly clientDataJSON?: unknown
|
|
41
|
+
readonly attestationObject?: unknown
|
|
42
|
+
readonly authenticatorData?: unknown
|
|
43
|
+
readonly signature?: unknown
|
|
44
|
+
readonly next?: unknown
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function json(body: unknown, status = 200): Response {
|
|
48
|
+
return new Response(JSON.stringify(body), {
|
|
49
|
+
status,
|
|
50
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function problem(message: string, status: number): Response {
|
|
55
|
+
return json({ error: { code: 'FORBIDDEN', message } }, status)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function text(value: unknown): string | null {
|
|
59
|
+
return typeof value === 'string' && value !== '' ? value : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function POST(request: NextRequest): Promise<Response> {
|
|
63
|
+
if (!isSameOrigin(request)) return crossOriginRefusal()
|
|
64
|
+
|
|
65
|
+
if (!(await passkeysEnabled())) {
|
|
66
|
+
return problem(await tr('authRoute.passkey.disabled'), 404)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const asked = request.nextUrl.searchParams.get('for')
|
|
70
|
+
const purpose: PasskeyPurpose =
|
|
71
|
+
asked === 'register' || asked === 'second-factor' || asked === 'credential-proof'
|
|
72
|
+
? asked
|
|
73
|
+
: 'authenticate'
|
|
74
|
+
|
|
75
|
+
let body: Submission
|
|
76
|
+
try {
|
|
77
|
+
body = (await request.json()) as Submission
|
|
78
|
+
} catch {
|
|
79
|
+
return problem(await tr('authRoute.passkey.responseUnreadable'), 400)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const challengeState = unpackChallenge(await readPasskeyChallengeCookie(), purpose)
|
|
83
|
+
await clearPasskeyChallengeCookie()
|
|
84
|
+
|
|
85
|
+
if (challengeState === null) {
|
|
86
|
+
return problem(await tr('authRoute.passkey.attemptExpired'), 400)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const clientDataJSON = text(body.clientDataJSON)
|
|
90
|
+
if (clientDataJSON === null) {
|
|
91
|
+
return problem(await tr('authRoute.passkey.responseIncomplete'), 400)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const log = logger({ module: 'passkeys' })
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const service = await passkeyService()
|
|
98
|
+
const party = await relyingParty()
|
|
99
|
+
|
|
100
|
+
if (purpose === 'register') {
|
|
101
|
+
const actor = await getActor()
|
|
102
|
+
if (actor.userId === null)
|
|
103
|
+
return problem(await tr('authRoute.passkey.signInBeforeAdding'), 403)
|
|
104
|
+
if (!(await memberManagedSignIns())) {
|
|
105
|
+
return problem(await tr('authRoute.passkey.managedSignIns'), 403)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const proof = await currentCredentialProof(actor.userId)
|
|
109
|
+
if (
|
|
110
|
+
proof === null ||
|
|
111
|
+
challengeState.userId !== actor.userId ||
|
|
112
|
+
challengeState.sessionId !== proof.session.id ||
|
|
113
|
+
challengeState.provedAt !== proof.provedAt.getTime()
|
|
114
|
+
) {
|
|
115
|
+
return problem(await tr('authRoute.passkey.recentProofRequired'), 403)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const attestationObject = text(body.attestationObject)
|
|
119
|
+
if (attestationObject === null) {
|
|
120
|
+
return problem(await tr('authRoute.passkey.responseIncomplete'), 400)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const passkey = await service.enrol({
|
|
124
|
+
userId: actor.userId,
|
|
125
|
+
label: text(body.label) ?? '',
|
|
126
|
+
expectedChallenge: challengeState.challenge,
|
|
127
|
+
relyingParty: party,
|
|
128
|
+
response: {
|
|
129
|
+
clientDataJSON,
|
|
130
|
+
attestationObject,
|
|
131
|
+
...(Array.isArray(body.transports)
|
|
132
|
+
? { transports: body.transports.filter((entry) => typeof entry === 'string') }
|
|
133
|
+
: {}),
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
await recordAuthEvent({ userId: actor.userId, kind: 'passkey_added' })
|
|
138
|
+
return json({ ok: true, label: passkey.label })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const credentialId = text(body.id)
|
|
142
|
+
const authenticatorData = text(body.authenticatorData)
|
|
143
|
+
const signature = text(body.signature)
|
|
144
|
+
|
|
145
|
+
if (credentialId === null || authenticatorData === null || signature === null) {
|
|
146
|
+
return problem(await tr('authRoute.passkey.responseIncomplete'), 400)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (purpose === 'credential-proof') {
|
|
150
|
+
const actor = await getActor()
|
|
151
|
+
const token = await readSessionToken()
|
|
152
|
+
const located = token ? await (await configuredIdentity()).locateSession(token) : null
|
|
153
|
+
if (
|
|
154
|
+
actor.userId === null ||
|
|
155
|
+
located === null ||
|
|
156
|
+
located.userId !== actor.userId ||
|
|
157
|
+
challengeState.userId !== actor.userId ||
|
|
158
|
+
challengeState.sessionId !== located.sessionId
|
|
159
|
+
) {
|
|
160
|
+
return problem(await tr('authRoute.passkey.signInExpired'), 403)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const proved = await service.proveOwnership({
|
|
164
|
+
userId: actor.userId,
|
|
165
|
+
credentialId,
|
|
166
|
+
expectedChallenge: challengeState.challenge,
|
|
167
|
+
relyingParty: party,
|
|
168
|
+
response: { clientDataJSON, authenticatorData, signature },
|
|
169
|
+
})
|
|
170
|
+
if (!proved || !(await markCurrentSessionCredentialProved(actor.userId))) {
|
|
171
|
+
return problem(await tr('authRoute.passkey.notForAccount'), 403)
|
|
172
|
+
}
|
|
173
|
+
return json({ ok: true, next: '/usercp/security?verified=1' })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (purpose === 'second-factor') {
|
|
177
|
+
const held = await readSecondFactorToken()
|
|
178
|
+
const identity = await configuredIdentity()
|
|
179
|
+
const pending =
|
|
180
|
+
held === undefined || held === '' ? null : await identity.pendingSecondFactor(held)
|
|
181
|
+
|
|
182
|
+
if (pending === null || held === undefined) {
|
|
183
|
+
return problem(await tr('authRoute.passkey.signInExpired'), 403)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await identity.assertSecondFactorAttemptsLeft(pending.userId)
|
|
187
|
+
|
|
188
|
+
const proved = await service.proveOwnership({
|
|
189
|
+
userId: pending.userId,
|
|
190
|
+
credentialId,
|
|
191
|
+
expectedChallenge: challengeState.challenge,
|
|
192
|
+
relyingParty: party,
|
|
193
|
+
response: { clientDataJSON, authenticatorData, signature },
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
if (!proved) {
|
|
197
|
+
await identity.recordSecondFactorFailure(pending.userId)
|
|
198
|
+
await recordAuthEvent({ userId: pending.userId, kind: 'second_factor_failed' })
|
|
199
|
+
return problem(await tr('authRoute.passkey.notForAccount'), 403)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await identity.clearSecondFactorFailures(pending.userId)
|
|
203
|
+
|
|
204
|
+
const login = await identity.redeemSecondFactor(held, {
|
|
205
|
+
ipPrefix: await retainedIpPrefix(),
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
await clearSecondFactorCookie()
|
|
209
|
+
await setSessionCookie(login.sessionToken, login.expiresAt)
|
|
210
|
+
|
|
211
|
+
if (pending.remember) {
|
|
212
|
+
const remembered = await (await configuredSessions()).startRemembered(pending.userId)
|
|
213
|
+
await setRememberCookie(remembered.rememberToken, remembered.rememberExpiresAt)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await recordAuthEvent({ userId: pending.userId, kind: 'login' })
|
|
217
|
+
|
|
218
|
+
const target = text(body.next)
|
|
219
|
+
return json({
|
|
220
|
+
ok: true,
|
|
221
|
+
next: target !== null && isSafeLocalPath(target) ? target : '/',
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const outcome = await service.authenticate({
|
|
226
|
+
credentialId,
|
|
227
|
+
expectedChallenge: challengeState.challenge,
|
|
228
|
+
relyingParty: party,
|
|
229
|
+
response: { clientDataJSON, authenticatorData, signature },
|
|
230
|
+
context: { ipPrefix: await retainedIpPrefix() },
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
await setSessionCookie(outcome.login.sessionToken, outcome.login.expiresAt)
|
|
234
|
+
|
|
235
|
+
const next = text(body.next)
|
|
236
|
+
return json({ ok: true, next: next !== null && isSafeLocalPath(next) ? next : '/' })
|
|
237
|
+
} catch (err) {
|
|
238
|
+
log.warn({ err, purpose }, 'a passkey exchange was refused')
|
|
239
|
+
return json(toPublicError(err), statusForError(err))
|
|
240
|
+
}
|
|
241
|
+
}
|