mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Egress optimization: - Add s-maxage + stale-while-revalidate to all API endpoints for Vercel CDN caching - Add vercel.json with immutable caching for hashed assets - Add gzip compression to sidecar responses >1KB - Add gzip to Railway RSS responses (4 paths previously uncompressed) - Increase polling intervals: markets/crypto 60s→120s, ETF/macro/stablecoins 60s→180s - Remove hardcoded Railway URL from theater-posture.js (now env-var only) PWA / Service Worker: - Add vite-plugin-pwa with autoUpdate strategy - Cache map tiles (CacheFirst), fonts (StaleWhileRevalidate), static assets - NetworkOnly for all /api/* routes (real-time data must be fresh) - Manual SW registration (web only, skip Tauri) - Add offline fallback page - Replace manual manifest with plugin-generated manifest Polymarket fix: - Route dev proxy through production Vercel (bypasses JA3 blocking) - Add 4th fallback tier: production URL as absolute fallback Desktop/Sidecar: - Dual-backend cache (_upstash-cache.js): Redis cloud + in-memory+file desktop - Settings window OK/Cancel redesign - Runtime config and secret injection improvements
201 lines
5.6 KiB
JavaScript
201 lines
5.6 KiB
JavaScript
// ACLED API proxy - keeps token server-side only
|
|
// Token is stored in ACLED_ACCESS_TOKEN (no VITE_ prefix)
|
|
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
|
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
|
|
import { recordCacheTelemetry } from './_cache-telemetry.js';
|
|
import { createIpRateLimiter } from './_ip-rate-limit.js';
|
|
|
|
export const config = { runtime: 'edge' };
|
|
|
|
const CACHE_KEY = 'acled:protests:v2';
|
|
const CACHE_TTL_SECONDS = 10 * 60;
|
|
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
|
|
|
|
// In-memory fallback cache when Redis is unavailable.
|
|
let fallbackCache = { data: null, timestamp: 0 };
|
|
|
|
const RATE_LIMIT = 10; // requests per minute
|
|
const RATE_WINDOW_MS = 60 * 1000;
|
|
const rateLimiter = createIpRateLimiter({
|
|
limit: RATE_LIMIT,
|
|
windowMs: RATE_WINDOW_MS,
|
|
maxEntries: 5000,
|
|
});
|
|
|
|
function getClientIp(req) {
|
|
return req.headers.get('x-forwarded-for')?.split(',')[0] ||
|
|
req.headers.get('x-real-ip') ||
|
|
'unknown';
|
|
}
|
|
|
|
function toErrorMessage(error) {
|
|
if (error instanceof Error) return error.message;
|
|
return String(error || 'unknown error');
|
|
}
|
|
|
|
export default async function handler(req) {
|
|
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
if (isDisallowedOrigin(req)) {
|
|
return new Response(null, { status: 403, headers: corsHeaders });
|
|
}
|
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
}
|
|
|
|
if (req.method !== 'GET') {
|
|
return Response.json({ error: 'Method not allowed', data: [] }, {
|
|
status: 405,
|
|
headers: corsHeaders,
|
|
});
|
|
}
|
|
|
|
if (isDisallowedOrigin(req)) {
|
|
return Response.json({ error: 'Origin not allowed', data: [] }, {
|
|
status: 403,
|
|
headers: corsHeaders,
|
|
});
|
|
}
|
|
|
|
const ip = getClientIp(req);
|
|
if (!rateLimiter.check(ip)) {
|
|
return Response.json({ error: 'Rate limited', data: [] }, {
|
|
status: 429,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Retry-After': '60',
|
|
},
|
|
});
|
|
}
|
|
|
|
const token = process.env.ACLED_ACCESS_TOKEN;
|
|
if (!token) {
|
|
return Response.json({
|
|
error: 'ACLED not configured',
|
|
data: [],
|
|
configured: false,
|
|
}, {
|
|
status: 200,
|
|
headers: corsHeaders,
|
|
});
|
|
}
|
|
|
|
const now = Date.now();
|
|
const cached = await getCachedJson(CACHE_KEY);
|
|
if (cached && typeof cached === 'object' && Array.isArray(cached.data)) {
|
|
recordCacheTelemetry('/api/acled', 'REDIS-HIT');
|
|
return Response.json(cached, {
|
|
status: 200,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
|
|
'X-Cache': 'REDIS-HIT',
|
|
},
|
|
});
|
|
}
|
|
|
|
if (fallbackCache.data && now - fallbackCache.timestamp < CACHE_TTL_MS) {
|
|
recordCacheTelemetry('/api/acled', 'MEMORY-HIT');
|
|
return Response.json(fallbackCache.data, {
|
|
status: 200,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
|
|
'X-Cache': 'MEMORY-HIT',
|
|
},
|
|
});
|
|
}
|
|
|
|
try {
|
|
const endDate = new Date().toISOString().split('T')[0];
|
|
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
const params = new URLSearchParams({
|
|
event_type: 'Protests',
|
|
event_date: `${startDate}|${endDate}`,
|
|
event_date_where: 'BETWEEN',
|
|
limit: '500',
|
|
_format: 'json',
|
|
});
|
|
|
|
const response = await fetch(`https://acleddata.com/api/acled/read?${params}`, {
|
|
headers: {
|
|
Accept: 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
return Response.json({
|
|
error: `ACLED API error: ${response.status}`,
|
|
details: text.substring(0, 200),
|
|
data: [],
|
|
}, {
|
|
status: response.status,
|
|
headers: corsHeaders,
|
|
});
|
|
}
|
|
|
|
const rawData = await response.json();
|
|
const events = Array.isArray(rawData?.data) ? rawData.data : [];
|
|
const sanitizedEvents = events.map((e) => ({
|
|
event_id_cnty: e.event_id_cnty,
|
|
event_date: e.event_date,
|
|
event_type: e.event_type,
|
|
sub_event_type: e.sub_event_type,
|
|
actor1: e.actor1,
|
|
actor2: e.actor2,
|
|
country: e.country,
|
|
admin1: e.admin1,
|
|
location: e.location,
|
|
latitude: e.latitude,
|
|
longitude: e.longitude,
|
|
fatalities: e.fatalities,
|
|
notes: typeof e.notes === 'string' ? e.notes.substring(0, 500) : undefined,
|
|
source: e.source,
|
|
tags: e.tags,
|
|
}));
|
|
|
|
const result = {
|
|
success: true,
|
|
count: sanitizedEvents.length,
|
|
data: sanitizedEvents,
|
|
cached_at: new Date().toISOString(),
|
|
};
|
|
|
|
fallbackCache = { data: result, timestamp: now };
|
|
void setCachedJson(CACHE_KEY, result, CACHE_TTL_SECONDS);
|
|
recordCacheTelemetry('/api/acled', 'MISS');
|
|
|
|
return Response.json(result, {
|
|
status: 200,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
|
|
'X-Cache': 'MISS',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (fallbackCache.data) {
|
|
recordCacheTelemetry('/api/acled', 'STALE');
|
|
return Response.json(fallbackCache.data, {
|
|
status: 200,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
|
|
'X-Cache': 'STALE',
|
|
},
|
|
});
|
|
}
|
|
|
|
recordCacheTelemetry('/api/acled', 'ERROR');
|
|
return Response.json({
|
|
error: `Fetch failed: ${toErrorMessage(error)}`,
|
|
data: [],
|
|
}, {
|
|
status: 500,
|
|
headers: corsHeaders,
|
|
});
|
|
}
|
|
}
|