diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index d69cd748164598edf287cfa10e9baf6673c33dd7..4e2895706bad20560e971c8ebdb9bda167518735 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -9,6 +9,7 @@ use Spatie\QueryBuilder\AllowedFilter; use Pterodactyl\Models\Filters\MultiFieldServerFilter; use Pterodactyl\Transformers\Api\Client\ServerTransformer; use Pterodactyl\Http\Requests\Api\Client\GetServersRequest; +use Pterodactyl\Services\Servers\ServerListRelationsService; class ClientController extends ClientApiController { @@ -62,6 +63,7 @@ class ClientController extends ClientApiController } $servers = $builder->paginate(min($request->query('per_page', 50), 100))->appends($request->query()); + app(ServerListRelationsService::class)->handle($servers->getCollection()); return $this->fractal->transformWith($transformer)->collection($servers)->toArray(); } diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php index 1d5de0da0ffb0de2d2c8fe93382f0234e9b85d15..1246e50b381f17ce88d1f4633526c90000a43b92 100644 --- a/app/Models/ApiKey.php +++ b/app/Models/ApiKey.php @@ -66,6 +66,29 @@ class ApiKey extends Model implements HasAbilities /** @use HasFactory<\Database\Factories\ApiKeyFactory> */ use HasFactory; + /** + * Sanctum saves the usage timestamp on authenticated requests. An unchanged + * key does not need identity/foreign-key queries on every timestamp update. + * Key creation and changes to any other attribute still use full validation. + */ + public function validate(): void + { + if ($this->skipValidation) { + return; + } + + if ($this->exists && !array_diff(array_keys($this->getDirty()), ['last_used_at', 'updated_at'])) { + static::$validatorFactory->make( + ['last_used_at' => $this->last_used_at], + ['last_used_at' => static::$validationRules['last_used_at']], + )->validate(); + + return; + } + + parent::validate(); + } + /** * The resource name for this model when it is transformed into an * API representation using fractal. diff --git a/app/Services/Servers/GetUserPermissionsService.php b/app/Services/Servers/GetUserPermissionsService.php index ae91cb33ff3009bb2a41fceaebbe83a99fa0a34c..1e85c16c238e449c642064c1b4a075573dadd2b3 100644 --- a/app/Services/Servers/GetUserPermissionsService.php +++ b/app/Services/Servers/GetUserPermissionsService.php @@ -27,7 +27,9 @@ class GetUserPermissionsService } /** @var \Pterodactyl\Models\Subuser|null $subuserPermissions */ - $subuserPermissions = $server->subusers()->where('user_id', $user->id)->first(); + $subuserPermissions = $server->relationLoaded('subusers') + ? $server->subusers->firstWhere('user_id', $user->id) + : $server->subusers()->where('user_id', $user->id)->first(); return $subuserPermissions ? $subuserPermissions->permissions : []; } diff --git a/app/Services/Servers/ServerListRelationsService.php b/app/Services/Servers/ServerListRelationsService.php new file mode 100644 index 0000000000000000000000000000000000000000..6d739ccbf6d51f1bad299c462e2af3506253f752 --- /dev/null +++ b/app/Services/Servers/ServerListRelationsService.php @@ -0,0 +1,48 @@ + $servers + */ + public function handle(Collection $servers): void + { + if ($servers->isEmpty()) { + return; + } + + $servers->loadMissing(['node', 'egg.configFrom', 'allocations', 'transfer', 'subusers']); + + // Server::variables() joins on the individual server ID, so ordinary + // eager loading would mix overrides between servers sharing an egg. + $definitions = EggVariable::query()->whereIn('egg_id', $servers->pluck('egg_id')->unique())->get()->groupBy('egg_id'); + $overrides = ServerVariable::query()->whereIn('server_id', $servers->modelKeys())->get()->groupBy('server_id'); + + foreach ($servers as $server) { + $values = ($overrides->get($server->id) ?? new Collection())->keyBy('variable_id'); + $variables = ($definitions->get($server->egg_id) ?? new Collection())->map(function (EggVariable $definition) use ($values) { + $variable = clone $definition; + $variable->setAttribute('server_value', $values->get($definition->id)?->variable_value); + + return $variable; + }); + $server->setRelation('variables', $variables); + + // AllocationTransformer needs the parent only to identify the + // primary allocation; reuse the already hydrated server instance. + foreach ($server->allocations as $allocation) { + $allocation->setRelation('server', $server); + } + $server->allocation?->setRelation('server', $server); + } + } +} diff --git a/resources/scripts/components/App.spec.tsx b/resources/scripts/components/App.spec.tsx index 09a5fdfd0d3e5d1b4e3a3481ca87ba61e4b8114d..0701f2dc516e4228752b6ef0985fd14f0a731aa6 100644 --- a/resources/scripts/components/App.spec.tsx +++ b/resources/scripts/components/App.spec.tsx @@ -74,6 +74,20 @@ describe("components/App", () => { expect(store.getState().settings.data?.name).toBe("Test Panel"); }); + it.each([ + { path: "/", kind: "dashboard" }, + { path: "/auth/login", kind: "auth" }, + { path: "/server/srv-1", kind: "server" }, + ] as const)( + "mounts the resolved $kind shell synchronously", + ({ path, kind }) => { + history.push(path); + const Component = () =>
Ready
; + render(); + expect(screen.getByTestId("resolved-shell")).toBeDefined(); + }, + ); + it("routes to authentication router when on /auth/login", async () => { history.push("/auth/login"); diff --git a/resources/scripts/components/App.tsx b/resources/scripts/components/App.tsx index 967648bdbea5e10e71bf88a0db7794282900245e..eed62329e8bcb286c6b6fd80b1c5608f766555ee 100644 --- a/resources/scripts/components/App.tsx +++ b/resources/scripts/components/App.tsx @@ -19,6 +19,7 @@ import "@/assets/index.css"; import Spinner from "@/components/elements/Spinner"; import PrivacyMode from "@/components/elements/PrivacyMode"; import PanelBackground from "@/components/elements/PanelBackground"; +import type { InitialRouter } from "@/routers/initialRouter"; const DashboardRouter = lazy(() => import("@/routers/DashboardRouter")); const ServerRouter = lazy(() => import("@/routers/ServerRouter")); @@ -44,7 +45,17 @@ interface ExtendedWindow extends Window { setupInterceptors(history); -const App = () => { +const App = ({ initialRouter }: { initialRouter?: InitialRouter }) => { + const AuthRouter = + initialRouter?.kind === "auth" + ? initialRouter.Component + : AuthenticationRouter; + const Dashboard = + initialRouter?.kind === "dashboard" + ? initialRouter.Component + : DashboardRouter; + const Server = + initialRouter?.kind === "server" ? initialRouter.Component : ServerRouter; const { PterodactylUser, SiteConfiguration } = window as ExtendedWindow; if (PterodactylUser && !store.getState().user.data) { store.getActions().user.setUserData({ @@ -78,7 +89,7 @@ const App = () => { path={"/auth/*"} element={ - + } /> @@ -88,7 +99,7 @@ const App = () => { - + @@ -99,7 +110,7 @@ const App = () => { element={ - + } diff --git a/resources/scripts/index.spec.tsx b/resources/scripts/index.spec.tsx index 22d17f8fb6009a51e598f8816899b7ebd8e95b67..2b2d65146b7c89b35d83e50a2746574dc84d35c3 100644 --- a/resources/scripts/index.spec.tsx +++ b/resources/scripts/index.spec.tsx @@ -4,6 +4,7 @@ const mockRender = vi.fn(); const mockCreateRoot = vi.fn((_el?: any) => ({ render: mockRender, })); +const mockLoadRouter = vi.fn(); vi.mock("react-dom/client", () => ({ createRoot: (el: any) => mockCreateRoot(el), @@ -12,12 +13,19 @@ vi.mock("@/components/App", () => ({ default: () => null, })); vi.mock("./i18n", () => ({})); +vi.mock("@/routers/initialRouter", () => ({ + loadInitialRouter: (...args: unknown[]) => mockLoadRouter(...args), +})); describe("index", () => { beforeEach(() => { vi.clearAllMocks(); document.body.innerHTML = ""; vi.resetModules(); + mockLoadRouter.mockResolvedValue({ + kind: "dashboard", + Component: () => null, + }); }); it("does not render when #app container is missing", async () => { @@ -35,4 +43,12 @@ describe("index", () => { expect(mockCreateRoot).toHaveBeenCalledWith(appDiv); expect(mockRender).toHaveBeenCalled(); }); + + it("falls back to App's lazy route error boundary if initial loading fails", async () => { + document.body.innerHTML = '
'; + mockLoadRouter.mockRejectedValue(new Error("Chunk unavailable")); + await import("./index"); + await vi.waitFor(() => expect(mockRender).toHaveBeenCalledTimes(2)); + expect(mockRender.mock.calls[1][0].props.initialRouter).toBeUndefined(); + }); }); diff --git a/resources/scripts/index.tsx b/resources/scripts/index.tsx index 64fda1da60bf5dac1c84769142681933a924a4fa..d5ead1a6c6977c880edaacb7659b339e49ce5b04 100644 --- a/resources/scripts/index.tsx +++ b/resources/scripts/index.tsx @@ -1,6 +1,8 @@ import React from "react"; import { createRoot } from "react-dom/client"; import App from "@/components/App"; +import Spinner from "@/components/elements/Spinner"; +import { loadInitialRouter } from "@/routers/initialRouter"; // Enable language support. import "./i18n"; @@ -8,5 +10,9 @@ import "./i18n"; const container = document.getElementById("app"); if (container) { const root = createRoot(container); - root.render(); + root.render(); + void loadInitialRouter(window.location.pathname).then( + (initialRouter) => root.render(), + () => root.render(), + ); } diff --git a/resources/scripts/routers/initialRouter.spec.ts b/resources/scripts/routers/initialRouter.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a37750698e3f8fcf29d91616cd0098706ea6da4 --- /dev/null +++ b/resources/scripts/routers/initialRouter.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadInitialRouter } from "./initialRouter"; + +vi.mock("./AuthenticationRouter", () => ({ default: () => null })); +vi.mock("./ServerRouter", () => ({ default: () => null })); +vi.mock("./DashboardRouter", () => ({ default: () => null })); + +describe("initial router selection", () => { + it.each([ + ["/auth/login", "auth"], + ["/auth/password", "auth"], + ["/server/identifier/files", "server"], + ["/", "dashboard"], + ["/account/api", "dashboard"], + ])("loads the shell for %s", async (path, kind) => { + const router = await loadInitialRouter(path); + expect(router.kind).toBe(kind); + expect(router.Component).toBeTypeOf("function"); + }); +}); diff --git a/resources/scripts/routers/initialRouter.ts b/resources/scripts/routers/initialRouter.ts new file mode 100644 index 0000000000000000000000000000000000000000..7cea3e2794fe2942e94f351f6b5e2fbeae6a3463 --- /dev/null +++ b/resources/scripts/routers/initialRouter.ts @@ -0,0 +1,30 @@ +import type { ComponentType } from "react"; + +export interface InitialRouter { + kind: "auth" | "server" | "dashboard"; + Component: ComponentType; +} + +// Resolve only the requested shell before mounting App. The initial screen +// can then render immediately without a lazy/Suspense reveal delay; subsequent +// route changes retain the normal lazy loading and error boundaries. +export const loadInitialRouter = async ( + pathname: string, +): Promise => { + if (pathname.startsWith("/auth/")) { + return { + kind: "auth", + Component: (await import("./AuthenticationRouter")).default, + }; + } + if (pathname.startsWith("/server/")) { + return { + kind: "server", + Component: (await import("./ServerRouter")).default, + }; + } + return { + kind: "dashboard", + Component: (await import("./DashboardRouter")).default, + }; +}; diff --git a/tests/Integration/Api/Client/ApiKeyUsageValidationTest.php b/tests/Integration/Api/Client/ApiKeyUsageValidationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3280640965f235f22ccbfef3666c6ab8ed64dc9a --- /dev/null +++ b/tests/Integration/Api/Client/ApiKeyUsageValidationTest.php @@ -0,0 +1,26 @@ +create(); + $key = app(KeyCreationService::class)->setKeyType(ApiKey::TYPE_ACCOUNT)->handle(['user_id' => $user->id, 'memo' => 'Usage test', 'allowed_ips' => []]); + DB::enableQueryLog(); + DB::flushQueryLog(); + $key->forceFill(['last_used_at' => now()])->save(); + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + expect(collect($queries)->filter(fn ($query) => str_starts_with(strtolower($query['query']), 'select'))->count())->toBe(0); + expect($key->fresh()->last_used_at)->not->toBeNull(); +}); + +test('changing an existing key still validates its identity', function () { + $user = User::factory()->create(); + $key = app(KeyCreationService::class)->setKeyType(ApiKey::TYPE_ACCOUNT)->handle(['user_id' => $user->id, 'memo' => 'Usage test', 'allowed_ips' => []]); + $key->identifier = 'invalid'; + expect(fn () => $key->save())->toThrow(DataValidationException::class); +}); diff --git a/tests/Integration/Api/Client/ServerListEmptyPageTest.php b/tests/Integration/Api/Client/ServerListEmptyPageTest.php new file mode 100644 index 0000000000000000000000000000000000000000..bc74dc242294e0a577673bf6bd2b58b2cb1cbc8b --- /dev/null +++ b/tests/Integration/Api/Client/ServerListEmptyPageTest.php @@ -0,0 +1,7 @@ +actingAs(User::factory()->create())->getJson('/api/client')->assertOk()->assertJsonCount(0, 'data'); +}); diff --git a/tests/Integration/Api/Client/ServerListPerformanceTest.php b/tests/Integration/Api/Client/ServerListPerformanceTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a26bc377f4fa9da2dae69b08b343eb7e63143ce6 --- /dev/null +++ b/tests/Integration/Api/Client/ServerListPerformanceTest.php @@ -0,0 +1,67 @@ +create(); + $first = $this->createServerModel(['owner_id' => $user->id, 'startup' => '{{BENCH_VALUE}} {{BENCH_SECRET}}']); + $egg = $this->cloneEggAndVariables($first->egg); + $first->update(['egg_id' => $egg->id]); + $second = $this->createServerModel(['owner_id' => $user->id, 'egg_id' => $first->egg_id, 'startup' => '{{BENCH_VALUE}} {{BENCH_SECRET}}']); + $third = $this->createServerModel(['owner_id' => $user->id, 'egg_id' => $first->egg_id, 'startup' => '{{BENCH_VALUE}} {{BENCH_SECRET}}']); + $visible = EggVariable::factory()->create(['egg_id' => $first->egg_id, 'env_variable' => 'BENCH_VALUE', 'default_value' => 'fallback', 'user_viewable' => true]); + $secret = EggVariable::factory()->create(['egg_id' => $first->egg_id, 'env_variable' => 'BENCH_SECRET', 'default_value' => 'private-default', 'user_viewable' => false]); + ServerVariable::query()->create(['server_id' => $first->id, 'variable_id' => $visible->id, 'variable_value' => 'first-value']); + ServerVariable::query()->create(['server_id' => $second->id, 'variable_id' => $visible->id, 'variable_value' => '']); + ServerVariable::query()->create(['server_id' => $first->id, 'variable_id' => $secret->id, 'variable_value' => 'private-override']); + + $response = $this->actingAs($user)->getJson('/api/client')->assertOk(); + $servers = collect($response->json('data'))->keyBy('attributes.uuid'); + expect($servers[$first->uuid]['attributes']['invocation'])->toBe('first-value [hidden]'); + expect($servers[$second->uuid]['attributes']['invocation'])->toBe(' [hidden]'); + expect($servers[$third->uuid]['attributes']['invocation'])->toBe('fallback [hidden]'); + foreach ($servers as $server) { + expect(collect($server['attributes']['relationships']['variables']['data'])->pluck('attributes.env_variable')->all())->not->toContain('BENCH_SECRET'); + expect($server['attributes']['relationships']['allocations']['data'][0]['attributes']['is_default'])->toBeTrue(); + } +}); + +test('batch loading does not expose startup variables or allocation notes to a restricted subuser', function () { + $server = $this->createServerModel(['startup' => '{{BENCH_VALUE}}']); + $egg = $this->cloneEggAndVariables($server->egg); + $server->update(['egg_id' => $egg->id]); + $user = User::factory()->create(); + Subuser::query()->create(['server_id' => $server->id, 'user_id' => $user->id, 'permissions' => [Permission::ACTION_CONTROL_CONSOLE]]); + $variable = EggVariable::factory()->create(['egg_id' => $server->egg_id, 'env_variable' => 'BENCH_VALUE', 'user_viewable' => true]); + ServerVariable::query()->create(['server_id' => $server->id, 'variable_id' => $variable->id, 'variable_value' => 'do-not-disclose']); + $server->allocation->update(['notes' => 'private-allocation-note']); + + $response = $this->actingAs($user)->getJson('/api/client')->assertOk(); + $response->assertJsonPath('data.0.attributes.invocation', '[hidden]'); + $response->assertJsonPath('data.0.attributes.user_permissions', [Permission::ACTION_CONTROL_CONSOLE]); + $response->assertJsonPath('data.0.attributes.relationships.allocations.data.0.attributes.notes', null); + expect($response->getContent())->not->toContain('do-not-disclose', 'private-allocation-note'); +}); + +test('list query count stays bounded as the page grows', function () { + $user = User::factory()->create(); + for ($i = 0; $i < 12; ++$i) { + $this->createServerModel(['owner_id' => $user->id]); + } + $this->actingAs($user)->getJson('/api/client?per_page=1')->assertOk(); + DB::enableQueryLog(); + DB::flushQueryLog(); + $this->getJson('/api/client?per_page=1')->assertOk(); + $small = count(DB::getQueryLog()); + DB::flushQueryLog(); + $this->getJson('/api/client?per_page=12')->assertOk()->assertJsonCount(12, 'data'); + $large = count(DB::getQueryLog()); + DB::disableQueryLog(); + + expect($large)->toBeLessThanOrEqual($small + 2); +}); diff --git a/tests/Integration/Services/RuntimePerformanceValidationTest.php b/tests/Integration/Services/RuntimePerformanceValidationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e94770c1b6c2798f35eb2282af0a64ac759b3b41 --- /dev/null +++ b/tests/Integration/Services/RuntimePerformanceValidationTest.php @@ -0,0 +1,26 @@ +createServerModel(); + $user = User::factory()->create(); + Subuser::query()->create(['server_id' => $server->id, 'user_id' => $user->id, 'permissions' => [Permission::ACTION_CONTROL_CONSOLE]]); + $server->unsetRelation('subusers'); + + expect(app(GetUserPermissionsService::class)->handle($server, $user))->toBe([Permission::ACTION_CONTROL_CONSOLE]); +}); + +test('the explicit model validation bypass remains available for imports', function () { + $user = User::factory()->create(); + $key = app(KeyCreationService::class)->setKeyType(ApiKey::TYPE_ACCOUNT)->handle(['user_id' => $user->id, 'memo' => 'Bypass test', 'allowed_ips' => []]); + $key->skipValidation()->forceFill(['identifier' => 'imported'])->save(); + expect($key->fresh()->identifier)->toBe('imported'); +});