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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

## 2026-10-15

- Savr now tells you when your CORS proxy itself stops working (#86), instead of saves and images just failing. A capped Cloudflare worker (error 1027) answers without CORS headers, so the browser hides the status; after a network-level failure Savr probes the proxy prefix with an empty target, with and without CORS, and reports only what it saw: "rate-limiting (HTTP 429)", "responding but not working" (likely a usage limit or misconfiguration) or "isn't responding". A non-blocking banner on the article list and add screens links to the setting (and, on the shared proxy, to setting up your own); Preferences and Diagnostics show a status line, and Diagnostics the raw probe evidence plus a per-device count of proxy requests today. A visible 429 is probed too, since the proxy passes a site's own 429 through: only a 429 on the probe itself blames the proxy, otherwise just that URL backs off (previously any 429 paused every proxied request). A bad verdict pauses proxied requests with the existing breaker; probes run at most once a minute. While the proxy is failing, a failed save says so instead of offering to report the article, and on desktop the banner notes that extension saves need no proxy. No automatic proxy switching and no guessed reset times

- The shared CORS proxy now only serves allowlisted calling sites — savr.link, Netlify preview/branch deploys (`*--savrlist.netlify.app`) and localhost — via a new optional `ALLOWED_ORIGINS` setting. It still fetches articles from any public site; other websites calling it get a 404, recorded in the stats. Self-hosted Savr frontends on their own domain need their own proxy. Unset (the default for self-hosted proxies) changes nothing (ADR 0006; rate limiting tracked in #117)
- The app is translated: the UI now speaks English and Spanish. The language is detected from the browser (`es*` → Spanish) and can be switched in Preferences → Language, where the choice is remembered. All screens, dialogs, snackbars, tooltips and empty states are covered, as are the article card/read-time strings and the ingestion progress messages produced by `lib/`; `{{placeholders}}` and plurals are interpolated per language. LLM summaries are asked to answer in Spanish when the UI is in Spanish, and the text-to-speech voice preference follows the language. `src/i18n.test.ts` enforces en/es key and placeholder parity, and `tests/e2e/spanish.spec.ts` covers loading, switching and persistence (the e2e browser locale is pinned to `en-US` so the existing English assertions stay deterministic)
- The browser extension is localised too, via the standard `_locales/{en,es}/messages.json` + `chrome.i18n` mechanism (`default_locale: en`); it follows the browser's language. The image relay keeps an English fallback for its error string
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,17 @@ Savr uses a CORS proxy server that acts as a middleman. Instead of your browser

Savr is also polite about how it uses the proxy: identical requests in flight are de-duplicated, a URL that keeps failing is backed off (a 404 is not retried automatically), and if the proxy itself is rate-limited Savr pauses requests for a cooldown instead of hammering it. If an image can't be downloaded when you save an article, it is replaced with a local placeholder, so reading the saved article never contacts the original site again.

**When the proxy stops working**
If the proxy itself fails (rather than the site you're saving), Savr shows a banner on the article list and the add screen, and a status line under **Preferences → CORS Proxy**, saying only what it observed:

| Observed | Message |
|---|---|
| The proxy itself answers with HTTP 429 | "Your CORS proxy is rate-limiting requests (HTTP 429)." |
| Something answers, but not the proxy's own code | "Your CORS proxy is responding but not working. It may have hit a usage limit or be misconfigured." |
| Nothing answers while you're online | "Your CORS proxy isn't responding. Check the URL in Preferences." |

The middle case is what a capped Cloudflare worker looks like: past the free plan's 100k requests/day the edge serves its own error page (1027) without CORS headers, so the browser hides the status. Savr tells it apart by probing the proxy prefix (with an empty target) twice, once normally and once with `no-cors`: our worker answers with CORS headers even on errors, while a `no-cors` request succeeds whenever anything answered. A 429 is probed too, because the proxy passes a site's own 429 straight through: only if the probe is also refused with 429 is the proxy blamed; otherwise just that page or image backs off. A probe runs at most once per minute, only after a network-level failure or a 429, and a bad verdict pauses proxied requests for a cooldown. Savr never switches proxies by itself and never guesses a reset time. While the proxy is failing, a failed save says so ("Couldn't save: your CORS proxy isn't working…") instead of offering to report the article, which isn't at fault; on desktop the banner also points out that saving with the browser extension needs no proxy. The banner clears as soon as a request or probe succeeds; **Diagnostics → CORS Proxy Health** shows the raw probe results, and roughly how many proxy requests this device sent today (other devices share the same proxy, so it's only a hint).

**Why bring your own proxy?**
While Savr provides a default proxy, you can configure your own CORS proxy server for:

Expand Down
27 changes: 25 additions & 2 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ rm -f tests/e2e/.test-env.json
| `main-page.spec.ts` | Main page UI tests | Requires React hydration |
| `add-article-dialog.spec.ts` | Add-article dialog interactions | Requires React hydration |
| `failure-report.spec.ts` | Failed-capture report dialog: dismiss, prefilled GitHub issue, mobile reachability | Needs `VITE_FAILURE_REPORT_REPO` (set by the Playwright web-server env) |
| `proxy-health.spec.ts` | CORS proxy health banner (#86): visible 429 from the proxy, a target's own 429 passed through a working proxy (no banner, report dialog still offered), capped-edge answer ("Couldn't save" notice, no report dialog, extension hint) without CORS headers (real local server — `route.fulfill` adds CORS headers itself), unreachable, and a failing target behind a working proxy (no banner) | Active |
| `share-article.spec.ts` | Share menu on a list article: system share sheet (Web Share stubbed) and clipboard fallback | Active |
| `ingest-local-article.spec.ts` | Article ingestion via UI, incl. PDF/markdown/image from `test_data/input/` | Serial |
| `ingest-failed-image.spec.ts` | A failed image is replaced by a local placeholder in saved HTML (#75) | Serial |
Expand Down Expand Up @@ -175,7 +176,11 @@ governor in `src/utils/net/fetchGovernor.ts`. The governor:
- coalesces concurrent fetches of the same URL into one request,
- records failures so a URL is not retried until its `nextAttemptAt` passes
(permanent 4xx wait days; transient 5xx/timeout/429 back off exponentially),
- opens a circuit breaker on HTTP 429 so a capped proxy isn't hammered.
- opens a circuit breaker when the proxy itself is failing, so a capped proxy
isn't hammered: on a 429, or a network-level failure with no visible status
(incl. timeouts), it asks `diagnoseProxy` whether the proxy is at fault —
the worker passes a target's own 429 through, so that only backs off the
URL. Without `diagnoseProxy` every 429 trips the breaker.

Failure history is persisted in the Dexie `fetchCache` table (db version 6) via
`src/utils/net/fetchGovernorStore.ts`. An explicit **Refetch** in the reader
Expand All @@ -184,9 +189,27 @@ Images that fail during ingest are swapped for a local placeholder
(`applyImagePlaceholder` in `lib/src/ingestion.ts`) so saved articles never
re-request a dead remote URL.

Unit tests: `src/utils/net/fetchGovernor.test.ts`,
Unit tests: `src/utils/net/fetchGovernor.test.ts`, `src/utils/net/proxyHealth.test.ts`,
`lib/__tests__/ingestion-placeholder.test.ts`.

### Proxy health (#86)

A capped Cloudflare worker (error 1027) is answered by the edge without CORS
headers, so the browser hides its status. `src/utils/net/proxyHealth.ts` (pure,
injected fetch/clock/storage) probes the proxy prefix with an empty target, once
with CORS and once with `no-cors`: CORS 429 → rate-limited; any other CORS
answer → running; only no-cors answers →
"responding but not working"; neither while online → unreachable; offline → no
verdict. Probes are coalesced and throttled (one per minute, i.e. one per
breaker cycle). The monitor instance (`proxyHealth`) and its wiring to the
governor live next to the governor in `src/utils/article/tools.ts`; the UI reads
it via `useProxyHealth` (`ProxyHealthBanner`, Preferences, Diagnostics). A
response that came back through the proxy with CORS headers clears it; changing
the proxy setting resets it (`resetProxyHealth`). It also keeps a per-device
daily count of proxied requests in localStorage (`savr-proxy-requests`).
While a problem is known, `useFailureReport` offers no "report this article"
dialog and the save/refetch snackbars name the proxy instead.

## Image sources and downloader logging

Ingest downloads each image through an ordered list of image sources
Expand Down
9 changes: 9 additions & 0 deletions infra/cors-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,12 @@ cap. The primary defense is the **browser** cache (the `Cache-Control` above
makes browsers stop re-requesting) plus the app-side fetch governor
(`src/utils/net/fetchGovernor.ts`), which de-dupes, negative-caches and backs
off so the app never generates the retry storm in the first place.

When the cap is hit anyway, the app notices (#86): the 1027 page comes from
Cloudflare's edge, not this worker, so it lacks the CORS headers that every
response from `core.js` carries (its own 4xx/5xx included). The app probes the
configured prefix with an empty target — this worker answers that with a CORS
`400` — once with CORS and once with `no-cors`, and reads "CORS fails, no-cors
succeeds" as "reachable but not running our code". Keep CORS headers on every
response the worker produces, or that probe will misread the worker as down.
See `src/utils/net/proxyHealth.ts`.
41 changes: 40 additions & 1 deletion infra/cors-worker/worker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,45 @@ test("rejects a missing url", async () => {
assert.equal(response.status, 400);
});

// The app's proxy-health probe (#86) fetches the configured prefix with an
// empty target and reads "answered with CORS headers" as "our code is
// running". Every shape of prefix must get that CORS 400.
test("answers the empty-target health probe with a CORS 400", async () => {
stubGlobals();
const cases = [
[`${PROXY}?url=`, { PUBLIC: "true" }],
[`${PROXY}${TOKEN}/?url=`, { PROXY_TOKENS: TOKEN }],
[`${PROXY}?token=${TOKEN}&url=`, { PROXY_TOKENS: TOKEN }],
];
for (const [prefix, env] of cases) {
const response = await callWorker(urlRequest(prefix), { env });
assert.equal(response.status, 400, prefix);
assert.equal(response.headers.get("access-control-allow-origin"), "*", prefix);
}
});

// Behind an origin allowlist the probe still reads as "running": an allowed
// caller gets the CORS 400, and a refused one a 404 that still carries CORS
// headers — never an answer the app would mistake for a capped proxy.
test("answers the health probe with CORS headers behind an origin allowlist", async () => {
stubGlobals();
const env = { PUBLIC: "true", ALLOWED_ORIGINS: "https://savr.link" };

const allowed = await callWorker(
urlRequest(`${PROXY}?url=`, { headers: { Origin: "https://savr.link" } }),
{ env }
);
assert.equal(allowed.status, 400);
assert.ok(allowed.headers.get("access-control-allow-origin"));

const refused = await callWorker(
urlRequest(`${PROXY}?url=`, { headers: { Origin: "https://elsewhere.example" } }),
{ env }
);
assert.equal(refused.status, 404);
assert.ok(refused.headers.get("access-control-allow-origin"));
});

test("rejects malformed and non-http urls", async () => {
stubGlobals();
const invalid = await callWorker(proxyRequest("not a url"), { env: { PUBLIC: "true" } });
Expand Down Expand Up @@ -781,7 +820,7 @@ test("origin allowlist is checked before the edge cache", async () => {
const allowed = await callWorker(fromOrigin("https://savr.link", "https://example.com/hit.jpg"), { env, cache });
assert.equal(allowed.status, 200);
assert.equal(await allowed.text(), "cached");
assert.equal(allowed.headers.get("access-control-allow-origin"), "https://savr.link");
assert.ok(allowed.headers.get("access-control-allow-origin"));
});

test("origin allowlist leaves preflight and health open", async () => {
Expand Down
18 changes: 16 additions & 2 deletions src/components/ArticleListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ import {
type ExtensionInfo,
} from "~/utils/extension/extensionBridge";
import { trackCapture, trackCaptureFailed } from "~/utils/telemetry";
import { removeArticle, patchArticleMetadata, loadThumbnail } from "~/utils/article/tools";
import {
removeArticle,
patchArticleMetadata,
loadThumbnail,
proxyHealth,
} from "~/utils/article/tools";
import { proxyProblemSummary } from "~/utils/net/proxyHealth";
import { useRemoteStorage } from "./RemoteStorageProvider";
import { useSyncStatus } from "./SyncStatusProvider";
import { useLiveQuery } from "dexie-react-hooks";
Expand All @@ -69,6 +75,7 @@ import { normalizeUrl } from "~/utils/net/url";
import { shareArticle } from "~/utils/share";
import FailureReportDialog from "./FailureReportDialog";
import { useTranslation } from "react-i18next";
import ProxyHealthBanner from "./ProxyHealthBanner";

import { keyframes } from "@mui/system";

Expand Down Expand Up @@ -816,7 +823,13 @@ export default function ArticleListScreen() {
} catch (error) {
console.error(error);
trackCaptureFailed();
enqueueSnackbar(t("list.saveError", { error: errorMessage(error) }), { variant: "error" });
const proxyProblem = proxyHealth.getState().problem;
enqueueSnackbar(
proxyProblem
? t("common.proxyHealth.saveFailed", { reason: proxyProblemSummary(t, proxyProblem) })
: t("list.saveError", { error: errorMessage(error) }),
{ variant: "error" }
);
if (!ingestSucceeded) {
reportFailure(targetUrl, error, t("list.downloadFailed"));
}
Expand Down Expand Up @@ -1080,6 +1093,7 @@ export default function ArticleListScreen() {
maxWidth="sm"
sx={{ mt: 2, mx: "auto", display: "flex", flexDirection: "column", alignItems: "center" }}
>
<ProxyHealthBanner />
{/* Article count and reading time info */}
{filteredArticles.length > 0 && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1, textAlign: "center" }}>
Expand Down
18 changes: 16 additions & 2 deletions src/components/ArticleScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,13 @@ import { Route } from "~/routes/article.$slug";
import { useRemoteStorage } from "./RemoteStorageProvider";
import { db } from "~/utils/db";
import { Article, SummaryMeta } from "../../lib/src/models";
import { removeArticle, patchArticleMetadata, clearFetchFailures } from "~/utils/article/tools";
import {
removeArticle,
patchArticleMetadata,
clearFetchFailures,
proxyHealth,
} from "~/utils/article/tools";
import { proxyProblemSummary } from "~/utils/net/proxyHealth";
import { useSnackbar } from "notistack";
import { useTranslation } from "react-i18next";
import ArticleComponent from "./ArticleComponent";
Expand Down Expand Up @@ -467,7 +473,15 @@ export default function ArticleScreen(_props: Props) {
setRefetchDrawerOpen(false);
setRefetchPercent(0);
setRefetchStatus(null);
enqueueSnackbar(t("article.refetchFailed"), { variant: "error" });
const proxyProblem = proxyHealth.getState().problem;
enqueueSnackbar(
proxyProblem
? t("common.proxyHealth.refetchFailed", {
reason: proxyProblemSummary(t, proxyProblem),
})
: t("article.refetchFailed"),
{ variant: "error" }
);
if (!ingestSucceeded) {
reportFailure(targetUrl, error, "Failed to refetch article");
}
Expand Down
51 changes: 51 additions & 0 deletions src/components/DiagnosticsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
import { isPWAMode } from "~/utils/net/network";
import { getCorsProxyValue, maskCorsProxyInText } from "~/utils/net/corsProxy";
import { useTranslation } from "react-i18next";
import { useProxyHealth } from "~/hooks/useProxyHealth";
import { proxyProblemMessage } from "~/utils/net/proxyHealth";
import { getProxiedRequestsTodayCount } from "~/utils/article/tools";
import React from "react";

interface RemoteStorageEvent {
Expand All @@ -45,6 +48,7 @@
const articles = useLiveQuery(() => db.articles.toArray());
const { status: syncStatus, isWiFi, isNetworkSupported } = useSyncStatus();
const syncProgress = useSyncProgress();
const proxyHealthState = useProxyHealth();
const [danglingItems, setDanglingItems] = React.useState<
Array<{
path: string;
Expand Down Expand Up @@ -190,7 +194,7 @@
} finally {
setIsScanningOrphaned(false);
}
}, [remoteStorageClient, articles?.length, t]);

Check warning on line 197 in src/components/DiagnosticsScreen.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

React Hook React.useCallback has a missing dependency: 'articles'. Either include it or remove the dependency array

React.useEffect(() => {
if (remoteStorageClient && articles) {
Expand All @@ -207,7 +211,7 @@
articlesLength: articles?.length,
});
}
}, [remoteStorageClient, articles?.length]);

Check warning on line 214 in src/components/DiagnosticsScreen.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

React Hook React.useEffect has missing dependencies: 'articles', 'scanDanglingItems', and 'scanOrphanedArticles'. Either include them or remove the dependency array

// Calculate IndexedDB diagnostics
React.useEffect(() => {
Expand Down Expand Up @@ -246,7 +250,7 @@
if (articles) {
calculateIndexedDbStats();
}
}, [articles?.length]);

Check warning on line 253 in src/components/DiagnosticsScreen.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

React Hook React.useEffect has a missing dependency: 'articles'. Either include it or remove the dependency array

// Set up RemoteStorage event listeners
React.useEffect(() => {
Expand Down Expand Up @@ -1041,6 +1045,53 @@
</Box>
</Box>

<Box
sx={{ mt: 4, p: 3, backgroundColor: "background.default", borderRadius: 1 }}
data-testid="proxy-health-diagnostics"
>
<Typography variant="h6" component="h3" gutterBottom>
{t("diagnostics.proxyHealthTitle")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t("diagnostics.proxyHealthDescription")}
</Typography>
<Typography
variant="body2"
data-testid="proxy-health-diagnostics-status"
color={proxyHealthState.problem ? "warning.main" : "text.primary"}
sx={{ mb: 1 }}
>
{proxyHealthState.problem
? proxyProblemMessage(t, proxyHealthState.problem, proxyHealthState.status)
: t("common.proxyHealth.noProblems")}
</Typography>
<Typography variant="body2" component="pre" sx={{ wordBreak: "break-all" }}>
{maskCorsProxyInText(
JSON.stringify(
{
problem: proxyHealthState.problem,
observedAt: proxyHealthState.observedAt
? new Date(proxyHealthState.observedAt).toISOString()
: null,
status: proxyHealthState.status ?? null,
lastProbe: proxyHealthState.lastProbe
? {
...proxyHealthState.lastProbe,
evidence: {
...proxyHealthState.lastProbe.evidence,
at: new Date(proxyHealthState.lastProbe.evidence.at).toISOString(),
},
}
: null,
proxiedRequestsToday: getProxiedRequestsTodayCount(),
},
null,
2
)
)}
</Typography>
</Box>

<Box sx={{ mt: 4, p: 3, backgroundColor: "background.default", borderRadius: 1 }}>
<Typography variant="h6" component="h3" gutterBottom>
{t("diagnostics.buildEnvTitle")}
Expand Down
Loading
Loading