Closes P0 #6 (no rate limiting) from PARTIAL → RESOLVED. With this merge, all 8 P0 ship-blockers are RESOLVED. fix-auth-bypass Brief 4 shipped lib/rate-limit.js with a single 5/15min auth limiter wired into login + register; this brief extends the module to 5 named limiters (auth/search/upload/generate/import) and wires them into the remaining abusable surface. Per architect Decision 1 — Option A (gate all 3 import routes uniformly). The architect's investigation found a critical secondary bug: pages/admin/card-import.js's fetch sends NO Authorization header today. Adding getUserFromRequest to the import APIs without fixing the admin UI atomically would have returned 401 on every "Import Cards" click. Both edits ship in this single commit — API gating + admin UI Bearer fix — for atomic safety. Lorcana is dead in frontend today (only scripts/import-lorcana.js uses that path) but gated uniformly to future-proof per AGENTS.md § 1 status; a delete-dead-lorcana-import follow-up convoy is queued for later if we decide to drop Lorcana entirely. Per Decision 2 — hybrid named-limiter shape in lib/rate-limit.js. checkAuthRateLimit(req) signature + return shape preserved verbatim (don't break Brief 4's contract); 4 new named functions added (checkSearchRateLimit, checkUploadRateLimit, checkGenerateRateLimit, checkImportRateLimit). Map<className, Ratelimit> cache, per-class Redis prefix (tcgvault:auth, tcgvault:search, tcgvault:upload, tcgvault:generate, tcgvault:import) so each class has its own budget. Per Decision 3 — per-class limit values tuned with evidence: auth 5 / 15min IP-keyed (unchanged from Brief 4) search 60 / 1min IP-keyed (bumped from 30 — ShareModal has no debounce; 17-char email = 16 requests in <5s) upload 10 / 1hr user-keyed generate 5 / 1hr user-keyed (DiceBear is free, kept at 5) import 5 / 1hr user-keyed (admin-only; external APIs have their own limits) Per Decision 4 — two extractors. extractIpIdentifier (existing, unchanged) and extractUserIdentifier (new). The new one THROWS on null/undefined/empty/NaN userId to prevent silent fallback-to-IP (which would convert per-user limits into per-IP and lock out households). Architect's R-finding: places the gate AFTER the auth check on every per-user-keyed route, never before. Per Decision 5 — uniform 429 response shape verbatim matching login.js/register.js: Retry-After header + JSON { error: 'Too many attempts. Try again later.' }. Anti- fingerprinting (per-class messages would tell an attacker which classes have which limits). Per Decision 6 — no new per-route handler tests this convoy. Vitest 21/21 unchanged at merge. Verification: - npm run lint: 128 problems (baseline match) - npm run test:run: 21/21 vitest pass (no regression; auth-utils tests don't transitively load rate-limit per architect D6 evidence) - 5 named limiter exports verified via per-route grep counts - Admin UI sends Authorization: Bearer <token> from localStorage in the import fetch (matching pattern from other admin pages) - Brief 4's login.js + register.js byte-identical at HEAD - .cursor/rules/api-routes.mdc § Rate limiting extended with per-class table + gate-ordering rules No new dependencies (Brief 4's @upstash/ratelimit + @upstash/redis suffice). No workflow YAML changes. No AGENTS.md edits (doc- writer pass at convoy close handles Gotcha #12 update + § 6 testing update + ship-readiness Status summary 7/8 → 8/8). Co-authored-by: Cursor <cursoragent@cursor.com>
167 lines
No EOL
5.2 KiB
JavaScript
167 lines
No EOL
5.2 KiB
JavaScript
import { sql } from '@vercel/postgres';
|
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
|
import { checkImportRateLimit } from '../../../lib/rate-limit.js';
|
|
|
|
// Helper function to delay execution
|
|
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
// Helper function to fetch with retry logic
|
|
async function fetchWithRetry(url, maxRetries = 3, delayMs = 2000) {
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'User-Agent': 'TCG-Vault/1.0',
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout: 30000 // 30 second timeout
|
|
});
|
|
|
|
if (response.ok) {
|
|
return response;
|
|
}
|
|
|
|
// If it's a 404, don't retry
|
|
if (response.status === 404) {
|
|
throw new Error(`Set not found: ${response.status}`);
|
|
}
|
|
|
|
// If it's a 504 or 503, wait longer before retry
|
|
if (response.status === 504 || response.status === 503) {
|
|
console.log(`Attempt ${attempt}: Got ${response.status}, waiting ${delayMs * attempt}ms before retry...`);
|
|
await delay(delayMs * attempt);
|
|
continue;
|
|
}
|
|
|
|
throw new Error(`Pokemon TCG API error: ${response.status}`);
|
|
} catch (error) {
|
|
if (attempt === maxRetries) {
|
|
throw error;
|
|
}
|
|
console.log(`Attempt ${attempt} failed:`, error.message);
|
|
await delay(delayMs * attempt);
|
|
}
|
|
}
|
|
}
|
|
|
|
export default async function handler(req, res) {
|
|
if (req.method !== 'POST') {
|
|
return res.status(405).json({ error: 'Method not allowed' });
|
|
}
|
|
|
|
const user = await getUserFromRequest(req);
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
if (user.role !== 'admin') {
|
|
return res.status(403).json({ error: 'Admin access required' });
|
|
}
|
|
|
|
const { allowed, reset } = await checkImportRateLimit(req, user.userId);
|
|
if (!allowed) {
|
|
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
|
|
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
}
|
|
|
|
try {
|
|
const { setCode } = req.body;
|
|
|
|
if (!setCode) {
|
|
return res.status(400).json({ error: 'Set code is required' });
|
|
}
|
|
|
|
console.log(`Starting import for Pokemon set: ${setCode}`);
|
|
|
|
// Fetch cards from Pokemon TCG API with retry logic
|
|
const response = await fetchWithRetry(`https://api.pokemontcg.io/v2/cards?q=set.id:${setCode}&pageSize=250`);
|
|
|
|
const data = await response.json();
|
|
const cards = data.data || [];
|
|
|
|
if (cards.length === 0) {
|
|
return res.status(200).json({
|
|
success: true,
|
|
message: `No cards found for set ${setCode}`,
|
|
imported: 0,
|
|
skipped: 0,
|
|
total: 0
|
|
});
|
|
}
|
|
|
|
console.log(`Found ${cards.length} cards for set ${setCode}`);
|
|
|
|
let importedCount = 0;
|
|
let skippedCount = 0;
|
|
|
|
for (const card of cards) {
|
|
try {
|
|
// Check if card already exists
|
|
const existingCard = await sql`
|
|
SELECT id FROM cards WHERE scryfall_id = ${card.id}
|
|
`;
|
|
|
|
if (existingCard.rows.length > 0) {
|
|
skippedCount++;
|
|
continue;
|
|
}
|
|
|
|
// Extract price data from TCGPlayer
|
|
let currentPrice = null;
|
|
if (card.tcgplayer?.prices?.normal?.market) {
|
|
currentPrice = parseFloat(card.tcgplayer.prices.normal.market);
|
|
} else if (card.tcgplayer?.prices?.holofoil?.market) {
|
|
currentPrice = parseFloat(card.tcgplayer.prices.holofoil.market);
|
|
}
|
|
|
|
// Determine rarity
|
|
let rarity = card.rarity || 'Unknown';
|
|
if (rarity.includes('Holo')) {
|
|
rarity = 'Holographic';
|
|
} else if (rarity.includes('Secret')) {
|
|
rarity = 'Secret Rare';
|
|
} else if (rarity.includes('Ultra')) {
|
|
rarity = 'Ultra Rare';
|
|
}
|
|
|
|
// Insert card into database
|
|
await sql`
|
|
INSERT INTO cards (
|
|
name, set_name, set_code, card_number, rarity, game,
|
|
mana_cost, cmc, card_type, colors, oracle_text,
|
|
power, toughness, image_url, stock_image_url,
|
|
current_price, market_price, scryfall_id, verified
|
|
) VALUES (
|
|
${card.name}, ${card.set.name}, ${card.set.id}, ${card.number},
|
|
${rarity}, 'Pokemon', null, null, ${card.supertype || 'Pokemon'},
|
|
${JSON.stringify(card.types || [])}, ${card.flavorText || null},
|
|
${card.attacks?.[0]?.damage || null}, null,
|
|
${card.images?.small || null}, ${card.images?.large || null},
|
|
${currentPrice}, null, ${card.id}, true
|
|
)
|
|
`;
|
|
|
|
importedCount++;
|
|
} catch (error) {
|
|
console.error(`Error importing card ${card.name}:`, error);
|
|
skippedCount++;
|
|
}
|
|
}
|
|
|
|
console.log(`Import completed for set ${setCode}: ${importedCount} imported, ${skippedCount} skipped`);
|
|
|
|
res.status(200).json({
|
|
success: true,
|
|
message: `Import completed for set ${setCode}`,
|
|
imported: importedCount,
|
|
skipped: skippedCount,
|
|
total: cards.length
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Card import error:', error);
|
|
res.status(500).json({
|
|
error: 'Import failed',
|
|
details: error.message
|
|
});
|
|
}
|
|
}
|