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

@ -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>
);
}