mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge origin/main: Panoramax, bikeshare Norway/Detroit, cloud filter strength, Overpass cache
Conflicts:
- KartaView viewer moved into the shared photoViewer.js on main; its
labels now take the provider name ("{name} street-level photo") and
are translated there.
- Weather: main's cloud filter strength slider, with its labels in t().
- Street Level caption: translated creator line plus main's licence.
Panoramax status and error texts are marked like KartaView's, and
"Panoramax answered HTTP {status}" gets a template in the panel and the
layer row. Catalogs: es/ru 2553 messages each, complete.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
7fd1dd867f
77 files changed
+9201
-919
No files matched your search
@@ -265,6 +265,26 @@ export const DATA_CREDITS = [
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
},
|
||||
{
|
||||
key: 'urbansharing-gbfs',
|
||||
html:
|
||||
'City bikes (Norway): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel',
|
||||
},
|
||||
{
|
||||
key: 'entur-mobility-gbfs',
|
||||
html:
|
||||
'City bikes (Stavanger, Skien/Porsgrunn): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)',
|
||||
},
|
||||
{
|
||||
key: 'jcdecaux-gbfs',
|
||||
html:
|
||||
'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' +
|
||||
'<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>',
|
||||
},
|
||||
{
|
||||
key: 'osrm-routing',
|
||||
// The service asks for its attribution to carry a "fix the map" link, so
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
GBFS_CLIENT_IDENTIFIER,
|
||||
GBFS_MAX_BODY_BYTES,
|
||||
fetchGbfsUpstream,
|
||||
} from '../../server/providers/gbfs.js';
|
||||
@@ -56,6 +57,41 @@ test('GBFS upstream fetch passes a direct 200 through unchanged', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('GBFS upstream fetch names the app in the Client-Identifier header', async () => {
|
||||
let observedHeaders = null;
|
||||
await fetchGbfsUpstream(
|
||||
'https://gbfs.urbansharing.com/oslobysykkel.no/station_status.json',
|
||||
{
|
||||
fetchImpl: async (_url, options) => {
|
||||
observedHeaders = options.headers;
|
||||
return new Response(STATION_BODY, { status: 200 });
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(GBFS_CLIENT_IDENTIFIER, 'gods-eye-view');
|
||||
assert.equal(observedHeaders['Client-Identifier'], GBFS_CLIENT_IDENTIFIER);
|
||||
});
|
||||
|
||||
test('GBFS upstream fetch adds ET-Client-Name for Entur and only for Entur', async () => {
|
||||
const headersFor = async (url) => {
|
||||
let observed = null;
|
||||
await fetchGbfsUpstream(url, {
|
||||
fetchImpl: async (_url, options) => {
|
||||
observed = options.headers;
|
||||
return new Response(STATION_BODY, { status: 200 });
|
||||
},
|
||||
});
|
||||
return observed;
|
||||
};
|
||||
const entur = await headersFor(
|
||||
'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status',
|
||||
);
|
||||
assert.equal(entur['ET-Client-Name'], 'gods-eye-view-bikeshare');
|
||||
assert.equal(entur['Client-Identifier'], GBFS_CLIENT_IDENTIFIER);
|
||||
const lyft = await headersFor(STATION_URL);
|
||||
assert.equal(lyft['ET-Client-Name'], undefined);
|
||||
});
|
||||
|
||||
test('GBFS upstream fetch forwards a non-redirect error status for the middleware to relay', async () => {
|
||||
const result = await fetchGbfsUpstream(STATION_URL, {
|
||||
fetchImpl: async () => new Response(null, { status: 404 }),
|
||||
|
||||
+56
-4
@@ -8,6 +8,31 @@ const GBFS_ALLOWED_HOSTS = new Set([
|
||||
'austin.publicbikesystem.net',
|
||||
'hon.publicbikesystem.net',
|
||||
'chat.publicbikesystem.net',
|
||||
'gbfs.urbansharing.com',
|
||||
'api.entur.io',
|
||||
'api.cyclocity.fr',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Hosts that serve more than GBFS keep the proxy to their GBFS tree: Entur's
|
||||
* api.entur.io fronts every Entur API, and api.cyclocity.fr every JCDecaux
|
||||
* contract endpoint.
|
||||
*/
|
||||
const GBFS_HOST_PATH_PREFIXES = new Map([
|
||||
['api.entur.io', '/mobility/v2/gbfs/'],
|
||||
['api.cyclocity.fr', '/contracts/'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Identifying headers an operator asks every GBFS client to send. Entur rate
|
||||
* limits or blocks consumers that do not send ET-Client-Name, in the form
|
||||
* "company-application".
|
||||
*/
|
||||
const GBFS_HOST_HEADERS = new Map([
|
||||
[
|
||||
'api.entur.io',
|
||||
Object.freeze({ 'ET-Client-Name': 'gods-eye-view-bikeshare' }),
|
||||
],
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -28,13 +53,40 @@ export function isAllowedGbfsHost(hostname) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Only allow station_information.json and station_status.json endpoints.
|
||||
* Only allow station_information and station_status endpoints. The `.json`
|
||||
* suffix is optional because Entur serves its feeds without one. When a
|
||||
* hostname is given and that host serves more than GBFS, the path must also
|
||||
* sit under the host's GBFS prefix.
|
||||
*
|
||||
* @param {string} pathname
|
||||
* @param {string} [hostname]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAllowedGbfsPath(pathname) {
|
||||
return /\/station_(information|status)\.json$/i.test(String(pathname || ''));
|
||||
export function isAllowedGbfsPath(pathname, hostname = '') {
|
||||
const path = String(pathname || '');
|
||||
if (!/\/station_(information|status)(\.json)?$/i.test(path)) return false;
|
||||
const prefix = GBFS_HOST_PATH_PREFIXES.get(
|
||||
String(hostname || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
);
|
||||
return !prefix || path.startsWith(prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra request headers an upstream GBFS host asks clients to send.
|
||||
*
|
||||
* @param {string} hostname
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function gbfsUpstreamHeaders(hostname) {
|
||||
return (
|
||||
GBFS_HOST_HEADERS.get(
|
||||
String(hostname || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
) || {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +99,7 @@ export function isAllowedGbfsPath(pathname) {
|
||||
* @returns {string} Cache-Control header value.
|
||||
*/
|
||||
export function gbfsCacheControl(pathname) {
|
||||
if (/\/station_information\.json$/i.test(String(pathname || ''))) {
|
||||
if (/\/station_information(\.json)?$/i.test(String(pathname || ''))) {
|
||||
return 'public, max-age=300';
|
||||
}
|
||||
return 'no-store';
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
validMilitaryInstallationBox,
|
||||
writeMilitaryInstallationDisk,
|
||||
} from '../../vite.config.js';
|
||||
import { resolveOverpassUpstreamTag } from '../../server/providers/overpass/constants.js';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
test('installation failure reasons disclose no raw upstream error and do not guess overload', () => {
|
||||
@@ -274,3 +275,30 @@ test('an unwritable cache directory degrades to a miss instead of throwing', asy
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an installation entry is not served after OVERPASS_UPSTREAMS is repointed', async (t) => {
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||
});
|
||||
const dir = tempCacheDir();
|
||||
const key = '30.250,-97.750,30.300,-97.700';
|
||||
try {
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://a.example/api/interpreter,https://b.example/api/interpreter';
|
||||
const tag = resolveOverpassUpstreamTag();
|
||||
await writeMilitaryInstallationDisk(key, { ...entry(Date.now()), upstreams: tag }, dir);
|
||||
assert.ok(await readMilitaryInstallationDisk(key, TTL_MS, dir), 'its own entry is a hit');
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://b.example/api/interpreter,https://a.example/api/interpreter';
|
||||
assert.equal(resolveOverpassUpstreamTag(), tag, 'reordering the same instances keeps the tag');
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://c.example/api/interpreter';
|
||||
assert.equal(await readMilitaryInstallationDisk(key, TTL_MS, dir), null, 'fresh read refused');
|
||||
assert.equal(await readMilitaryInstallationDisk(key, Infinity, dir), null, 'stale read refused');
|
||||
await writeMilitaryInstallationDisk('legacy', entry(Date.now()), dir);
|
||||
assert.equal(await readMilitaryInstallationDisk('legacy', Infinity, dir), null, 'untagged entry refused');
|
||||
delete process.env.OVERPASS_UPSTREAMS;
|
||||
assert.ok(await readMilitaryInstallationDisk(key, Infinity, dir), 'nothing configured accepts any entry');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+19
-4
@@ -299,6 +299,24 @@ function imageryPinOption(key, token) {
|
||||
});
|
||||
}
|
||||
|
||||
function satelliteFilterOption() {
|
||||
const percentage = boundedIntegerOption('infrared', 'i', 100, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
});
|
||||
return Object.freeze({
|
||||
...percentage,
|
||||
normalize: (value) =>
|
||||
value === 'filtered'
|
||||
? 100
|
||||
: value === 'full'
|
||||
? 0
|
||||
: percentage.normalize(value),
|
||||
decode: (value) =>
|
||||
value === 'f' ? 100 : value === 'a' ? 0 : percentage.decode(value),
|
||||
});
|
||||
}
|
||||
|
||||
const OPTION_GROUPS = Object.freeze({
|
||||
traffic: Object.freeze([
|
||||
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
|
||||
@@ -320,10 +338,7 @@ const OPTION_GROUPS = Object.freeze({
|
||||
}),
|
||||
]),
|
||||
'weather-satellite': Object.freeze([
|
||||
enumOption('infrared', 'i', 'filtered', ['filtered', 'full'], {
|
||||
filtered: 'f',
|
||||
full: 'a',
|
||||
}),
|
||||
satelliteFilterOption(),
|
||||
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
|
||||
light: 'l',
|
||||
strong: 's',
|
||||
|
||||
@@ -2422,23 +2422,74 @@ test('wind appearance shares round trip while old links retain weather defaults'
|
||||
assert.deepEqual(invalid.options.wind, defaults);
|
||||
});
|
||||
|
||||
test('observed weather round trips product and opacity without persisting historical playback', () => {
|
||||
test('observed weather round trips product and presets without persisting historical playback', () => {
|
||||
const state = normalizeLayerState({
|
||||
enabledLayerIds: ['weather-radar', 'weather-satellite'],
|
||||
options: {
|
||||
'weather-radar': { opacity: 'light', play: true },
|
||||
'weather-satellite': { product: 'clouds', opacity: 'light', step: -1 },
|
||||
'weather-satellite': {
|
||||
product: 'clouds',
|
||||
opacity: 'light',
|
||||
infrared: 'filtered',
|
||||
step: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const params = new URLSearchParams(encode(state));
|
||||
const decoded = decodeLayerStateParams(params);
|
||||
assert.deepEqual(decoded, state);
|
||||
assert.equal(state.options['weather-satellite'].product, 'clouds');
|
||||
assert.equal(state.options['weather-satellite'].opacity, 'light');
|
||||
assert.equal(state.options['weather-satellite'].infrared, 100);
|
||||
assert.equal(Object.hasOwn(state.options['weather-radar'], 'play'), false);
|
||||
});
|
||||
|
||||
test('satellite infrared display mode round trips and invalid or absent values use filtered', () => {
|
||||
for (const infrared of ['full', 'filtered', undefined, 'invalid']) {
|
||||
test('satellite filter strength percentages round-trip and legacy mode codes remain readable', () => {
|
||||
const state = normalizeLayerState({
|
||||
enabledLayerIds: ['weather-satellite'],
|
||||
options: { 'weather-satellite': { infrared: 53, opacity: 'light' } },
|
||||
});
|
||||
const params = encodeLayerStateParams(
|
||||
new URLSearchParams([['v', '2']]),
|
||||
state,
|
||||
);
|
||||
assert.deepEqual(decodeLayerStateParams(params), state);
|
||||
assert.equal(state.options['weather-satellite'].infrared, 53);
|
||||
assert.equal(state.options['weather-satellite'].opacity, 'light');
|
||||
for (const [legacy, expected] of [
|
||||
['f', 100],
|
||||
['a', 0],
|
||||
]) {
|
||||
const decoded = decodeLayerStateParams(
|
||||
new URLSearchParams(`v=2&l=o&lo=o.i.${legacy}`),
|
||||
);
|
||||
assert.equal(decoded.options['weather-satellite'].infrared, expected);
|
||||
}
|
||||
for (const [legacy, expected] of [
|
||||
['filtered', 100],
|
||||
['full', 0],
|
||||
]) {
|
||||
const normalized = normalizeLayerState({
|
||||
enabledLayerIds: ['weather-satellite'],
|
||||
options: { 'weather-satellite': { infrared: legacy } },
|
||||
});
|
||||
assert.equal(normalized.options['weather-satellite'].infrared, expected);
|
||||
}
|
||||
const invalid = normalizeLayerState({
|
||||
enabledLayerIds: ['weather-satellite'],
|
||||
options: { 'weather-satellite': { infrared: 101 } },
|
||||
});
|
||||
assert.equal(invalid.options['weather-satellite'].infrared, 100);
|
||||
});
|
||||
|
||||
test('satellite cloud filter round trips and invalid or absent values default to Clouds only', () => {
|
||||
for (const [infrared, expected] of [
|
||||
['full', 0],
|
||||
['filtered', 100],
|
||||
[53, 53],
|
||||
[undefined, 100],
|
||||
['invalid', 100],
|
||||
]) {
|
||||
const state = normalizeLayerState({
|
||||
enabledLayerIds: ['weather-satellite'],
|
||||
options: {
|
||||
@@ -2454,10 +2505,7 @@ test('satellite infrared display mode round trips and invalid or absent values u
|
||||
decodeLayerStateParams(new URLSearchParams(encode(state))),
|
||||
state,
|
||||
);
|
||||
assert.equal(
|
||||
state.options['weather-satellite'].infrared,
|
||||
infrared === 'full' ? 'full' : 'filtered',
|
||||
);
|
||||
assert.equal(state.options['weather-satellite'].infrared, expected);
|
||||
assert.equal(
|
||||
Object.hasOwn(state.options['weather-satellite'], 'step'),
|
||||
false,
|
||||
|
||||
Reference in new issue
Block a user