Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,19 @@ function renderDirectory() {
: r.has_legal_help_program === false
? "no"
: "<span style='color:var(--muted)'>?</span>";
const evidence = r.has_legal_help_program === true ? safeHttpUrl(r.legal_help_evidence) : null;
const legalCell = evidence
? `<a class="legal" href="${escapeHtml(evidence)}" rel="noopener noreferrer" target="_blank" data-testid="legal-evidence">yes</a>`
: legal;
const site = safeHttpUrl(r.website);
const siteLine = site
? `<br><small><a href="${escapeHtml(site)}" rel="noopener noreferrer" target="_blank">website</a></small>`
: "";
return `<tr data-fscs="${escapeHtml(outletKey(r))}" data-testid="directory-row">
<td>${libraryLink(r)}<br><small style="color:var(--muted)">${escapeHtml(r.outlet_type || "")}${r.phone ? ` · ${escapeHtml(formatPhone(r.phone))}` : ""}</small></td>
<td>${libraryLink(r)}${siteLine}<br><small style="color:var(--muted)">${escapeHtml(r.outlet_type || "")}${r.phone ? ` · ${escapeHtml(formatPhone(r.phone))}` : ""}</small></td>
<td>${escapeHtml(title(r.system_name))}</td><td>${escapeHtml(title(r.county))}</td>
<td>${escapeHtml([title(r.address), title(r.city), r.zip].filter(Boolean).join(", "))}</td>
<td>${legal}</td>
<td>${legalCell}</td>
<td><span class="st st-${escapeHtml(s)}">${escapeHtml(s)}</span>${notes ? `<br><small>${notes}</small>` : ""}</td>
</tr>`;
})
Expand Down Expand Up @@ -166,6 +174,7 @@ function renderOutlet(key) {
const director = contactField(r.director);
const board = contactField(r.board_url);
const website = safeHttpUrl(r.website);
const legalEvidence = safeHttpUrl(r.legal_help_evidence);
const formUrl = form ? safeHttpUrl(form.value) : null;
const boardUrl = board ? safeHttpUrl(board.value) : null;
const services = servicesOf(r);
Expand Down Expand Up @@ -218,12 +227,22 @@ function renderOutlet(key) {
services.length
? services
.map((s) => {
const ev = s.evidence_url ? ` <a href="${escapeHtml(s.evidence_url)}" rel="noopener noreferrer" target="_blank">evidence</a>` : "";
return `<span class="chip">${escapeHtml(s.name)}${ev}</span>`;
const ev = safeHttpUrl(s.evidence_url);
const evHtml = ev ? ` <a href="${escapeHtml(ev)}" rel="noopener noreferrer" target="_blank">evidence</a>` : "";
return `<span class="chip">${escapeHtml(s.name)}${evHtml}</span>`;
})
.join("")
: `<p class="sub">None recorded yet. Null beats guessed.</p>`
}</div>
${
r.has_legal_help_program === true
? `<p data-testid="legal-help">Legal-help program${
legalEvidence
? `: <a href="${escapeHtml(legalEvidence)}" rel="noopener noreferrer" target="_blank">evidence</a>`
: "."
}</p>`
: ""
}
<div class="proposal">
<h3>Propose an update</h3>
<p class="sub">Name a service, hours or a closure, or a book/resource at this location. No ratings, stars, or reviews.</p>
Expand Down
4 changes: 4 additions & 0 deletions src/lib/geolocation.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
46 changes: 35 additions & 11 deletions src/lib/nostos.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down
59 changes: 59 additions & 0 deletions tests/adversarial/geolocation.test.js
Original file line number Diff line number Diff line change
@@ -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 });
}
});
4 changes: 4 additions & 0 deletions tests/adversarial/nostos.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions tests/e2e/adversarial.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,28 @@ 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();
await expect(page.locator('a[href^="javascript:"]')).toHaveCount(0);
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");
Expand Down
Loading