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>
310 lines
No EOL
12 KiB
JavaScript
310 lines
No EOL
12 KiB
JavaScript
import { useState, useEffect } from 'react';
|
||
import { useRouter } from 'next/router';
|
||
import dynamic from 'next/dynamic';
|
||
import Layout from '../../components/Layout';
|
||
import AdminProtected from '../../components/AdminProtected';
|
||
|
||
const CardImport = () => {
|
||
const [mounted, setMounted] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
}, []);
|
||
|
||
if (!mounted) {
|
||
return (
|
||
<Layout user={null}>
|
||
<div className="flex items-center justify-center min-h-screen">
|
||
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--text-accent)' }}></div>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|
||
const router = useRouter();
|
||
const [importType, setImportType] = useState('mtg');
|
||
const [setCode, setSetCode] = useState('');
|
||
const [isImporting, setIsImporting] = useState(false);
|
||
const [importResult, setImportResult] = useState(null);
|
||
|
||
const handleImport = async () => {
|
||
if (!setCode.trim()) {
|
||
alert('Please enter a set code');
|
||
return;
|
||
}
|
||
|
||
setIsImporting(true);
|
||
setImportResult(null);
|
||
|
||
try {
|
||
const endpoint = importType === 'mtg'
|
||
? '/api/cards/import-mtg'
|
||
: '/api/cards/import-pokemon';
|
||
|
||
const response = await fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
|
||
},
|
||
body: JSON.stringify({ setCode: setCode.trim() }),
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok) {
|
||
setImportResult({
|
||
success: true,
|
||
message: result.message,
|
||
imported: result.imported,
|
||
skipped: result.skipped,
|
||
total: result.total
|
||
});
|
||
} else {
|
||
setImportResult({
|
||
success: false,
|
||
message: result.error || 'Import failed'
|
||
});
|
||
}
|
||
} catch (error) {
|
||
setImportResult({
|
||
success: false,
|
||
message: 'Network error: ' + error.message
|
||
});
|
||
} finally {
|
||
setIsImporting(false);
|
||
}
|
||
};
|
||
|
||
const popularSets = {
|
||
mtg: [
|
||
{ code: 'neo', name: 'Kamigawa: Neon Dynasty' },
|
||
{ code: 'vow', name: 'Innistrad: Crimson Vow' },
|
||
{ code: 'mid', name: 'Innistrad: Midnight Hunt' },
|
||
{ code: 'afr', name: 'Adventures in the Forgotten Realms' },
|
||
{ code: 'stx', name: 'Strixhaven: School of Mages' },
|
||
{ code: 'khm', name: 'Kaldheim' },
|
||
{ code: 'znr', name: 'Zendikar Rising' },
|
||
{ code: 'iko', name: 'Ikoria: Lair of Behemoths' },
|
||
{ code: 'thb', name: 'Theros Beyond Death' },
|
||
{ code: 'eld', name: 'Throne of Eldraine' }
|
||
],
|
||
pokemon: [
|
||
{ code: 'swsh1', name: 'Sword & Shield' },
|
||
{ code: 'swsh2', name: 'Rebel Clash' },
|
||
{ code: 'swsh3', name: 'Darkness Ablaze' },
|
||
{ code: 'swsh4', name: 'Vivid Voltage' },
|
||
{ code: 'swsh5', name: 'Battle Styles' },
|
||
{ code: 'swsh6', name: 'Chilling Reign' },
|
||
{ code: 'swsh7', name: 'Evolving Skies' },
|
||
{ code: 'swsh8', name: 'Fusion Strike' },
|
||
{ code: 'swsh9', name: 'Brilliant Stars' },
|
||
{ code: 'swsh10', name: 'Astral Radiance' }
|
||
]
|
||
};
|
||
|
||
return (
|
||
<AdminProtected>
|
||
{(user) => (
|
||
<Layout user={user}>
|
||
<div className="container mx-auto px-6 py-8">
|
||
{/* Admin Navigation */}
|
||
<div className="mb-8 p-4 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||
Admin Tools
|
||
</h1>
|
||
<div className="flex gap-4">
|
||
<button
|
||
onClick={() => router.push('/admin/card-editor')}
|
||
className="px-4 py-2 rounded-lg font-medium transition-all duration-200 border"
|
||
style={{
|
||
backgroundColor: 'var(--bg-primary)',
|
||
borderColor: 'var(--border)',
|
||
color: 'var(--text-primary)'
|
||
}}
|
||
>
|
||
🖊️ Card Editor
|
||
</button>
|
||
<button
|
||
onClick={() => router.push('/admin/card-import')}
|
||
className="px-4 py-2 rounded-lg font-medium gradient-bg-purple text-white"
|
||
>
|
||
📥 Card Import
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Card Import Section */}
|
||
<div className="mb-6">
|
||
<h2 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
Card Import Manager
|
||
</h2>
|
||
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
|
||
Import cards from external APIs into your database
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||
{/* Import Form */}
|
||
<div className="space-y-6">
|
||
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||
Import Settings
|
||
</h2>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
TCG Type
|
||
</label>
|
||
<select
|
||
value={importType}
|
||
onChange={(e) => setImportType(e.target.value)}
|
||
className="input-field w-full"
|
||
>
|
||
<option value="mtg">Magic: The Gathering</option>
|
||
<option value="pokemon">Pokemon TCG</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
Set Code
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={setCode}
|
||
onChange={(e) => setSetCode(e.target.value)}
|
||
placeholder="e.g., neo, vow, swsh1"
|
||
className="input-field w-full"
|
||
/>
|
||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||
Enter the set code (usually 3-4 characters)
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleImport}
|
||
disabled={isImporting || !setCode.trim()}
|
||
className={`w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
|
||
isImporting || !setCode.trim()
|
||
? 'opacity-50 cursor-not-allowed'
|
||
: 'gradient-bg-purple text-white hover:shadow-lg'
|
||
}`}
|
||
>
|
||
{isImporting ? 'Importing...' : 'Import Cards'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Import Result */}
|
||
{importResult && (
|
||
<div className={`p-6 rounded-xl ${
|
||
importResult.success
|
||
? 'bg-green-100 border border-green-300'
|
||
: 'bg-red-100 border border-red-300'
|
||
}`}>
|
||
<h3 className={`font-semibold mb-2 ${
|
||
importResult.success ? 'text-green-800' : 'text-red-800'
|
||
}`}>
|
||
{importResult.success ? 'Import Successful' : 'Import Failed'}
|
||
</h3>
|
||
<p className="text-sm mb-2">
|
||
{importResult.message}
|
||
</p>
|
||
{importResult.success && (
|
||
<div className="text-sm space-y-1">
|
||
<p>Imported: {importResult.imported}</p>
|
||
<p>Skipped: {importResult.skipped}</p>
|
||
<p>Total: {importResult.total}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Popular Sets */}
|
||
<div className="space-y-6">
|
||
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||
Popular Sets
|
||
</h2>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<h3 className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
Magic: The Gathering
|
||
</h3>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{popularSets.mtg.map((set) => (
|
||
<button
|
||
key={set.code}
|
||
onClick={() => {
|
||
setImportType('mtg');
|
||
setSetCode(set.code);
|
||
}}
|
||
className="text-left p-2 rounded-lg text-sm hover:bg-opacity-20 transition-all duration-200"
|
||
style={{
|
||
backgroundColor: 'var(--bg-tertiary)',
|
||
color: 'var(--text-secondary)'
|
||
}}
|
||
>
|
||
<div className="font-medium">{set.code.toUpperCase()}</div>
|
||
<div className="text-xs opacity-75">{set.name}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
Pokemon TCG
|
||
</h3>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{popularSets.pokemon.map((set) => (
|
||
<button
|
||
key={set.code}
|
||
onClick={() => {
|
||
setImportType('pokemon');
|
||
setSetCode(set.code);
|
||
}}
|
||
className="text-left p-2 rounded-lg text-sm hover:bg-opacity-20 transition-all duration-200"
|
||
style={{
|
||
backgroundColor: 'var(--bg-tertiary)',
|
||
color: 'var(--text-secondary)'
|
||
}}
|
||
>
|
||
<div className="font-medium">{set.code.toUpperCase()}</div>
|
||
<div className="text-xs opacity-75">{set.name}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Import Tips */}
|
||
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||
Import Tips
|
||
</h2>
|
||
<ul className="space-y-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
<li>• Set codes are case-insensitive</li>
|
||
<li>• Duplicate cards will be skipped automatically</li>
|
||
<li>• Import may take several minutes for large sets</li>
|
||
<li>• Prices are fetched from TCGPlayer when available</li>
|
||
<li>• Images are stored as URLs to external sources</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Layout>
|
||
)}
|
||
</AdminProtected>
|
||
);
|
||
};
|
||
|
||
// Export with dynamic import to disable SSR
|
||
export default dynamic(() => Promise.resolve(CardImport), { ssr: false }); |