### 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
This commit is contained in:
Fabián Gonzalo Artur de la Villarmois
2026-03-29 17:35:32 +13:00
parent 15b9c17b99
commit 0993bfa666
15 changed files with 487 additions and 18 deletions
+73
View File
@@ -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 }}
+35
View File
@@ -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
+1
View File
@@ -10,3 +10,4 @@
/games/*
!/games/.gitkeep
Thumbs.db
docker-compose.override.yml
+3 -2
View File
@@ -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];
}
}
@@ -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', [
-2
View File
@@ -23,8 +23,6 @@
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
@@ -0,0 +1,126 @@
<?php
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake();
});
test('returns an HTML directory listing for the games root', function () {
Storage::put('games/GameA/game.nsp', 'data');
$response = $this->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/');
});
+70
View File
@@ -0,0 +1,70 @@
<?php
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake();
});
test('serves a file as an octet-stream download', function () {
Storage::put('games/game.nsp', 'binary-content');
$response = $this->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');
});
-7
View File
@@ -1,7 +0,0 @@
<?php
test('the application returns a successful response', function () {
$response = $this->get('/');
$response->assertStatus(200);
});
@@ -0,0 +1,33 @@
<?php
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake();
});
test('a DBI user-agent on the web root gets a DBI directory listing', function () {
Storage::put('games/game.nsp', 'data');
$response = $this->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/');
});
@@ -0,0 +1,13 @@
<?php
test('a request with a theme header on the web root redirects to the tinfoil route', function () {
$response = $this->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);
});
@@ -0,0 +1,70 @@
<?php
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake();
});
test('returns a JSON response with files and success keys', function () {
Storage::put('game.nsp', 'data');
$response = $this->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);
});
-5
View File
@@ -1,5 +0,0 @@
<?php
test('that true is true', function () {
expect(true)->toBeTrue();
});
+32
View File
@@ -0,0 +1,32 @@
<?php
use App\Helpers\FileSize;
test('formats zero bytes as "0"', function () {
expect(FileSize::format(0))->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');
});
@@ -0,0 +1,29 @@
<?php
use App\Enums\NintendoFileExtension;
test('values() returns all four supported extensions', function () {
expect(NintendoFileExtension::values())->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();
});