Skip to content

Commit f30dab0

Browse files
committed
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
1 parent 9726a0d commit f30dab0

File tree

6 files changed

+152
-75
lines changed

6 files changed

+152
-75
lines changed

app/Http/Controllers/Api/Client/ClientController.php

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
use Pterodactyl\Models\Server;
66
use Pterodactyl\Models\Permission;
77
use Spatie\QueryBuilder\QueryBuilder;
8+
use Spatie\QueryBuilder\AllowedFilter;
9+
use Pterodactyl\Models\Filters\MultiFieldServerFilter;
810
use Pterodactyl\Repositories\Eloquent\ServerRepository;
911
use Pterodactyl\Transformers\Api\Client\ServerTransformer;
1012
use Pterodactyl\Http\Requests\Api\Client\GetServersRequest;
@@ -43,21 +45,32 @@ public function index(GetServersRequest $request): array
4345
// Start the query builder and ensure we eager load any requested relationships from the request.
4446
$builder = QueryBuilder::for(
4547
Server::query()->with($this->getIncludesForTransformer($transformer, ['node']))
46-
)->allowedFilters('uuid', 'name', 'external_id');
48+
)->allowedFilters([
49+
'uuid',
50+
'name',
51+
'external_id',
52+
AllowedFilter::custom('*', new MultiFieldServerFilter),
53+
]);
4754

