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:
@@ -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 "code review" --sort stars --limit 5
|
||||
<span className="text-text-muted">$</span> npx skillhub search "code review" --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
|
||||
|
||||
@@ -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>
|
||||
|
||||
149
apps/web/app/[locale]/malware/page.tsx
Normal file
149
apps/web/app/[locale]/malware/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
224
apps/web/app/[locale]/reviewed/stats/page.tsx
Normal file
224
apps/web/app/[locale]/reviewed/stats/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
106
apps/web/app/api/review/diagnose/route.ts
Normal file
106
apps/web/app/api/review/diagnose/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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` },
|
||||
];
|
||||
|
||||
|
||||
52
apps/web/components/SecurityAlertBanner.tsx
Normal file
52
apps/web/components/SecurityAlertBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 (75–100)",
|
||||
"scoreMid": "Solid (50–74)",
|
||||
"scoreLow": "Below Average (0–49)",
|
||||
"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!",
|
||||
|
||||
@@ -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 خوش آمدید!",
|
||||
|
||||
Reference in New Issue
Block a user