diff --git a/app/Http/Controllers/Api/Application/Users/UserController.php b/app/Http/Controllers/Api/Application/Users/UserController.php index 9976ee227..c41644d58 100644 --- a/app/Http/Controllers/Api/Application/Users/UserController.php +++ b/app/Http/Controllers/Api/Application/Users/UserController.php @@ -2,6 +2,7 @@ namespace Pterodactyl\Http\Controllers\Api\Application\Users; +use Illuminate\Support\Arr; use Pterodactyl\Models\User; use Illuminate\Http\Response; use Illuminate\Http\JsonResponse; @@ -49,20 +50,21 @@ class UserController extends ApplicationApiController $users = QueryBuilder::for(User::query()) ->allowedFilters([ - 'id', - 'uuid', + AllowedFilter::exact('id'), + AllowedFilter::exact('uuid'), + AllowedFilter::exact('external_id'), 'username', 'email', - 'external_id', - AllowedFilter::callback('*', function (Builder $builder) use ($request) { - $value = trim($request->input('filters.*'), '%'); - - return $builder->where(function (Builder $builder) use ($value) { - $builder->where('uuid', 'LIKE', $value . '%') - ->orWhere('username', 'LIKE', $value . '%') - ->orWhere('email', 'LIKE', $value . '%') - ->orWhere('external_id', 'LIKE', $value . '%'); - }); + AllowedFilter::callback('*', function (Builder $builder, $value) { + foreach (Arr::wrap($value) as $datum) { + $datum = '%' . $datum . '%'; + $builder->where(function (Builder $builder) use ($datum) { + $builder->where('uuid', 'LIKE', $datum) + ->orWhere('username', 'LIKE', $datum) + ->orWhere('email', 'LIKE', $datum) + ->orWhere('external_id', 'LIKE', $datum); + }); + } }), ]) ->allowedSorts(['id', 'uuid', 'username', 'email', 'admin_role_id']) diff --git a/resources/scripts/components/admin/users/UsersContainer.tsx b/resources/scripts/components/admin/users/UsersContainer.tsx index 56231a8b2..c02e06453 100644 --- a/resources/scripts/components/admin/users/UsersContainer.tsx +++ b/resources/scripts/components/admin/users/UsersContainer.tsx @@ -16,7 +16,10 @@ const filters = [ 'id', 'uuid', 'external_id', 'username', 'email' ] as const; const UsersContainer = () => { const [ search, setSearch ] = useDebouncedState('', 500); const [ selected, setSelected ] = useState([]); - const { data: users } = useGetUsers(extractSearchFilters(search, filters)); + const { data: users } = useGetUsers(extractSearchFilters(search, filters, { + splitUnmatched: true, + returnUnmatched: true, + })); useEffect(() => { document.title = 'Admin | Users'; diff --git a/resources/scripts/helpers/extractSearchFilters.spec.ts b/resources/scripts/helpers/extractSearchFilters.spec.ts index ea36315fb..4bc9c90e5 100644 --- a/resources/scripts/helpers/extractSearchFilters.spec.ts +++ b/resources/scripts/helpers/extractSearchFilters.spec.ts @@ -3,18 +3,17 @@ import extractSearchFilters from '@/helpers/extractSearchFilters'; type TestCase = [ string, 0 | Record ]; describe('@/helpers/extractSearchFilters.ts', function () { - const _DEFAULT = 0x00; const cases: TestCase[] = [ [ '', {} ], - [ 'hello world', _DEFAULT ], + [ 'hello world', {} ], [ 'bar:xyz foo:abc', { bar: [ 'xyz' ], foo: [ 'abc' ] } ], [ 'hello foo:abc', { foo: [ 'abc' ] } ], [ 'hello foo:abc world another bar:xyz hodor', { foo: [ 'abc' ], bar: [ 'xyz' ] } ], [ 'foo:1 foo:2 foo: 3 foo:4', { foo: [ '1', '2', '4' ] } ], [ ' foo:123 foo:bar:123 foo: foo:string', { foo: [ '123', 'bar:123', 'string' ] } ], [ 'foo:1 bar:2 baz:3', { foo: [ '1' ], bar: [ '2' ] } ], - [ 'hello "world this" is quoted', _DEFAULT ], - [ 'hello "world foo:123 is" quoted', _DEFAULT ], + [ 'hello "world this" is quoted', {} ], + [ 'hello "world foo:123 is" quoted', {} ], [ 'hello foo:"this is quoted" bar:"this \\"is deeply\\" quoted" world foo:another', { foo: [ 'this is quoted', 'another' ], bar: [ 'this "is deeply" quoted' ], @@ -23,23 +22,59 @@ describe('@/helpers/extractSearchFilters.ts', function () { it.each(cases)('should return expected filters: [%s]', function (input, output) { expect(extractSearchFilters(input, [ 'foo', 'bar' ])).toStrictEqual({ - filters: output === _DEFAULT ? { - '*': [ input ], - } : output, + filters: output, }); }); it('should allow modification of the default parameter', function () { - expect(extractSearchFilters('hello world', [ 'foo' ], 'default_param')).toStrictEqual({ + expect(extractSearchFilters('hello world', [ 'foo' ], { defaultFilter: 'default_param', returnUnmatched: true })).toStrictEqual({ filters: { default_param: [ 'hello world' ], }, }); - expect(extractSearchFilters('foo:123 bar', [ 'foo' ], 'default_param')).toStrictEqual({ + expect(extractSearchFilters('foo:123 bar', [ 'foo' ], { defaultFilter: 'default_param' })).toStrictEqual({ filters: { foo: [ '123' ], }, }); }); + + it.each([ + [ '', {} ], + [ 'hello world', { '*': [ 'hello world' ] } ], + [ 'hello world foo:123 bar:456', { foo: [ '123' ], bar: [ '456' ], '*': [ 'hello world' ] } ], + [ 'hello world foo:123 another string', { foo: [ '123' ], '*': [ 'hello world another string' ] } ], + ])('should return unmatched parameters: %s', function (input, output) { + expect(extractSearchFilters(input, [ 'foo', 'bar' ], { returnUnmatched: true })).toStrictEqual({ + filters: output, + }); + }); + + it.each([ + [ '', {} ], + [ 'hello world', { '*': [ 'hello', 'world' ] } ], + [ 'hello world foo:123 bar:456', { foo: [ '123' ], bar: [ '456' ], '*': [ 'hello', 'world' ] } ], + [ 'hello world foo:123 another string', { foo: [ '123' ], '*': [ 'hello', 'world', 'another', 'string' ] } ], + ])('should split unmatched parameters: %s', function (input, output) { + expect(extractSearchFilters(input, [ 'foo', 'bar' ], { + returnUnmatched: true, + splitUnmatched: true, + })).toStrictEqual({ + filters: output, + }); + }); + + it.each([ true, false ])('should return the unsplit value (splitting: %s)', function (split) { + const extracted = extractSearchFilters('hello foo:123 bar:123 world', [ 'foo' ], { + returnUnmatched: true, + splitUnmatched: split, + }); + expect(extracted).toStrictEqual({ + filters: { + foo: [ '123' ], + '*': split ? [ 'hello', 'bar:123', 'world' ] : [ 'hello bar:123 world' ], + }, + }); + }); }); diff --git a/resources/scripts/helpers/extractSearchFilters.ts b/resources/scripts/helpers/extractSearchFilters.ts index a603d81c4..f3213d128 100644 --- a/resources/scripts/helpers/extractSearchFilters.ts +++ b/resources/scripts/helpers/extractSearchFilters.ts @@ -1,40 +1,49 @@ import { QueryBuilderParams } from '@/api/http'; import splitStringWhitespace from '@/helpers/splitStringWhitespace'; -const extractSearchFilters = ( +interface Options { + defaultFilter?: D; + splitUnmatched?: boolean; + returnUnmatched?: boolean; +} + +const extractSearchFilters = ( str: string, params: Readonly, - defaultFilter: D = '*' as D, -): QueryBuilderParams | QueryBuilderParams => { - const filters: Map = new Map(); + options?: Options, +): QueryBuilderParams | QueryBuilderParams | QueryBuilderParams => { + const opts: Required> = { + defaultFilter: options?.defaultFilter || '*' as D, + splitUnmatched: options?.splitUnmatched || false, + returnUnmatched: options?.returnUnmatched || false, + }; - if (str.trim().length === 0) { - return { filters: {} }; - } + const filters: Map = new Map(); + const unmatched: string[] = []; for (const segment of splitStringWhitespace(str)) { const parts = segment.split(':'); const filter = parts[0] as T; const value = parts.slice(1).join(':'); - // @ts-ignore - if (!filter || !value || !params.includes(filter)) { - continue; + if (!filter || (parts.length > 1 && filter && !value)) { + // do nothing + } else if (!params.includes(filter)) { + unmatched.push(segment); + } else { + filters.set(filter, [ ...(filters.get(filter) || []), value ]); } + } - filters.set(filter, [ ...(filters.get(filter) || []), value ]); + if (opts.returnUnmatched && str.trim().length > 0) { + filters.set(opts.defaultFilter as any, opts.splitUnmatched ? unmatched : [ unmatched.join(' ') ]); } if (filters.size === 0) { - return { - filters: { - [defaultFilter]: [ str ] as Readonly, - } as unknown as QueryBuilderParams['filters'], - }; + return { filters: {} }; } - return { - filters: Object.fromEntries(filters) as unknown as QueryBuilderParams['filters'], - }; + // @ts-expect-error + return { filters: Object.fromEntries(filters) }; }; export default extractSearchFilters; diff --git a/webpack.config.js b/webpack.config.js index e1c9ebb06..79466a90b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -26,7 +26,7 @@ module.exports = { rules: [ { test: /\.tsx?$/, - exclude: /node_modules/, + exclude: /node_modules|\.spec\.tsx?$/, loader: 'babel-loader', }, {