Skip to content
Open
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
12 changes: 12 additions & 0 deletions docs/API_VERSIONING_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ This project uses URL-based API versioning to protect clients from breaking chan
- Path-based versioning is the primary version selection mechanism
- API clients should prefer explicit `/api/v1/...` paths when available

## Supported version numbers

The middleware validates version strings against the pattern `/^v\d+$/` (the letter
`v` followed by one or more digits). Any other format is rejected with `400 Bad Request`.

| Version | Status | Notes |
|---------|---------|-------------------------|
| `v1` | Active | Current stable version |
| `v2` | Planned | Reserved for future use |

Examples of **invalid** version strings that are rejected: `vABC`, `v1.2`, `../v1`, `123`.

## Compatibility layer

The middleware rewrites legacy API requests from `/api/*` to `/api/v1/*`.
Expand Down
23 changes: 23 additions & 0 deletions src/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Metadata } from 'next';

export const metadata: Metadata = {
title: 'TeachLink - Sign In or Create an Account',
description:
'Access your TeachLink account to continue learning offline. Sign in, sign up, or verify your email.',
openGraph: {
title: 'TeachLink - Sign In or Create an Account',
description: 'Access your TeachLink account to continue learning.',
type: 'website',
siteName: 'TeachLink',
},
twitter: {
card: 'summary',
site: '@teachlink',
title: 'TeachLink - Sign In or Create an Account',
description: 'Access your TeachLink account to continue learning.',
},
};

export default function AuthLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
76 changes: 76 additions & 0 deletions src/app/__tests__/twitter-cards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import { metadata as rootMetadata } from '@/app/layout';
import { metadata as authMetadata } from '@/app/(auth)/layout';
import { metadata as dashboardMetadata } from '@/app/dashboard/layout';
import { metadata as profileMetadata } from '@/app/profile/layout';

describe('Twitter Cards metadata', () => {
describe('Root layout', () => {
it('exports a twitter card field', () => {
expect(rootMetadata.twitter).toBeDefined();
});

it('uses summary_large_image card type', () => {
expect(rootMetadata.twitter?.card).toBe('summary_large_image');
});

it('includes a twitter title', () => {
expect(rootMetadata.twitter?.title).toBeTruthy();
});

it('includes a twitter description', () => {
expect(rootMetadata.twitter?.description).toBeTruthy();
});

it('includes twitter site handle', () => {
expect(rootMetadata.twitter?.site).toBe('@teachlink');
});

it('exports openGraph metadata', () => {
expect(rootMetadata.openGraph).toBeDefined();
expect(rootMetadata.openGraph?.siteName).toBe('TeachLink');
});
});

describe('Auth layout', () => {
it('exports a twitter card field', () => {
expect(authMetadata.twitter).toBeDefined();
});

it('uses summary card type', () => {
expect(authMetadata.twitter?.card).toBe('summary');
});

it('includes a twitter title', () => {
expect(authMetadata.twitter?.title).toBeTruthy();
});

it('includes a twitter description', () => {
expect(authMetadata.twitter?.description).toBeTruthy();
});

it('includes twitter site handle', () => {
expect(authMetadata.twitter?.site).toBe('@teachlink');
});
});

describe('Dashboard layout', () => {
it('exports a twitter card field', () => {
expect(dashboardMetadata.twitter).toBeDefined();
});

it('uses summary card type', () => {
expect(dashboardMetadata.twitter?.card).toBe('summary');
});
});

describe('Profile layout', () => {
it('exports a twitter card field', () => {
expect(profileMetadata.twitter).toBeDefined();
});

it('uses summary card type', () => {
expect(profileMetadata.twitter?.card).toBe('summary');
});
});
});
12 changes: 12 additions & 0 deletions src/app/courses/[courseId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ export async function generateMetadata({ params }: CoursePageProps): Promise<Met
title: 'Course Details | TeachLink',
description:
'View detailed information about this course, including syllabus, instructor details, and enrollment options.',
openGraph: {
title: 'Course Details | TeachLink',
description: 'View course syllabus, instructor details, and enrollment options.',
type: 'website',
siteName: 'TeachLink',
},
twitter: {
card: 'summary_large_image',
site: '@teachlink',
title: 'Course Details | TeachLink',
description: 'View course syllabus, instructor details, and enrollment options.',
},
};
}

