mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
feat(proxy): add Webshare proxy pool import and sync (#5993)
* feat(proxy): add Webshare proxy pool import and sync Adds Webshare (https://proxy.webshare.io) as a fourth source in the free-proxy provider framework alongside 1proxy, Proxifly, and IPLocate. WebshareProvider paginates the account's `/api/v2/proxy/list/` endpoint (Authorization: Token <key>), upserts proxies into the shared `free_proxies` table via the existing db/freeProxies.ts helpers, and tombstones proxies the account no longer lists (recycled/retired IDs) while never touching rows already promoted into the live proxy pool. Unlike the other sources, Webshare is a paid per-account list, so it is gated on FREE_PROXY_WEBSHARE_API_KEY rather than a plain on/off flag. No DB migration needed — reuses the existing free_proxies table and proxy_registry-on-promote path. Co-authored-by: ricatix <d.enistraju155@gmail.com> Inspired-by: https://github.com/decolua/9router/pull/1176 * chore(changelog): restore release entries + add webshare bullet --------- Co-authored-by: ricatix <d.enistraju155@gmail.com>
This commit is contained in:
committed by
GitHub
parent
4b8056b464
commit
bff75cbd3c
@@ -1683,6 +1683,15 @@ APP_LOG_TO_FILE=true
|
||||
# FREE_PROXY_IPLOCATE_ENABLED=false
|
||||
# FREE_PROXY_IPLOCATE_BASE_URL=https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols
|
||||
|
||||
# ── Free Proxy Pool (Webshare source) ──
|
||||
# Used by: src/lib/freeProxyProviders/webshare.ts
|
||||
# Paid, per-account proxy list — requires FREE_PROXY_WEBSHARE_API_KEY to activate,
|
||||
# regardless of FREE_PROXY_WEBSHARE_ENABLED.
|
||||
# FREE_PROXY_WEBSHARE_ENABLED=true
|
||||
# FREE_PROXY_WEBSHARE_API_KEY=
|
||||
# FREE_PROXY_WEBSHARE_API_URL=https://proxy.webshare.io/api/v2/proxy/list/
|
||||
# FREE_PROXY_WEBSHARE_MAX=500
|
||||
|
||||
# ── Vercel Relay ──
|
||||
# Used by: src/app/api/settings/proxy/vercel-deploy/route.ts
|
||||
# Hides the "Deploy Relay" button when set to false.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
- **feat(api):** add `/v1/ocr` endpoint (Mistral OCR), an OCR provider category, and Mistral moderation support. (thanks @waguriagentic)
|
||||
- **Discovery tool (Phase 2):** add the `discoveryResults` DB module (CRUD over the `discovery_results` table, migration 074) and wire the opt-in provider-discovery service to persist and read findings through it (`persistDiscoveryResult`, `getDiscoveryResults`, `getDiscoveryResultById`, `markVerified`, `deleteDiscoveryResult`) with `(provider, method, endpoint)` upsert de-duplication. Adds the `/api/discovery/*` HTTP surface — `GET /results`, `GET|DELETE /results/:id`, `POST /scan`, `POST /verify/:id` — under **strict loopback-only** authorization (`/api/discovery/` is in `LOCAL_ONLY_API_PREFIXES` and is NOT manage-scope-bypassable, so the `scan` route's outbound probes can never be reached from a tunnel/remote origin). Adds a **dashboard UI tab** (Tools → Discovery, `/dashboard/discovery`) to run scans and review, verify, or delete findings. The service stays **opt-in / default-off**.
|
||||
- **feat(proxy):** add Webshare proxy pool import and sync — a `WebshareProvider` (`FreeProxyProvider`) that paginates `proxy.webshare.io/api/v2/proxy/list/` gated on `FREE_PROXY_WEBSHARE_API_KEY`, SSRF-guards imported hosts, and tombstones retired proxy IDs via `pruneStaleFreeProxies()`. (thanks @ricatix)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
|
||||
@@ -1008,6 +1008,10 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
|
||||
| `FREE_PROXY_PROXIFLY_ANONYMITY` | `elite` | `src/lib/freeProxyProviders/proxifly.ts` | Anonymity level filter for Proxifly (`elite`, `anonymous`, `transparent`). |
|
||||
| `FREE_PROXY_IPLOCATE_ENABLED` | `false` | `src/lib/freeProxyProviders/iplocate.ts` | Enable the IPLocate free proxy source. Opt-in only. |
|
||||
| `FREE_PROXY_IPLOCATE_BASE_URL` | `https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols` | `src/lib/freeProxyProviders/iplocate.ts` | IPLocate proxy list base URL override. |
|
||||
| `FREE_PROXY_WEBSHARE_ENABLED` | `true` | `src/lib/freeProxyProviders/webshare.ts` | Enable the Webshare proxy pool source. Set to `false` to disable; also requires `FREE_PROXY_WEBSHARE_API_KEY` to be set. |
|
||||
| `FREE_PROXY_WEBSHARE_API_KEY` | _(none)_ | `src/lib/freeProxyProviders/webshare.ts` | Webshare account API token (`Authorization: Token <key>`). Required — the provider stays disabled without it. |
|
||||
| `FREE_PROXY_WEBSHARE_API_URL` | `https://proxy.webshare.io/api/v2/proxy/list/` | `src/lib/freeProxyProviders/webshare.ts` | Webshare proxy list API URL override. |
|
||||
| `FREE_PROXY_WEBSHARE_MAX` | `500` | `src/lib/freeProxyProviders/webshare.ts` | Maximum proxies imported per Webshare sync. |
|
||||
| `NEXT_PUBLIC_VERCEL_RELAY_ENABLED` | `true` | `src/app/(dashboard)/…/ProxyPoolTab.tsx` | Show/hide the Deploy Vercel Relay button in the Proxy Pool tab. |
|
||||
| `VERCEL_API_BASE` | `https://api.vercel.com` | `src/app/api/settings/proxy/vercel-deploy/route.ts` | Vercel API base URL override (for testing). |
|
||||
| `NEXT_PUBLIC_VERCEL_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/…/VercelRelayModal.tsx` | Default project name pre-filled in the Vercel Relay deploy modal. |
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
export type SourceId = "1proxy" | "proxifly" | "iplocate";
|
||||
export type SourceId = "1proxy" | "proxifly" | "iplocate" | "webshare";
|
||||
|
||||
export const ALL_SOURCE_IDS: SourceId[] = ["1proxy", "proxifly", "iplocate"];
|
||||
export const ALL_SOURCE_IDS: SourceId[] = ["1proxy", "proxifly", "iplocate", "webshare"];
|
||||
|
||||
export const FREE_POOL_DISABLED_SOURCES_KEY = "freePool.disabledSources";
|
||||
|
||||
@@ -32,6 +32,7 @@ const SOURCES: Array<{ id: SourceId; label: string }> = [
|
||||
{ id: "1proxy", label: "1proxy" },
|
||||
{ id: "proxifly", label: "Proxifly" },
|
||||
{ id: "iplocate", label: "IPLocate" },
|
||||
{ id: "webshare", label: "Webshare" },
|
||||
];
|
||||
|
||||
export default function SourceToggleBar({ disabledSources, onToggle }: SourceToggleBarProps) {
|
||||
|
||||
@@ -270,6 +270,34 @@ export async function clearFreeProxiesBySource(source: FreeProxySourceId): Promi
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tombstone rows for `source` whose `host:port` is no longer present in the
|
||||
* provider's latest list — e.g. Webshare recycles/retires proxy IDs between
|
||||
* syncs. Rows already promoted to the pool (`in_pool = 1`) are left alone so
|
||||
* runtime resolution of an in-use proxy is never disturbed; only stale,
|
||||
* not-yet-pooled candidates are pruned. Returns the number of rows removed.
|
||||
*/
|
||||
export async function pruneStaleFreeProxies(
|
||||
source: FreeProxySourceId,
|
||||
activeKeys: ReadonlySet<string>
|
||||
): Promise<number> {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare("SELECT id, host, port FROM free_proxies WHERE source = ? AND in_pool = 0")
|
||||
.all(source) as Array<{ id: string; host: string; port: number }>;
|
||||
|
||||
const staleIds = rows.filter((r) => !activeKeys.has(`${r.host}:${r.port}`)).map((r) => r.id);
|
||||
|
||||
if (staleIds.length === 0) return 0;
|
||||
|
||||
const placeholders = staleIds.map(() => "?").join(",");
|
||||
const result = db
|
||||
.prepare(`DELETE FROM free_proxies WHERE id IN (${placeholders})`)
|
||||
.run(...staleIds);
|
||||
backupDbFile("pre-write");
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
// #4878: the displayed "last sync" used to be derived from MAX(last_validated),
|
||||
// which only advances when a provider returns at least one new/updated proxy. A
|
||||
// sync that returns zero rows (or whose providers all fail) left the timestamp
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { FreeProxyProvider, FreeProxySourceId } from "./types";
|
||||
import { OneproxyProvider } from "./oneproxy";
|
||||
import { ProxiflyProvider } from "./proxifly";
|
||||
import { IplocateProvider } from "./iplocate";
|
||||
import { WebshareProvider } from "./webshare";
|
||||
|
||||
const ALL_PROVIDERS: FreeProxyProvider[] = [
|
||||
new OneproxyProvider(),
|
||||
new ProxiflyProvider(),
|
||||
new IplocateProvider(),
|
||||
new WebshareProvider(),
|
||||
];
|
||||
|
||||
export function getProvider(id: FreeProxySourceId): FreeProxyProvider | undefined {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type FreeProxySourceId = "1proxy" | "proxifly" | "iplocate";
|
||||
export type FreeProxySourceId = "1proxy" | "proxifly" | "iplocate" | "webshare";
|
||||
|
||||
export interface FreeProxyItem {
|
||||
source: FreeProxySourceId;
|
||||
|
||||
148
src/lib/freeProxyProviders/webshare.ts
Normal file
148
src/lib/freeProxyProviders/webshare.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { FreeProxyItem, FreeProxySyncResult, FreeProxyProvider } from "./types";
|
||||
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
|
||||
|
||||
const DEFAULT_API_URL = "https://proxy.webshare.io/api/v2/proxy/list/";
|
||||
const DEFAULT_MAX = 500;
|
||||
const DEFAULT_PAGE_SIZE = 100;
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const MAX_PAGES = 50; // hard stop so a misbehaving `next` cursor can't loop forever
|
||||
|
||||
type WebshareApiProxy = {
|
||||
proxy_address?: string;
|
||||
port?: number;
|
||||
valid?: boolean;
|
||||
country_code?: string | null;
|
||||
last_verification?: string | null;
|
||||
};
|
||||
|
||||
type WebshareApiResponse = {
|
||||
count?: number;
|
||||
next?: string | null;
|
||||
previous?: string | null;
|
||||
results?: WebshareApiProxy[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Webshare (https://proxy.webshare.io) proxy pool — imports the operator's
|
||||
* purchased/rotating proxy list via the account API. Unlike the other
|
||||
* free-proxy sources this is a paid, per-account list, so it is gated on an
|
||||
* API key rather than a plain on/off flag: `isEnabled()` returns false
|
||||
* whenever no key is configured, regardless of `FREE_PROXY_WEBSHARE_ENABLED`.
|
||||
*/
|
||||
export class WebshareProvider implements FreeProxyProvider {
|
||||
readonly id = "webshare" as const;
|
||||
readonly name = "Webshare";
|
||||
|
||||
isEnabled(): boolean {
|
||||
if (process.env.FREE_PROXY_WEBSHARE_ENABLED === "false") return false;
|
||||
return Boolean(process.env.FREE_PROXY_WEBSHARE_API_KEY);
|
||||
}
|
||||
|
||||
private getConfig() {
|
||||
return {
|
||||
apiUrl: process.env.FREE_PROXY_WEBSHARE_API_URL || DEFAULT_API_URL,
|
||||
apiKey: process.env.FREE_PROXY_WEBSHARE_API_KEY || "",
|
||||
maxProxies: parseInt(process.env.FREE_PROXY_WEBSHARE_MAX || "", 10) || DEFAULT_MAX,
|
||||
};
|
||||
}
|
||||
|
||||
async sync(): Promise<FreeProxySyncResult> {
|
||||
if (!this.isEnabled()) {
|
||||
return {
|
||||
fetched: 0,
|
||||
added: 0,
|
||||
updated: 0,
|
||||
errors: ["Webshare provider disabled (set FREE_PROXY_WEBSHARE_API_KEY to enable)"],
|
||||
};
|
||||
}
|
||||
|
||||
const { upsertFreeProxy } = await import("../db/freeProxies");
|
||||
const { pruneStaleFreeProxies } = await import("../db/freeProxies");
|
||||
const { apiUrl, apiKey, maxProxies } = this.getConfig();
|
||||
|
||||
const errors: string[] = [];
|
||||
const activeKeys = new Set<string>();
|
||||
let added = 0;
|
||||
let updated = 0;
|
||||
let fetched = 0;
|
||||
let page = 1;
|
||||
|
||||
try {
|
||||
while (fetched < maxProxies && page <= MAX_PAGES) {
|
||||
const url = new URL(apiUrl);
|
||||
url.searchParams.set("mode", "direct");
|
||||
url.searchParams.set("page", String(page));
|
||||
url.searchParams.set("page_size", String(Math.min(DEFAULT_PAGE_SIZE, maxProxies - fetched)));
|
||||
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
headers: { Authorization: `Token ${apiKey}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Response bodies never contain the request's own Authorization
|
||||
// header, so this is safe to surface verbatim (truncated) without
|
||||
// leaking the API key.
|
||||
const text = await res.text().catch(() => "");
|
||||
errors.push(`HTTP ${res.status}: ${text.slice(0, 100)}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as WebshareApiResponse;
|
||||
const results = Array.isArray(json.results) ? json.results : [];
|
||||
if (results.length === 0) break;
|
||||
|
||||
for (const p of results) {
|
||||
if (!p.proxy_address || !p.port) continue;
|
||||
if (isPrivateHost(p.proxy_address)) {
|
||||
errors.push(`Webshare: skipped private/loopback host ${p.proxy_address}`);
|
||||
continue;
|
||||
}
|
||||
if (p.valid === false) continue;
|
||||
|
||||
activeKeys.add(`${p.proxy_address}:${p.port}`);
|
||||
|
||||
const item: FreeProxyItem = {
|
||||
source: "webshare",
|
||||
host: p.proxy_address,
|
||||
port: p.port,
|
||||
type: "http",
|
||||
countryCode: p.country_code || null,
|
||||
qualityScore: null,
|
||||
latencyMs: null,
|
||||
anonymity: null,
|
||||
lastValidated: p.last_verification || new Date().toISOString(),
|
||||
};
|
||||
const result = await upsertFreeProxy(item);
|
||||
if (result.action === "created") added++;
|
||||
else updated++;
|
||||
fetched++;
|
||||
}
|
||||
|
||||
if (!json.next) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
// Tombstone rows this account no longer lists (recycled/retired IDs).
|
||||
// Only run this when the sync completed without a hard error, so a
|
||||
// failed fetch never wipes out a previously-good candidate list.
|
||||
if (errors.length === 0 && fetched > 0) {
|
||||
await pruneStaleFreeProxies("webshare", activeKeys);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
return { fetched, added, updated, errors };
|
||||
}
|
||||
|
||||
async list(filters: {
|
||||
protocol?: string;
|
||||
country?: string;
|
||||
minQuality?: number;
|
||||
limit?: number;
|
||||
}): Promise<FreeProxyItem[]> {
|
||||
const { listFreeProxiesBySource } = await import("../db/freeProxies");
|
||||
return listFreeProxiesBySource("webshare", filters);
|
||||
}
|
||||
}
|
||||
@@ -557,6 +557,7 @@ export {
|
||||
promoteFreeProxyToPool,
|
||||
deleteFreeProxy,
|
||||
clearFreeProxiesBySource,
|
||||
pruneStaleFreeProxies,
|
||||
getFreeProxyStats,
|
||||
recordFreeProxySync,
|
||||
} from "./db/freeProxies";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const freeProxySourceSchema = z.enum(["1proxy", "proxifly", "iplocate"]);
|
||||
export const freeProxySourceSchema = z.enum(["1proxy", "proxifly", "iplocate", "webshare"]);
|
||||
|
||||
export const freeProxyListSchema = z.object({
|
||||
sources: z
|
||||
|
||||
@@ -29,13 +29,14 @@ test.after(() => {
|
||||
|
||||
// ── Registry ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test("getAllProviders returns exactly 3 providers", () => {
|
||||
test("getAllProviders returns exactly 4 providers", () => {
|
||||
const providers = getAllProviders();
|
||||
assert.equal(providers.length, 3);
|
||||
assert.equal(providers.length, 4);
|
||||
const ids = providers.map((p) => p.id);
|
||||
assert.ok(ids.includes("1proxy"));
|
||||
assert.ok(ids.includes("proxifly"));
|
||||
assert.ok(ids.includes("iplocate"));
|
||||
assert.ok(ids.includes("webshare"));
|
||||
});
|
||||
|
||||
test("getProvider returns the correct provider by id", () => {
|
||||
|
||||
367
tests/unit/webshare-sync.test.ts
Normal file
367
tests/unit/webshare-sync.test.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-webshare-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
// Providers read process.env at call-time, so we can set flags before import.
|
||||
// Keep the other free-proxy sources disabled so this file only exercises Webshare.
|
||||
process.env.FREE_PROXY_1PROXY_ENABLED = "false";
|
||||
process.env.FREE_PROXY_PROXIFLY_ENABLED = "false";
|
||||
process.env.FREE_PROXY_IPLOCATE_ENABLED = "false";
|
||||
|
||||
const FAKE_API_KEY = "wsk_test_super_secret_token_1234567890";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { getProvider } = await import("../../src/lib/freeProxyProviders/index.ts");
|
||||
const freeProxiesDb = await import("../../src/lib/db/freeProxies.ts");
|
||||
|
||||
async function reset() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function webshareResponse(results: unknown[], next: string | null = null) {
|
||||
return new Response(JSON.stringify({ count: results.length, next, previous: null, results }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// ── isEnabled ────────────────────────────────────────────────────────────────
|
||||
|
||||
test("WebshareProvider.isEnabled is false without an API key", () => {
|
||||
const originalKey = process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
delete process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
|
||||
const p = getProvider("webshare")!;
|
||||
assert.equal(p.isEnabled(), false);
|
||||
|
||||
if (originalKey !== undefined) process.env.FREE_PROXY_WEBSHARE_API_KEY = originalKey;
|
||||
});
|
||||
|
||||
test("WebshareProvider.isEnabled is true once an API key is configured", () => {
|
||||
const originalKey = process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = FAKE_API_KEY;
|
||||
|
||||
const p = getProvider("webshare")!;
|
||||
assert.equal(p.isEnabled(), true);
|
||||
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = originalKey ?? "";
|
||||
if (originalKey === undefined) delete process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
});
|
||||
|
||||
test("WebshareProvider.isEnabled is false when explicitly disabled, even with a key", () => {
|
||||
const originalKey = process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
const originalEnabled = process.env.FREE_PROXY_WEBSHARE_ENABLED;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = FAKE_API_KEY;
|
||||
process.env.FREE_PROXY_WEBSHARE_ENABLED = "false";
|
||||
|
||||
const p = getProvider("webshare")!;
|
||||
assert.equal(p.isEnabled(), false);
|
||||
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = originalKey ?? "";
|
||||
if (originalKey === undefined) delete process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
process.env.FREE_PROXY_WEBSHARE_ENABLED = originalEnabled ?? "";
|
||||
if (originalEnabled === undefined) delete process.env.FREE_PROXY_WEBSHARE_ENABLED;
|
||||
});
|
||||
|
||||
// ── sync — disabled path ────────────────────────────────────────────────────
|
||||
|
||||
test("WebshareProvider.sync returns a disabled error (no key leak) when no API key is set", async () => {
|
||||
const originalKey = process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
delete process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
await reset();
|
||||
|
||||
const p = getProvider("webshare")!;
|
||||
const result = await p.sync();
|
||||
|
||||
assert.equal(result.fetched, 0);
|
||||
assert.ok(result.errors.length > 0);
|
||||
assert.ok(result.errors[0].includes("disabled"));
|
||||
|
||||
if (originalKey !== undefined) process.env.FREE_PROXY_WEBSHARE_API_KEY = originalKey;
|
||||
});
|
||||
|
||||
// ── sync — pagination + upsert ──────────────────────────────────────────────
|
||||
|
||||
test("WebshareProvider.sync paginates via `next` and upserts proxies", async () => {
|
||||
await reset();
|
||||
const originalKey = process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
const originalFetch = globalThis.fetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = FAKE_API_KEY;
|
||||
|
||||
const seenAuthHeaders: string[] = [];
|
||||
const seenPages: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(String(input));
|
||||
seenPages.push(url.searchParams.get("page") || "");
|
||||
const headers = new Headers(init?.headers);
|
||||
seenAuthHeaders.push(headers.get("authorization") || "");
|
||||
|
||||
const page = url.searchParams.get("page");
|
||||
if (page === "1") {
|
||||
return webshareResponse(
|
||||
[
|
||||
{
|
||||
proxy_address: "45.10.20.1",
|
||||
port: 6001,
|
||||
valid: true,
|
||||
country_code: "US",
|
||||
last_verification: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
proxy_address: "45.10.20.2",
|
||||
port: 6002,
|
||||
valid: true,
|
||||
country_code: "DE",
|
||||
last_verification: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
"https://proxy.webshare.io/api/v2/proxy/list/?page=2"
|
||||
);
|
||||
}
|
||||
return webshareResponse(
|
||||
[
|
||||
{
|
||||
proxy_address: "45.10.20.3",
|
||||
port: 6003,
|
||||
valid: true,
|
||||
country_code: "FR",
|
||||
last_verification: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
null
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const p = getProvider("webshare")!;
|
||||
const result = await p.sync();
|
||||
|
||||
assert.deepEqual(seenPages, ["1", "2"]);
|
||||
assert.ok(
|
||||
seenAuthHeaders.every((h) => h === `Token ${FAKE_API_KEY}`),
|
||||
"every request must carry the Webshare Authorization token"
|
||||
);
|
||||
assert.equal(result.fetched, 3);
|
||||
assert.equal(result.added, 3);
|
||||
assert.equal(result.updated, 0);
|
||||
assert.deepEqual(result.errors, []);
|
||||
|
||||
const items = await p.list({ limit: 10 });
|
||||
assert.equal(items.length, 3);
|
||||
assert.ok(items.every((item) => item.source === "webshare"));
|
||||
assert.ok(items.some((item) => item.host === "45.10.20.1" && item.port === 6001));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = originalKey ?? "";
|
||||
if (originalKey === undefined) delete process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
test("WebshareProvider.sync updates existing rows instead of duplicating them", async () => {
|
||||
await reset();
|
||||
const originalKey = process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
const originalFetch = globalThis.fetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = FAKE_API_KEY;
|
||||
|
||||
let call = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
call++;
|
||||
return webshareResponse([
|
||||
{
|
||||
proxy_address: "45.10.20.1",
|
||||
port: 6001,
|
||||
valid: true,
|
||||
country_code: "US",
|
||||
last_verification: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const p = getProvider("webshare")!;
|
||||
const first = await p.sync();
|
||||
const second = await p.sync();
|
||||
|
||||
assert.equal(first.added, 1);
|
||||
assert.equal(second.added, 0);
|
||||
assert.equal(second.updated, 1);
|
||||
assert.equal(call, 2);
|
||||
|
||||
const items = await p.list({ limit: 10 });
|
||||
assert.equal(items.length, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = originalKey ?? "";
|
||||
if (originalKey === undefined) delete process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
// ── sync — tombstone ─────────────────────────────────────────────────────────
|
||||
|
||||
test("WebshareProvider.sync tombstones proxies no longer returned by the account list", async () => {
|
||||
await reset();
|
||||
const originalKey = process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
const originalFetch = globalThis.fetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = FAKE_API_KEY;
|
||||
|
||||
let responses: unknown[][] = [
|
||||
[
|
||||
{ proxy_address: "45.10.20.1", port: 6001, valid: true, country_code: "US" },
|
||||
{ proxy_address: "45.10.20.2", port: 6002, valid: true, country_code: "DE" },
|
||||
],
|
||||
];
|
||||
let callIndex = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
const batch = responses[callIndex] ?? [];
|
||||
callIndex++;
|
||||
return webshareResponse(batch);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const p = getProvider("webshare")!;
|
||||
await p.sync();
|
||||
let items = await p.list({ limit: 10 });
|
||||
assert.equal(items.length, 2);
|
||||
|
||||
// Second sync: the account list now only has 45.10.20.1 — .2 was retired.
|
||||
responses = [[{ proxy_address: "45.10.20.1", port: 6001, valid: true, country_code: "US" }]];
|
||||
callIndex = 0;
|
||||
await p.sync();
|
||||
|
||||
items = await p.list({ limit: 10 });
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].host, "45.10.20.1");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = originalKey ?? "";
|
||||
if (originalKey === undefined) delete process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
test("WebshareProvider.sync never tombstones proxies already promoted to the pool", async () => {
|
||||
await reset();
|
||||
const originalKey = process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
const originalFetch = globalThis.fetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = FAKE_API_KEY;
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
webshareResponse([
|
||||
{ proxy_address: "45.10.20.1", port: 6001, valid: true, country_code: "US" },
|
||||
])) as typeof fetch;
|
||||
|
||||
try {
|
||||
const p = getProvider("webshare")!;
|
||||
await p.sync();
|
||||
const [item] = await freeProxiesDb.listFreeProxies({ sources: ["webshare"] });
|
||||
assert.ok(item);
|
||||
await freeProxiesDb.markFreeProxyInPool(item.id, "some-registry-id");
|
||||
|
||||
// Next sync returns an empty list — if pruning ignored `in_pool`, this row
|
||||
// would be deleted even though it's actively in use by the proxy pool.
|
||||
globalThis.fetch = (async () => webshareResponse([])) as typeof fetch;
|
||||
await p.sync();
|
||||
|
||||
const stillThere = await freeProxiesDb.getFreeProxyById(item.id);
|
||||
assert.ok(stillThere, "in-pool proxy must survive a sync where it is no longer listed");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = originalKey ?? "";
|
||||
if (originalKey === undefined) delete process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
// ── sync — error sanitization / key masking ─────────────────────────────────
|
||||
|
||||
test("WebshareProvider.sync never leaks the API key in error messages on an HTTP failure", async () => {
|
||||
await reset();
|
||||
const originalKey = process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
const originalFetch = globalThis.fetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = FAKE_API_KEY;
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("Unauthorized", { status: 401 })) as typeof fetch;
|
||||
|
||||
try {
|
||||
const p = getProvider("webshare")!;
|
||||
const result = await p.sync();
|
||||
|
||||
assert.equal(result.fetched, 0);
|
||||
assert.ok(result.errors.length > 0);
|
||||
for (const err of result.errors) {
|
||||
assert.ok(!err.includes(FAKE_API_KEY), `error must not leak the API key: ${err}`);
|
||||
assert.ok(!err.toLowerCase().includes("authorization"), `error must not leak the auth header: ${err}`);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = originalKey ?? "";
|
||||
if (originalKey === undefined) delete process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
test("WebshareProvider.sync never leaks the API key in error messages on a network exception", async () => {
|
||||
await reset();
|
||||
const originalKey = process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
const originalFetch = globalThis.fetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = FAKE_API_KEY;
|
||||
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error(`connect ECONNREFUSED (key was ${FAKE_API_KEY})`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const p = getProvider("webshare")!;
|
||||
const result = await p.sync();
|
||||
|
||||
// The provider itself must not additionally embed the key; it only
|
||||
// forwards err.message. This guards the call site never re-injects the
|
||||
// key into a wrapper string.
|
||||
assert.equal(result.fetched, 0);
|
||||
assert.ok(result.errors.length > 0);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = originalKey ?? "";
|
||||
if (originalKey === undefined) delete process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
test("WebshareProvider.sync skips private/loopback hosts", async () => {
|
||||
await reset();
|
||||
const originalKey = process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
const originalFetch = globalThis.fetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = FAKE_API_KEY;
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
webshareResponse([
|
||||
{ proxy_address: "127.0.0.1", port: 6001, valid: true, country_code: "US" },
|
||||
{ proxy_address: "45.10.20.9", port: 6009, valid: true, country_code: "US" },
|
||||
])) as typeof fetch;
|
||||
|
||||
try {
|
||||
const p = getProvider("webshare")!;
|
||||
const result = await p.sync();
|
||||
|
||||
assert.equal(result.fetched, 1);
|
||||
assert.ok(result.errors.some((e) => e.includes("private/loopback")));
|
||||
|
||||
const items = await p.list({ limit: 10 });
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].host, "45.10.20.9");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.FREE_PROXY_WEBSHARE_API_KEY = originalKey ?? "";
|
||||
if (originalKey === undefined) delete process.env.FREE_PROXY_WEBSHARE_API_KEY;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user