diff --git a/api/oref-alerts.js b/api/oref-alerts.js new file mode 100644 index 000000000..0276154cb --- /dev/null +++ b/api/oref-alerts.js @@ -0,0 +1,93 @@ +import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; + +export const config = { runtime: 'edge' }; + +function getRelayBaseUrl() { + const relayUrl = process.env.WS_RELAY_URL; + if (!relayUrl) return null; + return relayUrl.replace('wss://', 'https://').replace('ws://', 'http://').replace(/\/$/, ''); +} + +function getRelayHeaders(baseHeaders = {}) { + const headers = { ...baseHeaders }; + const relaySecret = process.env.RELAY_SHARED_SECRET || ''; + if (relaySecret) { + const relayHeader = (process.env.RELAY_AUTH_HEADER || 'x-relay-key').toLowerCase(); + headers[relayHeader] = relaySecret; + headers.Authorization = `Bearer ${relaySecret}`; + } + return headers; +} + +async function fetchWithTimeout(url, options, timeoutMs = 15000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +export default async function handler(req) { + const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS'); + + if (isDisallowedOrigin(req)) { + return new Response(JSON.stringify({ error: 'Origin not allowed' }), { + status: 403, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }); + } + if (req.method !== 'GET') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + const requestUrl = new URL(req.url); + const endpoint = requestUrl.searchParams.get('endpoint'); + const isHistory = endpoint === 'history'; + + const relayBaseUrl = getRelayBaseUrl(); + + if (relayBaseUrl) { + try { + const relayPath = isHistory ? '/oref/history' : '/oref/alerts'; + const response = await fetchWithTimeout(`${relayBaseUrl}${relayPath}`, { + headers: getRelayHeaders({ Accept: 'application/json' }), + }, 12000); + + if (response.ok) { + const cacheControl = isHistory + ? 'public, max-age=30, s-maxage=30, stale-while-revalidate=10' + : 'public, max-age=5, s-maxage=5, stale-while-revalidate=3'; + return new Response(await response.text(), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': cacheControl, + ...corsHeaders, + }, + }); + } + } catch { + // Relay failed + } + } + + return new Response(JSON.stringify({ + configured: false, + alerts: [], + historyCount24h: 0, + timestamp: new Date().toISOString(), + error: 'No data source available', + }), { + status: 503, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); +} diff --git a/scripts/ais-relay.cjs b/scripts/ais-relay.cjs index 2abfcc316..c1016bbf1 100644 --- a/scripts/ais-relay.cjs +++ b/scripts/ais-relay.cjs @@ -73,6 +73,14 @@ const RELAY_RSS_RATE_LIMIT_MAX = Number.isFinite(Number(process.env.RELAY_RSS_RA const RELAY_LOG_THROTTLE_MS = Math.max(1000, Number(process.env.RELAY_LOG_THROTTLE_MS || 10000)); const ALLOW_VERCEL_PREVIEW_ORIGINS = process.env.ALLOW_VERCEL_PREVIEW_ORIGINS === 'true'; +// OREF (Israel Home Front Command) siren alerts — fetched via HTTP proxy (Israel exit) +const OREF_PROXY_AUTH = process.env.OREF_PROXY_AUTH || ''; // format: user:pass@host:port +const OREF_ALERTS_URL = 'https://www.oref.org.il/WarningMessages/alert/alerts.json'; +const OREF_POLL_INTERVAL_MS = Math.max(30_000, Number(process.env.OREF_POLL_INTERVAL_MS || 300_000)); +const OREF_ENABLED = !!OREF_PROXY_AUTH; +const RELAY_OREF_RATE_LIMIT_MAX = Number.isFinite(Number(process.env.RELAY_OREF_RATE_LIMIT_MAX)) + ? Number(process.env.RELAY_OREF_RATE_LIMIT_MAX) : 600; + if (IS_PRODUCTION_RELAY && !RELAY_SHARED_SECRET && !ALLOW_UNAUTHENTICATED_RELAY) { console.error('[Relay] Error: RELAY_SHARED_SECRET is required in production'); console.error('[Relay] Set RELAY_SHARED_SECRET on Railway and Vercel to secure relay endpoints'); @@ -162,6 +170,15 @@ const telegramState = { startedAt: Date.now(), }; +const orefState = { + lastAlerts: [], + lastAlertsJson: '[]', + lastPollAt: 0, + lastError: null, + historyCount24h: 0, + history: [], +}; + function loadTelegramChannels() { // Product-managed curated list lives in repo root under data/ (shared by web + desktop). // Relay is executed from scripts/, so resolve ../data. @@ -332,6 +349,79 @@ function startTelegramPollLoop() { console.log('[Relay] Telegram poll loop started'); } +// ───────────────────────────────────────────────────────────── +// OREF Siren Alerts (Israel Home Front Command) +// Polls oref.org.il via HTTP CONNECT tunnel through residential proxy (Israel exit) +// ───────────────────────────────────────────────────────────── + +function stripBom(text) { + return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text; +} + +function orefCurlFetch(proxyAuth, url) { + // Use curl via child_process — Node.js TLS fingerprint (JA3) gets blocked by Akamai, + // but curl's fingerprint passes. curl is available on Railway (Linux) and macOS. + const { execSync } = require('child_process'); + const proxyUrl = `http://${proxyAuth}`; + const result = execSync( + `curl -s -x "${proxyUrl}" --max-time 15 -H "Accept: application/json" -H "Referer: https://www.oref.org.il/" "${url}"`, + { encoding: 'utf8', timeout: 20000 } + ); + return result; +} + +async function orefFetchAlerts() { + if (!OREF_ENABLED) return; + try { + const raw = orefCurlFetch(OREF_PROXY_AUTH, OREF_ALERTS_URL); + const cleaned = stripBom(raw).trim(); + + let alerts = []; + if (cleaned && cleaned !== '[]' && cleaned !== 'null') { + try { + const parsed = JSON.parse(cleaned); + alerts = Array.isArray(parsed) ? parsed : [parsed]; + } catch { alerts = []; } + } + + const newJson = JSON.stringify(alerts); + const changed = newJson !== orefState.lastAlertsJson; + + orefState.lastAlerts = alerts; + orefState.lastAlertsJson = newJson; + orefState.lastPollAt = Date.now(); + orefState.lastError = null; + + if (changed && alerts.length > 0) { + orefState.history.push({ + alerts, + timestamp: new Date().toISOString(), + }); + } + + const cutoff = Date.now() - 24 * 60 * 60 * 1000; + orefState.history = orefState.history.filter( + h => new Date(h.timestamp).getTime() > cutoff + ); + orefState.historyCount24h = orefState.history.length; + } catch (err) { + orefState.lastError = err.message || String(err); + console.warn('[Relay] OREF poll error:', orefState.lastError); + } +} + +function startOrefPollLoop() { + if (!OREF_ENABLED) { + console.log('[Relay] OREF disabled (no OREF_PROXY_AUTH)'); + return; + } + orefFetchAlerts().catch(e => console.warn('[Relay] OREF initial poll error:', e?.message || e)); + setInterval(() => { + orefFetchAlerts().catch(e => console.warn('[Relay] OREF poll error:', e?.message || e)); + }, OREF_POLL_INTERVAL_MS).unref?.(); + console.log(`[Relay] OREF poll loop started (interval ${OREF_POLL_INTERVAL_MS}ms)`); +} + function gzipSyncBuffer(body) { try { return zlib.gzipSync(typeof body === 'string' ? Buffer.from(body) : body); @@ -386,12 +476,14 @@ function getRouteGroup(pathname) { if (pathname.startsWith('/worldbank')) return 'worldbank'; if (pathname.startsWith('/polymarket')) return 'polymarket'; if (pathname.startsWith('/ucdp-events')) return 'ucdp-events'; + if (pathname.startsWith('/oref')) return 'oref'; return 'other'; } function getRateLimitForPath(pathname) { if (pathname.startsWith('/opensky')) return RELAY_OPENSKY_RATE_LIMIT_MAX; if (pathname.startsWith('/rss')) return RELAY_RSS_RATE_LIMIT_MAX; + if (pathname.startsWith('/oref')) return RELAY_OREF_RATE_LIMIT_MAX; return RELAY_RATE_LIMIT_MAX; } @@ -2140,6 +2232,13 @@ const server = http.createServer(async (req, res) => { lastPollAt: telegramState.lastPollAt ? new Date(telegramState.lastPollAt).toISOString() : null, hasError: !!telegramState.lastError, }, + oref: { + enabled: OREF_ENABLED, + alertCount: orefState.lastAlerts?.length || 0, + historyCount24h: orefState.historyCount24h, + lastPollAt: orefState.lastPollAt ? new Date(orefState.lastPollAt).toISOString() : null, + hasError: !!orefState.lastError, + }, memory: { rss: `${(mem.rss / 1024 / 1024).toFixed(0)}MB`, heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB`, @@ -2506,6 +2605,27 @@ const server = http.createServer(async (req, res) => { res.end(JSON.stringify({ error: err.message })); } } + } else if (pathname === '/oref/alerts') { + sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=5, s-maxage=5, stale-while-revalidate=3', + }, JSON.stringify({ + configured: OREF_ENABLED, + alerts: orefState.lastAlerts || [], + historyCount24h: orefState.historyCount24h, + timestamp: orefState.lastPollAt ? new Date(orefState.lastPollAt).toISOString() : new Date().toISOString(), + ...(orefState.lastError ? { error: orefState.lastError } : {}), + })); + } else if (pathname === '/oref/history') { + sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=10', + }, JSON.stringify({ + configured: OREF_ENABLED, + history: orefState.history || [], + historyCount24h: orefState.historyCount24h, + timestamp: orefState.lastPollAt ? new Date(orefState.lastPollAt).toISOString() : new Date().toISOString(), + })); } else if (pathname.startsWith('/ucdp-events')) { handleUcdpEventsRequest(req, res); } else if (pathname.startsWith('/opensky')) { @@ -2625,6 +2745,7 @@ const wss = new WebSocketServer({ server }); server.listen(PORT, () => { console.log(`[Relay] WebSocket relay on port ${PORT}`); startTelegramPollLoop(); + startOrefPollLoop(); }); wss.on('connection', (ws, req) => { diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index 0668da883..67b8acc7c 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -72,6 +72,7 @@ import { fetchConflictEvents, fetchUcdpClassifications, fetchHapiSummary, fetchU import { fetchUnhcrPopulation } from '@/services/displacement'; import { fetchClimateAnomalies } from '@/services/climate'; import { fetchSecurityAdvisories } from '@/services/security-advisories'; +import { fetchOrefAlerts, startOrefPolling, stopOrefPolling, onOrefAlertsUpdate } from '@/services/oref-alerts'; import { enrichEventsWithExposure } from '@/services/population-exposure'; import { debounce, getCircuitBreakerCooldownInfo } from '@/utils'; import { isFeatureAvailable } from '@/services/runtime-config'; @@ -101,6 +102,7 @@ import { TradePolicyPanel, SupplyChainPanel, SecurityAdvisoriesPanel, + OrefSirensPanel, } from '@/components'; import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel'; import { classifyNewsItem } from '@/services/positive-classifier'; @@ -143,7 +145,9 @@ export class DataLoaderManager implements AppModule { init(): void {} - destroy(): void {} + destroy(): void { + stopOrefPolling(); + } private shouldShowIntelligenceNotifications(): boolean { return !this.ctx.isMobile && !!this.ctx.findingsBadge?.isPopupEnabled(); @@ -1072,6 +1076,20 @@ export class DataLoaderManager implements AppModule { // Security advisories tasks.push(this.loadSecurityAdvisories()); + // OREF sirens + tasks.push((async () => { + try { + const data = await fetchOrefAlerts(); + (this.ctx.panels['oref-sirens'] as OrefSirensPanel)?.setData(data); + onOrefAlertsUpdate((update) => { + (this.ctx.panels['oref-sirens'] as OrefSirensPanel)?.setData(update); + }); + startOrefPolling(); + } catch (error) { + console.error('[Intelligence] OREF alerts fetch failed:', error); + } + })()); + await Promise.allSettled(tasks); try { diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index b717d0f9f..b6cfe5e00 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -34,6 +34,7 @@ import { TradePolicyPanel, SupplyChainPanel, SecurityAdvisoriesPanel, + OrefSirensPanel, } from '@/components'; import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel'; import { PositiveNewsFeedPanel } from '@/components/PositiveNewsFeedPanel'; @@ -548,6 +549,9 @@ export class PanelLayoutManager implements AppModule { void this.callbacks.loadSecurityAdvisories?.(); }); this.ctx.panels['security-advisories'] = securityAdvisoriesPanel; + + const orefSirensPanel = new OrefSirensPanel(); + this.ctx.panels['oref-sirens'] = orefSirensPanel; } if (SITE_VARIANT === 'finance') { diff --git a/src/components/OrefSirensPanel.ts b/src/components/OrefSirensPanel.ts new file mode 100644 index 000000000..e35598ad7 --- /dev/null +++ b/src/components/OrefSirensPanel.ts @@ -0,0 +1,95 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import type { OrefAlertsResponse, OrefAlert } from '@/services/oref-alerts'; + +export class OrefSirensPanel extends Panel { + private alerts: OrefAlert[] = []; + private historyCount24h = 0; + + constructor() { + super({ + id: 'oref-sirens', + title: t('panels.orefSirens'), + showCount: true, + trackActivity: true, + infoTooltip: t('components.orefSirens.infoTooltip'), + }); + this.showLoading(t('components.orefSirens.checking')); + } + + public setData(data: OrefAlertsResponse): void { + if (!data.configured) { + this.setContent(`
${t('components.orefSirens.notConfigured')}
`); + this.setCount(0); + return; + } + + const prevCount = this.alerts.length; + this.alerts = data.alerts || []; + this.historyCount24h = data.historyCount24h || 0; + this.setCount(this.alerts.length); + + if (prevCount === 0 && this.alerts.length > 0) { + this.setNewBadge(this.alerts.length); + } + + this.render(); + } + + private formatAlertTime(dateStr: string): string { + try { + const diff = Date.now() - new Date(dateStr).getTime(); + if (diff < 60_000) return t('components.orefSirens.justNow'); + const mins = Math.floor(diff / 60_000); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; + } catch { + return ''; + } + } + + private render(): void { + if (this.alerts.length === 0) { + this.setContent(` +
+
+ + ${t('components.orefSirens.noAlerts')} +
+ +
+ `); + return; + } + + const alertRows = this.alerts.slice(0, 20).map(alert => { + const areas = (alert.data || []).map(a => escapeHtml(a)).join(', '); + const time = this.formatAlertTime(alert.alertDate); + return `
+
+ ${escapeHtml(alert.title || alert.cat)} + ${time} +
+
${areas}
+
`; + }).join(''); + + this.setContent(` +
+
+ + ${t('components.orefSirens.activeSirens', { count: String(this.alerts.length) })} +
+
${alertRows}
+ +
+ `); + } +} diff --git a/src/components/index.ts b/src/components/index.ts index 442ca4d27..5a4f12d8a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -42,4 +42,5 @@ export * from './UnifiedSettings'; export * from './TradePolicyPanel'; export * from './SupplyChainPanel'; export * from './SecurityAdvisoriesPanel'; +export * from './OrefSirensPanel'; export * from './BreakingNewsBanner'; diff --git a/src/config/panels.ts b/src/config/panels.ts index 7641e5c73..e3cb1fcb3 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -51,6 +51,7 @@ const FULL_PANELS: Record = { climate: { name: 'Climate Anomalies', enabled: true, priority: 2 }, 'population-exposure': { name: 'Population Exposure', enabled: true, priority: 2 }, 'security-advisories': { name: 'Security Advisories', enabled: true, priority: 2 }, + 'oref-sirens': { name: 'OREF Sirens', enabled: true, priority: 2 }, }; const FULL_MAP_LAYERS: MapLayers = { @@ -604,7 +605,7 @@ export const PANEL_CATEGORY_MAP: Recordتنبيهات أمنية
تحذيرات السفر والتنبيهات الأمنية من الوكالات الحكومية." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/de.json b/src/locales/de.json index 346855a02..106c0f866 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -204,7 +204,8 @@ "gccInvestments": "GCC-Investitionen", "geoHubs": "Geopolitical Hotspots", "liveWebcams": "Live-Webcams", - "securityAdvisories": "Sicherheitshinweise" + "securityAdvisories": "Sicherheitshinweise", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1312,6 +1313,17 @@ "daysAgo": "vor {{count}} Tagen" }, "infoTooltip": "Sicherheitshinweise
Reisewarnungen und Sicherheitshinweise von Regierungsbehörden." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/el.json b/src/locales/el.json index 8410018b4..ec77c8437 100644 --- a/src/locales/el.json +++ b/src/locales/el.json @@ -204,7 +204,8 @@ "gccInvestments": "Επενδύσεις GCC", "geoHubs": "Γεωπολιτικά Κέντρα", "liveWebcams": "Ζωντανές Κάμερες", - "securityAdvisories": "Προειδοποιήσεις Ασφαλείας" + "securityAdvisories": "Προειδοποιήσεις Ασφαλείας", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1339,6 +1340,17 @@ "daysAgo": "{{count}} ημέρες πριν" }, "infoTooltip": "Προειδοποιήσεις Ασφαλείας
Ταξιδιωτικές οδηγίες και προειδοποιήσεις ασφαλείας." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/en.json b/src/locales/en.json index 23f5f74a6..3ca9d43d3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -186,6 +186,7 @@ "climate": "Climate Anomalies", "populationExposure": "Population Exposure", "securityAdvisories": "Security Advisories", + "orefSirens": "OREF Sirens", "startups": "Startups & VC", "vcblogs": "VC Insights & Essays", "regionalStartups": "Global Startup News", @@ -1110,6 +1111,17 @@ }, "infoTooltip": "Security Advisories
Travel advisories and security alerts from government foreign affairs agencies:

Sources:
\uD83C\uDDFA\uD83C\uDDF8 US State Dept Travel Advisories
\uD83C\uDDE6\uD83C\uDDFA AU DFAT Smartraveller
\uD83C\uDDEC\uD83C\uDDE7 UK FCDO Travel Advice
\uD83C\uDDF3\uD83C\uDDFF NZ MFAT SafeTravel

Levels:
\uD83D\uDFE5 Do Not Travel
\uD83D\uDFE7 Reconsider Travel
\uD83D\uDFE8 Exercise Caution
\uD83D\uDFE9 Normal Precautions" }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." + }, "satelliteFires": { "noData": "No fire data available", "region": "Region", diff --git a/src/locales/es.json b/src/locales/es.json index 4ac1717ee..a9735b458 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -204,7 +204,8 @@ "gccInvestments": "Inversiones del CCG", "geoHubs": "Geopolitical Hotspots", "liveWebcams": "Cámaras en Vivo", - "securityAdvisories": "Alertas de Seguridad" + "securityAdvisories": "Alertas de Seguridad", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1312,6 +1313,17 @@ "daysAgo": "hace {{count}} d" }, "infoTooltip": "Alertas de Seguridad
Avisos de viaje y alertas de seguridad de agencias gubernamentales." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/fr.json b/src/locales/fr.json index 42261fb26..d7643cba9 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -204,7 +204,8 @@ "gccInvestments": "Investissements du CCG", "geoHubs": "Centres géopolitiques", "liveWebcams": "Webcams en Direct", - "securityAdvisories": "Avis de Sécurité" + "securityAdvisories": "Avis de Sécurité", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1312,6 +1313,17 @@ "daysAgo": "il y a {{count}} j" }, "infoTooltip": "Avis de Sécurité
Avis aux voyageurs et alertes de sécurité des agences gouvernementales." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/it.json b/src/locales/it.json index b9823e033..baf390cce 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -204,7 +204,8 @@ "gccInvestments": "Investimenti GCC", "geoHubs": "Hotspot geopolitici", "liveWebcams": "Webcam in Diretta", - "securityAdvisories": "Avvisi di Sicurezza" + "securityAdvisories": "Avvisi di Sicurezza", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1312,6 +1313,17 @@ "daysAgo": "{{count}} giorni fa" }, "infoTooltip": "Avvisi di Sicurezza
Avvisi di viaggio e allerte di sicurezza dalle agenzie governative." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/ja.json b/src/locales/ja.json index ea0cbc431..753af3ec6 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -204,7 +204,8 @@ "gccInvestments": "GCC投資", "geoHubs": "地政学ハブ", "liveWebcams": "ライブカメラ", - "securityAdvisories": "セキュリティアドバイザリー" + "securityAdvisories": "セキュリティアドバイザリー", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1312,6 +1313,17 @@ "daysAgo": "{{count}}日前" }, "infoTooltip": "セキュリティアドバイザリー
各国政府の渡航情報と安全警告。" + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/ko.json b/src/locales/ko.json index 7792d57d0..73462a81b 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -186,6 +186,7 @@ "climate": "기후 이상", "populationExposure": "인구 노출", "securityAdvisories": "보안 권고", + "orefSirens": "OREF Sirens", "startups": "스타트업 & VC", "vcblogs": "VC 인사이트 & 에세이", "regionalStartups": "글로벌 스타트업 뉴스", @@ -1106,6 +1107,17 @@ }, "infoTooltip": "보안 주의보
각국 외교부의 여행 주의보 및 보안 경보:

출처:
🇺🇸 미 국무부 여행 주의보
🇦🇺 호주 DFAT Smartraveller
🇬🇧 영국 FCDO 여행 안내
🇳🇿 뉴질랜드 MFAT SafeTravel

수준:
🟥 여행 금지
🟧 여행 재고
🟨 주의 필요
🟩 일반 주의" }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." + }, "satelliteFires": { "noData": "화재 데이터 없음", "region": "지역", diff --git a/src/locales/nl.json b/src/locales/nl.json index c84004b68..8bf27431f 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -63,7 +63,8 @@ "polymarket": "Voorspellingen", "climate": "Klimaatafwijkingen", "liveWebcams": "Live Webcams", - "securityAdvisories": "Veiligheidsadviezen" + "securityAdvisories": "Veiligheidsadviezen", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1171,6 +1172,17 @@ "daysAgo": "{{count}} dagen geleden" }, "infoTooltip": "Veiligheidsadviezen
Reisadviezen en veiligheidswaarschuwingen van overheidsinstanties." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/pl.json b/src/locales/pl.json index e054c1ff4..d9d917501 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -204,7 +204,8 @@ "gccInvestments": "Inwestycje GCC", "geoHubs": "Geopolitical Hotspots", "liveWebcams": "Kamery na Żywo", - "securityAdvisories": "Ostrzeżenia Bezpieczeństwa" + "securityAdvisories": "Ostrzeżenia Bezpieczeństwa", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1312,6 +1313,17 @@ "daysAgo": "{{count}} dni temu" }, "infoTooltip": "Ostrzeżenia Bezpieczeństwa
Ostrzeżenia podróżne i alerty bezpieczeństwa z agencji rządowych." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/pt.json b/src/locales/pt.json index 35246e8aa..efc9f2617 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -63,7 +63,8 @@ "polymarket": "Previsões", "climate": "Anomalias Climáticas", "liveWebcams": "Câmeras ao Vivo", - "securityAdvisories": "Alertas de Segurança" + "securityAdvisories": "Alertas de Segurança", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1171,6 +1172,17 @@ "daysAgo": "há {{count}} d" }, "infoTooltip": "Alertas de Segurança
Avisos de viagem e alertas de segurança de agências governamentais." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/ru.json b/src/locales/ru.json index e7307510a..714da52a0 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -204,7 +204,8 @@ "gccInvestments": "Инвестиции стран Залива", "geoHubs": "Геополитические хабы", "liveWebcams": "Веб-камеры", - "securityAdvisories": "Предупреждения безопасности" + "securityAdvisories": "Предупреждения безопасности", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1312,6 +1313,17 @@ "daysAgo": "{{count}} дн. назад" }, "infoTooltip": "Предупреждения безопасности
Рекомендации по поездкам и предупреждения от государственных ведомств." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/sv.json b/src/locales/sv.json index 549f4591e..6e6b8f53c 100644 --- a/src/locales/sv.json +++ b/src/locales/sv.json @@ -63,7 +63,8 @@ "polymarket": "Förutsägelser", "climate": "Klimatanomalier", "liveWebcams": "Webbkameror", - "securityAdvisories": "Säkerhetsvarningar" + "securityAdvisories": "Säkerhetsvarningar", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1171,6 +1172,17 @@ "daysAgo": "{{count}} dagar sedan" }, "infoTooltip": "Säkerhetsvarningar
Resevarningar och säkerhetsmeddelanden från myndigheters utrikesdepartement." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/th.json b/src/locales/th.json index f2fc0b9b6..a9918981e 100644 --- a/src/locales/th.json +++ b/src/locales/th.json @@ -204,7 +204,8 @@ "gccInvestments": "การลงทุน GCC", "geoHubs": "ศูนย์กลางภูมิรัฐศาสตร์", "liveWebcams": "เว็บแคมสด", - "securityAdvisories": "คำเตือนด้านความปลอดภัย" + "securityAdvisories": "คำเตือนด้านความปลอดภัย", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1312,6 +1313,17 @@ "daysAgo": "{{count}} วันที่แล้ว" }, "infoTooltip": "คำเตือนด้านความปลอดภัย
คำเตือนการเดินทางและความปลอดภัยจากหน่วยงานรัฐบาล." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/tr.json b/src/locales/tr.json index f7cf13803..50d271ce8 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -204,7 +204,8 @@ "gccInvestments": "GCC Yatirimlari", "geoHubs": "Jeopolitik Merkezler", "liveWebcams": "Canli Web Kameralari", - "securityAdvisories": "Güvenlik Uyarıları" + "securityAdvisories": "Güvenlik Uyarıları", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1312,6 +1313,17 @@ "daysAgo": "{{count}} gün önce" }, "infoTooltip": "Güvenlik Uyarıları
Hükümet kurumlarından seyahat uyarıları ve güvenlik bildirimleri." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/vi.json b/src/locales/vi.json index 49ffb23a7..205cef761 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -204,7 +204,8 @@ "gccInvestments": "Đầu tư GCC", "geoHubs": "Trung tâm Địa chính trị", "liveWebcams": "Webcam Trực tiếp", - "securityAdvisories": "Cảnh báo An ninh" + "securityAdvisories": "Cảnh báo An ninh", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1312,6 +1313,17 @@ "daysAgo": "{{count}} ngày trước" }, "infoTooltip": "Cảnh báo An ninh
Cảnh báo du lịch và an ninh từ các cơ quan chính phủ." + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 29a688f16..fb14c38c3 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -204,7 +204,8 @@ "gccInvestments": "GCC投资", "geoHubs": "地缘政治枢纽", "liveWebcams": "实时摄像头", - "securityAdvisories": "安全警告" + "securityAdvisories": "安全警告", + "orefSirens": "OREF Sirens" }, "modals": { "search": { @@ -1312,6 +1313,17 @@ "daysAgo": "{{count}}天前" }, "infoTooltip": "安全警告
来自各国政府的旅行警告和安全提示。" + }, + "orefSirens": { + "checking": "Checking OREF alerts...", + "noAlerts": "No active sirens — all clear", + "notConfigured": "OREF proxy not configured", + "activeSirens": "{{count}} active siren(s)", + "area": "Area", + "time": "Time", + "justNow": "just now", + "historyCount": "{{count}} alerts in last 24h", + "infoTooltip": "OREF Sirens
Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).

Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding." } }, "popups": { diff --git a/src/services/oref-alerts.ts b/src/services/oref-alerts.ts new file mode 100644 index 000000000..fdc899208 --- /dev/null +++ b/src/services/oref-alerts.ts @@ -0,0 +1,208 @@ +import { getApiBaseUrl } from '@/services/runtime'; +import { translateText } from '@/services/summarization'; + +export interface OrefAlert { + id: string; + cat: string; + title: string; + data: string[]; + desc: string; + alertDate: string; +} + +export interface OrefAlertsResponse { + configured: boolean; + alerts: OrefAlert[]; + historyCount24h: number; + timestamp: string; + error?: string; +} + +export interface OrefHistoryEntry { + alerts: OrefAlert[]; + timestamp: string; +} + +export interface OrefHistoryResponse { + configured: boolean; + history: OrefHistoryEntry[]; + historyCount24h: number; + timestamp: string; + error?: string; +} + +let cachedResponse: OrefAlertsResponse | null = null; +let lastFetchAt = 0; +const CACHE_TTL = 8_000; +let pollingInterval: ReturnType | null = null; +let updateCallbacks: Array<(data: OrefAlertsResponse) => void> = []; + +const MAX_TRANSLATION_CACHE = 200; +const translationCache = new Map(); +let translationPromise: Promise | null = null; + +const HEBREW_RE = /[\u0590-\u05FF]/; + +function hasHebrew(text: string): boolean { + return HEBREW_RE.test(text); +} + +function alertNeedsTranslation(alert: OrefAlert): boolean { + return hasHebrew(alert.title) || alert.data.some(hasHebrew) || hasHebrew(alert.desc); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function buildTranslationPrompt(alerts: OrefAlert[]): string { + const lines: string[] = []; + for (const a of alerts) { + lines.push(`ALERT[${a.id}]: ${a.title || '(none)'}`); + lines.push(`AREAS[${a.id}]: ${a.data.join(', ') || '(none)'}`); + lines.push(`DESC[${a.id}]: ${a.desc || '(none)'}`); + } + return 'Translate each line from Hebrew to English. Keep the ALERT/AREAS/DESC labels and IDs exactly as-is. Only translate the text after the colon.\n' + lines.join('\n'); +} + +function parseTranslationResponse(raw: string, alerts: OrefAlert[]): void { + const lines = raw.split('\n'); + for (const alert of alerts) { + const eid = escapeRegExp(alert.id); + const reAlert = new RegExp(`ALERT\\[${eid}\\]:\\s*(.+)`); + const reAreas = new RegExp(`AREAS\\[${eid}\\]:\\s*(.+)`); + const reDesc = new RegExp(`DESC\\[${eid}\\]:\\s*(.+)`); + let title = alert.title; + let areas = alert.data; + let desc = alert.desc; + for (const line of lines) { + const alertMatch = line.match(reAlert); + if (alertMatch?.[1]) title = alertMatch[1].trim(); + const areasMatch = line.match(reAreas); + if (areasMatch?.[1]) areas = areasMatch[1].split(',').map(s => s.trim()); + const descMatch = line.match(reDesc); + if (descMatch?.[1]) desc = descMatch[1].trim(); + } + translationCache.set(alert.id, { title, data: areas, desc }); + } + if (translationCache.size > MAX_TRANSLATION_CACHE) { + const excess = translationCache.size - MAX_TRANSLATION_CACHE; + const iter = translationCache.keys(); + for (let i = 0; i < excess; i++) { + const k = iter.next().value; + if (k !== undefined) translationCache.delete(k); + } + } +} + +function applyTranslations(alerts: OrefAlert[]): OrefAlert[] { + return alerts.map(a => { + const cached = translationCache.get(a.id); + if (cached) return { ...a, ...cached }; + return a; + }); +} + +async function translateAlerts(alerts: OrefAlert[]): Promise { + const untranslated = alerts.filter(a => !translationCache.has(a.id) && alertNeedsTranslation(a)); + if (!untranslated.length) { + if (translationPromise) await translationPromise; + return false; + } + + if (translationPromise) { + await translationPromise; + return translateAlerts(alerts); + } + + let translated = false; + translationPromise = (async () => { + try { + const prompt = buildTranslationPrompt(untranslated); + const result = await translateText(prompt, 'en'); + if (result) { + parseTranslationResponse(result, untranslated); + translated = true; + } + } catch (e) { + console.warn('OREF alert translation failed', e); + } finally { + translationPromise = null; + } + return translated; + })(); + + await translationPromise; + return translated; +} + +function getOrefApiUrl(endpoint?: string): string { + const base = getApiBaseUrl(); + const suffix = endpoint ? `?endpoint=${endpoint}` : ''; + return `${base}/api/oref-alerts${suffix}`; +} + +export async function fetchOrefAlerts(): Promise { + const now = Date.now(); + if (cachedResponse && now - lastFetchAt < CACHE_TTL) { + return { ...cachedResponse, alerts: applyTranslations(cachedResponse.alerts) }; + } + + try { + const res = await fetch(getOrefApiUrl(), { + headers: { Accept: 'application/json' }, + }); + if (!res.ok) { + return { configured: false, alerts: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: `HTTP ${res.status}` }; + } + const data: OrefAlertsResponse = await res.json(); + cachedResponse = data; + lastFetchAt = now; + + if (data.alerts.length) { + translateAlerts(data.alerts).then((didTranslate) => { + if (didTranslate) { + for (const cb of updateCallbacks) cb({ ...data, alerts: applyTranslations(data.alerts) }); + } + }).catch(() => {}); + } + + return { ...data, alerts: applyTranslations(data.alerts) }; + } catch (err) { + return { configured: false, alerts: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: String(err) }; + } +} + +export async function fetchOrefHistory(): Promise { + try { + const res = await fetch(getOrefApiUrl('history'), { + headers: { Accept: 'application/json' }, + }); + if (!res.ok) { + return { configured: false, history: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: `HTTP ${res.status}` }; + } + return await res.json(); + } catch (err) { + return { configured: false, history: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: String(err) }; + } +} + +export function onOrefAlertsUpdate(cb: (data: OrefAlertsResponse) => void): void { + updateCallbacks.push(cb); +} + +export function startOrefPolling(): void { + if (pollingInterval) return; + pollingInterval = setInterval(async () => { + const data = await fetchOrefAlerts(); + for (const cb of updateCallbacks) cb(data); + }, 10_000); +} + +export function stopOrefPolling(): void { + if (pollingInterval) { + clearInterval(pollingInterval); + pollingInterval = null; + } + updateCallbacks = []; +} diff --git a/src/styles/panels.css b/src/styles/panels.css index 0d18e8ba8..3a19399fe 100644 --- a/src/styles/panels.css +++ b/src/styles/panels.css @@ -259,3 +259,25 @@ .sa-footer-source { font-size: 9px; color: var(--text-muted); } .sa-refresh-btn { background: transparent; border: 1px solid var(--border); color: var(--text-dim); padding: 3px 10px; font-size: 10px; cursor: pointer; border-radius: 3px; } .sa-refresh-btn:hover { border-color: var(--accent); color: var(--accent); } + +/* ---------------------------------------------------------- + OREF Sirens Panel + ---------------------------------------------------------- */ +.oref-panel-content { font-size: 12px; } +.oref-status { display: flex; align-items: center; gap: 8px; padding: 10px; border-radius: 4px; margin-bottom: 8px; font-weight: 600; } +.oref-ok { background: color-mix(in srgb, var(--semantic-normal) 10%, transparent); color: var(--semantic-normal); } +.oref-danger { background: color-mix(in srgb, var(--semantic-critical) 12%, transparent); color: var(--semantic-critical); } +.oref-status-icon { font-size: 16px; } +.oref-pulse { width: 10px; height: 10px; border-radius: 50%; background: var(--semantic-critical); animation: oref-pulse-anim 1.2s ease-in-out infinite; flex-shrink: 0; } +@keyframes oref-pulse-anim { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--semantic-critical) 60%, transparent); } + 50% { opacity: 0.6; box-shadow: 0 0 0 6px transparent; } +} +.oref-list { display: flex; flex-direction: column; gap: 4px; } +.oref-alert-row { padding: 8px; border-radius: 4px; border-left: 3px solid var(--semantic-critical); background: var(--overlay-subtle); } +.oref-alert-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 3px; } +.oref-alert-title { font-size: 11px; font-weight: 600; color: var(--text-primary); } +.oref-alert-time { font-size: 9px; color: var(--text-muted); } +.oref-alert-areas { font-size: 10px; color: var(--text-secondary); line-height: 1.35; } +.oref-footer { display: flex; align-items: center; justify-content: center; margin-top: 8px; padding-top: 6px; border-top: 1px solid var(--border-subtle); } +.oref-history { font-size: 9px; color: var(--text-muted); } diff --git a/tests/oref-proxy.test.mjs b/tests/oref-proxy.test.mjs new file mode 100644 index 000000000..c9491ed37 --- /dev/null +++ b/tests/oref-proxy.test.mjs @@ -0,0 +1,107 @@ +/** + * OREF Proxy Connectivity Test + * + * Tests the curl-based proxy approach used by ais-relay.cjs + * to reach oref.org.il through a residential proxy with Israel exit node. + * + * Requires OREF_PROXY_AUTH env var (format: user:pass@host:port) + * + * Usage: + * OREF_PROXY_AUTH='user:pass;il;;;@proxy.froxy.com:9000' node tests/oref-proxy.test.mjs + */ + +import { execSync } from 'node:child_process'; +import { strict as assert } from 'node:assert'; + +const OREF_PROXY_AUTH = process.env.OREF_PROXY_AUTH || ''; +const OREF_ALERTS_URL = 'https://www.oref.org.il/WarningMessages/alert/alerts.json'; + +function stripBom(text) { + return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text; +} + +function orefCurlFetch(proxyAuth, url) { + const proxyUrl = `http://${proxyAuth}`; + return execSync( + `curl -s -x "${proxyUrl}" --max-time 15 -H "Accept: application/json" -H "Referer: https://www.oref.org.il/" "${url}"`, + { encoding: 'utf8', timeout: 20000 } + ); +} + +async function runTests() { + if (!OREF_PROXY_AUTH) { + console.log('SKIP: OREF_PROXY_AUTH not set — set it to run proxy connectivity tests'); + console.log(' Example: OREF_PROXY_AUTH="user:pass;il;;;@proxy.froxy.com:9000" node tests/oref-proxy.test.mjs'); + process.exit(0); + } + + console.log('--- OREF Proxy Connectivity Tests ---\n'); + let passed = 0; + let failed = 0; + + // Test 1: curl is available + try { + execSync('curl --version', { encoding: 'utf8', timeout: 5000 }); + console.log(' PASS: curl is available'); + passed++; + } catch { + console.log(' FAIL: curl not found — required for OREF proxy'); + failed++; + process.exit(1); + } + + // Test 2: Fetch OREF alerts through proxy via curl + try { + const raw = orefCurlFetch(OREF_PROXY_AUTH, OREF_ALERTS_URL); + assert.ok(typeof raw === 'string', 'response should be a string'); + const cleaned = stripBom(raw).trim(); + + if (cleaned === '' || cleaned === '[]' || cleaned === 'null') { + console.log(' PASS: OREF alerts fetch → no active alerts (empty response)'); + } else { + const parsed = JSON.parse(cleaned); + // OREF returns a single object when 1 alert, or an array for multiple + const alerts = Array.isArray(parsed) ? parsed : [parsed]; + assert.ok(alerts.length > 0, 'should have at least one alert'); + assert.ok(alerts[0].id || alerts[0].cat, 'alert should have id or cat field'); + console.log(` PASS: OREF alerts fetch → ${alerts.length} active alert(s)`); + } + passed++; + } catch (err) { + console.log(` FAIL: OREF alerts fetch — ${err.message}`); + failed++; + } + + // Test 3: Fetch with HTTP status code check + try { + const proxyUrl = `http://${OREF_PROXY_AUTH}`; + const output = execSync( + `curl -s -o /dev/null -w "%{http_code}" -x "${proxyUrl}" --max-time 15 -H "Accept: application/json" -H "Referer: https://www.oref.org.il/" "${OREF_ALERTS_URL}"`, + { encoding: 'utf8', timeout: 20000 } + ).trim(); + assert.equal(output, '200', `Expected HTTP 200, got ${output}`); + console.log(' PASS: OREF HTTP status is 200'); + passed++; + } catch (err) { + console.log(` FAIL: OREF HTTP status check — ${err.message}`); + failed++; + } + + // Test 4: Invalid proxy should fail gracefully + try { + assert.throws( + () => orefCurlFetch('baduser:badpass@127.0.0.1:1', OREF_ALERTS_URL), + /./ + ); + console.log(' PASS: Invalid proxy fails gracefully'); + passed++; + } catch (err) { + console.log(` FAIL: Invalid proxy error handling — ${err.message}`); + failed++; + } + + console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests();