feat: add support for proxies to use with IRC and Indexers (#1421)

* feat: add support for proxies

* fix(http): release handler

* fix(migrations): define proxy early

* fix(migrations): pg proxy

* fix(proxy): list update delete

* fix(proxy): remove log and imports

* feat(irc): use proxy

* feat(irc): tests

* fix(web): update imports for ProxyForms.tsx

* fix(database): migration

* feat(proxy): test

* feat(proxy): validate proxy type

* feat(proxy): validate and test

* feat(proxy): improve validate and test

* feat(proxy): fix db schema

* feat(proxy): add db tests

* feat(proxy): handle http errors

* fix(http): imports

* feat(proxy): use proxy for indexer downloads

* feat(proxy): indexerforms select proxy

* feat(proxy): handle torrent download

* feat(proxy): skip if disabled

* feat(proxy): imports

* feat(proxy): implement in Feeds

* feat(proxy): update helper text indexer proxy

* feat(proxy): add internal cache
This commit is contained in:
ze0s 2024-09-02 11:10:45 +02:00 committed by GitHub
parent 472d327308
commit bc0f4cc055
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 2533 additions and 371 deletions

View file

@ -389,6 +389,20 @@ export const APIClient = {
body: notification
})
},
proxy: {
list: () => appClient.Get<Proxy[]>("api/proxy"),
getByID: (id: number) => appClient.Get<Proxy>(`api/proxy/${id}`),
store: (proxy: ProxyCreate) => appClient.Post("api/proxy", {
body: proxy
}),
update: (proxy: Proxy) => appClient.Put(`api/proxy/${proxy.id}`, {
body: proxy
}),
delete: (id: number) => appClient.Delete(`api/proxy/${id}`),
test: (proxy: Proxy) => appClient.Post("api/proxy/test", {
body: proxy
})
},
release: {
find: (query?: string) => appClient.Get<ReleaseFindResponse>(`api/release${query}`),
findRecent: () => appClient.Get<ReleaseFindResponse>("api/release/recent"),

View file

@ -11,7 +11,7 @@ import {
FeedKeys,
FilterKeys,
IndexerKeys,
IrcKeys, NotificationKeys,
IrcKeys, NotificationKeys, ProxyKeys,
ReleaseKeys,
SettingsKeys
} from "@api/query_keys";
@ -137,3 +137,17 @@ export const ReleasesIndexersQueryOptions = () =>
placeholderData: keepPreviousData,
staleTime: Infinity
});
export const ProxiesQueryOptions = () =>
queryOptions({
queryKey: ProxyKeys.lists(),
queryFn: () => APIClient.proxy.list(),
refetchOnWindowFocus: false
});
export const ProxyByIdQueryOptions = (proxyId: number) =>
queryOptions({
queryKey: ProxyKeys.detail(proxyId),
queryFn: async ({queryKey}) => await APIClient.proxy.getByID(queryKey[2]),
retry: false,
});

View file

@ -79,4 +79,11 @@ export const NotificationKeys = {
lists: () => [...NotificationKeys.all, "list"] as const,
details: () => [...NotificationKeys.all, "detail"] as const,
detail: (id: number) => [...NotificationKeys.details(), id] as const
};
};
export const ProxyKeys = {
all: ["proxy"] as const,
lists: () => [...ProxyKeys.all, "list"] as const,
details: () => [...ProxyKeys.all, "detail"] as const,
detail: (id: number) => [...ProxyKeys.details(), id] as const
};

View file

@ -17,6 +17,7 @@ interface SelectFieldProps<T> {
label: string;
help?: string;
placeholder?: string;
required?: boolean;
defaultValue?: OptionBasicTyped<T>;
tooltip?: JSX.Element;
options: OptionBasicTyped<T>[];
@ -158,7 +159,7 @@ export function SelectField<T>({ name, label, help, placeholder, options }: Sele
);
}
export function SelectFieldBasic<T>({ name, label, help, placeholder, tooltip, defaultValue, options }: SelectFieldProps<T>) {
export function SelectFieldBasic<T>({ name, label, help, placeholder, required, tooltip, defaultValue, options }: SelectFieldProps<T>) {
return (
<div className="space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div>
@ -182,6 +183,7 @@ export function SelectFieldBasic<T>({ name, label, help, placeholder, tooltip, d
<Select
{...field}
id={name}
required={required}
components={{
Input: common.SelectInput,
Control: common.SelectControl,

View file

@ -79,4 +79,33 @@ const SwitchGroup = ({
</Field>
);
export { SwitchGroup };
interface SwitchButtonProps {
name: string;
defaultValue?: boolean;
className?: string;
}
const SwitchButton = ({ name, defaultValue }: SwitchButtonProps) => (
<Field as="div" className="flex items-center justify-between">
<FormikField
name={name}
defaultValue={defaultValue as boolean}
type="checkbox"
>
{({
field,
form: { setFieldValue }
}: FieldProps) => (
<Checkbox
{...field}
value={!!field.checked}
setValue={(value) => {
setFieldValue(field?.name ?? "", value);
}}
/>
)}
</FormikField>
</Field>
);
export { SwitchGroup, SwitchButton };

View file

@ -577,3 +577,10 @@ export const ExternalFilterWebhookMethodOptions: OptionBasicTyped<WebhookMethod>
{ label: "PATCH", value: "PATCH" },
{ label: "DELETE", value: "DELETE" }
];
export const ProxyTypeOptions: OptionBasicTyped<ProxyType>[] = [
{
label: "SOCKS5",
value: "SOCKS5"
},
];

View file

@ -16,14 +16,15 @@ import { classNames, sleep } from "@utils";
import { DEBUG } from "@components/debug";
import { APIClient } from "@api/APIClient";
import { FeedKeys, IndexerKeys, ReleaseKeys } from "@api/query_keys";
import { IndexersSchemaQueryOptions } from "@api/queries";
import { IndexersSchemaQueryOptions, ProxiesQueryOptions } from "@api/queries";
import { SlideOver } from "@components/panels";
import Toast from "@components/notifications/Toast";
import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
import { PasswordFieldWide, SwitchButton, SwitchGroupWide, TextFieldWide } from "@components/inputs";
import { SelectFieldBasic, SelectFieldCreatable } from "@components/inputs/select_wide";
import { FeedDownloadTypeOptions } from "@domain/constants";
import { DocsLink } from "@components/ExternalLink";
import * as common from "@components/inputs/common";
import { SelectField } from "@forms/settings/IrcForms";
// const isRequired = (message: string) => (value?: string | undefined) => (!!value ? undefined : message);
@ -254,7 +255,7 @@ type SelectValue = {
value: string;
};
interface AddProps {
export interface AddProps {
isOpen: boolean;
toggle: () => void;
}
@ -718,6 +719,8 @@ interface IndexerUpdateInitialValues {
identifier_external: string;
implementation: string;
base_url: string;
use_proxy?: boolean;
proxy_id?: number;
settings: {
api_key?: string;
api_user?: string;
@ -735,6 +738,8 @@ interface UpdateProps {
export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
const queryClient = useQueryClient();
const proxies = useQuery(ProxiesQueryOptions());
const mutation = useMutation({
mutationFn: (indexer: Indexer) => APIClient.indexers.update(indexer),
onSuccess: () => {
@ -813,6 +818,8 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
identifier_external: indexer.identifier_external,
implementation: indexer.implementation,
base_url: indexer.base_url,
use_proxy: indexer.use_proxy,
proxy_id: indexer.proxy_id,
settings: indexer.settings?.reduce(
(o: Record<string, string>, obj: IndexerSetting) => ({
...o,
@ -833,7 +840,7 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
initialValues={initialValues}
extraButtons={(values) => <TestApiButton values={values as FormikValues} show={indexer.implementation === "irc" && indexer.supports.includes("api")} />}
>
{() => (
{(values) => (
<div className="py-2 space-y-6 sm:py-0 sm:space-y-0 divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<label
@ -863,14 +870,15 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
tooltip={
<div>
<p>External Identifier for use with ARRs to get features like seed limits working.</p>
<br />
<p>This needs to match the indexer name in your ARR. If using Prowlarr it will likely be "{indexer.name} (Prowlarr)"</p>
<br />
<DocsLink href="https://autobrr.com/configuration/indexers#setup" />
<br/>
<p>This needs to match the indexer name in your ARR. If using Prowlarr it will likely be
"{indexer.name} (Prowlarr)"</p>
<br/>
<DocsLink href="https://autobrr.com/configuration/indexers#setup"/>
</div>
}
/>
<SwitchGroupWide name="enabled" label="Enabled" />
<SwitchGroupWide name="enabled" label="Enabled"/>
{indexer.implementation == "irc" && (
<SelectFieldCreatable
@ -882,6 +890,31 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
)}
{renderSettingFields(indexer.settings)}
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="flex justify-between px-4">
<div className="space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Proxy
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Set a proxy to be used for downloads of .torrent files and feeds.
</p>
</div>
<SwitchButton name="use_proxy" />
</div>
{values.use_proxy === true && (
<div className="py-4 pt-6">
<SelectField<number>
name="proxy_id"
label="Select proxy"
placeholder="Select a proxy"
options={proxies.data ? proxies.data.map((p) => ({ label: p.name, value: p.id })) : []}
/>
</div>
)}
</div>
</div>
)}
</SlideOver>

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { XMarkIcon } from "@heroicons/react/24/solid";
import type { FieldProps } from "formik";
@ -16,11 +16,12 @@ import { DialogTitle } from "@headlessui/react";
import { IrcAuthMechanismTypeOptions, OptionBasicTyped } from "@domain/constants";
import { APIClient } from "@api/APIClient";
import { IrcKeys } from "@api/query_keys";
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
import { NumberFieldWide, PasswordFieldWide, SwitchButton, SwitchGroupWide, TextFieldWide } from "@components/inputs";
import { SlideOver } from "@components/panels";
import Toast from "@components/notifications/Toast";
import * as common from "@components/inputs/common";
import { classNames } from "@utils";
import { ProxiesQueryOptions } from "@api/queries";
interface ChannelsFieldArrayProps {
channels: IrcChannel[];
@ -270,6 +271,8 @@ interface IrcNetworkUpdateFormValues {
bouncer_addr: string;
bot_mode: boolean;
channels: Array<IrcChannel>;
use_proxy: boolean;
proxy_id: number;
}
interface IrcNetworkUpdateFormProps {
@ -285,6 +288,8 @@ export function IrcNetworkUpdateForm({
}: IrcNetworkUpdateFormProps) {
const queryClient = useQueryClient();
const proxies = useQuery(ProxiesQueryOptions());
const updateMutation = useMutation({
mutationFn: (network: IrcNetwork) => APIClient.irc.updateNetwork(network),
onSuccess: () => {
@ -325,7 +330,9 @@ export function IrcNetworkUpdateForm({
use_bouncer: network.use_bouncer,
bouncer_addr: network.bouncer_addr,
bot_mode: network.bot_mode,
channels: network.channels
channels: network.channels,
use_proxy: network.use_proxy,
proxy_id: network.proxy_id,
};
return (
@ -348,7 +355,7 @@ export function IrcNetworkUpdateForm({
required={true}
/>
<SwitchGroupWide name="enabled" label="Enabled" />
<SwitchGroupWide name="enabled" label="Enabled"/>
<TextFieldWide
name="server"
label="Server"
@ -362,7 +369,7 @@ export function IrcNetworkUpdateForm({
required={true}
/>
<SwitchGroupWide name="tls" label="TLS" />
<SwitchGroupWide name="tls" label="TLS"/>
<PasswordFieldWide
name="pass"
@ -377,7 +384,7 @@ export function IrcNetworkUpdateForm({
required={true}
/>
<SwitchGroupWide name="use_bouncer" label="Bouncer (BNC)" />
<SwitchGroupWide name="use_bouncer" label="Bouncer (BNC)"/>
{values.use_bouncer && (
<TextFieldWide
name="bouncer_addr"
@ -386,7 +393,32 @@ export function IrcNetworkUpdateForm({
/>
)}
<SwitchGroupWide name="bot_mode" label="IRCv3 Bot Mode" />
<SwitchGroupWide name="bot_mode" label="IRCv3 Bot Mode"/>
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="flex justify-between px-4">
<div className="space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Proxy
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Set a proxy to be used for connecting to the irc server.
</p>
</div>
<SwitchButton name="use_proxy"/>
</div>
{values.use_proxy === true && (
<div className="py-4 pt-6">
<SelectField<number>
name="proxy_id"
label="Select proxy"
placeholder="Select a proxy"
options={proxies.data ? proxies.data.map((p) => ({ label: p.name, value: p.id })) : []}
/>
</div>
)}
</div>
<div className="border-t border-gray-200 dark:border-gray-700 py-5">
<div className="px-4 space-y-1 mb-8">
@ -416,17 +448,17 @@ export function IrcNetworkUpdateForm({
/>
</div>
<PasswordFieldWide name="invite_command" label="Invite command" />
<PasswordFieldWide name="invite_command" label="Invite command"/>
<div className="border-t border-gray-200 dark:border-gray-700 py-5">
<div className="px-4 space-y-1 mb-8">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">Channels</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Channels are added when you setup IRC indexers. Do not edit unless you know what you are doing.
Channels are added when you setup IRC indexers. Do not edit unless you know what you are doing.
</p>
</div>
<ChannelsFieldArray channels={values.channels} />
<ChannelsFieldArray channels={values.channels}/>
</div>
</div>
)}
@ -438,9 +470,10 @@ interface SelectFieldProps<T> {
name: string;
label: string;
options: OptionBasicTyped<T>[]
placeholder?: string;
}
function SelectField<T>({ name, label, options }: SelectFieldProps<T>) {
export function SelectField<T>({ name, label, options, placeholder }: SelectFieldProps<T>) {
return (
<div className="flex items-center justify-between space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div>
@ -454,9 +487,9 @@ function SelectField<T>({ name, label, options }: SelectFieldProps<T>) {
<div className="sm:col-span-2">
<Field name={name} type="select">
{({
field,
form: { setFieldValue, resetForm }
}: FieldProps) => (
field,
form: { setFieldValue }
}: FieldProps) => (
<Select
{...field}
id={name}
@ -470,7 +503,7 @@ function SelectField<T>({ name, label, options }: SelectFieldProps<T>) {
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}}
placeholder="Choose a type"
placeholder={placeholder ?? "Choose a type"}
styles={{
singleValue: (base) => ({
...base,
@ -487,14 +520,18 @@ function SelectField<T>({ name, label, options }: SelectFieldProps<T>) {
})}
value={field?.value && options.find(o => o.value == field?.value)}
onChange={(option) => {
resetForm();
// resetForm();
// const opt = option as SelectOption;
// setFieldValue("name", option?.label ?? "")
setFieldValue(
field.name,
option.value ?? ""
);
if (option !== null) {
// const opt = option as SelectOption;
// setFieldValue("name", option?.label ?? "")
setFieldValue(
field.name,
option.value ?? ""
);
} else {
setFieldValue(field.name, undefined);
}
}}
options={options}
/>

View file

@ -0,0 +1,265 @@
import { Fragment } from "react";
import { Form, Formik, FormikValues } from "formik";
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
import { XMarkIcon } from "@heroicons/react/24/solid";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { AddProps } from "@forms/settings/IndexerForms";
import { DEBUG } from "@components/debug.tsx";
import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
import { SelectFieldBasic } from "@components/inputs/select_wide";
import { ProxyTypeOptions } from "@domain/constants";
import { APIClient } from "@api/APIClient";
import { ProxyKeys } from "@api/query_keys";
import Toast from "@components/notifications/Toast";
import { SlideOver } from "@components/panels";
export function ProxyAddForm({ isOpen, toggle }: AddProps) {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (req: ProxyCreate) => APIClient.proxy.store(req),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ProxyKeys.lists() });
toast.custom((t) => <Toast type="success" body="Proxy added!" t={t} />);
toggle();
},
onError: () => {
toast.custom((t) => <Toast type="error" body="Proxy could not be added" t={t} />);
}
});
const onSubmit = (formData: FormikValues) => {
createMutation.mutate(formData as ProxyCreate);
}
const testMutation = useMutation({
mutationFn: (data: Proxy) => APIClient.proxy.test(data),
onError: (err) => {
console.error(err);
}
});
const testProxy = (data: unknown) => testMutation.mutate(data as Proxy);
const initialValues: ProxyCreate = {
enabled: true,
name: "Proxy",
type: "SOCKS5",
addr: "socks5://ip:port",
user: "",
pass: "",
}
return (
<Transition show={isOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed inset-0 overflow-hidden"
open={isOpen}
onClose={toggle}
>
<div className="absolute inset-0 overflow-hidden">
<DialogPanel className="absolute inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16">
<TransitionChild
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl dark:border-gray-700 border-l">
<Formik
enableReinitialize={true}
initialValues={initialValues}
onSubmit={onSubmit}
>
{({ values }) => (
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
<div className="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Add proxy
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-200">
Add proxy to be used with Indexers or IRC.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white dark:bg-gray-700 rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
</div>
</div>
<div className="py-6 space-y-4 divide-y divide-gray-200 dark:divide-gray-700">
<SwitchGroupWide name="enabled" label="Enabled" />
<TextFieldWide name="name" label="Name" defaultValue="" required={true} />
<SelectFieldBasic
name="type"
label="Proxy type"
options={ProxyTypeOptions}
tooltip={<span>Proxy type. Commonly SOCKS5.</span>}
help="Usually SOCKS5"
/>
<TextFieldWide name="addr" label="Addr" required={true} help="Addr: scheme://ip:port or scheme://domain" autoComplete="off"/>
</div>
<div>
<TextFieldWide name="user" label="User" help="auth: username" autoComplete="off" />
<PasswordFieldWide name="pass" label="Pass" help="auth: password" autoComplete="off"/>
</div>
</div>
<div
className="flex-shrink-0 px-4 border-t border-gray-200 dark:border-gray-700 py-5 sm:px-6">
<div className="space-x-3 flex justify-end">
<button
type="button"
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
onClick={() => testProxy(values)}
>
Test
</button>
<button
type="button"
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
onClick={toggle}
>
Cancel
</button>
<button
type="submit"
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
>
Save
</button>
</div>
</div>
<DEBUG values={values}/>
</Form>
)}
</Formik>
</div>
</TransitionChild>
</DialogPanel>
</div>
</Dialog>
</Transition>
);
}
interface UpdateFormProps<T> {
isOpen: boolean;
toggle: () => void;
data: T;
}
export function ProxyUpdateForm({ isOpen, toggle, data }: UpdateFormProps<Proxy>) {
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: (req: Proxy) => APIClient.proxy.update(req),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ProxyKeys.lists() });
toast.custom((t) => <Toast type="success" body={`Proxy ${data.name} updated!`} t={t} />);
toggle();
},
onError: () => {
toast.custom((t) => <Toast type="error" body="Proxy could not be updated" t={t} />);
}
});
const onSubmit = (formData: unknown) => {
updateMutation.mutate(formData as Proxy);
}
const deleteMutation = useMutation({
mutationFn: (proxyId: number) => APIClient.proxy.delete(proxyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ProxyKeys.lists() });
toast.custom((t) => <Toast type="success" body={`Proxy ${data.name} was deleted.`} t={t}/>);
}
});
const deleteFn = () => deleteMutation.mutate(data.id);
const testMutation = useMutation({
mutationFn: (data: Proxy) => APIClient.proxy.test(data),
onError: (err) => {
console.error(err);
}
});
const testProxy = (data: unknown) => testMutation.mutate(data as Proxy);
const initialValues: Proxy = {
id: data.id,
enabled: data.enabled,
name: data.name,
type: data.type,
addr: data.addr,
user: data.user,
pass: data.pass,
}
return (
<SlideOver<Proxy>
title="Proxy"
initialValues={initialValues}
onSubmit={onSubmit}
deleteAction={deleteFn}
testFn={testProxy}
isOpen={isOpen}
toggle={toggle}
type="UPDATE"
>
{() => (
<div>
<div className="py-6 space-y-4 divide-y divide-gray-200 dark:divide-gray-700">
<SwitchGroupWide name="enabled" label="Enabled"/>
<TextFieldWide name="name" label="Name" defaultValue="" required={true}/>
<SelectFieldBasic
name="type"
label="Proxy type"
required={true}
options={ProxyTypeOptions}
tooltip={<span>Proxy type. Commonly SOCKS5.</span>}
help="Usually SOCKS5"
/>
<TextFieldWide name="addr" label="Addr" required={true} help="Addr: scheme://ip:port or scheme://domain" autoComplete="off"/>
</div>
<div>
<TextFieldWide name="user" label="User" help="auth: username" autoComplete="off"/>
<PasswordFieldWide name="pass" label="Pass" help="auth: password" autoComplete="off"/>
</div>
</div>
)}
</SlideOver>
);
}

View file

@ -11,7 +11,7 @@ import {
notFound,
Outlet,
redirect,
} from "@tanstack/react-router";
} from "@tanstack/react-router";
import { z } from "zod";
import { QueryClient } from "@tanstack/react-query";
@ -30,7 +30,8 @@ import {
FilterByIdQueryOptions,
IndexersQueryOptions,
IrcQueryOptions,
NotificationsQueryOptions
NotificationsQueryOptions,
ProxiesQueryOptions
} from "@api/queries";
import LogSettings from "@screens/settings/Logs";
import NotificationSettings from "@screens/settings/Notifications";
@ -50,6 +51,7 @@ import { AuthContext, SettingsContext } from "@utils/Context";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient } from "@api/QueryClient";
import ProxySettings from "@screens/settings/Proxy";
import { ErrorPage } from "@components/alerts";
@ -212,6 +214,13 @@ export const SettingsApiRoute = createRoute({
component: APISettings
});
export const SettingsProxiesRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'proxies',
loader: (opts) => opts.context.queryClient.ensureQueryData(ProxiesQueryOptions()),
component: ProxySettings
});
export const SettingsReleasesRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'releases',
@ -339,7 +348,7 @@ export const RootRoute = createRootRouteWithContext<{
});
const filterRouteTree = FiltersRoute.addChildren([FilterIndexRoute, FilterGetByIdRoute.addChildren([FilterGeneralRoute, FilterMoviesTvRoute, FilterMusicRoute, FilterAdvancedRoute, FilterExternalRoute, FilterActionsRoute])])
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsReleasesRoute, SettingsAccountRoute])
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsProxiesRoute, SettingsReleasesRoute, SettingsAccountRoute])
const authenticatedTree = AuthRoute.addChildren([AuthIndexRoute.addChildren([DashboardRoute, filterRouteTree, ReleasesRoute, settingsRouteTree, LogsRoute])])
const routeTree = RootRoute.addChildren([
authenticatedTree,

View file

@ -8,6 +8,7 @@ import {
ChatBubbleLeftRightIcon,
CogIcon,
FolderArrowDownIcon,
GlobeAltIcon,
KeyIcon,
RectangleStackIcon,
RssIcon,
@ -34,6 +35,7 @@ const subNavigation: NavTabType[] = [
{ name: "Clients", href: "/settings/clients", icon: FolderArrowDownIcon },
{ name: "Notifications", href: "/settings/notifications", icon: BellIcon },
{ name: "API keys", href: "/settings/api", icon: KeyIcon },
{ name: "Proxies", href: "/settings/proxies", icon: GlobeAltIcon },
{ name: "Releases", href: "/settings/releases", icon: RectangleStackIcon },
{ name: "Account", href: "/settings/account", icon: UserCircleIcon }
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}

View file

@ -0,0 +1,140 @@
import { useToggle } from "@hooks/hooks.ts";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { PlusIcon } from "@heroicons/react/24/solid";
import { toast } from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { ProxyKeys } from "@api/query_keys";
import { ProxiesQueryOptions } from "@api/queries";
import { Section } from "./_components";
import { EmptySimple } from "@components/emptystates";
import { Checkbox } from "@components/Checkbox";
import { ProxyAddForm, ProxyUpdateForm } from "@forms/settings/ProxyForms";
import Toast from "@components/notifications/Toast";
interface ListItemProps {
proxy: Proxy;
}
function ListItem({ proxy }: ListItemProps) {
const [isOpen, toggleUpdate] = useToggle(false);
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: (req: Proxy) => APIClient.proxy.update(req),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ProxyKeys.lists() });
toast.custom(t => <Toast type="success" body={`Proxy ${proxy.name} was ${proxy.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
},
onError: () => {
toast.custom((t) => <Toast type="error" body="Proxy state could not be updated" t={t} />);
}
});
const onToggleMutation = (newState: boolean) => {
updateMutation.mutate({
...proxy,
enabled: newState
});
};
return (
<li>
<ProxyUpdateForm isOpen={isOpen} toggle={toggleUpdate} data={proxy} />
<div className="grid grid-cols-12 items-center py-1.5">
<div className="col-span-2 sm:col-span-1 flex pl-1 sm:pl-5 items-center">
<Checkbox value={proxy.enabled ?? false} setValue={onToggleMutation} />
</div>
<div className="col-span-7 sm:col-span-8 pl-12 sm:pr-6 py-3 block flex-col text-sm font-medium text-gray-900 dark:text-white truncate">
{proxy.name}
</div>
<div className="hidden md:block col-span-2 pr-6 py-3 text-left items-center whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 truncate">
{proxy.type}
</div>
<div className="col-span-1 flex first-letter:px-6 py-3 whitespace-nowrap text-right text-sm font-medium">
<span
className="col-span-1 px-6 text-blue-600 dark:text-gray-300 hover:text-blue-900 dark:hover:text-blue-500 cursor-pointer"
onClick={toggleUpdate}
>
Edit
</span>
</div>
</div>
</li>
);
}
function ProxySettings() {
const [addProxyIsOpen, toggleAddProxy] = useToggle(false);
const proxiesQuery = useSuspenseQuery(ProxiesQueryOptions())
const proxies = proxiesQuery.data
return (
<Section
title="Proxies"
description={
<>
Proxies that can be used with Indexers, feeds and IRC.<br/>
</>
}
rightSide={
<button
type="button"
onClick={toggleAddProxy}
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
>
<PlusIcon className="h-5 w-5 mr-1"/>
Add new
</button>
}
>
<ProxyAddForm isOpen={addProxyIsOpen} toggle={toggleAddProxy} />
<div className="flex flex-col">
{proxies.length ? (
<ul className="min-w-full relative">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
<div
className="flex col-span-2 sm:col-span-1 pl-0 sm:pl-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
// onClick={() => sortedIndexers.requestSort("enabled")}
>
Enabled
{/*<span className="sort-indicator">{sortedIndexers.getSortIndicator("enabled")}</span>*/}
</div>
<div
className="col-span-7 sm:col-span-8 pl-12 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
// onClick={() => sortedIndexers.requestSort("name")}
>
Name
{/*<span className="sort-indicator">{sortedIndexers.getSortIndicator("name")}</span>*/}
</div>
<div
className="hidden md:flex col-span-1 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
// onClick={() => sortedIndexers.requestSort("implementation")}
>
Type
{/*<span className="sort-indicator">{sortedIndexers.getSortIndicator("implementation")}</span>*/}
</div>
</li>
{proxies.map((proxy) => (
<ListItem proxy={proxy} key={proxy.id}/>
))}
</ul>
) : (
<EmptySimple
title="No proxies"
subtitle=""
buttonText="Add new proxy"
buttonAction={toggleAddProxy}
/>
)}
</div>
</Section>
);
}
export default ProxySettings;

View file

@ -11,6 +11,7 @@ export { default as Indexer } from "./Indexer";
export { default as Irc } from "./Irc";
export { default as Logs } from "./Logs";
export { default as Notification } from "./Notifications";
export { default as Proxy } from "./Proxy";
export { default as Release } from "./Releases";
export { default as RegexPlayground } from "./RegexPlayground";
export { default as Account } from "./Account";

View file

@ -11,6 +11,8 @@ interface Indexer {
enabled: boolean;
implementation: string;
base_url: string;
use_proxy?: boolean;
proxy_id?: number;
settings: Array<IndexerSetting>;
}
@ -35,6 +37,8 @@ interface IndexerDefinition {
protocol: string;
urls: string[];
supports: string[];
use_proxy?: boolean;
proxy_id?: number;
settings: IndexerSetting[];
irc: IndexerIRC;
torznab: IndexerTorznab;

View file

@ -20,6 +20,8 @@ interface IrcNetwork {
channels: IrcChannel[];
connected: boolean;
connected_since: string;
use_proxy: boolean;
proxy_id: number;
}
interface IrcNetworkCreate {
@ -53,23 +55,8 @@ interface IrcChannelWithHealth extends IrcChannel {
last_announce: string;
}
interface IrcNetworkWithHealth {
id: number;
name: string;
enabled: boolean;
server: string;
port: number;
tls: boolean;
pass: string;
nick: string;
auth: IrcAuth; // optional
invite_command: string;
use_bouncer: boolean;
bouncer_addr: string;
bot_mode: boolean;
interface IrcNetworkWithHealth extends IrcNetwork {
channels: IrcChannelWithHealth[];
connected: boolean;
connected_since: string;
connection_errors: string[];
healthy: boolean;
}

27
web/src/types/Proxy.d.ts vendored Normal file
View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
interface Proxy {
id: number;
name: string;
enabled: boolean;
type: ProxyType;
addr: string;
user?: string;
pass?: string;
timeout?: number;
}
interface ProxyCreate {
name: string;
enabled: boolean;
type: ProxyType;
addr: string;
user?: string;
pass?: string;
timeout?: number;
}
type ProxyType = "SOCKS5" | "HTTP";