import express from 'express'; import cors from 'cors'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Always use ../data (relative to server/) so Docker and local dev use the same directory const DATA_DIR = process.env.DATA_PATH || path.join(__dirname, '..', 'data'); const PORT = process.env.PORT || 3001; const app = express(); app.use(cors()); app.use(express.json({ limit: '5mb' })); // Ensure data directory exists if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } // Auth credentials file const AUTH_FILE = path.join(DATA_DIR, 'auth.json'); // Read auth credentials const getAuthCredentials = () => { if (fs.existsSync(AUTH_FILE)) { try { return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8')); } catch (e) { console.error('Failed to read auth file:', e); } } // Default credentials if file doesn't exist return { username: 'admin', password: 'feeld123' }; }; // Simple session store (in-memory, resets on restart) const sessions = new Map(); // Helper to get user data file path const getUserDataPath = (userId) => { // Sanitize userId to prevent path traversal const safeId = userId.replace(/[^a-zA-Z0-9-_]/g, '_'); return path.join(DATA_DIR, `${safeId}.json`); }; // Helper to read user data const readUserData = (userId) => { const filePath = getUserDataPath(userId); if (fs.existsSync(filePath)) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (e) { console.error('Failed to read user data:', e); return {}; } } return {}; }; // Helper to write user data const writeUserData = (userId, data) => { const filePath = getUserDataPath(userId); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); }; // GET /api/data/:userId - Get all user data app.get('/api/data/:userId', (req, res) => { const { userId } = req.params; const data = readUserData(userId); res.json(data); }); // PUT /api/data/:userId - Update all user data app.put('/api/data/:userId', (req, res) => { const { userId } = req.params; const data = req.body; writeUserData(userId, data); res.json({ success: true, data }); }); // GET /api/data/:userId/:key - Get specific key app.get('/api/data/:userId/:key', (req, res) => { const { userId, key } = req.params; const data = readUserData(userId); res.json({ [key]: data[key] || null }); }); // PUT /api/data/:userId/:key - Update specific key app.put('/api/data/:userId/:key', (req, res) => { const { userId, key } = req.params; const { value } = req.body; const data = readUserData(userId); data[key] = value; data.updatedAt = new Date().toISOString(); writeUserData(userId, data); res.json({ success: true, [key]: value }); }); // DELETE /api/data/:userId/:key - Delete specific key app.delete('/api/data/:userId/:key', (req, res) => { const { userId, key } = req.params; const data = readUserData(userId); delete data[key]; writeUserData(userId, data); res.json({ success: true }); }); // POST /api/data/:userId/liked-profiles - Add a liked profile app.post('/api/data/:userId/liked-profiles', (req, res) => { const { userId } = req.params; const { id, name } = req.body; const data = readUserData(userId); if (!data.likedProfiles) { data.likedProfiles = []; } // Don't add duplicates if (!data.likedProfiles.some(p => p.id === id)) { data.likedProfiles.unshift({ id, name, likedAt: Date.now(), }); data.updatedAt = new Date().toISOString(); writeUserData(userId, data); } res.json({ success: true, likedProfiles: data.likedProfiles }); }); // DELETE /api/data/:userId/liked-profiles/:profileId - Remove a liked profile app.delete('/api/data/:userId/liked-profiles/:profileId', (req, res) => { const { userId, profileId } = req.params; const data = readUserData(userId); if (data.likedProfiles) { data.likedProfiles = data.likedProfiles.filter(p => p.id !== profileId); writeUserData(userId, data); } res.json({ success: true, likedProfiles: data.likedProfiles || [] }); }); // GET /api/who-liked-you - Get profiles who liked you (discovered via scanning) app.get('/api/who-liked-you', (req, res) => { const filePath = path.join(DATA_DIR, 'whoLikedYou.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); res.json(data); } catch (e) { console.error('Failed to read whoLikedYou.json:', e); res.json({ profiles: [] }); } } else { res.json({ profiles: [] }); } }); // POST /api/who-liked-you - Add or update a profile who liked you app.post('/api/who-liked-you', (req, res) => { const { profile } = req.body; const filePath = path.join(DATA_DIR, 'whoLikedYou.json'); let data = { profiles: [], updatedAt: null }; if (fs.existsSync(filePath)) { try { data = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (e) { console.error('Failed to read whoLikedYou.json:', e); } } // Find existing profile by ID const existingIndex = data.profiles.findIndex(p => p.id === profile.id); if (existingIndex >= 0) { // Update existing profile, preserving original discoveredAt const originalDiscoveredAt = data.profiles[existingIndex].discoveredAt; data.profiles[existingIndex] = { ...profile, discoveredAt: originalDiscoveredAt, updatedAt: new Date().toISOString(), }; console.log('Updated existing profile:', profile.imaginaryName); } else { // Add new profile data.profiles.unshift({ ...profile, discoveredAt: new Date().toISOString(), }); console.log('Added new profile:', profile.imaginaryName); } data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); res.json({ success: true, profile }); }); // DELETE /api/who-liked-you/:profileId - Remove a discovered profile app.delete('/api/who-liked-you/:profileId', (req, res) => { const { profileId } = req.params; const filePath = path.join(DATA_DIR, 'whoLikedYou.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); data.profiles = data.profiles.filter(p => p.id !== profileId); data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); res.json({ success: true, profiles: data.profiles }); } catch (e) { console.error('Failed to update whoLikedYou.json:', e); res.status(500).json({ success: false, error: e.message }); } } else { res.json({ success: true, profiles: [] }); } }); // GET /api/sent-pings - Get all sent pings app.get('/api/sent-pings', (req, res) => { const filePath = path.join(DATA_DIR, 'sentPings.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); res.json(data); } catch (e) { console.error('Failed to read sentPings.json:', e); res.json({ pings: [] }); } } else { res.json({ pings: [] }); } }); // POST /api/sent-pings - Add a sent ping app.post('/api/sent-pings', (req, res) => { const { targetProfileId, targetName, message } = req.body; const filePath = path.join(DATA_DIR, 'sentPings.json'); let data = { pings: [], updatedAt: null }; if (fs.existsSync(filePath)) { try { data = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (e) { console.error('Failed to read sentPings.json:', e); } } // Don't add duplicates if (!data.pings.some(p => p.targetProfileId === targetProfileId)) { data.pings.unshift({ targetProfileId, targetName, message, sentAt: Date.now(), status: 'SENT', }); data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); } res.json({ success: true, pings: data.pings }); }); // PUT /api/sent-pings/:targetProfileId - Update a sent ping status app.put('/api/sent-pings/:targetProfileId', (req, res) => { const { targetProfileId } = req.params; const { status } = req.body; const filePath = path.join(DATA_DIR, 'sentPings.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); const ping = data.pings.find(p => p.targetProfileId === targetProfileId); if (ping) { ping.status = status; data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); } res.json({ success: true, pings: data.pings }); } catch (e) { console.error('Failed to update sentPings.json:', e); res.status(500).json({ success: false, error: e.message }); } } else { res.json({ success: false, error: 'No sent pings found' }); } }); // DELETE /api/sent-pings/:targetProfileId - Remove a sent ping app.delete('/api/sent-pings/:targetProfileId', (req, res) => { const { targetProfileId } = req.params; const filePath = path.join(DATA_DIR, 'sentPings.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); data.pings = data.pings.filter(p => p.targetProfileId !== targetProfileId); data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); res.json({ success: true, pings: data.pings }); } catch (e) { console.error('Failed to update sentPings.json:', e); res.status(500).json({ success: false, error: e.message }); } } else { res.json({ success: true, pings: [] }); } }); // GET /api/disliked-profiles - Get all disliked profiles app.get('/api/disliked-profiles', (req, res) => { const filePath = path.join(DATA_DIR, 'dislikedProfiles.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); res.json(data); } catch (e) { console.error('Failed to read dislikedProfiles.json:', e); res.json({ profiles: [] }); } } else { res.json({ profiles: [] }); } }); // POST /api/disliked-profiles - Add a disliked profile app.post('/api/disliked-profiles', (req, res) => { const { profile } = req.body; const filePath = path.join(DATA_DIR, 'dislikedProfiles.json'); let data = { profiles: [], updatedAt: null }; if (fs.existsSync(filePath)) { try { data = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (e) { console.error('Failed to read dislikedProfiles.json:', e); } } // Don't add duplicates - check by ID if (!data.profiles.some(p => p.id === profile.id)) { data.profiles.unshift({ ...profile, dislikedAt: new Date().toISOString(), }); data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); console.log('Added disliked profile:', profile.imaginaryName); } res.json({ success: true, profiles: data.profiles }); }); // DELETE /api/disliked-profiles/:profileId - Remove a disliked profile app.delete('/api/disliked-profiles/:profileId', (req, res) => { const { profileId } = req.params; const filePath = path.join(DATA_DIR, 'dislikedProfiles.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); data.profiles = data.profiles.filter(p => p.id !== profileId); data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); res.json({ success: true, profiles: data.profiles }); } catch (e) { console.error('Failed to update dislikedProfiles.json:', e); res.status(500).json({ success: false, error: e.message }); } } else { res.json({ success: true, profiles: [] }); } }); // GET /api/discovered-profiles - Get all discovered profiles cache app.get('/api/discovered-profiles', (req, res) => { const filePath = path.join(DATA_DIR, 'discoveredProfiles.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); res.json(data); } catch (e) { console.error('Failed to read discoveredProfiles.json:', e); res.json({ profiles: [] }); } } else { res.json({ profiles: [] }); } }); // POST /api/discovered-profiles/batch - Batch upsert discovered profiles app.post('/api/discovered-profiles/batch', (req, res) => { const { profiles: incoming } = req.body; if (!Array.isArray(incoming) || incoming.length === 0) { return res.status(400).json({ success: false, error: 'profiles array required' }); } const filePath = path.join(DATA_DIR, 'discoveredProfiles.json'); let data = { profiles: [], updatedAt: null }; if (fs.existsSync(filePath)) { try { data = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (e) { console.error('Failed to read discoveredProfiles.json:', e); } } const existingMap = new Map(data.profiles.map(p => [p.id, p])); let added = 0; let updated = 0; for (const profile of incoming) { if (!profile.id) continue; const existing = existingMap.get(profile.id); if (existing) { // Update but preserve original discoveredAt existingMap.set(profile.id, { ...profile, discoveredAt: existing.discoveredAt, updatedAt: new Date().toISOString(), }); updated++; } else { existingMap.set(profile.id, { ...profile, discoveredAt: profile.discoveredAt || new Date().toISOString(), }); added++; } } // Convert back to array, sort by discoveredAt descending (newest first) let allProfiles = Array.from(existingMap.values()); allProfiles.sort((a, b) => { const dateA = a.discoveredAt ? new Date(a.discoveredAt).getTime() : 0; const dateB = b.discoveredAt ? new Date(b.discoveredAt).getTime() : 0; return dateB - dateA; }); // Cap at 2000 profiles, evict oldest if (allProfiles.length > 2000) { allProfiles = allProfiles.slice(0, 2000); } data.profiles = allProfiles; data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); console.log(`Discovered profiles batch: +${added} new, ${updated} updated, ${allProfiles.length} total`); res.json({ success: true, added, updated, total: allProfiles.length }); }); // DELETE /api/discovered-profiles/:profileId - Remove a discovered profile app.delete('/api/discovered-profiles/:profileId', (req, res) => { const { profileId } = req.params; const filePath = path.join(DATA_DIR, 'discoveredProfiles.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); data.profiles = data.profiles.filter(p => p.id !== profileId); data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); res.json({ success: true, profiles: data.profiles }); } catch (e) { console.error('Failed to update discoveredProfiles.json:', e); res.status(500).json({ success: false, error: e.message }); } } else { res.json({ success: true, profiles: [] }); } }); // Health check app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Login endpoint app.post('/api/auth/login', (req, res) => { const { username, password } = req.body; const creds = getAuthCredentials(); if (username === creds.username && password === creds.password) { // Generate a simple session token const token = crypto.randomUUID(); sessions.set(token, { username, createdAt: Date.now() }); res.json({ success: true, token }); } else { res.status(401).json({ success: false, error: 'Invalid credentials' }); } }); // Verify session endpoint app.get('/api/auth/verify', (req, res) => { const token = req.headers.authorization?.replace('Bearer ', ''); if (token && sessions.has(token)) { res.json({ success: true, authenticated: true }); } else { res.status(401).json({ success: false, authenticated: false }); } }); // Logout endpoint app.post('/api/auth/logout', (req, res) => { const token = req.headers.authorization?.replace('Bearer ', ''); if (token) { sessions.delete(token); } res.json({ success: true }); }); // ============================================================ // FeeldAPI Client — makes direct GraphQL calls to Feeld backend // ============================================================ const FIREBASE_API_KEY = 'AIzaSyD9o9mzulN50-hqOwF6ww9pxUNUxwVOCXA'; const FIREBASE_REFRESH_URL = `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`; const GRAPHQL_ENDPOINT = 'https://core.api.fldcore.com/graphql'; const APP_VERSION = '8.8.3'; const OS_VERSION = '18.6.2'; const AUTH_TOKENS_FILE = path.join(DATA_DIR, 'auth-tokens.json'); const ROTATION_STATE_FILE = path.join(DATA_DIR, 'locationRotation.json'); const SAVED_LOCATIONS_FILE = path.join(DATA_DIR, 'savedLocations.json'); class FeeldAPIClient { constructor() { this.accessToken = null; this.expiresAt = 0; this.profileId = null; this.refreshToken = null; this.analyticsId = null; } loadCredentials() { if (fs.existsSync(AUTH_TOKENS_FILE)) { try { const data = JSON.parse(fs.readFileSync(AUTH_TOKENS_FILE, 'utf8')); this.profileId = data.profileId || null; this.refreshToken = data.refreshToken || null; this.analyticsId = data.analyticsId || null; return !!this.profileId && !!this.refreshToken; } catch (e) { console.error('[FeeldAPI] Failed to load credentials:', e.message); return false; } } return false; } saveCredentials(profileId, refreshToken, analyticsId) { this.profileId = profileId; this.refreshToken = refreshToken; this.analyticsId = analyticsId || this.analyticsId; fs.writeFileSync(AUTH_TOKENS_FILE, JSON.stringify({ profileId, refreshToken, analyticsId: this.analyticsId, updatedAt: new Date().toISOString(), }, null, 2)); console.log('[FeeldAPI] Credentials saved'); } async refreshAccessToken() { if (!this.refreshToken) { throw new Error('No refresh token available — seed from browser first'); } const response = await fetch(FIREBASE_REFRESH_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: this.refreshToken, }), }); if (!response.ok) { const text = await response.text(); throw new Error(`Firebase token refresh failed: ${response.status} ${text}`); } const data = await response.json(); this.accessToken = data.access_token; this.expiresAt = Date.now() + parseInt(data.expires_in) * 1000; // Update stored refresh token (Firebase rotates them) if (data.refresh_token && data.refresh_token !== this.refreshToken) { this.refreshToken = data.refresh_token; this.saveCredentials(this.profileId, this.refreshToken, this.analyticsId); } console.log('[FeeldAPI] Token refreshed, expires in', data.expires_in, 'seconds'); return this.accessToken; } async getToken() { if (!this.accessToken || Date.now() >= this.expiresAt - 60000) { await this.refreshAccessToken(); } return this.accessToken; } async graphql(operationName, query, variables = {}) { const token = await this.getToken(); const transactionId = crypto.randomUUID(); const response = await fetch(GRAPHQL_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, 'x-profile-id': this.profileId, 'x-app-version': APP_VERSION, 'x-device-os': 'ios', 'x-os-version': OS_VERSION, 'x-transaction-id': transactionId, 'x-event-analytics-id': this.analyticsId || crypto.randomUUID(), 'User-Agent': 'feeld-mobile', 'Accept': '*/*', }, body: JSON.stringify({ operationName, query, variables }), }); if (!response.ok) { const text = await response.text(); throw new Error(`GraphQL request failed: ${response.status} ${text}`); } const result = await response.json(); if (result.errors) { throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`); } return result.data; } async updateLocation(lat, lng) { return this.graphql('DeviceLocationUpdate', ` mutation DeviceLocationUpdate($input: DeviceLocationInput!) { deviceLocationUpdate(input: $input) { id location { device { latitude longitude geocode { city country __typename } __typename } __typename } __typename } } `, { input: { latitude: lat, longitude: lng } }); } async getSearchSettings() { return this.graphql('DiscoverSearchSettingsQuery', ` query DiscoverSearchSettingsQuery($profileId: String!) { profile(id: $profileId) { id ageRange distanceMax lookingFor desiringFor recentlyOnline __typename } } `, { profileId: this.profileId }); } async discoverProfiles(filters = {}) { return this.graphql('DiscoverProfiles', ` query DiscoverProfiles($input: ProfileDiscoveryInput!) { discovery(input: $input) { nodes { id age imaginaryName gender sexuality isIncognito isMajestic verificationStatus connectionGoals desires bio interests distance { km mi __typename } photos { id publicId pictureIsSafe pictureIsPrivate pictureUrl pictureUrls { small medium large __typename } pictureType __typename } interactionStatus { message mine theirs __typename } __typename } hasNextBatch __typename } } `, { input: { filters } }); } } const feeldAPI = new FeeldAPIClient(); // ============================================================ // Location Rotation Cron // ============================================================ function readRotationState() { if (fs.existsSync(ROTATION_STATE_FILE)) { try { return JSON.parse(fs.readFileSync(ROTATION_STATE_FILE, 'utf8')); } catch (e) { console.error('[Rotation] Failed to read state:', e.message); } } return { groups: [], activeGroupId: null, intervalHours: 4, enabled: false, currentLocationIdx: 0, lastRotation: null, lastResult: null, history: [], }; } function writeRotationState(state) { fs.writeFileSync(ROTATION_STATE_FILE, JSON.stringify(state, null, 2)); } function readSavedLocations() { if (fs.existsSync(SAVED_LOCATIONS_FILE)) { try { return JSON.parse(fs.readFileSync(SAVED_LOCATIONS_FILE, 'utf8')); } catch (e) { console.error('[Rotation] Failed to read saved locations:', e.message); } } return []; } function writeSavedLocations(locations) { fs.writeFileSync(SAVED_LOCATIONS_FILE, JSON.stringify(locations, null, 2)); } // Helper to sanitize profile fields that may be {__typename: "..."} objects function safeStr(v) { return typeof v === 'string' ? v : ''; } function sanitizeProfile(p) { return { id: p.id, imaginaryName: safeStr(p.imaginaryName), age: p.age, gender: safeStr(p.gender), sexuality: safeStr(p.sexuality), bio: safeStr(p.bio), desires: p.desires, connectionGoals: p.connectionGoals, interests: p.interests, isMajestic: p.isMajestic, isIncognito: p.isIncognito, verificationStatus: safeStr(p.verificationStatus), distance: p.distance, photos: p.photos, interactionStatus: p.interactionStatus, }; } async function performRotation() { const state = readRotationState(); if (!state.enabled || !state.activeGroupId) return; const now = Date.now(); if (state.lastRotation && (now - new Date(state.lastRotation).getTime()) < state.intervalHours * 3600000) { return; // Not time yet } const group = state.groups.find(g => g.id === state.activeGroupId); if (!group || group.locationIds.length === 0) { console.log('[Rotation] Active group not found or empty'); return; } // Load credentials if (!feeldAPI.loadCredentials()) { console.log('[Rotation] No credentials — seed from browser first'); state.lastResult = { status: 'error', error: 'No credentials', timestamp: new Date().toISOString() }; writeRotationState(state); return; } // Resolve next location const savedLocations = readSavedLocations(); const nextIdx = (state.currentLocationIdx + 1) % group.locationIds.length; const locationId = group.locationIds[nextIdx]; const loc = savedLocations.find(l => l.id === locationId); if (!loc) { console.log('[Rotation] Location ID not found:', locationId); state.lastResult = { status: 'error', error: `Location ${locationId} not found`, timestamp: new Date().toISOString() }; writeRotationState(state); return; } console.log(`[Rotation] Rotating to: ${loc.name} (${loc.latitude}, ${loc.longitude})`); try { // 1. Update location await feeldAPI.updateLocation(loc.latitude, loc.longitude); console.log('[Rotation] Location updated'); // 2. Fetch search settings let filters = { ageRange: [22, 59], maxDistance: 100, lookingFor: ['WOMAN', 'MAN_WOMAN_COUPLE', 'WOMAN_WOMAN_COUPLE'], recentlyOnline: false, desiringFor: [], }; try { const settings = await feeldAPI.getSearchSettings(); const profile = settings?.profile; if (profile) { filters = { ageRange: profile.ageRange || filters.ageRange, maxDistance: profile.distanceMax || filters.maxDistance, lookingFor: profile.lookingFor || filters.lookingFor, recentlyOnline: profile.recentlyOnline || false, desiringFor: profile.desiringFor || [], }; } } catch (e) { console.log('[Rotation] Failed to fetch search settings, using defaults:', e.message); } // 3. Discover profiles (one batch) const discoveryResult = await feeldAPI.discoverProfiles(filters); const profiles = discoveryResult?.discovery?.nodes || []; console.log(`[Rotation] Discovered ${profiles.length} profiles at ${loc.name}`); // 4. Save who-liked-you profiles let likedMeCount = 0; const whoLikedYouPath = path.join(DATA_DIR, 'whoLikedYou.json'); let whoLikedYouData = { profiles: [], updatedAt: null }; if (fs.existsSync(whoLikedYouPath)) { try { whoLikedYouData = JSON.parse(fs.readFileSync(whoLikedYouPath, 'utf8')); } catch (e) {} } for (const p of profiles) { if (p.interactionStatus?.theirs === 'LIKED') { const sanitized = sanitizeProfile(p); const existingIdx = whoLikedYouData.profiles.findIndex(ep => ep.id === p.id); if (existingIdx >= 0) { const orig = whoLikedYouData.profiles[existingIdx].discoveredAt; whoLikedYouData.profiles[existingIdx] = { ...sanitized, discoveredAt: orig, updatedAt: new Date().toISOString() }; } else { whoLikedYouData.profiles.unshift({ ...sanitized, discoveredAt: new Date().toISOString() }); } likedMeCount++; } } if (likedMeCount > 0) { whoLikedYouData.updatedAt = new Date().toISOString(); fs.writeFileSync(whoLikedYouPath, JSON.stringify(whoLikedYouData, null, 2)); console.log(`[Rotation] Saved ${likedMeCount} who-liked-you profiles`); } // 5. Batch-save all discovered profiles if (profiles.length > 0) { const discoveredPath = path.join(DATA_DIR, 'discoveredProfiles.json'); let discoveredData = { profiles: [], updatedAt: null }; if (fs.existsSync(discoveredPath)) { try { discoveredData = JSON.parse(fs.readFileSync(discoveredPath, 'utf8')); } catch (e) {} } const existingMap = new Map(discoveredData.profiles.map(p => [p.id, p])); for (const p of profiles) { const sanitized = sanitizeProfile(p); const existing = existingMap.get(p.id); if (existing) { existingMap.set(p.id, { ...sanitized, discoveredAt: existing.discoveredAt, updatedAt: new Date().toISOString() }); } else { existingMap.set(p.id, { ...sanitized, discoveredAt: new Date().toISOString() }); } } let allProfiles = Array.from(existingMap.values()); allProfiles.sort((a, b) => new Date(b.discoveredAt || 0).getTime() - new Date(a.discoveredAt || 0).getTime()); if (allProfiles.length > 2000) allProfiles = allProfiles.slice(0, 2000); discoveredData.profiles = allProfiles; discoveredData.updatedAt = new Date().toISOString(); fs.writeFileSync(discoveredPath, JSON.stringify(discoveredData, null, 2)); } // 6. Update state const result = { status: 'success', profilesFound: profiles.length, likedMeFound: likedMeCount, location: loc.name, timestamp: new Date().toISOString(), }; state.currentLocationIdx = nextIdx; state.lastRotation = new Date().toISOString(); state.lastResult = result; state.history = [result, ...(state.history || [])].slice(0, 10); writeRotationState(state); console.log(`[Rotation] Complete: ${profiles.length} profiles, ${likedMeCount} liked me at ${loc.name}`); } catch (e) { console.error('[Rotation] Error:', e.message); const result = { status: 'error', error: e.message, location: loc.name, timestamp: new Date().toISOString() }; state.lastResult = result; state.history = [result, ...(state.history || [])].slice(0, 10); writeRotationState(state); } } // Check every 5 minutes if it's time to rotate let rotationInterval = null; function startRotationCron() { if (rotationInterval) clearInterval(rotationInterval); rotationInterval = setInterval(() => { performRotation().catch(e => console.error('[Rotation] Cron error:', e.message)); }, 5 * 60 * 1000); // 5 minutes console.log('[Rotation] Cron started (checks every 5 min)'); } // ============================================================ // Location Rotation API Endpoints // ============================================================ // GET /api/location-rotation — Return current rotation state app.get('/api/location-rotation', (req, res) => { res.json(readRotationState()); }); // PUT /api/location-rotation — Update rotation config app.put('/api/location-rotation', (req, res) => { const { groups, activeGroupId, intervalHours, enabled } = req.body; const state = readRotationState(); if (groups !== undefined) state.groups = groups; if (activeGroupId !== undefined) state.activeGroupId = activeGroupId; if (intervalHours !== undefined) state.intervalHours = intervalHours; if (enabled !== undefined) state.enabled = enabled; writeRotationState(state); res.json({ success: true, state }); }); // POST /api/location-rotation/rotate-now — Force immediate rotation app.post('/api/location-rotation/rotate-now', async (req, res) => { const state = readRotationState(); if (!state.enabled || !state.activeGroupId) { return res.status(400).json({ success: false, error: 'Rotation not enabled or no active group' }); } // Clear lastRotation to force immediate execution state.lastRotation = null; writeRotationState(state); try { await performRotation(); res.json({ success: true, state: readRotationState() }); } catch (e) { res.status(500).json({ success: false, error: e.message }); } }); // POST /api/location-rotation/seed-token — Browser sends its current refresh token app.post('/api/location-rotation/seed-token', (req, res) => { const { refreshToken, profileId, analyticsId } = req.body; if (!refreshToken || !profileId) { return res.status(400).json({ success: false, error: 'refreshToken and profileId required' }); } feeldAPI.saveCredentials(profileId, refreshToken, analyticsId); res.json({ success: true }); }); // GET /api/location-rotation/status — Quick status for UI app.get('/api/location-rotation/status', (req, res) => { const state = readRotationState(); const savedLocations = readSavedLocations(); let currentLocation = null; let nextRotation = null; if (state.enabled && state.activeGroupId) { const group = state.groups.find(g => g.id === state.activeGroupId); if (group && group.locationIds.length > 0) { const locId = group.locationIds[state.currentLocationIdx % group.locationIds.length]; currentLocation = savedLocations.find(l => l.id === locId) || null; } if (state.lastRotation) { const nextTime = new Date(state.lastRotation).getTime() + state.intervalHours * 3600000; nextRotation = new Date(nextTime).toISOString(); } } res.json({ enabled: state.enabled, activeGroup: state.activeGroupId ? state.groups.find(g => g.id === state.activeGroupId) : null, currentLocation, nextRotation, lastResult: state.lastResult, intervalHours: state.intervalHours, }); }); // GET /api/saved-locations — Return saved locations list app.get('/api/saved-locations', (req, res) => { res.json(readSavedLocations()); }); // PUT /api/saved-locations — Browser syncs its saved locations here app.put('/api/saved-locations', (req, res) => { const { locations } = req.body; if (!Array.isArray(locations)) { return res.status(400).json({ success: false, error: 'locations array required' }); } writeSavedLocations(locations); res.json({ success: true, count: locations.length }); }); // ============================================================ // Start server + rotation cron // ============================================================ app.listen(PORT, () => { console.log(`Data server running on http://localhost:${PORT}`); console.log(`Data stored in: ${DATA_DIR}`); // Load credentials and start rotation cron feeldAPI.loadCredentials(); startRotationCron(); });