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>
216 lines
No EOL
6.6 KiB
JavaScript
216 lines
No EOL
6.6 KiB
JavaScript
import { put, del } from '@vercel/blob';
|
|
import { sql } from '@vercel/postgres';
|
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
|
import { checkUploadRateLimit } from '../../../lib/rate-limit.js';
|
|
|
|
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' });
|
|
}
|
|
|
|
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.' });
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|