diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fee2812 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Matches the Dockerfile's node:22-alpine so CI runs the same Node + # version the server actually deploys on. + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npm test + + # The live RescueGroups integration test hits the real API and needs a + # real key -- it's excluded from CI on purpose (see README "Run the + # live RescueGroups integration test..."), same as it's excluded from + # `npm test` locally. Run `npm run test:live` manually before merging + # any change to search radius, pagination, or the RescueGroups query + # contract. diff --git a/.github/workflows/deploy-verify.yml b/.github/workflows/deploy-verify.yml new file mode 100644 index 0000000..304068e --- /dev/null +++ b/.github/workflows/deploy-verify.yml @@ -0,0 +1,84 @@ +name: Verify production deploy + +# Northflank's own build trigger on the `tabby` service fires independently +# on push to main (see buildConfiguration.pathIgnoreRules there) -- this +# workflow doesn't control or trigger that build, it just confirms it +# actually landed. Runs in parallel with Northflank's build, so it has to +# wait/poll rather than assume the deploy is already done by the time this +# job starts. +on: + push: + branches: [main] + +env: + PROD_URL: https://p01--tabby--bklqdgzwx4md.code.run + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check whether this push touches a deployed path + id: changed + run: | + # Mirrors the tabby service's own buildConfiguration allow-list + # (isAllowList: true, pathIgnoreRules: ["server/**", "Dockerfile"]) + # -- if Northflank wouldn't rebuild for this push, waiting for the + # live sha to change would just time out for no reason. + if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -qE '^(server/|Dockerfile$)'; then + echo "deploy=true" >> "$GITHUB_OUTPUT" + else + echo "deploy=false" >> "$GITHUB_OUTPUT" + echo "No server/** or Dockerfile changes in this push -- Northflank will not rebuild, skipping verification." + fi + + - name: Wait for the live deployment to report this commit + if: steps.changed.outputs.deploy == 'true' + run: | + set -e + EXPECTED="${{ github.sha }}" + echo "Waiting for $PROD_URL/healthz to report sha=$EXPECTED ..." + for i in $(seq 1 40); do + ACTUAL=$(curl -fsS "$PROD_URL/healthz" 2>/dev/null | jq -r '.sha // empty' || echo "") + if [ "$ACTUAL" = "$EXPECTED" ]; then + echo "Live deployment matches ($ACTUAL) after $((i * 15))s." + exit 0 + fi + echo " [$i/40] still on $ACTUAL, retrying in 15s..." + sleep 15 + done + echo "::error::Timed out after 10 minutes waiting for $PROD_URL to report sha=$EXPECTED (last seen: $ACTUAL). Check the tabby service's build/deploy status on Northflank before proceeding to store submission." + exit 1 + + - name: Smoke test the live API + if: steps.changed.outputs.deploy == 'true' + run: | + set -e + + echo "-- /healthz --" + curl -fsS "$PROD_URL/healthz" | jq . + + echo "-- POST /api/nearby-cats --" + NEARBY=$(curl -fsS -X POST "$PROD_URL/api/nearby-cats" \ + -H "Content-Type: application/json" \ + -d '{"location":{"postalcode":"10001"},"page":1}') + echo "$NEARBY" | jq '{cardCount: (.cards | length), radiusMiles}' + CARD_COUNT=$(echo "$NEARBY" | jq '.cards | length') + if [ "$CARD_COUNT" -lt 1 ]; then + echo "::error::/api/nearby-cats returned zero cards for a known-good ZIP -- possible RescueGroups outage or regression, not necessarily a bad deploy." + exit 1 + fi + IMAGE_URL=$(echo "$NEARBY" | jq -r '.cards[0].imageUrl') + + echo "-- GET /api/photo-thumb --" + curl -fsS -o /dev/null -w "status=%{http_code} size=%{size_download}\n" \ + "$PROD_URL/api/photo-thumb?url=$(node -e "console.log(encodeURIComponent(process.argv[1]))" "$IMAGE_URL")" + + echo "-- GET /api/photo-share --" + curl -fsS -o /dev/null -w "status=%{http_code} size=%{size_download}\n" \ + "$PROD_URL/api/photo-share?url=$(node -e "console.log(encodeURIComponent(process.argv[1]))" "$IMAGE_URL")" + + echo "All smoke checks passed -- safe to proceed with store submission." diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000..a8d7732 --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -0,0 +1,38 @@ +name: Tag release + +# manifest.json and package.json are always bumped together by +# scripts/release.js, so manifest.json's version is the single source of +# truth for "what version just landed on main." Runs on every push to main +# and no-ops if that version is already tagged, so it's safe to fire on +# every merge rather than needing to detect "was this a release commit." +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create and push a version tag if one doesn't already exist + run: | + set -e + VERSION=$(node -p "require('./manifest.json').version") + TAG="v$VERSION" + + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists -- nothing to do." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + echo "Created and pushed $TAG." diff --git a/Dockerfile b/Dockerfile index 2c833ef..07eec78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,13 @@ FROM node:22-alpine WORKDIR /app +# package.json declares "type": "module" -- server/index.js and +# server/rescuegroups.js rely on that for import/export syntax to parse as +# ESM. Node 22 happens to auto-detect module syntax when no package.json is +# present at all, which is why this worked before without it, but that's an +# implicit, version-specific fallback -- copying the real package.json makes +# module resolution explicit and correct regardless of the Node version this +# image is ever rebased to. +COPY package.json ./package.json COPY server ./server EXPOSE 8787 CMD ["node", "server/index.js"] diff --git a/README.md b/README.md index ab192ec..2a4ef11 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Tabby is a Manifest V3 Chrome extension that replaces the new tab page with a ne - Browser-coordinate lookup with native postal-code fallback. - Server-side 25 -> 75 -> 150 -> 250 mile radius ladder, escalating on cumulative deduplicated results until 40 unique cats are found. - RescueGroups `available/cats/haspic` query, nearest-first sorting, picture validation, organization join, and safe profile-url fallback. +- Content-aware crop for portrait photos: a row-wise edge-energy heuristic finds the likely subject band instead of always anchoring to the top, via a small hostname-locked analysis-thumbnail proxy (`GET /api/photo-thumb`) that works around RescueGroups' CDN sending no CORS headers. +- "Share this cat" via the Web Share API, attaching the actual photo (fetched through a second hostname-locked, share-sized proxy, `GET /api/photo-share`, for the same CORS reason as the crop analysis above) alongside the cat's details, a Tabby tagline, and its RescueGroups profile link. Degrades to a link-only native share if the photo can't be attached, and to a clipboard-copy if the platform has no Web Share API at all. - No third-party runtime dependencies; Node's built-in test runner. ## Run locally @@ -34,17 +36,29 @@ Deploying the server is a separate step from packaging the extension; whatever h - `RG_API_KEY` — set in the platform's secret manager, never committed. - `ALLOW_ORIGIN` — the installed extension's exact `chrome-extension://` origin, not `*`. Each store (Chrome Web Store, Edge Add-ons, ...) assigns its own extension ID even for an identical package, so once the extension is published to more than one store this needs a comma-separated list of every store's origin, e.g. `chrome-extension://elfpnkoboidkgahmoggodpnmekfodcig,chrome-extension://fieeoalehgckgnkohkdblljmgaemaiho` (Edge extensions also use the `chrome-extension://` scheme, not `edge-extension://`). The server reflects back whichever of these matches the incoming request's `Origin` header; an origin not in the list gets refused by the browser. This is only knowable once the extension has been uploaded to each store's dashboard at least once (that's what assigns the permanent ID), so it's normal to deploy once with `ALLOW_ORIGIN` unset (permissive, with a logged warning) and circle back to lock it down afterward — and to append to the list, rather than replace it, each time the extension is published to a new store. -- `NODE_ENV=production` — currently gates stack-trace logging; a follow-up change will also use it to select the production RescueGroups API endpoint instead of the dev/sandbox one. +- `NODE_ENV=production` — gates stack-trace logging in error responses. RescueGroups has only one API endpoint (`server/rescuegroups.js`'s `BASE_URL`); local dev, tests, and production all call the same live API, there is no separate dev/sandbox endpoint to select between. The in-memory cache is correct as-is for the intended deployment target: a single persistent Node process (for example Render, Railway, Fly.io, or Northflank). It would need to be replaced with a shared cache (for example KV/Redis) only if the server is ever scaled to multiple concurrent instances, or moved to a serverless/edge platform (Vercel functions, Cloudflare Workers) where in-process state isn't reliably shared or persistent between requests — those platforms would also require restructuring `server/index.js` away from its current `node:http` `createServer` model. +`/api/nearby-cats` is also rate-limited per client IP (30 requests / 5 minutes, in-memory, same deployment assumption as the cache above) — a cache miss costs a real RescueGroups API call, so this bounds how much a script varying postal codes/coordinates can cost regardless of the response cache. The client IP is taken from `X-Forwarded-For` when present (Northflank and similar platforms terminate the real connection and forward, so `request.socket.remoteAddress` alone would otherwise be the platform's internal proxy address for every request), falling back to the raw socket address only when that header is absent, as in local dev. If a future host doesn't set `X-Forwarded-For` in front of this server, every request would be seen as one shared IP. + Once the server has a real HTTPS URL, package the extension with: ```powershell npm.cmd run release 1.0.0 https://your-deployed-backend.example.com ``` -This bumps `manifest.json`/`package.json` to the given version and zips `manifest.json` + `extension/` for the Chrome Web Store — it does not touch `server/` or its hosting environment. The `` argument is required (must be `https://`) and is only baked into the **staged copy that gets zipped** — `extension/config.js` in the repo itself is never modified, so it always stays at `http://localhost:8787` for local dev and the test suite (which asserts requests go to `localhost:8787`). There's no manual edit-then-revert step needed for a release. +This bumps `manifest.json`/`package.json` to the given version and zips `manifest.json` + `extension/` for the Chrome Web Store — it does not touch `server/` or its hosting environment. The `` argument is required (must be `https://`) and is only baked into the **staged copy that gets zipped** — `extension/config.js` in the repo itself is never modified, so it always stays at `http://localhost:8787` for local dev and the test suite (which asserts requests go to `localhost:8787`). There's no manual edit-then-revert step needed for a release. The same zip is submitted to both the Chrome Web Store and Edge Add-ons — Edge is Chromium-based and needs no code changes. + +## CI/CD + +Three GitHub Actions workflows automate the release process end to end — see [RELEASE.md](RELEASE.md) for the step-by-step runbook: + +- **`ci.yml`** — runs `npm test` on every push and pull request to `main`/`dev`, pinned to Node 22 to match the Dockerfile. `main` has a ruleset (Settings → Rules → Rulesets) requiring this check to pass and requiring a pull request before merging — the workflow alone doesn't block anything, only the ruleset does. +- **`tag-release.yml`** — on every push to `main`, tags the commit `v` (read from `manifest.json`) if that tag doesn't already exist. Idempotent, so it's safe to fire on every push rather than needing to detect "was this actually a release." +- **`deploy-verify.yml`** — on every push to `main` that touches `server/**` or `Dockerfile` (mirroring the `tabby` service's own Northflank build trigger), polls the live `/healthz` endpoint until its `sha` field (see below) matches the pushed commit, then smoke-tests `/api/nearby-cats`, `/api/photo-thumb`, and `/api/photo-share` against production. A **green run is the signal that it's safe to build and submit the release to CWS/EWS** — since Northflank deploys in minutes and store review takes hours, the server is always live and correct well before any user's browser updates to a new extension version, as long as server changes stay additive/backward-compatible with whatever extension version is still in the wild. + +`/healthz` reports `{ status: "ok", sha }`, where `sha` is Northflank's auto-injected `NF_DEPLOYMENT_SHA` runtime env var (the exact git commit of the running build) — `null` locally, where that variable is never set. This is what lets `deploy-verify.yml` confirm the *new* code is actually live, not just that some process answered the health check. To manually smoke-test the unpacked extension against a real deployed backend (as opposed to packaging a release), temporarily edit `BACKEND_URL` in `extension/config.js` yourself, reload the unpacked extension, test, then revert the edit (`git checkout -- extension/config.js`) before committing anything or running the test suite. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..c5e6bb0 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,29 @@ +# Release runbook + +Steps to ship a change from `dev` to `main`, which auto-deploys the server to Northflank, and on to the Chrome Web Store / Edge Add-ons. + +## 1. One-time setup (only needs doing once, ever) + +- [ ] `main` has a ruleset requiring the `test` status check (from `.github/workflows/ci.yml`) to pass before merging, and requiring changes to go through a pull request. See GitHub → repo → Settings → Rules → Rulesets. + +## 2. Pre-merge + +- [ ] Open a pull request from `dev` into `main` — don't merge locally and push. Going through a PR is what lets the ruleset above actually gate the merge on CI passing (a direct push of an untested commit is rejected outright, since it can never satisfy the required check). +- [ ] Wait for the `test` check to go green. If it's red, stop — do not merge. +- [ ] Merge the PR. + +## 3. Post-merge (fully automated — just watch) + +- [ ] `.github/workflows/tag-release.yml` tags the commit `v` (read from `manifest.json`). Check the repo's Tags page. +- [ ] `.github/workflows/deploy-verify.yml` polls the live server's `/healthz` until it reports this exact commit's SHA (`NF_DEPLOYMENT_SHA`, injected by Northflank), then smoke-tests `/api/nearby-cats`, `/api/photo-thumb`, and `/api/photo-share` against production. **A green run is the signal that it's safe to proceed to store submission.** If it goes red or times out, stop and check the `tabby` service's build/deploy status directly on Northflank before doing anything else. + +## 4. Store submission (manual — both stores require a human in their dashboard) + +- [ ] Build the release zip: `npm run release https://p01--tabby--bklqdgzwx4md.code.run` +- [ ] Upload `dist/v.zip` to the Chrome Web Store dashboard, submit for review. +- [ ] Upload the same zip to the Edge Add-ons dashboard, submit for review. +- [ ] Update listing copy/screenshots if the release changes what's visibly different to a user. + +## Why store review lag isn't a problem + +Northflank deploys in minutes; CWS/EWS review takes hours, and browsers don't auto-update installed extensions instantly even after approval — so there's always a window where some users are on an older extension version talking to the new server. This is safe as long as every server change is **additive**: never remove or change an existing endpoint/field an older client version depends on in the same release that ships a client relying on the removal. A new endpoint an old client never calls is always safe to ship ahead of the client that uses it. diff --git a/cws/CHROMEWEBSTORE.md b/cws/CHROMEWEBSTORE.md index 1a4cd57..9ae2601 100644 --- a/cws/CHROMEWEBSTORE.md +++ b/cws/CHROMEWEBSTORE.md @@ -100,7 +100,7 @@ English 4. **Settings** — the redesigned settings page (stacked "Use my location" / ZIP layout from the recent redesign). 5. **Status chips & fee** — a card showing the "Adoption pending" / "Special needs" chips and a normalized adoption fee, to demonstrate the extension surfaces real, practical adoption details. -All screenshots were captured from the actual `extension/newtab.html` and `extension/options.html` running unmodified, with realistic sample data standing in for a live RescueGroups response (an automated capture can't depend on a real user's location or the live API's current inventory) — layout, CSS, and copy are all pixel-real, not a mockup. +All screenshots use the real `extension/newtab.css`/`newtab.js`/`options.js` unmodified — layout, CSS, and copy are all pixel-real, not a mockup — with realistic sample data standing in for a live RescueGroups response (an automated capture can't depend on a real user's location or the live API's current inventory). Per GitHub issue #28, the original captures showed the real 620px-wide card centered on a 1280×800 canvas with huge empty margins (close to half the image blank), since `.shell`'s max-width is fixed regardless of viewport. Recaptured via `screenshot-frame.source.html` / `screenshot-frame-options.source.html` (see those files for the exact per-screenshot URLs), which scale the rendered card up to ~75–90% of the canvas width without touching the real responsive layout at all. The card's own aspect ratio is close to square, so filling 80%+ of a wide 1280×800 canvas by width while keeping every line of text on-screen isn't simultaneously achievable — screenshots 1 and 5 trade a bit of width (down to ~63–75%) to keep their relevant text (name / status chips & fee) uncropped, rather than hit 80% at the cost of visibly truncated text; screenshots 2–4 reach ~75–90% width with nothing cropped. ## Permissions Justification diff --git a/cws/screenshot-1-main-card.png b/cws/screenshot-1-main-card.png index 8ef0c3a..677d3fd 100644 Binary files a/cws/screenshot-1-main-card.png and b/cws/screenshot-1-main-card.png differ diff --git a/cws/screenshot-2-explore.png b/cws/screenshot-2-explore.png index 5e50851..7c93f0b 100644 Binary files a/cws/screenshot-2-explore.png and b/cws/screenshot-2-explore.png differ diff --git a/cws/screenshot-3-first-run.png b/cws/screenshot-3-first-run.png index 2fdfd91..ebe8f58 100644 Binary files a/cws/screenshot-3-first-run.png and b/cws/screenshot-3-first-run.png differ diff --git a/cws/screenshot-4-settings.png b/cws/screenshot-4-settings.png index 9e9c130..21416eb 100644 Binary files a/cws/screenshot-4-settings.png and b/cws/screenshot-4-settings.png differ diff --git a/cws/screenshot-5-fee-tags.png b/cws/screenshot-5-fee-tags.png index e660116..d39005b 100644 Binary files a/cws/screenshot-5-fee-tags.png and b/cws/screenshot-5-fee-tags.png differ diff --git a/cws/screenshot-frame-options.source.html b/cws/screenshot-frame-options.source.html new file mode 100644 index 0000000..5f17ccc --- /dev/null +++ b/cws/screenshot-frame-options.source.html @@ -0,0 +1,58 @@ + + + + + +Tabby screenshot frame (settings) + + + + +
+
+
+

