2025-07-26 22:39:42 -04:00
|
|
|
import { put, del } from '@vercel/blob';
|
|
|
|
|
import { sql } from '@vercel/postgres';
|
|
|
|
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
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
|
|
|
import { checkUploadRateLimit } from '../../../lib/rate-limit.js';
|
2025-07-26 22:39:42 -04:00
|
|
|
|
|
|
|
|
export const config = {
|
|
|
|
|
api: {
|
|
|
|
|
bodyParser: {
|
|
|
|
|
sizeLimit: '5mb',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default async function handler(req, res) {
|
|
|
|
|
try {
|
|
|
|
|
// Get authenticated user
|
|
|
|
|
const user = await getUserFromRequest(req);
|
|
|
|
|
if (!user) {
|
|
|
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
const { allowed, reset } = await checkUploadRateLimit(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.' });
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-26 22:39:42 -04:00
|
|
|
if (req.method === 'POST') {
|
|
|
|
|
// Handle avatar upload
|
|
|
|
|
const contentType = req.headers['content-type'];
|
|
|
|
|
|
|
|
|
|
if (!contentType || !contentType.startsWith('multipart/form-data')) {
|
|
|
|
|
return res.status(400).json({ error: 'Content-Type must be multipart/form-data' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse multipart form data
|
|
|
|
|
const formData = await parseMultipartFormData(req);
|
|
|
|
|
const file = formData.avatar;
|
|
|
|
|
|
|
|
|
|
if (!file) {
|
|
|
|
|
return res.status(400).json({ error: 'No avatar file provided' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate file type
|
|
|
|
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
|
|
|
|
if (!allowedTypes.includes(file.type)) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: 'Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate file size (5MB limit)
|
|
|
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
|
|
|
return res.status(400).json({ error: 'File size must be less than 5MB' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Delete old avatar if exists
|
|
|
|
|
await deleteOldAvatar(user.userId);
|
|
|
|
|
|
|
|
|
|
// Generate unique filename
|
|
|
|
|
const fileExtension = file.type.split('/')[1];
|
|
|
|
|
const filename = `avatars/${user.userId}-${Date.now()}.${fileExtension}`;
|
|
|
|
|
|
|
|
|
|
// Upload to Vercel Blob
|
|
|
|
|
const blob = await put(filename, file.buffer, {
|
|
|
|
|
access: 'public',
|
|
|
|
|
contentType: file.type,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Save avatar info to database
|
|
|
|
|
await sql`
|
|
|
|
|
INSERT INTO user_avatars (user_id, filename, original_name, mime_type, file_size, file_path, is_active)
|
|
|
|
|
VALUES (${user.userId}, ${filename}, ${file.originalName}, ${file.type}, ${file.size}, ${blob.url}, true)
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
// Update user's avatar_url
|
|
|
|
|
await sql`
|
|
|
|
|
UPDATE users
|
|
|
|
|
SET avatar_url = ${blob.url}, updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
WHERE id = ${user.userId}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
res.status(200).json({
|
|
|
|
|
message: 'Avatar uploaded successfully',
|
|
|
|
|
avatar_url: blob.url
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
} catch (uploadError) {
|
|
|
|
|
console.error('Avatar upload error:', uploadError);
|
|
|
|
|
res.status(500).json({ error: 'Failed to upload avatar' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} else if (req.method === 'DELETE') {
|
|
|
|
|
// Handle avatar deletion
|
|
|
|
|
try {
|
|
|
|
|
await deleteOldAvatar(user.userId);
|
|
|
|
|
|
|
|
|
|
// Clear user's avatar_url
|
|
|
|
|
await sql`
|
|
|
|
|
UPDATE users
|
|
|
|
|
SET avatar_url = NULL, updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
WHERE id = ${user.userId}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
res.status(200).json({ message: 'Avatar deleted successfully' });
|
|
|
|
|
|
|
|
|
|
} catch (deleteError) {
|
|
|
|
|
console.error('Avatar deletion error:', deleteError);
|
|
|
|
|
res.status(500).json({ error: 'Failed to delete avatar' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
res.status(405).json({ error: 'Method not allowed' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Avatar API error:', error);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Parse multipart form data manually
|
|
|
|
|
*/
|
|
|
|
|
async function parseMultipartFormData(req) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const chunks = [];
|
|
|
|
|
|
|
|
|
|
req.on('data', (chunk) => {
|
|
|
|
|
chunks.push(chunk);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
req.on('end', () => {
|
|
|
|
|
try {
|
|
|
|
|
const buffer = Buffer.concat(chunks);
|
|
|
|
|
const boundary = req.headers['content-type'].split('boundary=')[1];
|
|
|
|
|
const parts = buffer.toString('binary').split(`--${boundary}`);
|
|
|
|
|
|
|
|
|
|
const formData = {};
|
|
|
|
|
|
|
|
|
|
for (const part of parts) {
|
|
|
|
|
if (part.includes('Content-Disposition: form-data')) {
|
|
|
|
|
const nameMatch = part.match(/name="([^"]+)"/);
|
|
|
|
|
const filenameMatch = part.match(/filename="([^"]+)"/);
|
|
|
|
|
const contentTypeMatch = part.match(/Content-Type: ([^\r\n]+)/);
|
|
|
|
|
|
|
|
|
|
if (nameMatch) {
|
|
|
|
|
const fieldName = nameMatch[1];
|
|
|
|
|
const headerEndIndex = part.indexOf('\r\n\r\n');
|
|
|
|
|
|
|
|
|
|
if (headerEndIndex !== -1) {
|
|
|
|
|
const content = part.substring(headerEndIndex + 4);
|
|
|
|
|
const contentBuffer = Buffer.from(content, 'binary');
|
|
|
|
|
|
|
|
|
|
if (filenameMatch && contentTypeMatch) {
|
|
|
|
|
// This is a file field
|
|
|
|
|
formData[fieldName] = {
|
|
|
|
|
originalName: filenameMatch[1],
|
|
|
|
|
type: contentTypeMatch[1],
|
|
|
|
|
buffer: contentBuffer.slice(0, -2), // Remove trailing \r\n
|
|
|
|
|
size: contentBuffer.length - 2
|
|
|
|
|
};
|
|
|
|
|
} else {
|
|
|
|
|
// This is a regular field
|
|
|
|
|
formData[fieldName] = content.trim();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resolve(formData);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
reject(error);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
req.on('error', reject);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete old avatar from Vercel Blob and database
|
|
|
|
|
*/
|
|
|
|
|
async function deleteOldAvatar(userId) {
|
|
|
|
|
try {
|
|
|
|
|
// Get current active avatar
|
|
|
|
|
const avatarResult = await sql`
|
|
|
|
|
SELECT file_path, filename FROM user_avatars
|
|
|
|
|
WHERE user_id = ${userId} AND is_active = true
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
if (avatarResult.rows.length > 0) {
|
|
|
|
|
const avatar = avatarResult.rows[0];
|
|
|
|
|
|
|
|
|
|
// Delete from Vercel Blob
|
|
|
|
|
try {
|
|
|
|
|
await del(avatar.file_path);
|
|
|
|
|
} catch (blobError) {
|
|
|
|
|
console.warn('Failed to delete blob file:', blobError);
|
|
|
|
|
// Continue anyway - the database record should still be cleaned up
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mark as inactive in database
|
|
|
|
|
await sql`
|
|
|
|
|
UPDATE user_avatars
|
|
|
|
|
SET is_active = false, updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
WHERE user_id = ${userId} AND is_active = true
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error deleting old avatar:', error);
|
|
|
|
|
// Don't throw - this shouldn't prevent new uploads
|
|
|
|
|
}
|
|
|
|
|
}
|