Initial release v1.0.0
Open-source marketplace for AI Agent skills. Features: - Next.js 15 web app with i18n (en/fa) - CLI tool for skill installation (npx skillhub) - GitHub crawler/indexer with multi-strategy discovery - Security scanning for all indexed skills - Self-hostable with Docker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
205
apps/web/app/api/__tests__/helpers.ts
Normal file
205
apps/web/app/api/__tests__/helpers.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Test helpers for API route tests
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Create a mock NextRequest for testing API routes
|
||||
*/
|
||||
export function createMockRequest(
|
||||
url: string,
|
||||
options?: {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
searchParams?: Record<string, string>;
|
||||
}
|
||||
): NextRequest {
|
||||
const { method = 'GET', body, headers = {}, searchParams = {} } = options || {};
|
||||
|
||||
// Build URL with search params
|
||||
const urlObj = new URL(url, 'http://localhost:3000');
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
urlObj.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const requestInit: { method: string; headers: Headers; body?: string } = {
|
||||
method,
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
}),
|
||||
};
|
||||
|
||||
if (body && method !== 'GET') {
|
||||
requestInit.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
// Cast to any to avoid Next.js RequestInit vs global RequestInit type mismatch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new NextRequest(urlObj, requestInit as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock authenticated session
|
||||
*/
|
||||
export function mockAuthSession(user?: {
|
||||
githubId: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
}) {
|
||||
const mockAuth = vi.fn().mockResolvedValue(
|
||||
user
|
||||
? {
|
||||
user: {
|
||||
...user,
|
||||
name: user.username,
|
||||
},
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
auth: mockAuth,
|
||||
}));
|
||||
|
||||
return mockAuth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock user for testing
|
||||
*/
|
||||
export function createMockUser(overrides: Partial<{
|
||||
id: string;
|
||||
githubId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'user-123',
|
||||
githubId: 'gh-12345',
|
||||
username: 'testuser',
|
||||
displayName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
isAdmin: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastLoginAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock skill for testing
|
||||
*/
|
||||
export function createMockSkill(overrides: Partial<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
githubOwner: string;
|
||||
githubRepo: string;
|
||||
githubStars: number;
|
||||
downloadCount: number;
|
||||
securityScore: number;
|
||||
isVerified: boolean;
|
||||
isFeatured: boolean;
|
||||
rating: number;
|
||||
ratingCount: number;
|
||||
compatibility: { platforms?: string[] };
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'test-owner/test-repo/test-skill',
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
githubOwner: 'test-owner',
|
||||
githubRepo: 'test-repo',
|
||||
skillPath: 'skills/test-skill',
|
||||
branch: 'main',
|
||||
version: '1.0.0',
|
||||
license: 'MIT',
|
||||
author: 'Test Author',
|
||||
githubStars: 100,
|
||||
githubForks: 10,
|
||||
downloadCount: 50,
|
||||
viewCount: 200,
|
||||
rating: 4,
|
||||
ratingCount: 5,
|
||||
ratingSum: 20,
|
||||
securityScore: 85,
|
||||
isVerified: false,
|
||||
isFeatured: false,
|
||||
compatibility: { platforms: ['claude', 'codex'] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
indexedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock category for testing
|
||||
*/
|
||||
export function createMockCategory(overrides: Partial<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
skillCount: number;
|
||||
sortOrder: number;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'cat-1',
|
||||
name: 'Test Category',
|
||||
slug: 'test-category',
|
||||
description: 'A test category',
|
||||
icon: 'folder',
|
||||
color: '#3B82F6',
|
||||
skillCount: 10,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock rating for testing
|
||||
*/
|
||||
export function createMockRating(overrides: Partial<{
|
||||
id: string;
|
||||
skillId: string;
|
||||
userId: string;
|
||||
rating: number;
|
||||
review: string;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'rating-1',
|
||||
skillId: 'test-owner/test-repo/test-skill',
|
||||
userId: 'user-123',
|
||||
rating: 4,
|
||||
review: 'Great skill!',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON response from NextResponse
|
||||
*/
|
||||
export async function parseResponse<T>(response: Response): Promise<{
|
||||
status: number;
|
||||
data: T;
|
||||
}> {
|
||||
const data = await response.json();
|
||||
return {
|
||||
status: response.status,
|
||||
data,
|
||||
};
|
||||
}
|
||||
62
apps/web/app/api/__tests__/setup.ts
Normal file
62
apps/web/app/api/__tests__/setup.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Test setup for API route tests
|
||||
*
|
||||
* This file is loaded before each test file via vitest setupFiles
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock environment variables
|
||||
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test';
|
||||
process.env.GITHUB_CLIENT_ID = 'test-client-id';
|
||||
process.env.GITHUB_CLIENT_SECRET = 'test-client-secret';
|
||||
process.env.AUTH_SECRET = 'test-auth-secret';
|
||||
|
||||
// Mock @skillhub/db module
|
||||
vi.mock('@skillhub/db', () => ({
|
||||
createDb: vi.fn(() => ({})),
|
||||
skillQueries: {
|
||||
getById: vi.fn(),
|
||||
search: vi.fn(),
|
||||
getFeatured: vi.fn(),
|
||||
getTrending: vi.fn(),
|
||||
getRecent: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
incrementDownloads: vi.fn(),
|
||||
incrementViews: vi.fn(),
|
||||
updateRating: vi.fn(),
|
||||
},
|
||||
categoryQueries: {
|
||||
getAll: vi.fn(),
|
||||
getBySlug: vi.fn(),
|
||||
getSkills: vi.fn(),
|
||||
updateSkillCount: vi.fn(),
|
||||
},
|
||||
userQueries: {
|
||||
getByGithubId: vi.fn(),
|
||||
upsertFromGithub: vi.fn(),
|
||||
getFavorites: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
},
|
||||
ratingQueries: {
|
||||
upsert: vi.fn(),
|
||||
getForSkill: vi.fn(),
|
||||
getUserRating: vi.fn(),
|
||||
},
|
||||
favoriteQueries: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
isFavorited: vi.fn(),
|
||||
getFavoritedIds: vi.fn(),
|
||||
},
|
||||
installationQueries: {
|
||||
track: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
},
|
||||
skills: {},
|
||||
categories: {},
|
||||
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
|
||||
sql: strings.join('?'),
|
||||
values,
|
||||
})),
|
||||
}));
|
||||
139
apps/web/app/api/attribution/route.ts
Normal file
139
apps/web/app/api/attribution/route.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { createDb, skills, discoveredRepos, awesomeLists, sql } from '@skillhub/db';
|
||||
import { getCached, setCache, cacheTTL } from '@/lib/cache';
|
||||
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
interface AttributionStats {
|
||||
totalSkills: number;
|
||||
totalContributors: number;
|
||||
totalRepos: number;
|
||||
awesomeLists: {
|
||||
count: number;
|
||||
totalRepos: number;
|
||||
};
|
||||
forkNetworks: number;
|
||||
licenseDistribution: Array<{
|
||||
license: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}>;
|
||||
discoveryBySource: Array<{
|
||||
source: string;
|
||||
count: number;
|
||||
withSkills: number;
|
||||
}>;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Rate limiting
|
||||
const rateLimitResult = await withRateLimit(request, 'anonymous');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get from cache first (cache for 1 hour)
|
||||
const cacheKey = 'attribution:stats';
|
||||
const cached = await getCached<AttributionStats>(cacheKey);
|
||||
if (cached) {
|
||||
return NextResponse.json(cached, {
|
||||
headers: {
|
||||
'X-Cache': 'HIT',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
|
||||
...createRateLimitHeaders(rateLimitResult),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Get total skills and contributors
|
||||
const skillStats = await db
|
||||
.select({
|
||||
totalSkills: sql<number>`count(*)::int`,
|
||||
totalContributors: sql<number>`count(distinct ${skills.githubOwner})::int`,
|
||||
})
|
||||
.from(skills);
|
||||
|
||||
// Get license distribution
|
||||
const licenseStats = await db
|
||||
.select({
|
||||
license: sql<string>`coalesce(${skills.license}, 'Unspecified')`,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(skills)
|
||||
.groupBy(sql`coalesce(${skills.license}, 'Unspecified')`)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(10);
|
||||
|
||||
const totalForPercentage = skillStats[0]?.totalSkills ?? 1;
|
||||
const licenseDistribution = licenseStats.map((l) => ({
|
||||
license: l.license || 'Unspecified',
|
||||
count: l.count,
|
||||
percentage: Math.round((l.count / totalForPercentage) * 100),
|
||||
}));
|
||||
|
||||
// Get discovered repos stats
|
||||
const repoStats = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(discoveredRepos);
|
||||
|
||||
// Get discovery by source
|
||||
const bySource = await db
|
||||
.select({
|
||||
source: discoveredRepos.discoveredVia,
|
||||
count: sql<number>`count(*)::int`,
|
||||
withSkills: sql<number>`sum(case when has_skill_md then 1 else 0 end)::int`,
|
||||
})
|
||||
.from(discoveredRepos)
|
||||
.groupBy(discoveredRepos.discoveredVia);
|
||||
|
||||
// Get fork networks count
|
||||
const forkCount = bySource.find((s) => s.source === 'fork-network');
|
||||
|
||||
// Get awesome lists stats
|
||||
const awesomeStats = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
totalRepos: sql<number>`coalesce(sum(${awesomeLists.repoCount}), 0)::int`,
|
||||
})
|
||||
.from(awesomeLists)
|
||||
.where(sql`${awesomeLists.isActive} = true`);
|
||||
|
||||
const data: AttributionStats = {
|
||||
totalSkills: skillStats[0]?.totalSkills ?? 0,
|
||||
totalContributors: skillStats[0]?.totalContributors ?? 0,
|
||||
totalRepos: repoStats[0]?.count ?? 0,
|
||||
awesomeLists: {
|
||||
count: awesomeStats[0]?.count ?? 0,
|
||||
totalRepos: awesomeStats[0]?.totalRepos ?? 0,
|
||||
},
|
||||
forkNetworks: forkCount?.count ?? 0,
|
||||
licenseDistribution,
|
||||
discoveryBySource: bySource.map((s) => ({
|
||||
source: s.source ?? 'unknown',
|
||||
count: s.count,
|
||||
withSkills: s.withSkills ?? 0,
|
||||
})),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Cache the result for 1 hour
|
||||
await setCache(cacheKey, data, cacheTTL.stats);
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: {
|
||||
'X-Cache': 'MISS',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
|
||||
...createRateLimitHeaders(rateLimitResult),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching attribution stats:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch attribution stats' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
3
apps/web/app/api/auth/[...nextauth]/route.ts
Normal file
3
apps/web/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from '@/lib/auth';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
122
apps/web/app/api/categories/route.test.ts
Normal file
122
apps/web/app/api/categories/route.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// Mock rate limiting - must be before route import
|
||||
vi.mock('@/lib/rate-limit', () => ({
|
||||
withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }),
|
||||
createRateLimitResponse: vi.fn(),
|
||||
createRateLimitHeaders: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Mock cache - must be before route import
|
||||
vi.mock('@/lib/cache', () => ({
|
||||
getCached: vi.fn().mockResolvedValue(null),
|
||||
setCache: vi.fn().mockResolvedValue(undefined),
|
||||
cacheKeys: { categories: () => 'categories' },
|
||||
cacheTTL: { categories: 43200 },
|
||||
}));
|
||||
|
||||
// Create mock category helper
|
||||
function createMockCategory(overrides: Partial<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
skillCount: number | null;
|
||||
sortOrder: number | null;
|
||||
color: string | null;
|
||||
parentId: string | null;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'cat-1',
|
||||
name: 'Test Category',
|
||||
slug: 'test-category',
|
||||
description: 'A test category',
|
||||
icon: 'folder',
|
||||
color: '#3B82F6',
|
||||
skillCount: 10,
|
||||
sortOrder: 0,
|
||||
parentId: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock the db module
|
||||
vi.mock('@skillhub/db', () => ({
|
||||
createDb: vi.fn(() => ({})),
|
||||
categoryQueries: {
|
||||
getLeafCategories: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET } from './route';
|
||||
// Import after mocking
|
||||
import { categoryQueries } from '@skillhub/db';
|
||||
|
||||
// Helper to create mock request
|
||||
function createMockRequest(url = 'http://localhost:3000/api/categories') {
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
describe('GET /api/categories', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return all categories', async () => {
|
||||
const mockCategories = [
|
||||
createMockCategory({ id: 'cat-1', name: 'Development', slug: 'development', skillCount: 20 }),
|
||||
createMockCategory({ id: 'cat-2', name: 'Testing', slug: 'testing', skillCount: 15 }),
|
||||
];
|
||||
vi.mocked(categoryQueries.getLeafCategories).mockResolvedValue(mockCategories);
|
||||
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.categories).toBeDefined();
|
||||
expect(Array.isArray(data.categories)).toBe(true);
|
||||
expect(data.categories.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should include skillCount for each category', async () => {
|
||||
const mockCategories = [
|
||||
createMockCategory({ skillCount: 25 }),
|
||||
];
|
||||
vi.mocked(categoryQueries.getLeafCategories).mockResolvedValue(mockCategories);
|
||||
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.categories[0].skillCount).toBeDefined();
|
||||
expect(typeof data.categories[0].skillCount).toBe('number');
|
||||
});
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
vi.mocked(categoryQueries.getLeafCategories).mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return empty array when no categories exist', async () => {
|
||||
vi.mocked(categoryQueries.getLeafCategories).mockResolvedValue([]);
|
||||
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.categories).toBeDefined();
|
||||
expect(Array.isArray(data.categories)).toBe(true);
|
||||
expect(data.categories.length).toBe(0);
|
||||
});
|
||||
});
|
||||
67
apps/web/app/api/categories/route.ts
Normal file
67
apps/web/app/api/categories/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { createDb, categoryQueries } from '@skillhub/db';
|
||||
import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
|
||||
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
interface CategoryData {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
skillCount: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface CategoriesResponse {
|
||||
categories: CategoryData[];
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Rate limiting
|
||||
const rateLimitResult = await withRateLimit(request, 'anonymous');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get from cache first
|
||||
const cacheKey = cacheKeys.categories();
|
||||
const cached = await getCached<CategoriesResponse>(cacheKey);
|
||||
if (cached) {
|
||||
return NextResponse.json(cached, {
|
||||
headers: { 'X-Cache': 'HIT', ...createRateLimitHeaders(rateLimitResult) },
|
||||
});
|
||||
}
|
||||
|
||||
// Get only leaf categories (filter out parent categories)
|
||||
const categories = await categoryQueries.getLeafCategories(db);
|
||||
|
||||
const data: CategoriesResponse = {
|
||||
categories: categories.map((cat) => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
description: cat.description,
|
||||
icon: cat.icon,
|
||||
skillCount: cat.skillCount ?? 0,
|
||||
sortOrder: cat.sortOrder ?? 0,
|
||||
})),
|
||||
};
|
||||
|
||||
// Cache the result (12 hours - categories rarely change)
|
||||
await setCache(cacheKey, data, cacheTTL.categories);
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: { 'X-Cache': 'MISS', ...createRateLimitHeaders(rateLimitResult) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch categories' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
136
apps/web/app/api/early-access/route.ts
Normal file
136
apps/web/app/api/early-access/route.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createDb, sql } from '@skillhub/db';
|
||||
|
||||
// Simple rate limiting
|
||||
const rateLimitMap = new Map<string, { count: number; timestamp: number }>();
|
||||
const RATE_LIMIT = 5; // 5 requests per minute
|
||||
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
||||
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const record = rateLimitMap.get(ip);
|
||||
|
||||
if (!record || now - record.timestamp > RATE_LIMIT_WINDOW) {
|
||||
rateLimitMap.set(ip, { count: 1, timestamp: now });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (record.count >= RATE_LIMIT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
record.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = request.headers.get('x-forwarded-for') || 'unknown';
|
||||
if (!checkRateLimit(ip)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { email, variant, locale, source } = body;
|
||||
|
||||
// Validate email
|
||||
if (!email || typeof email !== 'string' || !email.includes('@')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize email
|
||||
const sanitizedEmail = email.toLowerCase().trim().slice(0, 255);
|
||||
|
||||
// Validate variant
|
||||
const validVariant = variant === 'a' || variant === 'b' ? variant : 'a';
|
||||
|
||||
// Store in database
|
||||
const db = createDb();
|
||||
|
||||
// Create table if not exists (for first-time setup)
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS early_access_signups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
variant VARCHAR(1) NOT NULL DEFAULT 'a',
|
||||
locale VARCHAR(10),
|
||||
source VARCHAR(100),
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Insert or update (upsert)
|
||||
await db.execute(sql`
|
||||
INSERT INTO early_access_signups (email, variant, locale, source, ip_address, user_agent)
|
||||
VALUES (
|
||||
${sanitizedEmail},
|
||||
${validVariant},
|
||||
${locale || null},
|
||||
${source || 'unknown'},
|
||||
${ip},
|
||||
${request.headers.get('user-agent') || null}
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
variant = EXCLUDED.variant,
|
||||
locale = EXCLUDED.locale,
|
||||
source = EXCLUDED.source
|
||||
`);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Early access signup error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'An error occurred. Please try again.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get signup stats (for internal use)
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const expectedToken = process.env.ADMIN_API_TOKEN;
|
||||
|
||||
if (!expectedToken || authHeader !== `Bearer ${expectedToken}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const db = createDb();
|
||||
|
||||
// Get counts by variant
|
||||
const stats = await db.execute(sql`
|
||||
SELECT
|
||||
variant,
|
||||
COUNT(*) as count,
|
||||
MAX(created_at) as last_signup
|
||||
FROM early_access_signups
|
||||
GROUP BY variant
|
||||
`) as unknown as { rows: Array<{ variant: string; count: number; last_signup: Date }> };
|
||||
|
||||
const totalCount = await db.execute(sql`
|
||||
SELECT COUNT(*) as total FROM early_access_signups
|
||||
`) as unknown as { rows: Array<{ total: number }> };
|
||||
|
||||
return NextResponse.json({
|
||||
total: totalCount.rows[0]?.total || 0,
|
||||
byVariant: stats.rows,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching early access stats:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch stats' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
apps/web/app/api/favorites/check/route.ts
Normal file
48
apps/web/app/api/favorites/check/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { createDb, userQueries, favoriteQueries } from '@skillhub/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Rate limiting (authenticated)
|
||||
const rateLimitResult = await withRateLimit(request, 'authenticated');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.githubId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { skillIds } = body;
|
||||
|
||||
if (!Array.isArray(skillIds)) {
|
||||
return NextResponse.json({ error: 'skillIds must be an array' }, { status: 400 });
|
||||
}
|
||||
|
||||
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
|
||||
if (!dbUser) {
|
||||
return NextResponse.json({ favorited: {} }, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
}
|
||||
|
||||
const favoritedIds = await favoriteQueries.getFavoritedIds(db, dbUser.id, skillIds);
|
||||
const favorited: Record<string, boolean> = {};
|
||||
skillIds.forEach((id: string) => {
|
||||
favorited[id] = favoritedIds.includes(id);
|
||||
});
|
||||
|
||||
return NextResponse.json({ favorited }, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking favorites:', error);
|
||||
return NextResponse.json({ error: 'Failed to check favorites' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
227
apps/web/app/api/favorites/route.test.ts
Normal file
227
apps/web/app/api/favorites/route.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// Mock rate limiting - must be before route import
|
||||
vi.mock('@/lib/rate-limit', () => ({
|
||||
withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }),
|
||||
createRateLimitResponse: vi.fn(),
|
||||
createRateLimitHeaders: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Helper to create mock skill
|
||||
function createMockSkill(overrides: Partial<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'test-owner/test-repo/test-skill',
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
githubOwner: 'test-owner',
|
||||
githubRepo: 'test-repo',
|
||||
githubStars: 100,
|
||||
downloadCount: 50,
|
||||
securityScore: 85,
|
||||
isVerified: false,
|
||||
compatibility: { platforms: ['claude'] },
|
||||
rating: 4,
|
||||
ratingCount: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create mock user
|
||||
function createMockUser(overrides: Partial<{ id: string; githubId: string }> = {}) {
|
||||
return {
|
||||
id: 'user-123',
|
||||
githubId: 'gh-12345',
|
||||
username: 'testuser',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock auth
|
||||
const mockAuth = vi.fn();
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
auth: () => mockAuth(),
|
||||
}));
|
||||
|
||||
// Mock db
|
||||
vi.mock('@skillhub/db', () => ({
|
||||
createDb: vi.fn(() => ({})),
|
||||
userQueries: {
|
||||
getByGithubId: vi.fn(),
|
||||
getFavorites: vi.fn(),
|
||||
},
|
||||
skillQueries: {
|
||||
getById: vi.fn(),
|
||||
},
|
||||
favoriteQueries: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET, POST, DELETE } from './route';
|
||||
import { userQueries, skillQueries, favoriteQueries } from '@skillhub/db';
|
||||
|
||||
// Helper to create mock GET request
|
||||
function createMockGetRequest() {
|
||||
return new NextRequest('http://localhost:3000/api/favorites');
|
||||
}
|
||||
|
||||
describe('/api/favorites', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockAuth.mockResolvedValue(null);
|
||||
|
||||
const response = await GET(createMockGetRequest());
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('should return empty array for new user', async () => {
|
||||
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
|
||||
vi.mocked(userQueries.getByGithubId).mockResolvedValue(null as any);
|
||||
|
||||
const response = await GET(createMockGetRequest());
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.favorites).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return user favorites', async () => {
|
||||
const mockUser = createMockUser();
|
||||
const mockFavorites = [
|
||||
{ skill: createMockSkill({ id: 'skill-1' }) },
|
||||
{ skill: createMockSkill({ id: 'skill-2' }) },
|
||||
];
|
||||
|
||||
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
|
||||
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(userQueries.getFavorites).mockResolvedValue(mockFavorites as any);
|
||||
|
||||
const response = await GET(createMockGetRequest());
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.favorites).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
const createRequest = (body: unknown) => {
|
||||
return new NextRequest('http://localhost:3000/api/favorites', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockAuth.mockResolvedValue(null);
|
||||
|
||||
const request = createRequest({ skillId: 'test-skill' });
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('should return 400 when skillId missing', async () => {
|
||||
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
|
||||
|
||||
const request = createRequest({});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toContain('skillId');
|
||||
});
|
||||
|
||||
it('should return 404 when skill not found', async () => {
|
||||
const mockUser = createMockUser();
|
||||
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
|
||||
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(skillQueries.getById).mockResolvedValue(null as any);
|
||||
|
||||
const request = createRequest({ skillId: 'nonexistent' });
|
||||
const response = await POST(request);
|
||||
await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should add favorite successfully', async () => {
|
||||
const mockUser = createMockUser();
|
||||
const mockSkill = createMockSkill();
|
||||
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
|
||||
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
|
||||
vi.mocked(favoriteQueries.add).mockResolvedValue(undefined);
|
||||
|
||||
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.favorited).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
const createRequest = (body: unknown) => {
|
||||
return new NextRequest('http://localhost:3000/api/favorites', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockAuth.mockResolvedValue(null);
|
||||
|
||||
const request = createRequest({ skillId: 'test-skill' });
|
||||
const response = await DELETE(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('should return 400 when skillId missing', async () => {
|
||||
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
|
||||
|
||||
const request = createRequest({});
|
||||
const response = await DELETE(request);
|
||||
await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should remove favorite successfully', async () => {
|
||||
const mockUser = createMockUser();
|
||||
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
|
||||
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(favoriteQueries.remove).mockResolvedValue(undefined);
|
||||
|
||||
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
|
||||
const response = await DELETE(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.favorited).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
127
apps/web/app/api/favorites/route.ts
Normal file
127
apps/web/app/api/favorites/route.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { createDb, userQueries, favoriteQueries, skillQueries } from '@skillhub/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Rate limiting (authenticated)
|
||||
const rateLimitResult = await withRateLimit(request, 'authenticated');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.githubId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
|
||||
if (!dbUser) {
|
||||
return NextResponse.json({ favorites: [] }, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
}
|
||||
|
||||
const favorites = await userQueries.getFavorites(db, dbUser.id);
|
||||
return NextResponse.json({
|
||||
favorites: favorites.map((f) => ({
|
||||
id: f.skill.id,
|
||||
name: f.skill.name,
|
||||
description: f.skill.description,
|
||||
githubOwner: f.skill.githubOwner,
|
||||
githubRepo: f.skill.githubRepo,
|
||||
githubStars: f.skill.githubStars,
|
||||
downloadCount: f.skill.downloadCount,
|
||||
securityStatus: f.skill.securityStatus,
|
||||
isVerified: f.skill.isVerified,
|
||||
compatibility: f.skill.compatibility,
|
||||
rating: f.skill.rating,
|
||||
ratingCount: f.skill.ratingCount,
|
||||
})),
|
||||
}, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching favorites:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch favorites' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Rate limiting (authenticated)
|
||||
const rateLimitResult = await withRateLimit(request, 'authenticated');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.githubId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { skillId } = body;
|
||||
|
||||
if (!skillId) {
|
||||
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
|
||||
if (!dbUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify skill exists
|
||||
const skill = await skillQueries.getById(db, skillId);
|
||||
if (!skill) {
|
||||
return NextResponse.json({ error: 'Skill not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await favoriteQueries.add(db, dbUser.id, skillId);
|
||||
return NextResponse.json({ success: true, favorited: true }, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding favorite:', error);
|
||||
return NextResponse.json({ error: 'Failed to add favorite' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
// Rate limiting (authenticated)
|
||||
const rateLimitResult = await withRateLimit(request, 'authenticated');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.githubId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { skillId } = body;
|
||||
|
||||
if (!skillId) {
|
||||
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
|
||||
if (!dbUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await favoriteQueries.remove(db, dbUser.id, skillId);
|
||||
return NextResponse.json({ success: true, favorited: false }, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error removing favorite:', error);
|
||||
return NextResponse.json({ error: 'Failed to remove favorite' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
49
apps/web/app/api/health/route.test.ts
Normal file
49
apps/web/app/api/health/route.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { GET } from './route';
|
||||
|
||||
// Mock the database module
|
||||
vi.mock('@skillhub/db', () => ({
|
||||
createDb: () => ({
|
||||
execute: vi.fn().mockResolvedValue([{ '?column?': 1 }]),
|
||||
}),
|
||||
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }),
|
||||
isMeilisearchHealthy: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
describe('GET /api/health', () => {
|
||||
it('should return status 200', async () => {
|
||||
const response = await GET();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return health status', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
expect(['healthy', 'degraded', 'unhealthy']).toContain(data.status);
|
||||
});
|
||||
|
||||
it('should return current timestamp', async () => {
|
||||
const before = new Date().toISOString();
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
const after = new Date().toISOString();
|
||||
|
||||
expect(data.timestamp).toBeDefined();
|
||||
expect(new Date(data.timestamp).getTime()).toBeGreaterThanOrEqual(new Date(before).getTime());
|
||||
expect(new Date(data.timestamp).getTime()).toBeLessThanOrEqual(new Date(after).getTime());
|
||||
});
|
||||
|
||||
it('should return version', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
expect(data.version).toBe('0.1.0');
|
||||
});
|
||||
|
||||
it('should return services status', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
expect(data.services).toBeDefined();
|
||||
expect(data.services.database).toBeDefined();
|
||||
expect(data.services.meilisearch).toBeDefined();
|
||||
});
|
||||
});
|
||||
190
apps/web/app/api/health/route.ts
Normal file
190
apps/web/app/api/health/route.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createDb, sql, isMeilisearchHealthy } from '@skillhub/db';
|
||||
import { isCacheAvailable } from '@/lib/cache';
|
||||
|
||||
interface ServiceStatus {
|
||||
status: 'healthy' | 'unhealthy' | 'degraded';
|
||||
latency?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ReplicationStatus extends ServiceStatus {
|
||||
isReplica?: boolean;
|
||||
lagSeconds?: number;
|
||||
}
|
||||
|
||||
interface GitHubStatus extends ServiceStatus {
|
||||
// GitHub accessibility status (for OAuth on mirror)
|
||||
}
|
||||
|
||||
interface HealthResponse {
|
||||
status: 'healthy' | 'unhealthy' | 'degraded';
|
||||
timestamp: string;
|
||||
version: string;
|
||||
isPrimary: boolean;
|
||||
services: {
|
||||
database: ServiceStatus;
|
||||
meilisearch: ServiceStatus;
|
||||
redis: ServiceStatus;
|
||||
replication?: ReplicationStatus;
|
||||
github?: GitHubStatus;
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
|
||||
|
||||
const services: HealthResponse['services'] = {
|
||||
database: { status: 'unhealthy' },
|
||||
meilisearch: { status: 'unhealthy' },
|
||||
redis: { status: 'unhealthy' },
|
||||
};
|
||||
|
||||
// Check database connectivity
|
||||
try {
|
||||
const dbStart = Date.now();
|
||||
const db = createDb();
|
||||
await db.execute(sql`SELECT 1`);
|
||||
services.database = {
|
||||
status: 'healthy',
|
||||
latency: Date.now() - dbStart,
|
||||
};
|
||||
} catch (error) {
|
||||
// Don't expose DATABASE_URL or connection details in error messages
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
// Sanitize error message - remove any potential credentials
|
||||
const sanitizedError = errorMessage
|
||||
.replace(/postgresql:\/\/[^@]*@/gi, 'postgresql://***@')
|
||||
.replace(/password[=:][^\s&]*/gi, 'password=***');
|
||||
|
||||
services.database = {
|
||||
status: 'unhealthy',
|
||||
error: process.env.DATABASE_URL ? sanitizedError : 'DATABASE_URL not configured',
|
||||
};
|
||||
}
|
||||
|
||||
// Check Meilisearch connectivity (optional service)
|
||||
try {
|
||||
const meiliStart = Date.now();
|
||||
const meiliHealthy = await isMeilisearchHealthy();
|
||||
if (meiliHealthy) {
|
||||
services.meilisearch = {
|
||||
status: 'healthy',
|
||||
latency: Date.now() - meiliStart,
|
||||
};
|
||||
} else {
|
||||
services.meilisearch = {
|
||||
status: 'degraded',
|
||||
error: 'Meilisearch not responding or not configured',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
services.meilisearch = {
|
||||
status: 'degraded',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
// Check Redis connectivity (optional service for caching and rate limiting)
|
||||
try {
|
||||
const redisStart = Date.now();
|
||||
const redisHealthy = await isCacheAvailable();
|
||||
if (redisHealthy) {
|
||||
services.redis = {
|
||||
status: 'healthy',
|
||||
latency: Date.now() - redisStart,
|
||||
};
|
||||
} else {
|
||||
services.redis = {
|
||||
status: 'degraded',
|
||||
error: 'Redis not responding or not configured',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
services.redis = {
|
||||
status: 'degraded',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
// Mirror server only: Check PostgreSQL replication status
|
||||
if (!isPrimary) {
|
||||
try {
|
||||
const db = createDb();
|
||||
// drizzle-orm/postgres-js returns results directly as an array
|
||||
const lagResult = await db.execute<{ is_replica: boolean; lag_seconds: number | null }>(sql`
|
||||
SELECT
|
||||
pg_is_in_recovery() as is_replica,
|
||||
EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::int as lag_seconds
|
||||
`);
|
||||
|
||||
const row = lagResult[0];
|
||||
const isReplica = row?.is_replica ?? false;
|
||||
const lagSeconds = row?.lag_seconds ?? 0;
|
||||
|
||||
// Healthy if lag is under 5 minutes (300 seconds)
|
||||
services.replication = {
|
||||
status: isReplica && lagSeconds < 300 ? 'healthy' : 'degraded',
|
||||
isReplica,
|
||||
lagSeconds,
|
||||
latency: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
services.replication = {
|
||||
status: 'degraded',
|
||||
error: error instanceof Error ? error.message : 'Failed to check replication status',
|
||||
isReplica: false,
|
||||
lagSeconds: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Mirror server only: Check GitHub accessibility (for OAuth)
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const githubStart = Date.now();
|
||||
const githubCheck = await fetch('https://github.com', {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
services.github = {
|
||||
status: githubCheck.ok ? 'healthy' : 'degraded',
|
||||
latency: Date.now() - githubStart,
|
||||
};
|
||||
} catch {
|
||||
// GitHub likely filtered/blocked in Iran
|
||||
services.github = {
|
||||
status: 'degraded',
|
||||
error: 'GitHub unreachable (may be filtered)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
let overallStatus: 'healthy' | 'unhealthy' | 'degraded' = 'healthy';
|
||||
|
||||
// Database is critical - if it's down, the whole system is unhealthy
|
||||
if (services.database.status === 'unhealthy') {
|
||||
overallStatus = 'unhealthy';
|
||||
}
|
||||
// Meilisearch and Redis are optional - if either is down, system is degraded but still functional
|
||||
else if (services.meilisearch.status !== 'healthy' || services.redis.status !== 'healthy') {
|
||||
overallStatus = 'degraded';
|
||||
}
|
||||
|
||||
const response: HealthResponse = {
|
||||
status: overallStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
isPrimary,
|
||||
services,
|
||||
};
|
||||
|
||||
// Return appropriate HTTP status code for load balancers/orchestrators
|
||||
// 200 = healthy, 503 = unhealthy (critical service down)
|
||||
const statusCode = overallStatus === 'unhealthy' ? 503 : 200;
|
||||
return NextResponse.json(response, { status: statusCode });
|
||||
}
|
||||
114
apps/web/app/api/newsletter/subscribe/route.ts
Normal file
114
apps/web/app/api/newsletter/subscribe/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createDb } from '@skillhub/db';
|
||||
import { emailSubscriptionQueries } from '@skillhub/db';
|
||||
import { withRateLimit, createRateLimitResponse } from '@/lib/rate-limit';
|
||||
import { sendNewsletterWelcomeEmail } from '@/lib/email';
|
||||
|
||||
const PRIMARY_URL = process.env.PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
|
||||
|
||||
// Email validation regex (RFC 5322 simplified)
|
||||
const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
|
||||
/**
|
||||
* GET handler for one-click newsletter subscription from email links
|
||||
* Subscribes and redirects to homepage with confirmation
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const email = request.nextUrl.searchParams.get('email');
|
||||
const locale = request.nextUrl.searchParams.get('locale') || 'en';
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.redirect(new URL('/', PRIMARY_URL));
|
||||
}
|
||||
|
||||
const sanitizedEmail = email.toLowerCase().trim().slice(0, 255);
|
||||
if (!EMAIL_REGEX.test(sanitizedEmail)) {
|
||||
return NextResponse.redirect(new URL('/', PRIMARY_URL));
|
||||
}
|
||||
|
||||
const db = createDb();
|
||||
await emailSubscriptionQueries.subscribe(db, {
|
||||
email: sanitizedEmail,
|
||||
source: 'newsletter',
|
||||
marketingConsent: true,
|
||||
});
|
||||
|
||||
const validLocales = ['en', 'fa'];
|
||||
const sanitizedLocale = validLocales.includes(locale) ? locale : 'en';
|
||||
|
||||
sendNewsletterWelcomeEmail(sanitizedEmail, sanitizedLocale as 'en' | 'fa').catch((err) => {
|
||||
console.error('[Newsletter] Failed to send newsletter welcome email:', err);
|
||||
});
|
||||
|
||||
const redirectUrl = new URL('/', PRIMARY_URL);
|
||||
redirectUrl.searchParams.set('subscribed', 'true');
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
} catch (error) {
|
||||
console.error('[Newsletter] Subscribe GET error:', error);
|
||||
return NextResponse.redirect(new URL('/', PRIMARY_URL));
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting (reuse 'search' tier: 60/min)
|
||||
const rateLimitResult = await withRateLimit(request, 'search');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult, 'search');
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { email, source, marketingConsent, locale } = body;
|
||||
|
||||
// Validate email
|
||||
if (!email || typeof email !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const sanitizedEmail = email.toLowerCase().trim().slice(0, 255);
|
||||
|
||||
if (!EMAIL_REGEX.test(sanitizedEmail)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate source
|
||||
const validSources = ['newsletter', 'oauth', 'claim', 'early-access'];
|
||||
const sanitizedSource = validSources.includes(source) ? source : 'newsletter';
|
||||
|
||||
// Store in database
|
||||
const db = createDb();
|
||||
await emailSubscriptionQueries.subscribe(db, {
|
||||
email: sanitizedEmail,
|
||||
source: sanitizedSource,
|
||||
marketingConsent: Boolean(marketingConsent),
|
||||
});
|
||||
|
||||
// Validate locale
|
||||
const validLocales = ['en', 'fa'];
|
||||
const sanitizedLocale = validLocales.includes(locale) ? locale : 'en';
|
||||
|
||||
// Send newsletter welcome email (non-blocking, don't fail if email sending fails)
|
||||
sendNewsletterWelcomeEmail(sanitizedEmail, sanitizedLocale).catch((err) => {
|
||||
console.error('[Newsletter] Failed to send newsletter welcome email:', err);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
subscribed: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Newsletter] Subscribe error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'An error occurred. Please try again.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
77
apps/web/app/api/newsletter/unsubscribe/route.ts
Normal file
77
apps/web/app/api/newsletter/unsubscribe/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createDb } from '@skillhub/db';
|
||||
import { emailSubscriptionQueries } from '@skillhub/db';
|
||||
import { withRateLimit, createRateLimitResponse } from '@/lib/rate-limit';
|
||||
|
||||
const PRIMARY_URL = process.env.PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting (reuse 'search' tier: 60/min)
|
||||
const rateLimitResult = await withRateLimit(request, 'search');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult, 'search');
|
||||
}
|
||||
|
||||
// POST is already blocked by middleware on mirror servers (503)
|
||||
const body = await request.json();
|
||||
const { email } = body;
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const db = createDb();
|
||||
await emailSubscriptionQueries.unsubscribe(db, email);
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
unsubscribed: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Newsletter] Unsubscribe error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'An error occurred. Please try again.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET handler for one-click unsubscribe links in emails
|
||||
* Redirects to homepage after unsubscribing
|
||||
* On mirror servers, redirects to primary server for the unsubscribe action
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const email = request.nextUrl.searchParams.get('email');
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.redirect(new URL('/', PRIMARY_URL));
|
||||
}
|
||||
|
||||
// On mirror server, redirect to primary for write operation
|
||||
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
|
||||
if (!isPrimary) {
|
||||
return NextResponse.redirect(
|
||||
`${PRIMARY_URL}/api/newsletter/unsubscribe?email=${encodeURIComponent(email)}`
|
||||
);
|
||||
}
|
||||
|
||||
const db = createDb();
|
||||
await emailSubscriptionQueries.unsubscribe(db, email);
|
||||
|
||||
// Redirect to homepage with unsubscribe confirmation
|
||||
const redirectUrl = new URL('/', PRIMARY_URL);
|
||||
redirectUrl.searchParams.set('unsubscribed', 'true');
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
} catch (error) {
|
||||
console.error('[Newsletter] Unsubscribe GET error:', error);
|
||||
return NextResponse.redirect(new URL('/', request.url));
|
||||
}
|
||||
}
|
||||
43
apps/web/app/api/ratings/me/route.ts
Normal file
43
apps/web/app/api/ratings/me/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { createDb, ratingQueries, userQueries } from '@skillhub/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Rate limiting (authenticated)
|
||||
const rateLimitResult = await withRateLimit(request, 'authenticated');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.githubId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const skillId = searchParams.get('skillId');
|
||||
|
||||
if (!skillId) {
|
||||
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
|
||||
if (!dbUser) {
|
||||
return NextResponse.json({ rating: null }, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
}
|
||||
|
||||
const rating = await ratingQueries.getUserRating(db, dbUser.id, skillId);
|
||||
return NextResponse.json({ rating }, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching user rating:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch rating' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
281
apps/web/app/api/ratings/route.test.ts
Normal file
281
apps/web/app/api/ratings/route.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// Helper to create mock skill
|
||||
function createMockSkill(overrides: Partial<{
|
||||
id: string;
|
||||
rating: number;
|
||||
ratingCount: number;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'test-owner/test-repo/test-skill',
|
||||
name: 'test-skill',
|
||||
rating: 4,
|
||||
ratingCount: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create mock user
|
||||
function createMockUser(overrides: Partial<{ id: string; githubId: string; username: string }> = {}) {
|
||||
return {
|
||||
id: 'user-123',
|
||||
githubId: 'gh-12345',
|
||||
username: 'testuser',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create mock rating
|
||||
function createMockRating(overrides: Partial<{
|
||||
id: string;
|
||||
rating: number;
|
||||
review: string;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'rating-1',
|
||||
skillId: 'test-owner/test-repo/test-skill',
|
||||
userId: 'user-123',
|
||||
rating: 4,
|
||||
review: 'Great skill!',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock auth
|
||||
const mockAuth = vi.fn();
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
auth: () => mockAuth(),
|
||||
}));
|
||||
|
||||
// Mock db
|
||||
vi.mock('@skillhub/db', () => ({
|
||||
createDb: vi.fn(() => ({})),
|
||||
userQueries: {
|
||||
getByGithubId: vi.fn(),
|
||||
},
|
||||
skillQueries: {
|
||||
getById: vi.fn(),
|
||||
},
|
||||
ratingQueries: {
|
||||
getForSkill: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET, POST } from './route';
|
||||
import { userQueries, skillQueries, ratingQueries } from '@skillhub/db';
|
||||
|
||||
describe('/api/ratings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET', () => {
|
||||
const createRequest = (searchParams: Record<string, string> = {}) => {
|
||||
const url = new URL('http://localhost:3000/api/ratings');
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
return new NextRequest(url);
|
||||
};
|
||||
|
||||
it('should return 400 when skillId missing', async () => {
|
||||
const request = createRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toContain('skillId');
|
||||
});
|
||||
|
||||
it('should return ratings for skill', async () => {
|
||||
const mockSkill = createMockSkill();
|
||||
const mockRatings = [
|
||||
{
|
||||
rating: createMockRating({ id: 'rating-1' }),
|
||||
user: createMockUser(),
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
|
||||
vi.mocked(ratingQueries.getForSkill).mockResolvedValue(mockRatings as any);
|
||||
|
||||
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.ratings).toBeDefined();
|
||||
expect(Array.isArray(data.ratings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should include user info', async () => {
|
||||
const mockSkill = createMockSkill();
|
||||
const mockRatings = [
|
||||
{
|
||||
rating: createMockRating(),
|
||||
user: createMockUser({ username: 'testuser' }),
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
|
||||
vi.mocked(ratingQueries.getForSkill).mockResolvedValue(mockRatings as any);
|
||||
|
||||
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.ratings[0].user).toBeDefined();
|
||||
expect(data.ratings[0].user.username).toBe('testuser');
|
||||
});
|
||||
|
||||
it('should include rating summary', async () => {
|
||||
const mockSkill = createMockSkill({ rating: 4, ratingCount: 10 });
|
||||
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
|
||||
vi.mocked(ratingQueries.getForSkill).mockResolvedValue([]);
|
||||
|
||||
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.summary).toBeDefined();
|
||||
expect(data.summary.average).toBe(4);
|
||||
expect(data.summary.count).toBe(10);
|
||||
});
|
||||
|
||||
it('should respect pagination', async () => {
|
||||
const mockSkill = createMockSkill();
|
||||
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
|
||||
vi.mocked(ratingQueries.getForSkill).mockResolvedValue([]);
|
||||
|
||||
const request = createRequest({
|
||||
skillId: 'test-owner/test-repo/test-skill',
|
||||
limit: '5',
|
||||
offset: '10',
|
||||
});
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(ratingQueries.getForSkill).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'test-owner/test-repo/test-skill',
|
||||
5,
|
||||
10
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
const createRequest = (body: unknown) => {
|
||||
return new NextRequest('http://localhost:3000/api/ratings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockAuth.mockResolvedValue(null);
|
||||
|
||||
const request = createRequest({ skillId: 'test-skill', rating: 5 });
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('should return 400 when skillId missing', async () => {
|
||||
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
|
||||
|
||||
const request = createRequest({ rating: 5 });
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toContain('skillId');
|
||||
});
|
||||
|
||||
it('should return 400 when rating invalid', async () => {
|
||||
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
|
||||
|
||||
const request = createRequest({ skillId: 'test-skill', rating: 6 });
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toContain('between 1 and 5');
|
||||
});
|
||||
|
||||
it('should return 400 when rating is zero', async () => {
|
||||
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
|
||||
|
||||
const request = createRequest({ skillId: 'test-skill', rating: 0 });
|
||||
const response = await POST(request);
|
||||
await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 404 when skill not found', async () => {
|
||||
const mockUser = createMockUser();
|
||||
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
|
||||
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(skillQueries.getById).mockResolvedValue(null as any);
|
||||
|
||||
const request = createRequest({ skillId: 'nonexistent', rating: 5 });
|
||||
const response = await POST(request);
|
||||
await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should create rating successfully', async () => {
|
||||
const mockUser = createMockUser();
|
||||
const mockSkill = createMockSkill();
|
||||
const mockRating = createMockRating({ rating: 5 });
|
||||
|
||||
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
|
||||
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
|
||||
vi.mocked(ratingQueries.upsert).mockResolvedValue(mockRating as any);
|
||||
|
||||
const request = createRequest({
|
||||
skillId: 'test-owner/test-repo/test-skill',
|
||||
rating: 5,
|
||||
review: 'Excellent!',
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.rating).toBeDefined();
|
||||
expect(data.summary).toBeDefined();
|
||||
});
|
||||
|
||||
it('should update existing rating', async () => {
|
||||
const mockUser = createMockUser();
|
||||
const mockSkill = createMockSkill({ rating: 4, ratingCount: 1 });
|
||||
const mockRating = createMockRating({ rating: 4 });
|
||||
|
||||
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
|
||||
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
|
||||
vi.mocked(ratingQueries.upsert).mockResolvedValue(mockRating as any);
|
||||
|
||||
const request = createRequest({
|
||||
skillId: 'test-owner/test-repo/test-skill',
|
||||
rating: 4,
|
||||
});
|
||||
const response = await POST(request);
|
||||
await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(ratingQueries.upsert).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
122
apps/web/app/api/ratings/route.ts
Normal file
122
apps/web/app/api/ratings/route.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { createDb, ratingQueries, skillQueries, userQueries } from '@skillhub/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
|
||||
import { sanitizeReview } from '@/lib/sanitize';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Rate limiting
|
||||
const rateLimitResult = await withRateLimit(request, 'anonymous');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const skillId = searchParams.get('skillId');
|
||||
|
||||
// Parse and validate pagination parameters
|
||||
const limitRaw = parseInt(searchParams.get('limit') || '10');
|
||||
const limit = isNaN(limitRaw) || limitRaw < 1 ? 10 : Math.min(limitRaw, 100);
|
||||
|
||||
const offsetRaw = parseInt(searchParams.get('offset') || '0');
|
||||
const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw;
|
||||
|
||||
if (!skillId) {
|
||||
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ratings = await ratingQueries.getForSkill(db, skillId, limit, offset);
|
||||
const skill = await skillQueries.getById(db, skillId);
|
||||
|
||||
return NextResponse.json({
|
||||
ratings: ratings.map((r) => ({
|
||||
id: r.rating.id,
|
||||
rating: r.rating.rating,
|
||||
review: r.rating.review,
|
||||
createdAt: r.rating.createdAt,
|
||||
updatedAt: r.rating.updatedAt,
|
||||
user: {
|
||||
id: r.user.id,
|
||||
username: r.user.username,
|
||||
avatarUrl: r.user.avatarUrl,
|
||||
},
|
||||
})),
|
||||
summary: {
|
||||
average: skill?.rating || 0,
|
||||
count: skill?.ratingCount || 0,
|
||||
},
|
||||
}, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching ratings:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch ratings' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Rate limiting (authenticated tier for POST)
|
||||
const rateLimitResult = await withRateLimit(request, 'authenticated');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.githubId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { skillId, rating, review } = body;
|
||||
|
||||
if (!skillId) {
|
||||
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate rating
|
||||
const ratingValue = parseInt(rating);
|
||||
if (isNaN(ratingValue) || ratingValue < 1 || ratingValue > 5) {
|
||||
return NextResponse.json({ error: 'Rating must be between 1 and 5' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get database user ID
|
||||
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
|
||||
if (!dbUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify skill exists
|
||||
const skill = await skillQueries.getById(db, skillId);
|
||||
if (!skill) {
|
||||
return NextResponse.json({ error: 'Skill not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Upsert rating with sanitized review
|
||||
const result = await ratingQueries.upsert(db, {
|
||||
skillId,
|
||||
userId: dbUser.id,
|
||||
rating: ratingValue,
|
||||
review: sanitizeReview(review) ?? undefined,
|
||||
});
|
||||
|
||||
// Get updated skill aggregates
|
||||
const updatedSkill = await skillQueries.getById(db, skillId);
|
||||
|
||||
return NextResponse.json({
|
||||
rating: result,
|
||||
summary: {
|
||||
average: updatedSkill?.rating || 0,
|
||||
count: updatedSkill?.ratingCount || 0,
|
||||
},
|
||||
}, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating rating:', error);
|
||||
return NextResponse.json({ error: 'Failed to create rating' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
380
apps/web/app/api/skill-files/route.ts
Normal file
380
apps/web/app/api/skill-files/route.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { createDb, skillQueries } from '@skillhub/db';
|
||||
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
|
||||
import { getGitHubHeaders, updateTokenStats } from '@/lib/github-token-manager';
|
||||
|
||||
// Route configuration for longer timeout (skills with many files)
|
||||
export const maxDuration = 60; // 60 seconds
|
||||
|
||||
// Maximum recursion depth to prevent infinite loops
|
||||
const MAX_DEPTH = 5;
|
||||
|
||||
// Fetch timeout in milliseconds
|
||||
const FETCH_TIMEOUT = 30000; // 30 seconds
|
||||
|
||||
// Create database connection
|
||||
const db = createDb();
|
||||
|
||||
interface GitHubFile {
|
||||
name: string;
|
||||
path: string;
|
||||
sha: string;
|
||||
size: number;
|
||||
type: 'file' | 'dir';
|
||||
download_url: string | null;
|
||||
}
|
||||
|
||||
interface SkillFile {
|
||||
name: string;
|
||||
path: string;
|
||||
content: string;
|
||||
size: number;
|
||||
isBinary: boolean;
|
||||
}
|
||||
|
||||
interface CachedFiles {
|
||||
fetchedAt: string;
|
||||
commitSha: string;
|
||||
totalSize: number;
|
||||
items: SkillFile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/skill-files?id=anthropics/skills/pdf
|
||||
* Returns all files in a skill folder, using cache when available
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check if cached files exist in database
|
||||
* 2. If cache is valid (commitSha matches), return from cache (FAST)
|
||||
* 3. If cache is stale or missing, fetch from GitHub
|
||||
* 4. Save to cache for future requests
|
||||
* 5. Return files
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// Rate limiting (use search tier as this is an expensive operation)
|
||||
const rateLimitResult = await withRateLimit(request, 'search');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
const skillId = request.nextUrl.searchParams.get('id');
|
||||
|
||||
if (!skillId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing skill ID parameter', code: 'MISSING_ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get skill from database
|
||||
const skill = await skillQueries.getById(db, skillId);
|
||||
|
||||
if (!skill) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Skill not found', code: 'NOT_FOUND' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const { githubOwner, githubRepo, skillPath, branch, commitSha, sourceFormat } = skill;
|
||||
|
||||
// === CACHE CHECK ===
|
||||
// Try to get cached files first (this is the fast path)
|
||||
const cachedFiles = await skillQueries.getCachedFiles(db, skillId);
|
||||
|
||||
if (cachedFiles) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[skill-files] Cache HIT for ${skillId}`);
|
||||
|
||||
// Note: Download count is NOT incremented here.
|
||||
// It should only be incremented in /api/skills/install after successful download.
|
||||
// This endpoint is just for fetching files - the download may still fail
|
||||
// (e.g., if JSZip fails to load from CDN).
|
||||
|
||||
return NextResponse.json({
|
||||
skillId,
|
||||
githubOwner,
|
||||
githubRepo,
|
||||
skillPath,
|
||||
branch: branch || 'main',
|
||||
sourceFormat: sourceFormat || 'skill.md',
|
||||
files: cachedFiles.items.map(item => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: 'file' as const,
|
||||
size: item.size,
|
||||
content: item.isBinary ? undefined : item.content,
|
||||
downloadUrl: item.isBinary ? `https://raw.githubusercontent.com/${githubOwner}/${githubRepo}/${branch || 'main'}/${skillPath}/${item.path}` : undefined,
|
||||
})),
|
||||
fromCache: true,
|
||||
cachedAt: cachedFiles.fetchedAt,
|
||||
}, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
}
|
||||
|
||||
// === CACHE MISS - Fetch from GitHub ===
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[skill-files] Cache MISS for ${skillId}, fetching from GitHub...`);
|
||||
|
||||
// Get GitHub headers with token rotation
|
||||
const { headers: githubHeaders, token } = await getGitHubHeaders();
|
||||
|
||||
// Warn if no GitHub token (limited to 60 req/hr)
|
||||
if (!token) {
|
||||
console.warn('No GITHUB_TOKEN configured - limited to 60 requests/hour');
|
||||
}
|
||||
|
||||
// Fetch skill folder contents from GitHub
|
||||
const files = await fetchSkillFiles(
|
||||
githubOwner,
|
||||
githubRepo,
|
||||
skillPath,
|
||||
branch || 'main',
|
||||
0, // Start at depth 0
|
||||
githubHeaders,
|
||||
token
|
||||
);
|
||||
|
||||
// === SAVE TO CACHE ===
|
||||
// Prepare cached files structure
|
||||
const filesToCache: CachedFiles = {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
commitSha: commitSha || 'unknown',
|
||||
totalSize: files.reduce((sum, f) => sum + f.size, 0),
|
||||
items: files,
|
||||
};
|
||||
|
||||
// Save to database (async, don't block response)
|
||||
skillQueries.updateCachedFiles(db, skillId, filesToCache).catch((err) => {
|
||||
console.error(`[skill-files] Failed to cache files for ${skillId}:`, err);
|
||||
});
|
||||
|
||||
// Note: Download count is NOT incremented here.
|
||||
// It should only be incremented in /api/skills/install after successful download.
|
||||
|
||||
// Return response with files
|
||||
return NextResponse.json({
|
||||
skillId,
|
||||
githubOwner,
|
||||
githubRepo,
|
||||
skillPath,
|
||||
branch: branch || 'main',
|
||||
sourceFormat: sourceFormat || 'skill.md',
|
||||
files: files.map(item => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: 'file' as const,
|
||||
size: item.size,
|
||||
content: item.isBinary ? undefined : item.content,
|
||||
downloadUrl: item.isBinary ? `https://raw.githubusercontent.com/${githubOwner}/${githubRepo}/${branch || 'main'}/${skillPath}/${item.path}` : undefined,
|
||||
})),
|
||||
fromCache: false,
|
||||
}, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching skill files:', error);
|
||||
|
||||
// Return specific error codes for different failure types
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
if (errorMessage.includes('rate limit') || errorMessage.includes('403')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'GitHub API rate limit exceeded. Please try again later.', code: 'RATE_LIMIT' },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage.includes('timeout') || errorMessage.includes('AbortError')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Request timed out. The skill may have too many files.', code: 'TIMEOUT' },
|
||||
{ status: 504 }
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage.includes('404')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Skill files not found on GitHub', code: 'GITHUB_NOT_FOUND' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch skill files', code: 'FETCH_ERROR' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively fetch all files in a skill folder from GitHub
|
||||
* @param depth - Current recursion depth (max MAX_DEPTH levels)
|
||||
* @param headers - GitHub API headers (with token rotation)
|
||||
* @param token - Current token for stats tracking
|
||||
*/
|
||||
async function fetchSkillFiles(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
ref: string,
|
||||
depth: number = 0,
|
||||
headers: Record<string, string>,
|
||||
token: string | null
|
||||
): Promise<SkillFile[]> {
|
||||
// Prevent infinite recursion
|
||||
if (depth > MAX_DEPTH) {
|
||||
console.warn(`Max depth (${MAX_DEPTH}) reached at: ${path}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: SkillFile[] = [];
|
||||
|
||||
// Fetch directory contents with timeout
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
|
||||
const response = await fetch(apiUrl, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
||||
});
|
||||
|
||||
// Update token stats for rotation
|
||||
if (token) {
|
||||
await updateTokenStats(token, response.headers);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Check for rate limiting
|
||||
if (response.status === 403) {
|
||||
const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
|
||||
if (rateLimitRemaining === '0') {
|
||||
throw new Error('GitHub API rate limit exceeded');
|
||||
}
|
||||
}
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const contents: GitHubFile[] = await response.json();
|
||||
|
||||
// Process each item
|
||||
for (const item of contents) {
|
||||
if (item.type === 'file') {
|
||||
// Determine if file is binary
|
||||
const isBinary = !isTextFile(item.name);
|
||||
|
||||
if (!isBinary && item.size < 1024 * 1024) {
|
||||
// For text files (< 1MB), fetch content
|
||||
try {
|
||||
const content = await fetchFileContent(owner, repo, item.path, ref, headers, token);
|
||||
files.push({
|
||||
name: item.name,
|
||||
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
|
||||
content,
|
||||
size: item.size,
|
||||
isBinary: false,
|
||||
});
|
||||
} catch {
|
||||
// If content fetch fails, mark as binary (will use download URL)
|
||||
files.push({
|
||||
name: item.name,
|
||||
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
|
||||
content: '',
|
||||
size: item.size,
|
||||
isBinary: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For binary or large files, don't store content (use download URL)
|
||||
files.push({
|
||||
name: item.name,
|
||||
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
|
||||
content: '',
|
||||
size: item.size,
|
||||
isBinary: true,
|
||||
});
|
||||
}
|
||||
} else if (item.type === 'dir') {
|
||||
// Recursively fetch subdirectory (with depth limit)
|
||||
const subPath = `${path}/${item.name}`;
|
||||
const subFiles = await fetchSkillFiles(owner, repo, subPath, ref, depth + 1, headers, token);
|
||||
|
||||
// Add subdirectory files with proper paths
|
||||
for (const subFile of subFiles) {
|
||||
files.push({
|
||||
...subFile,
|
||||
path: `${item.name}/${subFile.path}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch file content from GitHub with timeout
|
||||
*/
|
||||
async function fetchFileContent(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
ref: string,
|
||||
headers: Record<string, string>,
|
||||
token: string | null
|
||||
): Promise<string> {
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
|
||||
const response = await fetch(apiUrl, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
||||
});
|
||||
|
||||
// Update token stats for rotation
|
||||
if (token) {
|
||||
await updateTokenStats(token, response.headers);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch file: ${path} (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.content && data.encoding === 'base64') {
|
||||
return Buffer.from(data.content, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
throw new Error(`Invalid file content for: ${path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is a text file based on extension
|
||||
* Note: .env files are excluded for security (could contain secrets)
|
||||
*/
|
||||
/**
|
||||
* Known instruction file names that are always plain text
|
||||
*/
|
||||
const KNOWN_TEXT_FILENAMES = ['SKILL.md', 'AGENTS.md', '.cursorrules', '.windsurfrules', 'copilot-instructions.md'];
|
||||
|
||||
function isTextFile(filename: string): boolean {
|
||||
// Exclude potentially sensitive files
|
||||
const sensitivePatterns = ['.env', '.secret', '.key', '.pem', '.credential'];
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
if (sensitivePatterns.some((p) => lowerFilename.includes(p))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Known instruction dotfiles that are plain text
|
||||
if (KNOWN_TEXT_FILENAMES.includes(filename)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const textExtensions = [
|
||||
'.md', '.txt', '.json', '.yaml', '.yml', '.xml', '.html', '.css',
|
||||
'.js', '.ts', '.jsx', '.tsx', '.py', '.sh', '.bash', '.ps1',
|
||||
'.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp',
|
||||
'.toml', '.ini', '.cfg', '.conf', '.gitignore', '.editorconfig',
|
||||
];
|
||||
|
||||
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
||||
return textExtensions.includes(ext) || !filename.includes('.');
|
||||
}
|
||||
438
apps/web/app/api/skill-files/zip/route.ts
Normal file
438
apps/web/app/api/skill-files/zip/route.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { type NextRequest } from 'next/server';
|
||||
import { createDb, skillQueries } from '@skillhub/db';
|
||||
import { withRateLimit, createRateLimitHeaders } from '@/lib/rate-limit';
|
||||
import { getGitHubHeaders, updateTokenStats } from '@/lib/github-token-manager';
|
||||
import { INSTRUCTION_FILE_PATTERNS } from 'skillhub-core';
|
||||
import archiver from 'archiver';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
// Route configuration for longer timeout
|
||||
export const maxDuration = 60;
|
||||
|
||||
// Create database connection
|
||||
const db = createDb();
|
||||
|
||||
// Fetch timeout in milliseconds
|
||||
const FETCH_TIMEOUT = 30000;
|
||||
|
||||
// Maximum recursion depth
|
||||
const MAX_DEPTH = 5;
|
||||
|
||||
type Platform = 'claude' | 'codex' | 'copilot' | 'cursor' | 'windsurf';
|
||||
|
||||
const VALID_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf'];
|
||||
|
||||
// All known main instruction file names
|
||||
const MAIN_FILE_NAMES = INSTRUCTION_FILE_PATTERNS.map(p => p.filename);
|
||||
|
||||
interface GitHubFile {
|
||||
name: string;
|
||||
path: string;
|
||||
sha: string;
|
||||
size: number;
|
||||
type: 'file' | 'dir';
|
||||
download_url: string | null;
|
||||
}
|
||||
|
||||
interface SkillFile {
|
||||
name: string;
|
||||
path: string;
|
||||
content: string;
|
||||
size: number;
|
||||
isBinary: boolean;
|
||||
}
|
||||
|
||||
interface CachedFiles {
|
||||
fetchedAt: string;
|
||||
commitSha: string;
|
||||
totalSize: number;
|
||||
items: SkillFile[];
|
||||
}
|
||||
|
||||
// --- Platform transformation (mirrors InstallSection.tsx logic) ---
|
||||
|
||||
function stripFrontmatter(content: string): { body: string; description?: string; filePatterns?: string[] } {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!match) return { body: content };
|
||||
|
||||
const yaml = match[1];
|
||||
const body = match[2].trim();
|
||||
|
||||
const descMatch = yaml.match(/^description:\s*(.+)$/m);
|
||||
const description = descMatch ? descMatch[1].trim() : undefined;
|
||||
|
||||
const patternsMatch = yaml.match(/filePatterns:\s*\n((?:\s+-\s+.+\n?)+)/);
|
||||
let filePatterns: string[] | undefined;
|
||||
if (patternsMatch) {
|
||||
filePatterns = patternsMatch[1]
|
||||
.split('\n')
|
||||
.map(l => l.replace(/^\s+-\s+/, '').replace(/["']/g, '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return { body, description, filePatterns };
|
||||
}
|
||||
|
||||
function getPlatformFileName(platform: Platform, skillName: string): string {
|
||||
switch (platform) {
|
||||
case 'claude':
|
||||
case 'codex':
|
||||
return 'SKILL.md';
|
||||
case 'cursor':
|
||||
return `${skillName}.mdc`;
|
||||
case 'windsurf':
|
||||
return `${skillName}.md`;
|
||||
case 'copilot':
|
||||
return `${skillName}.instructions.md`;
|
||||
}
|
||||
}
|
||||
|
||||
function transformContent(platform: Platform, content: string, skillName: string): string {
|
||||
if (platform === 'claude' || platform === 'codex') return content;
|
||||
|
||||
const { body, description, filePatterns } = stripFrontmatter(content);
|
||||
|
||||
if (platform === 'cursor') {
|
||||
const mdcFields: string[] = [];
|
||||
if (description) mdcFields.push(`description: ${description}`);
|
||||
if (filePatterns && filePatterns.length > 0) {
|
||||
mdcFields.push(`globs: ${filePatterns.join(', ')}`);
|
||||
mdcFields.push('alwaysApply: false');
|
||||
} else {
|
||||
mdcFields.push('alwaysApply: true');
|
||||
}
|
||||
return `---\n${mdcFields.join('\n')}\n---\n${body}\n`;
|
||||
}
|
||||
|
||||
// windsurf / copilot: plain markdown
|
||||
let plainBody = body;
|
||||
if (!plainBody.startsWith('# ')) {
|
||||
plainBody = `# ${skillName}\n\n${plainBody}`;
|
||||
}
|
||||
return plainBody + '\n';
|
||||
}
|
||||
|
||||
function isMainInstructionFile(fileName: string): boolean {
|
||||
return MAIN_FILE_NAMES.includes(fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/skill-files/zip?id=owner/repo/skill-name&platform=cursor
|
||||
* Returns a ZIP file containing all skill files, transformed for the target platform
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// Rate limiting
|
||||
const rateLimitResult = await withRateLimit(request, 'search');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Rate limit exceeded', code: 'RATE_LIMIT' }),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...createRateLimitHeaders(rateLimitResult),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const skillId = request.nextUrl.searchParams.get('id');
|
||||
const platformParam = request.nextUrl.searchParams.get('platform');
|
||||
const platform: Platform = (platformParam && VALID_PLATFORMS.includes(platformParam as Platform))
|
||||
? platformParam as Platform
|
||||
: 'claude';
|
||||
|
||||
if (!skillId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Missing skill ID parameter', code: 'MISSING_ID' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get skill from database
|
||||
const skill = await skillQueries.getById(db, skillId);
|
||||
|
||||
if (!skill) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Skill not found', code: 'NOT_FOUND' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const { githubOwner, githubRepo, skillPath, branch, name: skillName } = skill;
|
||||
|
||||
// Try to get cached files first
|
||||
let files: SkillFile[];
|
||||
const cachedFiles = await skillQueries.getCachedFiles(db, skillId);
|
||||
|
||||
if (cachedFiles) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[skill-files/zip] Cache HIT for ${skillId}`);
|
||||
files = cachedFiles.items;
|
||||
} else {
|
||||
// Fetch from GitHub
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[skill-files/zip] Cache MISS for ${skillId}, fetching from GitHub...`);
|
||||
const { headers: githubHeaders, token } = await getGitHubHeaders();
|
||||
|
||||
files = await fetchSkillFiles(
|
||||
githubOwner,
|
||||
githubRepo,
|
||||
skillPath,
|
||||
branch || 'main',
|
||||
0,
|
||||
githubHeaders,
|
||||
token
|
||||
);
|
||||
|
||||
// Save to cache (async, don't block)
|
||||
const filesToCache: CachedFiles = {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
commitSha: skill.commitSha || 'unknown',
|
||||
totalSize: files.reduce((sum, f) => sum + f.size, 0),
|
||||
items: files,
|
||||
};
|
||||
skillQueries.updateCachedFiles(db, skillId, filesToCache).catch((err) => {
|
||||
console.error(`[skill-files/zip] Failed to cache files for ${skillId}:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No files found in skill', code: 'NO_FILES' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine if this is a flat-file platform
|
||||
const isFlatPlatform = ['cursor', 'windsurf', 'copilot'].includes(platform);
|
||||
|
||||
// Create ZIP archive using streaming
|
||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||
|
||||
// Add files to archive with platform-specific transformation
|
||||
for (const file of files) {
|
||||
if (file.content && !file.isBinary) {
|
||||
const isMainFile = isMainInstructionFile(file.name) && file.path === file.name;
|
||||
|
||||
if (isMainFile) {
|
||||
const platformFileName = getPlatformFileName(platform, skillName);
|
||||
const transformed = transformContent(platform, file.content, skillName);
|
||||
if (isFlatPlatform) {
|
||||
archive.append(transformed, { name: platformFileName });
|
||||
} else {
|
||||
archive.append(transformed, { name: `${skillName}/${platformFileName}` });
|
||||
}
|
||||
} else {
|
||||
// Supporting files always go in subfolder
|
||||
archive.append(file.content, { name: `${skillName}/${file.path}` });
|
||||
}
|
||||
} else if (file.isBinary) {
|
||||
// For binary files, we need to fetch them
|
||||
const downloadUrl = `https://raw.githubusercontent.com/${githubOwner}/${githubRepo}/${branch || 'main'}/${skillPath}/${file.path}`;
|
||||
try {
|
||||
const response = await fetch(downloadUrl, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
||||
});
|
||||
if (response.ok) {
|
||||
const buffer = await response.arrayBuffer();
|
||||
archive.append(Buffer.from(buffer), { name: `${skillName}/${file.path}` });
|
||||
}
|
||||
} catch {
|
||||
console.warn(`[skill-files/zip] Failed to fetch binary file: ${file.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize archive
|
||||
archive.finalize();
|
||||
|
||||
// Convert Node.js stream to Web ReadableStream
|
||||
const webStream = Readable.toWeb(archive) as ReadableStream<Uint8Array>;
|
||||
|
||||
// Sanitize filename for Content-Disposition header
|
||||
const safeFileName = skillName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
return new Response(webStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${safeFileName}.zip"`,
|
||||
...createRateLimitHeaders(rateLimitResult),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[skill-files/zip] Error:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
if (errorMessage.includes('rate limit') || errorMessage.includes('403')) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'GitHub API rate limit exceeded', code: 'RATE_LIMIT' }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage.includes('timeout') || errorMessage.includes('AbortError')) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Request timed out', code: 'TIMEOUT' }),
|
||||
{ status: 504, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to generate ZIP', code: 'INTERNAL_ERROR' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively fetch all files in a skill folder from GitHub
|
||||
*/
|
||||
async function fetchSkillFiles(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
ref: string,
|
||||
depth: number = 0,
|
||||
headers: Record<string, string>,
|
||||
token: string | null
|
||||
): Promise<SkillFile[]> {
|
||||
if (depth > MAX_DEPTH) {
|
||||
console.warn(`Max depth (${MAX_DEPTH}) reached at: ${path}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: SkillFile[] = [];
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
||||
});
|
||||
|
||||
if (token) {
|
||||
await updateTokenStats(token, response.headers);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
|
||||
if (rateLimitRemaining === '0') {
|
||||
throw new Error('GitHub API rate limit exceeded');
|
||||
}
|
||||
}
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const contents: GitHubFile[] = await response.json();
|
||||
|
||||
for (const item of contents) {
|
||||
if (item.type === 'file') {
|
||||
const isBinary = !isTextFile(item.name);
|
||||
|
||||
if (!isBinary && item.size < 1024 * 1024) {
|
||||
try {
|
||||
const content = await fetchFileContent(owner, repo, item.path, ref, headers, token);
|
||||
files.push({
|
||||
name: item.name,
|
||||
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
|
||||
content,
|
||||
size: item.size,
|
||||
isBinary: false,
|
||||
});
|
||||
} catch {
|
||||
files.push({
|
||||
name: item.name,
|
||||
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
|
||||
content: '',
|
||||
size: item.size,
|
||||
isBinary: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
files.push({
|
||||
name: item.name,
|
||||
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
|
||||
content: '',
|
||||
size: item.size,
|
||||
isBinary: true,
|
||||
});
|
||||
}
|
||||
} else if (item.type === 'dir') {
|
||||
const subPath = `${path}/${item.name}`;
|
||||
const subFiles = await fetchSkillFiles(owner, repo, subPath, ref, depth + 1, headers, token);
|
||||
|
||||
for (const subFile of subFiles) {
|
||||
files.push({
|
||||
...subFile,
|
||||
path: `${item.name}/${subFile.path}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch file content from GitHub
|
||||
*/
|
||||
async function fetchFileContent(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
ref: string,
|
||||
headers: Record<string, string>,
|
||||
token: string | null
|
||||
): Promise<string> {
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
|
||||
const response = await fetch(apiUrl, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
||||
});
|
||||
|
||||
if (token) {
|
||||
await updateTokenStats(token, response.headers);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch file: ${path} (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.content && data.encoding === 'base64') {
|
||||
return Buffer.from(data.content, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
throw new Error(`Invalid file content for: ${path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is a text file based on extension
|
||||
*/
|
||||
function isTextFile(filename: string): boolean {
|
||||
const sensitivePatterns = ['.env', '.secret', '.key', '.pem', '.credential'];
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
if (sensitivePatterns.some((p) => lowerFilename.includes(p))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Known instruction dotfiles that are plain text
|
||||
if (MAIN_FILE_NAMES.includes(filename)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const textExtensions = [
|
||||
'.md', '.txt', '.json', '.yaml', '.yml', '.xml', '.html', '.css',
|
||||
'.js', '.ts', '.jsx', '.tsx', '.py', '.sh', '.bash', '.ps1',
|
||||
'.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp',
|
||||
'.toml', '.ini', '.cfg', '.conf', '.gitignore', '.editorconfig',
|
||||
];
|
||||
|
||||
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
||||
return textExtensions.includes(ext) || !filename.includes('.');
|
||||
}
|
||||
93
apps/web/app/api/skills/[...id]/route.ts
Normal file
93
apps/web/app/api/skills/[...id]/route.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { createDb, skillQueries } from '@skillhub/db';
|
||||
import { shouldCountView } from '@/lib/cache';
|
||||
|
||||
// Create database connection
|
||||
const db = createDb();
|
||||
|
||||
/**
|
||||
* Get client IP from request headers
|
||||
* Handles various proxy headers (Cloudflare, nginx, etc.)
|
||||
*/
|
||||
function getClientIp(request: NextRequest): string {
|
||||
// Try various headers in order of preference
|
||||
const cfConnectingIp = request.headers.get('cf-connecting-ip');
|
||||
if (cfConnectingIp) return cfConnectingIp;
|
||||
|
||||
const xRealIp = request.headers.get('x-real-ip');
|
||||
if (xRealIp) return xRealIp;
|
||||
|
||||
const xForwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (xForwardedFor) {
|
||||
// x-forwarded-for can contain multiple IPs, take the first one
|
||||
return xForwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
// Fallback to a default (shouldn't happen in production)
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const skillId = id.join('/');
|
||||
|
||||
// Get skill from database
|
||||
const skill = await skillQueries.getById(db, skillId);
|
||||
|
||||
if (!skill) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Skill not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Increment view count only on primary server (mirror DB is read-only)
|
||||
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
|
||||
if (isPrimary) {
|
||||
const clientIp = getClientIp(request);
|
||||
const shouldCount = await shouldCountView(skillId, clientIp);
|
||||
|
||||
if (shouldCount) {
|
||||
await skillQueries.incrementViews(db, skillId);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
githubOwner: skill.githubOwner,
|
||||
githubRepo: skill.githubRepo,
|
||||
skillPath: skill.skillPath,
|
||||
branch: skill.branch,
|
||||
version: skill.version,
|
||||
license: skill.license,
|
||||
author: skill.author,
|
||||
homepage: skill.homepage,
|
||||
githubStars: skill.githubStars,
|
||||
githubForks: skill.githubForks,
|
||||
downloadCount: skill.downloadCount,
|
||||
viewCount: skill.viewCount,
|
||||
securityScore: skill.securityScore,
|
||||
isVerified: skill.isVerified,
|
||||
isFeatured: skill.isFeatured,
|
||||
compatibility: skill.compatibility,
|
||||
triggers: skill.triggers,
|
||||
rawContent: skill.rawContent,
|
||||
sourceFormat: skill.sourceFormat || 'skill.md',
|
||||
createdAt: skill.createdAt,
|
||||
updatedAt: skill.updatedAt,
|
||||
indexedAt: skill.indexedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching skill:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch skill' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
429
apps/web/app/api/skills/add-request/route.ts
Normal file
429
apps/web/app/api/skills/add-request/route.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { createDb, addRequestQueries, userQueries } from '@skillhub/db';
|
||||
import { sanitizeReason } from '@/lib/sanitize';
|
||||
import { sendClaimSubmittedEmail } from '@/lib/email';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
// Parse GitHub URL to extract owner and repo
|
||||
function parseGitHubUrl(url: string): { owner: string; repo: string; path?: string } | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (!urlObj.hostname.includes('github.com')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle various GitHub URL formats:
|
||||
// https://github.com/owner/repo
|
||||
// https://github.com/owner/repo/tree/main/path/to/skill
|
||||
// https://github.com/owner/repo/blob/main/SKILL.md
|
||||
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (pathParts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const owner = pathParts[0];
|
||||
const repo = pathParts[1];
|
||||
|
||||
// Extract path if present (after tree/branch or blob/branch)
|
||||
let skillPath: string | undefined;
|
||||
if (pathParts.length > 3 && (pathParts[2] === 'tree' || pathParts[2] === 'blob')) {
|
||||
// Skip 'tree' or 'blob' and branch name
|
||||
const remainingPath = pathParts.slice(4).join('/');
|
||||
if (remainingPath && !remainingPath.endsWith('.md')) {
|
||||
skillPath = remainingPath;
|
||||
}
|
||||
}
|
||||
|
||||
return { owner, repo, path: skillPath };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Find all SKILL.md files in a repository using GitHub Tree API
|
||||
async function findSkillMdFiles(
|
||||
owner: string,
|
||||
repo: string,
|
||||
defaultBranch: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
// Get the full repository tree recursively
|
||||
const treeResponse = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'SkillHub',
|
||||
...(process.env.GITHUB_TOKEN && {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
}),
|
||||
},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
);
|
||||
|
||||
if (!treeResponse.ok) {
|
||||
console.error(`Failed to fetch repository tree (HTTP ${treeResponse.status}):`, treeResponse.statusText);
|
||||
return [];
|
||||
}
|
||||
|
||||
const treeData = await treeResponse.json() as {
|
||||
tree: Array<{ path: string; type: string }>;
|
||||
truncated?: boolean;
|
||||
};
|
||||
|
||||
if (treeData.truncated) {
|
||||
console.warn(`Repository tree for ${owner}/${repo} is truncated - some SKILL.md files may be missed`);
|
||||
}
|
||||
|
||||
// Find all SKILL.md files (case-sensitive)
|
||||
const skillMdPaths = treeData.tree
|
||||
.filter((item) => item.type === 'blob' && item.path.endsWith('/SKILL.md'))
|
||||
.map((item) => {
|
||||
// Extract the directory path (remove /SKILL.md)
|
||||
const parts = item.path.split('/');
|
||||
parts.pop(); // Remove SKILL.md
|
||||
return parts.join('/');
|
||||
});
|
||||
|
||||
// Also check for SKILL.md at root level
|
||||
const hasRootSkillMd = treeData.tree.some(
|
||||
(item) => item.type === 'blob' && item.path === 'SKILL.md'
|
||||
);
|
||||
|
||||
if (hasRootSkillMd) {
|
||||
skillMdPaths.unshift(''); // Empty string means root
|
||||
}
|
||||
|
||||
return skillMdPaths;
|
||||
} catch (error) {
|
||||
console.error('Error finding SKILL.md files:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Validate GitHub repository exists and check for SKILL.md
|
||||
async function validateGitHubRepo(
|
||||
owner: string,
|
||||
repo: string,
|
||||
skillPath?: string
|
||||
): Promise<{
|
||||
valid: boolean;
|
||||
hasSkillMd: boolean;
|
||||
skillPaths: string[];
|
||||
defaultBranch: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Check if repository exists and get default branch
|
||||
const repoResponse = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'SkillHub',
|
||||
...(process.env.GITHUB_TOKEN && {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
}),
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
}
|
||||
);
|
||||
|
||||
if (!repoResponse.ok) {
|
||||
if (repoResponse.status === 404) {
|
||||
return {
|
||||
valid: false,
|
||||
hasSkillMd: false,
|
||||
skillPaths: [],
|
||||
defaultBranch: 'main',
|
||||
error: 'Repository not found. Please check the URL and ensure the repository exists.'
|
||||
};
|
||||
}
|
||||
if (repoResponse.status === 403) {
|
||||
// Check if it's rate limit or forbidden access
|
||||
const rateLimitRemaining = repoResponse.headers.get('x-ratelimit-remaining');
|
||||
if (rateLimitRemaining === '0') {
|
||||
return {
|
||||
valid: false,
|
||||
hasSkillMd: false,
|
||||
skillPaths: [],
|
||||
defaultBranch: 'main',
|
||||
error: 'GitHub API rate limit exceeded. Please try again later.'
|
||||
};
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
hasSkillMd: false,
|
||||
skillPaths: [],
|
||||
defaultBranch: 'main',
|
||||
error: 'Repository is private or you do not have access. Please ensure the repository is public.'
|
||||
};
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
hasSkillMd: false,
|
||||
skillPaths: [],
|
||||
defaultBranch: 'main',
|
||||
error: `Failed to access repository (HTTP ${repoResponse.status})`
|
||||
};
|
||||
}
|
||||
|
||||
const repoData = await repoResponse.json() as { default_branch: string; private?: boolean };
|
||||
const defaultBranch = repoData.default_branch || 'main';
|
||||
|
||||
// Additional check for private repos (in case 200 but private)
|
||||
if (repoData.private) {
|
||||
return {
|
||||
valid: false,
|
||||
hasSkillMd: false,
|
||||
skillPaths: [],
|
||||
defaultBranch,
|
||||
error: 'Repository is private. SkillHub only indexes public repositories.'
|
||||
};
|
||||
}
|
||||
|
||||
// If a specific path is provided, only check that path
|
||||
if (skillPath) {
|
||||
const skillMdPath = `${skillPath}/SKILL.md`;
|
||||
const fileResponse = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}/contents/${skillMdPath}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'SkillHub',
|
||||
...(process.env.GITHUB_TOKEN && {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
}),
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
}
|
||||
);
|
||||
|
||||
const hasSkillMd = fileResponse.ok;
|
||||
return {
|
||||
valid: true,
|
||||
hasSkillMd,
|
||||
skillPaths: hasSkillMd ? [skillPath] : [],
|
||||
defaultBranch,
|
||||
};
|
||||
}
|
||||
|
||||
// No specific path - do a deep scan for all SKILL.md files
|
||||
const skillPaths = await findSkillMdFiles(owner, repo, defaultBranch);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
hasSkillMd: skillPaths.length > 0,
|
||||
skillPaths,
|
||||
defaultBranch,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('GitHub API error:', error);
|
||||
|
||||
// Distinguish timeout errors
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
return {
|
||||
valid: false,
|
||||
hasSkillMd: false,
|
||||
skillPaths: [],
|
||||
defaultBranch: 'main',
|
||||
error: 'Request timed out while checking repository. Please try again.'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
hasSkillMd: false,
|
||||
skillPaths: [],
|
||||
defaultBranch: 'main',
|
||||
error: 'Network error while verifying repository. Please check your connection and try again.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/skills/add-request - Get user's add requests
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.githubId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
|
||||
if (!dbUser) {
|
||||
return NextResponse.json({ requests: [] });
|
||||
}
|
||||
|
||||
const requests = await addRequestQueries.getByUser(db, dbUser.id);
|
||||
|
||||
return NextResponse.json({ requests });
|
||||
} catch (error) {
|
||||
console.error('Error fetching add requests:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/skills/add-request - Submit an add request
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.githubId || !session.user.username) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required', code: 'AUTH_REQUIRED' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { repositoryUrl, reason } = body;
|
||||
|
||||
if (!repositoryUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'repositoryUrl is required', code: 'INVALID_INPUT' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse GitHub URL
|
||||
const parsed = parseGitHubUrl(repositoryUrl);
|
||||
if (!parsed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid GitHub URL', code: 'INVALID_URL' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize the repository URL
|
||||
const normalizedUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
|
||||
const finalSkillPath = undefined;
|
||||
|
||||
// Get or create user in database
|
||||
let dbUser = await userQueries.getByGithubId(db, session.user.githubId);
|
||||
|
||||
if (!dbUser) {
|
||||
dbUser = await userQueries.upsertFromGithub(db, {
|
||||
githubId: session.user.githubId,
|
||||
username: session.user.username,
|
||||
displayName: session.user.name || undefined,
|
||||
email: session.user.email || undefined,
|
||||
avatarUrl: session.user.image || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!dbUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create user record', code: 'USER_CREATE_FAILED' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already has a pending request for this repository + path combination
|
||||
const hasPending = await addRequestQueries.hasPendingRequest(
|
||||
db,
|
||||
dbUser.id,
|
||||
normalizedUrl,
|
||||
finalSkillPath || null
|
||||
);
|
||||
if (hasPending) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You already have a pending request for this skill path', code: 'ALREADY_PENDING' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the GitHub repository
|
||||
const validation = await validateGitHubRepo(parsed.owner, parsed.repo, finalSkillPath);
|
||||
|
||||
if (!validation.valid) {
|
||||
let errorCode = 'INVALID_REPO';
|
||||
|
||||
// Map specific error messages to error codes
|
||||
if (validation.error?.includes('rate limit')) {
|
||||
errorCode = 'RATE_LIMIT_EXCEEDED';
|
||||
} else if (validation.error?.includes('not found')) {
|
||||
errorCode = 'INVALID_REPO';
|
||||
} else if (validation.error?.includes('timeout') || validation.error?.includes('timed out')) {
|
||||
errorCode = 'NETWORK_TIMEOUT';
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: validation.error || 'Invalid repository', code: errorCode },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create the add request with found skill paths
|
||||
const skillPathsJson = validation.skillPaths.length > 0
|
||||
? validation.skillPaths.join(',')
|
||||
: undefined;
|
||||
|
||||
// Sanitize user-provided reason
|
||||
const sanitizedReason = reason ? sanitizeReason(reason) : 'No reason provided';
|
||||
|
||||
const requestId = await addRequestQueries.create(db, {
|
||||
userId: dbUser.id,
|
||||
repositoryUrl: normalizedUrl,
|
||||
skillPath: skillPathsJson,
|
||||
reason: sanitizedReason,
|
||||
validRepo: validation.valid,
|
||||
hasSkillMd: validation.hasSkillMd,
|
||||
});
|
||||
|
||||
// Build appropriate response message
|
||||
let message: string;
|
||||
if (validation.skillPaths.length > 1) {
|
||||
message = `Request submitted. Found ${validation.skillPaths.length} skills in the repository.`;
|
||||
} else if (validation.skillPaths.length === 1) {
|
||||
const pathInfo = validation.skillPaths[0] === '' ? 'at root' : `in ${validation.skillPaths[0]}`;
|
||||
message = `Request submitted. SKILL.md found ${pathInfo}.`;
|
||||
} else {
|
||||
message = 'Request submitted. No SKILL.md found - repository will be reviewed.';
|
||||
}
|
||||
|
||||
// Send confirmation email (non-blocking) - ONLY if skills were found
|
||||
if (dbUser.email && validation.skillPaths.length > 0) {
|
||||
const locale = (dbUser.preferredLocale === 'fa' ? 'fa' : 'en') as 'en' | 'fa';
|
||||
sendClaimSubmittedEmail(dbUser.email, locale, 'add', {
|
||||
repositoryUrl: normalizedUrl,
|
||||
skillCount: validation.skillPaths.length,
|
||||
}).catch((err) => {
|
||||
console.error('[Claim] Failed to send add confirmation email:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
requestId,
|
||||
hasSkillMd: validation.hasSkillMd,
|
||||
skillCount: validation.skillPaths.length,
|
||||
skillPaths: validation.skillPaths,
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating add request:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error', code: 'SERVER_ERROR' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
102
apps/web/app/api/skills/featured/route.test.ts
Normal file
102
apps/web/app/api/skills/featured/route.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// Mock rate limiting - must be before route import
|
||||
vi.mock('@/lib/rate-limit', () => ({
|
||||
withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }),
|
||||
createRateLimitResponse: vi.fn(),
|
||||
createRateLimitHeaders: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Helper to create mock skill
|
||||
function createMockSkill(overrides: Partial<{
|
||||
id: string;
|
||||
name: string;
|
||||
isFeatured: boolean;
|
||||
githubStars: number;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'test-owner/test-repo/test-skill',
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
githubOwner: 'test-owner',
|
||||
githubRepo: 'test-repo',
|
||||
githubStars: 100,
|
||||
downloadCount: 50,
|
||||
securityScore: 85,
|
||||
isVerified: false,
|
||||
isFeatured: true,
|
||||
compatibility: { platforms: ['claude'] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock db
|
||||
vi.mock('@skillhub/db', () => ({
|
||||
createDb: vi.fn(() => ({})),
|
||||
skillQueries: {
|
||||
getFeatured: vi.fn(),
|
||||
getByPopularity: vi.fn(),
|
||||
getFeaturedWithDiversity: vi.fn(),
|
||||
},
|
||||
skills: {},
|
||||
}));
|
||||
|
||||
import { GET } from './route';
|
||||
import { skillQueries } from '@skillhub/db';
|
||||
|
||||
describe('GET /api/skills/featured', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return featured skills', async () => {
|
||||
const mockSkills = [
|
||||
createMockSkill({ id: 'skill-1', isFeatured: true }),
|
||||
createMockSkill({ id: 'skill-2', isFeatured: true }),
|
||||
];
|
||||
vi.mocked(skillQueries.getFeatured).mockResolvedValue(mockSkills as any);
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/skills/featured');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.skills).toBeDefined();
|
||||
expect(Array.isArray(data.skills)).toBe(true);
|
||||
expect(data.skills.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should respect limit parameter', async () => {
|
||||
const mockSkills = [createMockSkill()];
|
||||
vi.mocked(skillQueries.getFeatured).mockResolvedValue(mockSkills as any);
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/skills/featured?limit=5');
|
||||
await GET(request);
|
||||
|
||||
expect(skillQueries.getFeatured).toHaveBeenCalledWith(expect.anything(), 5);
|
||||
});
|
||||
|
||||
it('should fallback to diversity-based popularity when no featured', async () => {
|
||||
vi.mocked(skillQueries.getFeatured).mockResolvedValue([]);
|
||||
vi.mocked(skillQueries.getFeaturedWithDiversity).mockResolvedValue([createMockSkill()] as any);
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/skills/featured');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(skillQueries.getFeaturedWithDiversity).toHaveBeenCalled();
|
||||
expect(data.skills.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
vi.mocked(skillQueries.getFeatured).mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/skills/featured');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
88
apps/web/app/api/skills/featured/route.ts
Normal file
88
apps/web/app/api/skills/featured/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { createDb, skillQueries, type skills } from '@skillhub/db';
|
||||
import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
|
||||
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
type Skill = typeof skills.$inferSelect;
|
||||
|
||||
interface SkillData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
githubOwner: string;
|
||||
githubRepo: string;
|
||||
githubStars: number | null;
|
||||
downloadCount: number | null;
|
||||
securityStatus: string | null;
|
||||
isVerified: boolean | null;
|
||||
compatibility: unknown;
|
||||
}
|
||||
|
||||
interface FeaturedResponse {
|
||||
skills: SkillData[];
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Rate limiting
|
||||
const rateLimitResult = await withRateLimit(request, 'anonymous');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const limit = parseInt(searchParams.get('limit') || '6');
|
||||
|
||||
// Try to get from cache first (only for default limit)
|
||||
const cacheKey = cacheKeys.featuredSkills();
|
||||
if (limit === 6) {
|
||||
const cached = await getCached<FeaturedResponse>(cacheKey);
|
||||
if (cached) {
|
||||
return NextResponse.json(cached, {
|
||||
headers: { 'X-Cache': 'HIT', ...createRateLimitHeaders(rateLimitResult) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get featured skills, fallback to popularity-based ranking
|
||||
// Uses adaptive algorithm: quality + freshness + engagement
|
||||
let featuredSkills = await skillQueries.getFeatured(db, limit);
|
||||
|
||||
// If no manually featured skills, use adaptive popularity with owner/repo diversity
|
||||
if (featuredSkills.length === 0) {
|
||||
featuredSkills = await skillQueries.getFeaturedWithDiversity(db, limit, 2, 3);
|
||||
}
|
||||
|
||||
const data: FeaturedResponse = {
|
||||
skills: featuredSkills.map((skill: Skill) => ({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
githubOwner: skill.githubOwner,
|
||||
githubRepo: skill.githubRepo,
|
||||
githubStars: skill.githubStars,
|
||||
downloadCount: skill.downloadCount,
|
||||
securityStatus: skill.securityStatus,
|
||||
isVerified: skill.isVerified,
|
||||
compatibility: skill.compatibility,
|
||||
})),
|
||||
};
|
||||
|
||||
// Cache the result (2 hours)
|
||||
if (limit === 6) {
|
||||
await setCache(cacheKey, data, cacheTTL.featured);
|
||||
}
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: { 'X-Cache': 'MISS', ...createRateLimitHeaders(rateLimitResult) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching featured skills:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch featured skills' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
93
apps/web/app/api/skills/install/route.ts
Normal file
93
apps/web/app/api/skills/install/route.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { createDb, skillQueries } from '@skillhub/db';
|
||||
import { invalidateCache, cacheKeys, shouldCountDownload } from '@/lib/cache';
|
||||
|
||||
// Create database connection
|
||||
const db = createDb();
|
||||
|
||||
/**
|
||||
* Get client IP from request headers
|
||||
*/
|
||||
function getClientIp(request: NextRequest): string {
|
||||
const cfConnectingIp = request.headers.get('cf-connecting-ip');
|
||||
if (cfConnectingIp) return cfConnectingIp;
|
||||
|
||||
const xRealIp = request.headers.get('x-real-ip');
|
||||
if (xRealIp) return xRealIp;
|
||||
|
||||
const xForwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (xForwardedFor) {
|
||||
return xForwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/skills/install
|
||||
* Track a skill installation from CLI or other sources
|
||||
*
|
||||
* Body: { skillId: string, platform?: string, method?: string }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Parse request body
|
||||
let body: { skillId?: string; platform?: string; method?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { skillId, platform = 'unknown', method = 'unknown' } = body;
|
||||
|
||||
if (!skillId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'skillId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify skill exists
|
||||
const skill = await skillQueries.getById(db, skillId);
|
||||
if (!skill) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Skill not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limit: same IP can only count 1 download per skill per 5 minutes
|
||||
const clientIp = getClientIp(request);
|
||||
const shouldCount = await shouldCountDownload(skillId, clientIp);
|
||||
|
||||
// Only increment if this is a new download from this IP
|
||||
if (shouldCount) {
|
||||
await skillQueries.incrementDownloads(db, skillId);
|
||||
}
|
||||
|
||||
// Invalidate relevant caches so featured/recent lists reflect the new download
|
||||
await Promise.all([
|
||||
invalidateCache(cacheKeys.featuredSkills()),
|
||||
invalidateCache(cacheKeys.recentSkills()),
|
||||
invalidateCache(cacheKeys.stats()),
|
||||
invalidateCache(cacheKeys.skill(skillId)),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
skillId,
|
||||
platform,
|
||||
method,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error tracking install:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to track install' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
94
apps/web/app/api/skills/recent/route.test.ts
Normal file
94
apps/web/app/api/skills/recent/route.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// Helper to create mock skill
|
||||
function createMockSkill(overrides: Partial<{
|
||||
id: string;
|
||||
name: string;
|
||||
updatedAt: Date;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'test-owner/test-repo/test-skill',
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
githubOwner: 'test-owner',
|
||||
githubRepo: 'test-repo',
|
||||
githubStars: 100,
|
||||
downloadCount: 50,
|
||||
securityScore: 85,
|
||||
isVerified: false,
|
||||
compatibility: { platforms: ['claude'] },
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock db
|
||||
vi.mock('@skillhub/db', () => ({
|
||||
createDb: vi.fn(() => ({})),
|
||||
skillQueries: {
|
||||
getRecent: vi.fn(),
|
||||
},
|
||||
skills: {},
|
||||
}));
|
||||
|
||||
import { GET } from './route';
|
||||
import { skillQueries } from '@skillhub/db';
|
||||
|
||||
describe('GET /api/skills/recent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return recent skills', async () => {
|
||||
const mockSkills = [
|
||||
createMockSkill({ id: 'skill-1', updatedAt: new Date('2024-01-02') }),
|
||||
createMockSkill({ id: 'skill-2', updatedAt: new Date('2024-01-01') }),
|
||||
];
|
||||
vi.mocked(skillQueries.getRecent).mockResolvedValue(mockSkills as any);
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/skills/recent');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.skills).toBeDefined();
|
||||
expect(Array.isArray(data.skills)).toBe(true);
|
||||
expect(data.skills.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should order by updatedAt descending', async () => {
|
||||
const mockSkills = [
|
||||
createMockSkill({ id: 'skill-1', updatedAt: new Date('2024-01-02') }),
|
||||
createMockSkill({ id: 'skill-2', updatedAt: new Date('2024-01-01') }),
|
||||
];
|
||||
vi.mocked(skillQueries.getRecent).mockResolvedValue(mockSkills as any);
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/skills/recent');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.skills[0].updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should respect limit parameter', async () => {
|
||||
vi.mocked(skillQueries.getRecent).mockResolvedValue([]);
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/skills/recent?limit=5');
|
||||
await GET(request);
|
||||
|
||||
expect(skillQueries.getRecent).toHaveBeenCalledWith(expect.anything(), 5);
|
||||
});
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
vi.mocked(skillQueries.getRecent).mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/skills/recent');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
85
apps/web/app/api/skills/recent/route.ts
Normal file
85
apps/web/app/api/skills/recent/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { createDb, skillQueries, type skills } from '@skillhub/db';
|
||||
import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
|
||||
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
type Skill = typeof skills.$inferSelect;
|
||||
|
||||
interface SkillData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
githubOwner: string;
|
||||
githubRepo: string;
|
||||
githubStars: number | null;
|
||||
downloadCount: number | null;
|
||||
securityStatus: string | null;
|
||||
isVerified: boolean | null;
|
||||
compatibility: unknown;
|
||||
updatedAt: Date | null;
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
interface RecentResponse {
|
||||
skills: SkillData[];
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Rate limiting
|
||||
const rateLimitResult = await withRateLimit(request, 'anonymous');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
|
||||
// Try to get from cache first (only for default limit)
|
||||
const cacheKey = cacheKeys.recentSkills();
|
||||
if (limit === 10) {
|
||||
const cached = await getCached<RecentResponse>(cacheKey);
|
||||
if (cached) {
|
||||
return NextResponse.json(cached, {
|
||||
headers: { 'X-Cache': 'HIT', ...createRateLimitHeaders(rateLimitResult) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const recentSkills = await skillQueries.getRecent(db, limit);
|
||||
|
||||
const data: RecentResponse = {
|
||||
skills: recentSkills.map((skill: Skill) => ({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
githubOwner: skill.githubOwner,
|
||||
githubRepo: skill.githubRepo,
|
||||
githubStars: skill.githubStars,
|
||||
downloadCount: skill.downloadCount,
|
||||
securityStatus: skill.securityStatus,
|
||||
isVerified: skill.isVerified,
|
||||
compatibility: skill.compatibility,
|
||||
updatedAt: skill.updatedAt,
|
||||
createdAt: skill.createdAt,
|
||||
})),
|
||||
};
|
||||
|
||||
// Cache the result (1 hour)
|
||||
if (limit === 10) {
|
||||
await setCache(cacheKey, data, cacheTTL.recent);
|
||||
}
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: { 'X-Cache': 'MISS', ...createRateLimitHeaders(rateLimitResult) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching recent skills:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch recent skills' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
210
apps/web/app/api/skills/removal-request/route.ts
Normal file
210
apps/web/app/api/skills/removal-request/route.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { createDb, skillQueries, removalRequestQueries, userQueries } from '@skillhub/db';
|
||||
import { sanitizeReason } from '@/lib/sanitize';
|
||||
import { sendClaimSubmittedEmail } from '@/lib/email';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
/**
|
||||
* GET /api/skills/removal-request - Get user's removal requests
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.githubId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
|
||||
if (!dbUser) {
|
||||
return NextResponse.json({ requests: [] });
|
||||
}
|
||||
|
||||
const requests = await removalRequestQueries.getByUser(db, dbUser.id);
|
||||
|
||||
return NextResponse.json({ requests });
|
||||
} catch (error) {
|
||||
console.error('Error fetching removal requests:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/skills/removal-request - Submit a removal request
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.githubId || !session.user.username) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required', code: 'AUTH_REQUIRED' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { skillId, reason } = body;
|
||||
|
||||
if (!skillId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'skillId is required', code: 'INVALID_INPUT' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if skill exists
|
||||
const skill = await skillQueries.getById(db, skillId);
|
||||
if (!skill) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Skill not found', code: 'SKILL_NOT_FOUND' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get or create user in database
|
||||
let dbUser = await userQueries.getByGithubId(db, session.user.githubId);
|
||||
|
||||
// Auto-create user if not in database (first API request after OAuth)
|
||||
if (!dbUser) {
|
||||
dbUser = await userQueries.upsertFromGithub(db, {
|
||||
githubId: session.user.githubId,
|
||||
username: session.user.username,
|
||||
displayName: session.user.name || undefined,
|
||||
email: session.user.email || undefined,
|
||||
avatarUrl: session.user.image || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!dbUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create user record', code: 'USER_CREATE_FAILED' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already has a pending request for this skill
|
||||
const hasPending = await removalRequestQueries.hasPendingRequest(
|
||||
db,
|
||||
dbUser.id,
|
||||
skillId
|
||||
);
|
||||
if (hasPending) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You already have a pending request for this skill', code: 'ALREADY_PENDING' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify ownership via GitHub API
|
||||
const owner = skill.githubOwner;
|
||||
const repo = skill.githubRepo;
|
||||
const username = session.user.username;
|
||||
|
||||
// Check if skill has required GitHub info
|
||||
if (!owner || !repo) {
|
||||
console.error('Skill missing GitHub info:', { skillId, owner, repo });
|
||||
return NextResponse.json(
|
||||
{ error: 'Skill does not have valid GitHub repository information', code: 'INVALID_SKILL' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check repository ownership using public API (no token needed for public repos)
|
||||
let isOwner = false;
|
||||
let githubError: string | null = null;
|
||||
|
||||
try {
|
||||
const repoResponse = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'SkillHub',
|
||||
},
|
||||
signal: AbortSignal.timeout(10000), // 10 second timeout
|
||||
}
|
||||
);
|
||||
|
||||
if (repoResponse.ok) {
|
||||
const repoData = await repoResponse.json();
|
||||
if (repoData.owner?.login?.toLowerCase() === username.toLowerCase()) {
|
||||
isOwner = true;
|
||||
}
|
||||
} else if (repoResponse.status === 404) {
|
||||
githubError = 'Repository not found on GitHub';
|
||||
} else if (repoResponse.status === 403) {
|
||||
githubError = 'GitHub API rate limit exceeded';
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.error('GitHub API fetch error:', fetchError);
|
||||
githubError = 'Failed to verify repository ownership';
|
||||
}
|
||||
|
||||
if (githubError) {
|
||||
return NextResponse.json(
|
||||
{ error: githubError, code: 'GITHUB_ERROR' },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isOwner) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You are not the owner of this repository', code: 'NOT_OWNER' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create the removal request (auto-approved since owner is verified)
|
||||
const sanitizedReason = reason ? sanitizeReason(reason) : 'No reason provided';
|
||||
const requestId = await removalRequestQueries.create(db, {
|
||||
userId: dbUser.id,
|
||||
skillId,
|
||||
reason: sanitizedReason,
|
||||
verifiedOwner: true,
|
||||
});
|
||||
|
||||
// Auto-approve: Block the skill from being re-indexed
|
||||
await skillQueries.block(db, skillId);
|
||||
|
||||
// Update the request status to approved
|
||||
await removalRequestQueries.resolve(db, requestId, {
|
||||
status: 'approved',
|
||||
resolvedBy: dbUser.id,
|
||||
resolutionNote: 'Auto-approved: Owner verified via GitHub API',
|
||||
});
|
||||
|
||||
// Send confirmation email (non-blocking)
|
||||
if (dbUser.email) {
|
||||
const locale = (dbUser.preferredLocale === 'fa' ? 'fa' : 'en') as 'en' | 'fa';
|
||||
sendClaimSubmittedEmail(dbUser.email, locale, 'remove', { skillId }).catch((err) => {
|
||||
console.error('[Claim] Failed to send removal confirmation email:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
requestId,
|
||||
message: 'Skill has been blocked from indexing',
|
||||
blocked: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating removal request:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error', code: 'SERVER_ERROR' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
252
apps/web/app/api/skills/route.ts
Normal file
252
apps/web/app/api/skills/route.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { createDb, skillQueries, type skills, isMeilisearchHealthy, searchSkills as meilisearchSearch } from '@skillhub/db';
|
||||
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
|
||||
import { getCached, setCache, hashSearchParams, cacheKeys } from '@/lib/cache';
|
||||
import { captureException, log } from '@/lib/sentry';
|
||||
|
||||
// Create database connection
|
||||
const db = createDb();
|
||||
|
||||
type Skill = typeof skills.$inferSelect;
|
||||
|
||||
/**
|
||||
* Restore skill ID from Meilisearch format
|
||||
* Converts sanitized IDs back to original format:
|
||||
* "anthropics__skills__pdf" -> "anthropics/skills/pdf"
|
||||
* "bdmorin___dot_claude__git" -> "bdmorin/.claude/git"
|
||||
* "user__repo_dot_name__skill" -> "user/repo.name/skill"
|
||||
*/
|
||||
function restoreIdFromMeili(meiliId: string): string {
|
||||
return meiliId
|
||||
.replace(/_dot_/g, '.') // _dot_ -> dot (do this FIRST)
|
||||
.replace(/__/g, '/'); // double underscore -> slash
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Apply rate limiting (search is more expensive, use lower limit)
|
||||
const rateLimitResult = await withRateLimit(request, 'search');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
const query = searchParams.get('q') || undefined;
|
||||
const platform = searchParams.get('platform') || undefined;
|
||||
const category = searchParams.get('category') || undefined;
|
||||
const format = searchParams.get('format') || 'skill.md';
|
||||
const verified = searchParams.get('verified') === 'true';
|
||||
|
||||
// Parse and validate numeric parameters
|
||||
const minStarsRaw = parseInt(searchParams.get('minStars') || '0');
|
||||
const minStars = isNaN(minStarsRaw) || minStarsRaw < 0 ? 0 : minStarsRaw;
|
||||
|
||||
const sort = searchParams.get('sort') || 'stars';
|
||||
|
||||
const pageRaw = parseInt(searchParams.get('page') || '1');
|
||||
const page = isNaN(pageRaw) || pageRaw < 1 ? 1 : pageRaw;
|
||||
|
||||
const limitRaw = parseInt(searchParams.get('limit') || '20');
|
||||
const limit = isNaN(limitRaw) || limitRaw < 1 ? 20 : Math.min(limitRaw, 100); // Max 100 per page
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Create cache key from search parameters
|
||||
const searchHash = hashSearchParams({
|
||||
q: query,
|
||||
category,
|
||||
platform,
|
||||
format,
|
||||
verified: verified ? 'true' : undefined,
|
||||
sort,
|
||||
page,
|
||||
limit,
|
||||
minStars: minStars > 0 ? minStars : undefined,
|
||||
});
|
||||
const cacheKey = cacheKeys.searchSkills(searchHash);
|
||||
|
||||
// Check cache first
|
||||
const cached = await getCached<{
|
||||
skills: Skill[];
|
||||
total: number;
|
||||
searchEngine: string;
|
||||
}>(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
skills: cached.skills,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: cached.total,
|
||||
totalPages: Math.ceil(cached.total / limit),
|
||||
},
|
||||
searchEngine: 'cache',
|
||||
cachedFrom: cached.searchEngine,
|
||||
},
|
||||
{
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Try Meilisearch for text search queries
|
||||
if (query) {
|
||||
const useMeilisearch = await isMeilisearchHealthy();
|
||||
|
||||
if (useMeilisearch) {
|
||||
// Use Meilisearch for full-text search with relevance ranking
|
||||
// Note: category filter not supported in Meilisearch, handled in PostgreSQL fallback
|
||||
const meiliResult = await meilisearchSearch({
|
||||
query,
|
||||
filters: {
|
||||
platforms: platform && platform !== 'all' ? [platform] : undefined,
|
||||
minStars: minStars > 0 ? minStars : undefined,
|
||||
verified: verified ? true : undefined,
|
||||
},
|
||||
sort: sort as 'stars' | 'downloads' | 'rating' | 'recent',
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
if (meiliResult) {
|
||||
const skills = meiliResult.hits.map((hit) => ({
|
||||
id: restoreIdFromMeili(hit.id),
|
||||
name: hit.name,
|
||||
description: hit.description,
|
||||
githubOwner: hit.githubOwner,
|
||||
githubRepo: hit.githubRepo,
|
||||
githubStars: hit.githubStars,
|
||||
downloadCount: hit.downloadCount,
|
||||
securityScore: hit.securityScore,
|
||||
securityStatus: null, // Not available in Meilisearch yet
|
||||
rating: hit.rating,
|
||||
ratingCount: null, // Not available in Meilisearch yet
|
||||
isVerified: hit.isVerified,
|
||||
compatibility: { platforms: hit.platforms },
|
||||
}));
|
||||
|
||||
// Cache the result (5 minutes TTL)
|
||||
await setCache(
|
||||
cacheKey,
|
||||
{
|
||||
skills,
|
||||
total: meiliResult.estimatedTotalHits,
|
||||
searchEngine: 'meilisearch',
|
||||
},
|
||||
5 * 60
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
skills,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: meiliResult.estimatedTotalHits,
|
||||
totalPages: Math.ceil(meiliResult.estimatedTotalHits / limit),
|
||||
},
|
||||
searchEngine: 'meilisearch',
|
||||
processingTimeMs: meiliResult.processingTimeMs,
|
||||
}, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
}
|
||||
// If Meilisearch search failed, fall through to PostgreSQL
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to PostgreSQL search
|
||||
// Map sort parameter to database column
|
||||
const sortByMap: Record<string, 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded'> = {
|
||||
stars: 'stars',
|
||||
downloads: 'downloads',
|
||||
rating: 'rating',
|
||||
recent: 'updated',
|
||||
lastDownloaded: 'lastDownloaded',
|
||||
security: 'stars', // Use stars as fallback
|
||||
};
|
||||
const sortBy = sortByMap[sort] || 'downloads';
|
||||
|
||||
// Build filter options for database query
|
||||
const filterOptions = {
|
||||
query,
|
||||
category: category || undefined,
|
||||
platform: platform && platform !== 'all' ? platform : undefined,
|
||||
sourceFormat: format,
|
||||
minStars,
|
||||
verified: verified || undefined,
|
||||
};
|
||||
|
||||
// Get paginated results directly from database (no in-memory filtering)
|
||||
const paginatedResults = await skillQueries.search(db, {
|
||||
...filterOptions,
|
||||
limit,
|
||||
offset,
|
||||
sortBy,
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
|
||||
// Get total count for pagination
|
||||
const total = await skillQueries.count(db, filterOptions);
|
||||
|
||||
const skills = paginatedResults.map((skill: Skill) => ({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
githubOwner: skill.githubOwner,
|
||||
githubRepo: skill.githubRepo,
|
||||
skillPath: skill.skillPath,
|
||||
version: skill.version,
|
||||
license: skill.license,
|
||||
githubStars: skill.githubStars,
|
||||
downloadCount: skill.downloadCount,
|
||||
securityScore: skill.securityScore,
|
||||
securityStatus: skill.securityStatus,
|
||||
rating: skill.rating,
|
||||
ratingCount: skill.ratingCount,
|
||||
isVerified: skill.isVerified,
|
||||
compatibility: skill.compatibility,
|
||||
updatedAt: skill.updatedAt,
|
||||
}));
|
||||
|
||||
// Cache the result (5 minutes TTL)
|
||||
await setCache(
|
||||
cacheKey,
|
||||
{
|
||||
skills,
|
||||
total,
|
||||
searchEngine: 'postgresql',
|
||||
},
|
||||
5 * 60
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
skills,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
searchEngine: 'postgresql',
|
||||
}, {
|
||||
headers: createRateLimitHeaders(rateLimitResult),
|
||||
});
|
||||
} catch (error) {
|
||||
// Log and report error to Sentry
|
||||
log.error('Error fetching skills', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
captureException(error, {
|
||||
tags: { route: '/api/skills' },
|
||||
extra: { searchParams: Object.fromEntries(request.nextUrl.searchParams) },
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch skills' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
78
apps/web/app/api/skills/verify-ownership/route.ts
Normal file
78
apps/web/app/api/skills/verify-ownership/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Verify if the current user is the owner of a GitHub repository
|
||||
* GET /api/skills/verify-ownership?owner=...&repo=...
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.username) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required', isOwner: false },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const owner = searchParams.get('owner');
|
||||
const repo = searchParams.get('repo');
|
||||
|
||||
if (!owner || !repo) {
|
||||
return NextResponse.json(
|
||||
{ error: 'owner and repo parameters are required', isOwner: false },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the GitHub username from session
|
||||
const username = session.user.username;
|
||||
|
||||
// Check if user is the repo owner using public API
|
||||
const repoResponse = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'SkillHub',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!repoResponse.ok) {
|
||||
if (repoResponse.status === 404) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Repository not found', isOwner: false },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to verify repository', isOwner: false },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const repoData = await repoResponse.json();
|
||||
|
||||
// Check if the user is the owner
|
||||
const isOwner = repoData.owner?.login?.toLowerCase() === username.toLowerCase();
|
||||
|
||||
return NextResponse.json({
|
||||
isOwner,
|
||||
permission: isOwner ? 'owner' : 'none',
|
||||
username,
|
||||
repoOwner: repoData.owner?.login,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error verifying ownership:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error', isOwner: false },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
91
apps/web/app/api/stats/route.test.ts
Normal file
91
apps/web/app/api/stats/route.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// Mock rate limiting - must be before route import
|
||||
vi.mock('@/lib/rate-limit', () => ({
|
||||
withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }),
|
||||
createRateLimitResponse: vi.fn(),
|
||||
createRateLimitHeaders: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Mock cache - must be before route import
|
||||
vi.mock('@/lib/cache', () => ({
|
||||
getCached: vi.fn().mockResolvedValue(null),
|
||||
setCache: vi.fn().mockResolvedValue(undefined),
|
||||
cacheKeys: { stats: () => 'stats' },
|
||||
cacheTTL: { stats: 3600 },
|
||||
}));
|
||||
|
||||
// Mock the db module - must be before imports that use it
|
||||
vi.mock('@skillhub/db', () => {
|
||||
return {
|
||||
createDb: vi.fn(() => ({
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockResolvedValue([
|
||||
{ totalSkills: 100, totalDownloads: 5000, totalContributors: 50 },
|
||||
]),
|
||||
}),
|
||||
})),
|
||||
skills: { downloadCount: 'download_count', githubOwner: 'github_owner' },
|
||||
categories: {},
|
||||
sql: vi.fn(() => 'mock-sql'),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from './route';
|
||||
|
||||
// Helper to create mock request
|
||||
function createMockRequest(url = 'http://localhost:3000/api/stats') {
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
describe('GET /api/stats', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return totalSkills count', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.totalSkills).toBeDefined();
|
||||
expect(typeof data.totalSkills).toBe('number');
|
||||
});
|
||||
|
||||
it('should return totalDownloads sum', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.totalDownloads).toBeDefined();
|
||||
expect(typeof data.totalDownloads).toBe('number');
|
||||
});
|
||||
|
||||
it('should return totalCategories count', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.totalCategories).toBeDefined();
|
||||
expect(typeof data.totalCategories).toBe('number');
|
||||
});
|
||||
|
||||
it('should return totalContributors count', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.totalContributors).toBeDefined();
|
||||
expect(typeof data.totalContributors).toBe('number');
|
||||
});
|
||||
|
||||
it('should return platforms count', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.platforms).toBe(5);
|
||||
});
|
||||
});
|
||||
79
apps/web/app/api/stats/route.ts
Normal file
79
apps/web/app/api/stats/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { createDb, skills, categories, sql } from '@skillhub/db';
|
||||
import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
|
||||
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
interface StatsData {
|
||||
totalSkills: number;
|
||||
totalDownloads: number;
|
||||
totalCategories: number;
|
||||
totalContributors: number;
|
||||
platforms: number;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Rate limiting
|
||||
const rateLimitResult = await withRateLimit(request, 'anonymous');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get from cache first
|
||||
const cacheKey = cacheKeys.stats();
|
||||
const cached = await getCached<StatsData>(cacheKey);
|
||||
if (cached) {
|
||||
return NextResponse.json(cached, {
|
||||
headers: {
|
||||
'X-Cache': 'HIT',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
|
||||
...createRateLimitHeaders(rateLimitResult),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Consolidate all skill stats into a single query for better performance
|
||||
const statsResult = await db
|
||||
.select({
|
||||
totalSkills: sql<number>`count(*)::int`,
|
||||
totalDownloads: sql<number>`coalesce(sum(${skills.downloadCount}), 0)::int`,
|
||||
totalContributors: sql<number>`count(distinct ${skills.githubOwner})::int`,
|
||||
})
|
||||
.from(skills);
|
||||
|
||||
// Get category count in separate query (different table)
|
||||
const categoryResult = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(categories);
|
||||
|
||||
const stats = statsResult[0];
|
||||
const totalCategories = categoryResult[0]?.count ?? 0;
|
||||
|
||||
const data: StatsData = {
|
||||
totalSkills: stats?.totalSkills ?? 0,
|
||||
totalDownloads: stats?.totalDownloads ?? 0,
|
||||
totalCategories,
|
||||
totalContributors: stats?.totalContributors ?? 0,
|
||||
platforms: 5, // Claude, Codex, Copilot, Cursor, Windsurf
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await setCache(cacheKey, data, cacheTTL.stats);
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: {
|
||||
'X-Cache': 'MISS',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
|
||||
...createRateLimitHeaders(rateLimitResult),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch stats' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user