diff --git a/src/app.js b/src/app.js
index 1e47272..50bdb54 100644
--- a/src/app.js
+++ b/src/app.js
@@ -102,11 +102,19 @@ function renderDirectory() {
: r.has_legal_help_program === false
? "no"
: "?";
+ const evidence = r.has_legal_help_program === true ? safeHttpUrl(r.legal_help_evidence) : null;
+ const legalCell = evidence
+ ? `yes`
+ : legal;
+ const site = safeHttpUrl(r.website);
+ const siteLine = site
+ ? `
website`
+ : "";
return `
None recorded yet. Null beats guessed.
` } + ${ + r.has_legal_help_program === true + ? `Legal-help program${ + legalEvidence + ? `: evidence` + : "." + }
` + : "" + }Name a service, hours or a closure, or a book/resource at this location. No ratings, stars, or reviews.
diff --git a/src/lib/geolocation.js b/src/lib/geolocation.js index b0e8c87..ce5c59a 100644 --- a/src/lib/geolocation.js +++ b/src/lib/geolocation.js @@ -1,5 +1,9 @@ /** * Resolve device position via Capacitor on native, else the browser Geolocation API. + * + * `@capacitor/geolocation` is a runtime plugin (see capacitor.plugins.json). + * src/ is unbundled static HTML, so we call Capacitor.Plugins.Geolocation + * rather than a bare specifier that the browser cannot resolve. * @returns {Promise<{ lat: number; lon: number }>} */ export async function getDevicePosition() { diff --git a/src/lib/nostos.js b/src/lib/nostos.js index 2290585..0daf47e 100644 --- a/src/lib/nostos.js +++ b/src/lib/nostos.js @@ -122,15 +122,40 @@ function zip5Of(zip) { return null; } +/** + * Case-fold a place name. Periods drop so "W. Milford" matches "W MILFORD". + * @param {unknown} s + */ +function normalizePlaceName(s) { + return String(s || "") + .toLowerCase() + .replace(/[.]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + /** * @param {string} haystack * @param {string} needle */ function hasWordBoundary(haystack, needle) { + if (!needle || needle.length > 80) return false; const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return new RegExp(`\\b${escaped}\\b`, "i").test(haystack); } +/** + * City match that does not treat "Milford" as "New Milford". + * Normalized exact equality (periods stripped) — never substring includes. + * @param {string} city + * @param {string} term + */ +function cityMatchesQuery(city, term) { + const c = normalizePlaceName(city); + const t = normalizePlaceName(term); + return Boolean(c && t && c === t); +} + export function resolveLocationQuery(query, libraries) { const q = query.trim(); if (!q) return null; @@ -143,21 +168,20 @@ export function resolveLocationQuery(query, libraries) { return centroid(hits, `ZIP ${zip5}`); } - const term = q.toLowerCase(); - const exactCity = libraries.filter( - (r) => isValidCoord(r) && String(r.city || "").trim().toLowerCase() === term, - ); + if (q.length > 80) return null; + + const exactCity = libraries.filter((r) => isValidCoord(r) && cityMatchesQuery(String(r.city || ""), q)); if (exactCity.length > 0) { - const cities = [...new Set(exactCity.map((r) => String(r.city).trim().toLowerCase()))]; + const cities = [...new Set(exactCity.map((r) => normalizePlaceName(r.city)))]; + const labelCity = title(exactCity[0].city); const label = - exactCity.length > 1 && cities.length === 1 - ? `${title(exactCity[0].city)} (${exactCity.length} locations)` - : cities.length > 1 - ? `${title(term)} (${exactCity.length} locations)` - : title(exactCity[0].city); - return centroid(exactCity, label); + exactCity.length > 1 + ? `${labelCity} (${exactCity.length} locations)` + : labelCity; + return centroid(exactCity, cities.length === 1 ? label : `${title(q)} (${exactCity.length} locations)`); } + const term = q.toLowerCase(); if (term.length < NAME_FALLBACK_MIN_LEN || term.length > 80 || GENERIC_NAME_TERMS.has(term)) return null; const nameHits = libraries.filter( diff --git a/tests/adversarial/geolocation.test.js b/tests/adversarial/geolocation.test.js new file mode 100644 index 0000000..eb9cea0 --- /dev/null +++ b/tests/adversarial/geolocation.test.js @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { getDevicePosition } from "../../src/lib/geolocation.js"; + +afterEach(() => { + delete globalThis.Capacitor; +}); + +test("native Capacitor plugin is used when isNativePlatform is true", async () => { + let called = false; + globalThis.Capacitor = { + isNativePlatform: () => true, + Plugins: { + Geolocation: { + getCurrentPosition: async () => { + called = true; + return { coords: { latitude: 40.74, longitude: -74.03 } }; + }, + }, + }, + }; + const pos = await getDevicePosition(); + assert.equal(called, true); + assert.equal(pos.lat, 40.74); + assert.equal(pos.lon, -74.03); +}); + +test("browser geolocation is used when Capacitor is not native", async () => { + globalThis.Capacitor = { isNativePlatform: () => false }; + const original = navigator.geolocation; + Object.defineProperty(navigator, "geolocation", { + configurable: true, + value: { + getCurrentPosition: (ok) => ok({ coords: { latitude: 39.36, longitude: -74.42 } }), + }, + }); + try { + const pos = await getDevicePosition(); + assert.equal(pos.lat, 39.36); + assert.equal(pos.lon, -74.42); + } finally { + Object.defineProperty(navigator, "geolocation", { configurable: true, value: original }); + } +}); + +test("browser geolocation deny tells the user to use ZIP or town", async () => { + const original = navigator.geolocation; + Object.defineProperty(navigator, "geolocation", { + configurable: true, + value: { + getCurrentPosition: (_ok, err) => err({ code: 1, message: "denied" }), + }, + }); + try { + await assert.rejects(getDevicePosition(), /Location denied — use ZIP or town/i); + } finally { + Object.defineProperty(navigator, "geolocation", { configurable: true, value: original }); + } +}); diff --git a/tests/adversarial/nostos.test.js b/tests/adversarial/nostos.test.js index 2eb8598..3dbb7ed 100644 --- a/tests/adversarial/nostos.test.js +++ b/tests/adversarial/nostos.test.js @@ -115,6 +115,10 @@ test("resolveLocationQuery town matching is exact city, not substring", () => { assert.ok(franklinLakes); assert.match(franklinLakes.label, /Franklin Lakes/i); + const westMilford = resolveLocationQuery("W. Milford", libraries); + assert.ok(westMilford); + assert.match(westMilford.label, /W\.?\s*Milford/i); + assert.equal(resolveLocationQuery("New", libraries), null); assert.equal(resolveLocationQuery("1", libraries), null); assert.equal(resolveLocationQuery("a", libraries), null); diff --git a/tests/e2e/adversarial.spec.js b/tests/e2e/adversarial.spec.js index 542c572..c5adb7b 100644 --- a/tests/e2e/adversarial.spec.js +++ b/tests/e2e/adversarial.spec.js @@ -68,6 +68,10 @@ test("directory does not emit javascript: href from poisoned website", async ({ body: JSON.stringify(poisoned), }); }); + await page.goto(`${BASE}#/directory`, { waitUntil: "networkidle" }); + await page.getByTestId("search").fill("XSS OUTLET"); + await expect(page.getByTestId("directory-row").first()).toBeVisible(); + await expect(page.locator('a[href^="javascript:"]')).toHaveCount(0); await page.goto(`${BASE}#/outlet/XSS001-001`, { waitUntil: "networkidle" }); const contact = page.getByTestId("outlet-contact"); await expect(contact).toBeVisible(); @@ -75,6 +79,17 @@ test("directory does not emit javascript: href from poisoned website", async ({ await expect(page.getByTestId("outlet-name")).toContainText("Xss Outlet"); }); +test("finder Milford is Hunterdon, not New Milford", async ({ page }) => { + await page.goto(BASE, { waitUntil: "networkidle" }); + await page.getByTestId("finder-location").fill("Milford"); + await page.getByTestId("finder-submit").click(); + await expect(page.getByTestId("finder-status")).toContainText(/Milford/i); + await expect(page.getByTestId("finder-status")).not.toContainText(/New Milford/i); + await expect(page.getByTestId("finder-row").first()).toBeVisible(); + const first = await page.getByTestId("finder-row").first().locator("td:nth-child(2)").textContent(); + expect(first || "").not.toMatch(/New Milford/i); +}); + test("outlet page shows contact above the fold and rejects a review proposal", async ({ page }) => { await page.goto(BASE, { waitUntil: "networkidle" }); await page.getByTestId("finder-location").fill("08401");