TABBY SETTINGS

+ +
+
+

Your Location

+
+
+ +
+

or

+
+
+ + +
+ +
+
+ +
+
+
+ + + + diff --git a/cws/screenshot-frame.source.html b/cws/screenshot-frame.source.html new file mode 100644 index 0000000..aef7035 --- /dev/null +++ b/cws/screenshot-frame.source.html @@ -0,0 +1,200 @@ + + + + + +Tabby screenshot frame + + + + + +
+
+
+

Tabby

+
+ + +
+
+ + + + + +
+
+ + + + diff --git a/extension/error-messages.js b/extension/error-messages.js index 7eed0f7..d465c60 100644 --- a/extension/error-messages.js +++ b/extension/error-messages.js @@ -1,7 +1,9 @@ -export function classifyRefreshError(message) { - const isInvalidZip = /five-digit|postal ?code|zip code|invalid/i.test(message); +export function isInvalidZipError(message) { + return /five-digit|postal ?code|zip code|invalid/i.test(message); +} - if (isInvalidZip) { +export function classifyRefreshError(message) { + if (isInvalidZipError(message)) { return "That ZIP code looks invalid. Please update it."; } diff --git a/extension/newtab.css b/extension/newtab.css index 241334c..7fc5638 100644 --- a/extension/newtab.css +++ b/extension/newtab.css @@ -138,12 +138,14 @@ h1 { margin: 0; font-weight: 700; color: var(--ink); } .rescue { font-size: 0.8125rem; font-weight: 400; color: var(--ink); margin: 0 0 1.375rem; } .rescue a { color: var(--moss-dark); text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 2px; } +.card-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; } + .profile { display: flex; align-items: center; justify-content: center; + flex: 1 1 auto; width: min(100%, 220px); - margin: 0 auto; text-decoration: none; background: var(--moss); color: var(--paper); @@ -154,6 +156,17 @@ h1 { margin: 0; font-weight: 700; color: var(--ink); } } .profile:hover { background: var(--moss-dark); } +.share { + flex: 1 1 auto; + width: min(100%, 220px); + background: transparent; + color: var(--moss-dark); + border: 1.5px solid var(--moss); + padding: 10.5px 16px; + font-size: 0.95rem; +} +.share:hover { background: var(--moss); color: var(--paper); } + @media (max-width: 360px) { .photo { width: calc(100% - 32px); margin: 16px 16px 0; } .content { padding: 14px 16px 18px; } @@ -199,6 +212,10 @@ input { display: block; width: 100%; padding: 10px; margin-top: 4px; border: 1px } .notice.notice-error { color: var(--stamp); } .notice a { color: inherit; } +/* Message text plus any links wrap together as one run of text -- see + buildNoticeLinkButton()/showNotice() in newtab.js for why this can't just + be a flex item like the icon (GitHub issue #29). */ +.notice-body { flex: 1 1 auto; min-width: 0; } .notice-link { color: var(--moss-dark); font-weight: 600; text-decoration: underline; text-underline-offset: 2px; background: none; border: none; padding: 0; cursor: pointer; font: inherit; } .notice-link:hover { background: none; } diff --git a/extension/newtab.js b/extension/newtab.js index 50348fe..4ca07e3 100644 --- a/extension/newtab.js +++ b/extension/newtab.js @@ -1,4 +1,4 @@ -import { classifyRefreshError } from "./error-messages.js"; +import { classifyRefreshError, isInvalidZipError } from "./error-messages.js"; import { BACKEND_URL } from "./config.js"; import { locationFromBrowser } from "./location.js"; @@ -18,8 +18,36 @@ const SEEN_REFRESH_RATIO = 0.85; // box, and only the genuinely tall tail gets the full treatment. const MILD_PORTRAIT_HEIGHT_RATIO = 1.1; const TALL_PORTRAIT_HEIGHT_RATIO = 1.35; +// How much denser the winning row-band's edge energy has to be than the +// photo's own average row before it's trusted as a real subject signal +// rather than noise — see applyContentAwareCrop(). Calibrated against a +// dozen real portrait photos pulled live from the API (including issue +// #24's own cited example, a 500x1071 cat-dead-center photo that scored +// 1.12): every other sampled photo scored 1.19+, so 1.10 catches the hard +// case with a small margin while still requiring a real, non-trivial +// signal (a flat/textureless photo scores at or near 1.0). +const PORTRAIT_ANALYSIS_MIN_CONFIDENCE = 1.1; +const PORTRAIT_ANALYSIS_TIMEOUT_MS = 5000; +const PHOTO_SHARE_TIMEOUT_MS = 6000; +const TABBY_CWS_URL = "https://chromewebstore.google.com/detail/tabby-new-tab-for-adoptab/elfpnkoboidkgahmoggodpnmekfodcig"; +const TABBY_EDGE_URL = "https://microsoftedge.microsoft.com/addons/detail/fieeoalehgckgnkohkdblljmgaemaiho"; +const TABBY_TAGLINE = "Meet an adoptable cat every time you open a new tab."; + +// Chromium-based Edge identifies itself with "Edg/" in its user agent (not +// "Edge/", which was the older, pre-Chromium EdgeHTML browser) -- checked +// ahead of the generic case since Edge's UA also contains "Chrome/". An +// Edge user sharing a cat should link to the Edge Add-ons listing, since +// Edge blocks one-click installs from the Chrome Web Store by default. +function tabbyStoreUrl() { + return navigator.userAgent.includes("Edg/") ? TABBY_EDGE_URL : TABBY_CWS_URL; +} let inFlight = null; const $ = (id) => document.getElementById(id); +const ZIP_SETTINGS_LINK = { text: "zip code", action: "open-settings" }; +const NO_RESULTS_LINKS = [ + { ...ZIP_SETTINGS_LINK, token: "zip" }, + { text: "explore another city", action: "start-explore", token: "explore" } +]; // Well-known US metro coordinates, chosen for broad RescueGroups coverage. // Not individually spot-checked against the live API — a location with no @@ -67,7 +95,28 @@ function showExploreBanner(label) { function hideExploreBanner() { $("explore-banner").hidden = true; } -function showNotice(message, { linkText = null, linkAction = null, type = "info" } = {}) { +function buildNoticeLinkButton(link) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "notice-link"; + button.dataset.action = link.action; + button.textContent = link.text; + button.addEventListener("click", (event) => { + event.preventDefault(); + if (link.action === "open-settings") openSettings(); + else if (link.action === "report-issue") window.open("https://github.com/BrandonML/tabby/issues", "_blank", "noopener,noreferrer"); + else if (link.action === "start-explore") startExplore(); + }); + return button; +} + +// `links` is `[{ text, action, token? }]`. When a link has a `token` and the +// message contains a matching `{token}`, the button is spliced in at that +// exact spot (needed for a message with more than one link, e.g. "...try a +// different {zip} or {explore}."). Otherwise every link is appended after +// the message in order -- the common single-link case, unchanged from +// before this supported multiple links. +function showNotice(message, { links = [], type = "info" } = {}) { const notice = $("notice"); if (!notice) return; @@ -86,25 +135,37 @@ function showNotice(message, { linkText = null, linkAction = null, type = "info" notice.appendChild(icon); } - if (linkText && linkAction) { - notice.appendChild(document.createTextNode(`${message} `)); - - const button = document.createElement("button"); - button.type = "button"; - button.className = "notice-link"; - button.dataset.action = linkAction; - button.textContent = linkText; - - button.addEventListener("click", (event) => { - event.preventDefault(); - if (linkAction === "open-settings") openSettings(); + // The message and any links live in one wrapping element so they flow as + // normal text -- a link mid-sentence, wrapping with the words around it -- + // instead of `.notice`'s flex layout treating each one as its own row item + // with a fixed gap, which is what made a trailing link look like a + // detached chip rather than part of the sentence (GitHub issue #29). + const body = document.createElement("span"); + body.className = "notice-body"; + + const hasMatchingToken = links.some((link) => link.token && message.includes(`{${link.token}}`)); + if (links.length > 0 && hasMatchingToken) { + const tokenPattern = /\{(\w+)\}/g; + let lastIndex = 0; + let match; + while ((match = tokenPattern.exec(message))) { + if (match.index > lastIndex) body.appendChild(document.createTextNode(message.slice(lastIndex, match.index))); + const link = links.find((l) => l.token === match[1]); + body.appendChild(link ? buildNoticeLinkButton(link) : document.createTextNode(match[0])); + lastIndex = tokenPattern.lastIndex; + } + if (lastIndex < message.length) body.appendChild(document.createTextNode(message.slice(lastIndex))); + } else if (links.length > 0) { + body.appendChild(document.createTextNode(`${message} `)); + links.forEach((link, i) => { + body.appendChild(buildNoticeLinkButton(link)); + if (i < links.length - 1) body.appendChild(document.createTextNode(" ")); }); - - notice.appendChild(button); - return; + } else { + body.appendChild(document.createTextNode(message)); } - notice.appendChild(document.createTextNode(message)); + notice.appendChild(body); } function readingFormat(value) { if (!value) return ""; @@ -116,6 +177,93 @@ function readingFormat(value) { return new Intl.DateTimeFormat("en-US", { month: "2-digit", day: "2-digit", year: "numeric" }).format(updatedAt); } +// Content-aware crop for portrait photos (GitHub issue #24): the fixed +// object-position: top anchor (see photo-portrait/-mild in newtab.css) +// assumes the subject is near the top of the frame, which is often true +// but not always — a centered or lower subject gets cropped out entirely. +// This computes a per-photo vertical anchor instead, from a row-wise +// edge/contrast-energy profile (a crude proxy for "where's the subject": +// fur, faces, and toys carry more local contrast than a plain floor or +// wall). Requires reading pixel data, which RescueGroups' CDN images can't +// give us directly — they send no CORS headers, so a canvas drawn from one +// via is tainted and getImageData() throws unconditionally. Fetching +// a small analysis-only thumbnail through our own backend (which does send +// CORS headers) sidesteps that. Any failure — network, decode, a low- +// confidence profile — leaves the CSS default (object-position: top) +// untouched, so the worst case is exactly today's behavior. +async function applyContentAwareCrop(img, imageUrl) { + try { + const backendUrl = BACKEND_URL.replace(/\/$/, ""); + const response = await fetch(`${backendUrl}/api/photo-thumb?url=${encodeURIComponent(imageUrl)}`, { + signal: AbortSignal.timeout(PORTRAIT_ANALYSIS_TIMEOUT_MS) + }); + if (!response.ok) return; + + const bitmap = await createImageBitmap(await response.blob()); + const { width, height } = bitmap; + if (width < 4 || height < 4) return; + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(bitmap, 0, 0); + const { data } = ctx.getImageData(0, 0, width, height); + + const gray = new Float32Array(width * height); + for (let i = 0; i < gray.length; i++) { + const o = i * 4; + gray[i] = 0.299 * data[o] + 0.587 * data[o + 1] + 0.114 * data[o + 2]; + } + + // Per-row gradient-magnitude sum — a crude Sobel-style edge/contrast + // energy profile. Rows 0 and height-1 are left at 0 (no neighbor on one + // side); negligible for a >=4px-tall image. + const rowEnergy = new Float64Array(height); + for (let y = 1; y < height - 1; y++) { + let energy = 0; + for (let x = 1; x < width - 1; x++) { + const idx = y * width + x; + energy += Math.abs(gray[idx + 1] - gray[idx - 1]) + Math.abs(gray[idx + width] - gray[idx - width]); + } + rowEnergy[y] = energy; + } + + // Slide a band roughly a third of the photo's height (a plausible + // subject-fill assumption) down the energy profile and keep the + // highest-energy position — smooths out single-row noise spikes into a + // contiguous "subject band" instead of chasing one pixel-thin peak. + const bandHeight = Math.max(1, Math.round(height * 0.35)); + let bandSum = 0; + for (let y = 0; y < bandHeight; y++) bandSum += rowEnergy[y]; + let bestSum = bandSum; + let bestStart = 0; + for (let y = 1; y <= height - bandHeight; y++) { + bandSum += rowEnergy[y + bandHeight - 1] - rowEnergy[y - 1]; + if (bandSum > bestSum) { + bestSum = bandSum; + bestStart = y; + } + } + + // Confidence check: is the winning band meaningfully denser than the + // photo's own average row, or is this just a flat/noisy image with no + // real localized signal to act on? Below the threshold, do nothing — + // acting on a weak signal risks being *worse* than the current fixed + // top anchor, which the "no worse than today" bar doesn't allow. + let totalEnergy = 0; + for (let y = 0; y < height; y++) totalEnergy += rowEnergy[y]; + const meanRowEnergy = totalEnergy / height; + const bandMeanEnergy = bestSum / bandHeight; + if (meanRowEnergy <= 0 || bandMeanEnergy / meanRowEnergy < PORTRAIT_ANALYSIS_MIN_CONFIDENCE) return; + + const centerPercent = Math.min(100, Math.max(0, ((bestStart + bandHeight / 2) / height) * 100)); + img.style.objectPosition = `50% ${centerPercent.toFixed(1)}%`; + } catch { + // Network failure, decode failure, timeout — leave the CSS default. + } +} + function getSeenIds(feedCache) { return Array.isArray(feedCache?.seenIds) ? feedCache.seenIds : []; } @@ -167,11 +315,13 @@ function renderCard(card, { stale = false, exploreLabel = null, locationLabel = // subject's face/torso in frame, at the cost of some legs/tail. See the // MILD/TALL_PORTRAIT_HEIGHT_RATIO comment above for why this is two // graduated tiers rather than one. + const isPortrait = img.naturalHeight > img.naturalWidth * MILD_PORTRAIT_HEIGHT_RATIO; if (img.naturalHeight > img.naturalWidth * TALL_PORTRAIT_HEIGHT_RATIO) { img.classList.add("photo-portrait"); - } else if (img.naturalHeight > img.naturalWidth * MILD_PORTRAIT_HEIGHT_RATIO) { + } else if (isPortrait) { img.classList.add("photo-portrait-mild"); } + if (isPortrait) applyContentAwareCrop(img, card.imageUrl); }); cardContainer.appendChild(img); @@ -241,14 +391,31 @@ function renderCard(card, { stale = false, exploreLabel = null, locationLabel = } content.appendChild(rescueP); - if (profileUrl) { - const profileA = document.createElement("a"); - profileA.className = "profile"; - profileA.href = profileUrl; - profileA.target = "_blank"; - profileA.rel = "noreferrer"; - profileA.textContent = "View profile"; - content.appendChild(profileA); + const shareUrl = profileUrl || rescueUrl; + if (profileUrl || shareUrl) { + const actions = document.createElement("div"); + actions.className = "card-actions"; + + if (profileUrl) { + const profileA = document.createElement("a"); + profileA.className = "profile"; + profileA.href = profileUrl; + profileA.target = "_blank"; + profileA.rel = "noreferrer"; + profileA.textContent = "View profile"; + actions.appendChild(profileA); + } + + if (shareUrl && typeof navigator.share === "function") { + const shareButton = document.createElement("button"); + shareButton.type = "button"; + shareButton.className = "share"; + shareButton.textContent = `Share ${card.name}`; + shareButton.addEventListener("click", () => shareCard(card, shareUrl)); + actions.appendChild(shareButton); + } + + content.appendChild(actions); } cardContainer.appendChild(content); @@ -256,6 +423,65 @@ function renderCard(card, { stale = false, exploreLabel = null, locationLabel = showNotice(stale ? "Showing a recent saved match while we refresh." : ""); } +function buildShareText(card) { + const meta = [card.breed, card.age, card.sex].filter(Boolean).join(", "); + const intro = meta ? `${card.name} (${meta}) is looking for a home at ${card.rescueName}.` : `${card.name} is looking for a home at ${card.rescueName}.`; + return `${intro}\n\n${TABBY_TAGLINE} Get Tabby: ${tabbyStoreUrl()}`; +} + +const IMAGE_CONTENT_TYPE_EXTENSIONS = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/gif": "gif" }; + +// RescueGroups' CDN has no CORS headers (see applyContentAwareCrop's comment +// above), so a client-side fetch of the photo itself would be opaque/blocked +// the same way a canvas read would be -- routing through our own +// /api/photo-share proxy (CORS-safe, hostname-locked, forced to a +// share-appropriate size server-side) is what makes a real File object +// obtainable here at all. +async function fetchSharePhoto(imageUrl) { + const backendUrl = BACKEND_URL.replace(/\/$/, ""); + const response = await fetch(`${backendUrl}/api/photo-share?url=${encodeURIComponent(imageUrl)}`, { signal: AbortSignal.timeout(PHOTO_SHARE_TIMEOUT_MS) }); + if (!response.ok) throw new Error("Could not fetch photo for sharing."); + const blob = await response.blob(); + const extension = IMAGE_CONTENT_TYPE_EXTENSIONS[blob.type] || "jpg"; + return new File([blob], `cat.${extension}`, { type: blob.type || "image/jpeg" }); +} + +async function copyShareTextFallback(text, url) { + try { + await navigator.clipboard.writeText(`${text}\n${url}`); + showNotice("Copied to clipboard."); + } catch (error) { + console.error("[tabby]", error); + showNotice("Unable to share right now.", { type: "error" }); + } +} + +// Tries to attach the actual photo (issue #27 calls this the most important +// part of the share), then degrades in two steps if that's not possible: +// first to a link-only native share, then -- if navigator.share itself +// fails or was never available -- to copying the details to the clipboard. +async function shareCard(card, shareUrl) { + const text = buildShareText(card); + const shareData = { title: `Meet ${card.name}`, text, url: shareUrl }; + + try { + const photoFile = await fetchSharePhoto(card.imageUrl); + if (navigator.canShare?.({ files: [photoFile] })) { + shareData.files = [photoFile]; + } + } catch (error) { + console.error("[tabby]", error); // Photo unavailable -- share the link and text without it. + } + + try { + await navigator.share(shareData); + } catch (error) { + if (error?.name === "AbortError") return; // The user closed the share sheet -- not a failure. + console.error("[tabby]", error); + await copyShareTextFallback(text, shareUrl); + } +} + async function resolveLocation(settings, promptForLocation) { const savedLocation = settings?.location; if (savedLocation && Number.isFinite(savedLocation.lat) && Number.isFinite(savedLocation.lon)) { @@ -300,7 +526,8 @@ async function refresh(location, locationLabel) { // Only the *seen* cards are dropped on a refresh — whatever the user // hasn't looked at yet survives and is topped up with new cards below, // rather than being discarded wholesale. - const keptUnseenCards = isRepeatLocation ? feedCache.cards.filter((card) => !priorSeenIds.includes(card.id)) : []; + const priorSeenIdSet = new Set(priorSeenIds); + const keptUnseenCards = isRepeatLocation ? feedCache.cards.filter((card) => !priorSeenIdSet.has(card.id)) : []; let page = isRepeatLocation ? (feedCache.page || 1) + 1 : 1; let feed = await fetchCatsPage(location, page); let mergedCards = mergeCards(keptUnseenCards, feed.cards || [], priorSeenIds); @@ -325,7 +552,7 @@ async function refresh(location, locationLabel) { if (!mergedCards.length) { setCardVisible(false); $("location-panel").hidden = true; - showNotice(`No available cats were found within ${nextCache.radiusMiles} miles. Try using a different zip code instead.`, { linkText: "zip code", linkAction: "open-settings", type: "error" }); + showNotice(`No available cats were found within ${nextCache.radiusMiles} miles. Try using a different {zip} or {explore}.`, { links: NO_RESULTS_LINKS, type: "error" }); return; } // Every card in mergedCards is guaranteed unseen (seen ones were dropped, @@ -355,13 +582,13 @@ async function _start({ requestLocation = false } = {}) { await storageSet({ feedCache: { ...feedCache, seenIds: nextSeenIds } }); renderCard(selected, { stale: shouldRefresh, locationLabel }); } else if (feedCache && !feedCache.cards?.length) { - showNotice(`No available cats were found within ${feedCache.radiusMiles || 0} miles. Try using a different zip code instead.`, { linkText: "zip code", linkAction: "open-settings", type: "error" }); + showNotice(`No available cats were found within ${feedCache.radiusMiles || 0} miles. Try using a different {zip} or {explore}.`, { links: NO_RESULTS_LINKS, type: "error" }); } const location = await resolveLocation(resolvedSettings, requestLocation); if (!location) { $("location-panel").hidden = false; if (requestLocation) { - showNotice("Unable to determine your location. Try entering a zip code instead.", { linkText: "zip code", linkAction: "open-settings", type: "error" }); + showNotice("Unable to determine your location. Try entering a {zip} instead.", { links: [{ ...ZIP_SETTINGS_LINK, token: "zip" }], type: "error" }); } return; } @@ -370,7 +597,13 @@ async function _start({ requestLocation = false } = {}) { try { await refresh(location, locationLabel); } catch (error) { console.error("[tabby]", error); const finalMessage = classifyRefreshError(error.message); - showNotice(finalMessage, { linkText: "zip code", linkAction: "open-settings", type: "error" }); + // A ZIP-validation failure already carries enough detail to know it's + // a user issue, so it keeps the settings shortcut instead — the + // report-issue link is for failures that might actually be our bug. + const noticeOptions = isInvalidZipError(error.message) + ? { links: [ZIP_SETTINGS_LINK], type: "error" } + : { links: [{ text: "Report an issue", action: "report-issue" }], type: "error" }; + showNotice(finalMessage, noticeOptions); if (!feedCache?.cards?.length) $("location-panel").hidden = false; } } @@ -421,6 +654,11 @@ async function exploreArea() { } } +async function startExplore() { + showNotice("Exploring a new area…"); + await exploreArea(); +} + function showAnotherExploreCard() { if (!exploreBatch) return; const { selected, nextSeenIds } = nextCard(exploreBatch.cards, exploreBatch.seenIds); @@ -446,10 +684,7 @@ $("use-location").addEventListener("click", async () => { await start({ requestLocation: true }); }); $("open-settings").addEventListener("click", openSettings); -$("explore").addEventListener("click", async () => { - showNotice("Exploring a new area…"); - await exploreArea(); -}); +$("explore").addEventListener("click", startExplore); $("show-another-explore-cat").addEventListener("click", () => showAnotherExploreCard()); $("back-to-my-area").addEventListener("click", async () => { exploreBatch = null; diff --git a/extension/options.js b/extension/options.js index b7948ab..dfe1be5 100644 --- a/extension/options.js +++ b/extension/options.js @@ -2,11 +2,12 @@ import { classifyRefreshError } from "./error-messages.js"; import { BACKEND_URL } from "./config.js"; import { locationFromBrowser } from "./location.js"; -const form = document.getElementById("settings-form"); -const zip = document.getElementById("zip"); -const useLocation = document.getElementById("use-location"); -const saved = document.getElementById("saved"); -const closeSettings = document.getElementById("close-settings"); +const $ = (id) => document.getElementById(id); +const form = $("settings-form"); +const zip = $("zip"); +const useLocation = $("use-location"); +const saved = $("saved"); +const closeSettings = $("close-settings"); // Same reasoning as newtab.js's showNotice(): #saved reserves visible box // space (min-height + flex, via the shared .notice class) even with no diff --git a/manifest.json b/manifest.json index 20f38e6..f076cf2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Tabby: New Tab for Adoptable Cats", - "version": "1.1.0", + "version": "2.0.0", "description": "See one real, nearby adoptable cat on every new tab.", "permissions": [ "storage", diff --git a/scripts/zip.js b/scripts/zip.js index 44eccab..4136161 100644 --- a/scripts/zip.js +++ b/scripts/zip.js @@ -14,7 +14,7 @@ function buildCrcTable() { const CRC_TABLE = buildCrcTable(); -function crc32(buffer) { +export function crc32(buffer) { let crc = 0xffffffff; for (let i = 0; i < buffer.length; i++) { crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8); @@ -22,7 +22,7 @@ function crc32(buffer) { return (crc ^ 0xffffffff) >>> 0; } -function dosDateTime(date) { +export function dosDateTime(date) { const dosTime = ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | diff --git a/server/index.js b/server/index.js index c483178..723f111 100644 --- a/server/index.js +++ b/server/index.js @@ -21,6 +21,59 @@ export const cache = new Map(); const CACHE_MS = 3 * 60 * 1000; const MAX_CACHE_SIZE = 500; +// Per-IP rate limiting on /api/nearby-cats: the response cache above only +// helps *repeat* requests for the same location/page, so a script varying +// postal codes or coordinates would otherwise cost a real RescueGroups call +// per request, unbounded. A simple in-memory fixed-window counter is +// architecturally consistent with the cache right above it -- both assume +// the single-persistent-process deployment target documented in the +// README, not a distributed/serverless one. +export const rateLimits = new Map(); +const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000; +const RATE_LIMIT_MAX = 30; +// Bounds memory the same way MAX_CACHE_SIZE bounds the response cache -- +// independent of whether the IP key is trustworthy (see clientIp() below), +// so a flood of distinct/spoofed keys can't grow this unboundedly either. +const MAX_RATE_LIMIT_ENTRIES = 1000; + +// Northflank (and most platforms-as-a-reverse-proxy) terminates the real +// client connection and forwards to this container, so request.socket +// .remoteAddress would otherwise be the platform's internal proxy address +// for every request -- collapsing every real visitor into one shared +// bucket. Falls back to the socket address for local dev/tests, where +// there's no proxy in front to set the header. +function clientIp(request) { + const forwarded = request.headers["x-forwarded-for"]; + if (typeof forwarded === "string" && forwarded.trim()) { + return forwarded.split(",")[0].trim(); + } + return request.socket.remoteAddress || "unknown"; +} + +// Test-only: reset the module-level rate-limit state between test runs. +export function resetRateLimitsForTests() { + rateLimits.clear(); +} + +// Returns null when the request is allowed, or the number of seconds the +// client should wait before retrying. +function checkRateLimit(ip) { + const now = Date.now(); + const entry = rateLimits.get(ip); + if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) { + rateLimits.set(ip, { count: 1, windowStart: now }); + return null; + } + entry.count += 1; + if (entry.count > RATE_LIMIT_MAX) { + return Math.ceil((entry.windowStart + RATE_LIMIT_WINDOW_MS - now) / 1000); + } + if (rateLimits.size > MAX_RATE_LIMIT_ENTRIES) { + rateLimits.delete(rateLimits.keys().next().value); + } + return null; +} + // Upstream-failure alerting: a 502 here always means something unexpected // happened trying to serve a real request (RescueGroups itself failing, a // network-level error reaching it, or a genuine bug) — never routine bad @@ -78,22 +131,145 @@ function send(response, status, body, requestOrigin, extraHeaders = {}) { response.end(JSON.stringify(body)); } -async function bodyOf(request) { - request.setTimeout(5000, () => request.destroy(new Error("Request timeout"))); - const chunks = []; - let totalLength = 0; - try { - for await (const chunk of request) { +// Reads the body via events rather than `for await` so an early bail-out +// (oversized payload, timeout) never has to call request.destroy() while +// the body is still incomplete. IncomingMessage#destroy() destroys the +// underlying *socket* whenever readableEnded/complete is still false at +// that point — which is exactly the case both here and on a genuine +// timeout — so calling it there would kill the connection before the +// caller's error response ever reaches the client (confirmed empirically: +// the client saw a bare connection reset, not the intended 408/413 body). +// Listening for events and simply stopping avoids touching the socket at +// all; the caller is responsible for closing the connection afterward via +// a "Connection: close" response header instead. +function readRequestBody(request, maxBytes) { + return new Promise((resolve, reject) => { + const chunks = []; + let totalLength = 0; + function cleanup() { + request.off("data", onData); + request.off("end", onEnd); + request.off("error", onError); + } + function onData(chunk) { totalLength += chunk.length; - if (totalLength > 16384) throw new Error("Payload too large"); + if (totalLength > maxBytes) { + cleanup(); + reject(new Error("Payload too large")); + return; + } chunks.push(chunk); } - } finally { - if (request.socket) { - request.setTimeout(0); + function onEnd() { + cleanup(); + resolve(Buffer.concat(chunks)); + } + function onError(error) { + cleanup(); + reject(error); } + request.on("data", onData); + request.on("end", onEnd); + request.on("error", onError); + }); +} + +async function bodyOf(request) { + // Read live rather than cached at module load (same reasoning as + // ALERT_WEBHOOK_URL below) so tests can exercise this path with a short + // timeout instead of waiting out the real 5s default. + const timeoutMs = Number(process.env.REQUEST_BODY_TIMEOUT_MS) || 5000; + const bodyPromise = readRequestBody(request, 16384); + let timer; + try { + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Request timeout")), timeoutMs); + }); + const raw = await Promise.race([bodyPromise, timeoutPromise]); + return JSON.parse(raw.toString("utf8") || "{}"); + } finally { + clearTimeout(timer); + // If the timeout won the race, the body-reading listeners are still + // attached and will settle later (or never) — swallow that so it can't + // surface as an unhandled rejection. + bodyPromise.catch(() => {}); + } +} + +// Content-aware portrait crop (GitHub issue #24): the extension wants to +// read pixel data from a photo to find where the subject actually is, but +// RescueGroups' CDN sends no CORS headers on its images, so a client-side +// canvas drawn from one directly is tainted -- getImageData() throws, +// unconditionally, no workaround. Proxying a small analysis-only thumbnail +// through our own origin (which *does* send CORS headers) is the only way +// to make the pixels readable at all. Hostname-locked to RescueGroups' own +// CDN and forced to a small width regardless of what the caller asks for, +// so this can't become a general-purpose open proxy. +const PHOTO_THUMB_ALLOWED_HOST = "cdn.rescuegroups.org"; +const PHOTO_THUMB_WIDTH = 100; +const PHOTO_THUMB_MAX_BYTES = 200 * 1024; // generous for a ~100px-wide jpeg +// A shared photo (GitHub issue #27) is actually looked at, unlike the +// analysis-only thumbnail above -- 100px would render as a blurry postage +// stamp in a share sheet. 640px matches a typical social-preview image size; +// the byte cap is scaled up to match a jpeg at that size with headroom. +const PHOTO_SHARE_WIDTH = 640; +const PHOTO_SHARE_MAX_BYTES = 700 * 1024; + +function buildPhotoProxyUrl(rawUrl, width) { + let parsed; + try { + parsed = new URL(String(rawUrl)); + } catch { + return null; + } + if (parsed.protocol !== "https:" || parsed.hostname !== PHOTO_THUMB_ALLOWED_HOST) return null; + // Discard any caller-supplied query (including a caller-supplied width) + // before enforcing our own -- the whole point is that this can only ever + // request a fixed, known-safe image size, never whatever the caller asks + // for, so this can't become a general-purpose open proxy. + parsed.search = ""; + parsed.searchParams.set("width", String(width)); + return parsed.toString(); +} + +export function buildPhotoThumbUrl(rawUrl) { + return buildPhotoProxyUrl(rawUrl, PHOTO_THUMB_WIDTH); +} + +export function buildPhotoShareUrl(rawUrl) { + return buildPhotoProxyUrl(rawUrl, PHOTO_SHARE_WIDTH); +} + +async function sendPhotoProxy(response, requestOrigin, rawUrl, buildUrl, maxBytes) { + const upstreamUrl = buildUrl(rawUrl); + if (!upstreamUrl) return send(response, 400, { error: "Invalid photo URL." }, requestOrigin); + + try { + const upstreamResponse = await fetch(upstreamUrl, { signal: AbortSignal.timeout(5000) }); + if (!upstreamResponse.ok) return send(response, 502, { error: "Unable to fetch photo." }, requestOrigin); + + const contentType = upstreamResponse.headers.get("content-type") || ""; + if (!contentType.startsWith("image/")) return send(response, 502, { error: "Unexpected upstream content." }, requestOrigin); + + // RescueGroups' CDN is hostname-locked and trusted elsewhere in this + // file the same way -- buffering fully before the size check (rather + // than streaming with a running byte-counter) matches that existing + // trust level instead of adding a second, inconsistent defense here. + const buffer = Buffer.from(await upstreamResponse.arrayBuffer()); + if (buffer.length > maxBytes) return send(response, 502, { error: "Photo too large." }, requestOrigin); + + response.writeHead(200, { + "Content-Type": contentType, + "Content-Length": buffer.length, + "Cache-Control": "public, max-age=86400", + "Access-Control-Allow-Origin": resolveAllowOrigin(requestOrigin), + "Vary": "Origin" + }); + response.end(buffer); + } catch (error) { + console.error("[tabby-server] photo proxy failed", { message: error.message }); + return send(response, 502, { error: "Unable to fetch photo." }, requestOrigin); } - return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); } function cacheKey(location, page) { @@ -110,10 +286,33 @@ export const server = createServer(async (request, response) => { if (request.method === "OPTIONS") return send(response, 204, {}, origin); if (request.url === "/healthz") { if (request.method !== "GET") return send(response, 405, { error: "Method Not Allowed" }, origin, { "Allow": "GET" }); - return send(response, 200, { status: "ok" }, origin); + // NF_DEPLOYMENT_SHA is auto-injected by Northflank at runtime (the git + // commit hash of the running build) -- exposing it here lets automated + // post-deploy checks confirm the *new* code is actually live, rather + // than just that some process is answering on the port. null locally, + // where the env var is never set. + return send(response, 200, { status: "ok", sha: process.env.NF_DEPLOYMENT_SHA || null }, origin); + } + if (request.url.startsWith("/api/photo-thumb")) { + if (request.method !== "GET") return send(response, 405, { error: "Method Not Allowed" }, origin, { "Allow": "GET" }); + const requestUrl = new URL(request.url, "http://internal"); + return sendPhotoProxy(response, origin, requestUrl.searchParams.get("url") || "", buildPhotoThumbUrl, PHOTO_THUMB_MAX_BYTES); + } + if (request.url.startsWith("/api/photo-share")) { + if (request.method !== "GET") return send(response, 405, { error: "Method Not Allowed" }, origin, { "Allow": "GET" }); + const requestUrl = new URL(request.url, "http://internal"); + return sendPhotoProxy(response, origin, requestUrl.searchParams.get("url") || "", buildPhotoShareUrl, PHOTO_SHARE_MAX_BYTES); } if (request.url !== "/api/nearby-cats") return send(response, 404, { error: "Not found" }, origin); if (request.method !== "POST") return send(response, 405, { error: "Method Not Allowed" }, origin, { "Allow": "POST" }); + + const retryAfterSeconds = checkRateLimit(clientIp(request)); + if (retryAfterSeconds !== null) { + // The body is never read on this path, so (as with the 408/413 cases + // below) the connection can't safely be reused for a next request. + return send(response, 429, { error: "Too many requests. Please try again later." }, origin, { "Retry-After": String(retryAfterSeconds), "Connection": "close" }); + } + try { const { location, page } = await bodyOf(request); const safeLocation = validateLocation(location); @@ -151,7 +350,15 @@ export const server = createServer(async (request, response) => { if (status === 502) recordUpstreamFailureAndMaybeAlert(error.message); - return send(response, status, { error: status < 500 ? error.message : "Unable to refresh nearby cats right now." }, origin); + // A 408 or 413 means the body was abandoned mid-read (timed out, or cut + // off past the size cap) — bytes the client already sent (or is still + // sending) are never fully consumed, so the connection can't safely be + // reused for a next request on the same socket. "Connection: close" + // tells Node to close it once this response finishes, instead of + // leaving it keep-alive. + const extraHeaders = status === 408 || status === 413 ? { "Connection": "close" } : {}; + + return send(response, status, { error: status < 500 ? error.message : "Unable to refresh nearby cats right now." }, origin, extraHeaders); } }); diff --git a/server/rescuegroups.js b/server/rescuegroups.js index 1a3ac6f..25869ba 100644 --- a/server/rescuegroups.js +++ b/server/rescuegroups.js @@ -98,22 +98,25 @@ function relationshipResources(relationship, type, index) { return ids.map((item) => index.get(`${type}:${item.id}`)).filter(Boolean); } +// Pictures are pre-sorted by `order`; returns the first one whose large/ +// original URL actually normalizes to something usable, or null if none do. +function getBestPicture(pictures) { + for (const item of pictures.map((p) => p.attributes || {})) { + const imageUrl = normalizeUrl(item.large?.url || item.original?.url); + if (imageUrl) return { picture: item, imageUrl }; + } + return null; +} + export function normalizeCards(payload) { const index = includedIndex(payload.included); return (payload.data || []).map((animal) => { const relationships = animal.relationships || {}; const pictures = relationshipResources(relationships.pictures, "pictures", index) .sort((a, b) => (a.attributes?.order ?? Number.MAX_SAFE_INTEGER) - (b.attributes?.order ?? Number.MAX_SAFE_INTEGER)); - let picture, imageUrl; - for (const item of pictures.map((p) => p.attributes || {})) { - const rawImg = item.large?.url || item.original?.url; - imageUrl = normalizeUrl(rawImg); - if (imageUrl) { - picture = item; - break; - } - } - if (!picture) return null; + const best = getBestPicture(pictures); + if (!best) return null; + const { picture, imageUrl } = best; const org = relationshipResources(relationships.orgs, "orgs", index)[0]; const attrs = animal.attributes || {}; const updatedAt = newestTimestamp(attrs.updatedDate, attrs.updatedAt); diff --git a/test/location.test.js b/test/location.test.js new file mode 100644 index 0000000..f90dbf7 --- /dev/null +++ b/test/location.test.js @@ -0,0 +1,45 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import { locationFromBrowser } from "../extension/location.js"; + +describe("locationFromBrowser", () => { + let originalNavigator; + + beforeEach(() => { + originalNavigator = Object.getOwnPropertyDescriptor(global, "navigator"); + }); + + afterEach(() => { + if (originalNavigator) Object.defineProperty(global, "navigator", originalNavigator); + else delete global.navigator; + }); + + function stubNavigator(geolocation) { + // Node's own `navigator` global is a non-configurable-looking getter in + // recent versions — a plain assignment throws. defineProperty replaces + // it outright for the duration of the test. + Object.defineProperty(global, "navigator", { value: { geolocation }, configurable: true }); + } + + it("resolves to { lat, lon } from a successful geolocation result", async () => { + stubNavigator({ + getCurrentPosition: (success) => { + success({ coords: { latitude: 40.7128, longitude: -74.006 } }); + } + }); + + const location = await locationFromBrowser(); + assert.deepStrictEqual(location, { lat: 40.7128, lon: -74.006 }); + }); + + it("propagates the rejection when geolocation fails", async () => { + const geoError = new Error("User denied Geolocation"); + stubNavigator({ + getCurrentPosition: (_success, error) => { + error(geoError); + } + }); + + await assert.rejects(() => locationFromBrowser(), (err) => err === geoError); + }); +}); diff --git a/test/newtab.test.js b/test/newtab.test.js index cf8dd8d..84605e6 100644 --- a/test/newtab.test.js +++ b/test/newtab.test.js @@ -6,13 +6,13 @@ import path from 'node:path'; const htmlContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'newtab.html'), 'utf-8'); const jsContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'newtab.js'), 'utf-8'); -const errorMsgsContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'error-messages.js'), 'utf-8').replace('export function', 'function'); +const errorMsgsContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'error-messages.js'), 'utf-8').replace(/export function/g, 'function'); const configContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'config.js'), 'utf-8').replace('export const', 'const'); const locationContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'location.js'), 'utf-8').replace('export async function', 'async function'); function inlineScript(source) { return source - .replace(/import \{ classifyRefreshError \} from "\.\/error-messages\.js";/, errorMsgsContent) + .replace(/import \{ classifyRefreshError, isInvalidZipError \} from "\.\/error-messages\.js";/, errorMsgsContent) .replace(/import \{ BACKEND_URL \} from "\.\/config\.js";/, configContent) .replace(/import \{ locationFromBrowser \} from "\.\/location\.js";/, locationContent); } @@ -53,32 +53,59 @@ describe('newtab.js DOM manipulation', () => { it('showNotice creates safe DOM elements without innerHTML', () => { // Call showNotice on the window context - window.showNotice("Hello World", { linkText: "Click Me", linkAction: "open-settings" }); + window.showNotice("Hello World", { links: [{ text: "Click Me", action: "open-settings" }] }); const notice = document.getElementById("notice"); - assert.equal(notice.childNodes.length, 2); + assert.equal(notice.childNodes.length, 1); + const body = notice.childNodes[0]; + assert.equal(body.className, "notice-body"); + assert.equal(body.childNodes.length, 2); - const textNode = notice.childNodes[0]; + const textNode = body.childNodes[0]; assert.equal(textNode.nodeType, 3); // TEXT_NODE assert.equal(textNode.textContent, "Hello World "); - const button = notice.childNodes[1]; + const button = body.childNodes[1]; assert.equal(button.tagName, "BUTTON"); assert.equal(button.textContent, "Click Me"); assert.equal(button.className, "notice-link"); assert.equal(button.dataset.action, "open-settings"); // Attempt an XSS - window.showNotice("", { linkText: "", linkAction: '">XSS' }); - assert.equal(notice.childNodes[0].textContent, " "); - assert.equal(notice.childNodes[1].textContent, ""); - assert.equal(notice.childNodes[1].dataset.action, '\">XSS'); + window.showNotice("", { links: [{ text: "", action: '">XSS' }] }); + const body2 = notice.childNodes[0]; + assert.equal(body2.childNodes[0].textContent, " "); + assert.equal(body2.childNodes[1].textContent, ""); + assert.equal(body2.childNodes[1].dataset.action, '\">XSS'); // Ensure no HTML elements were created by accident assert.equal(notice.querySelector('img'), null); assert.equal(notice.querySelector('script'), null); }); + it('showNotice splices multiple links inline via {token} placeholders', () => { + window.showNotice("Try a different {zip} or {explore}.", { + links: [ + { token: "zip", text: "zip code", action: "open-settings" }, + { token: "explore", text: "explore another city", action: "start-explore" } + ], + type: "error" + }); + + const notice = document.getElementById("notice"); + const body = notice.querySelector('.notice-body'); + const links = body.querySelectorAll('.notice-link'); + assert.equal(links.length, 2); + assert.equal(links[0].textContent, "zip code"); + assert.equal(links[0].dataset.action, "open-settings"); + assert.equal(links[1].textContent, "explore another city"); + assert.equal(links[1].dataset.action, "start-explore"); + // The full sentence -- including the surrounding words -- must survive + // as one continuous run of text with the links spliced in place, not + // just the two link labels floating with the rest of the text dropped. + assert.equal(body.textContent, "Try a different zip code or explore another city."); + }); + it('renderCard creates safe DOM elements without innerHTML', () => { const cardData = { name: "", @@ -136,6 +163,140 @@ describe('newtab.js DOM manipulation', () => { assert.equal(h1.classList.contains('name-long'), false); }); }); + describe('Share this cat (GitHub issue #27)', () => { + const shareCardData = { + name: "Luna", + breed: "Tabby", + age: "Adult", + sex: "Female", + rescueName: "Happy Paws Rescue", + rescueUrl: "https://rescue.org", + profileUrl: "https://rescuegroups.org/animals/luna", + imageUrl: "https://cdn.rescuegroups.org/pic.jpg" + }; + + it('does not render a share button when the platform has no Web Share API', () => { + delete window.navigator.share; + window.renderCard(shareCardData); + assert.equal(document.querySelector('#card .share'), null); + }); + + it('renders a "Share {name}" button when navigator.share is available', () => { + window.navigator.share = async () => {}; + window.renderCard(shareCardData); + const shareButton = document.querySelector('#card .share'); + assert.ok(shareButton); + assert.equal(shareButton.textContent, 'Share Luna'); + assert.equal(shareButton.tagName, 'BUTTON'); + }); + + it('shares the photo, text and profile link together when the platform supports file attachments', async () => { + window.navigator.canShare = () => true; + let sharedData; + window.navigator.share = async (data) => { sharedData = data; }; + window.fetch = async (url) => { + assert.ok(url.includes('/api/photo-share?url='), 'must fetch through the CORS-safe photo-share proxy, not the CDN directly'); + return { ok: true, blob: async () => new window.Blob(["fake-photo-bytes"], { type: "image/jpeg" }) }; + }; + + window.renderCard(shareCardData); + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(sharedData, 'navigator.share should have been called'); + assert.equal(sharedData.title, 'Meet Luna'); + assert.equal(sharedData.url, 'https://rescuegroups.org/animals/luna'); + assert.ok(sharedData.text.includes('Luna')); + assert.ok(sharedData.text.includes('Happy Paws Rescue')); + assert.ok(sharedData.text.includes('Meet an adoptable cat every time you open a new tab.'), 'must include the Tabby tagline'); + assert.ok(sharedData.text.includes('chromewebstore.google.com'), 'must promote the Tabby listing'); + assert.equal(sharedData.files.length, 1); + assert.ok(sharedData.files[0] instanceof window.File); + assert.equal(sharedData.files[0].type, 'image/jpeg'); + }); + + it('promotes the Edge Add-ons listing instead of the Chrome Web Store when running in Edge', async () => { + Object.defineProperty(window.navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0', + configurable: true + }); + window.navigator.canShare = () => true; + let sharedData; + window.navigator.share = async (data) => { sharedData = data; }; + window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); + + window.renderCard(shareCardData); + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(sharedData); + assert.ok(sharedData.text.includes('microsoftedge.microsoft.com/addons'), 'Edge users should get the Edge Add-ons link, not the CWS one'); + assert.ok(!sharedData.text.includes('chromewebstore.google.com'), 'must not also include the Chrome Web Store link'); + }); + + it('shares without a photo file when canShare rejects file attachments', async () => { + window.navigator.canShare = () => false; + let sharedData; + window.navigator.share = async (data) => { sharedData = data; }; + window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); + + window.renderCard(shareCardData); + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(sharedData); + assert.equal(sharedData.files, undefined); + assert.equal(sharedData.url, 'https://rescuegroups.org/animals/luna'); + }); + + it('shares without a photo file when the photo-share proxy fetch fails', async () => { + let sharedData; + window.navigator.canShare = () => true; + window.navigator.share = async (data) => { sharedData = data; }; + window.fetch = async () => ({ ok: false }); + window.console.error = () => {}; + + window.renderCard(shareCardData); + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(sharedData, 'a failed photo fetch should not block sharing the link and text'); + assert.equal(sharedData.files, undefined); + }); + + it('treats the user cancelling the native share sheet as a no-op, not an error', async () => { + window.navigator.canShare = () => true; + window.navigator.share = async () => { const err = new Error('cancelled'); err.name = 'AbortError'; throw err; }; + window.navigator.clipboard = { writeText: async () => { throw new Error('should not be called'); } }; + window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); + const loggedErrors = []; + window.console.error = (...args) => { loggedErrors.push(args); }; + + window.renderCard(shareCardData); + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.equal(loggedErrors.length, 0, 'a user-cancelled share should not be logged as an error'); + assert.equal(document.getElementById('notice').textContent, ''); + }); + + it('falls back to copying the details to the clipboard when navigator.share fails for a real reason', async () => { + window.navigator.canShare = () => true; + window.navigator.share = async () => { throw new Error('share failed'); }; + let clipboardText; + window.navigator.clipboard = { writeText: async (text) => { clipboardText = text; } }; + window.fetch = async () => ({ ok: true, blob: async () => new window.Blob(["x"], { type: "image/jpeg" }) }); + window.console.error = () => {}; + + window.renderCard(shareCardData); + document.querySelector('#card .share').dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(clipboardText.includes('Luna')); + assert.ok(clipboardText.includes('https://rescuegroups.org/animals/luna')); + assert.ok(document.getElementById('notice').textContent.includes('Copied to clipboard')); + }); + }); describe('showNotice error vs. informational tone', () => { it('defaults to informational: no notice-error class, no warning icon', () => { window.showNotice("Finding your location…"); @@ -287,6 +448,116 @@ describe('newtab.js DOM manipulation', () => { assert.ok(img.classList.contains('photo-portrait')); assert.equal(img.classList.contains('photo-portrait-mild'), false, 'a tall portrait should not also carry the mild class'); }); + + describe('content-aware crop', () => { + const WIDTH = 20; + const HEIGHT = 40; + + // JSDOM doesn't implement a real 2D canvas context, so getContext() is + // stubbed to return a fake one whose getImageData() hands back + // synthetic pixel data with a controlled energy profile, instead of + // whatever drawImage() would have actually rendered. + function stubCanvasWithPixels(pixelFn) { + window.HTMLCanvasElement.prototype.getContext = function () { + return { + drawImage() {}, + getImageData(x, y, width, height) { + const data = new Uint8ClampedArray(width * height * 4); + for (let py = 0; py < height; py++) { + for (let px = 0; px < width; px++) { + const value = pixelFn(px, py); + const o = (py * width + px) * 4; + data[o] = data[o + 1] = data[o + 2] = value; + data[o + 3] = 255; + } + } + return { data, width, height }; + } + }; + }; + } + + function loadPortraitImage() { + window.renderCard({ name: "Milo", imageUrl: "https://cdn.rescuegroups.org/pic.jpg" }); + const img = document.querySelector('.photo'); + Object.defineProperty(img, 'naturalWidth', { value: 500, configurable: true }); + Object.defineProperty(img, 'naturalHeight', { value: 700, configurable: true }); // tall portrait + img.dispatchEvent(new window.Event('load')); + return img; + } + + it('anchors to a high-contrast band instead of the default top when the signal is strong', async () => { + window.fetch = async () => ({ ok: true, blob: async () => ({}) }); + window.createImageBitmap = async () => ({ width: WIDTH, height: HEIGHT }); + // Uniform everywhere except a strong checkerboard band at rows 10-19 + // (25%-50% of the image height) -- real subject-like local contrast + // against a flat background. + stubCanvasWithPixels((x, y) => (y >= 10 && y < 20 ? ((x + y) % 2 === 0 ? 0 : 255) : 100)); + + const img = loadPortraitImage(); + await new Promise((r) => setTimeout(r, 20)); + + assert.ok(img.style.objectPosition, 'a strong signal should set an explicit object-position'); + const percent = Number(img.style.objectPosition.split(' ')[1].replace('%', '')); + assert.ok(percent > 15 && percent < 60, `expected the anchor near the 25%-50% band, got ${percent}%`); + }); + + it('leaves the CSS default untouched when the photo has no localized signal (uniform)', async () => { + window.fetch = async () => ({ ok: true, blob: async () => ({}) }); + window.createImageBitmap = async () => ({ width: WIDTH, height: HEIGHT }); + stubCanvasWithPixels(() => 128); // perfectly flat -- zero edge energy anywhere + + const img = loadPortraitImage(); + await new Promise((r) => setTimeout(r, 20)); + + assert.equal(img.style.objectPosition, '', 'a flat photo has no basis for overriding the default top anchor'); + }); + + it('leaves the CSS default untouched when the analysis fetch fails', async () => { + window.fetch = async () => ({ ok: false }); + window.createImageBitmap = async () => { throw new Error('should not be called'); }; + + const img = loadPortraitImage(); + await new Promise((r) => setTimeout(r, 20)); + + assert.equal(img.style.objectPosition, ''); + }); + + it('leaves the CSS default untouched when the analysis fetch throws (network error)', async () => { + window.fetch = async () => { throw new Error('network down'); }; + + const img = loadPortraitImage(); + await new Promise((r) => setTimeout(r, 20)); + + assert.equal(img.style.objectPosition, ''); + }); + + it('requests the analysis thumbnail through the backend proxy, not RescueGroups directly', async () => { + let requestedUrl; + window.fetch = async (url) => { requestedUrl = url; return { ok: true, blob: async () => ({}) }; }; + window.createImageBitmap = async () => ({ width: WIDTH, height: HEIGHT }); + stubCanvasWithPixels(() => 128); + + loadPortraitImage(); + await new Promise((r) => setTimeout(r, 20)); + + assert.equal(requestedUrl, 'http://localhost:8787/api/photo-thumb?url=https%3A%2F%2Fcdn.rescuegroups.org%2Fpic.jpg'); + }); + + it('does not run at all for a landscape or square photo', async () => { + let fetchCalled = false; + window.fetch = async () => { fetchCalled = true; return { ok: true, blob: async () => ({}) }; }; + + window.renderCard({ name: "Milo", imageUrl: "https://cdn.rescuegroups.org/pic.jpg" }); + const img = document.querySelector('.photo'); + Object.defineProperty(img, 'naturalWidth', { value: 800, configurable: true }); + Object.defineProperty(img, 'naturalHeight', { value: 600, configurable: true }); + img.dispatchEvent(new window.Event('load')); + await new Promise((r) => setTimeout(r, 20)); + + assert.equal(fetchCalled, false); + }); + }); it('getSeenIds treats a null or missing feedCache as no seen ids', () => { assert.deepEqual(window.getSeenIds(null), []); assert.deepEqual(window.getSeenIds(undefined), []); @@ -438,6 +709,37 @@ describe('newtab.js DOM manipulation', () => { assert.ok(notice.classList.contains('notice-error'), "an empty-results notice needs the user to act, so it should read as an error"); }); + it('offers both a zip-code and an explore-another-city link on empty results (GitHub issue #29)', async () => { + window.fetch = async () => ({ + ok: true, + json: async () => ({ cards: [], radiusMiles: 5 }) + }); + + await window.refresh({ postalcode: '12345' }); + const notice = document.getElementById("notice"); + const links = notice.querySelectorAll('.notice-link'); + assert.equal(links.length, 2); + assert.equal(links[0].textContent, 'zip code'); + assert.equal(links[0].dataset.action, 'open-settings'); + assert.equal(links[1].textContent, 'explore another city'); + assert.equal(links[1].dataset.action, 'start-explore'); + // The links must read as part of one flowing sentence, not floating + // text disconnected from the surrounding message. + assert.equal(notice.querySelector('.notice-body').textContent, 'No available cats were found within 5 miles. Try using a different zip code or explore another city.'); + + let fetchedBody; + window.fetch = async (url, opts) => { + fetchedBody = JSON.parse(opts.body); + return { ok: true, json: async () => ({ cards: [{ id: 'explore-1', name: 'ExploreCat' }], radiusMiles: 25 }) }; + }; + links[1].dispatchEvent(new window.Event('click')); + await new Promise(r => setTimeout(r, 10)); + + assert.ok(fetchedBody, 'clicking "explore another city" should trigger the same explore fetch as the header Explore button'); + assert.equal(document.getElementById('card').querySelector('h1').textContent, 'ExploreCat'); + assert.equal(document.getElementById('explore-banner').hidden, false); + }); + it('resets to page 1 when a later page comes back exhausted and empty', async () => { window.chrome.storage.local.get = async () => ({ feedCache: { cards: [{ id: '1' }], fetchedAt: Date.now(), location: { postalcode: '12345' }, page: 4, seenIds: ['1'] } @@ -902,7 +1204,7 @@ describe('newtab.js DOM manipulation', () => { assert.ok(notice.textContent.includes('Unable to determine your location')); }); - it('logs and shows a notice when refresh fails', async () => { + it('logs and shows a notice with a report-issue link when refresh fails generically', async () => { window.fetch = async () => ({ ok: false, json: async () => ({ error: "Server error" }) @@ -919,6 +1221,31 @@ describe('newtab.js DOM manipulation', () => { const notice = document.getElementById("notice"); assert.ok(notice.textContent.length > 0); + const link = notice.querySelector('.notice-link'); + assert.ok(link, 'a report-issue link should be rendered for a non-ZIP failure'); + assert.equal(link.dataset.action, 'report-issue'); + assert.equal(link.textContent, 'Report an issue'); + + let opened; + window.open = (url) => { opened = url; }; + link.dispatchEvent(new window.Event('click')); + assert.equal(opened, 'https://github.com/BrandonML/tabby/issues'); + }); + + it('shows the zip-code settings link (not report-issue) when refresh fails on an invalid ZIP', async () => { + window.fetch = async () => ({ + ok: false, + json: async () => ({ error: "Provide a five-digit postal code or valid latitude and longitude." }) + }); + window.console.error = () => {}; + + await window.start(); + + const notice = document.getElementById("notice"); + const link = notice.querySelector('.notice-link'); + assert.ok(link); + assert.equal(link.dataset.action, 'open-settings'); + assert.equal(link.textContent, 'zip code'); }); it('prevents multiple concurrent executions', async () => { diff --git a/test/options.test.js b/test/options.test.js index 36bbad2..5e18840 100644 --- a/test/options.test.js +++ b/test/options.test.js @@ -6,7 +6,7 @@ import path from 'node:path'; const htmlContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'options.html'), 'utf-8'); const jsContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'options.js'), 'utf-8'); -const errorMsgsContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'error-messages.js'), 'utf-8').replace('export function', 'function'); +const errorMsgsContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'error-messages.js'), 'utf-8').replace(/export function/g, 'function'); const configContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'config.js'), 'utf-8').replace('export const', 'const'); const locationContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'location.js'), 'utf-8').replace('export async function', 'async function'); @@ -218,7 +218,7 @@ describe('options.js settings logic', () => { assert.equal(saved.textContent, 'Saved.'); }); - it('closes settings when close button clicked', async () => { + it('closes settings when close button clicked (fallback: no active tab found)', async () => { let closeCalled = false; window.close = () => { closeCalled = true; }; const closeBtn = document.getElementById('close-settings'); @@ -228,6 +228,26 @@ describe('options.js settings logic', () => { assert.ok(closeCalled); }); + it('closes settings by replacing the active tab with newtab.html (the primary path)', async () => { + let createArgs; + let removedTabId; + window.chrome.tabs.query = (query, cb) => cb([{ id: 7 }]); + window.chrome.tabs.create = (opts, cb) => { createArgs = opts; if (cb) cb(); }; + window.chrome.tabs.remove = (id) => { removedTabId = id; }; + + let closeCalled = false; + window.close = () => { closeCalled = true; }; + + const closeBtn = document.getElementById('close-settings'); + closeBtn.dispatchEvent(new window.Event('click')); + + await new Promise(r => setTimeout(r, 10)); + + assert.deepEqual(createArgs, { url: 'chrome-extension://id/extension/newtab.html', active: true }); + assert.equal(removedTabId, 7); + assert.equal(closeCalled, false, 'the fallback window.close() must not run when an active tab was found'); + }); + it('submitting a blank ZIP is a no-op', async () => { let setCalled = false; window.chrome.storage.local.set = async () => { setCalled = true; }; diff --git a/test/photo-share.test.js b/test/photo-share.test.js new file mode 100644 index 0000000..f009d75 --- /dev/null +++ b/test/photo-share.test.js @@ -0,0 +1,116 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import http from "node:http"; +import { server, buildPhotoShareUrl } from "../server/index.js"; + +describe("buildPhotoShareUrl", () => { + it("forces a share-sized width on a valid RescueGroups CDN URL", () => { + const result = buildPhotoShareUrl("https://cdn.rescuegroups.org/1409/pictures/animals/22664/22664830/103576412.jpg"); + assert.strictEqual(result, "https://cdn.rescuegroups.org/1409/pictures/animals/22664/22664830/103576412.jpg?width=640"); + }); + + it("discards a caller-supplied width/query instead of trusting it", () => { + const result = buildPhotoShareUrl("https://cdn.rescuegroups.org/pic.jpg?width=5000&foo=bar"); + assert.strictEqual(result, "https://cdn.rescuegroups.org/pic.jpg?width=640"); + }); + + it("rejects a URL on a different host (would otherwise be an open proxy)", () => { + assert.strictEqual(buildPhotoShareUrl("https://evil.example.com/pic.jpg"), null); + }); + + it("rejects a non-https URL", () => { + assert.strictEqual(buildPhotoShareUrl("http://cdn.rescuegroups.org/pic.jpg"), null); + }); + + it("rejects a malformed URL", () => { + assert.strictEqual(buildPhotoShareUrl("not a url"), null); + assert.strictEqual(buildPhotoShareUrl(""), null); + assert.strictEqual(buildPhotoShareUrl(undefined), null); + }); +}); + +describe("GET /api/photo-share", () => { + let port; + + beforeEach(async () => { + await new Promise((resolve) => server.listen(0, resolve)); + port = server.address().port; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(resolve)); + mock.restoreAll(); + }); + + const get = (path) => { + return new Promise((resolve, reject) => { + const req = http.request({ hostname: "127.0.0.1", port, path, method: "GET" }, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => resolve({ res, body: Buffer.concat(chunks) })); + }); + req.on("error", reject); + req.end(); + }); + }; + + it("streams back the upstream image with CORS headers on a valid request", async () => { + const fakeImage = Buffer.from("fake-jpeg-bytes-but-bigger-for-sharing"); + let requestedUrl; + mock.method(global, "fetch", async (url) => { + requestedUrl = url; + return { + ok: true, + headers: new Map([["content-type", "image/jpeg"]]), + arrayBuffer: async () => fakeImage.buffer.slice(fakeImage.byteOffset, fakeImage.byteOffset + fakeImage.byteLength) + }; + }); + + const url = encodeURIComponent("https://cdn.rescuegroups.org/pic.jpg"); + const { res, body } = await get(`/api/photo-share?url=${url}`); + + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.headers["content-type"], "image/jpeg"); + assert.strictEqual(res.headers["access-control-allow-origin"], "*"); + assert.deepStrictEqual(body, fakeImage); + assert.strictEqual(requestedUrl, "https://cdn.rescuegroups.org/pic.jpg?width=640", "must request the share width, not the analysis thumbnail width"); + }); + + it("returns 400 for a disallowed host without ever calling fetch", async () => { + const fetchSpy = mock.method(global, "fetch", async () => { throw new Error("should not be called"); }); + + const url = encodeURIComponent("https://evil.example.com/pic.jpg"); + const { res, body } = await get(`/api/photo-share?url=${url}`); + + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(fetchSpy.mock.callCount(), 0); + assert.strictEqual(JSON.parse(body.toString()).error, "Invalid photo URL."); + }); + + it("returns 502 when the upstream image exceeds the share size cap", async () => { + const bigImage = Buffer.alloc(800 * 1024, 1); + mock.method(global, "fetch", async () => ({ + ok: true, + headers: new Map([["content-type", "image/jpeg"]]), + arrayBuffer: async () => bigImage.buffer.slice(bigImage.byteOffset, bigImage.byteOffset + bigImage.byteLength) + })); + + const url = encodeURIComponent("https://cdn.rescuegroups.org/pic.jpg"); + const { res } = await get(`/api/photo-share?url=${url}`); + + assert.strictEqual(res.statusCode, 502); + }); + + it("405s a non-GET request", async () => { + const { res } = await new Promise((resolve, reject) => { + const req = http.request({ hostname: "127.0.0.1", port, path: "/api/photo-share?url=x", method: "POST" }, (resp) => { + resp.resume(); + resp.on("end", () => resolve({ res: resp })); + }); + req.on("error", reject); + req.end(); + }); + assert.strictEqual(res.statusCode, 405); + assert.strictEqual(res.headers.allow, "GET"); + }); +}); diff --git a/test/photo-thumb.test.js b/test/photo-thumb.test.js new file mode 100644 index 0000000..4f4152c --- /dev/null +++ b/test/photo-thumb.test.js @@ -0,0 +1,126 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import http from "node:http"; +import { server, buildPhotoThumbUrl } from "../server/index.js"; + +describe("buildPhotoThumbUrl", () => { + it("forces a small width on a valid RescueGroups CDN URL", () => { + const result = buildPhotoThumbUrl("https://cdn.rescuegroups.org/1409/pictures/animals/22664/22664830/103576412.jpg"); + assert.strictEqual(result, "https://cdn.rescuegroups.org/1409/pictures/animals/22664/22664830/103576412.jpg?width=100"); + }); + + it("discards a caller-supplied width/query instead of trusting it", () => { + const result = buildPhotoThumbUrl("https://cdn.rescuegroups.org/pic.jpg?width=5000&foo=bar"); + assert.strictEqual(result, "https://cdn.rescuegroups.org/pic.jpg?width=100"); + }); + + it("rejects a URL on a different host (would otherwise be an open proxy)", () => { + assert.strictEqual(buildPhotoThumbUrl("https://evil.example.com/pic.jpg"), null); + }); + + it("rejects a non-https URL", () => { + assert.strictEqual(buildPhotoThumbUrl("http://cdn.rescuegroups.org/pic.jpg"), null); + }); + + it("rejects a malformed URL", () => { + assert.strictEqual(buildPhotoThumbUrl("not a url"), null); + assert.strictEqual(buildPhotoThumbUrl(""), null); + assert.strictEqual(buildPhotoThumbUrl(undefined), null); + }); + + it("rejects a lookalike host that merely contains the allowed hostname", () => { + assert.strictEqual(buildPhotoThumbUrl("https://cdn.rescuegroups.org.evil.com/pic.jpg"), null); + assert.strictEqual(buildPhotoThumbUrl("https://notcdn.rescuegroups.org.example.com/pic.jpg"), null); + }); +}); + +describe("GET /api/photo-thumb", () => { + let port; + + beforeEach(async () => { + await new Promise((resolve) => server.listen(0, resolve)); + port = server.address().port; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(resolve)); + mock.restoreAll(); + }); + + const get = (path) => { + return new Promise((resolve, reject) => { + const req = http.request({ hostname: "127.0.0.1", port, path, method: "GET" }, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => resolve({ res, body: Buffer.concat(chunks) })); + }); + req.on("error", reject); + req.end(); + }); + }; + + it("streams back the upstream image with CORS headers on a valid request", async () => { + const fakeImage = Buffer.from("fake-jpeg-bytes"); + mock.method(global, "fetch", async () => ({ + ok: true, + headers: new Map([["content-type", "image/jpeg"]]), + arrayBuffer: async () => fakeImage.buffer.slice(fakeImage.byteOffset, fakeImage.byteOffset + fakeImage.byteLength) + })); + + const url = encodeURIComponent("https://cdn.rescuegroups.org/pic.jpg"); + const { res, body } = await get(`/api/photo-thumb?url=${url}`); + + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.headers["content-type"], "image/jpeg"); + assert.strictEqual(res.headers["access-control-allow-origin"], "*"); + assert.deepStrictEqual(body, fakeImage); + }); + + it("returns 400 for a disallowed host without ever calling fetch", async () => { + const fetchSpy = mock.method(global, "fetch", async () => { throw new Error("should not be called"); }); + + const url = encodeURIComponent("https://evil.example.com/pic.jpg"); + const { res, body } = await get(`/api/photo-thumb?url=${url}`); + + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(fetchSpy.mock.callCount(), 0); + assert.strictEqual(JSON.parse(body.toString()).error, "Invalid photo URL."); + }); + + it("returns 502 when the upstream fetch fails", async () => { + mock.method(global, "fetch", async () => ({ ok: false })); + const errorSpy = mock.method(console, "error", () => {}); + + const url = encodeURIComponent("https://cdn.rescuegroups.org/pic.jpg"); + const { res } = await get(`/api/photo-thumb?url=${url}`); + + assert.strictEqual(res.statusCode, 502); + assert.strictEqual(errorSpy.mock.callCount(), 0, "an unsuccessful upstream response is handled, not thrown"); + }); + + it("returns 502 when the upstream content isn't an image", async () => { + mock.method(global, "fetch", async () => ({ + ok: true, + headers: new Map([["content-type", "text/html"]]), + arrayBuffer: async () => new ArrayBuffer(0) + })); + + const url = encodeURIComponent("https://cdn.rescuegroups.org/pic.jpg"); + const { res } = await get(`/api/photo-thumb?url=${url}`); + + assert.strictEqual(res.statusCode, 502); + }); + + it("405s a non-GET request", async () => { + const { res } = await new Promise((resolve, reject) => { + const req = http.request({ hostname: "127.0.0.1", port, path: "/api/photo-thumb?url=x", method: "POST" }, (resp) => { + resp.resume(); + resp.on("end", () => resolve({ res: resp })); + }); + req.on("error", reject); + req.end(); + }); + assert.strictEqual(res.statusCode, 405); + assert.strictEqual(res.headers.allow, "GET"); + }); +}); diff --git a/test/server.test.js b/test/server.test.js index 13871a9..7ed174e 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -1,7 +1,8 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert"; -import { cache, server, resetAlertStateForTests } from "../server/index.js"; +import { cache, server, resetAlertStateForTests, resetRateLimitsForTests } from "../server/index.js"; import http from "node:http"; +import net from "node:net"; // Need to mock fetch globally to avoid actual RescueGroups hits describe("server cache", () => { @@ -9,6 +10,7 @@ describe("server cache", () => { beforeEach(async () => { cache.clear(); + resetRateLimitsForTests(); await new Promise((resolve) => server.listen(0, resolve)); port = server.address().port; }); @@ -64,6 +66,11 @@ describe("server cache", () => { async function makeRequestsInBatches(zips) { for (let i = 0; i < zips.length; i += BATCH_SIZE) { await Promise.all(zips.slice(i, i + BATCH_SIZE).map(makeRequest)); + // This test is about cache eviction, not rate limiting -- all 500+ + // requests land from the same loopback IP, which the per-IP rate + // limiter would otherwise start rejecting well before this test's + // real target (cache-size bounding) is ever reached. + resetRateLimitsForTests(); } } @@ -101,6 +108,7 @@ describe("server routing and behavior", () => { beforeEach(async () => { cache.clear(); + resetRateLimitsForTests(); await new Promise((resolve) => server.listen(0, resolve)); port = server.address().port; }); @@ -206,6 +214,24 @@ describe("server routing and behavior", () => { assert.strictEqual(body.status, "ok"); }); + it("GET /healthz reports null sha when NF_DEPLOYMENT_SHA is unset, as in local dev", async () => { + delete process.env.NF_DEPLOYMENT_SHA; + const { data } = await request({ path: '/healthz', method: 'GET' }); + const body = JSON.parse(data); + assert.strictEqual(body.sha, null); + }); + + it("GET /healthz reports NF_DEPLOYMENT_SHA when Northflank has injected it, so a post-deploy check can confirm the new code is live", async () => { + process.env.NF_DEPLOYMENT_SHA = "abc1234"; + try { + const { data } = await request({ path: '/healthz', method: 'GET' }); + const body = JSON.parse(data); + assert.strictEqual(body.sha, "abc1234"); + } finally { + delete process.env.NF_DEPLOYMENT_SHA; + } + }); + it("POST /healthz returns 405 with an Allow header", async () => { const { res, data } = await request({ path: '/healthz', method: 'POST' }); assert.strictEqual(res.statusCode, 405); @@ -225,6 +251,57 @@ describe("server routing and behavior", () => { const body = JSON.parse(data); assert.strictEqual(body.error, "Payload too large"); assert.strictEqual(errorSpy.mock.callCount(), 1); + // The body was abandoned mid-read, so the connection isn't safe to + // reuse for a next request. + assert.strictEqual(res.headers.connection, "close"); + }); + + it("A request whose body never finishes arriving times out with a real 408, not a bare connection reset", async () => { + const errorSpy = mock.method(console, 'error', () => {}); + const originalTimeoutMs = process.env.REQUEST_BODY_TIMEOUT_MS; + process.env.REQUEST_BODY_TIMEOUT_MS = "50"; + try { + // A raw socket, not the http.request-based `request()` helper above — + // that helper always calls req.end(), which completes the body. This + // deliberately writes a partial body and never ends it, so the + // request genuinely stalls and the server's own timeout has to fire. + const { statusCode, headers, body } = await new Promise((resolve, reject) => { + const socket = net.connect(port, "127.0.0.1", () => { + socket.write( + "POST /api/nearby-cats HTTP/1.1\r\n" + + "Host: 127.0.0.1\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: 100\r\n" + + "\r\n" + + '{"location":' + ); + }); + let raw = ""; + socket.on("data", (chunk) => { raw += chunk.toString(); }); + socket.on("close", () => { + const [statusLine, ...rest] = raw.split("\r\n\r\n")[0].split("\r\n"); + const statusCode = Number(statusLine.split(" ")[1]); + const headers = Object.fromEntries( + rest.map((line) => { + const [key, ...valueParts] = line.split(":"); + return [key.toLowerCase(), valueParts.join(":").trim()]; + }) + ); + const chunkedBody = raw.split("\r\n\r\n")[1] || ""; + const body = chunkedBody.split("\r\n")[1] || ""; // skip the chunk-size line + resolve({ statusCode, headers, body }); + }); + socket.on("error", reject); + }); + + assert.strictEqual(statusCode, 408); + assert.strictEqual(headers.connection, "close"); + assert.deepStrictEqual(JSON.parse(body), { error: "Request timeout" }); + assert.strictEqual(errorSpy.mock.callCount(), 1); + } finally { + if (originalTimeoutMs === undefined) delete process.env.REQUEST_BODY_TIMEOUT_MS; + else process.env.REQUEST_BODY_TIMEOUT_MS = originalTimeoutMs; + } }); it("Malformed JSON body returns 400, not 502", async () => { @@ -251,6 +328,48 @@ describe("server routing and behavior", () => { assert.strictEqual(errorSpy.mock.callCount(), 1); }); + it("allows requests under the per-IP limit, then rejects further ones with 429", async () => { + mock.method(console, 'error', () => {}); + // An invalid ZIP is a cheap, fast 400 -- rate limiting is checked before + // the body is even parsed, so what the request would otherwise return + // doesn't matter here. + const badBody = JSON.stringify({ location: { postalcode: "123" } }); + for (let i = 0; i < 30; i++) { + const { res } = await request({ path: '/api/nearby-cats', method: 'POST' }, badBody); + assert.strictEqual(res.statusCode, 400, `request ${i + 1} of 30 should not be rate-limited yet`); + } + const { res, data } = await request({ path: '/api/nearby-cats', method: 'POST' }, badBody); + assert.strictEqual(res.statusCode, 429); + assert.ok(Number(res.headers['retry-after']) > 0); + assert.strictEqual(res.headers.connection, 'close', 'the unread body makes the connection unsafe to reuse'); + assert.deepStrictEqual(JSON.parse(data), { error: "Too many requests. Please try again later." }); + }); + + it("scopes the rate limit per client IP (via X-Forwarded-For), not globally", async () => { + mock.method(console, 'error', () => {}); + const badBody = JSON.stringify({ location: { postalcode: "123" } }); + for (let i = 0; i < 30; i++) { + await request({ path: '/api/nearby-cats', method: 'POST', headers: { 'X-Forwarded-For': '1.2.3.4' } }, badBody); + } + const limited = await request({ path: '/api/nearby-cats', method: 'POST', headers: { 'X-Forwarded-For': '1.2.3.4' } }, badBody); + assert.strictEqual(limited.res.statusCode, 429); + + const otherIp = await request({ path: '/api/nearby-cats', method: 'POST', headers: { 'X-Forwarded-For': '5.6.7.8' } }, badBody); + assert.strictEqual(otherIp.res.statusCode, 400, "a different client IP must not be affected by another IP's rate limit"); + }); + + it("does not rate-limit OPTIONS preflight or /healthz once /api/nearby-cats is limited", async () => { + mock.method(console, 'error', () => {}); + const badBody = JSON.stringify({ location: { postalcode: "123" } }); + for (let i = 0; i < 31; i++) { + await request({ path: '/api/nearby-cats', method: 'POST' }, badBody); + } + const optionsRes = await request({ path: '/api/nearby-cats', method: 'OPTIONS' }); + assert.strictEqual(optionsRes.res.statusCode, 204); + const healthRes = await request({ path: '/healthz', method: 'GET' }); + assert.strictEqual(healthRes.res.statusCode, 200); + }); + it("Different page numbers for the same location are cached independently", async () => { process.env.RG_API_KEY = "test-key"; let fetchCalls = 0; @@ -364,6 +483,7 @@ describe("upstream failure alerting", () => { beforeEach(async () => { cache.clear(); + resetRateLimitsForTests(); resetAlertStateForTests(); process.env.RG_API_KEY = "test-key"; await new Promise((resolve) => server.listen(0, resolve)); diff --git a/test/zip.test.js b/test/zip.test.js new file mode 100644 index 0000000..a8f6d03 --- /dev/null +++ b/test/zip.test.js @@ -0,0 +1,51 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { crc32, dosDateTime, createZipBuffer } from "../scripts/zip.js"; + +describe("crc32", () => { + it("returns 0 for an empty buffer", () => { + assert.strictEqual(crc32(Buffer.alloc(0)), 0); + }); + + it("matches the standard CRC-32 check value for the ASCII digits '123456789'", () => { + // The canonical CRC-32/ISO-HDLC (poly 0xEDB88320) check value. + assert.strictEqual(crc32(Buffer.from("123456789", "ascii")), 0xcbf43926); + }); +}); + +describe("dosDateTime", () => { + it("encodes a normal date/time into DOS bit-packed date and time", () => { + // Local-time constructor args, not an ISO string — dosDateTime() reads + // local getHours()/getMonth()/etc., so this stays deterministic + // regardless of the machine's timezone. + const date = new Date(2024, 0, 15, 13, 45, 30); + assert.deepStrictEqual(dosDateTime(date), { dosTime: 28079, dosDate: 22575 }); + }); + + it("encodes the DOS epoch (1980-01-01 00:00:00) as all-zero time and minimal date", () => { + const date = new Date(1980, 0, 1, 0, 0, 0); + assert.deepStrictEqual(dosDateTime(date), { dosTime: 0, dosDate: 33 }); + }); + + it("encodes a late date/time near the top of the DOS field ranges", () => { + const date = new Date(2107, 11, 31, 23, 59, 58); + assert.deepStrictEqual(dosDateTime(date), { dosTime: 49021, dosDate: 65439 }); + }); +}); + +describe("createZipBuffer", () => { + it("produces a zip whose embedded CRC32 matches the standalone crc32() of the same data", () => { + const data = Buffer.from("123456789", "ascii"); + const zip = createZipBuffer([{ name: "digits.txt", data }]); + const expectedCrc = crc32(data); + + // Local file header's CRC-32 field is 4 bytes starting at offset 14. + assert.strictEqual(zip.readUInt32LE(14), expectedCrc); + }); + + it("starts and ends with the expected zip signatures", () => { + const zip = createZipBuffer([{ name: "a.txt", data: Buffer.from("hello") }]); + assert.strictEqual(zip.readUInt32LE(0), 0x04034b50); // local file header signature + assert.strictEqual(zip.readUInt32LE(zip.length - 22), 0x06054b50); // end-of-central-directory signature + }); +});