diff --git a/app/Http/Controllers/SkinlibController.php b/app/Http/Controllers/SkinlibController.php index 11018cdc..d82fb731 100644 --- a/app/Http/Controllers/SkinlibController.php +++ b/app/Http/Controllers/SkinlibController.php @@ -7,33 +7,18 @@ use App\Models\Texture; use App\Models\User; use Auth; use Blessing\Filter; +use Blessing\Rejection; +use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Http\JsonResponse; +use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Http\Request; -use Option; +use Illuminate\Support\Facades\Log; +use Illuminate\Validation\Rule; use Parsedown; use Storage; class SkinlibController extends Controller { - /** - * Map error code of file uploading to human-readable text. - * - * @see http://php.net/manual/en/features.file-upload.errors.php - * - * @var array - */ - public static $phpFileUploadErrors = [ - 0 => 'There is no error, the file uploaded with success', - 1 => 'The uploaded file exceeds the upload_max_filesize directive in php.ini', - 2 => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form', - 3 => 'The uploaded file was only partially uploaded', - 4 => 'No file was uploaded', - 6 => 'Missing a temporary folder', - 7 => 'Failed to write file to disk.', - 8 => 'A PHP extension stopped the file upload.', - ]; - public function library(Request $request) { $user = Auth::user(); @@ -180,61 +165,127 @@ class SkinlibController extends Controller ]); } - public function handleUpload(Request $request) - { - $user = Auth::user(); - - if (($response = $this->checkUpload($request)) instanceof JsonResponse) { - return $response; - } - + public function handleUpload( + Request $request, + Filter $filter, + Dispatcher $dispatcher + ) { $file = $request->file('file'); - $responses = event(new \App\Events\HashingFile($file)); - if (isset($responses[0]) && is_string($responses[0])) { - return $responses[0]; // @codeCoverageIgnore + if ($file && !$file->isValid()) { + Log::error($file->getErrorMessage()); } - $t = new Texture(); - $t->name = $request->input('name'); - $t->type = $request->input('type'); - $t->hash = hash_file('sha256', $file); - $t->size = ceil($request->file('file')->getSize() / 1024); - $t->public = $request->input('public') == 'true'; - $t->uploader = $user->uid; + $data = $request->validate([ + 'name' => [ + 'required', + option('texture_name_regexp') ? 'regex:'.option('texture_name_regexp') : 'string', + ], + 'file' => 'required|mimes:png|max:'.option('max_upload_file_size'), + 'type' => ['required', Rule::in(['steve', 'alex', 'cape'])], + 'public' => 'required|boolean', + ]); - $cost = $t->size * ($t->public ? Option::get('score_per_storage') : Option::get('private_score_per_storage')); - $cost += option('score_per_closet_item'); - $cost -= option('score_award_per_texture', 0); + $file = $filter->apply('uploaded_texture_file', $file); - if ($user->score < $cost) { - return json(trans('skinlib.upload.lack-score'), 7); + $name = $data['name']; + $name = $filter->apply('uploaded_texture_name', $name, [$file]); + + $can = $filter->apply('can_upload_texture', true, [$file, $name]); + if ($can instanceof Rejection) { + return json($can->getReason(), 1); } - $repeated = Texture::where('hash', $t->hash)->where('public', true)->first(); - if ($repeated) { + $type = $data['type']; + $size = getimagesize($file); + $ratio = $size[0] / $size[1]; + if ($type == 'steve' || $type == 'alex') { + if ($ratio != 2 && $ratio != 1) { + $message = trans('skinlib.upload.invalid-size', [ + 'type' => trans('general.skin'), + 'width' => $size[0], + 'height' => $size[1], + ]); + + return json($message, 1); + } + if ($size[0] % 64 != 0 || $size[1] % 32 != 0) { + $message = trans('skinlib.upload.invalid-hd-skin', [ + 'type' => trans('general.skin'), + 'width' => $size[0], + 'height' => $size[1], + ]); + + return json($message, 1); + } + } elseif ($type == 'cape') { + if ($ratio != 2) { + $message = trans('skinlib.upload.invalid-size', [ + 'type' => trans('general.cape'), + 'width' => $size[0], + 'height' => $size[1], + ]); + + return json($message, 1); + } + } + + $hash = hash_file('sha256', $file); + $hash = $filter->apply('uploaded_texture_hash', $hash, [$file]); + + $duplicated = Texture::where('hash', $hash)->where('public', true)->first(); + if ($duplicated) { // if the texture already uploaded was set to private, // then allow to re-upload it. - return json(trans('skinlib.upload.repeated'), 2, ['tid' => $repeated->tid]); + return json(trans('skinlib.upload.repeated'), 2, ['tid' => $duplicated->tid]); } - if (Storage::disk('textures')->missing($t->hash)) { - Storage::disk('textures')->put($t->hash, file_get_contents($request->file('file'))); + /** @var User */ + $user = Auth::user(); + + $size = ceil($file->getSize() / 1024); + $isPublic = is_string($data['public']) + ? $data['public'] === '1' + : $data['public']; + $cost = $size * ( + $isPublic + ? option('score_per_storage') + : option('private_score_per_storage') + ); + $cost += option('score_per_closet_item'); + $cost -= option('score_award_per_texture', 0); + if ($user->score < $cost) { + return json(trans('skinlib.upload.lack-score'), 1); } - $t->likes++; - $t->save(); + $dispatcher->dispatch('texture.uploading', [$file, $name, $hash]); + + $texture = new Texture(); + $texture->name = $name; + $texture->type = $type; + $texture->hash = $hash; + $texture->size = $size; + $texture->public = $isPublic; + $texture->uploader = $user->uid; + $texture->likes = 1; + $texture->save(); + + /** @var FilesystemAdapter */ + $disk = Storage::disk('textures'); + if ($disk->missing($hash)) { + $disk->putFile($hash, $file); + } $user->score -= $cost; - $user->closet()->attach($t->tid, ['item_name' => $t->name]); + $user->closet()->attach($texture->tid, ['item_name' => $name]); $user->save(); - return json(trans('skinlib.upload.success', ['name' => $request->input('name')]), 0, [ - 'tid' => $t->tid, + $dispatcher->dispatch('texture.uploaded', [$texture, $file]); + + return json(trans('skinlib.upload.success', ['name' => $name]), 0, [ + 'tid' => $texture->tid, ]); } - // @codeCoverageIgnore - public function delete(Request $request) { $texture = Texture::find($request->tid); @@ -355,48 +406,4 @@ class SkinlibController extends Controller return json(trans('skinlib.model.success', ['model' => $data['model']]), 0); } - - protected function checkUpload(Request $request) - { - if ($file = $request->files->get('file')) { - if ($file->getError() !== UPLOAD_ERR_OK) { - return json(static::$phpFileUploadErrors[$file->getError()], $file->getError()); - } - } - - $request->validate([ - 'name' => [ - 'required', - option('texture_name_regexp') ? 'regex:'.option('texture_name_regexp') : 'string', - ], - 'file' => 'required|max:'.option('max_upload_file_size'), - 'public' => 'required', - ]); - - $mime = $request->file('file')->getMimeType(); - if ($mime != 'image/png' && $mime != 'image/x-png') { - return json(trans('skinlib.upload.type-error'), 1); - } - - $type = $request->input('type'); - $size = getimagesize($request->file('file')); - $ratio = $size[0] / $size[1]; - - if ($type == 'steve' || $type == 'alex') { - if ($ratio != 2 && $ratio != 1) { - return json(trans('skinlib.upload.invalid-size', ['type' => trans('general.skin'), 'width' => $size[0], 'height' => $size[1]]), 1); - } - if ($size[0] % 64 != 0 || $size[1] % 32 != 0) { - return json(trans('skinlib.upload.invalid-hd-skin', ['type' => trans('general.skin'), 'width' => $size[0], 'height' => $size[1]]), 1); - } - } elseif ($type == 'cape') { - if ($ratio != 2) { - return json(trans('skinlib.upload.invalid-size', ['type' => trans('general.cape'), 'width' => $size[0], 'height' => $size[1]]), 1); - } - } else { - return json(trans('general.illegal-parameters'), 1); - } - } - - // @codeCoverageIgnore } diff --git a/resources/assets/src/views/skinlib/Upload.tsx b/resources/assets/src/views/skinlib/Upload.tsx index 83ccee99..d44116ee 100644 --- a/resources/assets/src/views/skinlib/Upload.tsx +++ b/resources/assets/src/views/skinlib/Upload.tsx @@ -82,7 +82,7 @@ const Upload: React.FC = () => { formData.append('name', name) formData.append('type', type) formData.append('file', file, file.name) - formData.append('public', (!isPrivate).toString()) + formData.append('public', isPrivate ? '0' : '1') setIsUploading(true) const { code, message, data: { tid } = { tid: 0 } } = await fetch.post< diff --git a/resources/assets/tests/views/skinlib/Upload.test.tsx b/resources/assets/tests/views/skinlib/Upload.test.tsx index 7d0b608f..5f4b0abf 100644 --- a/resources/assets/tests/views/skinlib/Upload.test.tsx +++ b/resources/assets/tests/views/skinlib/Upload.test.tsx @@ -223,21 +223,24 @@ describe('upload texture', () => { expect(formData.get('name')).toBe('t') expect(formData.get('type')).toBe('steve') expect(formData.get('file')).toStrictEqual(file) - expect(formData.get('public')).toBe('true') + expect(formData.get('public')).toBe('1') }) it('uploaded successfully', async () => { fetch.post.mockResolvedValue({ code: 0, message: 'ok', tid: 1 }) - const { getByText, getByTitle } = render() + const { getByText, getByTitle, getByLabelText } = render() const file = new File([], 't.png', { type: 'image/png' }) fireEvent.change(getByTitle(t('skinlib.upload.select-file')), { target: { files: [file] }, }) + fireEvent.click(getByLabelText(t('skinlib.upload.set-as-private'))) fireEvent.click(getByText(t('skinlib.upload.button'))) await waitFor(() => expect(fetch.post).toBeCalled()) + const formData: FormData = fetch.post.mock.calls[0][1] + expect(formData.get('public')).toBe('0') }) it('duplicated texture detected', async () => { diff --git a/tests/HttpTest/ControllersTest/SkinlibControllerTest.php b/tests/HttpTest/ControllersTest/SkinlibControllerTest.php index 34d2fe30..fc5ecea0 100644 --- a/tests/HttpTest/ControllersTest/SkinlibControllerTest.php +++ b/tests/HttpTest/ControllersTest/SkinlibControllerTest.php @@ -5,10 +5,12 @@ namespace Tests; use App\Models\Player; use App\Models\Texture; use App\Models\User; -use Blessing\Filter; +use Blessing\Rejection; use Carbon\Carbon; +use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Storage; class SkinlibControllerTest extends TestCase @@ -220,9 +222,21 @@ class SkinlibControllerTest extends TestCase public function testHandleUpload() { - Storage::fake('textures'); + Event::fake(); + /** @var FilesystemAdapter */ + $disk = Storage::fake('textures'); + $filter = Fakes\Filter::fake(); + $user = factory(User::class)->create(); - // Some error occurred when uploading file + // without file + $this->actingAs($user) + ->postJson('/skinlib/upload', [ + 'name' => 'name', + 'type' => 'steve', + 'public' => true, + ])->assertJsonValidationErrors('file'); + + // some error occurred when uploading file $file = UploadedFile::fake()->image('test.png'); $upload = new UploadedFile( $file->path(), @@ -231,31 +245,29 @@ class SkinlibControllerTest extends TestCase UPLOAD_ERR_NO_TMP_DIR, true ); - $this->actingAs(factory(User::class)->create()) - ->postJson( - '/skinlib/upload', - ['file' => $upload] - )->assertJson([ - 'code' => UPLOAD_ERR_NO_TMP_DIR, - 'message' => \App\Http\Controllers\SkinlibController::$phpFileUploadErrors[UPLOAD_ERR_NO_TMP_DIR], - ]); - - // Without `name` field - $this->postJson('/skinlib/upload')->assertJsonValidationErrors('name'); - - // Specified regular expression for texture name - option(['texture_name_regexp' => '/\\d+/']); $this->postJson('/skinlib/upload', [ - 'name' => 'abc', - ])->assertJsonValidationErrors('name'); - option(['texture_name_regexp' => null]); - - // Without file - $this->postJson('/skinlib/upload', [ - 'name' => 'texture', + 'name' => 'name', + 'file' => $upload, + 'type' => 'steve', + 'public' => true, ])->assertJsonValidationErrors('file'); - // Too large file + // without `name` field + $this->postJson('/skinlib/upload')->assertJsonValidationErrors('name'); + + // specified regular expression for texture name + option(['texture_name_regexp' => '/\\d+/']); + $this->postJson('/skinlib/upload', ['name' => 'abc']) + ->assertJsonValidationErrors('name'); + option(['texture_name_regexp' => null]); + + // not a PNG file + $this->postJson('/skinlib/upload', [ + 'name' => 'texture', + 'file' => UploadedFile::fake()->create('fake', 5), + ])->assertJsonValidationErrors('file'); + + // too large file option(['max_upload_file_size' => 2]); $upload = UploadedFile::fake()->create('large.png', 5); $this->postJson('/skinlib/upload', [ @@ -264,113 +276,80 @@ class SkinlibControllerTest extends TestCase ])->assertJsonValidationErrors('file'); option(['max_upload_file_size' => 1024]); - // Without `public` field + // no texture type is specified $this->postJson('/skinlib/upload', [ 'name' => 'texture', - 'file' => 'content', // Though it is not a file, it is OK + 'file' => $file, + ])->assertJsonValidationErrors('type'); + + // invalid texture type + $this->postJson('/skinlib/upload', [ + 'name' => 'texture', + 'file' => $file, + 'type' => 'nope', + ])->assertJsonValidationErrors('type'); + + // without `public` field + $this->postJson('/skinlib/upload', [ + 'name' => 'texture', + 'file' => $file, + 'type' => 'steve', ])->assertJsonValidationErrors('public'); - // Not a PNG image - $this->postJson( - '/skinlib/upload', - [ - 'name' => 'texture', - 'public' => 'true', - 'file' => UploadedFile::fake()->create('fake', 5), - ] - )->assertJson([ + // invalid skin size + $this->postJson('/skinlib/upload', [ + 'name' => 'texture', + 'public' => true, + 'type' => 'steve', + 'file' => UploadedFile::fake()->image('texture.png', 64, 30), + ])->assertJson([ 'code' => 1, - 'message' => trans('skinlib.upload.type-error'), + 'message' => trans('skinlib.upload.invalid-size', [ + 'type' => trans('general.skin'), + 'width' => 64, + 'height' => 30, + ]), ]); - - // No texture type is specified - $this->postJson( - '/skinlib/upload', - [ - 'name' => 'texture', - 'public' => 'true', - 'file' => UploadedFile::fake()->image('texture.png', 64, 32), - ] - )->assertJson([ + $this->postJson('/skinlib/upload', [ + 'name' => 'texture', + 'public' => true, + 'type' => 'alex', + 'file' => UploadedFile::fake()->image('texture.png', 100, 50), + ])->assertJson([ 'code' => 1, - 'message' => trans('general.illegal-parameters'), + 'message' => trans('skinlib.upload.invalid-hd-skin', [ + 'type' => trans('general.skin'), + 'width' => 100, + 'height' => 50, + ]), ]); - - // Invalid skin size - $this->postJson( - '/skinlib/upload', - [ - 'name' => 'texture', - 'public' => 'true', - 'type' => 'steve', - 'file' => UploadedFile::fake()->image('texture.png', 64, 30), - ] - )->assertJson([ + $this->postJson('/skinlib/upload', [ + 'name' => 'texture', + 'public' => true, + 'type' => 'cape', + 'file' => UploadedFile::fake()->image('texture.png', 64, 30), + ])->assertJson([ 'code' => 1, - 'message' => trans( - 'skinlib.upload.invalid-size', - [ - 'type' => trans('general.skin'), - 'width' => 64, - 'height' => 30, - ] - ), - ]); - $this->postJson( - '/skinlib/upload', - [ - 'name' => 'texture', - 'public' => 'true', - 'type' => 'alex', - 'file' => UploadedFile::fake()->image('texture.png', 100, 50), - ] - )->assertJson([ - 'code' => 1, - 'message' => trans( - 'skinlib.upload.invalid-hd-skin', - [ - 'type' => trans('general.skin'), - 'width' => 100, - 'height' => 50, - ] - ), - ]); - $this->postJson( - '/skinlib/upload', - [ - 'name' => 'texture', - 'public' => 'true', - 'type' => 'cape', - 'file' => UploadedFile::fake()->image('texture.png', 64, 30), - ] - )->assertJson([ - 'code' => 1, - 'message' => trans( - 'skinlib.upload.invalid-size', - [ - 'type' => trans('general.cape'), - 'width' => 64, - 'height' => 30, - ] - ), + 'message' => trans('skinlib.upload.invalid-size', [ + 'type' => trans('general.cape'), + 'width' => 64, + 'height' => 30, + ]), ]); $upload = UploadedFile::fake()->image('texture.png', 64, 32); - // Score is not enough + // score is not enough $user = factory(User::class)->create(['score' => 0]); $this->actingAs($user) - ->postJson( - '/skinlib/upload', - [ - 'name' => 'texture', - 'public' => 'true', - 'type' => 'steve', - 'file' => $upload, - ] - ) + ->postJson('/skinlib/upload', [ + 'name' => 'texture', + 'public' => true, + 'type' => 'steve', + 'file' => $upload, + ]) ->assertJson([ - 'code' => 7, + 'code' => 1, 'message' => trans('skinlib.upload.lack-score'), ]); @@ -381,60 +360,104 @@ class SkinlibControllerTest extends TestCase '/skinlib/upload', [ 'name' => 'texture', - 'public' => 'false', // Private texture cost more scores + 'public' => false, 'type' => 'steve', 'file' => $upload, ] )->assertJson([ - 'code' => 7, + 'code' => 1, 'message' => trans('skinlib.upload.lack-score'), ]); - // Success + // success option(['score_award_per_texture' => 2]); - $response = $this->postJson( - '/skinlib/upload', - [ - 'name' => 'texture', - 'public' => 'true', // Public texture - 'type' => 'steve', - 'file' => $upload, - ] - ); - $t = Texture::where('name', 'texture')->first(); - $response->assertJson([ + $this->postJson('/skinlib/upload', [ + 'name' => 'texture', + 'public' => true, + 'type' => 'steve', + 'file' => $upload, + ])->assertJson([ 'code' => 0, 'message' => trans('skinlib.upload.success', ['name' => 'texture']), - 'data' => ['tid' => $t->tid], ]); - Storage::disk('textures')->assertExists($t->hash); - $user = User::find($user->uid); + $texture = Texture::where('name', 'texture')->first(); + $disk->assertExists($texture->hash); + $user->refresh(); $this->assertEquals(2, $user->score); - $this->assertEquals('texture', $t->name); - $this->assertEquals('steve', $t->type); - $this->assertEquals(1, $t->likes); - $this->assertEquals(1, $t->size); - $this->assertEquals($user->uid, $t->uploader); - $this->assertTrue($t->public); + $this->assertEquals('texture', $texture->name); + $this->assertEquals('steve', $texture->type); + $this->assertEquals(1, $texture->likes); + $this->assertEquals(1, $texture->size); + $this->assertEquals($user->uid, $texture->uploader); + $this->assertTrue($texture->public); + $filter->assertApplied('uploaded_texture_file', function ($file) { + $this->assertInstanceOf(UploadedFile::class, $file); - // Upload a duplicated texture + return true; + }); + $filter->assertApplied('uploaded_texture_name', function ($name) { + $this->assertEquals('texture', $name); + + return true; + }); + $filter->assertApplied( + 'uploaded_texture_hash', + function ($hash, $file) use ($texture) { + $this->assertEquals($texture->hash, $hash); + $this->assertInstanceOf(UploadedFile::class, $file); + + return true; + } + ); + Event::assertDispatched( + 'texture.uploading', + function ($eventName, $payload) use ($texture) { + $this->assertInstanceOf(UploadedFile::class, $payload[0]); + $this->assertEquals($texture->name, $payload[1]); + $this->assertEquals($texture->hash, $payload[2]); + + return true; + } + ); + Event::assertDispatched( + 'texture.uploaded', + function ($eventName, $payload) use ($texture) { + $this->assertTrue($texture->is($payload[0])); + $this->assertInstanceOf(UploadedFile::class, $payload[1]); + + return true; + } + ); + + // upload a duplicated texture $user = factory(User::class)->create(); $this->actingAs($user) - ->postJson( - '/skinlib/upload', - [ - 'name' => 'texture', - 'public' => 'true', - 'type' => 'steve', - 'file' => $upload, - ] - )->assertJson([ + ->postJson('/skinlib/upload', [ + 'name' => 'texture', + 'public' => true, + 'type' => 'steve', + 'file' => $upload, + ])->assertJson([ 'code' => 2, 'message' => trans('skinlib.upload.repeated'), - 'data' => ['tid' => $t->tid], + 'data' => ['tid' => $texture->tid], ]); - unlink(storage_path('framework/testing/disks/textures/'.$t->hash)); + // rejected + $filter->add('can_upload_texture', function ($can, $file, $name) { + $this->assertInstanceOf(UploadedFile::class, $file); + $this->assertEquals('texture', $name); + + return new Rejection('rejected'); + }); + $this->postJson('/skinlib/upload', [ + 'name' => 'texture', + 'public' => true, + 'type' => 'steve', + 'file' => $upload, + ])->assertJson(['code' => 1, 'message' => 'rejected']); + + $disk->delete($texture->hash); } public function testDelete()