diff --git a/web/src/app/community/d/[id]/page.tsx b/web/src/app/community/d/[id]/page.tsx index f243053f5..18e411a48 100644 --- a/web/src/app/community/d/[id]/page.tsx +++ b/web/src/app/community/d/[id]/page.tsx @@ -22,7 +22,9 @@ export async function generateMetadata(props: RouteParams): Promise { if (!communityEnabled()) return {}; const { id } = await props.params; const deck = await getDeck(id); - if (!deck || deck.visibility === "private") return {}; + // A gone deck takes community/not-found.tsx's title, as a gone pattern does. + if (!deck) notFound(); + if (deck.visibility === "private") return {}; return { title: `${deck.title} / Patternflow Community`, description: diff --git a/web/src/app/community/not-found.test.tsx b/web/src/app/community/not-found.test.tsx new file mode 100644 index 000000000..420ce94c0 --- /dev/null +++ b/web/src/app/community/not-found.test.tsx @@ -0,0 +1,122 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { render, screen } from "@testing-library/react"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import CommunityNotFound, { metadata } from "./not-found"; + +// A deleted pattern's old address, a deck or profile that never existed, and +// somebody else's private work all 404. They used to land on the site's root +// 404: "404", a link to the landing page, no community header, and a tab that +// still read "Community / Patternflow". This file now draws them inside the +// community layout, and each page's metadata 404s with it, so a gone row takes +// this file's title instead of the layout's. + +// The pages' client halves run pattern code in sandboxes; metadata never +// reaches them. +vi.mock("./p/[id]/PatternDetailClient", () => ({ default: () => null })); +vi.mock("./d/[id]/DeckDetailClient", () => ({ default: () => null })); +vi.mock("@/components/community/DeckCard", () => ({ default: () => null })); +vi.mock("@/components/community/PatternCard", () => ({ default: () => null })); +vi.mock("@/components/community/SignOutLink", () => ({ default: () => null })); + +const ENV_KEYS = ["COMMUNITY_DB_PATH", "COMMUNITY_ENABLED", "BETTER_AUTH_SECRET"] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-not-found-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "not-found-test-secret-not-a-real-one"; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + + await getAuth().api.signUpEmail({ + body: { email: "ada@patternflow.local", password: "not-found-test-password", name: "ada", username: "ada" }, + }); + const db = getDb(); + const [ada] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, "ada")) + .limit(1); + const userId = ada!.id; + const now = new Date(); + await db.insert(schema.patterns).values({ + id: "private1", userId, title: "Night bus window", code: "// js", visibility: "private", createdAt: now, updatedAt: now, + }); + await db.insert(schema.decks).values({ + id: "private2", userId, title: "Late set", visibility: "private", createdAt: now, updatedAt: now, + }); +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe("the community's 404", () => { + it("says why the page is gone and links to the wall, not the landing page", () => { + render(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Nothing at this address."); + expect(screen.getByText(/deleted, or its owner made it private/)).toBeInTheDocument(); + const links = screen.getAllByRole("link"); + expect(links.map((link) => link.getAttribute("href"))).toEqual(["/community/patterns"]); + expect(links[0]).toHaveTextContent("Browse patterns"); + }); + + it("names itself in the tab", () => { + expect(metadata.title).toBe("Not found / Patternflow Community"); + }); +}); + +// notFound() throws an error whose digest names the status. +const NOT_FOUND = { digest: "NEXT_HTTP_ERROR_FALLBACK;404" }; + +describe("a gone row's metadata", () => { + it("404s for a pattern that is not there", async () => { + const { generateMetadata } = await import("./p/[id]/page"); + const props = { params: Promise.resolve({ id: "0000000000000000" }), searchParams: Promise.resolve({}) }; + await expect(generateMetadata(props)).rejects.toMatchObject(NOT_FOUND); + }); + + it("404s for a deck that is not there", async () => { + const { generateMetadata } = await import("./d/[id]/page"); + await expect(generateMetadata({ params: Promise.resolve({ id: "0000000000000000" }) })).rejects.toMatchObject( + NOT_FOUND, + ); + }); + + it("404s for a profile that is not there", async () => { + const { generateMetadata } = await import("./u/[username]/page"); + await expect(generateMetadata({ params: Promise.resolve({ username: "nobody404" }) })).rejects.toMatchObject( + NOT_FOUND, + ); + }); + + // Metadata cannot tell the owner from a visitor, and the owner opens these. + it("stays generic for a private pattern or deck, which its owner can still open", async () => { + const pattern = await import("./p/[id]/page"); + const deck = await import("./d/[id]/page"); + await expect( + pattern.generateMetadata({ params: Promise.resolve({ id: "private1" }), searchParams: Promise.resolve({}) }), + ).resolves.toEqual({}); + await expect(deck.generateMetadata({ params: Promise.resolve({ id: "private2" }) })).resolves.toEqual({}); + }); + + it("still names a profile that is there", async () => { + const { generateMetadata } = await import("./u/[username]/page"); + await expect(generateMetadata({ params: Promise.resolve({ username: "ada" }) })).resolves.toMatchObject({ + title: "@ada / Patternflow Community", + }); + }); +}); diff --git a/web/src/app/community/not-found.tsx b/web/src/app/community/not-found.tsx new file mode 100644 index 000000000..4d377026e --- /dev/null +++ b/web/src/app/community/not-found.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import styles from "@/components/community/Community.module.css"; + +// A pattern, deck or profile page calls notFound() for a row that is gone and +// for one its owner made private (a 404, not a 403, so the id is never +// confirmed). Without this file those links fell through to the site's root +// 404, which drops the community header and tabs and links to the landing +// page. Here the layout stays around it and the way on is the wall. + +export const metadata: Metadata = { + title: "Not found / Patternflow Community", +}; + +export default function CommunityNotFound() { + return ( +
+ 404 · not here +

Nothing at this address.

+

+ Whatever was here has been deleted, or its owner made it private. A mistyped link + lands here too. +

+ + Browse patterns → + +
+ ); +} diff --git a/web/src/app/community/p/[id]/page.tsx b/web/src/app/community/p/[id]/page.tsx index e8616afec..42ecbeb46 100644 --- a/web/src/app/community/p/[id]/page.tsx +++ b/web/src/app/community/p/[id]/page.tsx @@ -40,9 +40,12 @@ export async function generateMetadata(props: RouteParams): Promise { if (!communityEnabled()) return {}; const { id } = await props.params; const pattern = await getPatternOnce(id); + // A gone pattern takes community/not-found.tsx's title. The page itself + // 404s too, but the tab only reads what this function decides. + if (!pattern) notFound(); // Metadata is what previews and crawlers read, so a private pattern's title // must not surface here — even the owner's tab goes generic. - if (!pattern || pattern.visibility === "private") return {}; + if (pattern.visibility === "private") return {}; return { title: `${pattern.title} / Patternflow Community`, description: pattern.description ?? `An LED matrix pattern by ${pattern.displayUsername ?? pattern.username}.`, diff --git a/web/src/app/community/u/[username]/page.tsx b/web/src/app/community/u/[username]/page.tsx index a447a3f12..a8ed73ff0 100644 --- a/web/src/app/community/u/[username]/page.tsx +++ b/web/src/app/community/u/[username]/page.tsx @@ -30,7 +30,8 @@ export async function generateMetadata(props: RouteParams): Promise { if (!communityEnabled()) return {}; const { username } = await props.params; const profile = await getUserByUsername(username.toLowerCase()); - if (!profile) return {}; + // A gone account takes community/not-found.tsx's title, as a gone pattern does. + if (!profile) notFound(); const handle = profile.displayUsername ?? profile.username; return { title: `@${handle} / Patternflow Community`,