From 0993bfa666f1a619ce609689a3450afbdee33905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A1n=20Gonzalo=20Artur=20de=20la=20Villarmois?= Date: Sun, 29 Mar 2026 17:35:32 +1300 Subject: [PATCH 1/3] ### Changes Fixes #3: Error on DBI when files or folders contains special characters. ### Additional changes * Added unit and feature tests * Fix file size display * Run tests on CI/CD * Publish docker image on release --- .github/workflows/release.yml | 73 ++++++++++ .github/workflows/tests.yml | 35 +++++ .gitignore | 1 + www/app/Helpers/FileSize.php | 5 +- .../Http/Controllers/DBI/IndexController.php | 4 +- www/phpunit.xml | 2 - .../Feature/DBI/DirectoryListingTest.php | 126 ++++++++++++++++++ www/tests/Feature/DBI/FileServingTest.php | 70 ++++++++++ www/tests/Feature/ExampleTest.php | 7 - .../Middleware/EnsureIsDbiClientTest.php | 33 +++++ .../Middleware/EnsureIsTinfoilClientTest.php | 13 ++ .../Feature/Tinfoil/IndexControllerTest.php | 70 ++++++++++ www/tests/Unit/ExampleTest.php | 5 - www/tests/Unit/FileSizeTest.php | 32 +++++ www/tests/Unit/NintendoFileExtensionTest.php | 29 ++++ 15 files changed, 487 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/tests.yml create mode 100644 www/tests/Feature/DBI/DirectoryListingTest.php create mode 100644 www/tests/Feature/DBI/FileServingTest.php delete mode 100644 www/tests/Feature/ExampleTest.php create mode 100644 www/tests/Feature/Middleware/EnsureIsDbiClientTest.php create mode 100644 www/tests/Feature/Middleware/EnsureIsTinfoilClientTest.php create mode 100644 www/tests/Feature/Tinfoil/IndexControllerTest.php delete mode 100644 www/tests/Unit/ExampleTest.php create mode 100644 www/tests/Unit/FileSizeTest.php create mode 100644 www/tests/Unit/NintendoFileExtensionTest.php diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a555e88 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,73 @@ +name: Release + +on: + release: + types: [published] + +jobs: + test: + name: Run test suite + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: mbstring + coverage: none + + - name: Install Composer dependencies + working-directory: www + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Set up environment + working-directory: www + run: | + cp .env.example .env + php artisan key:generate + + - name: Run tests + working-directory: www + run: php artisan test + + docker: + name: Build and push Docker image + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU (multi-platform support) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: notf0und/sgs + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..58fe852 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,35 @@ +name: Tests + +on: + pull_request: + branches: ['**'] + +jobs: + test: + name: Run test suite + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: mbstring + coverage: none + + - name: Install Composer dependencies + working-directory: www + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Set up environment + working-directory: www + run: | + cp .env.example .env + php artisan key:generate + + - name: Run tests + working-directory: www + run: php artisan test diff --git a/.gitignore b/.gitignore index a6e7ef5..9cd1b6f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ /games/* !/games/.gitkeep Thumbs.db +docker-compose.override.yml diff --git a/www/app/Helpers/FileSize.php b/www/app/Helpers/FileSize.php index 61f9ea0..e82e27e 100644 --- a/www/app/Helpers/FileSize.php +++ b/www/app/Helpers/FileSize.php @@ -20,7 +20,8 @@ class FileSize $unitIndex = min($unitIndex, count(self::UNITS) - 1); $size = $bytes / pow(self::THRESHOLD, $unitIndex); - - return number_format($size, 2) . self::UNITS[$unitIndex]; + $decimals = $unitIndex === 0 ? 0 : 2; + + return number_format($size, $decimals, '.', '') . self::UNITS[$unitIndex]; } } diff --git a/www/app/Http/Controllers/DBI/IndexController.php b/www/app/Http/Controllers/DBI/IndexController.php index 447d543..cd08653 100644 --- a/www/app/Http/Controllers/DBI/IndexController.php +++ b/www/app/Http/Controllers/DBI/IndexController.php @@ -35,7 +35,7 @@ class IndexController extends Controller private function getRequestPath(Request $request): ?string { $path = $request->route('path'); - return $path ? urldecode($path) : null; + return $path ? rawurldecode($path) : null; } /** @@ -132,7 +132,7 @@ class IndexController extends Controller private function buildDirectoryListingHtml(Collection $directories, Collection $files, string $currentPath, string $baseUrl, bool $isDBI): string { $displayPath = $currentPath - ? '/games/' . urldecode($currentPath) . '/' + ? '/games/' . rawurldecode($currentPath) . '/' : '/games/'; return view('dbi.directory-listing', [ diff --git a/www/phpunit.xml b/www/phpunit.xml index d703241..04fd4c8 100644 --- a/www/phpunit.xml +++ b/www/phpunit.xml @@ -23,8 +23,6 @@ - - diff --git a/www/tests/Feature/DBI/DirectoryListingTest.php b/www/tests/Feature/DBI/DirectoryListingTest.php new file mode 100644 index 0000000..a6dc07b --- /dev/null +++ b/www/tests/Feature/DBI/DirectoryListingTest.php @@ -0,0 +1,126 @@ +get('/api/dbi/'); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'text/html; charset=utf-8'); + $response->assertSee('Index of /games/'); +}); + +test('lists directories that contain installable files', function () { + Storage::put('games/GameA/game.nsp', 'data'); + Storage::put('games/GameB/game.xci', 'data'); + + $response = $this->get('/api/dbi/'); + + $response->assertSee('GameA'); + $response->assertSee('GameB'); +}); + +test('excludes directories with no installable files', function () { + Storage::put('games/HasGames/game.nsp', 'data'); + Storage::put('games/NoGames/readme.txt', 'data'); + + $response = $this->get('/api/dbi/'); + + $response->assertSee('HasGames'); + $response->assertDontSee('NoGames'); +}); + +test('lists only files with supported extensions', function () { + Storage::put('games/game.nsp', 'data'); + Storage::put('games/game.nsz', 'data'); + Storage::put('games/game.xci', 'data'); + Storage::put('games/game.xcz', 'data'); + Storage::put('games/readme.txt', 'data'); + + $response = $this->get('/api/dbi/'); + + $response->assertSee('game.nsp'); + $response->assertSee('game.nsz'); + $response->assertSee('game.xci'); + $response->assertSee('game.xcz'); + $response->assertDontSee('readme.txt'); +}); + +test('shows a parent directory link when inside a subdirectory', function () { + Storage::put('games/GameA/game.nsp', 'data'); + + $response = $this->get('/api/dbi/GameA/'); + + $response->assertSee('Parent Directory'); +}); + +test('does not show a parent directory link at the root', function () { + Storage::put('games/GameA/game.nsp', 'data'); + + $response = $this->get('/api/dbi/'); + + $response->assertDontSee('Parent Directory'); +}); + +test('directory links use rawurlencode so special characters are encoded', function () { + Storage::put('games/Game Title/game.nsp', 'data'); + Storage::put('games/Special & Chars/game.nsp', 'data'); + + $response = $this->get('/api/dbi/'); + + $response->assertSee('Game%20Title'); + $response->assertSee('Special%20%26%20Chars'); +}); + +test('folder names with a plus sign are encoded as %2B in links, not as a space', function () { + Storage::put('games/Game+Title/game.nsp', 'data'); + + $response = $this->get('/api/dbi/'); + + // The href must use %2B, not a literal + (which urlencode would produce) + $response->assertSee('Game%2BTitle'); + // The display name is rendered as-is; only the href encoding matters + $response->assertDontSee('Game%20Title'); + $response->assertDontSee('Game Title'); +}); + +test('navigating into a folder with a plus sign returns a listing', function () { + Storage::put('games/Game+Title/game.nsp', 'data'); + + // The client follows the %2B-encoded link + $response = $this->get('/api/dbi/Game%2BTitle/'); + + $response->assertStatus(200); + $response->assertSee('game.nsp'); +}); + +test('DBI user-agent receives relative links', function () { + Storage::put('games/GameA/game.nsp', 'data'); + + $response = $this->get('/api/dbi/', ['HTTP_USER_AGENT' => 'DBI/1.0']); + + // DBI clients need a relative href like "GameA/" not an absolute path + $response->assertSee('href="' . rawurlencode('GameA') . '/"', false); +}); + +test('browser user-agent receives absolute links', function () { + Storage::put('games/GameA/game.nsp', 'data'); + + $response = $this->get('/api/dbi/'); + + $response->assertSee('href="/api/dbi/' . rawurlencode('GameA') . '"', false); +}); + +test('displays the current subdirectory path in the page title', function () { + Storage::put('games/GameA/game.nsp', 'data'); + + $response = $this->get('/api/dbi/GameA/'); + + $response->assertSee('Index of /games/GameA/'); +}); diff --git a/www/tests/Feature/DBI/FileServingTest.php b/www/tests/Feature/DBI/FileServingTest.php new file mode 100644 index 0000000..49b1cf9 --- /dev/null +++ b/www/tests/Feature/DBI/FileServingTest.php @@ -0,0 +1,70 @@ +get('/api/dbi/game.nsp'); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'application/octet-stream'); +}); + +test('returns 404 when the requested file does not exist', function () { + $response = $this->get('/api/dbi/missing.nsp'); + + $response->assertStatus(404); +}); + +test('serves a file inside a subdirectory', function () { + Storage::put('games/GameA/game.nsp', 'binary-content'); + + $response = $this->get('/api/dbi/GameA/game.nsp'); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'application/octet-stream'); +}); + +test('serves a file whose folder name contains a plus sign', function () { + Storage::put('games/Game+Title/game.nsp', 'binary-content'); + + // %2B in the URL must decode to a literal + (not a space) + $response = $this->get('/api/dbi/Game%2BTitle/game.nsp'); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'application/octet-stream'); +}); + +test('returns 404 when plus sign is misread as a space', function () { + Storage::put('games/Game+Title/game.nsp', 'binary-content'); + + // A literal + in the URL path should NOT match a folder named "Game+Title" + // because + must be decoded as %2B, not as a space + $response = $this->get('/api/dbi/Game+Title/game.nsp'); + + // "Game+Title" decoded with rawurldecode stays "Game+Title", + // which correctly finds the file. + $response->assertStatus(200); +}); + +test('serves a file with a plus sign in the filename', function () { + Storage::put('games/Game+Edition.nsp', 'binary-content'); + + $response = $this->get('/api/dbi/Game%2BEdition.nsp'); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'application/octet-stream'); +}); + +test('response includes Accept-Ranges header for range request support', function () { + Storage::put('games/game.nsp', 'binary-content'); + + $response = $this->get('/api/dbi/game.nsp'); + + $response->assertHeader('Accept-Ranges', 'bytes'); +}); diff --git a/www/tests/Feature/ExampleTest.php b/www/tests/Feature/ExampleTest.php deleted file mode 100644 index 8fdc86b..0000000 --- a/www/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,7 +0,0 @@ -get('/'); - - $response->assertStatus(200); -}); diff --git a/www/tests/Feature/Middleware/EnsureIsDbiClientTest.php b/www/tests/Feature/Middleware/EnsureIsDbiClientTest.php new file mode 100644 index 0000000..3a8f1ab --- /dev/null +++ b/www/tests/Feature/Middleware/EnsureIsDbiClientTest.php @@ -0,0 +1,33 @@ +get('/', ['HTTP_USER_AGENT' => 'DBI/1.0']); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'text/html; charset=utf-8'); + $response->assertSee('Index of /games/'); +}); + +test('a non-DBI user-agent on the web root is passed through', function () { + $response = $this->get('/', ['HTTP_USER_AGENT' => 'Mozilla/5.0']); + + // The welcome view or the next middleware handles the request (not DBI) + $response->assertDontSee('Index of /games/'); +}); + +test('a DBI user-agent on an arbitrary web path gets a DBI directory listing', function () { + Storage::put('games/GameA/game.nsp', 'data'); + + $response = $this->get('/anything', ['HTTP_USER_AGENT' => 'DBI/2.5']); + + $response->assertStatus(200); + $response->assertSee('Index of /games/'); +}); diff --git a/www/tests/Feature/Middleware/EnsureIsTinfoilClientTest.php b/www/tests/Feature/Middleware/EnsureIsTinfoilClientTest.php new file mode 100644 index 0000000..4e35e77 --- /dev/null +++ b/www/tests/Feature/Middleware/EnsureIsTinfoilClientTest.php @@ -0,0 +1,13 @@ +get('/', ['HTTP_THEME' => 'default']); + + $response->assertRedirect(route('tinfoil')); +}); + +test('a request without a theme header on the web root is not redirected', function () { + $response = $this->get('/'); + + $response->assertStatus(200); +}); diff --git a/www/tests/Feature/Tinfoil/IndexControllerTest.php b/www/tests/Feature/Tinfoil/IndexControllerTest.php new file mode 100644 index 0000000..d42dc4c --- /dev/null +++ b/www/tests/Feature/Tinfoil/IndexControllerTest.php @@ -0,0 +1,70 @@ +get('/api/tinfoil'); + + $response->assertStatus(200); + $response->assertJsonStructure(['files', 'success']); +}); + +test('success value equals the application name', function () { + $response = $this->get('/api/tinfoil'); + + $response->assertJsonPath('success', config('app.name')); +}); + +test('returns an empty files array when there are no installable files', function () { + $response = $this->get('/api/tinfoil'); + + $response->assertJsonPath('files', []); +}); + +test('each file entry contains a url and a size', function () { + Storage::put('game.nsp', 'binary-content'); + + $response = $this->get('/api/tinfoil'); + + $response->assertJsonStructure(['files' => [['url', 'size']]]); +}); + +test('only includes files with supported Nintendo Switch extensions', function () { + Storage::put('game.nsp', 'data'); + Storage::put('game.nsz', 'data'); + Storage::put('game.xci', 'data'); + Storage::put('game.xcz', 'data'); + Storage::put('readme.txt', 'data'); + Storage::put('cover.jpg', 'data'); + + $response = $this->get('/api/tinfoil'); + + $files = collect($response->json('files')); + expect($files)->toHaveCount(4); + expect($files->pluck('url')->every(fn ($url) => !str_ends_with($url, '.txt') && !str_ends_with($url, '.jpg')))->toBeTrue(); +}); + +test('file url is an absolute URL', function () { + Storage::put('game.nsp', 'data'); + + $response = $this->get('/api/tinfoil'); + + $url = $response->json('files.0.url'); + expect($url)->toStartWith('http'); +}); + +test('file size matches the stored file size', function () { + $content = str_repeat('x', 1024); + Storage::put('game.nsp', $content); + + $response = $this->get('/api/tinfoil'); + + $size = $response->json('files.0.size'); + expect($size)->toBe(1024); +}); diff --git a/www/tests/Unit/ExampleTest.php b/www/tests/Unit/ExampleTest.php deleted file mode 100644 index 44a4f33..0000000 --- a/www/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/www/tests/Unit/FileSizeTest.php b/www/tests/Unit/FileSizeTest.php new file mode 100644 index 0000000..2e7922f --- /dev/null +++ b/www/tests/Unit/FileSizeTest.php @@ -0,0 +1,32 @@ +toBe('0'); +}); + +test('formats byte values without decimal places', function () { + expect(FileSize::format(1))->toBe('1'); + expect(FileSize::format(512))->toBe('512'); + expect(FileSize::format(1001))->toBe('1001'); + expect(FileSize::format(1023))->toBe('1023'); +}); + +test('formats kilobytes', function () { + expect(FileSize::format(1024))->toBe('1.00K'); + expect(FileSize::format(1536))->toBe('1.50K'); +}); + +test('formats megabytes', function () { + expect(FileSize::format(1024 * 1024))->toBe('1.00M'); + expect(FileSize::format((int) (1.5 * 1024 * 1024)))->toBe('1.50M'); +}); + +test('formats gigabytes', function () { + expect(FileSize::format(1024 * 1024 * 1024))->toBe('1.00G'); +}); + +test('formats terabytes', function () { + expect(FileSize::format(1024 * 1024 * 1024 * 1024))->toBe('1.00T'); +}); diff --git a/www/tests/Unit/NintendoFileExtensionTest.php b/www/tests/Unit/NintendoFileExtensionTest.php new file mode 100644 index 0000000..5bebc6e --- /dev/null +++ b/www/tests/Unit/NintendoFileExtensionTest.php @@ -0,0 +1,29 @@ +toBe(['nsp', 'nsz', 'xci', 'xcz']); +}); + +test('isSupported() returns true for each supported extension', function (string $filename) { + expect(NintendoFileExtension::isSupported($filename))->toBeTrue(); +})->with(['game.nsp', 'game.nsz', 'game.xci', 'game.xcz']); + +test('isSupported() is case-insensitive', function (string $filename) { + expect(NintendoFileExtension::isSupported($filename))->toBeTrue(); +})->with(['game.NSP', 'game.NSZ', 'game.XCI', 'game.XCZ', 'game.Nsp', 'game.Xci']); + +test('isSupported() returns false for unsupported extensions', function (string $filename) { + expect(NintendoFileExtension::isSupported($filename))->toBeFalse(); +})->with(['game.txt', 'game.exe', 'game.zip', 'game.iso', 'game.php', 'game.rom']); + +test('isSupported() returns false for files without an extension', function () { + expect(NintendoFileExtension::isSupported('gamefile'))->toBeFalse(); +}); + +test('isSupported() works with paths containing directory separators', function () { + expect(NintendoFileExtension::isSupported('folder/game.nsp'))->toBeTrue(); + expect(NintendoFileExtension::isSupported('folder/sub/game.xci'))->toBeTrue(); + expect(NintendoFileExtension::isSupported('folder/game.txt'))->toBeFalse(); +}); From bae79d68ab2092518b6359eab02833317e2abb4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A1n=20Gonzalo=20Artur=20de=20la=20Villarmois?= Date: Sun, 29 Mar 2026 17:49:51 +1300 Subject: [PATCH 2/3] * Fix CI/CD php version * Add Pint * Format code --- .github/workflows/release.yml | 27 +++++++++++++++++-- .github/workflows/tests.yml | 25 ++++++++++++++++- www/app/Enums/NintendoFileExtension.php | 2 +- www/app/Helpers/FileSize.php | 5 ++-- .../Http/Controllers/DBI/IndexController.php | 25 ++++++++--------- .../Controllers/Tinfoil/IndexController.php | 4 +-- .../Http/Middleware/EnsureIsTinfoilClient.php | 7 ++--- www/bootstrap/providers.php | 4 ++- www/composer.json | 4 +-- www/composer.lock | 27 ++++++++++--------- www/config/auth.php | 4 ++- www/config/database.php | 5 ++-- www/routes/api.php | 6 +++-- www/routes/web.php | 18 ++++++------- .../Feature/DBI/DirectoryListingTest.php | 4 +-- .../Feature/Tinfoil/IndexControllerTest.php | 2 +- www/tests/Pest.php | 4 ++- 17 files changed, 114 insertions(+), 59 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a555e88..8634e3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.4' extensions: mbstring coverage: none @@ -34,10 +34,33 @@ jobs: working-directory: www run: php artisan test + lint: + name: Check code style (Pint) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring + coverage: none + + - name: Install Composer dependencies + working-directory: www + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Run Pint + working-directory: www + run: ./vendor/bin/pint --test + docker: name: Build and push Docker image runs-on: ubuntu-latest - needs: test + needs: [test, lint] steps: - name: Checkout diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 58fe852..1dd15a0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,7 @@ jobs: - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.4' extensions: mbstring coverage: none @@ -33,3 +33,26 @@ jobs: - name: Run tests working-directory: www run: php artisan test + + lint: + name: Check code style (Pint) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring + coverage: none + + - name: Install Composer dependencies + working-directory: www + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Run Pint + working-directory: www + run: ./vendor/bin/pint --test diff --git a/www/app/Enums/NintendoFileExtension.php b/www/app/Enums/NintendoFileExtension.php index e65b339..b35581a 100644 --- a/www/app/Enums/NintendoFileExtension.php +++ b/www/app/Enums/NintendoFileExtension.php @@ -18,7 +18,7 @@ enum NintendoFileExtension: string */ public static function values(): array { - return array_map(fn($case) => $case->value, self::cases()); + return array_map(fn ($case) => $case->value, self::cases()); } /** diff --git a/www/app/Helpers/FileSize.php b/www/app/Helpers/FileSize.php index e82e27e..7135188 100644 --- a/www/app/Helpers/FileSize.php +++ b/www/app/Helpers/FileSize.php @@ -5,6 +5,7 @@ namespace App\Helpers; class FileSize { private const UNITS = ['', 'K', 'M', 'G', 'T']; + private const THRESHOLD = 1024; /** @@ -18,10 +19,10 @@ class FileSize $unitIndex = (int) floor(log($bytes, self::THRESHOLD)); $unitIndex = min($unitIndex, count(self::UNITS) - 1); - + $size = $bytes / pow(self::THRESHOLD, $unitIndex); $decimals = $unitIndex === 0 ? 0 : 2; - return number_format($size, $decimals, '.', '') . self::UNITS[$unitIndex]; + return number_format($size, $decimals, '.', '').self::UNITS[$unitIndex]; } } diff --git a/www/app/Http/Controllers/DBI/IndexController.php b/www/app/Http/Controllers/DBI/IndexController.php index cd08653..d33b444 100644 --- a/www/app/Http/Controllers/DBI/IndexController.php +++ b/www/app/Http/Controllers/DBI/IndexController.php @@ -35,6 +35,7 @@ class IndexController extends Controller private function getRequestPath(Request $request): ?string { $path = $request->route('path'); + return $path ? rawurldecode($path) : null; } @@ -51,9 +52,9 @@ class IndexController extends Controller */ private function serveFile(string $path): Response|BinaryFileResponse { - $storagePath = self::GAMES_DIRECTORY . '/' . $path; + $storagePath = self::GAMES_DIRECTORY.'/'.$path; - if (!Storage::exists($storagePath)) { + if (! Storage::exists($storagePath)) { return response('File not found', 404); } @@ -68,19 +69,19 @@ class IndexController extends Controller */ private function serveDirectoryListing(?string $path, Request $request): Response { - $storagePath = $path - ? self::GAMES_DIRECTORY . '/' . $path + $storagePath = $path + ? self::GAMES_DIRECTORY.'/'.$path : self::GAMES_DIRECTORY; $directories = $this->getDirectories($storagePath); $files = $this->getFiles($storagePath); - + // Determine if this is a DBI client or regular browser $isDBI = str_starts_with($request->userAgent() ?? '', 'DBI/'); - + // Determine base URL for links based on request path $baseUrl = str_starts_with($request->path(), 'api/dbi') ? '/api/dbi/' : '/'; - + $html = $this->buildDirectoryListingHtml($directories, $files, $path ?? '', $baseUrl, $isDBI); return response($html)->header('Content-Type', 'text/html'); @@ -131,8 +132,8 @@ class IndexController extends Controller */ private function buildDirectoryListingHtml(Collection $directories, Collection $files, string $currentPath, string $baseUrl, bool $isDBI): string { - $displayPath = $currentPath - ? '/games/' . rawurldecode($currentPath) . '/' + $displayPath = $currentPath + ? '/games/'.rawurldecode($currentPath).'/' : '/games/'; return view('dbi.directory-listing', [ @@ -151,7 +152,7 @@ class IndexController extends Controller */ private function getParentDirectoryUrl(string $currentPath, string $baseUrl, bool $isDBI): ?string { - if (!$currentPath) { + if (! $currentPath) { return null; } @@ -167,8 +168,8 @@ class IndexController extends Controller return $baseUrl; } - return rtrim($baseUrl, '/') . '/' . collect(explode('/', $parentPath)) + return rtrim($baseUrl, '/').'/'.collect(explode('/', $parentPath)) ->map(fn ($segment) => rawurlencode($segment)) ->implode('/'); } -} \ No newline at end of file +} diff --git a/www/app/Http/Controllers/Tinfoil/IndexController.php b/www/app/Http/Controllers/Tinfoil/IndexController.php index 0ceea8e..9c3da1a 100644 --- a/www/app/Http/Controllers/Tinfoil/IndexController.php +++ b/www/app/Http/Controllers/Tinfoil/IndexController.php @@ -18,7 +18,7 @@ class IndexController extends Controller { return response()->json([ 'files' => $this->getInstallableFiles()->values(), - 'success' => config('app.name') + 'success' => config('app.name'), ]); } @@ -31,7 +31,7 @@ class IndexController extends Controller ->filter(fn ($path) => NintendoFileExtension::isSupported($path)) ->map(fn ($path) => [ 'url' => url($path), - 'size' => Storage::size($path) + 'size' => Storage::size($path), ]); } } diff --git a/www/app/Http/Middleware/EnsureIsTinfoilClient.php b/www/app/Http/Middleware/EnsureIsTinfoilClient.php index b0850f7..991a641 100644 --- a/www/app/Http/Middleware/EnsureIsTinfoilClient.php +++ b/www/app/Http/Middleware/EnsureIsTinfoilClient.php @@ -6,20 +6,17 @@ use Closure; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Support\Facades\Log; class EnsureIsTinfoilClient { /** * Handle an incoming request. * - * @param Request $request - * @param Closure(Request): (Response|RedirectResponse) $next - * @return Response|RedirectResponse + * @param Closure(Request): (Response|RedirectResponse) $next */ public function handle(Request $request, Closure $next): Response|RedirectResponse { - if (!!$request->header('theme')) { + if ((bool) $request->header('theme')) { return redirect()->route('tinfoil'); } diff --git a/www/bootstrap/providers.php b/www/bootstrap/providers.php index 38b258d..fc94ae6 100644 --- a/www/bootstrap/providers.php +++ b/www/bootstrap/providers.php @@ -1,5 +1,7 @@ [ 'users' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), + 'model' => env('AUTH_MODEL', User::class), ], // 'users' => [ diff --git a/www/config/database.php b/www/config/database.php index c57fa63..93b15be 100644 --- a/www/config/database.php +++ b/www/config/database.php @@ -1,6 +1,7 @@ true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], @@ -79,7 +80,7 @@ return [ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/www/routes/api.php b/www/routes/api.php index 3b0c1aa..1e1935e 100644 --- a/www/routes/api.php +++ b/www/routes/api.php @@ -1,5 +1,7 @@ name('tinfoil'); -Route::get('dbi/{path?}', \App\Http\Controllers\DBI\IndexController::class) +Route::get('dbi/{path?}', DBIIndexController::class) ->where('path', '.*') ->name('dbi'); diff --git a/www/routes/web.php b/www/routes/web.php index d048365..29336f9 100644 --- a/www/routes/web.php +++ b/www/routes/web.php @@ -6,37 +6,37 @@ use Illuminate\Support\Facades\Route; Route::view('/', 'welcome')->middleware([ EnsureIsDbiClient::class, - EnsureIsTinfoilClient::class + EnsureIsTinfoilClient::class, ]); Route::get('/docs/{page}', function ($page) { $allowedPages = ['TINFOIL', 'DBI']; - if (!in_array($page, $allowedPages)) { + if (! in_array($page, $allowedPages)) { abort(404); } - + $content = file_get_contents(base_path("docs/{$page}.md")); - + // Convert relative image paths to absolute paths for web viewing $content = str_replace( '](images/', '](/docs-images/', $content ); - + return view('docs', ['content' => $content, 'title' => $page]); })->name('docs'); Route::get('/docs-images/{path}', function ($path) { $imagePath = base_path("docs/images/{$path}"); - - if (!file_exists($imagePath)) { + + if (! file_exists($imagePath)) { abort(404); } - + return response()->file($imagePath); })->where('path', '.*')->name('docs.images'); -Route::get('/{path}', fn() => response('Not Found', 404)) +Route::get('/{path}', fn () => response('Not Found', 404)) ->where('path', '(?!api|docs-images).*') ->middleware(EnsureIsDbiClient::class); diff --git a/www/tests/Feature/DBI/DirectoryListingTest.php b/www/tests/Feature/DBI/DirectoryListingTest.php index a6dc07b..947bc0c 100644 --- a/www/tests/Feature/DBI/DirectoryListingTest.php +++ b/www/tests/Feature/DBI/DirectoryListingTest.php @@ -106,7 +106,7 @@ test('DBI user-agent receives relative links', function () { $response = $this->get('/api/dbi/', ['HTTP_USER_AGENT' => 'DBI/1.0']); // DBI clients need a relative href like "GameA/" not an absolute path - $response->assertSee('href="' . rawurlencode('GameA') . '/"', false); + $response->assertSee('href="'.rawurlencode('GameA').'/"', false); }); test('browser user-agent receives absolute links', function () { @@ -114,7 +114,7 @@ test('browser user-agent receives absolute links', function () { $response = $this->get('/api/dbi/'); - $response->assertSee('href="/api/dbi/' . rawurlencode('GameA') . '"', false); + $response->assertSee('href="/api/dbi/'.rawurlencode('GameA').'"', false); }); test('displays the current subdirectory path in the page title', function () { diff --git a/www/tests/Feature/Tinfoil/IndexControllerTest.php b/www/tests/Feature/Tinfoil/IndexControllerTest.php index d42dc4c..7668f9b 100644 --- a/www/tests/Feature/Tinfoil/IndexControllerTest.php +++ b/www/tests/Feature/Tinfoil/IndexControllerTest.php @@ -47,7 +47,7 @@ test('only includes files with supported Nintendo Switch extensions', function ( $files = collect($response->json('files')); expect($files)->toHaveCount(4); - expect($files->pluck('url')->every(fn ($url) => !str_ends_with($url, '.txt') && !str_ends_with($url, '.jpg')))->toBeTrue(); + expect($files->pluck('url')->every(fn ($url) => ! str_ends_with($url, '.txt') && ! str_ends_with($url, '.jpg')))->toBeTrue(); }); test('file url is an absolute URL', function () { diff --git a/www/tests/Pest.php b/www/tests/Pest.php index 60f04a4..3834758 100644 --- a/www/tests/Pest.php +++ b/www/tests/Pest.php @@ -1,5 +1,7 @@ extend(Tests\TestCase::class) +pest()->extend(TestCase::class) // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature'); From 36c8671c525368263377675f9f0e10fc8abe4022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A1n=20Gonzalo=20Artur=20de=20la=20Villarmois?= Date: Sun, 29 Mar 2026 17:53:14 +1300 Subject: [PATCH 3/3] Install and build npm packages on CI --- .github/workflows/release.yml | 4 ++++ .github/workflows/tests.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8634e3e..1343103 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,10 @@ jobs: cp .env.example .env php artisan key:generate + - name: Build frontend assets + working-directory: www + run: npm ci && npm run build + - name: Run tests working-directory: www run: php artisan test diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1dd15a0..b234296 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,6 +30,10 @@ jobs: cp .env.example .env php artisan key:generate + - name: Build frontend assets + working-directory: www + run: npm ci && npm run build + - name: Run tests working-directory: www run: php artisan test