refactor texture uploading

This commit is contained in:
Pig Fang 2020-06-04 16:36:10 +08:00
parent d991db1640
commit b8d85ad2b3
4 changed files with 286 additions and 253 deletions

View File

@ -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
}

View File

@ -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<

View File

@ -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(<Upload />)
const { getByText, getByTitle, getByLabelText } = render(<Upload />)
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 () => {

View File

@ -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()