55+
$type = $request->input('type');
4856
// Either return all of the servers the user has access to because they are an admin `?type=admin` or
4957
// just return all of the servers the user has access to because they are the owner or a subuser of the
50-
// server.
51-
if ($request->input('type') === 'admin') {
52-
$builder = $user->root_admin
53-
? $builder->whereNotIn('id', $user->accessibleServers()->pluck('id')->all())
54-
// If they aren't an admin but want all the admin servers don't fail the request, just
55-
// make it a query that will never return any results back.
56-
: $builder->whereRaw('1 = 2');
57-
} elseif ($request->input('type') === 'owner') {
58-
$builder = $builder->where('owner_id', $user->id);
58+
// server. If ?type=admin-all is passed all servers on the system will be returned to the user, rather
59+
// than only servers they can see because they are an admin.
60+
if (in_array($type, ['admin', 'admin-all'])) {
61+
// If they aren't an admin but want all the admin servers don't fail the request, just
62+
// make it a query that will never return any results back.
63+
if (! $user->root_admin) {
64+
$builder->whereRaw('1 = 2');
65+
} else {
66+
$builder = $type === 'admin-all'
67+
? $builder
68+
: $builder->whereNotIn('servers.id', $user->accessibleServers()->pluck('id')->all());
69+
}
70+
} else if ($type === 'owner') {
71+
$builder = $builder->where('servers.owner_id', $user->id);
5972
} else {
60-
$builder = $builder->whereIn('id', $user->accessibleServers()->pluck('id')->all());
73+
$builder = $builder->whereIn('servers.id', $user->accessibleServers()->pluck('id')->all());
6174
}
6275

6376
$servers = $builder->paginate(min($request->query('per_page', 50), 100))->appends($request->query());
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace Pterodactyl\Models\Filters;
4+
5+
use BadMethodCallException;
6+
use Illuminate\Support\Str;
7+
use Spatie\QueryBuilder\Filters\Filter;
8+
use Illuminate\Database\Eloquent\Builder;
9+
10+
class MultiFieldServerFilter implements Filter
11+
{
12+
/**
13+
* If we detect that the value matches an IPv4 address we will use a different type of filtering
14+
* to look at the allocations.
15+
*/
16+
private const IPV4_REGEX = '/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(\:\d{1,5})?$/';
17+
18+
/**
19+
* A multi-column filter for the servers table that allows you to pass in a single value and
20+
* search across multiple columns. This allows us to provide a very generic search ability for
21+
* the frontend.
22+
*
23+
* @param \Illuminate\Database\Eloquent\Builder $query
24+
* @param string $value
25+
* @param string $property
26+
*/
27+
public function __invoke(Builder $query, $value, string $property)
28+
{
29+
if ($query->getQuery()->from !== 'servers') {
30+
throw new BadMethodCallException(
31+
'Cannot use the MultiFieldServerFilter against a non-server model.'
32+
);
33+
}
34+
35+
if (preg_match(self::IPV4_REGEX, $value) || preg_match('/^:\d{1,5}$/', $value)) {
36+
$query
37+
// Only select the server values, otherwise you'll end up merging the allocation and
38+
// server objects together, resulting in incorrect behavior and returned values.
39+
->select('servers.*')
40+
->join('allocations', 'allocations.server_id', '=', 'servers.id')
41+
->where(function (Builder $builder) use ($value) {
42+
$parts = explode(':', $value);
43+
44+
$builder->when(
45+
!Str::startsWith($value, ':'),
46+
// When the string does not start with a ":" it means we're looking for an IP or IP:Port
47+
// combo, so use a query to handle that.
48+
function (Builder $builder) use ($parts) {
49+
$builder->orWhere('allocations.ip', $parts[0]);
50+
if (!is_null($parts[1] ?? null)) {
51+
$builder->where('allocations.port', 'LIKE', "%{$parts[1]}");
52+
}
53+
},
54+
// Otherwise, just try to search for that specific port in the allocations.
55+
function (Builder $builder) use ($value) {
56+
$builder->orWhere('allocations.port', substr($value, 1));
57+
}
58+
);
59+
})
60+
->groupBy('servers.id');
61+
62+
return;
63+
}
64+
65+
$query
66+
->where(function (Builder $builder) use ($value) {
67+
$builder->where('servers.uuid', $value)
68+
->orWhere('servers.uuid', 'LIKE', "$value%")
69+
->orWhere('servers.uuidShort', $value)
70+
->orWhere('servers.external_id', $value)
71+
->orWhereRaw('LOWER(servers.name) LIKE ?', ["%$value%"]);
72+
});
73+
}
74+
}

resources/scripts/api/getServers.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,15 @@ import http, { getPaginationSet, PaginatedResult } from '@/api/http';
44
interface QueryParams {
55
query?: string;
66
page?: number;
7-
onlyAdmin?: boolean;
7+
type?: string;
88
}
99

10-
export default ({ query, page = 1, onlyAdmin = false }: QueryParams): Promise<PaginatedResult<Server>> => {
10+
export default ({ query, ...params }: QueryParams): Promise<PaginatedResult<Server>> => {
1111
return new Promise((resolve, reject) => {
1212
http.get('/api/client', {
1313
params: {
14-
type: onlyAdmin ? 'admin' : undefined,
15-
'filter[name]': query,
16-
page,
14+
'filter[*]': query,
15+
...params,
1716
},
1817
})
1918
.then(({ data }) => resolve({

resources/scripts/components/dashboard/DashboardContainer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export default () => {
2121

2222
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
2323
[ '/api/client/servers', showOnlyAdmin, page ],
24-
() => getServers({ onlyAdmin: showOnlyAdmin, page }),
24+
() => getServers({ page, type: showOnlyAdmin ? 'admin' : undefined }),
2525
);
2626

2727
useEffect(() => {

resources/scripts/components/dashboard/search/SearchModal.tsx

Lines changed: 48 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -47,23 +47,21 @@ const SearchWatcher = () => {
4747

4848
export default ({ ...props }: Props) => {
4949
const ref = useRef<HTMLInputElement>(null);
50-
const [ loading, setLoading ] = useState(false);
51-
const [ servers, setServers ] = useState<Server[]>([]);
5250
const isAdmin = useStoreState(state => state.user.data!.rootAdmin);
53-
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
51+
const [ servers, setServers ] = useState<Server[]>([]);
52+
const { clearAndAddHttpError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
5453

5554
const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => {
56-
setLoading(true);
57-
setSubmitting(false);
5855
clearFlashes('search');
5956

60-
getServers({ query: term })
57+
// if (ref.current) ref.current.focus();
58+
getServers({ query: term, type: isAdmin ? 'admin-all' : undefined })
6159
.then(servers => setServers(servers.items.filter((_, index) => index < 5)))
6260
.catch(error => {
6361
console.error(error);
64-
addError({ key: 'search', message: httpErrorToHuman(error) });
62+
clearAndAddHttpError({ key: 'search', error });
6563
})
66-
.then(() => setLoading(false))
64+
.then(() => setSubmitting(false))
6765
.then(() => ref.current?.focus());
6866
}, 500);
6967

@@ -74,7 +72,7 @@ export default ({ ...props }: Props) => {
7472
}, [ props.visible ]);
7573

7674
// Formik does not support an innerRef on custom components.
77-
const InputWithRef = (props: any) => <Input {...props} ref={ref}/>;
75+
const InputWithRef = (props: any) => <Input autoFocus {...props} ref={ref}/>;
7876

7977
return (
8078
<Formik
@@ -84,53 +82,51 @@ export default ({ ...props }: Props) => {
8482
})}
8583
initialValues={{ term: '' } as Values}
8684
>
87-
<Modal {...props}>
88-
<Form>
89-
<FormikFieldWrapper
90-
name={'term'}
91-
label={'Search term'}
92-
description={
93-
isAdmin
94-
? 'Enter a server name, user email, or uuid to begin searching.'
95-
: 'Enter a server name to begin searching.'
96-
}
97-
>
98-
<SearchWatcher/>
99-
<InputSpinner visible={loading}>
100-
<Field as={InputWithRef} name={'term'}/>
101-
</InputSpinner>
102-
</FormikFieldWrapper>
103-
</Form>
104-
{servers.length > 0 &&
105-
<div css={tw`mt-6`}>
106-
{
107-
servers.map(server => (
108-
<ServerResult
109-
key={server.uuid}
110-
to={`/server/${server.id}`}
111-
onClick={() => props.onDismissed()}
112-
>
113-
<div>
114-
<p css={tw`text-sm`}>{server.name}</p>
115-
<p css={tw`mt-1 text-xs text-neutral-400`}>
116-
{
117-
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
118-
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
119-
))
120-
}
121-
</p>
122-
</div>
123-
<div css={tw`flex-1 text-right`}>
85+
{({ isSubmitting }) => (
86+
<Modal {...props}>
87+
<Form>
88+
<FormikFieldWrapper
89+
name={'term'}
90+
label={'Search term'}
91+
description={'Enter a server name, uuid, or allocation to begin searching.'}
92+
>
93+
<SearchWatcher/>
94+
<InputSpinner visible={isSubmitting}>
95+
<Field as={InputWithRef} name={'term'}/>
96+
</InputSpinner>
97+
</FormikFieldWrapper>
98+
</Form>
99+
{servers.length > 0 &&
100+
<div css={tw`mt-6`}>
101+
{
102+
servers.map(server => (
103+
<ServerResult
104+
key={server.uuid}
105+
to={`/server/${server.id}`}
106+
onClick={() => props.onDismissed()}
107+
>
108+
<div>
109+
<p css={tw`text-sm`}>{server.name}</p>
110+
<p css={tw`mt-1 text-xs text-neutral-400`}>
111+
{
112+
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
113+
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
114+
))
115+
}
116+
</p>
117+
</div>
118+
<div css={tw`flex-1 text-right`}>
124119
<span css={tw`text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded`}>
125120
{server.node}
126121
</span>
127-
</div>
128-
</ServerResult>
129-
))
122+
</div>
123+
</ServerResult>
124+
))
125+
}
126+
</div>
130127
}
131-
</div>
132-
}
133-
</Modal>
128+
</Modal>
129+
)}
134130
</Formik>
135131
);
136132
};

resources/scripts/components/elements/InputSpinner.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,7 @@ import tw from 'twin.macro';
55

66
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
77
<div css={tw`relative`}>
8-
<Fade
9-
appear
10-
unmountOnExit
11-
in={visible}
12-
timeout={150}
13-
>
8+
<Fade appear unmountOnExit in={visible} timeout={150}>
149
<div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}>
1510
<Spinner size={'small'}/>
1611
</div>

0 commit comments

Comments
 (0)