From f30dab053b68e01efe224f677dc24efaadec8112 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Thu, 15 Oct 2020 21:21:38 -0700 Subject: [PATCH] Support much better server querying from frontend Search all servers if making a query as an admin, allow searching by a more complex set of data, fix unfocus on search field when loading indicator was rendered --- .../Api/Client/ClientController.php | 35 ++++-- app/Models/Filters/MultiFieldServerFilter.php | 74 +++++++++++++ resources/scripts/api/getServers.ts | 9 +- .../dashboard/DashboardContainer.tsx | 2 +- .../dashboard/search/SearchModal.tsx | 100 +++++++++--------- .../components/elements/InputSpinner.tsx | 7 +- 6 files changed, 152 insertions(+), 75 deletions(-) create mode 100644 app/Models/Filters/MultiFieldServerFilter.php diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 5eec40b51..243868b5e 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -5,6 +5,8 @@ namespace Pterodactyl\Http\Controllers\Api\Client; use Pterodactyl\Models\Server; use Pterodactyl\Models\Permission; use Spatie\QueryBuilder\QueryBuilder; +use Spatie\QueryBuilder\AllowedFilter; +use Pterodactyl\Models\Filters\MultiFieldServerFilter; use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Transformers\Api\Client\ServerTransformer; use Pterodactyl\Http\Requests\Api\Client\GetServersRequest; @@ -43,21 +45,32 @@ class ClientController extends ClientApiController // Start the query builder and ensure we eager load any requested relationships from the request. $builder = QueryBuilder::for( Server::query()->with($this->getIncludesForTransformer($transformer, ['node'])) - )->allowedFilters('uuid', 'name', 'external_id'); + )->allowedFilters([ + 'uuid', + 'name', + 'external_id', + AllowedFilter::custom('*', new MultiFieldServerFilter), + ]); + $type = $request->input('type'); // Either return all of the servers the user has access to because they are an admin `?type=admin` or // just return all of the servers the user has access to because they are the owner or a subuser of the - // server. - if ($request->input('type') === 'admin') { - $builder = $user->root_admin - ? $builder->whereNotIn('id', $user->accessibleServers()->pluck('id')->all()) - // If they aren't an admin but want all the admin servers don't fail the request, just - // make it a query that will never return any results back. - : $builder->whereRaw('1 = 2'); - } elseif ($request->input('type') === 'owner') { - $builder = $builder->where('owner_id', $user->id); + // server. If ?type=admin-all is passed all servers on the system will be returned to the user, rather + // than only servers they can see because they are an admin. + if (in_array($type, ['admin', 'admin-all'])) { + // If they aren't an admin but want all the admin servers don't fail the request, just + // make it a query that will never return any results back. + if (! $user->root_admin) { + $builder->whereRaw('1 = 2'); + } else { + $builder = $type === 'admin-all' + ? $builder + : $builder->whereNotIn('servers.id', $user->accessibleServers()->pluck('id')->all()); + } + } else if ($type === 'owner') { + $builder = $builder->where('servers.owner_id', $user->id); } else { - $builder = $builder->whereIn('id', $user->accessibleServers()->pluck('id')->all()); + $builder = $builder->whereIn('servers.id', $user->accessibleServers()->pluck('id')->all()); } $servers = $builder->paginate(min($request->query('per_page', 50), 100))->appends($request->query()); diff --git a/app/Models/Filters/MultiFieldServerFilter.php b/app/Models/Filters/MultiFieldServerFilter.php new file mode 100644 index 000000000..ed47d132f --- /dev/null +++ b/app/Models/Filters/MultiFieldServerFilter.php @@ -0,0 +1,74 @@ +getQuery()->from !== 'servers') { + throw new BadMethodCallException( + 'Cannot use the MultiFieldServerFilter against a non-server model.' + ); + } + + if (preg_match(self::IPV4_REGEX, $value) || preg_match('/^:\d{1,5}$/', $value)) { + $query + // Only select the server values, otherwise you'll end up merging the allocation and + // server objects together, resulting in incorrect behavior and returned values. + ->select('servers.*') + ->join('allocations', 'allocations.server_id', '=', 'servers.id') + ->where(function (Builder $builder) use ($value) { + $parts = explode(':', $value); + + $builder->when( + !Str::startsWith($value, ':'), + // When the string does not start with a ":" it means we're looking for an IP or IP:Port + // combo, so use a query to handle that. + function (Builder $builder) use ($parts) { + $builder->orWhere('allocations.ip', $parts[0]); + if (!is_null($parts[1] ?? null)) { + $builder->where('allocations.port', 'LIKE', "%{$parts[1]}"); + } + }, + // Otherwise, just try to search for that specific port in the allocations. + function (Builder $builder) use ($value) { + $builder->orWhere('allocations.port', substr($value, 1)); + } + ); + }) + ->groupBy('servers.id'); + + return; + } + + $query + ->where(function (Builder $builder) use ($value) { + $builder->where('servers.uuid', $value) + ->orWhere('servers.uuid', 'LIKE', "$value%") + ->orWhere('servers.uuidShort', $value) + ->orWhere('servers.external_id', $value) + ->orWhereRaw('LOWER(servers.name) LIKE ?', ["%$value%"]); + }); + } +} diff --git a/resources/scripts/api/getServers.ts b/resources/scripts/api/getServers.ts index 63329bfa7..6094b1706 100644 --- a/resources/scripts/api/getServers.ts +++ b/resources/scripts/api/getServers.ts @@ -4,16 +4,15 @@ import http, { getPaginationSet, PaginatedResult } from '@/api/http'; interface QueryParams { query?: string; page?: number; - onlyAdmin?: boolean; + type?: string; } -export default ({ query, page = 1, onlyAdmin = false }: QueryParams): Promise> => { +export default ({ query, ...params }: QueryParams): Promise> => { return new Promise((resolve, reject) => { http.get('/api/client', { params: { - type: onlyAdmin ? 'admin' : undefined, - 'filter[name]': query, - page, + 'filter[*]': query, + ...params, }, }) .then(({ data }) => resolve({ diff --git a/resources/scripts/components/dashboard/DashboardContainer.tsx b/resources/scripts/components/dashboard/DashboardContainer.tsx index f8b13eda2..b4cb4c290 100644 --- a/resources/scripts/components/dashboard/DashboardContainer.tsx +++ b/resources/scripts/components/dashboard/DashboardContainer.tsx @@ -21,7 +21,7 @@ export default () => { const { data: servers, error } = useSWR>( [ '/api/client/servers', showOnlyAdmin, page ], - () => getServers({ onlyAdmin: showOnlyAdmin, page }), + () => getServers({ page, type: showOnlyAdmin ? 'admin' : undefined }), ); useEffect(() => { diff --git a/resources/scripts/components/dashboard/search/SearchModal.tsx b/resources/scripts/components/dashboard/search/SearchModal.tsx index 9bd871ce5..e8a2a7511 100644 --- a/resources/scripts/components/dashboard/search/SearchModal.tsx +++ b/resources/scripts/components/dashboard/search/SearchModal.tsx @@ -47,23 +47,21 @@ const SearchWatcher = () => { export default ({ ...props }: Props) => { const ref = useRef(null); - const [ loading, setLoading ] = useState(false); - const [ servers, setServers ] = useState([]); const isAdmin = useStoreState(state => state.user.data!.rootAdmin); - const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + const [ servers, setServers ] = useState([]); + const { clearAndAddHttpError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers) => { - setLoading(true); - setSubmitting(false); clearFlashes('search'); - getServers({ query: term }) + // if (ref.current) ref.current.focus(); + getServers({ query: term, type: isAdmin ? 'admin-all' : undefined }) .then(servers => setServers(servers.items.filter((_, index) => index < 5))) .catch(error => { console.error(error); - addError({ key: 'search', message: httpErrorToHuman(error) }); + clearAndAddHttpError({ key: 'search', error }); }) - .then(() => setLoading(false)) + .then(() => setSubmitting(false)) .then(() => ref.current?.focus()); }, 500); @@ -74,7 +72,7 @@ export default ({ ...props }: Props) => { }, [ props.visible ]); // Formik does not support an innerRef on custom components. - const InputWithRef = (props: any) => ; + const InputWithRef = (props: any) => ; return ( { })} initialValues={{ term: '' } as Values} > - -
- - - - - - -
- {servers.length > 0 && -
- { - servers.map(server => ( - props.onDismissed()} - > -
-

{server.name}

-

- { - server.allocations.filter(alloc => alloc.isDefault).map(allocation => ( - {allocation.alias || allocation.ip}:{allocation.port} - )) - } -

-
-
+ {({ isSubmitting }) => ( + +
+ + + + + + +
+ {servers.length > 0 && +
+ { + servers.map(server => ( + props.onDismissed()} + > +
+

{server.name}

+

+ { + server.allocations.filter(alloc => alloc.isDefault).map(allocation => ( + {allocation.alias || allocation.ip}:{allocation.port} + )) + } +

+
+
{server.node} -
-
- )) +
+ + )) + } +
} -
- } -
+ + )}
); }; diff --git a/resources/scripts/components/elements/InputSpinner.tsx b/resources/scripts/components/elements/InputSpinner.tsx index ce4843dff..cac920f8a 100644 --- a/resources/scripts/components/elements/InputSpinner.tsx +++ b/resources/scripts/components/elements/InputSpinner.tsx @@ -5,12 +5,7 @@ import tw from 'twin.macro'; const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
- +