diff --git a/packages/webapp/__tests__/GatedPagesSeo.spec.ts b/packages/webapp/__tests__/GatedPagesSeo.spec.ts index 03a50466663..0378840e192 100644 --- a/packages/webapp/__tests__/GatedPagesSeo.spec.ts +++ b/packages/webapp/__tests__/GatedPagesSeo.spec.ts @@ -1,17 +1,52 @@ import type { NextSeoProps } from 'next-seo/lib/types'; +import { getSquadInvitation } from '@dailydotdev/shared/src/graphql/squads'; import FollowingFeed from '../pages/following'; +import SquadAnalytics from '../pages/squads/[handle]/analytics'; +import ModerateSquad from '../pages/squads/moderate'; +import { getStaticProps as getSquadInviteStaticProps } from '../pages/squads/[handle]/[token]'; + +jest.mock('@dailydotdev/shared/src/graphql/squads', () => { + const actual = jest.requireActual('@dailydotdev/shared/src/graphql/squads'); + + return { + ...actual, + getSquadInvitation: jest.fn(), + }; +}); type WithLayoutProps = { layoutProps?: { seo?: NextSeoProps }; }; +const layoutSeo = (page: unknown): NextSeoProps | undefined => + (page as WithLayoutProps).layoutProps?.seo; + // Regression lock: auth-gated pages must stay noindex,nofollow so private, // crawler-inaccessible surfaces are never advertised as indexable. describe('gated page seo', () => { - it('following feed is noindex and nofollow', () => { - const { seo } = (FollowingFeed as WithLayoutProps).layoutProps ?? {}; + it.each([ + ['following feed', FollowingFeed], + ['squad analytics', SquadAnalytics], + ['squad moderation', ModerateSquad], + ])('%s is noindex and nofollow', (_, page) => { + const seo = layoutSeo(page); expect(seo?.noindex).toBe(true); expect(seo?.nofollow).toBe(true); }); + + it.each([ + ['a valid invite', { user: { name: 'Ido' }, source: { name: 'My Squad' } }], + ['an invalid invite', {}], + ])('squad invite token page is noindex for %s', async (_, invitation) => { + (getSquadInvitation as jest.Mock).mockResolvedValue(invitation); + + const result = await getSquadInviteStaticProps({ + params: { handle: 'my-squad', token: 'token' }, + } as never); + + expect(result).toMatchObject({ + props: { seo: { noindex: true, nofollow: true } }, + }); + }); }); diff --git a/packages/webapp/__tests__/PostStaticProps.spec.ts b/packages/webapp/__tests__/PostStaticProps.spec.ts index d160954c069..4b2616975bc 100644 --- a/packages/webapp/__tests__/PostStaticProps.spec.ts +++ b/packages/webapp/__tests__/PostStaticProps.spec.ts @@ -1,4 +1,4 @@ -import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; import type { Post } from '@dailydotdev/shared/src/graphql/posts'; import { PostType } from '@dailydotdev/shared/src/graphql/posts'; import { getStaticProps, shouldNoindexPost } from '../pages/posts/[id]/index'; @@ -109,6 +109,39 @@ describe('post static props seo', () => { }); }); + it.each([ + ['the post is private', { private: true }], + ['the source is not public', { source: { public: false } }], + ])('should noindex when %s', (_, overrides) => { + expect( + shouldNoindexPost({ + ...createPost({ type: PostType.Article, upvotes: 10 }), + ...overrides, + } as Post), + ).toBe(true); + }); + + // The ISR fetch is unauthenticated, so posts in private squads always fail + // with FORBIDDEN. This fallback used to ship no seo, leaving them indexable. + it('should noindex the error fallback', async () => { + mockRequest.mockRejectedValue({ + response: { + errors: [{ extensions: { code: ApiError.Forbidden, postId: 'p-1' } }], + }, + }); + + const result = await getStaticProps({ + params: { id: 'post-id' }, + } as never); + + expect(result).toMatchObject({ + props: { + id: 'p-1', + seo: { noindex: true, nofollow: true }, + }, + }); + }); + it('should preserve indexed status when the author reputation is missing', () => { expect( shouldNoindexPost({ diff --git a/packages/webapp/__tests__/SquadPageStaticProps.spec.ts b/packages/webapp/__tests__/SquadPageStaticProps.spec.ts index fe04014f1b4..1ca068ce62a 100644 --- a/packages/webapp/__tests__/SquadPageStaticProps.spec.ts +++ b/packages/webapp/__tests__/SquadPageStaticProps.spec.ts @@ -1,4 +1,4 @@ -import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; import { getSquad, getSquadStaticFields, @@ -89,4 +89,47 @@ describe('squad page getServerSideProps seo', () => { }, }); }); + + it('marks a squad with an unknown visibility as noindex and nofollow', async () => { + mockStaticFields.mockResolvedValue({ + ...createSquad(true), + public: undefined, + }); + + const result = await runGssp(); + + expect(result).toMatchObject({ + props: { + seo: { + noindex: true, + nofollow: true, + }, + }, + }); + expect(result).not.toMatchObject({ props: { jsonLd: expect.anything() } }); + }); + + // SSR is always unauthenticated, so the API rejects every private squad with + // FORBIDDEN before we ever see `public`. This fallback used to ship no seo at + // all, which left private squads advertised as index,follow. + it.each([ApiError.Forbidden, ApiError.NotFound, ApiError.RateLimited])( + 'marks the %s fallback as noindex and nofollow', + async (code) => { + mockRequest.mockRejectedValue({ + response: { errors: [{ extensions: { code } }] }, + }); + + const result = await runGssp(); + + expect(result).toMatchObject({ + props: { + handle: 'my-squad', + seo: { + noindex: true, + nofollow: true, + }, + }, + }); + }, + ); }); diff --git a/packages/webapp/next-seo.ts b/packages/webapp/next-seo.ts index 75f5ae14460..55d76de21b2 100644 --- a/packages/webapp/next-seo.ts +++ b/packages/webapp/next-seo.ts @@ -69,8 +69,12 @@ export const TAGLINE = "Where developers discover what's next"; export const defaultSeoTitle = `daily.dev | ${TAGLINE}`; +// Shared by every logged-in Recruiter surface (dashboard, opportunities, +// candidate review, org settings, payment). None of them render for a crawler, +// and they hold customer data, so the whole product stays out of the index. export const recruiterSeo: NextSeoProps = { title: 'daily.dev Recruiter | Dashboard', description: 'Your dashboard for the developer-first hiring platform. Manage roles, review matches, and track warm introductions.', + ...noindexSeoProps, }; diff --git a/packages/webapp/pages/backoffice/keywords/[value].tsx b/packages/webapp/pages/backoffice/keywords/[value].tsx index a0dcb997e8b..8dd6e83475b 100644 --- a/packages/webapp/pages/backoffice/keywords/[value].tsx +++ b/packages/webapp/pages/backoffice/keywords/[value].tsx @@ -1,3 +1,4 @@ +import type { NextSeoProps } from 'next-seo'; import type { ReactElement } from 'react'; import React, { useContext } from 'react'; import type { @@ -15,6 +16,9 @@ import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; import Custom404 from '../../404'; import { getLayout as getMainLayout } from '../../../components/layouts/MainLayout'; import KeywordManagement from '../../../components/KeywordManagement'; +import { noindexSeoProps } from '../../../next-seo'; + +const seo: NextSeoProps = { ...noindexSeoProps }; export type KeywordPageProps = { keyword: string }; @@ -49,6 +53,8 @@ const KeywordPage = ({ KeywordPage.getLayout = getMainLayout; +KeywordPage.layoutProps = { seo }; + export default KeywordPage; export async function getStaticPaths(): Promise { diff --git a/packages/webapp/pages/backoffice/pendingKeywords.tsx b/packages/webapp/pages/backoffice/pendingKeywords.tsx index f8fb8231ac7..507b4b55a9b 100644 --- a/packages/webapp/pages/backoffice/pendingKeywords.tsx +++ b/packages/webapp/pages/backoffice/pendingKeywords.tsx @@ -1,3 +1,4 @@ +import type { NextSeoProps } from 'next-seo'; import type { ReactElement } from 'react'; import React, { useContext } from 'react'; import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; @@ -9,6 +10,9 @@ import useRequirePermissions from '@dailydotdev/shared/src/hooks/useRequirePermi import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; import KeywordManagement from '../../components/KeywordManagement'; import { getLayout as getMainLayout } from '../../components/layouts/MainLayout'; +import { noindexSeoProps } from '../../next-seo'; + +const seo: NextSeoProps = { ...noindexSeoProps }; const PendingKeywords = (): ReactElement => { useRequirePermissions(Roles.Moderator); @@ -58,4 +62,6 @@ const PendingKeywords = (): ReactElement => { PendingKeywords.getLayout = getMainLayout; +PendingKeywords.layoutProps = { seo }; + export default PendingKeywords; diff --git a/packages/webapp/pages/callback.tsx b/packages/webapp/pages/callback.tsx index 4010207ea22..02194b7e4d8 100644 --- a/packages/webapp/pages/callback.tsx +++ b/packages/webapp/pages/callback.tsx @@ -1,3 +1,4 @@ +import type { NextSeoProps } from 'next-seo'; import { broadcastMessage, postWindowMessage, @@ -11,6 +12,9 @@ import { AUTH_REDIRECT_KEY, shouldRedirectAuth, } from '@dailydotdev/shared/src/features/onboarding/shared'; +import { noindexSeoProps } from '../next-seo'; + +const seo: NextSeoProps = { ...noindexSeoProps }; const checkShouldSendBroadcast = () => { const ua = navigator.userAgent; @@ -94,4 +98,6 @@ function CallbackPage(): ReactElement | null { return null; } +CallbackPage.layoutProps = { seo }; + export default CallbackPage; diff --git a/packages/webapp/pages/join/organization.tsx b/packages/webapp/pages/join/organization.tsx index 8db81d3b6c2..70a5ce94a2f 100644 --- a/packages/webapp/pages/join/organization.tsx +++ b/packages/webapp/pages/join/organization.tsx @@ -32,6 +32,7 @@ import { InfoIcon } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { getFirstQueryParam } from '@dailydotdev/shared/src/lib/func'; import Custom404Seo from '../404'; +import { noindexSeoProps } from '../../next-seo'; const Page = ({ token, @@ -241,4 +242,7 @@ export const getServerSideProps: GetServerSideProps<{ } }; +// Personal, token-bearing invite link: never index it. +Page.layoutProps = { seo: { ...noindexSeoProps } }; + export default Page; diff --git a/packages/webapp/pages/posts/[id]/analytics/index.tsx b/packages/webapp/pages/posts/[id]/analytics/index.tsx index ce5650abcc3..7b2d6d28137 100644 --- a/packages/webapp/pages/posts/[id]/analytics/index.tsx +++ b/packages/webapp/pages/posts/[id]/analytics/index.tsx @@ -98,6 +98,7 @@ import { seoTitle } from '../index'; import { getPageSeoTitles } from '../../../../components/layouts/utils'; import { getLayout } from '../../../../components/layouts/MainLayout'; import { getPostCanonicalUrl } from '../../../../lib/seo'; +import { noindexSeoProps } from '../../../../next-seo'; import type { SharePostPageProps } from '../share'; import type { AnalyticsNumberList } from '../../../../../shared/src/components/analytics/common'; @@ -131,10 +132,13 @@ export const getServerSideProps: GetServerSideProps< const pageSeoTitles = getPageSeoTitles( [seoTitle(post), 'Analytics'].filter(Boolean).join(' | '), ); + // Only the post author can open this page, so it must never be indexed + // even though the post it belongs to is public. const seo: NextSeoProps = { canonical: post?.slug ? getPostCanonicalUrl(post.slug) : undefined, title: pageSeoTitles.title, description: getSeoDescription(post), + ...noindexSeoProps, openGraph: { ...pageSeoTitles.openGraph, images: [ @@ -168,7 +172,7 @@ export const getServerSideProps: GetServerSideProps< const { postId } = clientError.response.errors[0].extensions; return { - props: { id: postId || id }, + props: { id: postId || id, seo: { ...noindexSeoProps } }, }; } throw err; diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 8fb970f07ea..a470eff1ce1 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -60,6 +60,7 @@ import { PostSEOSchema, } from '../../../components/PostSEOSchema'; import type { DynamicSeoProps } from '../../../components/common'; +import { noindexSeoProps } from '../../../next-seo'; import useSharedByToast from '../../../hooks/useSharedByToast'; import { getPostCanonicalUrl } from '../../../lib/seo'; @@ -166,6 +167,12 @@ export const seoTitle = (post: Post): string | undefined => { }; export const shouldNoindexPost = (post: Post): boolean => { + // Posts in private squads normally fail the unauthenticated ISR fetch, but a + // cached page can outlive a squad turning private, so fail closed here too. + if (post?.private || post?.source?.public === false) { + return true; + } + const hasLowReputationAuthor = typeof post?.author?.reputation === 'number' && post.author.reputation <= 10; @@ -377,12 +384,6 @@ export async function getStaticProps({ }, locale: post?.language || 'en', }, - additionalMetaTags: [ - { - name: 'robots', - content: 'max-image-preview:large', - }, - ], }; return { @@ -409,8 +410,14 @@ export async function getStaticProps({ const { postId } = responseErrors?.[0]?.extensions ?? {}; + // FORBIDDEN lands here for every post in a private squad, since the ISR + // fetch is unauthenticated. Without seo these fell back to index,follow. return { - props: { id: postId || id, error: errorCode }, + props: { + id: postId || id, + error: errorCode, + seo: { ...noindexSeoProps }, + }, revalidate: 60, }; } diff --git a/packages/webapp/pages/reset-password.tsx b/packages/webapp/pages/reset-password.tsx index 7aad3a1c6bb..b8b296b9694 100644 --- a/packages/webapp/pages/reset-password.tsx +++ b/packages/webapp/pages/reset-password.tsx @@ -1,3 +1,4 @@ +import type { NextSeoProps } from 'next-seo'; import type { ReactElement } from 'react'; import React from 'react'; import { useRouter } from 'next/router'; @@ -8,6 +9,9 @@ import { Button, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; +import { noindexSeoProps } from '../next-seo'; + +const seo: NextSeoProps = { ...noindexSeoProps }; const ResetPassword = (): ReactElement | null => { const router = useRouter(); @@ -55,4 +59,6 @@ const ResetPassword = (): ReactElement | null => { ); }; +ResetPassword.layoutProps = { seo }; + export default ResetPassword; diff --git a/packages/webapp/pages/squads/[handle]/[token].tsx b/packages/webapp/pages/squads/[handle]/[token].tsx index bb1b5880476..deb545e5bac 100644 --- a/packages/webapp/pages/squads/[handle]/[token].tsx +++ b/packages/webapp/pages/squads/[handle]/[token].tsx @@ -42,7 +42,7 @@ import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; import Link from '@dailydotdev/shared/src/components/utilities/Link'; import { getLayout } from '../../../components/layouts/MainLayout'; -import { getSquadOpenGraph } from '../../../next-seo'; +import { getSquadOpenGraph, noindexSeoProps } from '../../../next-seo'; import type { DynamicSeoProps } from '../../../components/common'; const getOthers = (others: Edge[], total: number) => { @@ -306,14 +306,22 @@ export async function getStaticProps({ if (!source || !user) { return { - props: { handle, token, initialData: null }, + props: { + handle, + token, + initialData: null, + seo: { ...noindexSeoProps }, + }, }; } + // Invite tokens are personal, single-squad links (private squads included) and + // must never end up in search results. const seo: NextSeoProps = { title: `${user.name} invited you to ${source.name}`, description: source.description, openGraph: getSquadOpenGraph({ squad: source }), + ...noindexSeoProps, }; return { diff --git a/packages/webapp/pages/squads/[handle]/analytics.tsx b/packages/webapp/pages/squads/[handle]/analytics.tsx index 3badeab9d1c..a8b8a7d1e03 100644 --- a/packages/webapp/pages/squads/[handle]/analytics.tsx +++ b/packages/webapp/pages/squads/[handle]/analytics.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useEffect, useMemo } from 'react'; +import type { NextSeoProps } from 'next-seo'; import { useRouter } from 'next/router'; import { useQuery } from '@tanstack/react-query'; import { addDays, subDays } from 'date-fns'; @@ -49,6 +50,12 @@ import { } from '@dailydotdev/shared/src/lib/timezones'; import { getFirstQueryParam } from '@dailydotdev/shared/src/lib/func'; import { getLayout as getMainLayout } from '../../../components/layouts/MainLayout'; +import { noindexSeoProps } from '../../../next-seo'; + +const seo: NextSeoProps = { + title: 'Squad analytics', + ...noindexSeoProps, +}; const SectionContainer = classed('div', 'flex flex-col gap-4'); const dividerClassName = 'bg-border-subtlest-tertiary'; @@ -257,5 +264,6 @@ const SquadAnalyticsPage = (): ReactElement => { }; SquadAnalyticsPage.getLayout = getMainLayout; +SquadAnalyticsPage.layoutProps = { seo }; export default SquadAnalyticsPage; diff --git a/packages/webapp/pages/squads/[handle]/index.tsx b/packages/webapp/pages/squads/[handle]/index.tsx index f31d3336294..170c1b1cabe 100644 --- a/packages/webapp/pages/squads/[handle]/index.tsx +++ b/packages/webapp/pages/squads/[handle]/index.tsx @@ -92,7 +92,7 @@ import { mainFeedLayoutProps } from '../../../components/layouts/MainFeedPage'; import { getLayout } from '../../../components/layouts/FeedLayout'; import type { ProtectedPageProps } from '../../../components/ProtectedPage'; import ProtectedPage from '../../../components/ProtectedPage'; -import { getSquadOpenGraph } from '../../../next-seo'; +import { getSquadOpenGraph, noindexSeoProps } from '../../../next-seo'; import { getPageSeoTitles } from '../../../components/layouts/utils'; import type { DynamicSeoProps } from '../../../components/common'; import { getAppOrigin } from '../../../lib/seo'; @@ -696,7 +696,11 @@ export async function getServerSideProps({ referringUserPromise, ]); - const seoUsers = squad.public + // Fail closed: anything we can't positively confirm as a public squad stays + // out of the index. + const isPublicSquad = squad?.public === true; + + const seoUsers = isPublicSquad ? await getSquad(handle) .then((fullSquad) => getSeoSquadUsers(fullSquad)) .catch(() => undefined) @@ -716,8 +720,8 @@ export async function getServerSideProps({ ...squadSeoTitles.openGraph, ...getSquadOpenGraph({ squad }), }, - nofollow: !squad.public, - noindex: !squad.public, + nofollow: !isPublicSquad, + noindex: !isPublicSquad, }; return { @@ -727,7 +731,7 @@ export async function getServerSideProps({ initialData: squad as Squad, ...(seoUsers && { seoUsers }), ...(referringUser && { referringUser }), - ...(squad.public && { + ...(isPublicSquad && { jsonLd: getSquadPageJsonLd(squad as SquadStaticData), }), }, @@ -740,8 +744,11 @@ export async function getServerSideProps({ if (errors.includes(errorCode)) { setCacheHeader(); + // SSR always runs unauthenticated, so every private squad resolves as + // FORBIDDEN here. This branch also serves the soft-404/rate-limited + // cases, none of which should ever be advertised as indexable. return { - props: { handle }, + props: { handle, seo: { ...noindexSeoProps } }, }; } diff --git a/packages/webapp/pages/squads/moderate.tsx b/packages/webapp/pages/squads/moderate.tsx index 0079a0f6c1d..d55ff6de6c5 100644 --- a/packages/webapp/pages/squads/moderate.tsx +++ b/packages/webapp/pages/squads/moderate.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import type { GetServerSideProps } from 'next'; +import type { NextSeoProps } from 'next-seo'; import React, { useEffect } from 'react'; import { ManageSquadPageContainer } from '@dailydotdev/shared/src/components/squads/utils'; import { @@ -22,6 +23,12 @@ import { verifyPermission } from '@dailydotdev/shared/src/graphql/squads'; import { SourcePermissions } from '@dailydotdev/shared/src/graphql/sources'; import { TypographyType } from '@dailydotdev/shared/src/components/typography/Typography'; import { getLayout as getMainLayout } from '../../components/layouts/MainLayout'; +import { noindexSeoProps } from '../../next-seo'; + +const seo: NextSeoProps = { + title: 'Squad settings', + ...noindexSeoProps, +}; interface ModerateSquadPageProps { handle: string | null; @@ -82,3 +89,4 @@ export default function ModerateSquadPage({ } ModerateSquadPage.getLayout = getMainLayout; +ModerateSquadPage.layoutProps = { seo }; diff --git a/packages/webapp/pages/verification.tsx b/packages/webapp/pages/verification.tsx index 3ccf12fcc30..ef7e5e213e7 100644 --- a/packages/webapp/pages/verification.tsx +++ b/packages/webapp/pages/verification.tsx @@ -1,3 +1,4 @@ +import type { NextSeoProps } from 'next-seo'; import type { ReactElement } from 'react'; import React from 'react'; import EmailCodeVerification from '@dailydotdev/shared/src/components/auth/EmailCodeVerification'; @@ -14,6 +15,9 @@ import { Button, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; +import { noindexSeoProps } from '../next-seo'; + +const seo: NextSeoProps = { ...noindexSeoProps }; const Verification = (): ReactElement | null => { const router = useRouter(); @@ -78,4 +82,6 @@ const Verification = (): ReactElement | null => { ); }; +Verification.layoutProps = { seo }; + export default Verification; diff --git a/scripts/typecheck-strict-changed.js b/scripts/typecheck-strict-changed.js index 22d948f3a1e..4a3b48231ad 100644 --- a/scripts/typecheck-strict-changed.js +++ b/scripts/typecheck-strict-changed.js @@ -164,6 +164,18 @@ const strictSkipList = new Set([ // mutable formRef typing on unrelated lines) predate this change and should // be addressed in a dedicated cleanup PR. 'packages/webapp/pages/posts/[id]/edit.tsx', + // Noindex branch — these pages were touched only to attach `noindex` seo + // (a `layoutProps` assignment or a spread into an existing seo object). + // Pre-existing strict violations (squad/organization/member optionality, + // untyped route params, `null` component returns, campaign flag + // optionality) live on unrelated lines and should be addressed in a + // dedicated cleanup PR. + 'packages/webapp/pages/squads/[handle]/[token].tsx', + 'packages/webapp/pages/squads/[handle]/analytics.tsx', + 'packages/webapp/pages/squads/moderate.tsx', + 'packages/webapp/pages/join/organization.tsx', + 'packages/webapp/pages/posts/[id]/analytics/index.tsx', + 'packages/webapp/pages/backoffice/keywords/[value].tsx', ]); const changedFiles = getChangedTypescriptFiles().filter(