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