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:
airano
2026-02-12 06:08:51 +03:30
commit 97b427831a
227 changed files with 48411 additions and 0 deletions

View 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 }
);
}
}

View 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 }
);
}
}

View 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();
});
});

View 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 }
);
}
}

View 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 }
);
}
}

View 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();
});
});

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}