Initial commit / First Release

This commit is contained in:
Fabián Gonzalo Artur de la Villarmois
2025-12-26 16:51:23 +13:00
parent 2e8f5ce028
commit 2579f561c3
73 changed files with 14521 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Enums;
use Illuminate\Support\Str;
enum NintendoFileExtension: string
{
case NSP = 'nsp';
case NSZ = 'nsz';
case XCI = 'xci';
case XCZ = 'xcz';
/**
* Get all supported file extensions.
*
* @return array<string>
*/
public static function values(): array
{
return array_map(fn($case) => $case->value, self::cases());
}
/**
* Check if a filename has a supported extension.
*/
public static function isSupported(string $filename): bool
{
$extension = Str::lower(Str::afterLast($filename, '.'));
return in_array($extension, self::values(), true);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Helpers;
class FileSize
{
private const UNITS = ['', 'K', 'M', 'G', 'T'];
private const THRESHOLD = 1024;
/**
* Format bytes into human-readable file size.
*/
public static function format(int $bytes): string
{
if ($bytes === 0) {
return '0';
}
$unitIndex = (int) floor(log($bytes, self::THRESHOLD));
$unitIndex = min($unitIndex, count(self::UNITS) - 1);
$size = $bytes / pow(self::THRESHOLD, $unitIndex);
return number_format($size, 2) . self::UNITS[$unitIndex];
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
@@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers\DBI;
use App\Enums\NintendoFileExtension;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
class IndexController extends Controller
{
private const GAMES_DIRECTORY = 'games';
/**
* Handle DBI file browser requests.
* Serves either file downloads or Apache-style directory listings.
*/
public function __invoke(Request $request): Response|BinaryFileResponse
{
$path = $this->getRequestPath($request);
if ($this->isFileRequest($path)) {
return $this->serveFile($path);
}
return $this->serveDirectoryListing($path, $request);
}
/**
* Extract and decode the path from the request.
*/
private function getRequestPath(Request $request): ?string
{
$path = $request->route('path');
return $path ? urldecode($path) : null;
}
/**
* Determine if the request is for a file download.
*/
private function isFileRequest(?string $path): bool
{
return $path && NintendoFileExtension::isSupported($path);
}
/**
* Serve a file download response.
*/
private function serveFile(string $path): Response|BinaryFileResponse
{
$storagePath = self::GAMES_DIRECTORY . '/' . $path;
if (!Storage::exists($storagePath)) {
return response('File not found', 404);
}
return response()->file(Storage::path($storagePath), [
'Content-Type' => 'application/octet-stream',
'Accept-Ranges' => 'bytes',
]);
}
/**
* Serve an Apache-style directory listing.
*/
private function serveDirectoryListing(?string $path, Request $request): Response
{
$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');
}
/**
* Get directories that contain allowed file types.
*/
private function getDirectories(string $path): Collection
{
return collect(Storage::directories($path))
->filter(fn ($dir) => $this->containsInstallableFiles($dir))
->map(fn ($dir) => [
'name' => basename($dir),
'mtime' => Storage::lastModified($dir),
])
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
->values();
}
/**
* Get files with allowed extensions.
*/
private function getFiles(string $path): Collection
{
return collect(Storage::files($path))
->filter(fn ($file) => NintendoFileExtension::isSupported($file))
->map(fn ($file) => [
'name' => basename($file),
'mtime' => Storage::lastModified($file),
'size' => Storage::size($file),
])
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
->values();
}
/**
* Check if a directory contains any installable files.
*/
private function containsInstallableFiles(string $directory): bool
{
return collect(Storage::allFiles($directory))
->contains(fn ($file) => NintendoFileExtension::isSupported($file));
}
/**
* Build Apache-style HTML directory listing compatible with DBI.
*/
private function buildDirectoryListingHtml(Collection $directories, Collection $files, string $currentPath, string $baseUrl, bool $isDBI): string
{
$displayPath = $currentPath
? '/games/' . urldecode($currentPath) . '/'
: '/games/';
return view('dbi.directory-listing', [
'displayPath' => $displayPath,
'currentPath' => $currentPath,
'baseUrl' => rtrim($baseUrl, '/'),
'isDBI' => $isDBI,
'parentUrl' => $this->getParentDirectoryUrl($currentPath, $baseUrl, $isDBI),
'directories' => $directories,
'files' => $files,
])->render();
}
/**
* Get the URL for the parent directory.
*/
private function getParentDirectoryUrl(string $currentPath, string $baseUrl, bool $isDBI): ?string
{
if (!$currentPath) {
return null;
}
// DBI needs relative paths
if ($isDBI) {
return '../';
}
// Browsers need absolute paths
$parentPath = dirname($currentPath);
if ($parentPath === '.') {
return $baseUrl;
}
return rtrim($baseUrl, '/') . '/' . collect(explode('/', $parentPath))
->map(fn ($segment) => rawurlencode($segment))
->implode('/');
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Tinfoil;
use App\Enums\NintendoFileExtension;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
class IndexController extends Controller
{
/**
* Handle the incoming request from Tinfoil client.
* Returns a JSON list of available Nintendo Switch installation files.
*/
public function __invoke(): JsonResponse
{
return response()->json([
'files' => $this->getInstallableFiles()->values(),
'success' => config('app.name')
]);
}
/**
* Get all Nintendo Switch installable files from storage.
*/
private function getInstallableFiles(): Collection
{
return collect(Storage::allFiles())
->filter(fn ($path) => NintendoFileExtension::isSupported($path))
->map(fn ($path) => [
'url' => url($path),
'size' => Storage::size($path)
]);
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use App\Http\Controllers\DBI\IndexController;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class EnsureIsDbiClient
{
public function handle(Request $request, Closure $next): Response
{
if (Str::startsWith($request->userAgent() ?? '', 'DBI/')) {
return app(IndexController::class)($request);
}
return $next($request);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
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
*/
public function handle(Request $request, Closure $next): Response|RedirectResponse
{
if (!!$request->header('theme')) {
return redirect()->route('tinfoil');
}
return $next($request);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}