Implement email verification lockout and missing phase 2 items

This commit is contained in:
Mohan Ki
2026-07-27 19:55:19 +05:30
parent 2bfa741a71
commit 542426a12a
14 changed files with 980 additions and 210 deletions
+9
View File
@@ -80,6 +80,15 @@ router.post('/login', async (req, res) => {
const isMatch = await bcrypt.compare(password, user.password_hash);
if (!isMatch) return res.status(401).json({ error: 'Invalid credentials' });
if (!user.is_email_verified) {
const createdAt = new Date(user.created_at);
const now = new Date();
const daysSinceCreation = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceCreation > 5) {
return res.status(403).json({ error: 'Account locked. Please verify your email address to continue.', needsVerification: true });
}
}
const token = jwt.sign({ id: user.id, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '14d' });
res.cookie('token', token, { httpOnly: true, maxAge: 14 * 24 * 60 * 60 * 1000, secure: false, sameSite: 'lax' });
+82 -15
View File
@@ -12,6 +12,56 @@ const pgPool = new Pool({
password: process.env.PGPASSWORD || 'postgres',
database: process.env.PGDATABASE || 'ckqr',
});
// === BULK OPERATIONS ===
// POST /api/qr/bulk-delete
router.post('/bulk-delete', verifyToken, async (req, res) => {
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ error: 'No IDs provided' });
}
try {
const result = await pgPool.query(
'DELETE FROM qr_codes WHERE id = ANY($1) AND user_id = $2 RETURNING id',
[ids, req.user.id]
);
res.json({ success: true, deleted: result.rowCount });
} catch (err) {
console.error('Error in bulk delete:', err);
res.status(500).json({ error: 'Server error during bulk delete' });
}
});
// POST /api/qr/bulk-move
router.post('/bulk-move', verifyToken, async (req, res) => {
const { ids, folderId } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ error: 'No IDs provided' });
}
try {
let targetFolder = folderId;
if (targetFolder === 'default') targetFolder = null;
// Verify folder ownership if not default
if (targetFolder) {
const folderRes = await pgPool.query('SELECT id FROM folders WHERE id = $1 AND user_id = $2', [targetFolder, req.user.id]);
if (folderRes.rows.length === 0) {
return res.status(403).json({ error: 'Folder access denied' });
}
}
const result = await pgPool.query(
'UPDATE qr_codes SET folder_id = $1 WHERE id = ANY($2) AND user_id = $3 RETURNING id',
[targetFolder, ids, req.user.id]
);
res.json({ success: true, moved: result.rowCount });
} catch (err) {
console.error('Error in bulk move:', err);
res.status(500).json({ error: 'Server error during bulk move' });
}
});
// Get all QRs for user
router.get('/', verifyToken, async (req, res) => {
@@ -57,7 +107,7 @@ router.get('/:id', verifyToken, async (req, res) => {
// Generate QR Code
router.post('/generate', verifyToken, requireEmailVerification, async (req, res) => {
const { name, type, dataType, destinationUrl, designData, customSlug, folderId } = req.body;
const { name, type, dataType, destinationUrl, routingData, designData, customSlug, folderId, tags } = req.body;
const userId = req.user.id;
if (!type || !dataType) {
@@ -66,11 +116,11 @@ router.post('/generate', verifyToken, requireEmailVerification, async (req, res)
try {
let shortUrlId = null;
let finalQrContent = destinationUrl;
let finalQrContent = destinationUrl; // Only valid if static and URL
// In static mode, the finalQrContent is whatever payload we send (handled mostly by frontend),
// but if dynamic, we generate the shortUrlId.
if (type === 'dynamic') {
if (!destinationUrl) return res.status(400).json({ error: 'Destination URL is required for dynamic QR' });
// Allow custom slug or generate random
shortUrlId = customSlug ? customSlug.trim() : shortid.generate();
@@ -84,16 +134,20 @@ router.post('/generate', verifyToken, requireEmailVerification, async (req, res)
// In production, this would use the real domain (e.g. https://ck-qr.com/l/)
finalQrContent = `${process.env.FRONTEND_URL || 'http://localhost:4000'}/l/${shortUrlId}`;
} else {
// If static, destinationUrl is sent as the actual payload by the frontend for simple URLs,
// but for complex types, frontend puts the raw payload in designData.data.
// We just store destinationUrl for backward compatibility.
}
const result = await pgPool.query(
`INSERT INTO qr_codes (user_id, name, type, data_type, destination_url, short_url_id, design_data, folder_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[userId, name || 'Untitled QR', type, dataType, destinationUrl, shortUrlId, designData || {}, folderId || null]
`INSERT INTO qr_codes (user_id, name, type, data_type, destination_url, routing_data, short_url_id, design_data, folder_id, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
[userId, name || 'Untitled QR', type, dataType, destinationUrl || null, routingData || null, shortUrlId, designData || {}, folderId || null, tags || []]
);
// Generate Image (Data URI for simple response, could be saved to S3 later)
const qrImage = await QRCode.toDataURL(finalQrContent);
const qrImage = await QRCode.toDataURL(finalQrContent || 'preview');
res.json({
message: 'QR Code generated',
@@ -108,7 +162,7 @@ router.post('/generate', verifyToken, requireEmailVerification, async (req, res)
// Update QR Code (Editable Link / Move Folder / Update Design)
router.put('/:id', verifyToken, requireEmailVerification, async (req, res) => {
const { name, destinationUrl, shortUrlId, designData, folderId } = req.body;
const { name, dataType, destinationUrl, routingData, shortUrlId, designData, folderId, tags } = req.body;
const qrId = req.params.id;
const userId = req.user.id;
@@ -134,12 +188,25 @@ router.put('/:id', verifyToken, requireEmailVerification, async (req, res) => {
const updated = await pgPool.query(
`UPDATE qr_codes
SET name = COALESCE($1, name),
destination_url = COALESCE($2, destination_url),
short_url_id = COALESCE($3, short_url_id),
design_data = COALESCE($4, design_data),
folder_id = $5
WHERE id = $6 RETURNING *`,
[name, destinationUrl, newShortUrlId, designData || qr.design_data, folderId !== undefined ? (folderId === 'default' ? null : folderId) : qr.folder_id, qrId]
data_type = COALESCE($2, data_type),
destination_url = $3,
routing_data = COALESCE($4, routing_data),
short_url_id = COALESCE($5, short_url_id),
design_data = COALESCE($6, design_data),
folder_id = $7,
tags = $8
WHERE id = $9 RETURNING *`,
[
name,
dataType,
destinationUrl !== undefined ? destinationUrl : qr.destination_url,
routingData !== undefined ? routingData : qr.routing_data,
newShortUrlId,
designData || qr.design_data,
folderId !== undefined ? (folderId === 'default' ? null : folderId) : qr.folder_id,
tags !== undefined ? tags : qr.tags,
qrId
]
);
res.json({ message: 'QR Code updated', qrRecord: updated.rows[0] });
+43 -6
View File
@@ -34,7 +34,7 @@ router.get('/:shortUrlId', async (req, res) => {
} else {
// 2. Not in cache, check DB
const result = await pgPool.query(
`SELECT q.id as qr_id, q.destination_url, u.role
`SELECT q.id as qr_id, q.destination_url, q.data_type, q.routing_data, u.role
FROM qr_codes q
JOIN users u ON q.user_id = u.id
WHERE q.short_url_id = $1`,
@@ -62,15 +62,52 @@ router.get('/:shortUrlId', async (req, res) => {
[qrData.qr_id, ip, req.headers['user-agent'], os, deviceType, country]
).catch(err => console.error('Failed to log scan:', err));
// 4. Check Tier & Redirect
if (qrData.role === 'free') {
const encodedUrl = encodeURIComponent(qrData.destination_url);
// 4. Determine final destination based on data_type
let finalDestination = qrData.destination_url;
let isDirectAction = false;
let isDownload = false;
let downloadContent = '';
if (qrData.data_type === 'App Store' && qrData.routing_data) {
const rd = typeof qrData.routing_data === 'string' ? JSON.parse(qrData.routing_data) : qrData.routing_data;
if (os === 'iOS' || os === 'Mac OS') {
finalDestination = rd.ios || rd.fallback;
} else if (os === 'Android') {
finalDestination = rd.android || rd.fallback;
} else {
finalDestination = rd.fallback || rd.ios || rd.android;
}
} else if (qrData.data_type === 'Email' && qrData.routing_data) {
const rd = typeof qrData.routing_data === 'string' ? JSON.parse(qrData.routing_data) : qrData.routing_data;
finalDestination = `mailto:${rd.email}?subject=${encodeURIComponent(rd.subject || '')}&body=${encodeURIComponent(rd.body || '')}`;
isDirectAction = true;
} else if (qrData.data_type === 'SMS' && qrData.routing_data) {
const rd = typeof qrData.routing_data === 'string' ? JSON.parse(qrData.routing_data) : qrData.routing_data;
finalDestination = `sms:${rd.phone}?body=${encodeURIComponent(rd.message || '')}`;
isDirectAction = true;
} else if (qrData.data_type === 'vCard' && qrData.routing_data) {
const rd = typeof qrData.routing_data === 'string' ? JSON.parse(qrData.routing_data) : qrData.routing_data;
isDownload = true;
downloadContent = `BEGIN:VCARD\r\nVERSION:3.0\r\nN:${rd.lastName || ''};${rd.firstName || ''}\r\nFN:${rd.firstName || ''} ${rd.lastName || ''}\r\nORG:${rd.company || ''}\r\nTITLE:${rd.title || ''}\r\nTEL:${rd.phone || ''}\r\nEMAIL:${rd.email || ''}\r\nEND:VCARD`;
} else if (qrData.data_type === 'Wi-Fi') {
return res.send('Wi-Fi QR Codes are meant to be scanned natively as static QRs.');
}
if (isDownload) {
res.setHeader('Content-Type', 'text/vcard');
res.setHeader('Content-Disposition', 'attachment; filename="contact.vcf"');
return res.send(downloadContent);
}
// 5. Check Tier & Redirect
if (qrData.role === 'free' && !isDirectAction) {
const encodedUrl = encodeURIComponent(finalDestination);
// In production, this points to the real Next.js domain
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:4000';
return res.redirect(302, `${frontendUrl}/ad-redirect?url=${encodedUrl}`);
} else {
// Paid users skip the ad
return res.redirect(302, qrData.destination_url);
// Paid users or Direct Actions (mailto/sms) skip the ad
return res.redirect(302, finalDestination);
}
} catch (err) {