Expand Down
12 changes: 12 additions & 0 deletions src/app/editor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ import { EditorWorkspace } from './EditorWorkspace';
export const metadata: Metadata = {
title: 'Post Editor | TeachLink',
description: 'Create and edit privileged post content with a secure editor workspace.',
openGraph: {
title: 'Post Editor | TeachLink',
description: 'Create and edit privileged post content with a secure editor workspace.',
type: 'website',
siteName: 'TeachLink',
},
twitter: {
card: 'summary',
site: '@teachlink',
title: 'Post Editor | TeachLink',
description: 'Create and edit privileged post content with a secure editor workspace.',
},
};

function fallback() {
Expand Down
14 changes: 14 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ export const metadata: Metadata = {
title: 'TeachLink - Offline Learning Platform',
description: 'Learn anywhere, anytime with offline capabilities',
manifest: '/manifest.json',
openGraph: {
title: 'TeachLink - Offline Learning Platform',
description: 'Learn anywhere, anytime with offline capabilities',
type: 'website',
siteName: 'TeachLink',
url: 'https://teachlink.app',
},
twitter: {
card: 'summary_large_image',
site: '@teachlink',
creator: '@teachlink',
title: 'TeachLink - Offline Learning Platform',
description: 'Learn anywhere, anytime with offline capabilities',
},
};

export default async function RootLayout({
Expand Down
12 changes: 12 additions & 0 deletions src/app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ import { LeaderboardConference } from '@/components/leaderboard/LeaderboardConfe
export const metadata: Metadata = {
title: 'Leaderboard | TeachLink',
description: 'View top contributors and join live conference sessions on TeachLink.',
openGraph: {
title: 'Leaderboard | TeachLink',
description: 'View top contributors and join live conference sessions on TeachLink.',
type: 'website',
siteName: 'TeachLink',
},
twitter: {
card: 'summary',
site: '@teachlink',
title: 'Leaderboard | TeachLink',
description: 'View top contributors and join live conference sessions on TeachLink.',
},
};

export default function LeaderboardPage() {
Expand Down
6 changes: 6 additions & 0 deletions src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export const metadata: Metadata = {
'max-image-preview': 'large',
'max-video-preview': -1,
},
twitter: {
card: 'summary',
site: '@teachlink',
title: 'Privacy Policy | TeachLink',
description: 'Learn how TeachLink collects, uses, and protects your personal information.',
},
};

/**
Expand Down
15 changes: 14 additions & 1 deletion src/app/search/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import type { Metadata } from 'next';
import { AdvancedSearchInterface } from '@/components/search/AdvancedSearchInterface';

export const metadata = {
export const metadata: Metadata = {
title: 'Advanced Search | TeachLink',
description: 'Powerful multi-dimensional search for the TeachLink ecosystem.',
openGraph: {
title: 'Advanced Search | TeachLink',
description: 'Powerful multi-dimensional search for the TeachLink ecosystem.',
type: 'website',
siteName: 'TeachLink',
},
twitter: {
card: 'summary',
site: '@teachlink',
title: 'Advanced Search | TeachLink',
description: 'Powerful multi-dimensional search for the TeachLink ecosystem.',
},
};

export default function SearchPage() {
Expand Down
12 changes: 12 additions & 0 deletions src/app/study-groups/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ export const metadata: Metadata = {
title: 'Study Groups | TeachLink',
description:
'Create and collaborate in study groups with discussions, resources, and challenges.',
openGraph: {
title: 'Study Groups | TeachLink',
description: 'Create and collaborate in study groups on TeachLink.',
type: 'website',
siteName: 'TeachLink',
},
twitter: {
card: 'summary',
site: '@teachlink',
title: 'Study Groups | TeachLink',
description: 'Create and collaborate in study groups on TeachLink.',
},
};

export default function Page() {
Expand Down
12 changes: 12 additions & 0 deletions src/app/topics/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ export async function generateMetadata({ params }: TopicPageProps): Promise<Meta
return {
title: `#${name} · TeachLink`,
description: `Explore posts and discussions about ${name} on TeachLink.`,
openGraph: {
title: `#${name} · TeachLink`,
description: `Explore posts and discussions about ${name} on TeachLink.`,
type: 'website',
siteName: 'TeachLink',
},
twitter: {
card: 'summary',
site: '@teachlink',
title: `#${name} · TeachLink`,
description: `Explore posts and discussions about ${name} on TeachLink.`,
},
};
}

Expand Down
11 changes: 7 additions & 4 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ export async function middleware(request: NextRequest) {
};

const permissionResponse = checkRoutePermission(request, userRole);
if (permissionResponse) {
return withHeaders(permissionResponse);
}
if (permissionResponse) return withHeaders(permissionResponse);

const { pathname } = request.nextUrl;
if (pathname.startsWith(API_ROOT)) {
Expand All @@ -72,8 +70,13 @@ export async function middleware(request: NextRequest) {
return withHeaders(response);
}

// Fix for #726 — validate version string before use
const extractedVersion = pathname.split('/')[2];
if (!extractedVersion || !/^v\d+$/.test(extractedVersion)) {
return withHeaders(new NextResponse('Invalid API version', { status: 400 }));
}
const response = NextResponse.next();
response.headers.set(API_VERSION_HEADER, pathname.split('/')[2] || DEFAULT_API_VERSION);
response.headers.set(API_VERSION_HEADER, extractedVersion);
return withHeaders(response);
}

Expand Down
63 changes: 62 additions & 1 deletion src/middleware/__tests__/apiVersioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,65 @@ describe('API versioning middleware', () => {
expect(response.headers.get(API_VERSION_HEADER)).toBe('v1');
expect(response.headers.get(API_DEPRECATION_HEADER)).toBeNull();
});
});

describe('valid version strings — should route correctly', () => {
it('accepts v1 and sets X-Api-Version header', () => {
const request = createMockRequest('/api/v1/posts');
const response = middleware(request) as NextResponse;
expect(response.status).not.toBe(400);
expect(response.headers.get(API_VERSION_HEADER)).toBe('v1');
});

it('accepts v2 and sets X-Api-Version header', () => {
const request = createMockRequest('/api/v2/posts');
const response = middleware(request) as NextResponse;
expect(response.status).not.toBe(400);
expect(response.headers.get(API_VERSION_HEADER)).toBe('v2');
});

it('accepts large version numbers like v10', () => {
const request = createMockRequest('/api/v10/posts');
const response = middleware(request) as NextResponse;
expect(response.status).not.toBe(400);
expect(response.headers.get(API_VERSION_HEADER)).toBe('v10');
});
});

describe('malformed version strings — should return 400', () => {
it('rejects alphabetic version string (vABC)', () => {
const request = createMockRequest('/api/vABC/posts');
const response = middleware(request) as NextResponse;
expect(response.status).toBe(400);
});

it('rejects path-traversal characters (/../)', () => {
const request = createMockRequest('/api/../v1/posts');
const response = middleware(request) as NextResponse;
expect(response.status).toBe(400);
});

it('rejects empty version segment (/api/v/)', () => {
const request = createMockRequest('/api/v/posts');
const response = middleware(request) as NextResponse;
expect(response.status).toBe(400);
});

it('rejects version with special characters (v1.2)', () => {
const request = createMockRequest('/api/v1.2/posts');
const response = middleware(request) as NextResponse;
expect(response.status).toBe(400);
});

it('rejects version with injection attempt (v1;drop)', () => {
const request = createMockRequest('/api/v1;drop/posts');
const response = middleware(request) as NextResponse;
expect(response.status).toBe(400);
});

it('rejects purely numeric version without v prefix (123)', () => {
const request = createMockRequest('/api/123/posts');
const response = middleware(request) as NextResponse;
expect(response.status).toBe(400);
});
});
});