2025-07-24 18:04:06 -04:00
|
|
|
|
import { useState, useEffect } from 'react';
|
2025-07-24 16:16:57 -04:00
|
|
|
|
import { useRouter } from 'next/router';
|
2025-07-24 18:04:06 -04:00
|
|
|
|
import dynamic from 'next/dynamic';
|
2025-07-23 22:26:54 -04:00
|
|
|
|
import Layout from '../../components/Layout';
|
2025-07-24 17:31:29 -04:00
|
|
|
|
import AdminProtected from '../../components/AdminProtected';
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
2025-07-24 18:04:06 -04:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-07-24 16:16:57 -04:00
|
|
|
|
const router = useRouter();
|
2025-07-23 22:26:54 -04:00
|
|
|
|
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',
|
feat(security): rate-limit search/upload/import + gate import routes (P0 #6)
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>
2026-05-24 23:46:27 -04:00
|
|
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
|
2025-07-23 22:26:54 -04:00
|
|
|
|
},
|
|
|
|
|
|
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 (
|
2025-07-24 17:31:29 -04:00
|
|
|
|
<AdminProtected>
|
2025-07-24 18:04:06 -04:00
|
|
|
|
{(user) => (
|
|
|
|
|
|
<Layout user={user}>
|
2025-07-24 16:16:57 -04:00
|
|
|
|
<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 */}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
<div className="mb-6">
|
2025-07-24 16:16:57 -04:00
|
|
|
|
<h2 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
Card Import Manager
|
2025-07-24 16:16:57 -04:00
|
|
|
|
</h2>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
<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>
|
2025-07-24 17:31:29 -04:00
|
|
|
|
</Layout>
|
2025-07-24 18:04:06 -04:00
|
|
|
|
)}
|
2025-07-24 17:31:29 -04:00
|
|
|
|
</AdminProtected>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
);
|
2025-07-24 18:04:06 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Export with dynamic import to disable SSR
|
|
|
|
|
|
export default dynamic(() => Promise.resolve(CardImport), { ssr: false });
|