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:
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user