diff --git a/CHANGELOG.md b/CHANGELOG.md index eb55e69e19..01a3f0ae3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### πŸ”§ Bug Fixes +- **dashboard (free proxy pool):** the free proxy pool "Sync All" no longer fails silently with `Total: 0`. Three fixes: (1) the **IPLocate** source fetched `…/protocols/.json` and parsed it as JSON, but the upstream list is plain text (`.txt`, one `ip:port` per line) β€” every protocol 404'd / failed to parse; it now fetches `.txt` and parses the line list. (2) The sync route **isolates each source** in its own try/catch, so one provider throwing (e.g. a TLS handshake failure) no longer aborts the whole sync β€” the working sources still populate the pool. (3) The UI now **surfaces the per-source errors** the route already returns, instead of discarding the response, so a partial/empty sync explains itself. Regression guards: `tests/unit/free-proxy-providers.test.ts`, `tests/unit/proxy-pool-sync-4878.test.ts`, `tests/unit/free-pool-tab.test.tsx`. ([#5595](https://github.com/diegosouzapw/OmniRoute/issues/5595)) + - **dashboard (memory engine):** the memory engine status page no longer mixes English and Portuguese. The embedding / vector-store / rerank **status detail strings** were hardcoded in Portuguese in the backend (`resolveEmbeddingSource`, `engineStatus`), e.g. `auto: nenhuma fonte de embedding disponΓ­vel` and `sqlite-vec ativo, dim=…`, while the surrounding UI labels render from the English i18n bundle β€” so an English user saw a half-translated page. The backend detail strings are now English (`auto: no embedding source available`, `sqlite-vec active, dim=…`, etc.), matching the rest of the page. Regression guard: `tests/unit/memory-engine-status.test.ts`. ([#5596](https://github.com/diegosouzapw/OmniRoute/issues/5596)) - **providers (cline):** stop falsely mapping valid **Cline** (OAuth) responses to `502 empty_choices` + account cooldown. `detectMalformedNonStream` only recognized `choices[].message.content` as a **string**, but some OpenAI-compatible upstreams β€” Cline via OAuth among them β€” return `content` as an **array of Anthropic-style text blocks** inside an OpenAI envelope. A non-empty response (recvBytes > 0) was therefore classified as `empty_choices` and turned into a 502 that also cooled the account down. The malformed-response detector now also treats a content array carrying at least one non-empty `text` block as real output. Regression guard: `tests/unit/diagnostics.test.ts`. ([#5559](https://github.com/diegosouzapw/OmniRoute/issues/5559)) diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx index f3f52d590b..de797c884c 100644 --- a/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx @@ -30,6 +30,8 @@ export default function FreePoolTab() { const [selected, setSelected] = useState>(new Set()); const [addingIds, setAddingIds] = useState>(new Set()); const [bulkProgress, setBulkProgress] = useState(null); + // #5595: per-source sync errors so a "Total: 0" result is never silent. + const [syncErrors, setSyncErrors] = useState | null>(null); // Load persisted disabled-sources from localStorage on mount useEffect(() => { @@ -83,14 +85,27 @@ export default function FreePoolTab() { const handleSync = async () => { setSyncing(true); + setSyncErrors(null); try { const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s)); const body = enabledSources.length < ALL_SOURCE_IDS.length ? { sources: enabledSources } : {}; - await fetch("/api/settings/free-proxies/sync", { + const res = await fetch("/api/settings/free-proxies/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); + // #5595: surface per-source errors the route already returns so a + // partial/empty sync explains itself instead of silently showing "Total: 0". + const data = await res.json().catch(() => null); + if (data?.results) { + const errs: Record = {}; + for (const [src, r] of Object.entries( + data.results as Record + )) { + if (Array.isArray(r?.errors) && r.errors.length > 0) errs[src] = r.errors; + } + if (Object.keys(errs).length > 0) setSyncErrors(errs); + } await loadData(); } catch {} setSyncing(false); @@ -208,6 +223,20 @@ export default function FreePoolTab() { )} + {syncErrors && ( +
+ {Object.entries(syncErrors).map(([src, errs]) => ( + + {src}: {errs.join("; ")} + + ))} +
+ )} + {selected.size > 0 && (
{t("proxyFreePoolSelected", { count: selected.size })} diff --git a/src/app/api/settings/free-proxies/sync/route.ts b/src/app/api/settings/free-proxies/sync/route.ts index d7b9e8bc1b..df5bf78e49 100644 --- a/src/app/api/settings/free-proxies/sync/route.ts +++ b/src/app/api/settings/free-proxies/sync/route.ts @@ -4,7 +4,12 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { freeProxySyncSchema } from "@/shared/validation/freeProxySchemas"; import { getEnabledProviders, getProvider } from "@/lib/freeProxyProviders"; import { recordFreeProxySync } from "@/lib/localDb"; -import type { FreeProxySourceId } from "@/lib/freeProxyProviders/types"; +import type { FreeProxyProvider, FreeProxySourceId } from "@/lib/freeProxyProviders/types"; + +let _providersOverrideForTests: FreeProxyProvider[] | null = null; +export function _setProvidersForTests(providers: FreeProxyProvider[] | null): void { + _providersOverrideForTests = providers; +} export async function POST(request: Request) { const authError = await requireManagementAuth(request); @@ -35,15 +40,28 @@ export async function POST(request: Request) { try { const providers = - validation.data.sources && validation.data.sources.length > 0 + _providersOverrideForTests ?? + (validation.data.sources && validation.data.sources.length > 0 ? validation.data.sources .map((id) => getProvider(id as FreeProxySourceId)) .filter((p): p is NonNullable => p != null) - : getEnabledProviders(); + : getEnabledProviders()); const results: Record = {}; for (const provider of providers) { - results[provider.id] = await provider.sync(); + try { + results[provider.id] = await provider.sync(); + } catch (error) { + // #5595: isolate per-source failures so one provider throwing doesn't + // abort the whole sync β€” the other sources still populate the pool and + // the failure is surfaced in `results` instead of a blanket 500. + results[provider.id] = { + fetched: 0, + added: 0, + updated: 0, + errors: [error instanceof Error ? error.message : String(error)], + }; + } } // #4878: persist the sync timestamp so the UI's "last sync" advances even diff --git a/src/lib/freeProxyProviders/iplocate.ts b/src/lib/freeProxyProviders/iplocate.ts index 9395e8f2d2..341f246e99 100644 --- a/src/lib/freeProxyProviders/iplocate.ts +++ b/src/lib/freeProxyProviders/iplocate.ts @@ -8,12 +8,6 @@ const PROTOCOLS = ["http", "https", "socks4", "socks5"] as const; let lastFetchAt = 0; const CACHE_TTL_MS = 30 * 60 * 1000; // 30 min -type IplocateProxy = { - ip: string; - port: number; - country: string; -}; - export class IplocateProvider implements FreeProxyProvider { readonly id = "iplocate" as const; readonly name = "IPLocate"; @@ -46,7 +40,11 @@ export class IplocateProvider implements FreeProxyProvider { for (const proto of PROTOCOLS) { try { - const url = `${baseUrl}/${proto}.json`; + // #5595: the iplocate/free-proxy-list repo serves plain-text `ip:port` + // lists at `.txt` β€” the previous `.json` path 404'd on every + // protocol (and `res.json()` would fail even if it didn't, since the + // payload is not JSON). + const url = `${baseUrl}/${proto}.txt`; const res = await fetch(url, { signal: AbortSignal.timeout(15000), headers: @@ -59,21 +57,25 @@ export class IplocateProvider implements FreeProxyProvider { continue; } - const data = (await res.json()) as IplocateProxy[]; - if (!Array.isArray(data)) continue; - - for (const p of data) { - if (!p.ip || !p.port) continue; - if (isPrivateHost(p.ip)) { - errors.push(`${proto}: skipped private/loopback host ${p.ip}`); + const text = await res.text(); + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const sep = trimmed.lastIndexOf(":"); + if (sep <= 0) continue; + const host = trimmed.slice(0, sep).trim(); + const port = Number(trimmed.slice(sep + 1).trim()); + if (!host || !Number.isInteger(port) || port < 1 || port > 65535) continue; + if (isPrivateHost(host)) { + errors.push(`${proto}: skipped private/loopback host ${host}`); continue; } const item: FreeProxyItem = { source: "iplocate", - host: p.ip, - port: Number(p.port), + host, + port, type: proto, - countryCode: p.country?.slice(0, 2).toUpperCase() || null, + countryCode: null, // the txt list carries no country data qualityScore: null, latencyMs: null, anonymity: null, diff --git a/tests/unit/free-pool-tab.test.tsx b/tests/unit/free-pool-tab.test.tsx index b36f2e30c0..6279174fa4 100644 --- a/tests/unit/free-pool-tab.test.tsx +++ b/tests/unit/free-pool-tab.test.tsx @@ -237,3 +237,45 @@ describe("FreePoolTab data loading", () => { expect(el.textContent).toMatch(/In pool: 2/); }); }); + +describe("FreePoolTab sync error surfacing (#5595)", () => { + it("renders the per-source errors the sync route returns instead of failing silently", async () => { + const mockFetch = vi.fn((url: string) => { + if (String(url).includes("/sync")) { + return okJson({ + success: true, + results: { + proxifly: { fetched: 0, added: 0, updated: 0, errors: ["TLS handshake failed"] }, + iplocate: { fetched: 0, added: 0, updated: 0, errors: ["http: HTTP 404"] }, + "1proxy": { fetched: 5, added: 5, updated: 0, errors: [] }, + }, + lastSyncAt: "2026-06-30T00:00:00Z", + }); + } + if (String(url).includes("/stats")) return okJson({ stats: defaultStats }); + return okJson({ items: [] }); + }); + vi.stubGlobal("fetch", mockFetch); + + const el = renderTab(); + await waitForCondition(() => el.querySelector("[role='group']") !== null); + + const syncBtn = Array.from(el.querySelectorAll("button")).find((b) => + b.textContent?.includes("proxyFreePoolSyncAll") + )!; + expect(syncBtn).toBeTruthy(); + act(() => { + syncBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + // RED before the fix: handleSync discarded the response, so no error box appears. + await waitForCondition( + () => el.querySelector("[data-testid='free-pool-sync-errors']") !== null + ); + const errBox = el.querySelector("[data-testid='free-pool-sync-errors']")!; + expect(errBox.textContent).toContain("TLS handshake failed"); + expect(errBox.textContent).toContain("HTTP 404"); + // A source with no errors must not be listed. + expect(errBox.textContent).not.toContain("1proxy"); + }); +}); diff --git a/tests/unit/free-proxy-providers.test.ts b/tests/unit/free-proxy-providers.test.ts index d04f84c5b6..a63d53087c 100644 --- a/tests/unit/free-proxy-providers.test.ts +++ b/tests/unit/free-proxy-providers.test.ts @@ -233,3 +233,40 @@ test("IplocateProvider.sync returns disabled error when not enabled", async () = process.env.FREE_PROXY_IPLOCATE_ENABLED = original ?? ""; }); + +test("IplocateProvider.sync parses the plain-text ip:port lists (.txt, not .json) (#5595)", async () => { + const original = process.env.FREE_PROXY_IPLOCATE_ENABLED; + const originalFetch = globalThis.fetch; + process.env.FREE_PROXY_IPLOCATE_ENABLED = "true"; + await reset(); + + const seenUrls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seenUrls.push(String(input)); + // The iplocate/free-proxy-list repo serves one `ip:port` per line (no JSON). + // Include a blank line and a comment to exercise line-skipping. + const body = "103.173.141.10:8080\n8.211.49.86:9028\n\n# comment line\n"; + return new Response(body, { status: 200, headers: { "content-type": "text/plain" } }); + }) as typeof fetch; + + try { + const p = getProvider("iplocate")!; + const result = await p.sync(); + // RED before the fix: the URL ended in `.json` and `res.json()` threw on the + // plain-text payload β†’ every protocol errored and 0 proxies were parsed. + assert.ok( + seenUrls.length > 0 && seenUrls.every((u) => u.endsWith(".txt")), + `expected .txt URLs, got: ${seenUrls.join(", ")}` + ); + assert.ok(result.fetched > 0, `expected proxies parsed from the txt list, got ${result.fetched}`); + const items = await p.list({ limit: 50 }); + assert.ok( + items.some((i) => i.host === "103.173.141.10" && i.port === 8080), + "parsed ip:port must be stored" + ); + assert.ok(items.every((i) => i.source === "iplocate")); + } finally { + globalThis.fetch = originalFetch; + process.env.FREE_PROXY_IPLOCATE_ENABLED = original ?? ""; + } +}); diff --git a/tests/unit/proxy-pool-sync-4878.test.ts b/tests/unit/proxy-pool-sync-4878.test.ts index 557cd3799a..787a2a0b50 100644 --- a/tests/unit/proxy-pool-sync-4878.test.ts +++ b/tests/unit/proxy-pool-sync-4878.test.ts @@ -16,6 +16,7 @@ const freeProxiesDb = await import("../../src/lib/db/freeProxies.ts"); const addToPoolRoute = await import( "../../src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts" ); +const syncRoute = await import("../../src/app/api/settings/free-proxies/sync/route.ts"); const rateLimiter = await import("../../src/shared/utils/rateLimiter.ts"); function reset() { @@ -124,6 +125,46 @@ test("#4878 recordFreeProxySync advances lastSyncAt on a subsequent sync", async assert.equal(stats.lastSyncAt, "2030-06-25T12:00:00.000Z"); }); +// ── #5595: a throwing source must not abort the whole sync ─────────────────── + +test("#5595 sync route isolates a throwing provider β€” others still sync, error surfaced", async () => { + const makeProvider = (id: string, sync: () => Promise) => + ({ id, name: id, isEnabled: () => true, sync, list: async () => [] }) as unknown as Parameters< + typeof syncRoute._setProvidersForTests + >[0][number]; + + const good = makeProvider("1proxy", async () => ({ + fetched: 3, + added: 3, + updated: 0, + errors: [], + })); + const bad = makeProvider("proxifly", async () => { + throw new Error("TLS handshake failed"); + }); + + syncRoute._setProvidersForTests([bad, good]); + try { + const res = await syncRoute.POST( + new Request("http://localhost/api/settings/free-proxies/sync", { method: "POST" }) + ); + // RED before the fix: the throwing provider escaped to the outer catch β†’ 500, + // and the working provider never ran. + assert.equal(res.status, 200, `expected 200 (partial success), got ${res.status}`); + const body = (await res.json()) as { success: boolean; results: Record }; + assert.equal(body.success, true); + // Working source still produced its result. + assert.deepEqual(body.results["1proxy"], { fetched: 3, added: 3, updated: 0, errors: [] }); + // Failing source surfaced its error instead of aborting everything. + assert.ok( + body.results["proxifly"].errors.some((e: string) => e.includes("TLS handshake failed")), + `expected the proxifly error surfaced, got: ${JSON.stringify(body.results["proxifly"])}` + ); + } finally { + syncRoute._setProvidersForTests(null); + } +}); + // ── SUB-FIX 3: Redis error-log throttle is state-change-gated ───────────────── test("#4878 shouldLogRedisError only logs once per error-state change", () => {