mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
fix: Proxy does not support formats from proxy providers #307
This commit is contained in:
@@ -20,6 +20,7 @@ use crate::error::code::ErrorCode;
|
|||||||
use crate::error::BichonResult;
|
use crate::error::BichonResult;
|
||||||
use crate::oauth2::{entity::OAuth2, pending::OAuth2PendingEntity, token::OAuth2AccessToken};
|
use crate::oauth2::{entity::OAuth2, pending::OAuth2PendingEntity, token::OAuth2AccessToken};
|
||||||
use crate::settings::proxy::Proxy;
|
use crate::settings::proxy::Proxy;
|
||||||
|
use crate::utils::net::parse_proxy_url;
|
||||||
use crate::{decrypt, encrypt, raise_error};
|
use crate::{decrypt, encrypt, raise_error};
|
||||||
use oauth2::{
|
use oauth2::{
|
||||||
basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken,
|
basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken,
|
||||||
@@ -265,13 +266,30 @@ impl OAuth2Flow {
|
|||||||
fn build_http_client(use_proxy: Option<u64>) -> BichonResult<reqwest::Client> {
|
fn build_http_client(use_proxy: Option<u64>) -> BichonResult<reqwest::Client> {
|
||||||
if let Some(proxy_id) = use_proxy {
|
if let Some(proxy_id) = use_proxy {
|
||||||
let proxy = Proxy::get(proxy_id)?;
|
let proxy = Proxy::get(proxy_id)?;
|
||||||
|
// Normalize the URL: reqwest only understands standard format user:pass@host:port.
|
||||||
|
// Our parse_proxy_url handles both standard and non-standard (host:port:user:pass).
|
||||||
|
let proxy_url = match parse_proxy_url(&proxy.url) {
|
||||||
|
Ok(addr) => {
|
||||||
|
if let (Some(user), Some(pass)) = (&addr.username, &addr.password) {
|
||||||
|
format!("socks5://{}:{}@{}:{}", user, pass, addr.host, addr.port)
|
||||||
|
} else if let Some(user) = &addr.username {
|
||||||
|
format!("socks5://{}@{}:{}", user, addr.host, addr.port)
|
||||||
|
} else {
|
||||||
|
format!("socks5://{}:{}", addr.host, addr.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Fallback: pass through as-is for backward compatibility
|
||||||
|
proxy.url.clone()
|
||||||
|
}
|
||||||
|
};
|
||||||
return oauth2::reqwest::ClientBuilder::new()
|
return oauth2::reqwest::ClientBuilder::new()
|
||||||
.redirect(oauth2::reqwest::redirect::Policy::none())
|
.redirect(oauth2::reqwest::redirect::Policy::none())
|
||||||
.proxy(reqwest::Proxy::all(&proxy.url).map_err(|e| {
|
.proxy(reqwest::Proxy::all(&proxy_url).map_err(|e| {
|
||||||
raise_error!(
|
raise_error!(
|
||||||
format!(
|
format!(
|
||||||
"Failed to configure SOCKS5 proxy ({}): {:#?}. Please check",
|
"Failed to configure SOCKS5 proxy ({}): {:#?}. Please check",
|
||||||
&proxy.url, e
|
&proxy_url, e
|
||||||
),
|
),
|
||||||
ErrorCode::InternalError
|
ErrorCode::InternalError
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
error::{code::ErrorCode, BichonResult},
|
error::{code::ErrorCode, BichonResult},
|
||||||
id, raise_error, utc_now,
|
id, raise_error, utc_now,
|
||||||
utils::net::parse_proxy_addr,
|
utils::net::parse_proxy_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -98,9 +98,9 @@ impl Proxy {
|
|||||||
insert_impl(DB_MANAGER.db(), self.to_owned())
|
insert_impl(DB_MANAGER.db(), self.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate that the URL is a valid SOCKS5 proxy URL.
|
/// Validate that the URL is a valid proxy URL.
|
||||||
pub fn validate(&self) -> BichonResult<()> {
|
pub fn validate(&self) -> BichonResult<()> {
|
||||||
parse_proxy_addr(&self.url)?;
|
parse_proxy_url(&self.url)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_valid_proxy_urls() {
|
fn test_valid_proxy_urls() {
|
||||||
let urls = vec!["socks5://127.0.0.1:1080", "http://127.0.0.1:8080"];
|
let urls = vec![
|
||||||
|
"socks5://127.0.0.1:1080",
|
||||||
|
"http://127.0.0.1:8080",
|
||||||
|
"socks5://proxy.example.com:1080",
|
||||||
|
"socks5://user:pass@proxy.example.com:1080",
|
||||||
|
"socks5://user@proxy.example.com:1080",
|
||||||
|
// Non-standard format: host:port:user:pass
|
||||||
|
"socks5://server.nodeprovider.com:8080:username123:passwordhere",
|
||||||
|
];
|
||||||
|
|
||||||
for url in urls {
|
for url in urls {
|
||||||
let proxy = Proxy::new(url.to_string());
|
let proxy = Proxy::new(url.to_string());
|
||||||
|
|||||||
+198
-18
@@ -32,6 +32,15 @@ use tracing::error;
|
|||||||
|
|
||||||
pub(crate) const TIMEOUT: Duration = Duration::from_secs(30);
|
pub(crate) const TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Parsed proxy address components.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProxyAddr {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn establish_tcp_connection_with_timeout(
|
pub(crate) async fn establish_tcp_connection_with_timeout(
|
||||||
address: SocketAddr,
|
address: SocketAddr,
|
||||||
use_proxy: Option<u64>,
|
use_proxy: Option<u64>,
|
||||||
@@ -66,20 +75,27 @@ pub async fn establish_tls_connection(
|
|||||||
Ok(tls_stream)
|
Ok(tls_stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_proxy_addr(input: &str) -> BichonResult<SocketAddr> {
|
/// Parse a proxy URL into its components.
|
||||||
// Normalize and check protocol prefix
|
///
|
||||||
let (scheme, stripped) = if let Some(rest) = input
|
/// Supports two formats:
|
||||||
|
/// - **Standard**: `[scheme://][user:pass@]host:port`
|
||||||
|
/// - **Non-standard** (some proxy providers): `[scheme://]host:port:username:password`
|
||||||
|
///
|
||||||
|
/// The distinguishing feature is the `@` sign in the standard format.
|
||||||
|
pub fn parse_proxy_url(input: &str) -> BichonResult<ProxyAddr> {
|
||||||
|
// Normalize and strip scheme prefix
|
||||||
|
let stripped = if let Some(rest) = input
|
||||||
.strip_prefix("socks5://")
|
.strip_prefix("socks5://")
|
||||||
.or_else(|| input.strip_prefix("SOCKS5://"))
|
.or_else(|| input.strip_prefix("SOCKS5://"))
|
||||||
.or_else(|| input.strip_prefix("Socks5://"))
|
.or_else(|| input.strip_prefix("Socks5://"))
|
||||||
{
|
{
|
||||||
("socks5", rest)
|
rest
|
||||||
} else if let Some(rest) = input
|
} else if let Some(rest) = input
|
||||||
.strip_prefix("http://")
|
.strip_prefix("http://")
|
||||||
.or_else(|| input.strip_prefix("HTTP://"))
|
.or_else(|| input.strip_prefix("HTTP://"))
|
||||||
.or_else(|| input.strip_prefix("Http://"))
|
.or_else(|| input.strip_prefix("Http://"))
|
||||||
{
|
{
|
||||||
("http", rest)
|
rest
|
||||||
} else {
|
} else {
|
||||||
return Err(raise_error!(
|
return Err(raise_error!(
|
||||||
format!(
|
format!(
|
||||||
@@ -90,43 +106,207 @@ pub fn parse_proxy_addr(input: &str) -> BichonResult<SocketAddr> {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse the remaining address
|
if stripped.is_empty() {
|
||||||
let addr = stripped.parse::<SocketAddr>().map_err(|e| {
|
return Err(raise_error!(
|
||||||
|
"Proxy URL has empty address after scheme.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for standard format: user:pass@host:port
|
||||||
|
if let Some(at_pos) = stripped.rfind('@') {
|
||||||
|
let userinfo = &stripped[..at_pos];
|
||||||
|
let hostport = &stripped[at_pos + 1..];
|
||||||
|
|
||||||
|
let (username, password) = split_userinfo(userinfo)?;
|
||||||
|
let (host, port) = split_hostport(hostport)?;
|
||||||
|
|
||||||
|
return Ok(ProxyAddr {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// No '@' — check for non-standard format: host:port:user:pass
|
||||||
|
let parts: Vec<&str> = stripped.rsplitn(4, ':').collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>();
|
||||||
|
|
||||||
|
match parts.len() {
|
||||||
|
2 => {
|
||||||
|
// host:port, no auth
|
||||||
|
let (host, port) = split_hostport(stripped)?;
|
||||||
|
Ok(ProxyAddr {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username: None,
|
||||||
|
password: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
4 => {
|
||||||
|
// Non-standard: host:port:username:password
|
||||||
|
let host = parts[0].to_string();
|
||||||
|
let port = parts[1]
|
||||||
|
.parse::<u16>()
|
||||||
|
.map_err(|_| {
|
||||||
raise_error!(
|
raise_error!(
|
||||||
|
format!("Invalid port '{}' in proxy URL.", parts[1]),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let username = parts[2].to_string();
|
||||||
|
let password = parts[3].to_string();
|
||||||
|
|
||||||
|
if host.is_empty() {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Empty hostname in proxy URL.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if username.is_empty() {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Empty username in proxy URL.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Empty password in proxy URL.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ProxyAddr {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username: Some(username),
|
||||||
|
password: Some(password),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Err(raise_error!(
|
||||||
format!(
|
format!(
|
||||||
"Failed to parse {} proxy address '{}': {}",
|
"Invalid proxy URL format '{}'. Expected '[scheme://][user:pass@]host:port' or 'scheme://host:port:user:pass'.",
|
||||||
scheme, stripped, e
|
input
|
||||||
),
|
),
|
||||||
ErrorCode::InvalidParameter
|
ErrorCode::InvalidParameter
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split "user:pass" into (Some(user), Some(pass)), or "user" into (Some(user), None).
|
||||||
|
fn split_userinfo(userinfo: &str) -> BichonResult<(Option<String>, Option<String>)> {
|
||||||
|
if userinfo.is_empty() {
|
||||||
|
return Ok((None, None));
|
||||||
|
}
|
||||||
|
if let Some(colon_pos) = userinfo.find(':') {
|
||||||
|
let user = &userinfo[..colon_pos];
|
||||||
|
let pass = &userinfo[colon_pos + 1..];
|
||||||
|
if user.is_empty() {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Empty username in proxy URL credentials.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((Some(user.to_string()), Some(pass.to_string())))
|
||||||
|
} else {
|
||||||
|
Ok((Some(userinfo.to_string()), None))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split "host:port" into (host, port). Handles IPv6 addresses in brackets.
|
||||||
|
fn split_hostport(hostport: &str) -> BichonResult<(String, u16)> {
|
||||||
|
if hostport.is_empty() {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Empty host:port in proxy URL.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6: [::1]:1080
|
||||||
|
if hostport.starts_with('[') {
|
||||||
|
let close_bracket = hostport.find(']').ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!("Invalid IPv6 address in proxy URL: '{}'.", hostport),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let host = hostport[1..close_bracket].to_string();
|
||||||
|
let after_bracket = &hostport[close_bracket + 1..];
|
||||||
|
if !after_bracket.starts_with(':') {
|
||||||
|
return Err(raise_error!(
|
||||||
|
format!("Missing port after IPv6 address in proxy URL: '{}'.", hostport),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let port = after_bracket[1..].parse::<u16>().map_err(|_| {
|
||||||
|
raise_error!(
|
||||||
|
format!("Invalid port in proxy URL: '{}'.", hostport),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
return Ok((host, port));
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostname:port or ip:port — split from right
|
||||||
|
let last_colon = hostport.rfind(':').ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!("Missing port in proxy URL: '{}'.", hostport),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let host = hostport[..last_colon].to_string();
|
||||||
|
let port = hostport[last_colon + 1..].parse::<u16>().map_err(|_| {
|
||||||
|
raise_error!(
|
||||||
|
format!("Invalid port in proxy URL: '{}'.", hostport),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(addr)
|
if host.is_empty() {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Empty hostname in proxy URL.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((host, port))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to connect via SOCKS5 proxy or TCP with timeout
|
/// Try to connect via SOCKS5 proxy or TCP with timeout.
|
||||||
async fn connect_with_optional_proxy(
|
async fn connect_with_optional_proxy(
|
||||||
use_proxy: Option<u64>,
|
use_proxy: Option<u64>,
|
||||||
address: SocketAddr,
|
address: SocketAddr,
|
||||||
) -> BichonResult<TcpStream> {
|
) -> BichonResult<TcpStream> {
|
||||||
// Try if proxy is enabled
|
|
||||||
if let Some(proxy_id) = use_proxy {
|
if let Some(proxy_id) = use_proxy {
|
||||||
let proxy = Proxy::get(proxy_id)?;
|
let proxy = Proxy::get(proxy_id)?;
|
||||||
let proxy = parse_proxy_addr(&proxy.url)?;
|
let addr = parse_proxy_url(&proxy.url)?;
|
||||||
return timeout(TIMEOUT, Socks5Stream::connect(proxy, address))
|
let proxy_addr = (addr.host.as_str(), addr.port);
|
||||||
|
|
||||||
|
let result = if let (Some(ref user), Some(ref pass)) = (addr.username, addr.password) {
|
||||||
|
timeout(
|
||||||
|
TIMEOUT,
|
||||||
|
Socks5Stream::connect_with_password(proxy_addr, address, user.as_str(), pass.as_str()),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
|
} else {
|
||||||
|
timeout(TIMEOUT, Socks5Stream::connect(proxy_addr, address)).await
|
||||||
|
};
|
||||||
|
|
||||||
|
return result
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
error!(
|
error!(
|
||||||
"SOCKS5 proxy connection to {} via {} timed out after {}s",
|
"SOCKS5 proxy connection to {} via {}:{} timed out after {}s",
|
||||||
address,
|
address,
|
||||||
proxy,
|
addr.host,
|
||||||
|
addr.port,
|
||||||
TIMEOUT.as_secs()
|
TIMEOUT.as_secs()
|
||||||
);
|
);
|
||||||
raise_error!(
|
raise_error!(
|
||||||
format!(
|
format!(
|
||||||
"SOCKS5 proxy connection to {} via {} timed out after {}s",
|
"SOCKS5 proxy connection to {} via {}:{} timed out after {}s",
|
||||||
address,
|
address,
|
||||||
proxy,
|
addr.host,
|
||||||
|
addr.port,
|
||||||
TIMEOUT.as_secs()
|
TIMEOUT.as_secs()
|
||||||
),
|
),
|
||||||
ErrorCode::ConnectionTimeout
|
ErrorCode::ConnectionTimeout
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe('Proxy Form Schema', () => {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
expect(
|
expect(
|
||||||
result.error.issues.some((i) =>
|
result.error.issues.some((i) =>
|
||||||
i.message?.includes('http:// or socks5://')
|
i.message?.includes('Invalid format')
|
||||||
)
|
)
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ describe('Proxy Form Schema', () => {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
expect(
|
expect(
|
||||||
result.error.issues.some((i) =>
|
result.error.issues.some((i) =>
|
||||||
i.message?.includes('Invalid URL format')
|
i.message?.includes('Invalid format')
|
||||||
)
|
)
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
}
|
}
|
||||||
@@ -170,4 +170,27 @@ describe('Proxy Form Schema', () => {
|
|||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('url field - non-standard format (host:port:user:pass)', () => {
|
||||||
|
it('accepts non-standard format with auth', () => {
|
||||||
|
const result = proxyFormSchema.safeParse({
|
||||||
|
url: 'socks5://server.nodeprovider.com:8080:nodeprovider_a1234_alias_com-country-us-region-california-sid-b123123123-filter-medium:passwordhere',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts simple non-standard format', () => {
|
||||||
|
const result = proxyFormSchema.safeParse({
|
||||||
|
url: 'socks5://proxy.example.com:1080:myuser:mypassword',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-standard format without password', () => {
|
||||||
|
const result = proxyFormSchema.safeParse({
|
||||||
|
url: 'socks5://proxy.example.com:1080:myuser',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const getColumns = (t: (key: string) => string): ColumnDef<Proxy>[] => [
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return <LongText>{row.original.url}</LongText>
|
return <LongText>{row.original.url}</LongText>
|
||||||
},
|
},
|
||||||
meta: { className: 'w-60' },
|
meta: { className: 'max-w-60' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'created_at',
|
accessorKey: 'created_at',
|
||||||
|
|||||||
@@ -1,63 +1,175 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
// Parse a proxy URL into components. Supports two formats:
|
||||||
|
// Standard: socks5://[user:pass@]host:port
|
||||||
|
// Non-standard: socks5://host:port:user:pass (some proxy providers)
|
||||||
|
function parseProxyUrl(value: string): {
|
||||||
|
scheme: string
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
} | null {
|
||||||
|
// Strip scheme
|
||||||
|
let stripped: string
|
||||||
|
let scheme: string
|
||||||
|
const lower = value.toLowerCase()
|
||||||
|
if (lower.startsWith('socks5://')) {
|
||||||
|
scheme = 'socks5'
|
||||||
|
stripped = value.slice('socks5://'.length)
|
||||||
|
} else if (lower.startsWith('http://')) {
|
||||||
|
scheme = 'http'
|
||||||
|
stripped = value.slice('http://'.length)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stripped) return null
|
||||||
|
|
||||||
|
// Standard format: user:pass@host:port
|
||||||
|
const atIdx = stripped.lastIndexOf('@')
|
||||||
|
if (atIdx >= 0) {
|
||||||
|
const userinfo = stripped.slice(0, atIdx)
|
||||||
|
const hostport = stripped.slice(atIdx + 1)
|
||||||
|
|
||||||
|
// Parse userinfo
|
||||||
|
let username: string | undefined
|
||||||
|
let password: string | undefined
|
||||||
|
if (userinfo) {
|
||||||
|
const colonIdx = userinfo.indexOf(':')
|
||||||
|
if (colonIdx >= 0) {
|
||||||
|
username = userinfo.slice(0, colonIdx)
|
||||||
|
password = userinfo.slice(colonIdx + 1)
|
||||||
|
} else {
|
||||||
|
username = userinfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse host:port
|
||||||
|
const { host, port } = splitHostPort(hostport)
|
||||||
|
if (!host || !port) return null
|
||||||
|
|
||||||
|
return { scheme, host, port, username, password }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-standard format: host:port[:user[:pass]]
|
||||||
|
const parts = stripped.split(':')
|
||||||
|
if (parts.length === 1) {
|
||||||
|
// host only, default port to 1080
|
||||||
|
const host = parts[0]
|
||||||
|
if (!host) return null
|
||||||
|
return { scheme, host, port: 1080 }
|
||||||
|
}
|
||||||
|
if (parts.length === 2) {
|
||||||
|
// host:port, no auth
|
||||||
|
const host = parts[0]
|
||||||
|
const port = parseInt(parts[1], 10)
|
||||||
|
if (!host || isNaN(port)) return null
|
||||||
|
return { scheme, host, port }
|
||||||
|
}
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
// host:port:username:password (and possibly more colons in user/pass)
|
||||||
|
// Last part = password, second-to-last = username, rest = host:port
|
||||||
|
const password = parts[parts.length - 1]
|
||||||
|
const username = parts[parts.length - 2]
|
||||||
|
const hostport = parts.slice(0, parts.length - 2).join(':')
|
||||||
|
const { host, port } = splitHostPort(hostport)
|
||||||
|
if (!host || !port || !username || !password) return null
|
||||||
|
return { scheme, host, port, username, password }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitHostPort(hostport: string): { host: string; port: number | null } {
|
||||||
|
if (!hostport) return { host: '', port: null }
|
||||||
|
|
||||||
|
// IPv6: [::1]:1080 or [::1]
|
||||||
|
if (hostport.startsWith('[')) {
|
||||||
|
const close = hostport.indexOf(']')
|
||||||
|
if (close < 0) return { host: '', port: null }
|
||||||
|
const host = hostport.slice(1, close)
|
||||||
|
const after = hostport.slice(close + 1)
|
||||||
|
if (!after.startsWith(':')) {
|
||||||
|
// No port specified, default to 1080
|
||||||
|
return { host, port: 1080 }
|
||||||
|
}
|
||||||
|
const port = parseInt(after.slice(1), 10)
|
||||||
|
return { host, port: isNaN(port) ? null : port }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastColon = hostport.lastIndexOf(':')
|
||||||
|
if (lastColon < 0) {
|
||||||
|
// No port specified, default to 1080
|
||||||
|
return { host: hostport, port: 1080 }
|
||||||
|
}
|
||||||
|
const host = hostport.slice(0, lastColon)
|
||||||
|
const port = parseInt(hostport.slice(lastColon + 1), 10)
|
||||||
|
return { host, port: isNaN(port) ? null : port }
|
||||||
|
}
|
||||||
|
|
||||||
export const proxyFormSchema = z.object({
|
export const proxyFormSchema = z.object({
|
||||||
url: z
|
url: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'Proxy address cannot be empty')
|
.min(1, 'Proxy address cannot be empty')
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
if (value.length === 0) {
|
if (value.length === 0) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let url: URL
|
// Try our custom parser first (handles both standard and non-standard)
|
||||||
try {
|
const parsed = parseProxyUrl(value)
|
||||||
url = new URL(value)
|
|
||||||
} catch (_e) {
|
if (!parsed) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Invalid URL format',
|
message: 'Invalid format. Expected socks5://[user:pass@]host:port or socks5://host:port:user:pass',
|
||||||
path: [],
|
path: [],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.protocol !== 'socks5:' && url.protocol !== 'http:') {
|
if (parsed.scheme !== 'socks5' && parsed.scheme !== 'http') {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'URL must start with http:// or socks5://',
|
message: 'URL must start with http:// or socks5://',
|
||||||
path: [],
|
path: [],
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^[a-zA-Z0-9\-\.]+$/.test(url.hostname)) {
|
if (!/^[a-zA-Z0-9\-\.]+$/.test(parsed.host)) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Hostname contains invalid characters',
|
message: 'Hostname contains invalid characters',
|
||||||
path: [],
|
path: [],
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const port = parseInt(url.port || '1080')
|
if (parsed.port <= 0 || parsed.port > 65535) {
|
||||||
if (port <= 0 || port > 65535) {
|
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Port must be between 1-65535',
|
message: 'Port must be between 1-65535',
|
||||||
path: [],
|
path: [],
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.username && !url.password) {
|
if (parsed.username && !parsed.password) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Password cannot be empty when username is provided',
|
message: 'Password cannot be empty when username is provided',
|
||||||
path: [],
|
path: [],
|
||||||
})
|
})
|
||||||
} else if (url.password && url.password.length < 8) {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.password && parsed.password.length < 8) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Password must be at least 8 characters',
|
message: 'Password must be at least 8 characters',
|
||||||
path: [],
|
path: [],
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user