mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
Put news on the globe, with a feed panel in the right rail
Events ▸ News draws the last 24 hours of reports from curated hotspots (Ukraine–Russia, Israel–Gaza–Lebanon, Iran–Hormuz, Yemen–Red Sea, Sudan, Sahel) and developing situations elsewhere, from any server that answers the `/api/news` read routes. - Globe: one marker per merged event, clamped to the ground and culled at the horizon; one beacon per hotspot when zoomed out and a pulsing beacon for a developing situation; hotspot outlines with disputed areas dotted and labelled by who administers and who claims them; picture cards for at most ten major or fast-growing events with two or more sources, never overlapping, switchable off. - Panel: hotspot chips, a severity filter (significant and above by default), categories, minor news off by default, and a scrolling list of event cards with picture, headline, place, sources, age and the sides that reported it. "In view" lists what is near the map below 3,000 km once the camera settles. A card flies the globe to its event and opens it with a link to the publisher. Reduced motion stops the pulse and the flights. - Core serves no news itself: the local server proxies GEV_NEWS_ORIGIN when it is set, and answers 503 unavailable otherwise, so the layer reads "not configured" once instead of retrying. - Share token 9 (enabled-only); voice stays off for this layer.
This commit is contained in:
1 parent
2d9d518946
commit
5bb489d1fb
48 files changed
+3394
-53
No files matched your search
@@ -29,6 +29,7 @@ import { firePerimetersProxy } from './firePerimeters.js';
|
||||
import { gnssIntegrityProxy } from './gnssIntegrity.js';
|
||||
import { ghostMapsProxy } from './ghostMaps.js';
|
||||
import { droneAirspaceProxy } from './droneAirspace.js';
|
||||
import { newsProxy } from './news.js';
|
||||
import { cycloneProxy } from './cyclones.js';
|
||||
import { windProxy } from './wind.js';
|
||||
|
||||
@@ -81,6 +82,7 @@ function localProviderPlugins({
|
||||
gnssIntegrityProxy(),
|
||||
ghostMapsProxy(),
|
||||
droneAirspaceProxy(),
|
||||
newsProxy(),
|
||||
keySetupEndpoint(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { newsQueryAllowed } from '../../src/layers/news/source.js';
|
||||
import { readResponseTextCapped } from './common/http.js';
|
||||
|
||||
// News (/api/news/*): Core serves no news of its own. With GEV_NEWS_ORIGIN
|
||||
// set (a news server such as https://news.example), this forwards exactly
|
||||
// the GET requests the news layer sends there and relays the JSON; without
|
||||
// it, every route answers "not configured" and the layer shows itself
|
||||
// unavailable without retrying.
|
||||
|
||||
const TIMEOUT_MS = 15_000;
|
||||
const MAX_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The configured news server's origin, or null: http(s) only, no
|
||||
* credentials, query or fragment; a path is ignored.
|
||||
* @param {string|undefined} raw
|
||||
*/
|
||||
export function newsOrigin(raw = process.env.GEV_NEWS_ORIGIN) {
|
||||
const text = String(raw ?? '').trim();
|
||||
if (!text) return null;
|
||||
try {
|
||||
const url = new URL(text);
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
)
|
||||
return null;
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A thin, validated GET proxy for the news layer's three read routes.
|
||||
* @param {{origin?: () => string|null, fetchImpl?: typeof fetch, timeoutMs?: number}} [options]
|
||||
*/
|
||||
export function newsProxy({
|
||||
origin = () => newsOrigin(),
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
timeoutMs = TIMEOUT_MS,
|
||||
} = {}) {
|
||||
const json = (res, status, value) => {
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
...(status === 405 ? { Allow: 'GET' } : {}),
|
||||
});
|
||||
res.end(typeof value === 'string' ? value : JSON.stringify(value));
|
||||
};
|
||||
|
||||
async function handler(req, res) {
|
||||
if (req.method !== 'GET')
|
||||
return json(res, 405, { error: 'method_not_allowed' });
|
||||
const local = new URL(req.url || '/', 'http://local');
|
||||
const target = new URL(
|
||||
`/api/news${local.pathname}${local.search}`,
|
||||
'http://local',
|
||||
);
|
||||
if (!newsQueryAllowed(target))
|
||||
return json(res, 400, { error: 'invalid_news_request' });
|
||||
const base = origin();
|
||||
if (!base)
|
||||
return json(res, 503, {
|
||||
error: 'news_not_configured',
|
||||
unavailable: true,
|
||||
retryable: false,
|
||||
});
|
||||
try {
|
||||
const upstream = await fetchImpl(
|
||||
new URL(`${target.pathname}${target.search}`, base),
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
},
|
||||
);
|
||||
if (!upstream.ok) {
|
||||
await upstream.body?.cancel().catch(() => {});
|
||||
return json(res, 502, {
|
||||
error: 'news_upstream_error',
|
||||
status: upstream.status,
|
||||
});
|
||||
}
|
||||
const text = await readResponseTextCapped(upstream, MAX_BYTES);
|
||||
// Relayed only as JSON; the browser validates every record.
|
||||
JSON.parse(text);
|
||||
return json(res, 200, text);
|
||||
} catch (error) {
|
||||
return json(res, error?.name === 'TimeoutError' ? 504 : 502, {
|
||||
error: 'news_unavailable',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'news',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/news', handler);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/news', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { newsOrigin, newsProxy } from './news.js';
|
||||
|
||||
/** Mount the proxy on a fake middleware stack and call it like Connect does. */
|
||||
function mount(options) {
|
||||
let handler = null;
|
||||
newsProxy(options).configureServer({
|
||||
middlewares: {
|
||||
use(prefix, fn) {
|
||||
assert.equal(prefix, '/api/news');
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
return async (url, method = 'GET') => {
|
||||
const res = {
|
||||
destroyed: false,
|
||||
status: 0,
|
||||
headers: {},
|
||||
body: '',
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler({ url, method }, res);
|
||||
return { ...res, json: res.body ? JSON.parse(res.body) : null };
|
||||
};
|
||||
}
|
||||
|
||||
test('the origin must be a plain http(s) origin', () => {
|
||||
assert.equal(newsOrigin('https://news.example'), 'https://news.example');
|
||||
assert.equal(
|
||||
newsOrigin('https://news.example/base/'),
|
||||
'https://news.example',
|
||||
);
|
||||
for (const bad of [
|
||||
undefined,
|
||||
'',
|
||||
' ',
|
||||
'ftp://x',
|
||||
'http://u:p@x',
|
||||
'http://x/?a=1',
|
||||
'http://x/#f',
|
||||
'not a url',
|
||||
])
|
||||
assert.equal(newsOrigin(bad), null, String(bad));
|
||||
});
|
||||
|
||||
test('without GEV_NEWS_ORIGIN every route answers not configured and nothing is fetched', async () => {
|
||||
let fetches = 0;
|
||||
const call = mount({
|
||||
origin: () => null,
|
||||
fetchImpl: async () => {
|
||||
fetches++;
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
});
|
||||
const response = await call('/hotspots');
|
||||
assert.equal(response.status, 503);
|
||||
assert.deepEqual(response.json, {
|
||||
error: 'news_not_configured',
|
||||
unavailable: true,
|
||||
retryable: false,
|
||||
});
|
||||
assert.equal(fetches, 0);
|
||||
});
|
||||
|
||||
test('forwards only the client routes, by GET, and relays the JSON', async () => {
|
||||
const calls = [];
|
||||
const call = mount({
|
||||
origin: () => 'https://news.example',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return new Response('{"reports":[],"nextBefore":null}', { status: 200 });
|
||||
},
|
||||
});
|
||||
const ok = await call('/feed?hotspot=all&limit=60&today=1&events=1');
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(ok.json, { reports: [], nextBefore: null });
|
||||
assert.equal(ok.headers['Cache-Control'], 'no-store');
|
||||
assert.equal(
|
||||
calls[0].url,
|
||||
'https://news.example/api/news/feed?hotspot=all&limit=60&today=1&events=1',
|
||||
);
|
||||
assert.equal(calls[0].init.redirect, 'error');
|
||||
assert.ok(calls[0].init.signal instanceof AbortSignal);
|
||||
|
||||
for (const url of [
|
||||
'/air-alerts',
|
||||
'/feed?side=UA',
|
||||
'/feed?limit=9999',
|
||||
'/../admin',
|
||||
'/hotspots?x=1',
|
||||
]) {
|
||||
const refused = await call(url);
|
||||
assert.equal(refused.status, 400, url);
|
||||
}
|
||||
const post = await call('/hotspots', 'POST');
|
||||
assert.equal(post.status, 405);
|
||||
assert.equal(post.headers.Allow, 'GET');
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('upstream failures, bad JSON and timeouts become bounded errors', async () => {
|
||||
const failing = mount({
|
||||
origin: () => 'https://news.example',
|
||||
fetchImpl: async () => new Response('down', { status: 500 }),
|
||||
});
|
||||
const down = await failing('/hotspots');
|
||||
assert.equal(down.status, 502);
|
||||
assert.deepEqual(down.json, { error: 'news_upstream_error', status: 500 });
|
||||
|
||||
const garbled = mount({
|
||||
origin: () => 'https://news.example',
|
||||
fetchImpl: async () => new Response('<html>', { status: 200 }),
|
||||
});
|
||||
assert.equal((await garbled('/hotspots')).status, 502);
|
||||
|
||||
const slow = mount({
|
||||
origin: () => 'https://news.example',
|
||||
timeoutMs: 10,
|
||||
fetchImpl: (_url, { signal }) =>
|
||||
new Promise((_, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
),
|
||||
});
|
||||
const timedOut = await slow('/hotspots');
|
||||
assert.equal(timedOut.status, 504);
|
||||
assert.deepEqual(timedOut.json, { error: 'news_unavailable' });
|
||||
});
|
||||
Reference in new issue
Block a user