Block banned-country locations and align GraphQL ops

Defense-in-depth banned-country gate covering every entry point that could
set a location Feeld's policy disallows (~60 countries from their support
article):

- New src/config/bannedCountries.ts — single source of truth (ISO codes + aliases)
- New src/utils/reverseGeocode.ts — Nominatim reverse lookup w/ localStorage cache
- New src/api/links/bannedCountryLink.ts — Apollo link chokepoint; intercepts
  every DeviceLocationUpdate mutation and refuses to forward if reverse-geocode
  resolves to a banned country. Catches Settings, Discover, Likes scanner, and
  ApiExplorer raw GraphQL alike.
- useLocation.tsx — setLocation throws BannedCountryError; saveLocation gate;
  sanitize banned entries on localStorage and server hydration
- Settings.tsx — block at search, saved-location pick, and save-current
- Likes.tsx — skip banned saved locations in scanForLikes and "Fuck It" scan
- server/index.js — PUT /api/saved-locations filters; readSavedLocations
  filters legacy banned entries so rotation cron is safe too
- nginx.conf — route additions for new backend endpoints

Plus the broader rc/realign-graphql-ops session work: GraphQL query/mutation
realignment after Feeld API changes, ApiExplorer updates, Profile/Discover/Likes
refinements, useFavorites hook, dataSync extensions, vite proxy adjustments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-06-01 18:30:37 -05:00
parent f84786e654
commit da2bab21e5
21 changed files with 1646 additions and 531 deletions
+126 -4
View File
@@ -315,6 +315,81 @@ app.delete('/api/sent-pings/:targetProfileId', (req, res) => {
}
});
// GET /api/favorites - Get all saved/favorited profiles
app.get('/api/favorites', (req, res) => {
const filePath = path.join(DATA_DIR, 'favorites.json');
if (fs.existsSync(filePath)) {
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
res.json(data);
} catch (e) {
console.error('Failed to read favorites.json:', e);
res.json({ favorites: [] });
}
} else {
res.json({ favorites: [] });
}
});
// POST /api/favorites - Add a favorited profile (idempotent)
app.post('/api/favorites', (req, res) => {
const { targetProfileId, profile } = req.body;
if (!targetProfileId) {
return res.status(400).json({ success: false, error: 'targetProfileId required' });
}
const filePath = path.join(DATA_DIR, 'favorites.json');
let data = { favorites: [], updatedAt: null };
if (fs.existsSync(filePath)) {
try {
data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (e) {
console.error('Failed to read favorites.json:', e);
}
}
if (!data.favorites.some(f => f.targetProfileId === targetProfileId)) {
data.favorites.unshift({
targetProfileId,
savedAt: Date.now(),
profile: profile || null,
});
data.updatedAt = new Date().toISOString();
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
res.json({ success: true, favorites: data.favorites });
});
// DELETE /api/favorites/:targetProfileId - Remove a favorite
app.delete('/api/favorites/:targetProfileId', (req, res) => {
const { targetProfileId } = req.params;
const filePath = path.join(DATA_DIR, 'favorites.json');
if (fs.existsSync(filePath)) {
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
data.favorites = data.favorites.filter(f => f.targetProfileId !== targetProfileId);
data.updatedAt = new Date().toISOString();
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
res.json({ success: true, favorites: data.favorites });
} catch (e) {
console.error('Failed to update favorites.json:', e);
res.status(500).json({ success: false, error: e.message });
}
} else {
res.json({ success: true, favorites: [] });
}
});
// DELETE /api/favorites - Clear all favorites
app.delete('/api/favorites', (req, res) => {
const filePath = path.join(DATA_DIR, 'favorites.json');
const data = { favorites: [], updatedAt: new Date().toISOString() };
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
res.json({ success: true, favorites: [] });
});
// GET /api/disliked-profiles - Get all disliked profiles
app.get('/api/disliked-profiles', (req, res) => {
const filePath = path.join(DATA_DIR, 'dislikedProfiles.json');
@@ -1186,6 +1261,35 @@ 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');
// Countries where Feeld is NOT available — mirror of web/src/config/bannedCountries.ts.
// Keep in sync if that file changes. Used to filter incoming saved-location syncs.
const BANNED_COUNTRY_CODES = new Set([
'AL','DZ','AO','AM','AZ','BH','BD','BJ','BW','BN','BF','CM','TD','CN','CG','CU',
'EG','GA','GM','HK','IR','IQ','JO','KZ','KE','LA','LR','MO','MG','MW','ML','MR',
'MU','MM','MA','MZ','NA','NE','OM','PK','PG','PH','QA','RU','SA','SN','SL','SD',
'TW','TJ','TZ','TH','TG','TN','TM','UG','UZ','YE','ZM','ZW',
]);
const BANNED_COUNTRY_NAMES = new Set([
'albania','algeria','angola','armenia','azerbaijan','bahrain','bangladesh','benin','botswana',
'brunei darussalam','brunei','burkina faso','cameroon','chad','china',"people's republic of china",
'republic of the congo','congo','congo, republic of','congo-brazzaville','cuba','egypt','gabon',
'gambia','the gambia','hong kong','iran','islamic republic of iran','iraq','jordan','kazakhstan',
'kenya','laos',"lao people's democratic republic",'lao pdr','liberia','macau','macao','madagascar',
'malawi','mali','mauritania','mauritius','myanmar','burma','mayanmar','morocco','mozambique',
'namibia','niger','oman','pakistan','papua new guinea','philippines','qatar','russia',
'russian federation','saudi arabia','senegal','sierra leone','sudan','taiwan',
'taiwan, province of china','republic of china','tajikistan','tanzania',
'united republic of tanzania','thailand','togo','tunisia','turkmenistan','uganda','uzbekistan',
'yemen','zambia','zimbabwe',
]);
function isBannedLocation(loc) {
if (!loc) return false;
if (loc.countryCode && BANNED_COUNTRY_CODES.has(String(loc.countryCode).trim().toUpperCase())) return true;
if (loc.country && BANNED_COUNTRY_NAMES.has(String(loc.country).trim().toLowerCase())) return true;
return false;
}
class FeeldAPIClient {
constructor() {
this.accessToken = null;
@@ -1393,7 +1497,11 @@ function writeRotationState(state) {
function readSavedLocations() {
if (fs.existsSync(SAVED_LOCATIONS_FILE)) {
try {
return JSON.parse(fs.readFileSync(SAVED_LOCATIONS_FILE, 'utf8'));
const raw = JSON.parse(fs.readFileSync(SAVED_LOCATIONS_FILE, 'utf8'));
const arr = Array.isArray(raw) ? raw : [];
// Strip any banned-country entries that might be left over from before
// the country filter was added. Rotation and selection both go through here.
return arr.filter((loc) => !isBannedLocation(loc));
} catch (e) {
console.error('[Rotation] Failed to read saved locations:', e.message);
}
@@ -1683,14 +1791,28 @@ app.get('/api/saved-locations', (req, res) => {
res.json(readSavedLocations());
});
// PUT /api/saved-locations — Browser syncs its saved locations here
// PUT /api/saved-locations — Browser syncs its saved locations here.
// Strips any locations in Feeld-restricted countries (defense in depth against
// stale clients or directly-crafted requests).
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 });
const filtered = [];
const rejected = [];
for (const loc of locations) {
if (isBannedLocation(loc)) {
rejected.push({ id: loc.id, name: loc.name, country: loc.country, countryCode: loc.countryCode });
} else {
filtered.push(loc);
}
}
if (rejected.length) {
console.warn(`[saved-locations] dropped ${rejected.length} banned-country location(s):`, rejected);
}
writeSavedLocations(filtered);
res.json({ success: true, count: filtered.length, rejected: rejected.length });
});
// ============================================================