sync: malware page, security alerts, review diagnostics, skill detail improvements

- Add malware security advisory page and SecurityAlertBanner component
- Add review diagnostics API and reviewed stats page
- Improve skill detail page with better scoring display and metadata
- Update review API endpoints with enhanced filtering and stats
- Add skill file serving improvements and cache enhancements
- Remove legacy curation scripts (moved to internal tooling)
- Update CLI search with score filtering support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 16:08:12 +02:00
parent 3e22d2e238
commit 16ee9e3beb
33 changed files with 1231 additions and 3404 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "skillhub",
"version": "0.2.9",
"version": "0.2.11",
"description": "CLI tool for managing AI Agent skills - search, install, and update skills for Claude, Codex, Copilot, and more",
"author": "SkillHub Contributors",
"license": "MIT",

View File

@@ -39,6 +39,14 @@ export async function install(skillId: string, options: InstallOptions): Promise
try {
skillInfo = await getSkill(skillId);
if (skillInfo) {
// Block installation of malicious skills
if (skillInfo.isMalicious) {
spinner.fail(chalk.red('This skill has been flagged as malicious (contains malware).'));
console.log(chalk.red('Installation blocked for your safety.'));
console.log(chalk.dim(`See: https://skills.palebluedot.live/en/skill/${skillId}`));
process.exit(1);
}
const reviewBadge = skillInfo.aiScore && skillInfo.reviewStatus === 'ai-reviewed'
? chalk.dim(` | AI: ${skillInfo.aiScore}/100`)
: skillInfo.reviewStatus === 'verified'

View File

@@ -57,14 +57,15 @@ export async function search(query: string, options: SearchOptions): Promise<voi
`${num} ${verified} ${chalk.cyan(skill.id.padEnd(38))} ${security}`
);
// Second line: AI score (if reviewed) + downloads + stars + description
// Second line: AI score + downloads + stars + description
const aiScore = skill.aiScore;
const hasAiScore = aiScore != null && aiScore > 0 && skill.reviewStatus && skill.reviewStatus !== 'unreviewed' && skill.reviewStatus !== 'auto-scored';
const aiPrefix = hasAiScore
? `${chalk.magenta('AI')} ${(aiScore >= 75 ? chalk.green : aiScore >= 50 ? chalk.yellow : chalk.dim)(String(aiScore))} `
const isAiReviewed = aiScore != null && aiScore > 0 && skill.reviewStatus && skill.reviewStatus !== 'unreviewed' && skill.reviewStatus !== 'auto-scored';
const showAiScore = isAiReviewed;
const aiPrefix = showAiScore
? `${chalk.magenta('AI')} ${(aiScore >= 75 ? chalk.green : aiScore >= 50 ? chalk.yellow : chalk.dim)(String(aiScore).padStart(2))} `
: '';
console.log(
` ${aiPrefix}${formatNumber(skill.downloadCount).padStart(6)}${formatNumber(skill.githubStars).padStart(6)} ${chalk.dim(skill.description.slice(0, hasAiScore ? 45 : 55))}${skill.description.length > (hasAiScore ? 45 : 55) ? '...' : ''}`
` ${aiPrefix}${formatNumber(skill.downloadCount).padStart(6)}${formatNumber(skill.githubStars).padStart(6)} ${chalk.dim(skill.description.slice(0, showAiScore ? 45 : 55))}${skill.description.length > (showAiScore ? 45 : 55) ? '...' : ''}`
);
// Third line: Rating (only if ratingCount >= 3)

View File

@@ -128,6 +128,7 @@ export interface SkillInfo {
ratingCount?: number | null;
reviewStatus?: string | null;
aiScore?: number | null;
isMalicious?: boolean;
isVerified: boolean;
compatibility: {
platforms: string[];

View File

@@ -120,7 +120,7 @@ export default async function CliDocsPage({
<div>
<p className="text-sm text-text-muted mb-1">{tContent('exSearchSort')}</p>
<code className="block text-sm font-mono">
<span className="text-text-muted">$</span> npx skillhub search &quot;code review&quot; --sort stars --limit 5
<span className="text-text-muted">$</span> npx skillhub search &quot;code review&quot; --sort aiScore --limit 5
</code>
<code className="block text-sm font-mono text-text-muted mt-1">
# Sort options: recommended (default), aiScore, downloads, stars, rating, recent

View File

@@ -5,6 +5,7 @@ import { notFound } from 'next/navigation';
import { locales, localeDirection, type Locale } from '@/i18n';
import { Providers } from '../providers';
import { Suspense } from 'react';
import { SecurityAlertBanner } from '@/components/SecurityAlertBanner';
import { QueryNotification } from '@/components/QueryNotification';
import { ProgressBar } from '@/components/ProgressBar';
import '../globals.css';
@@ -68,6 +69,7 @@ export default async function LocaleLayout({
<body className="min-h-screen bg-surface">
<Providers>
<NextIntlClientProvider messages={messages}>
<SecurityAlertBanner />
<Suspense fallback={null}>
<QueryNotification />
</Suspense>

View File

@@ -0,0 +1,149 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { createDb, skillReviewQueries } from '@skillhub/db';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { ShieldAlert, Ban, ExternalLink } from 'lucide-react';
export const dynamic = 'force-dynamic';
async function getMalwareData(page: number, limit: number) {
try {
return await getOrSetCache(cacheKeys.malwarePage(page), cacheTTL.malware, async () => {
const db = createDb();
const offset = (page - 1) * limit;
const [skills, total] = await Promise.all([
skillReviewQueries.getMaliciousSkills(db, limit, offset),
skillReviewQueries.countMaliciousSkills(db),
]);
return { skills, total };
});
} catch (error) {
console.error('Error fetching malware skills:', error);
return { skills: [], total: 0 };
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/malware'),
};
}
export default async function MalwarePage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ page?: string }>;
}) {
const { locale } = await params;
const searchParamsResolved = await searchParams;
setRequestLocale(locale);
const t = await getTranslations('malwarePage');
const tMalicious = await getTranslations('malicious');
const limit = 20;
const page = parseInt(searchParamsResolved.page || '1');
const { skills: malwareSkills } = await getMalwareData(page, limit);
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{/* Header */}
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-error/10 text-error mb-4">
<ShieldAlert className="w-7 h-7" />
</div>
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main max-w-4xl">
{malwareSkills.length === 0 ? (
<p className="text-center text-text-muted py-12">{t('noResults')}</p>
) : (
<div className="space-y-4">
{malwareSkills.map((skill) => (
<div
key={skill.id}
className="card p-5 border-error/20 bg-error/[0.02]"
>
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<Link
href={`/${locale}/skill/${skill.id}`}
className="text-lg font-semibold text-text-primary hover:text-primary-600 transition-colors truncate"
>
{skill.name || skill.id}
</Link>
<span className="flex items-center gap-1 px-1.5 py-0.5 text-xs font-bold rounded border text-error bg-error/10 border-error/20 shrink-0">
<ShieldAlert className="w-3 h-3" />
{tMalicious('badge')}
</span>
</div>
<p className="text-sm text-text-muted mb-2 font-mono">
{skill.githubOwner}/{skill.githubRepo}
</p>
{skill.description && (
<p className="text-sm text-text-secondary line-clamp-2 mb-3">
{skill.description}
</p>
)}
<div className="flex items-center gap-4 text-xs text-text-muted flex-wrap">
<span className="flex items-center gap-1">
<Ban className="w-3.5 h-3.5 text-error" />
{t('downloadBlocked')}
</span>
<span className="flex items-center gap-1">
<Ban className="w-3.5 h-3.5 text-error" />
{t('installBlocked')}
</span>
</div>
</div>
<a
href={`https://github.com/${skill.githubOwner}/${skill.githubRepo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-text-muted hover:text-text-primary transition-colors shrink-0"
>
GitHub <ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
))}
</div>
)}
{/* Back to stats link */}
<div className="text-center mt-8">
<Link
href={`/${locale}/reviewed/stats`}
className="btn-secondary"
>
{locale === 'fa' ? 'بازگشت به آمار بررسی‌ها' : 'Back to Review Statistics'}
</Link>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -8,7 +8,8 @@ import { SkillCard } from '@/components/SkillCard';
import { ReviewedSortSelector } from '@/components/ReviewedSortSelector';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { Sparkles } from 'lucide-react';
import { Sparkles, BarChart3 } from 'lucide-react';
import Link from 'next/link';
// Force dynamic rendering to fetch fresh data from database
@@ -95,9 +96,16 @@ export default async function ReviewedPage({
</div>
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto mb-6">{t('subtitle')}</p>
<p className="text-text-secondary text-sm max-w-3xl mx-auto leading-relaxed">
<p className="text-text-secondary text-sm max-w-3xl mx-auto leading-relaxed mb-4">
{t('explanation')}
</p>
<Link
href={`/${locale}/reviewed/stats`}
className="inline-flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
<BarChart3 className="w-4 h-4" />
{locale === 'fa' ? 'مشاهده آمار بررسی‌ها' : 'View Review Statistics'}
</Link>
</div>
</section>

View File

@@ -0,0 +1,224 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { createDb, skillReviewQueries } from '@skillhub/db';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { BarChart3, ShieldAlert, AlertTriangle, CheckCircle } from 'lucide-react';
export const dynamic = 'force-dynamic';
interface StatsData {
pipeline: Record<string, number>;
totalReviews: number;
scoreDistribution: Record<string, number>;
securityStats: Record<string, number>;
malwareCount: number;
}
async function getPublicReviewStats(): Promise<StatsData | null> {
try {
return await getOrSetCache(cacheKeys.reviewStatsPublic(), cacheTTL.reviewStatsPublic, async () => {
const db = createDb();
const [pipeline, totalReviews, scoreDistribution, securityStats, malwareCount] = await Promise.all([
skillReviewQueries.getPublicPipelineStats(db),
skillReviewQueries.countTotalReviews(db),
skillReviewQueries.getScoreDistribution(db),
skillReviewQueries.getSecurityStats(db),
skillReviewQueries.countMaliciousSkills(db),
]);
return { pipeline, totalReviews, scoreDistribution, securityStats, malwareCount };
});
} catch (error) {
console.error('Error fetching review stats:', error);
return null;
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/reviewed/stats'),
};
}
function StatCard({
icon: Icon,
label,
value,
color,
href,
}: {
icon: React.ElementType;
label: string;
value: number;
color: string;
href?: string;
}) {
const content = (
<div className={`flex items-center gap-3 p-4 rounded-xl border border-border bg-surface ${href ? 'hover:border-primary-300 transition-colors' : ''}`}>
<div className={`flex items-center justify-center w-10 h-10 rounded-lg ${color}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className="text-2xl font-bold text-text-primary ltr-nums">{value.toLocaleString()}</p>
<p className="text-sm text-text-secondary">{label}</p>
</div>
</div>
);
if (href) {
return <Link href={href}>{content}</Link>;
}
return content;
}
function BarRow({
label,
value,
total,
color,
}: {
label: string;
value: number;
total: number;
color: string;
}) {
const pct = total > 0 ? (value / total) * 100 : 0;
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-text-secondary">{label}</span>
<span className="font-medium text-text-primary ltr-nums">
{value.toLocaleString()} <span className="text-text-muted">({pct.toFixed(1)}%)</span>
</span>
</div>
<div className="h-3 rounded-full bg-surface-subtle overflow-hidden">
<div
className={`h-full rounded-full ${color} transition-all duration-500`}
style={{ width: `${Math.max(pct, 0.5)}%` }}
/>
</div>
</div>
);
}
export default async function ReviewStatsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('reviewStats');
const data = await getPublicReviewStats();
if (!data) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 flex items-center justify-center">
<p className="text-text-muted">Failed to load stats.</p>
</main>
<Footer />
</div>
);
}
// Total = all browse-ready SKILL.md skills (for meaningful percentages)
const pipelineTotal = Object.values(data.pipeline).reduce((sum, n) => sum + n, 0);
const scoreTotal =
data.scoreDistribution.high +
data.scoreDistribution.mid +
data.scoreDistribution.low;
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{/* Header Section */}
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-primary-50 text-primary-600 mb-4">
<BarChart3 className="w-7 h-7" />
</div>
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main max-w-4xl">
{/* Review Pipeline */}
<div className="card p-6 mb-8">
<h2 className="text-xl font-semibold text-text-primary mb-1">{t('pipelineTitle')}</h2>
<p className="text-sm text-text-muted mb-6">{t('pipelineDescription')}</p>
<div className="space-y-4">
<BarRow label={t('aiReviewed')} value={data.pipeline['ai-reviewed'] ?? 0} total={pipelineTotal} color="bg-primary-500" />
<BarRow label={t('needsReReview')} value={data.pipeline['needs-re-review'] ?? 0} total={pipelineTotal} color="bg-warning" />
</div>
<div className="mt-6 pt-4 border-t border-border flex items-center justify-between text-sm">
<span className="text-text-secondary">{t('totalReviews')}</span>
<span className="font-semibold text-text-primary ltr-nums">{data.totalReviews.toLocaleString()}</span>
</div>
</div>
{/* Score Distribution */}
<div className="card p-6 mb-8">
<h2 className="text-xl font-semibold text-text-primary mb-1">{t('scoreTitle')}</h2>
<p className="text-sm text-text-muted mb-6">{t('scoreDescription')}</p>
<div className="space-y-4">
<BarRow label={t('scoreHigh')} value={data.scoreDistribution.high} total={scoreTotal} color="bg-success" />
<BarRow label={t('scoreMid')} value={data.scoreDistribution.mid} total={scoreTotal} color="bg-gold" />
<BarRow label={t('scoreLow')} value={data.scoreDistribution.low} total={scoreTotal} color="bg-error" />
</div>
</div>
{/* Security & Malware */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Security Scan */}
<div className="card p-6">
<h2 className="text-xl font-semibold text-text-primary mb-1">{t('securityTitle')}</h2>
<p className="text-sm text-text-muted mb-6">{t('securityDescription')}</p>
<div className="space-y-3">
<StatCard icon={CheckCircle} label={t('securityPass')} value={data.securityStats.pass} color="bg-success/10 text-success" />
<StatCard icon={AlertTriangle} label={t('securityWarning')} value={data.securityStats.warning} color="bg-warning/10 text-warning" />
<StatCard icon={ShieldAlert} label={t('securityFail')} value={data.securityStats.fail} color="bg-error/10 text-error" />
</div>
</div>
{/* Malware Detection */}
<div className="card p-6">
<h2 className="text-xl font-semibold text-text-primary mb-1">{t('malwareTitle')}</h2>
<p className="text-sm text-text-muted mb-6">{t('malwareDescription')}</p>
<StatCard
icon={ShieldAlert}
label={t('malwareFlagged')}
value={data.malwareCount}
color="bg-error/10 text-error"
href={`/${locale}/malware`}
/>
{data.malwareCount > 0 && (
<Link
href={`/${locale}/malware`}
className="inline-flex items-center gap-1.5 mt-4 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
{t('malwareViewAll')}
</Link>
)}
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { notFound } from 'next/navigation';
import { headers } from 'next/headers';
import {
Star, Download, Shield, CheckCircle, Copy,
ExternalLink, Github, Calendar, User, Tag, ChevronRight, Eye, Sparkles
ExternalLink, Github, Calendar, User, Tag, ChevronRight, Eye, Sparkles, XCircle, ShieldAlert
} from 'lucide-react';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
@@ -113,6 +113,8 @@ export default async function SkillPage({ params }: SkillPageProps) {
notFound();
}
const isMalicious = dbSkill.isMalicious ?? false;
// Track view count with IP-based rate limiting (1 hour cooldown per IP)
// Get client IP from headers (works with Cloudflare, nginx, etc.)
const headersList = await headers();
@@ -159,7 +161,13 @@ export default async function SkillPage({ params }: SkillPageProps) {
// Parse review notes for structured display
const parsedNotes = review?.reviewNotes ? parseReviewNotes(review.reviewNotes) : null;
const hasReview = review && review.aiScore && (dbSkill.reviewStatus === 'ai-reviewed' || dbSkill.reviewStatus === 'verified');
const hasReview = review && review.aiScore != null && (dbSkill.reviewStatus === 'ai-reviewed' || dbSkill.reviewStatus === 'verified');
const isRejected = hasReview && review.aiScore === 0;
// Review is outdated only if both hashes are non-empty and differ
const reviewOutdated = hasReview
&& review.contentHashAtReview && review.contentHashAtReview.length > 1
&& dbSkill.contentHash && dbSkill.contentHash.length > 1
&& dbSkill.contentHash !== review.contentHashAtReview;
// Content section title based on source format (uses FORMAT_LABELS from skillhub-core)
const getContentTitle = (format: string) => {
@@ -358,12 +366,23 @@ export default async function SkillPage({ params }: SkillPageProps) {
{/* Compact AI Review Score */}
{hasReview && (
<div className="flex items-center gap-3 px-4 py-3 bg-surface-elevated rounded-xl border border-border">
<Sparkles className={`w-5 h-5 ${review.aiScore! >= 75 ? 'text-success' : 'text-gold'}`} />
<span className={`text-2xl font-bold ltr-nums ${review.aiScore! >= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}>
{review.aiScore}
</span>
<span className="text-sm text-text-muted">{t('review.outOf100')}</span>
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl border ${isRejected ? 'bg-error/5 border-error/20' : 'bg-surface-elevated border-border'}`}>
{isRejected ? (
<>
<XCircle className="w-5 h-5 text-error" />
<span className="text-lg font-bold text-error">
{isRTL ? 'رد شده' : 'Rejected'}
</span>
</>
) : (
<>
<Sparkles className={`w-5 h-5 ${review.aiScore! >= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-muted'}`} />
<span className={`text-2xl font-bold ltr-nums ${review.aiScore! >= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}>
{review.aiScore}
</span>
<span className="text-sm text-text-muted">{t('review.outOf100')}</span>
</>
)}
</div>
)}
</div>
@@ -396,6 +415,25 @@ export default async function SkillPage({ params }: SkillPageProps) {
</div>
)}
{/* Malicious Skill Warning Banner */}
{isMalicious && (
<div className="bg-error/10 border-y-2 border-error px-4 py-6">
<div className="container-main flex items-start gap-4">
<ShieldAlert className="w-8 h-8 text-error flex-shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-bold text-error mb-1" dir="auto">
{isRTL ? 'بدافزار شناسایی شد' : 'Malware Detected'}
</h2>
<p className="text-text-secondary text-sm" dir="auto">
{isRTL
? 'این مهارت به عنوان مخرب شناسایی شده است. شامل کد مبهم‌سازی شده برای دانلود و اجرای بارهای مضر است. دانلود فایل و نصب مسدود شده است.'
: 'This skill has been flagged as malicious. It contains obfuscated code designed to download and execute harmful payloads. File downloads and installation are blocked.'}
</p>
</div>
</div>
</div>
)}
{/* Stale Skill Warning Banner */}
{dbSkill.isStale && (
<div className="bg-warning/10 border-b border-warning/20">
@@ -470,56 +508,87 @@ export default async function SkillPage({ params }: SkillPageProps) {
<div className="lg:col-span-2 space-y-6">
{/* Quick Install (Mobile) */}
<div className="lg:hidden">
<InstallSection
skillId={skill.id}
skillName={skill.name}
repositoryUrl={skill.repository}
sourceFormat={skill.sourceFormat}
installCommands={installCommands}
translations={{
title: t('install.title'),
cli: t('install.cli'),
cliGlobal: t('install.cliGlobal') || 'Install globally (user-level):',
cliProject: t('install.cliProject') || 'Install in current project:',
selectFolder: t('install.selectFolder'),
suggestedPath: t('install.suggestedPath'),
copied: t('install.copied'),
downloadZip: t('install.downloadZip') || 'Download ZIP',
copyCommand: t('install.copyCommand') || 'Copy command',
downloading: t('install.downloading') || 'Downloading...',
installing: t('install.installing') || 'Installing...',
installed: t('install.installed') || 'Installed!',
downloadFailed: t('install.downloadFailed') || 'Download failed',
browserNotSupported: t('install.browserNotSupported') || 'Browser not supported',
rateLimitError: t('install.rateLimitError') || 'Rate limit exceeded',
timeoutError: t('install.timeoutError') || 'Request timed out',
notFoundError: t('install.notFoundError') || 'Skill not found',
noFilesError: t('install.noFilesError') || 'No files found',
disclaimer: t('install.disclaimer'),
folderNotePrefix: t('install.folderNotePrefix') || 'A folder named "',
folderNoteSuffix: t('install.folderNoteSuffix') || '" will be created',
}}
/>
{isMalicious ? (
<div className="bg-error/5 border border-error/20 rounded-lg p-4 text-center">
<ShieldAlert className="w-6 h-6 text-error mx-auto mb-2" />
<p className="text-sm text-error font-medium" dir="auto">
{isRTL ? 'نصب مسدود شده — این مهارت حاوی بدافزار است' : 'Installation blocked — this skill contains malware'}
</p>
</div>
) : (
<InstallSection
skillId={skill.id}
skillName={skill.name}
repositoryUrl={skill.repository}
sourceFormat={skill.sourceFormat}
installCommands={installCommands}
translations={{
title: t('install.title'),
cli: t('install.cli'),
cliGlobal: t('install.cliGlobal') || 'Install globally (user-level):',
cliProject: t('install.cliProject') || 'Install in current project:',
selectFolder: t('install.selectFolder'),
suggestedPath: t('install.suggestedPath'),
copied: t('install.copied'),
downloadZip: t('install.downloadZip') || 'Download ZIP',
copyCommand: t('install.copyCommand') || 'Copy command',
downloading: t('install.downloading') || 'Downloading...',
installing: t('install.installing') || 'Installing...',
installed: t('install.installed') || 'Installed!',
downloadFailed: t('install.downloadFailed') || 'Download failed',
browserNotSupported: t('install.browserNotSupported') || 'Browser not supported',
rateLimitError: t('install.rateLimitError') || 'Rate limit exceeded',
timeoutError: t('install.timeoutError') || 'Request timed out',
notFoundError: t('install.notFoundError') || 'Skill not found',
noFilesError: t('install.noFilesError') || 'No files found',
disclaimer: t('install.disclaimer'),
folderNotePrefix: t('install.folderNotePrefix') || 'A folder named "',
folderNoteSuffix: t('install.folderNoteSuffix') || '" will be created',
}}
/>
)}
</div>
{/* AI Review Card (Mobile) */}
{hasReview && (
<div className="lg:hidden bg-surface-elevated rounded-2xl border border-border p-6">
<div className={`lg:hidden rounded-2xl border p-6 ${isRejected ? 'bg-error/5 border-error/20' : 'bg-surface-elevated border-border'}`}>
<h3 className="font-semibold text-text-primary mb-4 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary-500" />
{isRejected ? <XCircle className="w-4 h-4 text-error" /> : <Sparkles className="w-4 h-4 text-primary-500" />}
{t('review.title')}
</h3>
<div className="space-y-4">
<div className="space-y-3">
<ScoreBar label={t('review.instructionQuality')} score={review.instructionQuality} />
<ScoreBar label={t('review.descriptionPrecision')} score={review.descriptionPrecision} />
<ScoreBar label={t('review.usefulness')} score={review.usefulness} />
<ScoreBar label={t('review.technicalSoundness')} score={review.technicalSoundness} />
{isRejected ? (
<div className="space-y-4">
<div className="text-center">
<div className="text-xl font-bold text-error">
{isRTL ? 'رد شده' : 'Rejected'}
</div>
<div className="text-sm text-text-muted">
{isRTL ? 'این مهارت معیارهای کیفیت را ندارد' : 'Does not meet quality standards'}
</div>
</div>
{parsedNotes?.rationale && (
<p className="text-sm text-text-secondary pt-3 border-t border-error/20" dir="auto">{parsedNotes.rationale}</p>
)}
</div>
{parsedNotes?.rationale && (
<p className="text-sm text-text-secondary pt-3 border-t border-border" dir="auto">{parsedNotes.rationale}</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="space-y-3">
<ScoreBar label={t('review.instructionQuality')} score={review.instructionQuality} />
<ScoreBar label={t('review.descriptionPrecision')} score={review.descriptionPrecision} />
<ScoreBar label={t('review.usefulness')} score={review.usefulness} />
<ScoreBar label={t('review.technicalSoundness')} score={review.technicalSoundness} />
</div>
{parsedNotes?.rationale && (
<p className="text-sm text-text-secondary pt-3 border-t border-border" dir="auto">{parsedNotes.rationale}</p>
)}
{reviewOutdated && (
<p className="text-xs text-warning flex items-center gap-1.5 pt-3 border-t border-border">
<span></span>
{isRTL ? 'بررسی بر اساس نسخه قبلی' : 'Review based on previous version'}
</p>
)}
</div>
)}
</div>
)}
@@ -547,61 +616,84 @@ export default async function SkillPage({ params }: SkillPageProps) {
<div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto space-y-6 scrollbar-thin">
{/* AI Review Card (Desktop) — above Install for visibility */}
{hasReview && (
<div className="bg-surface-elevated rounded-2xl border border-border p-6">
<div className={`rounded-2xl border p-6 ${isRejected ? 'bg-error/5 border-error/20' : 'bg-surface-elevated border-border'}`}>
<h3 className="font-semibold text-text-primary mb-4 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary-500" />
{isRejected ? <XCircle className="w-4 h-4 text-error" /> : <Sparkles className="w-4 h-4 text-primary-500" />}
{t('review.title')}
</h3>
{/* Overall Score */}
<div className="text-center mb-4">
<div className={`text-4xl font-bold ${review.aiScore! >= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}>
{review.aiScore}
</div>
<div className="text-sm text-text-muted">{t('review.outOf100')}</div>
</div>
{/* 4-axis score bars */}
<div className="space-y-3">
<ScoreBar label={t('review.instructionQuality')} score={review.instructionQuality} />
<ScoreBar label={t('review.descriptionPrecision')} score={review.descriptionPrecision} />
<ScoreBar label={t('review.usefulness')} score={review.usefulness} />
<ScoreBar label={t('review.technicalSoundness')} score={review.technicalSoundness} />
</div>
{/* Rationale */}
{parsedNotes?.rationale && (
<div className="mt-4 pt-4 border-t border-border">
<p className="text-sm text-text-secondary" dir="auto">{parsedNotes.rationale}</p>
</div>
)}
{/* Tags: Audience, Maturity, Complexity, Use Cases */}
{(parsedNotes?.maturity || parsedNotes?.complexity || (parsedNotes?.audience && parsedNotes.audience.length > 0) || (parsedNotes?.useCases && parsedNotes.useCases.length > 0)) && (
<div className="mt-4 pt-4 border-t border-border">
<div className="flex flex-wrap gap-1.5">
{parsedNotes?.maturity && (
<span className="px-2 py-0.5 text-xs rounded bg-surface-subtle text-text-muted border border-border">
{parsedNotes.maturity}
</span>
)}
{parsedNotes?.complexity && (
<span className="px-2 py-0.5 text-xs rounded bg-surface-subtle text-text-muted border border-border">
{parsedNotes.complexity}
</span>
)}
{parsedNotes?.audience?.map((a: string) => (
<span key={a} className="px-2 py-0.5 text-xs rounded bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400">
{a.trim()}
</span>
))}
{parsedNotes?.useCases?.map((uc: string) => (
<span key={uc} className="px-2 py-0.5 text-xs rounded bg-success/10 text-success">
{uc.trim()}
</span>
))}
{isRejected ? (
<>
{/* Rejected status */}
<div className="text-center mb-4">
<div className="text-2xl font-bold text-error">
{isRTL ? 'رد شده' : 'Rejected'}
</div>
<div className="text-sm text-text-muted">
{isRTL ? 'این مهارت معیارهای کیفیت را ندارد' : 'Does not meet quality standards'}
</div>
</div>
</div>
{/* Rationale for rejection */}
{parsedNotes?.rationale && (
<div className="pt-4 border-t border-error/20">
<p className="text-sm text-text-secondary" dir="auto">{parsedNotes.rationale}</p>
</div>
)}
</>
) : (
<>
{/* Overall Score */}
<div className="text-center mb-4">
<div className={`text-4xl font-bold ${review.aiScore! >= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}>
{review.aiScore}
</div>
<div className="text-sm text-text-muted">{t('review.outOf100')}</div>
</div>
{/* 4-axis score bars */}
<div className="space-y-3">
<ScoreBar label={t('review.instructionQuality')} score={review.instructionQuality} />
<ScoreBar label={t('review.descriptionPrecision')} score={review.descriptionPrecision} />
<ScoreBar label={t('review.usefulness')} score={review.usefulness} />
<ScoreBar label={t('review.technicalSoundness')} score={review.technicalSoundness} />
</div>
{/* Rationale */}
{parsedNotes?.rationale && (
<div className="mt-4 pt-4 border-t border-border">
<p className="text-sm text-text-secondary" dir="auto">{parsedNotes.rationale}</p>
</div>
)}
{/* Tags: Audience, Maturity, Complexity, Use Cases */}
{(parsedNotes?.maturity || parsedNotes?.complexity || (parsedNotes?.audience && parsedNotes.audience.length > 0) || (parsedNotes?.useCases && parsedNotes.useCases.length > 0)) && (
<div className="mt-4 pt-4 border-t border-border">
<div className="flex flex-wrap gap-1.5">
{parsedNotes?.maturity && (
<span className="px-2 py-0.5 text-xs rounded bg-surface-subtle text-text-muted border border-border">
{parsedNotes.maturity}
</span>
)}
{parsedNotes?.complexity && (
<span className="px-2 py-0.5 text-xs rounded bg-surface-subtle text-text-muted border border-border">
{parsedNotes.complexity}
</span>
)}
{parsedNotes?.audience?.map((a: string) => (
<span key={a} className="px-2 py-0.5 text-xs rounded bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400">
{a.trim()}
</span>
))}
{parsedNotes?.useCases?.map((uc: string) => (
<span key={uc} className="px-2 py-0.5 text-xs rounded bg-success/10 text-success">
{uc.trim()}
</span>
))}
</div>
</div>
)}
</>
)}
{/* Reviewer info */}
@@ -611,40 +703,59 @@ export default async function SkillPage({ params }: SkillPageProps) {
<> {t('review.reviewedOn', { date: new Date(review.reviewedAt).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US') })}</>
)}
</div>
{/* Outdated review warning */}
{!isRejected && reviewOutdated && (
<div className="mt-3 pt-3 border-t border-border">
<p className="text-xs text-warning flex items-center gap-1.5">
<span></span>
{isRTL ? 'بررسی بر اساس نسخه قبلی' : 'Review based on previous version'}
</p>
</div>
)}
</div>
)}
{/* Install Section */}
<InstallSection
skillId={skill.id}
skillName={skill.name}
repositoryUrl={skill.repository}
sourceFormat={skill.sourceFormat}
installCommands={installCommands}
translations={{
title: t('install.title'),
cli: t('install.cli'),
cliGlobal: t('install.cliGlobal') || 'Install globally (user-level):',
cliProject: t('install.cliProject') || 'Install in current project:',
selectFolder: t('install.selectFolder'),
suggestedPath: t('install.suggestedPath'),
copied: t('install.copied'),
downloadZip: t('install.downloadZip') || 'Download ZIP',
copyCommand: t('install.copyCommand') || 'Copy command',
downloading: t('install.downloading') || 'Downloading...',
installing: t('install.installing') || 'Installing...',
installed: t('install.installed') || 'Installed!',
downloadFailed: t('install.downloadFailed') || 'Download failed',
browserNotSupported: t('install.browserNotSupported') || 'Browser not supported',
rateLimitError: t('install.rateLimitError') || 'Rate limit exceeded',
timeoutError: t('install.timeoutError') || 'Request timed out',
notFoundError: t('install.notFoundError') || 'Skill not found',
noFilesError: t('install.noFilesError') || 'No files found',
disclaimer: t('install.disclaimer'),
folderNotePrefix: t('install.folderNotePrefix') || 'A folder named "',
folderNoteSuffix: t('install.folderNoteSuffix') || '" will be created',
}}
/>
{isMalicious ? (
<div className="bg-error/5 border border-error/20 rounded-lg p-4 text-center">
<ShieldAlert className="w-6 h-6 text-error mx-auto mb-2" />
<p className="text-sm text-error font-medium" dir="auto">
{isRTL ? 'نصب مسدود شده — این مهارت حاوی بدافزار است' : 'Installation blocked — this skill contains malware'}
</p>
</div>
) : (
<InstallSection
skillId={skill.id}
skillName={skill.name}
repositoryUrl={skill.repository}
sourceFormat={skill.sourceFormat}
installCommands={installCommands}
translations={{
title: t('install.title'),
cli: t('install.cli'),
cliGlobal: t('install.cliGlobal') || 'Install globally (user-level):',
cliProject: t('install.cliProject') || 'Install in current project:',
selectFolder: t('install.selectFolder'),
suggestedPath: t('install.suggestedPath'),
copied: t('install.copied'),
downloadZip: t('install.downloadZip') || 'Download ZIP',
copyCommand: t('install.copyCommand') || 'Copy command',
downloading: t('install.downloading') || 'Downloading...',
installing: t('install.installing') || 'Installing...',
installed: t('install.installed') || 'Installed!',
downloadFailed: t('install.downloadFailed') || 'Download failed',
browserNotSupported: t('install.browserNotSupported') || 'Browser not supported',
rateLimitError: t('install.rateLimitError') || 'Rate limit exceeded',
timeoutError: t('install.timeoutError') || 'Request timed out',
notFoundError: t('install.notFoundError') || 'Skill not found',
noFilesError: t('install.noFilesError') || 'No files found',
disclaimer: t('install.disclaimer'),
folderNotePrefix: t('install.folderNotePrefix') || 'A folder named "',
folderNoteSuffix: t('install.folderNoteSuffix') || '" will be created',
}}
/>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,106 @@
import { type NextRequest, NextResponse } from 'next/server';
import { createDb, skillQueries, sql } from '@skillhub/db';
import { requireAdmin } from '@/lib/admin-auth';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
/**
* GET /api/review/diagnose?id=owner/repo/skill-name
* Returns which review pipeline filters a skill passes/fails.
* Calls the actual PostgreSQL raw_content_passes_prefilter function to detect
* discrepancies between JS approximation and SQL reality (e.g. invalid UTF-8).
* Admin-only endpoint for debugging why skills don't appear in pending list.
*/
export async function GET(request: NextRequest) {
const rateLimitResult = await withRateLimit(request, 'anonymous');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
const adminCheck = await requireAdmin(request);
if (!adminCheck.authorized) {
return adminCheck.response;
}
try {
const { searchParams } = new URL(request.url);
const skillId = searchParams.get('id');
if (!skillId) {
return NextResponse.json({ error: 'Missing id parameter' }, { status: 400 });
}
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return NextResponse.json({ error: 'Skill not found', id: skillId }, { status: 404 });
}
// Call the ACTUAL PostgreSQL function to check prefilter
// This catches UTF-8 issues that the JS approximation misses
let sqlPrefilterPass = false;
try {
const result = await db.execute(
sql`SELECT raw_content_passes_prefilter(raw_content) AS passes FROM skills WHERE id = ${skillId}`
);
const row = [...result][0] as { passes?: boolean } | undefined;
sqlPrefilterPass = row?.passes === true;
} catch {
sqlPrefilterPass = false;
}
// JS approximation for comparison
const rawContent = skill.rawContent ?? '';
const contentLength = Buffer.byteLength(rawContent, 'utf8');
const hasGeneratedComment = rawContent.includes('<!-- generated');
const hasUserPath = rawContent.substring(0, 1000).includes('/Users/') ||
rawContent.substring(0, 1000).includes('C:\\Users\\');
const jsPrefilterPass = contentLength >= 200 && !hasGeneratedComment && !hasUserPath;
const filters = {
// browseReadyFilter conditions
isDuplicate: { value: skill.isDuplicate, pass: !skill.isDuplicate || skill.isOwnerClaimed },
isStale: { value: skill.isStale, pass: !skill.isStale },
isMalicious: { value: skill.isMalicious, pass: !skill.isMalicious },
// Other conditions
isBlocked: { value: skill.isBlocked, pass: !skill.isBlocked },
sourceFormat: { value: skill.sourceFormat, pass: skill.sourceFormat === 'skill.md' },
isDeprecated: { value: skill.isDeprecated, pass: !skill.isDeprecated },
securityStatus: { value: skill.securityStatus, pass: skill.securityStatus === 'pass' },
qualityScore: { value: skill.qualityScore, pass: (skill.qualityScore ?? 0) >= 50 },
reviewStatus: { value: skill.reviewStatus, pass: skill.reviewStatus === 'auto-scored' },
// Prefilter: actual PostgreSQL function result
sqlPrefilter: { value: sqlPrefilterPass, pass: sqlPrefilterPass },
// JS approximation breakdown (for debugging discrepancies)
jsPrefilter: { value: jsPrefilterPass, pass: jsPrefilterPass },
contentLength: { value: contentLength, pass: contentLength >= 200 },
hasGeneratedComment: { value: hasGeneratedComment, pass: !hasGeneratedComment },
hasUserPath: { value: hasUserPath, pass: !hasUserPath },
};
// Use sqlPrefilter as the real filter (not JS approximation)
const failedFilters = Object.entries(filters)
.filter(([key, f]) => !f.pass && key !== 'jsPrefilter' && key !== 'contentLength' && key !== 'hasGeneratedComment' && key !== 'hasUserPath')
.map(([name]) => name);
return NextResponse.json(
{
id: skill.id,
name: skill.name,
downloadCount: skill.downloadCount,
wouldAppearInPending: failedFilters.length === 0,
failedFilters,
filters,
// Flag discrepancy between JS and SQL prefilter
...(jsPrefilterPass !== sqlPrefilterPass ? { prefilterDiscrepancy: true } : {}),
},
{ headers: createRateLimitHeaders(rateLimitResult) }
);
} catch (error) {
console.error('[Review] Diagnose error:', error);
return NextResponse.json({ error: 'Failed to diagnose skill' }, { status: 500 });
}
}

View File

@@ -13,12 +13,16 @@ const db = createDb();
* Supports owner-capped batches for diversity and hybrid re-review/new-review mixing.
*
* Query params:
* batch_size - number of skills to return (default 20, max 50)
* offset - number of skills to skip for pagination (default 0)
* min_quality - minimum quality_score (default 50)
* security - security_status filter (default "pass")
* priority - "re-review" to show needs-re-review first, "re-review-all" to include already ai-reviewed skills
* owner_limit - max skills per github_owner in batch (default 0=unlimited, max 10)
* batch_size - number of skills to return (default 20, max 50)
* offset - number of skills to skip for pagination (default 0)
* min_quality - minimum quality_score (default 50)
* security - security_status filter (default "pass")
* priority - "re-review" to show needs-re-review first, "re-review-all" to include already ai-reviewed skills
* owner_limit - max skills per github_owner in batch (default 0=unlimited, max 10)
* sort_by - sort order: "quality" (default), "stars", "downloads"
* min_ai_score - minimum latestAiScore filter (for targeted re-review)
* max_ai_score - maximum latestAiScore filter (for targeted re-review)
* reviewed_before - ISO date string, only include skills reviewed before this date
*/
export async function GET(request: NextRequest) {
// Rate limiting
@@ -52,6 +56,14 @@ export async function GET(request: NextRequest) {
const currentReviewVersion = Math.max(
parseInt(searchParams.get('review_version') ?? '0', 10) || 0, 0
);
const sortBy = (['quality', 'stars', 'downloads'] as const).includes(
searchParams.get('sort_by') as 'quality' | 'stars' | 'downloads'
) ? (searchParams.get('sort_by') as 'quality' | 'stars' | 'downloads') : 'quality';
const minAiScoreParam = searchParams.get('min_ai_score');
const minAiScore = minAiScoreParam ? parseInt(minAiScoreParam, 10) : undefined;
const maxAiScoreParam = searchParams.get('max_ai_score');
const maxAiScore = maxAiScoreParam ? parseInt(maxAiScoreParam, 10) : undefined;
const reviewedBefore = searchParams.get('reviewed_before') || undefined;
// Run counts in parallel
const [totalPending, reReviews] = await Promise.all([
@@ -72,6 +84,10 @@ export async function GET(request: NextRequest) {
reReviewAll: true,
ownerLimit,
currentReviewVersion,
sortBy,
minAiScore,
maxAiScore,
reviewedBefore,
}) as typeof batch;
batch = [...allBatch].slice(0, batchSize);
} else {
@@ -86,6 +102,7 @@ export async function GET(request: NextRequest) {
securityPass,
priorityReReview: true,
ownerLimit,
sortBy,
});
batch = [...reReviewBatch] as typeof batch;
}
@@ -102,6 +119,7 @@ export async function GET(request: NextRequest) {
securityPass,
priorityReReview,
ownerLimit,
sortBy,
}) as typeof batch;
if (priorityReReview) {

View File

@@ -7,10 +7,8 @@ import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
const db = createDb();
interface ReviewStatsData {
unreviewed: number;
auto_scored: number;
total_skills: number;
ai_reviewed: number;
verified: number;
needs_re_review: number;
total_reviews: number;
}
@@ -45,17 +43,18 @@ export async function GET(request: NextRequest) {
});
}
// Run stats and total reviews count in parallel
// Run pipeline stats and total reviews count in parallel
const [statusCounts, totalReviews] = await Promise.all([
skillReviewQueries.getStats(db),
skillReviewQueries.getPublicPipelineStats(db),
skillReviewQueries.countTotalReviews(db),
]);
// total_skills = sum of all statuses from pipeline query (browse-ready SKILL.md)
const totalSkills = Object.values(statusCounts).reduce((sum, n) => sum + n, 0);
const data: ReviewStatsData = {
unreviewed: statusCounts['unreviewed'] ?? 0,
auto_scored: statusCounts['auto-scored'] ?? 0,
total_skills: totalSkills,
ai_reviewed: statusCounts['ai-reviewed'] ?? 0,
verified: statusCounts['verified'] ?? 0,
needs_re_review: statusCounts['needs-re-review'] ?? 0,
total_reviews: totalReviews,
};

View File

@@ -1,5 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server';
import { createDb, skillReviewQueries } from '@skillhub/db';
import { createDb, skillQueries, skillReviewQueries } from '@skillhub/db';
import { requireAdmin } from '@/lib/admin-auth';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
@@ -24,6 +24,7 @@ interface ReviewItem {
set_verified?: boolean;
review_version?: number;
reviewer?: string;
recommendation?: 'flag-malicious' | null;
}
function validateReviews(body: unknown): { reviews: ReviewItem[] } | { error: string } {
@@ -74,6 +75,12 @@ function validateReviews(body: unknown): { reviews: ReviewItem[] } | { error: st
return { error: `reviews[${i}].reviewer must be a non-empty string (max 50 chars)` };
}
}
// Validate recommendation
if (item.recommendation !== undefined && item.recommendation !== null) {
if (item.recommendation !== 'flag-malicious') {
return { error: `reviews[${i}].recommendation must be 'flag-malicious' or null` };
}
}
}
return { reviews: reviews as ReviewItem[] };
@@ -134,6 +141,7 @@ export async function POST(request: NextRequest) {
i18nPriority: r.i18n_priority,
contentHashAtReview: r.content_hash_at_review,
reviewVersion: r.review_version,
recommendation: r.recommendation ?? undefined,
}));
await skillReviewQueries.createBatch(db, dbReviews);
@@ -147,10 +155,20 @@ export async function POST(request: NextRequest) {
await skillReviewQueries.updateSkillReviewStatus(db, r.skill_id, newStatus, r.ai_score, new Date());
}
// Flag malicious skills
let flaggedCount = 0;
for (const r of reviews) {
if (r.recommendation === 'flag-malicious') {
await skillQueries.flagMalicious(db, r.skill_id);
flaggedCount++;
}
}
return NextResponse.json(
{
submitted: reviews.length,
verified: verifiedCount,
flagged: flaggedCount,
},
{
headers: createRateLimitHeaders(rateLimitResult),

View File

@@ -87,6 +87,14 @@ export async function GET(request: NextRequest) {
);
}
// Block file downloads for malicious skills
if (skill.isMalicious) {
return NextResponse.json(
{ error: 'This skill has been flagged as malicious. File downloads are blocked.', code: 'MALICIOUS' },
{ status: 403 }
);
}
const { githubOwner, githubRepo, skillPath, branch, commitSha, sourceFormat } = skill;
// === CACHE CHECK ===

View File

@@ -84,8 +84,15 @@ export async function GET(
downloadCount: skill.downloadCount,
viewCount: skill.viewCount,
securityScore: skill.securityScore,
securityStatus: skill.securityStatus,
qualityScore: skill.qualityScore,
isVerified: skill.isVerified,
isFeatured: skill.isFeatured,
isMalicious: skill.isMalicious ?? false,
isDuplicate: skill.isDuplicate ?? false,
isStale: skill.isStale ?? false,
isDeprecated: skill.isDeprecated ?? false,
isBlocked: skill.isBlocked ?? false,
compatibility: skill.compatibility,
triggers: skill.triggers,
rawContent: skill.rawContent,

View File

@@ -21,6 +21,7 @@ export function Header() {
{ name: t('home'), href: `/${locale}` },
{ name: t('browse'), href: `/${locale}/browse` },
{ name: t('categories'), href: `/${locale}/categories` },
{ name: t('reviewed'), href: `/${locale}/reviewed` },
{ name: t('docs'), href: `/${locale}/docs` },
];

View File

@@ -0,0 +1,52 @@
'use client';
import { useState, useEffect } from 'react';
import { X, ShieldAlert } from 'lucide-react';
import { useTranslations } from 'next-intl';
const BLOG_POST_URL = 'https://blog.palebluedot.live/2026/03/19/malware-openclaw-skills-security-advisory/';
const STORAGE_KEY = 'security-alert-openclaw-dismissed';
export function SecurityAlertBanner() {
const [isVisible, setIsVisible] = useState(false);
const t = useTranslations('securityAlert');
useEffect(() => {
const dismissed = localStorage.getItem(STORAGE_KEY);
if (!dismissed) {
setIsVisible(true);
}
}, []);
const handleDismiss = () => {
setIsVisible(false);
localStorage.setItem(STORAGE_KEY, 'true');
};
if (!isVisible) return null;
return (
<div className="relative bg-error-bg border-b-2 border-error">
<div className="container-main py-2.5 px-4 flex items-center justify-center gap-2 text-sm">
<ShieldAlert className="w-4 h-4 text-error flex-shrink-0" />
<span className="font-semibold text-error">{t('label')}</span>
<span className="text-text-primary">{t('text')}</span>
<a
href={BLOG_POST_URL}
target="_blank"
rel="noopener noreferrer"
className="text-error underline underline-offset-2 hover:text-error/80 transition-colors font-medium"
>
{t('link')}
</a>
<button
onClick={handleDismiss}
className="absolute end-2 sm:end-4 p-1 hover:bg-error/10 rounded transition-colors"
aria-label={t('dismiss')}
>
<X className="w-4 h-4 text-error" />
</button>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import Link from 'next/link';
import { Star, Download, Shield, CheckCircle, Clock, RefreshCw, Sparkles } from 'lucide-react';
import { Star, Download, Shield, CheckCircle, Clock, RefreshCw, Sparkles, XCircle, ShieldAlert } from 'lucide-react';
import { formatCompactNumber } from '@/lib/format-number';
const FORMAT_BADGE_LABELS: Record<string, string> = {
@@ -27,6 +27,8 @@ interface SkillCardProps {
sourceFormat?: string | null;
createdAt?: Date | string | null;
updatedAt?: Date | string | null;
reviewOutdated?: boolean | null;
isMalicious?: boolean | null;
};
locale: string;
/** Show time badge (for New Skills page) */
@@ -56,16 +58,21 @@ export function SkillCard({ skill, locale, showTimeBadge, formatTimeAgo }: Skill
const showRating = (skill.ratingCount ?? 0) >= 3;
// AI review score badge (only for reviewed skills with score >= 50)
// AI review score badge (for all reviewed skills)
const getAiScoreBadge = () => {
const rs = skill.reviewStatus;
if (!rs || rs === 'unreviewed' || rs === 'auto-scored') return null;
const score = skill.aiScore ?? skill.latestAiScore;
if (!score || score < 50) return null;
if (score == null) return null;
if (score === 0) {
return { score: 0, color: 'text-error bg-error/10 border-error/20', rejected: true };
}
const color = score >= 75
? 'text-success bg-success/10 border-success/20'
: 'text-gold bg-gold/10 border-gold/20';
return { score, color };
: score >= 50
? 'text-gold bg-gold/10 border-gold/20'
: 'text-text-muted bg-surface-subtle border-border';
return { score, color, rejected: false };
};
const aiScoreBadge = getAiScoreBadge();
@@ -117,12 +124,28 @@ export function SkillCard({ skill, locale, showTimeBadge, formatTimeAgo }: Skill
{skill.description}
</p>
{/* Metadata Row: AI Score + Stars + Downloads + Rating */}
{/* Metadata Row: Malware / AI Score + Stars + Downloads + Rating */}
<div className="flex flex-wrap items-center gap-4 text-sm text-text-muted mb-2">
{aiScoreBadge && (
{skill.isMalicious && (
<span className="flex items-center gap-1 px-1.5 py-0.5 text-xs font-bold rounded border text-error bg-error/10 border-error/20">
<ShieldAlert className="w-3 h-3" />
<span>{locale === 'fa' ? 'بدافزار' : 'Malware'}</span>
</span>
)}
{!skill.isMalicious && aiScoreBadge && (
<span className={`flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium rounded border ${aiScoreBadge.color}`}>
<Sparkles className="w-3 h-3" />
<span className="ltr-nums">{aiScoreBadge.score}</span>
{aiScoreBadge.rejected ? (
<>
<XCircle className="w-3 h-3" />
<span>{locale === 'fa' ? 'رد شده' : 'Rejected'}</span>
</>
) : (
<>
<Sparkles className="w-3 h-3" />
<span className="ltr-nums">{aiScoreBadge.score}</span>
{skill.reviewOutdated && <span className="text-warning" title="Review based on previous version"></span>}
</>
)}
</span>
)}
<span className="flex items-center gap-1">

View File

@@ -171,6 +171,8 @@ export const cacheKeys = {
skillReview: (id: string) => `review:skill:${id.replace(/\//g, ':')}`,
reviewStats: () => 'review:stats',
reviewedPage: (sort: string, page: number, minScore = 50) => `page:reviewed:${sort}:s${minScore}:${page}`,
reviewStatsPublic: () => 'page:review-stats:public',
malwarePage: (page: number) => `page:malware:${page}`,
};
// TTL values in seconds
@@ -189,6 +191,8 @@ export const cacheTTL = {
download: 5 * 60, // 5 minutes - same IP can only count as 1 download per 5 min
reviewStats: 60, // 1 minute - admin review stats
reviewed: 60 * 60, // 1 hour - reviewed skills page
reviewStatsPublic: 30 * 60, // 30 minutes - public review stats page
malware: 60 * 60, // 1 hour - malware listing page
};
/**

View File

@@ -31,6 +31,7 @@
"browse": "Browse",
"categories": "Categories",
"docs": "Docs",
"reviewed": "Reviewed",
"about": "About",
"github": "GitHub",
"sourceCode": "Source Code",
@@ -464,7 +465,7 @@
"platformWindsurf": "Windsurf — Windsurf config",
"fullDocsLink": "See the full discovery documentation for detailed prompts, search strategies, and advanced configuration.",
"cmdInstallDesc": "Install a skill (--platform, --project, --force, --no-api)",
"cmdSearchDesc": "Search for skills (--platform, --limit, --page, --sort stars|downloads|rating|recent)",
"cmdSearchDesc": "Search for skills (--platform, --limit, --page, --sort recommended|aiScore|downloads|stars|rating|recent)",
"cmdListDesc": "List installed skills (--platform, --project, --all)",
"cmdUpdateDesc": "Update a skill or use --all to update all",
"cmdUninstallDesc": "Remove an installed skill (--platform, --project)",
@@ -472,7 +473,7 @@
"exInstallGlobal": "Install globally:",
"exInstallProject": "Install in current project:",
"exSearch": "Search for skills:",
"exSearchSort": "Search sorted by stars:",
"exSearchSort": "Search sorted by AI review score:",
"exUpdateAll": "Update all installed skills:",
"platformsTitle": "Supported Platforms",
"platformsDesc": "SkillHub CLI supports 5 AI coding platforms:",
@@ -571,6 +572,44 @@
"all": "All scores"
}
},
"malicious": {
"warningTitle": "Malware Detected",
"warningDescription": "This skill has been flagged as malicious. It contains obfuscated code designed to download and execute harmful payloads. File downloads and installation are blocked.",
"installBlocked": "Installation blocked — this skill contains malware",
"badge": "Malware"
},
"reviewStats": {
"title": "Review Statistics",
"subtitle": "Overview of the AI review pipeline, score distribution, and security status",
"pipelineTitle": "Review Pipeline",
"pipelineDescription": "Status of skills in the review process",
"aiReviewed": "AI Reviewed",
"needsReReview": "Needs Re-review",
"totalReviews": "Total Review Records",
"scoreTitle": "Score Distribution",
"scoreDescription": "Distribution of AI review scores across reviewed skills",
"scoreHigh": "High Quality (75100)",
"scoreMid": "Solid (5074)",
"scoreLow": "Below Average (049)",
"securityTitle": "Security Scan",
"securityDescription": "Automated security scan results for all skills",
"securityPass": "Pass",
"securityWarning": "Warning",
"securityFail": "Fail",
"malwareTitle": "Malware Detection",
"malwareDescription": "Skills flagged as containing malicious code",
"malwareFlagged": "Flagged as Malware",
"malwareViewAll": "View flagged skills",
"skills": "skills"
},
"malwarePage": {
"title": "Flagged Malware Skills",
"subtitle": "These skills have been identified as containing malicious code. Downloads and installation are blocked for your safety.",
"noResults": "No malware-flagged skills found.",
"flaggedOn": "Flagged on",
"downloadBlocked": "Downloads blocked",
"installBlocked": "Installation blocked"
},
"owner": {
"title": "@{username}'s Skills",
"notFound": "Owner not found",
@@ -933,6 +972,12 @@
"feedback": "Feedback",
"dismiss": "Dismiss"
},
"securityAlert": {
"label": "Security Alert:",
"text": "Malware found in 5 skills from openclaw/skills.",
"link": "Read the advisory",
"dismiss": "Dismiss"
},
"email": {
"welcome": {
"subject": "Welcome to SkillHub!",

View File

@@ -31,6 +31,7 @@
"browse": "مرور",
"categories": "دسته‌بندی‌ها",
"docs": "مستندات",
"reviewed": "بررسی‌شده",
"about": "درباره ما",
"github": "گیت‌هاب",
"sourceCode": "کد منبع",
@@ -464,7 +465,7 @@
"platformWindsurf": "Windsurf — پیکربندی Windsurf",
"fullDocsLink": "مستندات کامل کشف را برای پرامپت‌های دقیق، استراتژی‌های جستجو و پیکربندی پیشرفته ببینید.",
"cmdInstallDesc": "نصب مهارت (--platform, --project, --force, --no-api)",
"cmdSearchDesc": "جستجوی مهارت‌ها (--platform, --limit, --page, --sort stars|downloads|rating|recent)",
"cmdSearchDesc": "جستجوی مهارت‌ها (--platform, --limit, --page, --sort recommended|aiScore|downloads|stars|rating|recent)",
"cmdListDesc": "لیست مهارت‌های نصب‌شده (--platform, --project, --all)",
"cmdUpdateDesc": "به‌روزرسانی مهارت یا استفاده از --all برای به‌روزرسانی همه",
"cmdUninstallDesc": "حذف مهارت نصب‌شده (--platform, --project)",
@@ -472,7 +473,7 @@
"exInstallGlobal": "نصب سراسری:",
"exInstallProject": "نصب در پروژه فعلی:",
"exSearch": "جستجوی مهارت‌ها:",
"exSearchSort": "جستجو بر اساس ستاره‌ها:",
"exSearchSort": "جستجو بر اساس امتیاز بررسی:",
"exUpdateAll": "به‌روزرسانی همه مهارت‌ها:",
"platformsTitle": "پلتفرم‌های پشتیبانی‌شده",
"platformsDesc": "CLI SkillHub از ۵ پلتفرم کدنویسی هوش مصنوعی پشتیبانی می‌کند:",
@@ -571,6 +572,44 @@
"all": "همه امتیازها"
}
},
"malicious": {
"warningTitle": "بدافزار شناسایی شد",
"warningDescription": "این مهارت به عنوان مخرب شناسایی شده است. شامل کد مبهم‌سازی شده برای دانلود و اجرای بارهای مضر است. دانلود فایل و نصب مسدود شده است.",
"installBlocked": "نصب مسدود شده — این مهارت حاوی بدافزار است",
"badge": "بدافزار"
},
"reviewStats": {
"title": "آمار بررسی‌ها",
"subtitle": "نمای کلی از خط لوله بررسی هوش مصنوعی، توزیع امتیاز و وضعیت امنیتی",
"pipelineTitle": "خط لوله بررسی",
"pipelineDescription": "وضعیت مهارت‌ها در فرآیند بررسی",
"aiReviewed": "بررسی‌شده توسط AI",
"needsReReview": "نیاز به بررسی مجدد",
"totalReviews": "کل رکوردهای بررسی",
"scoreTitle": "توزیع امتیاز",
"scoreDescription": "توزیع امتیازهای بررسی هوش مصنوعی در مهارت‌های بررسی‌شده",
"scoreHigh": "کیفیت بالا (۷۵۱۰۰)",
"scoreMid": "قابل قبول (۵۰–۷۴)",
"scoreLow": "زیر متوسط (۰–۴۹)",
"securityTitle": "اسکن امنیتی",
"securityDescription": "نتایج اسکن امنیتی خودکار برای تمام مهارت‌ها",
"securityPass": "تایید",
"securityWarning": "هشدار",
"securityFail": "رد",
"malwareTitle": "شناسایی بدافزار",
"malwareDescription": "مهارت‌هایی که حاوی کد مخرب شناسایی شده‌اند",
"malwareFlagged": "فلگ شده به عنوان بدافزار",
"malwareViewAll": "مشاهده مهارت‌های فلگ‌شده",
"skills": "مهارت"
},
"malwarePage": {
"title": "مهارت‌های فلگ‌شده به عنوان بدافزار",
"subtitle": "این مهارت‌ها حاوی کد مخرب شناسایی شده‌اند. دانلود و نصب آن‌ها برای امنیت شما مسدود شده است.",
"noResults": "هیچ مهارت بدافزاردار فلگ‌شده‌ای یافت نشد.",
"flaggedOn": "فلگ شده در",
"downloadBlocked": "دانلود مسدود شده",
"installBlocked": "نصب مسدود شده"
},
"owner": {
"title": "مهارت‌های @{username}",
"notFound": "مالک یافت نشد",
@@ -933,6 +972,12 @@
"feedback": "بازخورد",
"dismiss": "بستن"
},
"securityAlert": {
"label": "هشدار امنیتی:",
"text": "بدافزار در ۵ مهارت از openclaw/skills شناسایی شد.",
"link": "مشاهده اطلاعیه",
"dismiss": "بستن"
},
"email": {
"welcome": {
"subject": "به SkillHub خوش آمدید!",