Files
Feeld/web/server/index.js
2026-03-20 18:49:48 -05:00

1041 lines
34 KiB
JavaScript
Executable File

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();
});