Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/app/g/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,31 @@ import { preload } from "react-dom";

import { fetchFeedServer } from "@/lib/fetch-feed";
import { OG_IMAGE_PIXEL_SIZE } from "@/lib/generation-media-url";
import { getGenerationBySlugCached } from "@/lib/public-generation";
import {
findPublishedSlugAlias,
getGenerationBySlugCached,
} from "@/lib/public-generation";
publishedGenerationPath,
} from "@/lib/published-slug-alias";
import { formatOgDescription, formatResultTitle } from "@/lib/ui/format";
import type { FeedItem } from "@/lib/ui/types";

import { GenerationDetailClient } from "./generation-detail-client";

type Props = { params: Promise<{ slug: string }> };

async function redirectToPublishedSlugAlias(slug: string): Promise<void> {
const alias = await findPublishedSlugAlias(slug);
if (alias) permanentRedirect(publishedGenerationPath(alias));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const gen = await getGenerationBySlugCached(slug);

if (!gen) {
// Must throw before this metadata is committed; otherwise the response is
// already 200 HTML ("Not found" + noindex) and permanentRedirect streams.
await redirectToPublishedSlugAlias(slug);
return {
title: "Not found",
robots: { index: false, follow: false },
Expand Down Expand Up @@ -73,8 +82,7 @@ export default async function GenerationDetailPage({ params }: Props) {
const { slug } = await params;
const gen = await getGenerationBySlugCached(slug);
if (!gen) {
const alias = await findPublishedSlugAlias(slug);
if (alias) permanentRedirect(`/g/${encodeURIComponent(alias)}`);
await redirectToPublishedSlugAlias(slug);
notFound();
}

Expand Down
109 changes: 109 additions & 0 deletions src/lib/__tests__/published-slug-alias.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import {
generationDetailSlugFromPathname,
publishedGenerationPath,
resolvePublishedSlugAliasRedirect,
} from "@/lib/published-slug-alias";

const { createPublicFeedClient } = vi.hoisted(() => ({
createPublicFeedClient: vi.fn(),
}));

vi.mock("@/lib/feed-client", () => ({
createPublicFeedClient,
}));

afterEach(() => {
vi.clearAllMocks();
});

function mockGenerationsQuery(
results: Array<{ data: unknown; error: { message: string } | null }>,
) {
const query: {
eq: ReturnType<typeof vi.fn>;
in: ReturnType<typeof vi.fn>;
like: ReturnType<typeof vi.fn>;
limit: ReturnType<typeof vi.fn>;
} = {
eq: vi.fn(),
in: vi.fn(),
like: vi.fn(),
limit: vi.fn(),
};
query.eq.mockReturnValue(query);
query.in.mockReturnValue(query);
query.like.mockReturnValue(query);
for (const result of results) {
query.limit.mockResolvedValueOnce(result);
}
createPublicFeedClient.mockReturnValue({
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue(query),
}),
});
return query;
}

describe("generationDetailSlugFromPathname", () => {
it("reads a single /g/{slug} segment", () => {
expect(generationDetailSlugFromPathname("/g/microsoft-teams-tinder")).toBe(
"microsoft-teams-tinder",
);
expect(
generationDetailSlugFromPathname("/g/microsoft-teams-tinder/"),
).toBe("microsoft-teams-tinder");
expect(generationDetailSlugFromPathname("/g/foo/bar")).toBeNull();
expect(generationDetailSlugFromPathname("/feed")).toBeNull();
});
});

describe("publishedGenerationPath", () => {
it("encodes the alias path", () => {
expect(publishedGenerationPath("microsoft-teams-tinder-1")).toBe(
"/g/microsoft-teams-tinder-1",
);
});
});

describe("resolvePublishedSlugAliasRedirect", () => {
it("returns the lowest numeric alias when the exact slug is not published", async () => {
mockGenerationsQuery([
{ data: [], error: null },
{
data: [
{ slug: "microsoft-teams-tinder-2" },
{ slug: "microsoft-teams-tinder-1" },
{ slug: "microsoft-teams-tinder-box" },
],
error: null,
},
]);

await expect(
resolvePublishedSlugAliasRedirect("microsoft-teams-tinder"),
).resolves.toBe("microsoft-teams-tinder-1");
});

it("does not redirect when the exact slug is already a published generation", async () => {
mockGenerationsQuery([
{ data: [{ slug: "youtube-figma" }], error: null },
]);

await expect(
resolvePublishedSlugAliasRedirect("youtube-figma"),
).resolves.toBeNull();
});

it("returns null when no numeric alias exists", async () => {
mockGenerationsQuery([
{ data: [], error: null },
{ data: [], error: null },
]);

await expect(
resolvePublishedSlugAliasRedirect("no-such-mashup"),
).resolves.toBeNull();
});
});
39 changes: 0 additions & 39 deletions src/lib/public-generation.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { getAnonSessionId } from "@/lib/anon-session";
import { createPublicFeedClient } from "@/lib/feed-client";
import { publicGenerationsQuery } from "@/lib/feed-public-filters";
import { generationImageUrl } from "@/lib/generation-media-url";
import type { GenerationStatus } from "@/lib/generation/types";
import { isGenerationStatus } from "@/lib/generation/types";
import { isNumericSlugAlias } from "@/lib/slug";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { createSupabaseServiceClient } from "@/lib/supabase/service";
import { cache } from "react";
Expand Down Expand Up @@ -259,42 +256,6 @@ export async function getGenerationBySlug(
*/
export const getGenerationBySlugCached = cache(getGenerationBySlug);

/**
* If `/g/{slug}` is not a public generation, find a published numeric alias
* (`{slug}-1`) so dead uniqueness-suffix paths can redirect.
*/
export async function findPublishedSlugAlias(
requested: string,
): Promise<string | null> {
const trimmed = requested.trim();
if (!trimmed) return null;

const supabase = createPublicFeedClient();
if (!supabase) return null;

try {
const { data, error } = await publicGenerationsQuery(supabase, "slug")
.like("slug", `${trimmed}-%`)
.limit(50);

if (error || !data) return null;

const matches = (data as { slug: unknown }[])
.map((row) => row.slug)
.filter((slug): slug is string => typeof slug === "string")
.filter((slug) => isNumericSlugAlias(trimmed, slug) && slug !== trimmed)
.sort((a, b) => {
const aN = Number(a.slice(trimmed.length + 1));
const bN = Number(b.slice(trimmed.length + 1));
return aN - bN;
});

return matches[0] ?? null;
} catch {
return null;
}
}

/** @deprecated Use getGenerationBySlug */
export async function getPublishedGenerationBySlug(
slug: string,
Expand Down
91 changes: 91 additions & 0 deletions src/lib/published-slug-alias.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { createPublicFeedClient } from "@/lib/feed-client";
import { publicGenerationsQuery } from "@/lib/feed-public-filters";
import { isNumericSlugAlias } from "@/lib/slug";

/** `/g/{slug}` with a single path segment, or null. */
export function generationDetailSlugFromPathname(
pathname: string,
): string | null {
const match = /^\/g\/([^/]+)\/?$/.exec(pathname);
if (!match?.[1]) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return match[1];
}
}

export function publishedGenerationPath(slug: string): string {
return `/g/${encodeURIComponent(slug)}`;
}

/**
* If `/g/{slug}` is not a public generation, find a published numeric alias
* (`{slug}-1`) so dead uniqueness-suffix paths can redirect.
*/
export async function findPublishedSlugAlias(
requested: string,
): Promise<string | null> {
const trimmed = requested.trim();
if (!trimmed) return null;

const supabase = createPublicFeedClient();
if (!supabase) return null;

try {
const { data, error } = await publicGenerationsQuery(supabase, "slug")
.like("slug", `${trimmed}-%`)
.limit(50);

if (error || !data) return null;

const matches = (data as { slug: unknown }[])
.map((row) => row.slug)
.filter((slug): slug is string => typeof slug === "string")
.filter((slug) => isNumericSlugAlias(trimmed, slug) && slug !== trimmed)
.sort((a, b) => {
const aN = Number(a.slice(trimmed.length + 1));
const bN = Number(b.slice(trimmed.length + 1));
return aN - bN;
});

return matches[0] ?? null;
} catch {
return null;
}
}

async function hasPublishedGenerationSlug(slug: string): Promise<boolean> {
const trimmed = slug.trim();
if (!trimmed) return false;

const supabase = createPublicFeedClient();
if (!supabase) return false;

try {
const { data, error } = await publicGenerationsQuery(supabase, "slug")
.in("slug", [trimmed])
.limit(1);

if (error || !data) return false;

return (data as { slug: unknown }[]).some(
(row) => typeof row.slug === "string" && row.slug === trimmed,
);
} catch {
return false;
}
}

/**
* Alias to 308 toward when `{slug}` itself is not a published generation.
* Returns null when the exact slug is live, so `/g/foo` is not sent to `/g/foo-1`.
*/
export async function resolvePublishedSlugAliasRedirect(
requested: string,
): Promise<string | null> {
const trimmed = requested.trim();
if (!trimmed) return null;
if (await hasPublishedGenerationSlug(trimmed)) return null;
return findPublishedSlugAlias(trimmed);
}
27 changes: 27 additions & 0 deletions src/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,41 @@
import { createServerClient } from "@supabase/ssr";
import { type NextRequest, NextResponse } from "next/server";

import {
generationDetailSlugFromPathname,
publishedGenerationPath,
resolvePublishedSlugAliasRedirect,
} from "@/lib/published-slug-alias";
import { hasSupabaseAuthCookie } from "@/lib/supabase/auth-cookie";
import { tryGetSupabasePublicEnv } from "@/lib/supabase/public-env";

/**
* HTTP 308 before any HTML. `permanentRedirect` in the page runs after
* generateMetadata / the root layout have already started the document, so
* crawlers that only read the status line saw 200 + "Not found" + noindex.
*/
async function publishedSlugAliasRedirect(
request: NextRequest,
): Promise<NextResponse | null> {
const slug = generationDetailSlugFromPathname(request.nextUrl.pathname);
if (!slug) return null;

const alias = await resolvePublishedSlugAliasRedirect(slug);
if (!alias) return null;

const url = request.nextUrl.clone();
url.pathname = publishedGenerationPath(alias);
return NextResponse.redirect(url, 308);
}

/**
* Refreshes Supabase Auth cookies before render (Next.js 16+ proxy convention).
* @see https://supabase.com/docs/guides/auth/server-side/nextjs
*/
export async function proxy(request: NextRequest) {
const aliasRedirect = await publishedSlugAliasRedirect(request);
if (aliasRedirect) return aliasRedirect;

if (!hasSupabaseAuthCookie(request.cookies.getAll())) {
return NextResponse.next({ request });
}
Expand Down
Loading