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>
140 lines
No EOL
4.6 KiB
JavaScript
140 lines
No EOL
4.6 KiB
JavaScript
import { put } from '@vercel/blob';
|
|
import { sql } from '@vercel/postgres';
|
|
import { getUserFromRequest } from '../../../../lib/permission-middleware';
|
|
import { checkGenerateRateLimit } from '../../../../lib/rate-limit.js';
|
|
|
|
export default async function handler(req, res) {
|
|
if (req.method !== 'POST') {
|
|
return res.status(405).json({ error: 'Method not allowed' });
|
|
}
|
|
|
|
try {
|
|
// Get authenticated user
|
|
const user = await getUserFromRequest(req);
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
|
|
const { allowed, reset } = await checkGenerateRateLimit(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.' });
|
|
}
|
|
|
|
// Get user information for avatar generation
|
|
const userResult = await sql`
|
|
SELECT email, first_name, last_name, username FROM users WHERE id = ${user.userId}
|
|
`;
|
|
|
|
if (userResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'User not found' });
|
|
}
|
|
|
|
const userData = userResult.rows[0];
|
|
|
|
try {
|
|
// Delete old avatar if exists
|
|
await deleteOldAvatar(user.userId);
|
|
|
|
// Generate avatar using a service (we'll use DiceBear Avatars as an example)
|
|
const avatarStyle = 'initials'; // You can change this to other styles like 'avataaars', 'bottts', etc.
|
|
const seed = userData.username || userData.email || `user-${user.userId}`;
|
|
const initials = getInitials(userData);
|
|
|
|
// Create avatar URL with DiceBear API
|
|
const avatarUrl = `https://api.dicebear.com/7.x/${avatarStyle}/svg?seed=${encodeURIComponent(seed)}&chars=2&backgroundColor=d84315,ff5722,ff7043&textColor=ffffff&fontSize=40`;
|
|
|
|
// Fetch the generated avatar
|
|
const avatarResponse = await fetch(avatarUrl);
|
|
if (!avatarResponse.ok) {
|
|
throw new Error('Failed to generate avatar');
|
|
}
|
|
|
|
const avatarBuffer = Buffer.from(await avatarResponse.arrayBuffer());
|
|
|
|
// Generate unique filename
|
|
const filename = `avatars/generated-${user.userId}-${Date.now()}.svg`;
|
|
|
|
// Upload to Vercel Blob
|
|
const blob = await put(filename, avatarBuffer, {
|
|
access: 'public',
|
|
contentType: 'image/svg+xml',
|
|
});
|
|
|
|
// 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}, 'generated-avatar.svg', 'image/svg+xml', ${avatarBuffer.length}, ${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 generated successfully',
|
|
avatar_url: blob.url
|
|
});
|
|
|
|
} catch (generateError) {
|
|
console.error('Avatar generation error:', generateError);
|
|
res.status(500).json({ error: 'Failed to generate avatar' });
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Avatar generation API error:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user initials for avatar generation
|
|
*/
|
|
function getInitials(userData) {
|
|
if (userData.first_name || userData.last_name) {
|
|
return `${userData.first_name?.charAt(0) || ''}${userData.last_name?.charAt(0) || ''}`.toUpperCase();
|
|
}
|
|
if (userData.username) {
|
|
return userData.username.substring(0, 2).toUpperCase();
|
|
}
|
|
return userData.email?.charAt(0).toUpperCase() || 'U';
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
const { del } = await import('@vercel/blob');
|
|
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
|
|
}
|
|
}
|