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 @@ -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/<proto>.json` and parsed it as JSON, but the upstream list is plain text (`<proto>.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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export default function FreePoolTab() {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [addingIds, setAddingIds] = useState<Set<string>>(new Set());
const [bulkProgress, setBulkProgress] = useState<string | null>(null);
// #5595: per-source sync errors so a "Total: 0" result is never silent.
const [syncErrors, setSyncErrors] = useState<Record<string, string[]> | null>(null);

// Load persisted disabled-sources from localStorage on mount
useEffect(() => {
Expand Down Expand Up @@ -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<string, string[]> = {};
for (const [src, r] of Object.entries(
data.results as Record<string, { errors?: string[] }>
)) {
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);
Expand Down Expand Up @@ -208,6 +223,20 @@ export default function FreePoolTab() {
</div>
)}

{syncErrors && (
<div
className="text-xs text-red-500 flex flex-col gap-1"
role="alert"
data-testid="free-pool-sync-errors"
>
{Object.entries(syncErrors).map(([src, errs]) => (
<span key={src}>
{src}: {errs.join("; ")}
</span>
))}
</div>
)}

{selected.size > 0 && (
<div className="flex items-center gap-2 p-2 bg-primary/10 rounded border border-primary/20">
<span className="text-xs">{t("proxyFreePoolSelected", { count: selected.size })}</span>
Expand Down
26 changes: 22 additions & 4 deletions src/app/api/settings/free-proxies/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<typeof p> => p != null)
: getEnabledProviders();
: getEnabledProviders());

const results: Record<string, unknown> = {};
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
Expand Down
36 changes: 19 additions & 17 deletions src/lib/freeProxyProviders/iplocate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 `<proto>.txt` — the previous `<proto>.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:
Expand All @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/free-pool-tab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
37 changes: 37 additions & 0 deletions tests/unit/free-proxy-providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "";
}
});
41 changes: 41 additions & 0 deletions tests/unit/proxy-pool-sync-4878.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<unknown>) =>
({ 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<string, any> };
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", () => {
Expand Down
Loading