bulltrackers-module 1.0.616 → 1.0.618
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Firestore helper functions for fetching data from collections
|
|
2
2
|
const { FieldValue } = require('@google-cloud/firestore');
|
|
3
3
|
const { dispatchSyncRequest } = require('../task_engine_helper.js');
|
|
4
|
+
const crypto = require('crypto');
|
|
4
5
|
|
|
5
6
|
// 1. Fetch latest stored snapshots of user data from a user-centric collection
|
|
6
7
|
|
|
@@ -525,10 +526,43 @@ const fetchUserVerificationData = async (firestore, userId) => {
|
|
|
525
526
|
}
|
|
526
527
|
};
|
|
527
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Hash email for use as document ID in lookup collection
|
|
531
|
+
* Uses SHA-256 for consistent hashing
|
|
532
|
+
*/
|
|
533
|
+
const hashEmail = (email) => {
|
|
534
|
+
const normalizedEmail = String(email).toLowerCase().trim();
|
|
535
|
+
return crypto.createHash('sha256').update(normalizedEmail).digest('hex');
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Create or update email-to-CID lookup document for O(1) lookups
|
|
540
|
+
* This is called when an email is verified or when migration finds a match
|
|
541
|
+
*/
|
|
542
|
+
const createEmailLookup = async (firestore, email, cid) => {
|
|
543
|
+
try {
|
|
544
|
+
const hashedEmail = hashEmail(email);
|
|
545
|
+
const lookupRef = firestore.collection('sys_user_lookup').doc(hashedEmail);
|
|
546
|
+
|
|
547
|
+
await lookupRef.set({
|
|
548
|
+
cid: Number(cid),
|
|
549
|
+
emailHash: hashedEmail,
|
|
550
|
+
createdAt: FieldValue.serverTimestamp(),
|
|
551
|
+
updatedAt: FieldValue.serverTimestamp(),
|
|
552
|
+
}, { merge: true });
|
|
553
|
+
|
|
554
|
+
console.log(`[createEmailLookup] Created lookup document for email hash ${hashedEmail} -> CID ${cid}`);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
console.error(`[createEmailLookup] Error creating lookup for ${email}:`, error);
|
|
557
|
+
// Don't throw - lookup creation is non-critical, fallback will work
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
528
561
|
/**
|
|
529
562
|
* Lookup CID by email (server-side only for security)
|
|
530
|
-
*
|
|
531
|
-
*
|
|
563
|
+
*
|
|
564
|
+
* PERFORMANCE: Uses O(1) lookup collection first, falls back to O(N) search if not found
|
|
565
|
+
* MIGRATION: When fallback finds a match, creates lookup document for future O(1) access
|
|
532
566
|
*
|
|
533
567
|
* NOTE: Multiple Firebase accounts (different UIDs) can access the same CID
|
|
534
568
|
* if their emails are in the verification document's email array.
|
|
@@ -541,8 +575,60 @@ const lookupCidByEmail = async (firestore, userEmail, firebaseUid = null) => {
|
|
|
541
575
|
try {
|
|
542
576
|
// Normalize email for comparison
|
|
543
577
|
const normalizedEmail = String(userEmail).toLowerCase().trim();
|
|
578
|
+
const hashedEmail = hashEmail(normalizedEmail);
|
|
579
|
+
|
|
580
|
+
// STEP 1: Try O(1) lookup from denormalized collection
|
|
581
|
+
const lookupRef = firestore.collection('sys_user_lookup').doc(hashedEmail);
|
|
582
|
+
const lookupDoc = await lookupRef.get();
|
|
583
|
+
|
|
584
|
+
if (lookupDoc.exists) {
|
|
585
|
+
const lookupData = lookupDoc.data();
|
|
586
|
+
const cid = lookupData.cid;
|
|
587
|
+
|
|
588
|
+
// Fetch verification data to return full user info
|
|
589
|
+
const verificationRef = firestore.collection('SignedInUsers').doc(String(cid)).collection('verification').doc('data');
|
|
590
|
+
const verificationDoc = await verificationRef.get();
|
|
591
|
+
|
|
592
|
+
if (verificationDoc.exists) {
|
|
593
|
+
const verificationData = verificationDoc.data();
|
|
594
|
+
|
|
595
|
+
// Verify email is still in the verification document (safety check)
|
|
596
|
+
const emails = Array.isArray(verificationData.email)
|
|
597
|
+
? verificationData.email
|
|
598
|
+
: (verificationData.email ? [verificationData.email] : []);
|
|
599
|
+
const normalizedEmails = emails.map(e => String(e).toLowerCase().trim());
|
|
600
|
+
|
|
601
|
+
if (normalizedEmails.includes(normalizedEmail)) {
|
|
602
|
+
// Optional: Validate Firebase UID if provided
|
|
603
|
+
if (firebaseUid && verificationData.firebaseUids && Array.isArray(verificationData.firebaseUids)) {
|
|
604
|
+
if (!verificationData.firebaseUids.includes(firebaseUid)) {
|
|
605
|
+
console.warn(`[lookupCidByEmail] Email ${userEmail} matches CID ${cid} but Firebase UID ${firebaseUid} not in firebaseUids array`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Return result from O(1) lookup
|
|
610
|
+
return {
|
|
611
|
+
cid: verificationData.etoroCID || Number(cid),
|
|
612
|
+
accountSetupComplete: verificationData.accountSetupComplete || false,
|
|
613
|
+
etoroUsername: verificationData.etoroUsername,
|
|
614
|
+
displayName: verificationData.displayName,
|
|
615
|
+
photoURL: verificationData.photoURL,
|
|
616
|
+
verifiedAt: verificationData.verifiedAt,
|
|
617
|
+
setupCompletedAt: verificationData.setupCompletedAt,
|
|
618
|
+
};
|
|
619
|
+
} else {
|
|
620
|
+
// Email not in verification doc - lookup is stale, will fall through to migration
|
|
621
|
+
console.warn(`[lookupCidByEmail] Lookup document exists but email ${normalizedEmail} not in verification doc for CID ${cid}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// STEP 2: Fallback to O(N) search (migration path)
|
|
627
|
+
// This happens when:
|
|
628
|
+
// 1. Lookup document doesn't exist yet (new user or not migrated)
|
|
629
|
+
// 2. Lookup document exists but email not in verification (stale data)
|
|
630
|
+
console.log(`[lookupCidByEmail] Lookup document not found for ${normalizedEmail}, performing O(N) search (migration)`);
|
|
544
631
|
|
|
545
|
-
// Get all SignedInUsers documents
|
|
546
632
|
const signedInUsersSnapshot = await firestore.collection('SignedInUsers').get();
|
|
547
633
|
|
|
548
634
|
// Search through each CID's verification document
|
|
@@ -559,18 +645,24 @@ const lookupCidByEmail = async (firestore, userEmail, firebaseUid = null) => {
|
|
|
559
645
|
// Case-insensitive email comparison
|
|
560
646
|
const normalizedEmails = emails.map(e => String(e).toLowerCase().trim());
|
|
561
647
|
if (normalizedEmails.includes(normalizedEmail)) {
|
|
562
|
-
|
|
563
|
-
|
|
648
|
+
const cid = verificationData.etoroCID || Number(userDoc.id);
|
|
649
|
+
|
|
650
|
+
// Optional: Validate Firebase UID if provided
|
|
564
651
|
if (firebaseUid && verificationData.firebaseUids && Array.isArray(verificationData.firebaseUids)) {
|
|
565
652
|
if (!verificationData.firebaseUids.includes(firebaseUid)) {
|
|
566
|
-
|
|
567
|
-
console.warn(`[lookupCidByEmail] Email ${userEmail} matches CID ${userDoc.id} but Firebase UID ${firebaseUid} not in firebaseUids array`);
|
|
653
|
+
console.warn(`[lookupCidByEmail] Email ${userEmail} matches CID ${cid} but Firebase UID ${firebaseUid} not in firebaseUids array`);
|
|
568
654
|
}
|
|
569
655
|
}
|
|
570
656
|
|
|
571
|
-
//
|
|
657
|
+
// STEP 3: Create lookup document for future O(1) access (migration)
|
|
658
|
+
// Create lookup for all emails in the verification document to handle multiple accounts
|
|
659
|
+
for (const email of normalizedEmails) {
|
|
660
|
+
await createEmailLookup(firestore, email, cid);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Return result
|
|
572
664
|
return {
|
|
573
|
-
cid:
|
|
665
|
+
cid: cid,
|
|
574
666
|
accountSetupComplete: verificationData.accountSetupComplete || false,
|
|
575
667
|
etoroUsername: verificationData.etoroUsername,
|
|
576
668
|
displayName: verificationData.displayName,
|
|
@@ -1700,6 +1792,14 @@ const finalizeVerification = async (db, pubsub, userId, username) => {
|
|
|
1700
1792
|
accountSetupComplete: false, // Will be set to true by frontend completeAccountSetup
|
|
1701
1793
|
createdAt: FieldValue.serverTimestamp(),
|
|
1702
1794
|
}, { merge: true });
|
|
1795
|
+
|
|
1796
|
+
// 3. Create lookup documents for all emails (O(1) lookup optimization)
|
|
1797
|
+
// This ensures future lookups are fast even if emails are added later
|
|
1798
|
+
for (const email of emails) {
|
|
1799
|
+
if (email) {
|
|
1800
|
+
await createEmailLookup(db, email, realCID);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1703
1803
|
|
|
1704
1804
|
// 4. Trigger Downstream Systems via Task Engine
|
|
1705
1805
|
if (pubsub) {
|
|
@@ -2624,6 +2724,8 @@ module.exports = {
|
|
|
2624
2724
|
fetchPopularInvestorMasterList,
|
|
2625
2725
|
isDeveloper,
|
|
2626
2726
|
lookupCidByEmail,
|
|
2727
|
+
createEmailLookup,
|
|
2728
|
+
hashEmail,
|
|
2627
2729
|
manageUserWatchlist,
|
|
2628
2730
|
fetchUserVerificationData,
|
|
2629
2731
|
requestPopularInvestorAddition,
|
|
@@ -104,6 +104,15 @@ const resolveUserIdentity = async (req, res, next) => {
|
|
|
104
104
|
// 1. Identify the requested user ID (from params/headers/body)
|
|
105
105
|
const requestedUserId = req.query.userCid || req.body.userCid || req.headers['x-user-cid'];
|
|
106
106
|
|
|
107
|
+
// SECURITY: For private routes, require Firebase Auth to prevent IDOR attacks
|
|
108
|
+
if (!isPublic && !authenticatedUserCid && !hasFirebaseAuth) {
|
|
109
|
+
// Private route without authentication - reject immediately
|
|
110
|
+
console.warn(`[Identity] Security violation: Private route ${req.path} accessed without Firebase Auth`);
|
|
111
|
+
return res.status(401).json({
|
|
112
|
+
error: "Authentication required. Please provide a valid Firebase ID token in the Authorization header."
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
107
116
|
// For public routes, userCid is optional
|
|
108
117
|
if (!requestedUserId && !isPublic && !hasFirebaseAuth) {
|
|
109
118
|
return res.status(400).json({ error: "Missing user identification (userCid)" });
|
|
@@ -150,24 +159,30 @@ const resolveUserIdentity = async (req, res, next) => {
|
|
|
150
159
|
req.targetUserId = authenticatedUserCid;
|
|
151
160
|
req.isImpersonating = false;
|
|
152
161
|
} else if (requestedUserId) {
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
// Consider requiring Firebase Auth for all authenticated routes
|
|
162
|
+
// SECURITY FIX: Legacy mode only allowed for public routes
|
|
163
|
+
// For private routes, Firebase Auth is REQUIRED to prevent IDOR attacks
|
|
156
164
|
if (!isPublic) {
|
|
157
|
-
|
|
165
|
+
// Private route without authentication - reject the request
|
|
166
|
+
console.warn(`[Identity] Security violation: Private route accessed without Firebase Auth. Requested CID: ${requestedUserId}`);
|
|
167
|
+
return res.status(401).json({
|
|
168
|
+
error: "Authentication required. Please provide a valid Firebase ID token in the Authorization header."
|
|
169
|
+
});
|
|
158
170
|
}
|
|
171
|
+
|
|
172
|
+
// Legacy mode only for public routes (backward compatibility)
|
|
173
|
+
// Public routes don't expose sensitive data, so this is acceptable
|
|
159
174
|
req.actualUserId = requestedUserId;
|
|
160
175
|
req.targetUserId = requestedUserId;
|
|
161
176
|
req.isImpersonating = false;
|
|
162
177
|
|
|
163
|
-
// Check for developer impersonation (legacy mode)
|
|
178
|
+
// Check for developer impersonation (legacy mode - only for public routes)
|
|
164
179
|
const impersonateId = req.headers['x-impersonate-cid'] || req.query.impersonateCid;
|
|
165
180
|
if (impersonateId && impersonateId !== requestedUserId) {
|
|
166
181
|
const isDev = await isDeveloper(db, requestedUserId);
|
|
167
182
|
if (isDev) {
|
|
168
183
|
req.targetUserId = impersonateId;
|
|
169
184
|
req.isImpersonating = true;
|
|
170
|
-
console.log(`[Identity] Dev ${requestedUserId} is impersonating ${impersonateId}`);
|
|
185
|
+
console.log(`[Identity] Dev ${requestedUserId} is impersonating ${impersonateId} (public route)`);
|
|
171
186
|
} else {
|
|
172
187
|
console.warn(`[Identity] Unauthorized impersonation attempt by ${requestedUserId}`);
|
|
173
188
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
|
-
const { initiateVerification, finalizeVerification, fetchUserVerificationData, lookupCidByEmail } = require('../helpers/data-fetchers/firestore.js');
|
|
2
|
+
const { initiateVerification, finalizeVerification, fetchUserVerificationData, lookupCidByEmail, createEmailLookup } = require('../helpers/data-fetchers/firestore.js');
|
|
3
3
|
const { requireFirebaseAuth } = require('../middleware/firebase_auth_middleware.js');
|
|
4
4
|
|
|
5
5
|
const router = express.Router();
|
|
@@ -79,4 +79,57 @@ router.post('/finalize', async (req, res) => {
|
|
|
79
79
|
}
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
// POST /verification/update-lookup
|
|
83
|
+
// Updates email-to-CID lookup documents when emails are added to verification
|
|
84
|
+
// Called by frontend after updating verification document with new emails
|
|
85
|
+
// This ensures O(1) lookups work immediately without waiting for migration
|
|
86
|
+
router.post('/update-lookup', requireFirebaseAuth, async (req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
const { db } = req.dependencies;
|
|
89
|
+
const { cid, emails } = req.body;
|
|
90
|
+
|
|
91
|
+
if (!cid || !Array.isArray(emails) || emails.length === 0) {
|
|
92
|
+
return res.status(400).json({
|
|
93
|
+
success: false,
|
|
94
|
+
error: 'CID and emails array are required'
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Verify the authenticated user owns this CID
|
|
99
|
+
const userEmail = req.firebaseUser?.email;
|
|
100
|
+
if (userEmail) {
|
|
101
|
+
const userData = await lookupCidByEmail(db, userEmail);
|
|
102
|
+
if (!userData || String(userData.cid) !== String(cid)) {
|
|
103
|
+
return res.status(403).json({
|
|
104
|
+
success: false,
|
|
105
|
+
error: 'Unauthorized: You can only update lookup for your own CID'
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Create lookup documents for all emails
|
|
111
|
+
const results = [];
|
|
112
|
+
for (const email of emails) {
|
|
113
|
+
if (email) {
|
|
114
|
+
try {
|
|
115
|
+
await createEmailLookup(db, email, cid);
|
|
116
|
+
results.push({ email, success: true });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error(`[update-lookup] Failed to create lookup for ${email}:`, error);
|
|
119
|
+
results.push({ email, success: false, error: error.message });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
res.json({
|
|
125
|
+
success: true,
|
|
126
|
+
message: `Updated ${results.filter(r => r.success).length} lookup document(s)`,
|
|
127
|
+
results
|
|
128
|
+
});
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error('[Verification Update Lookup] Error:', error);
|
|
131
|
+
res.status(500).json({ success: false, error: error.message });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
82
135
|
module.exports = router;
|
|
@@ -35,6 +35,14 @@ router.post('/auto-generate', async (req, res) => {
|
|
|
35
35
|
}
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
// GET /watchlists/public - Must be before /:id route to avoid route conflict
|
|
39
|
+
router.get('/public', async (req, res) => {
|
|
40
|
+
try {
|
|
41
|
+
const data = await fetchPublicWatchlists(req.dependencies.db, req.query.limit, req.query.offset);
|
|
42
|
+
res.json({ success: true, data });
|
|
43
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
44
|
+
});
|
|
45
|
+
|
|
38
46
|
// GET /watchlists/:id (Rec 12)
|
|
39
47
|
router.get('/:id', async (req, res) => {
|
|
40
48
|
try {
|
|
@@ -86,13 +94,6 @@ router.post('/manage', async (req, res) => {
|
|
|
86
94
|
});
|
|
87
95
|
|
|
88
96
|
// Public & Copy Routes (Existing)
|
|
89
|
-
router.get('/public', async (req, res) => {
|
|
90
|
-
try {
|
|
91
|
-
const data = await fetchPublicWatchlists(req.dependencies.db, req.query.limit, req.query.offset);
|
|
92
|
-
res.json({ success: true, data });
|
|
93
|
-
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
94
|
-
});
|
|
95
|
-
|
|
96
97
|
router.post('/:id/publish', async (req, res) => {
|
|
97
98
|
try {
|
|
98
99
|
const result = await publishWatchlistVersion(req.dependencies.db, req.targetUserId, req.params.id);
|