mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
expand proxy provider support
This commit is contained in:
@@ -105,6 +105,14 @@ export interface Proxy {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface ProxyTestResult {
|
||||
ip?: string | null;
|
||||
country?: string | null;
|
||||
region?: string | null;
|
||||
city?: string | null;
|
||||
isp?: string | null;
|
||||
}
|
||||
|
||||
export type ServerConfigurations = {
|
||||
bichon_log_level: string
|
||||
bichon_http_port: number
|
||||
@@ -168,6 +176,11 @@ export const update_proxy = async (id: number, url: string) => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const test_proxy = async (id: number) => {
|
||||
const response = await axiosInstance.post<ProxyTestResult>(`api/v1/proxy/${id}/test`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const add_proxy = async (url: string) => {
|
||||
const response = await axiosInstance.post(`api/v1/proxy`, url, {
|
||||
headers: {
|
||||
@@ -181,4 +194,4 @@ export const add_proxy = async (url: string) => {
|
||||
export const get_system_configurations = async () => {
|
||||
const response = await axiosInstance.get<ServerConfigurations>(`api/v1/system-configurations`);
|
||||
return response.data;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,196 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { proxyFormSchema } from '../schema'
|
||||
|
||||
describe('Proxy Form Schema', () => {
|
||||
describe('url field - basic validation', () => {
|
||||
it('rejects empty URL', () => {
|
||||
const result = proxyFormSchema.safeParse({ url: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid socks5 URL', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid http URL', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'http://proxy.example.com:8080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
it('rejects empty URL', () => {
|
||||
const result = proxyFormSchema.safeParse({ url: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
describe('url field - protocol validation', () => {
|
||||
it('rejects https protocol', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'https://proxy.example.com:443',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Invalid format')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects ftp protocol', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'ftp://files.example.com',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects URL without protocol', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: '127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Invalid format')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - port validation', () => {
|
||||
it('rejects port 0', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:0',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects port > 65535', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:99999',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts port 65535', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:65535',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts port 1', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:1',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to port 1080 when no port specified', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - hostname validation', () => {
|
||||
it('accepts IP address hostname', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://192.168.1.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts domain hostname', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://proxy.internal:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects hostname with invalid characters', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://proxy_host:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Hostname contains invalid characters')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - auth validation', () => {
|
||||
it('rejects username without password', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://user@127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Password cannot be empty')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects short password when username provided', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://user:short@127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Password must be at least 8')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid auth credentials', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://user:password123@127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts URL without auth (no credentials)', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:1080',
|
||||
})
|
||||
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)
|
||||
it('leaves proxy URL validation to the server', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://proxy.example.com:8080:customer-zone-us:secret',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,18 +35,25 @@ export const getColumns = (t: (key: string) => string): ColumnDef<Proxy>[] => [
|
||||
<LongText className='max-w-72'>{`${row.original.id}`}</LongText>
|
||||
),
|
||||
enableHiding: false,
|
||||
meta: { className: 'w-60' },
|
||||
enableSorting: false
|
||||
meta: { className: 'w-44' },
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "url",
|
||||
accessorKey: 'url',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('settings.url')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <LongText>{row.original.url}</LongText>
|
||||
return (
|
||||
<LongText
|
||||
className='max-w-full'
|
||||
contentClassName='max-w-[32rem] break-all'
|
||||
>
|
||||
{row.original.url}
|
||||
</LongText>
|
||||
)
|
||||
},
|
||||
meta: { className: 'max-w-60' },
|
||||
meta: { className: 'min-w-0' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
@@ -54,11 +61,12 @@ export const getColumns = (t: (key: string) => string): ColumnDef<Proxy>[] => [
|
||||
<DataTableColumnHeader column={column} title={t('settings.createdAt')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const created_at = row.original.created_at;
|
||||
const date = format(new Date(created_at), 'yyyy-MM-dd HH:mm:ss');
|
||||
return <LongText>{date}</LongText>;
|
||||
const created_at = row.original.created_at
|
||||
const date = format(new Date(created_at), 'yyyy-MM-dd HH:mm:ss')
|
||||
return <LongText>{date}</LongText>
|
||||
},
|
||||
enableHiding: false,
|
||||
meta: { className: 'w-44 whitespace-nowrap' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
@@ -66,14 +74,16 @@ export const getColumns = (t: (key: string) => string): ColumnDef<Proxy>[] => [
|
||||
<DataTableColumnHeader column={column} title={t('settings.updatedAt')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const updated_at = row.original.updated_at;
|
||||
const date = format(new Date(updated_at), 'yyyy-MM-dd HH:mm:ss');
|
||||
return <LongText>{date}</LongText>;
|
||||
const updated_at = row.original.updated_at
|
||||
const date = format(new Date(updated_at), 'yyyy-MM-dd HH:mm:ss')
|
||||
return <LongText>{date}</LongText>
|
||||
},
|
||||
enableHiding: false,
|
||||
meta: { className: 'w-44 whitespace-nowrap' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: DataTableRowActions,
|
||||
meta: { className: 'w-16' },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
|
||||
|
||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Row } from '@tanstack/react-table'
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react'
|
||||
import { AxiosError } from 'axios'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -31,8 +33,9 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useProxyContext } from '../context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Proxy } from '@/api/system/api'
|
||||
import { Proxy, test_proxy } from '@/api/system/api'
|
||||
import { useCurrentUser } from '@/hooks/use-current-user'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
@@ -43,6 +46,34 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { setOpen, setCurrentRow } = useProxyContext()
|
||||
const { require_any_permission } = useCurrentUser()
|
||||
const { t } = useTranslation()
|
||||
const canManage = require_any_permission(['system:root'])
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => test_proxy(row.original.id),
|
||||
onSuccess: (result) => {
|
||||
const description = [
|
||||
result.ip,
|
||||
result.city,
|
||||
result.region,
|
||||
result.country,
|
||||
result.isp,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
toast({
|
||||
title: t('settings.proxyTestSuccess'),
|
||||
description: description || undefined,
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('settings.proxyTestFailed'),
|
||||
description: axiosError.response?.data?.message || axiosError.message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
@@ -57,7 +88,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[160px]'>
|
||||
<DropdownMenuItem
|
||||
disabled={!require_any_permission(['system:root'])}
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
setCurrentRow(row.original)
|
||||
setOpen('edit')
|
||||
@@ -68,9 +99,17 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
<IconEdit size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!canManage || testMutation.isPending}
|
||||
onClick={() => testMutation.mutate()}
|
||||
>
|
||||
{testMutation.isPending
|
||||
? t('settings.proxyTesting')
|
||||
: t('settings.proxyTest')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={!require_any_permission(['system:root'])}
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
setCurrentRow(row.original)
|
||||
setOpen('delete')
|
||||
|
||||
@@ -15,22 +15,19 @@
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AxiosError } from 'axios'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { IconAlertTriangle } from '@tabler/icons-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { delete_proxy } from '@/api/system/api'
|
||||
import { Proxy } from '@/api/system/api'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { AxiosError } from 'axios'
|
||||
import { delete_proxy } from '@/api/system/api'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Proxy } from '@/api/system/api'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -40,41 +37,50 @@ interface Props {
|
||||
|
||||
export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [value, setValue] = useState(0)
|
||||
const queryClient = useQueryClient();
|
||||
const [value, setValue] = useState('')
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: t('proxyDelete.successTitle'),
|
||||
description: t('proxyDelete.successDesc'),
|
||||
action: <ToastAction altText={t('proxyDelete.close')}>{t('proxyDelete.close')}</ToastAction>,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-list'] });
|
||||
onOpenChange(false);
|
||||
action: (
|
||||
<ToastAction altText={t('proxyDelete.close')}>
|
||||
{t('proxyDelete.close')}
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-list'] })
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
function handleError(error: AxiosError) {
|
||||
const errorMessage = (error.response?.data as { message?: string })?.message ||
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
t('proxyDelete.failedDesc');
|
||||
t('proxyDelete.failedDesc')
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
variant: 'destructive',
|
||||
title: t('proxyDelete.failedTitle'),
|
||||
description: errorMessage as string,
|
||||
action: <ToastAction altText={t('proxyDelete.tryAgain')}>{t('proxyDelete.tryAgain')}</ToastAction>,
|
||||
});
|
||||
console.error(error);
|
||||
action: (
|
||||
<ToastAction altText={t('proxyDelete.tryAgain')}>
|
||||
{t('proxyDelete.tryAgain')}
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => delete_proxy(id),
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError
|
||||
onError: handleError,
|
||||
})
|
||||
|
||||
const handleDelete = () => {
|
||||
if (value !== currentRow.id) return
|
||||
if (value !== `${currentRow.id}`) return
|
||||
deleteMutation.mutate(currentRow.id)
|
||||
}
|
||||
|
||||
@@ -83,8 +89,8 @@ export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
handleConfirm={handleDelete}
|
||||
disabled={value !== currentRow.id}
|
||||
className="max-w-2xl"
|
||||
disabled={value !== `${currentRow.id}`}
|
||||
className='max-w-2xl'
|
||||
title={
|
||||
<span className='text-destructive'>
|
||||
<IconAlertTriangle
|
||||
@@ -97,7 +103,8 @@ export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
desc={
|
||||
<div className='space-y-4'>
|
||||
<p className='mb-2'>
|
||||
{t('proxyDelete.confirmText')} <span className='font-bold'>{`${currentRow.id}`}</span>?
|
||||
{t('proxyDelete.confirmText')}{' '}
|
||||
<span className='font-bold'>{`${currentRow.id}`}</span>?
|
||||
<br />
|
||||
{t('proxyDelete.permanent')}
|
||||
</p>
|
||||
@@ -105,19 +112,16 @@ export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
<Label className='my-2'>
|
||||
{t('proxyDelete.proxyIdLabel')}
|
||||
<Input
|
||||
type="number"
|
||||
value={`${value}`}
|
||||
onChange={(e) => setValue(parseInt(e.target.value, 10))}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={t('proxyDelete.proxyIdPlaceholder')}
|
||||
className="mt-2"
|
||||
className='mt-2'
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Alert variant='destructive'>
|
||||
<AlertTitle>{t('proxyDelete.warningTitle', 'Warning!')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('proxyDelete.warningDesc')}
|
||||
</AlertDescription>
|
||||
<AlertDescription>{t('proxyDelete.warningDesc')}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
@@ -126,4 +130,3 @@ export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,177 +1,7 @@
|
||||
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({
|
||||
url: z
|
||||
.string()
|
||||
.min(1, 'Proxy address cannot be empty')
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.length === 0) return
|
||||
|
||||
// Try our custom parser first (handles both standard and non-standard)
|
||||
const parsed = parseProxyUrl(value)
|
||||
|
||||
if (!parsed) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid format. Expected socks5://[user:pass@]host:port or socks5://host:port:user:pass',
|
||||
path: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.scheme !== 'socks5' && parsed.scheme !== 'http') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'URL must start with http:// or socks5://',
|
||||
path: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9\-\.]+$/.test(parsed.host)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Hostname contains invalid characters',
|
||||
path: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.port <= 0 || parsed.port > 65535) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Port must be between 1-65535',
|
||||
path: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.username && !parsed.password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Password cannot be empty when username is provided',
|
||||
path: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.password && parsed.password.length < 8) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Password must be at least 8 characters',
|
||||
path: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
}),
|
||||
url: z.string().min(1, 'Proxy address cannot be empty'),
|
||||
})
|
||||
|
||||
export type ProxyFormValues = z.infer<typeof proxyFormSchema>
|
||||
|
||||
@@ -79,8 +79,8 @@ export function ProxyTable({ columns, data }: DataTableProps) {
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: Number(localStorage.getItem('bichon_proxy_page_size')) || 10
|
||||
}
|
||||
pageSize: Number(localStorage.getItem('bichon_proxy_page_size')) || 10,
|
||||
},
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
@@ -98,8 +98,8 @@ export function ProxyTable({ columns, data }: DataTableProps) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<DataTableToolbar table={table} />
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<div className='overflow-x-auto rounded-md border'>
|
||||
<Table className='w-full table-fixed'>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className='group/row'>
|
||||
@@ -113,9 +113,9 @@ export function ProxyTable({ columns, data }: DataTableProps) {
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
@@ -133,7 +133,7 @@ export function ProxyTable({ columns, data }: DataTableProps) {
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cell.column.columnDef.meta?.className ?? ''}
|
||||
className={`overflow-hidden ${cell.column.columnDef.meta?.className ?? ''}`}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
"imapPassword": "IMAP Password",
|
||||
"imapPort": "IMAP Port",
|
||||
"imapPortPlaceholder": "e.g 993",
|
||||
"imapProxy": "Use a SOCKS5 proxy for IMAP connections.",
|
||||
"imapProxy": "Use a proxy (http/socks5) for IMAP connections.",
|
||||
"incDownload": "Interval",
|
||||
"lastSync": "Last Sync",
|
||||
"leaveEmptyToKeepExisting": "Leave empty to keep the existing password, or enter a new password to update it.",
|
||||
@@ -924,7 +924,7 @@
|
||||
"updateOrCreationFailed": "{{action}} failed, please try again later",
|
||||
"updated": "Updated",
|
||||
"useProxyOptional": "Use Proxy (optional):",
|
||||
"useSocks5ProxyForOAuthRequests": "Use SOCKS5 proxy for OAuth requests when direct access is blocked.",
|
||||
"useSocks5ProxyForOAuthRequests": "Use a proxy for OAuth requests when direct access is blocked.",
|
||||
"value": "Value:",
|
||||
"valueCannotBeEmpty": "Value cannot be empty",
|
||||
"valueIsRequired": "Value is required",
|
||||
@@ -1420,6 +1420,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyTest": "Check Proxy",
|
||||
"proxyTesting": "Checking...",
|
||||
"proxyTestFailed": "Proxy check failed",
|
||||
"proxyTestSuccess": "Proxy works",
|
||||
"proxyUpdateOrAddFailed": "{{action}} failed, please try again later",
|
||||
"reset": "Reset",
|
||||
"resetRootPassword": "Reset Root Password",
|
||||
@@ -1820,4 +1824,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batch size must be at most 200",
|
||||
"singleRequestBatchSizeTooSmall": "Batch size must be at least 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user