Enable nginx X-Accel for original artwork downloads.
Hand originals to nginx after auth so PHP is not in the byte path. Keep DOWNLOAD_ACCEL_ENABLED off until the internal location is verified.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\ArtworkDownloadController;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkStatsService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
beforeEach(function () {
|
||||
$root = storage_path('framework/testing/artwork-downloads-accel');
|
||||
config([
|
||||
'uploads.storage_root' => $root,
|
||||
'uploads.local_originals_root' => $root,
|
||||
'app.download_accel_enabled' => false,
|
||||
'app.download_accel_path' => '/internal/originals',
|
||||
'app.url' => 'https://skinbase.test',
|
||||
]);
|
||||
|
||||
if (File::exists($root)) {
|
||||
File::deleteDirectory($root);
|
||||
}
|
||||
|
||||
File::makeDirectory($root, 0755, true);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
$root = storage_path('framework/testing/artwork-downloads-accel');
|
||||
if (File::exists($root)) {
|
||||
File::deleteDirectory($root);
|
||||
}
|
||||
});
|
||||
|
||||
function accelOriginal(string $hash, string $ext, string $content = 'original-bytes'): string
|
||||
{
|
||||
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
|
||||
$dir = $root.DIRECTORY_SEPARATOR.substr($hash, 0, 2).DIRECTORY_SEPARATOR.substr($hash, 2, 2);
|
||||
File::makeDirectory($dir, 0755, true, true);
|
||||
$path = $dir.DIRECTORY_SEPARATOR.$hash.'.'.$ext;
|
||||
File::put($path, $content);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
function resolveAccelUri(string $filePath): ?string
|
||||
{
|
||||
$controller = app(ArtworkDownloadController::class);
|
||||
$method = new ReflectionMethod(ArtworkDownloadController::class, 'resolveAccelUri');
|
||||
|
||||
return $method->invoke($controller, $filePath);
|
||||
}
|
||||
|
||||
it('uses the PHP download fallback when acceleration is disabled', function () {
|
||||
$hash = '2721722cb2f407cc53539ee8718cc89c4cdfc579';
|
||||
accelOriginal($hash, 'zip', 'zip-body');
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'file_name' => 'Nested Original',
|
||||
'hash' => $hash,
|
||||
'file_ext' => 'zip',
|
||||
]);
|
||||
|
||||
$response = $this->get('/download/artwork/'.$artwork->id);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->headers->get('X-Accel-Redirect'))->toBeNull()
|
||||
->and($response->baseResponse)->toBeInstanceOf(BinaryFileResponse::class);
|
||||
});
|
||||
|
||||
it('returns X-Accel-Redirect for a nested original when acceleration is enabled', function () {
|
||||
config(['app.download_accel_enabled' => true]);
|
||||
$hash = '2721722cb2f407cc53539ee8718cc89c4cdfc579';
|
||||
accelOriginal($hash, 'zip', 'zip-body');
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'file_name' => 'Nested Original',
|
||||
'hash' => $hash,
|
||||
'file_ext' => 'zip',
|
||||
]);
|
||||
|
||||
$this->mock(ArtworkStatsService::class, function ($mock) use ($artwork): void {
|
||||
$mock->shouldReceive('incrementDownloads')->once()->with($artwork->id, 1, false);
|
||||
});
|
||||
|
||||
$response = $this->get('/download/artwork/'.$artwork->id);
|
||||
|
||||
$response->assertOk()
|
||||
->assertHeader('X-Accel-Redirect', '/internal/originals/27/21/2721722cb2f407cc53539ee8718cc89c4cdfc579.zip')
|
||||
->assertHeader('Content-Type', 'application/octet-stream')
|
||||
->assertHeader('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
$disposition = (string) $response->headers->get('Content-Disposition');
|
||||
expect($disposition)->toContain('attachment')
|
||||
->and($disposition)->toContain('Nested Original')
|
||||
->and($disposition)->toContain('.zip')
|
||||
->and($response->baseResponse)->not->toBeInstanceOf(BinaryFileResponse::class);
|
||||
|
||||
expect(DB::table('artwork_downloads')->where('artwork_id', $artwork->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('maps nested originals under the accel prefix without duplicate slashes', function () {
|
||||
config(['app.download_accel_enabled' => true]);
|
||||
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
|
||||
$relative = '27'.DIRECTORY_SEPARATOR.'21'.DIRECTORY_SEPARATOR.'2721722cb2f407cc53539ee8718cc89c4cdfc579.zip';
|
||||
$path = $root.DIRECTORY_SEPARATOR.$relative;
|
||||
|
||||
expect(resolveAccelUri($path))->toBe('/internal/originals/27/21/2721722cb2f407cc53539ee8718cc89c4cdfc579.zip');
|
||||
});
|
||||
|
||||
it('does not emit an accel URI for a file outside the originals root', function () {
|
||||
config(['app.download_accel_enabled' => true]);
|
||||
$outside = sys_get_temp_dir().DIRECTORY_SEPARATOR.'not-original.zip';
|
||||
File::put($outside, 'nope');
|
||||
|
||||
expect(resolveAccelUri($outside))->toBeNull();
|
||||
|
||||
File::delete($outside);
|
||||
});
|
||||
|
||||
it('returns 404 for an unsupported original extension', function () {
|
||||
$hash = 'aa11bb22cc33dd44ee55ff6677889900aabbccdd';
|
||||
accelOriginal($hash, 'exe', 'not-allowed');
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'hash' => $hash,
|
||||
'file_ext' => 'exe',
|
||||
'file_name' => 'bad.exe',
|
||||
]);
|
||||
|
||||
$this->mock(ArtworkStatsService::class, function ($mock): void {
|
||||
$mock->shouldReceive('incrementDownloads')->never();
|
||||
});
|
||||
|
||||
$this->get('/download/artwork/'.$artwork->id)->assertNotFound();
|
||||
expect(DB::table('artwork_downloads')->where('artwork_id', $artwork->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('still records download analytics when acceleration is enabled', function () {
|
||||
config(['app.download_accel_enabled' => true]);
|
||||
$hash = 'bb22cc33dd44ee55ff6677889900aabbccddeeff';
|
||||
accelOriginal($hash, 'jpg', 'jpeg-bytes');
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'hash' => $hash,
|
||||
'file_ext' => 'jpg',
|
||||
'file_name' => 'Photo',
|
||||
]);
|
||||
|
||||
DB::table('artwork_stats')->insertOrIgnore([
|
||||
'artwork_id' => $artwork->id,
|
||||
'views' => 0,
|
||||
'views_24h' => 0,
|
||||
'views_7d' => 0,
|
||||
'downloads' => 0,
|
||||
'downloads_24h' => 0,
|
||||
'downloads_7d' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
]);
|
||||
|
||||
$this->get('/download/artwork/'.$artwork->id)
|
||||
->assertOk()
|
||||
->assertHeader('X-Accel-Redirect');
|
||||
|
||||
expect(DB::table('artwork_downloads')->where('artwork_id', $artwork->id)->count())->toBe(1)
|
||||
->and((int) DB::table('artwork_stats')->where('artwork_id', $artwork->id)->value('downloads'))->toBe(1);
|
||||
});
|
||||
@@ -57,7 +57,7 @@ it('downloads an existing artwork file', function () {
|
||||
$response = $this->get("/download/artwork/{$artwork->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertDownload('Sky Sunset.png');
|
||||
$response->assertDownload('Sky Sunset (skinbase.org).png');
|
||||
});
|
||||
|
||||
it('forces the download filename using file_name and extension', function () {
|
||||
@@ -74,7 +74,7 @@ it('forces the download filename using file_name and extension', function () {
|
||||
$response = $this->get("/download/artwork/{$artwork->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertDownload('My Original Name.jpg');
|
||||
$response->assertDownload('My Original Name (skinbase.org).jpg');
|
||||
});
|
||||
|
||||
it('returns 404 for a missing artwork', function () {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Services\ArtworkStatsService;
|
||||
use App\Services\Traffic\OnlineVisitorRepository;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
beforeEach(function () {
|
||||
$root = storage_path('framework/testing/artwork-downloads-m15c');
|
||||
config([
|
||||
'uploads.storage_root' => $root,
|
||||
'uploads.local_originals_root' => $root,
|
||||
'app.download_accel_enabled' => false,
|
||||
'app.url' => 'https://skinbase.test',
|
||||
'skinbase-sessions.enabled' => true,
|
||||
'skinbase-sessions.debug_header' => true,
|
||||
'skinbase-sessions.skip_anonymous_public_get' => true,
|
||||
]);
|
||||
|
||||
if (File::exists($root)) {
|
||||
File::deleteDirectory($root);
|
||||
}
|
||||
|
||||
File::makeDirectory($root, 0755, true);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
$root = storage_path('framework/testing/artwork-downloads-m15c');
|
||||
if (File::exists($root)) {
|
||||
File::deleteDirectory($root);
|
||||
}
|
||||
});
|
||||
|
||||
function m15cOriginal(string $hash, string $ext, string $content = 'bytes'): string
|
||||
{
|
||||
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
|
||||
$dir = $root.DIRECTORY_SEPARATOR.substr($hash, 0, 2).DIRECTORY_SEPARATOR.substr($hash, 2, 2);
|
||||
File::makeDirectory($dir, 0755, true, true);
|
||||
$path = $dir.DIRECTORY_SEPARATOR.$hash.'.'.$ext;
|
||||
File::put($path, $content);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
it('returns a cheap 404 for a nonexistent artwork without analytics', function () {
|
||||
$this->mock(ArtworkStatsService::class, function ($mock): void {
|
||||
$mock->shouldReceive('incrementDownloads')->never();
|
||||
});
|
||||
|
||||
$this->get('/download/artwork/999999')->assertNotFound();
|
||||
|
||||
expect(DB::table('artwork_downloads')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('returns a cheap 404 for a missing original without analytics', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'hash' => 'c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0',
|
||||
'file_ext' => 'webp',
|
||||
'file_name' => 'gone.webp',
|
||||
]);
|
||||
|
||||
$this->mock(ArtworkStatsService::class, function ($mock): void {
|
||||
$mock->shouldReceive('incrementDownloads')->never();
|
||||
});
|
||||
|
||||
$this->get('/download/artwork/'.$artwork->id)->assertNotFound();
|
||||
|
||||
expect(DB::table('artwork_downloads')->where('artwork_id', $artwork->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('keeps valid anonymous downloads and X-Accel-Redirect when enabled', function () {
|
||||
config(['app.download_accel_enabled' => true, 'app.download_accel_path' => '/internal/originals']);
|
||||
$hash = '2721722cb2f407cc53539ee8718cc89c4cdfc579';
|
||||
m15cOriginal($hash, 'zip');
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'hash' => $hash,
|
||||
'file_ext' => 'zip',
|
||||
'file_name' => 'Anon File',
|
||||
]);
|
||||
|
||||
$this->get('/download/artwork/'.$artwork->id)
|
||||
->assertOk()
|
||||
->assertHeader('X-Accel-Redirect', '/internal/originals/27/21/2721722cb2f407cc53539ee8718cc89c4cdfc579.zip');
|
||||
});
|
||||
|
||||
it('still records ArtworkDownload.user_id for authenticated downloads', function () {
|
||||
$hash = 'aa11bb22cc33dd44ee55ff6677889900aabbccdd';
|
||||
m15cOriginal($hash, 'png');
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'hash' => $hash,
|
||||
'file_ext' => 'png',
|
||||
'file_name' => 'Auth File',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->get('/download/artwork/'.$artwork->id)->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('artwork_downloads', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips session for anonymous download GET without a session cookie', function () {
|
||||
$this->get('/download/artwork/999999')
|
||||
->assertNotFound()
|
||||
->assertHeader('X-Skinbase-Session', 'skipped');
|
||||
});
|
||||
|
||||
it('starts session for download GET when a session cookie is already present', function () {
|
||||
$cookie = (string) config('session.cookie');
|
||||
|
||||
$this->withCookie($cookie, 'existing-session-cookie')
|
||||
->get('/download/artwork/999999')
|
||||
->assertNotFound()
|
||||
->assertHeader('X-Skinbase-Session', 'started');
|
||||
});
|
||||
|
||||
it('does not record presence for artwork download requests', function () {
|
||||
$this->mock(OnlineVisitorRepository::class, function ($mock): void {
|
||||
$mock->shouldReceive('track')->never();
|
||||
});
|
||||
|
||||
$this->get('/download/artwork/999999')->assertNotFound();
|
||||
});
|
||||
Reference in New Issue
Block a user