diff --git a/.env b/.env
new file mode 100644
index 0000000..7c67d96
--- /dev/null
+++ b/.env
@@ -0,0 +1,48 @@
+APP_DEBUG=false
+APP_ENV=production
+APP_FALLBACK_LOCALE=en
+
+DB_CONNECTION=mysql
+DB_HOST=localhost
+DB_PORT=3306
+DB_DATABASE=skin_tdogmc_top
+DB_USERNAME=skin_tdogmc_top
+DB_PASSWORD=Lcm18876588595
+DB_PREFIX=
+
+# Hash Algorithm for Passwords
+#
+# Available values:
+# - BCRYPT, ARGON2I, PHP_PASSWORD_HASH
+# - MD5, SALTED2MD5
+# - SHA256, SALTED2SHA256
+# - SHA512, SALTED2SHA512
+#
+# New sites are *highly* recommended to use BCRYPT.
+#
+PWD_METHOD=BCRYPT
+APP_KEY=base64:J3pS/AID0gUU1u6GHFFOSBtFMOEyx/EFIKc77A0ZGMc=
+
+MAIL_MAILER=smtp
+MAIL_HOST=smtp.163.com
+MAIL_PORT=465
+MAIL_USERNAME=tdogmc@163.com
+MAIL_PASSWORD=EMJCETKJALZNMPBY
+MAIL_ENCRYPTION=ssl
+MAIL_FROM_ADDRESS=tdogmc@163.com
+MAIL_FROM_NAME=神之愿服务器
+
+CACHE_DRIVER=file
+SESSION_DRIVER=file
+QUEUE_CONNECTION=sync
+
+REDIS_CLIENT=phpredis
+REDIS_HOST=127.0.0.1
+REDIS_PASSWORD=null
+REDIS_PORT=6379
+
+PLUGINS_DIR=null
+PLUGINS_URL=null
+PLUGINS_REGISTRY=https://d2jw1l0ullrzt6.cloudfront.net/registry_{lang}.json
+
+JWT_SECRET=Jfz3FrSndLFfOE0AaT7UBsvEaufHBHYUAcjeO3eCegaMykRR6MuS5pb0lyDnsyzs
\ No newline at end of file
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..057cfce
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,45 @@
+APP_DEBUG=false
+APP_ENV=production
+APP_FALLBACK_LOCALE=en
+
+DB_CONNECTION=mysql
+DB_HOST=localhost
+DB_PORT=3306
+DB_DATABASE=blessingskin
+DB_USERNAME=username
+DB_PASSWORD=secret
+DB_PREFIX=
+
+# Hash Algorithm for Passwords
+#
+# Available values:
+# - BCRYPT, ARGON2I, PHP_PASSWORD_HASH
+# - MD5, SALTED2MD5
+# - SHA256, SALTED2SHA256
+# - SHA512, SALTED2SHA512
+#
+# New sites are *highly* recommended to use BCRYPT.
+#
+PWD_METHOD=BCRYPT
+APP_KEY=
+
+MAIL_MAILER=smtp
+MAIL_HOST=
+MAIL_PORT=465
+MAIL_USERNAME=
+MAIL_PASSWORD=
+MAIL_ENCRYPTION=
+MAIL_FROM_ADDRESS=
+MAIL_FROM_NAME=
+
+CACHE_DRIVER=file
+SESSION_DRIVER=file
+QUEUE_CONNECTION=sync
+
+REDIS_CLIENT=phpredis
+REDIS_HOST=127.0.0.1
+REDIS_PASSWORD=null
+REDIS_PORT=6379
+
+PLUGINS_DIR=null
+PLUGINS_URL=null
diff --git a/.htaccess b/.htaccess
new file mode 100755
index 0000000..0519ecb
--- /dev/null
+++ b/.htaccess
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/404.html b/404.html
new file mode 100755
index 0000000..ca6e415
--- /dev/null
+++ b/404.html
@@ -0,0 +1,9 @@
+
+
+
404 Not Found
+
+404 Not Found
+
nginx
+
+
+
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..2aa298c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2016-present The Blessing Skin Team
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/app/Console/Commands/BsInstallCommand.php b/app/Console/Commands/BsInstallCommand.php
new file mode 100755
index 0000000..36fc57b
--- /dev/null
+++ b/app/Console/Commands/BsInstallCommand.php
@@ -0,0 +1,52 @@
+exists(storage_path('install.lock'))) {
+ $this->info('You have installed Blessing Skin Server. Nothing to do.');
+
+ return;
+ }
+
+ $this->call('migrate', ['--force' => true]);
+ if (!$this->getLaravel()->runningUnitTests()) {
+ // @codeCoverageIgnoreStart
+ $this->call('key:generate');
+ $this->call('passport:keys', ['--no-interaction' => true]);
+ // @codeCoverageIgnoreEnd
+ }
+
+ option(['site_url' => url('/')]);
+
+ $admin = new User();
+ $admin->email = $this->argument('email');
+ $admin->nickname = $this->argument('nickname');
+ $admin->score = option('user_initial_score');
+ $admin->avatar = 0;
+ $admin->password = app('cipher')->hash($this->argument('password'), config('secure.salt'));
+ $admin->ip = '127.0.0.1';
+ $admin->permission = User::SUPER_ADMIN;
+ $admin->register_at = Carbon::now();
+ $admin->last_sign_at = Carbon::now()->subDay();
+ $admin->verified = true;
+ $admin->save();
+
+ $filesystem->put(storage_path('install.lock'), '');
+
+ $this->info('Installation completed!');
+ $this->info('We recommend to modify your "Site URL" option if incorrect.');
+ }
+}
diff --git a/app/Console/Commands/OptionsCacheCommand.php b/app/Console/Commands/OptionsCacheCommand.php
new file mode 100755
index 0000000..5a13108
--- /dev/null
+++ b/app/Console/Commands/OptionsCacheCommand.php
@@ -0,0 +1,28 @@
+delete($path);
+ $app->forgetInstance(Option::class);
+
+ $content = var_export(resolve(Option::class)->all(), true);
+ $notice = '// This is auto-generated. DO NOT edit manually.'.PHP_EOL;
+ $content = 'put($path, $content);
+ $this->info('Options cached successfully.');
+ }
+}
diff --git a/app/Console/Commands/PluginDisableCommand.php b/app/Console/Commands/PluginDisableCommand.php
new file mode 100755
index 0000000..e62c15f
--- /dev/null
+++ b/app/Console/Commands/PluginDisableCommand.php
@@ -0,0 +1,25 @@
+get($this->argument('name'));
+ if ($plugin) {
+ $plugins->disable($this->argument('name'));
+ $title = trans($plugin->title);
+ $this->info(trans('admin.plugins.operations.disabled', ['plugin' => $title]));
+ } else {
+ $this->warn(trans('admin.plugins.operations.not-found'));
+ }
+ }
+}
diff --git a/app/Console/Commands/PluginEnableCommand.php b/app/Console/Commands/PluginEnableCommand.php
new file mode 100755
index 0000000..eb45f27
--- /dev/null
+++ b/app/Console/Commands/PluginEnableCommand.php
@@ -0,0 +1,26 @@
+argument('name');
+ $result = $plugins->enable($name);
+ if ($result === true) {
+ $plugin = $plugins->get($name);
+ $title = trans($plugin->title);
+ $this->info(trans('admin.plugins.operations.enabled', ['plugin' => $title]));
+ } elseif ($result === false) {
+ $this->warn(trans('admin.plugins.operations.not-found'));
+ }
+ }
+}
diff --git a/app/Console/Commands/SaltRandomCommand.php b/app/Console/Commands/SaltRandomCommand.php
new file mode 100755
index 0000000..de94b64
--- /dev/null
+++ b/app/Console/Commands/SaltRandomCommand.php
@@ -0,0 +1,44 @@
+generateRandomSalt();
+
+ if ($this->option('show')) {
+ return $this->line(''.$salt.'');
+ }
+
+ // Next, we will replace the application salt in the environment file so it is
+ // automatically setup for this developer. This salt gets generated using a
+ // secure random byte generator and is later base64 encoded for storage.
+ $this->setKeyInEnvironmentFile($salt);
+
+ $this->laravel['config']['secure.salt'] = $salt;
+
+ $this->info("Application salt [$salt] set successfully.");
+ }
+
+ protected function setKeyInEnvironmentFile(string $salt)
+ {
+ file_put_contents($this->laravel->environmentFilePath(), str_replace(
+ 'SALT = '.$this->laravel['config']['secure.salt'],
+ 'SALT = '.$salt,
+ file_get_contents($this->laravel->environmentFilePath())
+ ));
+ }
+
+ protected function generateRandomSalt(): string
+ {
+ return bin2hex(resolve(\Illuminate\Contracts\Encryption\Encrypter::class)->generateKey('AES-128-CBC'));
+ }
+}
diff --git a/app/Console/Commands/UpdateCommand.php b/app/Console/Commands/UpdateCommand.php
new file mode 100755
index 0000000..bbf89a1
--- /dev/null
+++ b/app/Console/Commands/UpdateCommand.php
@@ -0,0 +1,49 @@
+procedures()->each(function ($procedure, $version) {
+ if (Comparator::lessThan(option('version'), $version)) {
+ $procedure();
+ }
+ });
+
+ option(['version' => config('app.version')]);
+ $artisan->call('migrate', ['--force' => true]);
+ $artisan->call('view:clear');
+ $filesystem->put(storage_path('install.lock'), '');
+ Cache::flush();
+
+ $this->info(trans('setup.updates.success.title'));
+ }
+
+ /**
+ * @codeCoverageIgnore
+ */
+ protected function procedures()
+ {
+ return collect([
+ // this is just for testing
+ '0.0.1' => fn () => event('__0.0.1'),
+ '5.0.0' => function () {
+ if (option('home_pic_url') === './app/bg.jpg') {
+ option(['home_pic_url' => './app/bg.webp']);
+ }
+ },
+ ]);
+ }
+}
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
new file mode 100755
index 0000000..257f751
--- /dev/null
+++ b/app/Console/Kernel.php
@@ -0,0 +1,18 @@
+menu = &$menu;
+ }
+}
diff --git a/app/Events/ConfigureExploreMenu.php b/app/Events/ConfigureExploreMenu.php
new file mode 100755
index 0000000..36ab7f0
--- /dev/null
+++ b/app/Events/ConfigureExploreMenu.php
@@ -0,0 +1,14 @@
+menu = &$menu;
+ }
+}
diff --git a/app/Events/ConfigureRoutes.php b/app/Events/ConfigureRoutes.php
new file mode 100755
index 0000000..20ca265
--- /dev/null
+++ b/app/Events/ConfigureRoutes.php
@@ -0,0 +1,15 @@
+router = $router;
+ }
+}
diff --git a/app/Events/ConfigureUserMenu.php b/app/Events/ConfigureUserMenu.php
new file mode 100755
index 0000000..7e882e0
--- /dev/null
+++ b/app/Events/ConfigureUserMenu.php
@@ -0,0 +1,13 @@
+menu = &$menu;
+ }
+}
diff --git a/app/Events/Event.php b/app/Events/Event.php
new file mode 100755
index 0000000..caf3481
--- /dev/null
+++ b/app/Events/Event.php
@@ -0,0 +1,7 @@
+player = $player;
+ }
+}
diff --git a/app/Events/PlayerRetrieved.php b/app/Events/PlayerRetrieved.php
new file mode 100755
index 0000000..7a29b7a
--- /dev/null
+++ b/app/Events/PlayerRetrieved.php
@@ -0,0 +1,15 @@
+player = $player;
+ }
+}
diff --git a/app/Events/PlayerWasAdded.php b/app/Events/PlayerWasAdded.php
new file mode 100755
index 0000000..06d52fb
--- /dev/null
+++ b/app/Events/PlayerWasAdded.php
@@ -0,0 +1,15 @@
+player = $player;
+ }
+}
diff --git a/app/Events/PlayerWasDeleted.php b/app/Events/PlayerWasDeleted.php
new file mode 100755
index 0000000..4e24146
--- /dev/null
+++ b/app/Events/PlayerWasDeleted.php
@@ -0,0 +1,13 @@
+playerName = $playerName;
+ }
+}
diff --git a/app/Events/PlayerWillBeAdded.php b/app/Events/PlayerWillBeAdded.php
new file mode 100755
index 0000000..7e55a21
--- /dev/null
+++ b/app/Events/PlayerWillBeAdded.php
@@ -0,0 +1,13 @@
+playerName = $playerName;
+ }
+}
diff --git a/app/Events/PlayerWillBeDeleted.php b/app/Events/PlayerWillBeDeleted.php
new file mode 100755
index 0000000..496e2ba
--- /dev/null
+++ b/app/Events/PlayerWillBeDeleted.php
@@ -0,0 +1,15 @@
+player = $player;
+ }
+}
diff --git a/app/Events/PluginBootFailed.php b/app/Events/PluginBootFailed.php
new file mode 100755
index 0000000..51ca2dc
--- /dev/null
+++ b/app/Events/PluginBootFailed.php
@@ -0,0 +1,15 @@
+plugin = $plugin;
+ }
+}
diff --git a/app/Events/PluginWasDeleted.php b/app/Events/PluginWasDeleted.php
new file mode 100755
index 0000000..c162c9d
--- /dev/null
+++ b/app/Events/PluginWasDeleted.php
@@ -0,0 +1,15 @@
+plugin = $plugin;
+ }
+}
diff --git a/app/Events/PluginWasDisabled.php b/app/Events/PluginWasDisabled.php
new file mode 100755
index 0000000..a69b6eb
--- /dev/null
+++ b/app/Events/PluginWasDisabled.php
@@ -0,0 +1,15 @@
+plugin = $plugin;
+ }
+}
diff --git a/app/Events/PluginWasEnabled.php b/app/Events/PluginWasEnabled.php
new file mode 100755
index 0000000..a3721dd
--- /dev/null
+++ b/app/Events/PluginWasEnabled.php
@@ -0,0 +1,15 @@
+plugin = $plugin;
+ }
+}
diff --git a/app/Events/RenderingBadges.php b/app/Events/RenderingBadges.php
new file mode 100755
index 0000000..4594d78
--- /dev/null
+++ b/app/Events/RenderingBadges.php
@@ -0,0 +1,13 @@
+badges = &$badges;
+ }
+}
diff --git a/app/Events/RenderingFooter.php b/app/Events/RenderingFooter.php
new file mode 100755
index 0000000..7efd142
--- /dev/null
+++ b/app/Events/RenderingFooter.php
@@ -0,0 +1,18 @@
+contents = &$contents;
+ }
+
+ public function addContent(string $content)
+ {
+ $this->contents[] = $content;
+ }
+}
diff --git a/app/Events/RenderingHeader.php b/app/Events/RenderingHeader.php
new file mode 100755
index 0000000..c136f33
--- /dev/null
+++ b/app/Events/RenderingHeader.php
@@ -0,0 +1,18 @@
+contents = &$contents;
+ }
+
+ public function addContent(string $content)
+ {
+ $this->contents[] = $content;
+ }
+}
diff --git a/app/Events/TextureDeleting.php b/app/Events/TextureDeleting.php
new file mode 100755
index 0000000..59522ff
--- /dev/null
+++ b/app/Events/TextureDeleting.php
@@ -0,0 +1,13 @@
+texture = $texture;
+ }
+}
diff --git a/app/Events/UserAuthenticated.php b/app/Events/UserAuthenticated.php
new file mode 100755
index 0000000..8faf511
--- /dev/null
+++ b/app/Events/UserAuthenticated.php
@@ -0,0 +1,15 @@
+user = $user;
+ }
+}
diff --git a/app/Events/UserLoggedIn.php b/app/Events/UserLoggedIn.php
new file mode 100755
index 0000000..6dc44ea
--- /dev/null
+++ b/app/Events/UserLoggedIn.php
@@ -0,0 +1,15 @@
+user = $user;
+ }
+}
diff --git a/app/Events/UserProfileUpdated.php b/app/Events/UserProfileUpdated.php
new file mode 100755
index 0000000..6ef8387
--- /dev/null
+++ b/app/Events/UserProfileUpdated.php
@@ -0,0 +1,17 @@
+type = $type;
+ $this->user = $user;
+ }
+}
diff --git a/app/Events/UserRegistered.php b/app/Events/UserRegistered.php
new file mode 100755
index 0000000..1ebf02c
--- /dev/null
+++ b/app/Events/UserRegistered.php
@@ -0,0 +1,15 @@
+user = $user;
+ }
+}
diff --git a/app/Events/UserTryToLogin.php b/app/Events/UserTryToLogin.php
new file mode 100755
index 0000000..bd851b0
--- /dev/null
+++ b/app/Events/UserTryToLogin.php
@@ -0,0 +1,16 @@
+identification = $identification;
+ $this->authType = $authType;
+ }
+}
diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php
new file mode 100755
index 0000000..8dc3cc8
--- /dev/null
+++ b/app/Exceptions/Handler.php
@@ -0,0 +1,64 @@
+getModel();
+ if (Str::endsWith($model, 'Texture')) {
+ $exception = new ModelNotFoundException(trans('skinlib.non-existent'));
+ }
+ } elseif ($exception instanceof MissingScopeException) {
+ return json($exception->getMessage(), 403);
+ }
+
+ return parent::render($request, $exception);
+ }
+
+ protected function convertExceptionToArray(Throwable $e)
+ {
+ return [
+ 'message' => $e->getMessage(),
+ 'exception' => true,
+ 'trace' => collect($e->getTrace())
+ ->map(fn ($trace) => Arr::only($trace, ['file', 'line']))
+ ->filter(fn ($trace) => Arr::has($trace, 'file'))
+ ->map(function ($trace) {
+ $trace['file'] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $trace['file']);
+
+ return $trace;
+ })
+ ->filter(function ($trace) {
+ // @codeCoverageIgnoreStart
+ $isFromPlugins = !app()->runningUnitTests() &&
+ Str::contains($trace['file'], resolve('plugins')->getPluginsDirs()->all());
+ // @codeCoverageIgnoreEnd
+ return Str::startsWith($trace['file'], 'app') || $isFromPlugins;
+ })
+ ->values(),
+ ];
+ }
+}
diff --git a/app/Exceptions/PrettyPageException.php b/app/Exceptions/PrettyPageException.php
new file mode 100755
index 0000000..a15a8c0
--- /dev/null
+++ b/app/Exceptions/PrettyPageException.php
@@ -0,0 +1,11 @@
+view('errors.pretty', ['code' => $this->code, 'message' => $this->message]);
+ }
+}
diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php
new file mode 100755
index 0000000..24ec0c5
--- /dev/null
+++ b/app/Http/Controllers/AdminController.php
@@ -0,0 +1,149 @@
+ [
+ ['md-6', 'md-6'],
+ ],
+ 'widgets' => [
+ [
+ [
+ 'admin.widgets.dashboard.usage',
+ 'admin.widgets.dashboard.notification',
+ ],
+ ['admin.widgets.dashboard.chart'],
+ ],
+ ],
+ ];
+ $grid = $filter->apply('grid:admin.index', $grid);
+
+ return view('admin.index', [
+ 'grid' => $grid,
+ 'sum' => [
+ 'users' => User::count(),
+ 'players' => Player::count(),
+ 'textures' => Texture::count(),
+ 'storage' => Texture::select('size')->sum('size'),
+ ],
+ ]);
+ }
+
+ public function chartData()
+ {
+ $xAxis = Collection::times(31, fn ($i) => Carbon::today()->subDays(31 - $i)->isoFormat('l'));
+
+ $oneMonthAgo = Carbon::today()->subMonth();
+
+ $grouping = fn ($field) => fn ($item) => Carbon::parse($item->$field)->isoFormat('l');
+ $mapping = fn ($item) => count($item);
+ $aligning = fn ($data) => fn ($day) => ($data->get($day) ?? 0);
+
+ /** @var Collection */
+ $userRegistration = User::where('register_at', '>=', $oneMonthAgo)
+ ->select('register_at')
+ ->get()
+ ->groupBy($grouping('register_at'))
+ ->map($mapping);
+
+ /** @var Collection */
+ $textureUploads = Texture::where('upload_at', '>=', $oneMonthAgo)
+ ->select('upload_at')
+ ->get()
+ ->groupBy($grouping('upload_at'))
+ ->map($mapping);
+
+ return [
+ 'labels' => [
+ trans('admin.index.user-registration'),
+ trans('admin.index.texture-uploads'),
+ ],
+ 'xAxis' => $xAxis,
+ 'data' => [
+ $xAxis->map($aligning($userRegistration)),
+ $xAxis->map($aligning($textureUploads)),
+ ],
+ ];
+ }
+
+ public function status(
+ Request $request,
+ PluginManager $plugins,
+ Filesystem $filesystem,
+ Filter $filter
+ ) {
+ $db = config('database.connections.'.config('database.default'));
+ $dbType = Arr::get([
+ 'mysql' => 'MySQL/MariaDB',
+ 'sqlite' => 'SQLite',
+ 'pgsql' => 'PostgreSQL',
+ ], config('database.default'), '');
+
+ $enabledPlugins = $plugins->getEnabledPlugins()->map(fn ($plugin) => [
+ 'title' => trans($plugin->title), 'version' => $plugin->version,
+ ]);
+
+ if ($filesystem->exists(base_path('.git'))) {
+ $process = new \Symfony\Component\Process\Process(
+ ['git', 'log', '--pretty=%H', '-1']
+ );
+ $process->run();
+ $commit = $process->isSuccessful() ? trim($process->getOutput()) : '';
+ }
+
+ $grid = [
+ 'layout' => [
+ ['md-6', 'md-6'],
+ ],
+ 'widgets' => [
+ [
+ ['admin.widgets.status.info'],
+ ['admin.widgets.status.plugins'],
+ ],
+ ],
+ ];
+ $grid = $filter->apply('grid:admin.status', $grid);
+
+ return view('admin.status')
+ ->with('grid', $grid)
+ ->with('detail', [
+ 'bs' => [
+ 'version' => config('app.version'),
+ 'env' => config('app.env'),
+ 'debug' => config('app.debug') ? trans('general.yes') : trans('general.no'),
+ 'commit' => Str::limit($commit ?? '', 16, ''),
+ 'laravel' => app()->version(),
+ ],
+ 'server' => [
+ 'php' => PHP_VERSION,
+ 'web' => $request->server('SERVER_SOFTWARE', trans('general.unknown')),
+ 'os' => sprintf('%s %s %s', php_uname('s'), php_uname('r'), php_uname('m')),
+ ],
+ 'db' => [
+ 'type' => $dbType,
+ 'host' => Arr::get($db, 'host', ''),
+ 'port' => Arr::get($db, 'port', ''),
+ 'username' => Arr::get($db, 'username'),
+ 'database' => Arr::get($db, 'database'),
+ 'prefix' => Arr::get($db, 'prefix'),
+ ],
+ ])
+ ->with('plugins', $enabledPlugins);
+ }
+}
diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php
new file mode 100755
index 0000000..d750c9a
--- /dev/null
+++ b/app/Http/Controllers/AuthController.php
@@ -0,0 +1,373 @@
+getValidIpAddress();
+ $ip = $filter->apply('client_ip', $ip);
+
+ $rows = [
+ 'auth.rows.login.notice',
+ 'auth.rows.login.message',
+ 'auth.rows.login.form',
+ 'auth.rows.login.registration-link',
+ ];
+ $rows = $filter->apply('auth_page_rows:login', $rows);
+
+ return view('auth.login', [
+ 'rows' => $rows,
+ 'extra' => [
+ 'tooManyFails' => cache(sha1('login_fails_'.$ip)) > 3,
+ 'recaptcha' => option('recaptcha_sitekey'),
+ 'invisible' => (bool) option('recaptcha_invisible'),
+ ],
+ ]);
+ }
+
+ public function handleLogin(
+ Request $request,
+ Rules\Captcha $captcha,
+ Dispatcher $dispatcher,
+ Filter $filter
+ ) {
+ $data = $request->validate([
+ 'identification' => 'required',
+ 'password' => 'required|min:6|max:32',
+ ]);
+ $identification = $data['identification'];
+ $password = $data['password'];
+
+ $can = $filter->apply('can_login', null, [$identification, $password]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ // Guess type of identification
+ $authType = filter_var($identification, FILTER_VALIDATE_EMAIL) ? 'email' : 'username';
+
+ $dispatcher->dispatch('auth.login.attempt', [$identification, $password, $authType]);
+ event(new Events\UserTryToLogin($identification, $authType));
+
+ if ($authType == 'email') {
+ $user = User::where('email', $identification)->first();
+ } else {
+ $player = Player::where('name', $identification)->first();
+ $user = optional($player)->user;
+ }
+
+ // Require CAPTCHA if user fails to login more than 3 times
+ $whip = new Whip();
+ $ip = $whip->getValidIpAddress();
+ $ip = $filter->apply('client_ip', $ip);
+ $loginFailsCacheKey = sha1('login_fails_'.$ip);
+ $loginFails = (int) Cache::get($loginFailsCacheKey, 0);
+
+ if ($loginFails > 3) {
+ $request->validate(['captcha' => ['required', $captcha]]);
+ }
+
+ if (!$user) {
+ return json(trans('auth.validation.user'), 2);
+ }
+
+ $dispatcher->dispatch('auth.login.ready', [$user]);
+
+ if ($user->verifyPassword($request->input('password'))) {
+ Session::forget('login_fails');
+ Cache::forget($loginFailsCacheKey);
+
+ Auth::login($user, $request->input('keep'));
+
+ $dispatcher->dispatch('auth.login.succeeded', [$user]);
+ event(new Events\UserLoggedIn($user));
+
+ return json(trans('auth.login.success'), 0, [
+ 'redirectTo' => $request->session()->pull('last_requested_path', url('/user')),
+ ]);
+ } else {
+ $loginFails++;
+ Cache::put($loginFailsCacheKey, $loginFails, 3600);
+ $dispatcher->dispatch('auth.login.failed', [$user, $loginFails]);
+
+ return json(trans('auth.validation.password'), 1, [
+ 'login_fails' => $loginFails,
+ ]);
+ }
+ }
+
+ public function logout(Dispatcher $dispatcher)
+ {
+ $user = Auth::user();
+
+ $dispatcher->dispatch('auth.logout.before', [$user]);
+ Auth::logout();
+ $dispatcher->dispatch('auth.logout.after', [$user]);
+
+ return json(trans('auth.logout.success'), 0);
+ }
+
+ public function register(Filter $filter)
+ {
+ $rows = [
+ 'auth.rows.register.notice',
+ 'auth.rows.register.form',
+ ];
+ $rows = $filter->apply('auth_page_rows:register', $rows);
+
+ return view('auth.register', [
+ 'site_name' => option_localized('site_name'),
+ 'rows' => $rows,
+ 'extra' => [
+ 'player' => (bool) option('register_with_player_name'),
+ 'recaptcha' => option('recaptcha_sitekey'),
+ 'invisible' => (bool) option('recaptcha_invisible'),
+ ],
+ ]);
+ }
+
+ public function handleRegister(
+ Request $request,
+ Rules\Captcha $captcha,
+ Dispatcher $dispatcher,
+ Filter $filter
+ ) {
+ $can = $filter->apply('can_register', null);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ $rule = option('register_with_player_name') ?
+ ['player_name' => [
+ 'required',
+ new Rules\PlayerName(),
+ 'min:'.option('player_name_length_min'),
+ 'max:'.option('player_name_length_max'),
+ ]] :
+ ['nickname' => 'required|max:255'];
+ $data = $request->validate(array_merge([
+ 'email' => 'required|email|unique:users',
+ 'password' => 'required|min:8|max:32',
+ 'captcha' => ['required', $captcha],
+ ], $rule));
+ $playerName = $request->input('player_name');
+
+ $dispatcher->dispatch('auth.registration.attempt', [$data]);
+
+ if (
+ option('register_with_player_name') &&
+ Player::where('name', $playerName)->count() > 0
+ ) {
+ return json(trans('user.player.add.repeated'), 1);
+ }
+
+ // If amount of registered accounts of IP is more than allowed amount,
+ // reject this registration.
+ $whip = new Whip();
+ $ip = $whip->getValidIpAddress();
+ $ip = $filter->apply('client_ip', $ip);
+ if (User::where('ip', $ip)->count() >= option('regs_per_ip')) {
+ return json(trans('auth.register.max', ['regs' => option('regs_per_ip')]), 1);
+ }
+
+ $dispatcher->dispatch('auth.registration.ready', [$data]);
+
+ $user = new User();
+ $user->email = $data['email'];
+ $user->nickname = $data[option('register_with_player_name') ? 'player_name' : 'nickname'];
+ $user->score = option('user_initial_score');
+ $user->avatar = 0;
+ $password = app('cipher')->hash($data['password'], config('secure.salt'));
+ $password = $filter->apply('user_password', $password);
+ $user->password = $password;
+ $user->ip = $ip;
+ $user->permission = User::NORMAL;
+ $user->register_at = Carbon::now();
+ $user->last_sign_at = Carbon::now()->subDay();
+ $user->save();
+
+ $dispatcher->dispatch('auth.registration.completed', [$user]);
+ event(new Events\UserRegistered($user));
+
+ if (option('register_with_player_name')) {
+ $dispatcher->dispatch('player.adding', [$playerName, $user]);
+
+ $player = new Player();
+ $player->uid = $user->uid;
+ $player->name = $playerName;
+ $player->tid_skin = 0;
+ $player->save();
+
+ $dispatcher->dispatch('player.added', [$player, $user]);
+ event(new Events\PlayerWasAdded($player));
+ }
+
+ $dispatcher->dispatch('auth.login.ready', [$user]);
+ Auth::login($user);
+ $dispatcher->dispatch('auth.login.succeeded', [$user]);
+
+ return json(trans('auth.register.success'), 0);
+ }
+
+ public function forgot()
+ {
+ if (config('mail.default') != '') {
+ return view('auth.forgot', [
+ 'extra' => [
+ 'recaptcha' => option('recaptcha_sitekey'),
+ 'invisible' => (bool) option('recaptcha_invisible'),
+ ],
+ ]);
+ } else {
+ throw new PrettyPageException(trans('auth.forgot.disabled'), 8);
+ }
+ }
+
+ public function handleForgot(
+ Request $request,
+ Rules\Captcha $captcha,
+ Dispatcher $dispatcher,
+ Filter $filter
+ ) {
+ $data = $request->validate([
+ 'email' => 'required|email',
+ 'captcha' => ['required', $captcha],
+ ]);
+
+ if (!config('mail.default')) {
+ return json(trans('auth.forgot.disabled'), 1);
+ }
+
+ $email = $data['email'];
+ $dispatcher->dispatch('auth.forgot.attempt', [$email]);
+
+ $rateLimit = 180;
+ $whip = new Whip();
+ $ip = $whip->getValidIpAddress();
+ $ip = $filter->apply('client_ip', $ip);
+ $lastMailCacheKey = sha1('last_mail_'.$ip);
+ $remain = $rateLimit + Cache::get($lastMailCacheKey, 0) - time();
+ if ($remain > 0) {
+ return json(trans('auth.forgot.frequent-mail'), 2);
+ }
+
+ $user = User::where('email', $email)->first();
+ if (!$user) {
+ return json(trans('auth.forgot.unregistered'), 1);
+ }
+
+ $dispatcher->dispatch('auth.forgot.ready', [$user]);
+
+ $url = URL::temporarySignedRoute(
+ 'auth.reset',
+ Carbon::now()->addHour(),
+ ['uid' => $user->uid],
+ false
+ );
+ try {
+ Mail::to($email)->send(new ForgotPassword(url($url)));
+ } catch (\Exception $e) {
+ report($e);
+ $dispatcher->dispatch('auth.forgot.failed', [$user, $url]);
+
+ return json(trans('auth.forgot.failed', ['msg' => $e->getMessage()]), 2);
+ }
+
+ $dispatcher->dispatch('auth.forgot.sent', [$user, $url]);
+ Cache::put($lastMailCacheKey, time(), 3600);
+
+ return json(trans('auth.forgot.success'), 0);
+ }
+
+ public function reset(Request $request, $uid)
+ {
+ abort_unless($request->hasValidSignature(false), 403, trans('auth.reset.invalid'));
+
+ return view('auth.reset')->with('user', User::find($uid));
+ }
+
+ public function handleReset(Dispatcher $dispatcher, Request $request, $uid)
+ {
+ abort_unless($request->hasValidSignature(false), 403, trans('auth.reset.invalid'));
+
+ ['password' => $password] = $request->validate([
+ 'password' => 'required|min:8|max:32',
+ ]);
+ $user = User::find($uid);
+
+ $dispatcher->dispatch('auth.reset.before', [$user, $password]);
+ $user->changePassword($password);
+ $dispatcher->dispatch('auth.reset.after', [$user, $password]);
+
+ return json(trans('auth.reset.success'), 0);
+ }
+
+ public function captcha(\Gregwar\Captcha\CaptchaBuilder $builder)
+ {
+ $builder->build(100, 34);
+ session(['captcha' => $builder->getPhrase()]);
+
+ return response($builder->output(), 200, [
+ 'Content-Type' => 'image/jpeg',
+ 'Cache-Control' => 'no-store',
+ ]);
+ }
+
+ public function fillEmail(Request $request)
+ {
+ $email = $request->validate(['email' => 'required|email|unique:users'])['email'];
+ $user = $request->user();
+ $user->email = $email;
+ $user->save();
+
+ return redirect('/user');
+ }
+
+ public function verify(Request $request)
+ {
+ if (!option('require_verification')) {
+ throw new PrettyPageException(trans('user.verification.disabled'), 1);
+ }
+
+ abort_unless($request->hasValidSignature(false), 403, trans('auth.verify.invalid'));
+
+ return view('auth.verify');
+ }
+
+ public function handleVerify(Request $request, User $user)
+ {
+ abort_unless($request->hasValidSignature(false), 403, trans('auth.verify.invalid'));
+
+ ['email' => $email] = $request->validate(['email' => 'required|email']);
+
+ if ($user->email !== $email) {
+ return back()->with('errorMessage', trans('auth.verify.not-matched'));
+ }
+
+ $user->verified = true;
+ $user->save();
+
+ return redirect()->route('user.home');
+ }
+}
diff --git a/app/Http/Controllers/ClosetController.php b/app/Http/Controllers/ClosetController.php
new file mode 100755
index 0000000..3529cfa
--- /dev/null
+++ b/app/Http/Controllers/ClosetController.php
@@ -0,0 +1,198 @@
+ [
+ ['md-8', 'md-4'],
+ ],
+ 'widgets' => [
+ [
+ [
+ 'user.widgets.email-verification',
+ 'user.widgets.closet.list',
+ ],
+ ['shared.previewer'],
+ ],
+ ],
+ ];
+ $grid = $filter->apply('grid:user.closet', $grid);
+
+ return view('user.closet')
+ ->with('grid', $grid)
+ ->with('extra', [
+ 'unverified' => option('require_verification') && !auth()->user()->verified,
+ 'rule' => trans('user.player.player-name-rule.'.option('player_name_rule')),
+ 'length' => trans(
+ 'user.player.player-name-length',
+ ['min' => option('player_name_length_min'), 'max' => option('player_name_length_max')]
+ ),
+ ]);
+ }
+
+ public function getClosetData(Request $request)
+ {
+ $category = $request->input('category', 'skin');
+ /** @var User */
+ $user = auth()->user();
+
+ return $user
+ ->closet()
+ ->when(
+ $category === 'cape',
+ fn (Builder $query) => $query->where('type', 'cape'),
+ fn (Builder $query) => $query->whereIn('type', ['steve', 'alex']),
+ )
+ ->when(
+ $request->input('q'),
+ fn (Builder $query, $search) => $query->like('item_name', $search)
+ )
+ ->orderBy('texture_tid', 'DESC')
+ ->paginate((int) $request->input('perPage', 6));
+ }
+
+ public function allIds()
+ {
+ /** @var User */
+ $user = auth()->user();
+
+ return $user->closet()->pluck('texture_tid');
+ }
+
+ public function add(
+ Request $request,
+ Dispatcher $dispatcher,
+ Filter $filter
+ ) {
+ ['tid' => $tid, 'name' => $name] = $request->validate([
+ 'tid' => 'required|integer',
+ 'name' => 'required',
+ ]);
+
+ /** @var User */
+ $user = Auth::user();
+ $name = $filter->apply('add_closet_item_name', $name, [$tid]);
+ $dispatcher->dispatch('closet.adding', [$tid, $name, $user]);
+
+ $can = $filter->apply('can_add_closet_item', true, [$tid, $name]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ if ($user->score < option('score_per_closet_item')) {
+ return json(trans('user.closet.add.lack-score'), 1);
+ }
+
+ $tid = $request->tid;
+ $texture = Texture::find($tid);
+ if (!$texture) {
+ return json(trans('user.closet.add.not-found'), 1);
+ }
+
+ if (!$texture->public && ($texture->uploader != $user->uid && !$user->isAdmin())) {
+ return json(trans('skinlib.show.private'), 1);
+ }
+
+ if ($user->closet()->where('tid', $request->tid)->count() > 0) {
+ return json(trans('user.closet.add.repeated'), 1);
+ }
+
+ $user->closet()->attach($tid, ['item_name' => $request->name]);
+ $user->score -= option('score_per_closet_item');
+ $user->save();
+
+ $texture->likes++;
+ $texture->save();
+
+ $dispatcher->dispatch('closet.added', [$texture, $name, $user]);
+
+ $uploader = User::find($texture->uploader);
+ if ($uploader && $uploader->uid != $user->uid) {
+ $uploader->score += option('score_award_per_like', 0);
+ $uploader->save();
+ }
+
+ return json(trans('user.closet.add.success', ['name' => $request->input('name')]), 0);
+ }
+
+ public function rename(
+ Request $request,
+ Dispatcher $dispatcher,
+ Filter $filter,
+ $tid
+ ) {
+ ['name' => $name] = $request->validate(['name' => 'required']);
+ /** @var User */
+ $user = auth()->user();
+
+ $name = $filter->apply('rename_closet_item_name', $name, [$tid]);
+ $dispatcher->dispatch('closet.renaming', [$tid, $name, $user]);
+
+ $item = $user->closet()->find($tid);
+ if (empty($item)) {
+ return json(trans('user.closet.remove.non-existent'), 1);
+ }
+ $previousName = $item->pivot->item_name;
+
+ $can = $filter->apply('can_rename_closet_item', true, [$item, $name]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ $user->closet()->updateExistingPivot($tid, ['item_name' => $name]);
+
+ $dispatcher->dispatch('closet.renamed', [$item, $previousName, $user]);
+
+ return json(trans('user.closet.rename.success', ['name' => $name]), 0);
+ }
+
+ public function remove(Dispatcher $dispatcher, Filter $filter, $tid)
+ {
+ /** @var User */
+ $user = auth()->user();
+
+ $dispatcher->dispatch('closet.removing', [$tid, $user]);
+
+ $item = $user->closet()->find($tid);
+ if (empty($item)) {
+ return json(trans('user.closet.remove.non-existent'), 1);
+ }
+
+ $can = $filter->apply('can_remove_closet_item', true, [$item]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ $user->closet()->detach($tid);
+
+ if (option('return_score')) {
+ $user->score += option('score_per_closet_item');
+ $user->save();
+ }
+
+ $texture = Texture::find($tid);
+ $texture->likes--;
+ $texture->save();
+
+ $dispatcher->dispatch('closet.removed', [$texture, $user]);
+
+ $uploader = User::find($texture->uploader);
+ $uploader->score -= option('score_award_per_like', 0);
+ $uploader->save();
+
+ return json(trans('user.closet.remove.success'), 0);
+ }
+}
diff --git a/app/Http/Controllers/ClosetManagementController.php b/app/Http/Controllers/ClosetManagementController.php
new file mode 100755
index 0000000..514f3ea
--- /dev/null
+++ b/app/Http/Controllers/ClosetManagementController.php
@@ -0,0 +1,47 @@
+closet;
+ }
+
+ public function add(Request $request, Dispatcher $dispatcher, User $user)
+ {
+ $tid = $request->input('tid');
+ /** @var Texture */
+ $texture = Texture::findOrFail($tid);
+ $name = $texture->name;
+
+ $dispatcher->dispatch('closet.adding', [$tid, $name, $user]);
+
+ $user->closet()->attach($texture->tid, ['item_name' => $name]);
+
+ $dispatcher->dispatch('closet.added', [$texture, $name, $user]);
+
+ return json('', 0, compact('user', 'texture'));
+ }
+
+ public function remove(Request $request, Dispatcher $dispatcher, User $user)
+ {
+ $tid = $request->input('tid');
+ $dispatcher->dispatch('closet.removing', [$tid, $user]);
+
+ /** @var Texture */
+ $texture = Texture::findOrFail($tid);
+
+ $user->closet()->detach($texture->tid);
+
+ $dispatcher->dispatch('closet.removed', [$texture, $user]);
+
+ return json('', 0, compact('user', 'texture'));
+ }
+}
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
new file mode 100755
index 0000000..23cb22e
--- /dev/null
+++ b/app/Http/Controllers/Controller.php
@@ -0,0 +1,13 @@
+with('user', auth()->user())
+ ->with('site_description', option_localized('site_description'))
+ ->with('transparent_navbar', (bool) option('transparent_navbar', false))
+ ->with('fixed_bg', option('fixed_bg'))
+ ->with('hide_intro', option('hide_intro'))
+ ->with('home_pic_url', option('home_pic_url') ?: config('options.home_pic_url'));
+ }
+
+ public function apiRoot()
+ {
+ $copyright = Arr::get(
+ [
+ 'Powered with ❤ by Blessing Skin Server.',
+ 'Powered by Blessing Skin Server.',
+ 'Proudly powered by Blessing Skin Server.',
+ '由 Blessing Skin Server 强力驱动。',
+ '采用 Blessing Skin Server 搭建。',
+ '使用 Blessing Skin Server 稳定运行。',
+ '自豪地采用 Blessing Skin Server。',
+ ],
+ option_localized('copyright_prefer', 0)
+ );
+
+ return response()->json([
+ 'blessing_skin' => config('app.version'),
+ 'spec' => 0,
+ 'copyright' => $copyright,
+ 'site_name' => option('site_name'),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/MarketController.php b/app/Http/Controllers/MarketController.php
new file mode 100755
index 0000000..d3857fd
--- /dev/null
+++ b/app/Http/Controllers/MarketController.php
@@ -0,0 +1,100 @@
+fetch()->map(function ($item) use ($manager) {
+ $plugin = $manager->get($item['name']);
+
+ if ($plugin) {
+ $item['installed'] = $plugin->version;
+ $item['can_update'] = Comparator::greaterThan($item['version'], $item['installed']);
+ } else {
+ $item['installed'] = false;
+ }
+
+ $requirements = Arr::get($item, 'require', []);
+ unset($item['require']);
+ $item['dependencies'] = [
+ 'all' => $requirements,
+ 'unsatisfied' => $manager->getUnsatisfied(new Plugin('', $item)),
+ ];
+
+ return $item;
+ });
+
+ return $plugins;
+ }
+
+ public function download(Request $request, PluginManager $manager, Unzip $unzip)
+ {
+ $name = $request->input('name');
+ $plugins = $this->fetch();
+ $metadata = $plugins->firstWhere('name', $name);
+
+ if (!$metadata) {
+ return json(trans('admin.plugins.market.non-existent', ['plugin' => $name]), 1);
+ }
+
+ $fakePlugin = new Plugin('', $metadata);
+ $unsatisfied = $manager->getUnsatisfied($fakePlugin);
+ $conflicts = $manager->getConflicts($fakePlugin);
+ if ($unsatisfied->isNotEmpty() || $conflicts->isNotEmpty()) {
+ $reason = $manager->formatUnresolved($unsatisfied, $conflicts);
+
+ return json(trans('admin.plugins.market.unresolved'), 1, compact('reason'));
+ }
+
+ $path = tempnam(sys_get_temp_dir(), $name);
+ $response = Http::withOptions([
+ 'sink' => $path,
+ 'verify' => CaBundle::getSystemCaRootBundlePath(),
+ ])->get($metadata['dist']['url']);
+
+ if ($response->ok()) {
+ $unzip->extract($path, $manager->getPluginsDirs()->first());
+
+ return json(trans('admin.plugins.market.install-success'), 0);
+ } else {
+ return json(trans('admin.download.errors.download', ['error' => $response->status()]), 1);
+ }
+ }
+
+ protected function fetch(): Collection
+ {
+ $lang = in_array(app()->getLocale(), config('plugins.locales'))
+ ? app()->getLocale()
+ : config('app.fallback_locale');
+
+ $plugins = collect(explode(',', config('plugins.registry')))
+ ->map(function ($registry) use ($lang) {
+ $registry = str_replace('{lang}', $lang, $registry);
+ $response = Http::withOptions([
+ 'verify' => CaBundle::getSystemCaRootBundlePath(),
+ ])->get(trim($registry));
+
+ if ($response->ok()) {
+ return $response->json()['packages'];
+ } else {
+ throw new Exception(trans('admin.plugins.market.connection-error', ['error' => $response->status()]));
+ }
+ })
+ ->flatten(1);
+
+ return $plugins;
+ }
+}
diff --git a/app/Http/Controllers/NotificationsController.php b/app/Http/Controllers/NotificationsController.php
new file mode 100755
index 0000000..8b7d35e
--- /dev/null
+++ b/app/Http/Controllers/NotificationsController.php
@@ -0,0 +1,72 @@
+validate([
+ 'receiver' => 'required|in:all,normal,uid,email',
+ 'uid' => 'required_if:receiver,uid|nullable|integer|exists:users',
+ 'email' => 'required_if:receiver,email|nullable|email|exists:users',
+ 'title' => 'required|max:20',
+ 'content' => 'string|nullable',
+ ]);
+
+ $notification = new Notifications\SiteMessage($data['title'], $data['content']);
+
+ switch ($data['receiver']) {
+ case 'all':
+ $users = User::all();
+ break;
+ case 'normal':
+ $users = User::where('permission', User::NORMAL)->get();
+ break;
+ case 'uid':
+ $users = User::where('uid', $data['uid'])->get();
+ break;
+ case 'email':
+ $users = User::where('email', $data['email'])->get();
+ break;
+ }
+ Notification::send($users, $notification);
+
+ session(['sentResult' => trans('admin.notifications.send.success')]);
+
+ return redirect('/admin');
+ }
+
+ public function all()
+ {
+ return auth()->user()
+ ->unreadNotifications
+ ->map(fn ($notification) => [
+ 'id' => $notification->id,
+ 'title' => $notification->data['title'],
+ ]);
+ }
+
+ public function read($id)
+ {
+ $notification = auth()
+ ->user()
+ ->unreadNotifications
+ ->first(fn ($notification) => $notification->id === $id);
+ $notification->markAsRead();
+
+ $converter = new GithubFlavoredMarkdownConverter();
+
+ return [
+ 'title' => $notification->data['title'],
+ 'content' => $converter->convertToHtml($notification->data['content'] ?? '')->getContent(),
+ 'time' => $notification->created_at->toDateTimeString(),
+ ];
+ }
+}
diff --git a/app/Http/Controllers/OptionsController.php b/app/Http/Controllers/OptionsController.php
new file mode 100755
index 0000000..a308e0c
--- /dev/null
+++ b/app/Http/Controllers/OptionsController.php
@@ -0,0 +1,266 @@
+text('home_pic_url')->hint();
+
+ $form->text('favicon_url')->hint()->description();
+
+ $form->checkbox('transparent_navbar')->label();
+
+ $form->checkbox('hide_intro')->label();
+
+ $form->checkbox('fixed_bg')->label();
+
+ $form->select('copyright_prefer')
+ ->option('0', 'Powered with ❤ by Blessing Skin Server.')
+ ->option('1', 'Powered by Blessing Skin Server.')
+ ->option('2', 'Proudly powered by Blessing Skin Server.')
+ ->option('3', '由 Blessing Skin Server 强力驱动。')
+ ->option('4', '采用 Blessing Skin Server 搭建。')
+ ->option('5', '使用 Blessing Skin Server 稳定运行。')
+ ->option('6', '自豪地采用 Blessing Skin Server。')
+ ->description();
+
+ $form->textarea('copyright_text')->rows(6)->description();
+ })->handle(function () {
+ Option::set('copyright_prefer_'.config('app.locale'), request('copyright_prefer'));
+ Option::set('copyright_text_'.config('app.locale'), request('copyright_text'));
+ });
+
+ $customJsCss = Option::form('customJsCss', OptionForm::AUTO_DETECT, function ($form) {
+ $form->textarea('custom_css', 'CSS')->rows(6);
+ $form->textarea('custom_js', 'JavaScript')->rows(6);
+ })->addMessage()->handle();
+
+ if ($request->isMethod('post') && $request->input('action') === 'color') {
+ $navbar = $request->input('navbar');
+ if ($navbar) {
+ option(['navbar_color' => $navbar]);
+ }
+
+ $sidebar = $request->input('sidebar');
+ if ($sidebar) {
+ option(['sidebar_color' => $sidebar]);
+ }
+ }
+
+ return view('admin.customize', [
+ 'colors' => [
+ 'navbar' => [
+ 'primary', 'secondary', 'success', 'danger', 'indigo',
+ 'purple', 'pink', 'teal', 'cyan', 'dark', 'gray',
+ 'fuchsia', 'maroon', 'olive', 'navy',
+ 'lime', 'light', 'warning', 'white', 'orange',
+ ],
+ 'sidebar' => [
+ 'primary', 'warning', 'info', 'danger', 'success', 'indigo',
+ 'navy', 'purple', 'fuchsia', 'pink', 'maroon', 'orange',
+ 'lime', 'teal', 'olive',
+ ],
+ ],
+ 'forms' => [
+ 'homepage' => $homepage,
+ 'custom_js_css' => $customJsCss,
+ ],
+ 'extra' => [
+ 'navbar' => option('navbar_color'),
+ 'sidebar' => option('sidebar_color'),
+ ],
+ ]);
+ }
+
+ public function score()
+ {
+ $rate = Option::form('rate', OptionForm::AUTO_DETECT, function ($form) {
+ $form->group('score_per_storage')->text('score_per_storage')->addon();
+
+ $form->group('private_score_per_storage')
+ ->text('private_score_per_storage')->addon()->hint();
+
+ $form->group('score_per_closet_item')
+ ->text('score_per_closet_item')->addon();
+
+ $form->checkbox('return_score')->label();
+
+ $form->group('score_per_player')->text('score_per_player')->addon();
+
+ $form->text('user_initial_score');
+ })->handle();
+
+ $report = Option::form('report', OptionForm::AUTO_DETECT, function ($form) {
+ $form->text('reporter_score_modification')->description();
+
+ $form->text('reporter_reward_score');
+ })->handle();
+
+ $sign = Option::form('sign', OptionForm::AUTO_DETECT, function ($form) {
+ $form->group('sign_score')
+ ->text('sign_score_from')->addon(trans('options.sign.sign_score.addon1'))
+ ->text('sign_score_to')->addon(trans('options.sign.sign_score.addon2'));
+
+ $form->group('sign_gap_time')->text('sign_gap_time')->addon();
+
+ $form->checkbox('sign_after_zero')->label()->hint();
+ })->after(function () {
+ $sign_score = request('sign_score_from').','.request('sign_score_to');
+ Option::set('sign_score', $sign_score);
+ })->with([
+ 'sign_score_from' => @explode(',', option('sign_score'))[0],
+ 'sign_score_to' => @explode(',', option('sign_score'))[1],
+ ])->handle();
+
+ $sharing = Option::form('sharing', OptionForm::AUTO_DETECT, function ($form) {
+ $form->group('score_award_per_texture')
+ ->text('score_award_per_texture')
+ ->addon(trans('general.user.score'));
+ $form->checkbox('take_back_scores_after_deletion')->label();
+ $form->group('score_award_per_like')
+ ->text('score_award_per_like')
+ ->addon(trans('general.user.score'));
+ })->handle();
+
+ return view('admin.score', ['forms' => compact('rate', 'report', 'sign', 'sharing')]);
+ }
+
+ public function options()
+ {
+ $general = Option::form('general', OptionForm::AUTO_DETECT, function ($form) {
+ $form->text('site_name');
+ $form->text('site_description')->description();
+
+ $form->text('site_url')
+ ->hint()
+ ->format(function ($url) {
+ if (Str::endsWith($url, '/')) {
+ $url = substr($url, 0, -1);
+ }
+
+ if (Str::endsWith($url, '/index.php')) {
+ $url = substr($url, 0, -10);
+ }
+
+ return $url;
+ });
+
+ $form->checkbox('register_with_player_name')->label();
+ $form->checkbox('require_verification')->label();
+
+ $form->text('regs_per_ip');
+
+ $form->group('max_upload_file_size')
+ ->text('max_upload_file_size')->addon('KB')
+ ->hint(trans('options.general.max_upload_file_size.hint', ['size' => ini_get('upload_max_filesize')]));
+
+ $form->select('player_name_rule')
+ ->option('official', trans('options.general.player_name_rule.official'))
+ ->option('cjk', trans('options.general.player_name_rule.cjk'))
+ ->option('utf8', trans('options.general.player_name_rule.utf8'))
+ ->option('custom', trans('options.general.player_name_rule.custom'));
+
+ $form->text('custom_player_name_regexp')->hint()->placeholder();
+
+ $form->group('player_name_length')
+ ->text('player_name_length_min')
+ ->addon('~')
+ ->text('player_name_length_max')
+ ->addon(trans('options.general.player_name_length.suffix'));
+
+ $form->checkbox('auto_del_invalid_texture')->label()->hint();
+
+ $form->checkbox('allow_downloading_texture')->label();
+
+ $form->select('status_code_for_private')
+ ->option('403', '403 Forbidden')
+ ->option('404', '404 Not Found');
+
+ $form->text('texture_name_regexp')->hint()->placeholder();
+
+ $form->textarea('content_policy')->rows(3)->description();
+ })->handle(function () {
+ Option::set('site_name_'.config('app.locale'), request('site_name'));
+ Option::set('site_description_'.config('app.locale'), request('site_description'));
+ Option::set('content_policy_'.config('app.locale'), request('content_policy'));
+ });
+
+ $announ = Option::form('announ', OptionForm::AUTO_DETECT, function ($form) {
+ $form->textarea('announcement')->rows(10)->description();
+ })->renderWithoutTable()->handle(function () {
+ Option::set('announcement_'.config('app.locale'), request('announcement'));
+ });
+
+ $meta = Option::form('meta', OptionForm::AUTO_DETECT, function ($form) {
+ $form->text('meta_keywords')->hint();
+ $form->text('meta_description')->hint();
+ $form->textarea('meta_extras')->rows(6);
+ })->handle();
+
+ $recaptcha = Option::form('recaptcha', 'reCAPTCHA', function ($form) {
+ $form->text('recaptcha_sitekey', 'sitekey');
+ $form->text('recaptcha_secretkey', 'secretkey');
+ $form->checkbox('recaptcha_invisible')->label();
+ })->handle();
+
+ return view('admin.options')
+ ->with('forms', compact('general', 'announ', 'meta', 'recaptcha'));
+ }
+
+ public function resource(Request $request)
+ {
+ $resources = Option::form('resources', OptionForm::AUTO_DETECT, function ($form) {
+ $form->checkbox('force_ssl')->label()->hint();
+ $form->checkbox('auto_detect_asset_url')->label()->description();
+
+ $form->text('cache_expire_time')->hint(OptionForm::AUTO_DETECT);
+ $form->text('cdn_address')
+ ->hint(OptionForm::AUTO_DETECT)
+ ->description(OptionForm::AUTO_DETECT);
+ })
+ ->type('primary')
+ ->hint(OptionForm::AUTO_DETECT)
+ ->after(function () {
+ $cdnAddress = request('cdn_address');
+ if ($cdnAddress == null) {
+ $cdnAddress = '';
+ }
+ if (Str::endsWith($cdnAddress, '/')) {
+ $cdnAddress = substr($cdnAddress, 0, -1);
+ }
+ Option::set('cdn_address', $cdnAddress);
+ })
+ ->handle();
+
+ $cache = Option::form('cache', OptionForm::AUTO_DETECT, function ($form) {
+ $form->checkbox('enable_avatar_cache')->label();
+ $form->checkbox('enable_preview_cache')->label();
+ })
+ ->type('warning')
+ ->addButton([
+ 'text' => trans('options.cache.clear'),
+ 'type' => 'a',
+ 'class' => 'float-right',
+ 'style' => 'warning',
+ 'href' => '?clear-cache',
+ ])
+ ->addMessage(trans('options.cache.driver', ['driver' => config('cache.default')]), 'info');
+
+ if ($request->has('clear-cache')) {
+ Cache::flush();
+ $cache->addMessage(trans('options.cache.cleared'), 'success');
+ }
+ $cache->handle();
+
+ return view('admin.resource')->with('forms', compact('resources', 'cache'));
+ }
+}
diff --git a/app/Http/Controllers/PlayerController.php b/app/Http/Controllers/PlayerController.php
new file mode 100755
index 0000000..057ecb2
--- /dev/null
+++ b/app/Http/Controllers/PlayerController.php
@@ -0,0 +1,260 @@
+middleware(function (Request $request, $next) {
+ /** @var Player */
+ $player = $request->route('player');
+ if ($player->user->isNot($request->user())) {
+ return json(trans('admin.players.no-permission'), 1)
+ ->setStatusCode(403);
+ }
+
+ return $next($request);
+ }, [
+ 'only' => ['delete', 'rename', 'setTexture', 'clearTexture'],
+ ]);
+ }
+
+ public function index(Filter $filter)
+ {
+ $grid = [
+ 'layout' => [
+ ['md-6', 'md-6'],
+ ],
+ 'widgets' => [
+ [
+ [
+ 'user.widgets.players.list',
+ 'user.widgets.players.notice',
+ ],
+ ['shared.previewer'],
+ ],
+ ],
+ ];
+ $grid = $filter->apply('grid:user.player', $grid);
+
+ /** @var User */
+ $user = auth()->user();
+
+ return view('user.player')
+ ->with('grid', $grid)
+ ->with('extra', [
+ 'count' => $user->players()->count(),
+ 'rule' => trans('user.player.player-name-rule.'.option('player_name_rule')),
+ 'length' => trans(
+ 'user.player.player-name-length',
+ ['min' => option('player_name_length_min'), 'max' => option('player_name_length_max')]
+ ),
+ 'score' => auth()->user()->score,
+ 'cost' => (int) option('score_per_player'),
+ ]);
+ }
+
+ public function list()
+ {
+ return Auth::user()->players;
+ }
+
+ public function add(Request $request, Dispatcher $dispatcher, Filter $filter)
+ {
+ /** @var User */
+ $user = Auth::user();
+
+ $name = $request->validate([
+ 'name' => [
+ 'required',
+ new Rules\PlayerName(),
+ 'min:'.option('player_name_length_min'),
+ 'max:'.option('player_name_length_max'),
+ 'unique:players',
+ ],
+ ])['name'];
+ $name = $filter->apply('new_player_name', $name);
+
+ $dispatcher->dispatch('player.add.attempt', [$name, $user]);
+
+ $can = $filter->apply('can_add_player', true, [$name]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ if ($user->score < (int) option('score_per_player')) {
+ return json(trans('user.player.add.lack-score'), 7);
+ }
+
+ $dispatcher->dispatch('player.adding', [$name, $user]);
+ event(new PlayerWillBeAdded($name));
+
+ $player = new Player();
+ $player->uid = $user->uid;
+ $player->name = $name;
+ $player->tid_skin = 0;
+ $player->tid_cape = 0;
+ $player->save();
+
+ $user->score -= (int) option('score_per_player');
+ $user->save();
+
+ $dispatcher->dispatch('player.added', [$player, $user]);
+ event(new PlayerWasAdded($player));
+
+ return json(trans('user.player.add.success', ['name' => $name]), 0, $player->toArray());
+ }
+
+ public function delete(
+ Dispatcher $dispatcher,
+ Filter $filter,
+ Player $player
+ ) {
+ /** @var User */
+ $user = auth()->user();
+ $playerName = $player->name;
+
+ $dispatcher->dispatch('player.delete.attempt', [$player, $user]);
+
+ $can = $filter->apply('can_delete_player', true, [$player]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ $dispatcher->dispatch('player.deleting', [$player, $user]);
+ event(new PlayerWillBeDeleted($player));
+
+ $player->delete();
+
+ if (option('return_score')) {
+ $user->score += (int) option('score_per_player');
+ $user->save();
+ }
+
+ $dispatcher->dispatch('player.deleted', [$player, $user]);
+ event(new PlayerWasDeleted($playerName));
+
+ return json(trans('user.player.delete.success', ['name' => $playerName]), 0);
+ }
+
+ public function rename(
+ Request $request,
+ Dispatcher $dispatcher,
+ Filter $filter,
+ Player $player
+ ) {
+ $name = $request->validate([
+ 'name' => [
+ 'required',
+ new Rules\PlayerName(),
+ 'min:'.option('player_name_length_min'),
+ 'max:'.option('player_name_length_max'),
+ Rule::unique('players')->ignoreModel($player),
+ ],
+ ])['name'];
+ $name = $filter->apply('new_player_name', $name);
+
+ $dispatcher->dispatch('player.renaming', [$player, $name]);
+
+ $can = $filter->apply('can_rename_player', true, [$player, $name]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ $old = $player->replicate();
+ $player->name = $name;
+ $player->save();
+
+ $dispatcher->dispatch('player.renamed', [$player, $old]);
+
+ return json(
+ trans('user.player.rename.success', ['old' => $old->name, 'new' => $name]),
+ 0,
+ $player->toArray()
+ );
+ }
+
+ public function setTexture(
+ Request $request,
+ Dispatcher $dispatcher,
+ Filter $filter,
+ Player $player
+ ) {
+ /** @var User */
+ $user = auth()->user();
+
+ foreach (['skin', 'cape'] as $type) {
+ $tid = $request->input($type);
+
+ $can = $filter->apply('can_set_texture', true, [$player, $type, $tid]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ if ($tid) {
+ $texture = Texture::find($tid);
+ if (empty($texture)) {
+ return json(trans('skinlib.non-existent'), 1);
+ }
+
+ if ($user->closet()->where('texture_tid', $tid)->doesntExist()) {
+ return json(trans('user.closet.remove.non-existent'), 1);
+ }
+
+ $dispatcher->dispatch('player.texture.updating', [$player, $texture]);
+
+ $field = "tid_$type";
+ $player->$field = $tid;
+ $player->save();
+
+ $dispatcher->dispatch('player.texture.updated', [$player, $texture]);
+ }
+ }
+
+ return json(trans('user.player.set.success', ['name' => $player->name]), 0, $player->toArray());
+ }
+
+ public function clearTexture(
+ Request $request,
+ Dispatcher $dispatcher,
+ Filter $filter,
+ Player $player
+ ) {
+ $types = $request->input('type', []);
+
+ foreach (['skin', 'cape'] as $type) {
+ $can = $filter->apply('can_clear_texture', true, [$player, $type]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ if ($request->has($type) || in_array($type, $types)) {
+ $dispatcher->dispatch('player.texture.resetting', [$player, $type]);
+
+ $field = "tid_$type";
+ $player->$field = 0;
+ $player->save();
+
+ $dispatcher->dispatch('player.texture.reset', [$player, $type]);
+ }
+ }
+
+ return json(trans('user.player.clear.success', ['name' => $player->name]), 0, $player->toArray());
+ }
+}
diff --git a/app/Http/Controllers/PlayersManagementController.php b/app/Http/Controllers/PlayersManagementController.php
new file mode 100755
index 0000000..aa4faf0
--- /dev/null
+++ b/app/Http/Controllers/PlayersManagementController.php
@@ -0,0 +1,136 @@
+middleware(function (Request $request, $next) {
+ /** @var Player */
+ $player = $request->route('player');
+ $owner = $player->user;
+
+ /** @var User */
+ $currentUser = $request->user();
+
+ if (
+ $owner->uid !== $currentUser->uid &&
+ $owner->permission >= $currentUser->permission
+ ) {
+ return json(trans('admin.players.no-permission'), 1)
+ ->setStatusCode(403);
+ }
+
+ return $next($request);
+ })->except(['list']);
+ }
+
+ public function list(Request $request)
+ {
+ $query = $request->query('q');
+
+ return Player::usingSearchString($query)->paginate(10);
+ }
+
+ public function name(
+ Player $player,
+ Request $request,
+ Dispatcher $dispatcher
+ ) {
+ $name = $request->validate([
+ 'player_name' => [
+ 'required',
+ new Rules\PlayerName(),
+ 'min:'.option('player_name_length_min'),
+ 'max:'.option('player_name_length_max'),
+ 'unique:players,name',
+ ],
+ ])['player_name'];
+
+ $dispatcher->dispatch('player.renaming', [$player, $name]);
+
+ $oldName = $player->name;
+ $player->name = $name;
+ $player->save();
+
+ $dispatcher->dispatch('player.renamed', [$player, $oldName]);
+
+ return json(trans('admin.players.name.success', ['player' => $player->name]), 0);
+ }
+
+ public function owner(
+ Player $player,
+ Request $request,
+ Dispatcher $dispatcher
+ ) {
+ $uid = $request->validate(['uid' => 'required|integer'])['uid'];
+
+ $dispatcher->dispatch('player.owner.updating', [$player, $uid]);
+
+ /** @var User */
+ $user = User::find($request->uid);
+ if (empty($user)) {
+ return json(trans('admin.users.operations.non-existent'), 1);
+ }
+
+ $player->uid = $uid;
+ $player->save();
+
+ $dispatcher->dispatch('player.owner.updated', [$player, $user]);
+
+ return json(trans('admin.players.owner.success', [
+ 'player' => $player->name,
+ 'user' => $user->nickname,
+ ]), 0);
+ }
+
+ public function texture(
+ Player $player,
+ Request $request,
+ Dispatcher $dispatcher
+ ) {
+ $data = $request->validate([
+ 'tid' => 'required|integer',
+ 'type' => ['required', Rule::in(['skin', 'cape'])],
+ ]);
+ $tid = (int) $data['tid'];
+ $type = $data['type'];
+
+ $dispatcher->dispatch('player.texture.updating', [$player, $type, $tid]);
+
+ if (Texture::where('tid', $tid)->doesntExist() && $tid !== 0) {
+ return json(trans('admin.players.textures.non-existent', ['tid' => $tid]), 1);
+ }
+
+ $field = 'tid_'.$type;
+ $previousTid = $player->$field;
+ $player->$field = $tid;
+ $player->save();
+
+ $dispatcher->dispatch('player.texture.updated', [$player, $type, $previousTid]);
+
+ return json(trans('admin.players.textures.success', ['player' => $player->name]), 0);
+ }
+
+ public function delete(
+ Player $player,
+ Dispatcher $dispatcher
+ ) {
+ $dispatcher->dispatch('player.deleting', [$player]);
+
+ $player->delete();
+
+ $dispatcher->dispatch('player.deleted', [$player]);
+
+ return json(trans('admin.players.delete.success'), 0);
+ }
+}
diff --git a/app/Http/Controllers/PluginController.php b/app/Http/Controllers/PluginController.php
new file mode 100755
index 0000000..d219e58
--- /dev/null
+++ b/app/Http/Controllers/PluginController.php
@@ -0,0 +1,140 @@
+get($name);
+ if ($plugin && $plugin->isEnabled()) {
+ if ($plugin->hasConfigClass()) {
+ return app()->call($plugin->getConfigClass().'@render');
+ } elseif ($plugin->hasConfigView()) {
+ return $plugin->getConfigView();
+ } else {
+ return abort(404, trans('admin.plugins.operations.no-config-notice'));
+ }
+ } else {
+ return abort(404, trans('admin.plugins.operations.no-config-notice'));
+ }
+ }
+
+ public function readme(PluginManager $plugins, $name)
+ {
+ $plugin = $plugins->get($name);
+ if (empty($plugin)) {
+ return abort(404, trans('admin.plugins.operations.no-readme-notice'));
+ }
+
+ $readmePath = $plugin->getReadme();
+ if (empty($readmePath)) {
+ return abort(404, trans('admin.plugins.operations.no-readme-notice'));
+ }
+
+ $title = trans($plugin->title);
+ $path = $plugin->getPath().'/'.$readmePath;
+ $converter = new GithubFlavoredMarkdownConverter();
+ $content = $converter->convertToHtml(file_get_contents($path));
+
+ return view('admin.plugin.readme', compact('content', 'title'));
+ }
+
+ public function manage(Request $request, PluginManager $plugins)
+ {
+ $name = $request->input('name');
+ $plugin = $plugins->get($name);
+
+ if ($plugin) {
+ // Pass the plugin title through the translator.
+ $plugin->title = trans($plugin->title);
+
+ switch ($request->get('action')) {
+ case 'enable':
+ $result = $plugins->enable($name);
+
+ if ($result === true) {
+ return json(trans('admin.plugins.operations.enabled', ['plugin' => $plugin->title]), 0);
+ } else {
+ $reason = $plugins->formatUnresolved($result['unsatisfied'], $result['conflicts']);
+
+ return json(trans('admin.plugins.operations.unsatisfied.notice'), 1, compact('reason'));
+ }
+
+ // no break
+ case 'disable':
+ $plugins->disable($name);
+
+ return json(trans('admin.plugins.operations.disabled', ['plugin' => $plugin->title]), 0);
+
+ case 'delete':
+ $plugins->delete($name);
+
+ return json(trans('admin.plugins.operations.deleted'), 0);
+
+ default:
+ return json(trans('admin.invalid-action'), 1);
+ }
+ } else {
+ return json(trans('admin.plugins.operations.not-found'), 1);
+ }
+ }
+
+ public function getPluginData(PluginManager $plugins)
+ {
+ return $plugins->all()
+ ->map(function (Plugin $plugin) {
+ return [
+ 'name' => $plugin->name,
+ 'title' => trans($plugin->title),
+ 'description' => trans($plugin->description ?? ''),
+ 'version' => $plugin->version,
+ 'enabled' => $plugin->isEnabled(),
+ 'readme' => (bool) $plugin->getReadme(),
+ 'config' => $plugin->hasConfig(),
+ 'icon' => array_merge(
+ ['fa' => 'plug', 'faType' => 'fas', 'bg' => 'navy'],
+ $plugin->getManifestAttr('enchants.icon', [])
+ ),
+ ];
+ })
+ ->values();
+ }
+
+ public function upload(Request $request, PluginManager $manager, Unzip $unzip)
+ {
+ $request->validate(['file' => 'required|file|mimetypes:application/zip']);
+
+ $path = $request->file('file')->getPathname();
+ $unzip->extract($path, $manager->getPluginsDirs()->first());
+
+ return json(trans('admin.plugins.market.install-success'), 0);
+ }
+
+ public function wget(Request $request, PluginManager $manager, Unzip $unzip)
+ {
+ $data = $request->validate(['url' => 'required|url']);
+
+ $path = tempnam(sys_get_temp_dir(), 'wget-plugin');
+ $response = Http::withOptions([
+ 'sink' => $path,
+ 'verify' => CaBundle::getSystemCaRootBundlePath(),
+ ])->get($data['url']);
+
+ if ($response->ok()) {
+ $unzip->extract($path, $manager->getPluginsDirs()->first());
+
+ return json(trans('admin.plugins.market.install-success'), 0);
+ } else {
+ return json(trans('admin.download.errors.download', ['error' => $response->status()]), 1);
+ }
+ }
+}
diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php
new file mode 100755
index 0000000..6cb1327
--- /dev/null
+++ b/app/Http/Controllers/ReportController.php
@@ -0,0 +1,170 @@
+validate([
+ 'tid' => 'required|exists:textures',
+ 'reason' => 'required',
+ ]);
+ /** @var User */
+ $reporter = auth()->user();
+ $tid = $data['tid'];
+ $reason = $data['reason'];
+
+ $can = $filter->apply('user_can_report', true, [$tid, $reason, $reporter]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ $dispatcher->dispatch('report.submitting', [$tid, $reason, $reporter]);
+
+ if (Report::where('reporter', $reporter->uid)->where('tid', $tid)->count() > 0) {
+ return json(trans('skinlib.report.duplicate'), 1);
+ }
+
+ $score = option('reporter_score_modification', 0);
+ if ($score < 0 && $reporter->score < -$score) {
+ return json(trans('skinlib.upload.lack-score'), 1);
+ }
+ $reporter->score += $score;
+ $reporter->save();
+
+ $report = new Report();
+ $report->tid = $tid;
+ $report->uploader = Texture::find($tid)->uploader;
+ $report->reporter = $reporter->uid;
+ $report->reason = $reason;
+ $report->status = Report::PENDING;
+ $report->save();
+
+ $dispatcher->dispatch('report.submitted', [$report]);
+
+ return json(trans('skinlib.report.success'), 0);
+ }
+
+ public function track()
+ {
+ $reports = Report::where('reporter', auth()->id())
+ ->orderBy('report_at', 'desc')
+ ->paginate(10);
+
+ return view('user.report', ['reports' => $reports]);
+ }
+
+ public function manage(Request $request)
+ {
+ $q = $request->input('q');
+
+ return Report::usingSearchString($q)
+ ->with(['texture', 'textureUploader', 'informer'])
+ ->paginate(9);
+ }
+
+ public function review(
+ Report $report,
+ Request $request,
+ Dispatcher $dispatcher
+ ) {
+ $data = $request->validate([
+ 'action' => ['required', Rule::in(['delete', 'ban', 'reject'])],
+ ]);
+ $action = $data['action'];
+
+ $dispatcher->dispatch('report.reviewing', [$report, $action]);
+
+ if ($action == 'reject') {
+ if (
+ $report->informer &&
+ ($score = option('reporter_score_modification', 0)) > 0 &&
+ $report->status == Report::PENDING
+ ) {
+ $report->informer->score -= $score;
+ $report->informer->save();
+ }
+ $report->status = Report::REJECTED;
+ $report->save();
+
+ $dispatcher->dispatch('report.rejected', [$report]);
+
+ return json(trans('general.op-success'), 0, ['status' => Report::REJECTED]);
+ }
+
+ switch ($action) {
+ case 'delete':
+ /** @var Texture */
+ $texture = $report->texture;
+ if ($texture) {
+ $dispatcher->dispatch('texture.deleting', [$texture]);
+ Storage::disk('textures')->delete($texture->hash);
+ $texture->delete();
+ $dispatcher->dispatch('texture.deleted', [$texture]);
+ } else {
+ // The texture has been deleted by its uploader
+ // We will return the score, but will not give the informer any reward
+ self::returnScore($report);
+ $report->status = Report::RESOLVED;
+ $report->save();
+
+ $dispatcher->dispatch('report.resolved', [$report, $action]);
+
+ return json(trans('general.texture-deleted'), 0, ['status' => Report::RESOLVED]);
+ }
+ break;
+ case 'ban':
+ $uploader = User::find($report->uploader);
+ if (!$uploader) {
+ return json(trans('admin.users.operations.non-existent'), 1);
+ }
+ if (auth()->user()->permission <= $uploader->permission) {
+ return json(trans('admin.users.operations.no-permission'), 1);
+ }
+ $uploader->permission = User::BANNED;
+ $uploader->save();
+ $dispatcher->dispatch('user.banned', [$uploader]);
+ break;
+ }
+
+ self::returnScore($report);
+ self::giveAward($report);
+ $report->status = Report::RESOLVED;
+ $report->save();
+
+ $dispatcher->dispatch('report.resolved', [$report, $action]);
+
+ return json(trans('general.op-success'), 0, ['status' => Report::RESOLVED]);
+ }
+
+ public static function returnScore($report)
+ {
+ if (
+ $report->status == Report::PENDING &&
+ ($score = option('reporter_score_modification', 0)) < 0 &&
+ $report->informer
+ ) {
+ $report->informer->score -= $score;
+ $report->informer->save();
+ }
+ }
+
+ public static function giveAward($report)
+ {
+ if ($report->status == Report::PENDING && $report->informer) {
+ $report->informer->score += option('reporter_reward_score', 0);
+ $report->informer->save();
+ }
+ }
+}
diff --git a/app/Http/Controllers/SetupController.php b/app/Http/Controllers/SetupController.php
new file mode 100755
index 0000000..6092fc6
--- /dev/null
+++ b/app/Http/Controllers/SetupController.php
@@ -0,0 +1,157 @@
+isMethod('get')) {
+ try {
+ $connection->getPdo();
+
+ return redirect('setup/info');
+ } catch (\Exception $e) {
+ return view('setup.wizard.database', [
+ 'host' => env('DB_HOST'),
+ 'port' => env('DB_PORT'),
+ 'username' => env('DB_USERNAME'),
+ 'password' => env('DB_PASSWORD'),
+ 'database' => env('DB_DATABASE'),
+ 'prefix' => env('DB_PREFIX'),
+ ]);
+ }
+ }
+
+ config([
+ 'database.connections.temp.driver' => $request->input('type'),
+ 'database.connections.temp.host' => $request->input('host'),
+ 'database.connections.temp.port' => $request->input('port'),
+ 'database.connections.temp.username' => $request->input('username'),
+ 'database.connections.temp.password' => $request->input('password'),
+ 'database.connections.temp.database' => $request->input('db'),
+ 'database.connections.temp.prefix' => $request->input('prefix'),
+ ]);
+
+ try {
+ $manager->connection('temp')->getPdo();
+ } catch (\Exception $e) {
+ $msg = $e->getMessage();
+ $type = Arr::get([
+ 'mysql' => 'MySQL/MariaDB',
+ 'sqlite' => 'SQLite',
+ 'pgsql' => 'PostgreSQL',
+ ], $request->input('type'), '');
+
+ throw new PrettyPageException(trans('setup.database.connection-error', compact('msg', 'type')), $e->getCode());
+ }
+
+ $content = $filesystem->get(base_path('.env'));
+ $content = preg_replace(
+ '/DB_CONNECTION.+/',
+ 'DB_CONNECTION='.$request->input('type', ''),
+ $content
+ );
+ $content = preg_replace(
+ '/DB_HOST.+/',
+ 'DB_HOST='.$request->input('host', ''),
+ $content
+ );
+ $content = preg_replace(
+ '/DB_PORT.+/',
+ 'DB_PORT='.$request->input('port', ''),
+ $content
+ );
+ $content = preg_replace(
+ '/DB_DATABASE.+/',
+ 'DB_DATABASE='.$request->input('db', ''),
+ $content
+ );
+ $content = preg_replace(
+ '/DB_USERNAME.+/',
+ 'DB_USERNAME='.$request->input('username', ''),
+ $content
+ );
+ $content = preg_replace(
+ '/DB_PASSWORD.+/',
+ 'DB_PASSWORD='.$request->input('password', ''),
+ $content
+ );
+ $content = preg_replace(
+ '/DB_PREFIX.+/',
+ 'DB_PREFIX='.$request->input('prefix', ''),
+ $content
+ );
+ $filesystem->put(base_path('.env'), $content);
+
+ return redirect('setup/info');
+ }
+
+ public function finish(Request $request, Filesystem $filesystem, Artisan $artisan)
+ {
+ $data = $request->validate([
+ 'email' => 'required|email',
+ 'nickname' => 'required',
+ 'password' => 'required|min:8|max:32|confirmed',
+ 'site_name' => 'required',
+ ]);
+
+ $artisan->call('passport:keys', ['--no-interaction' => true]);
+
+ // Create tables
+ $artisan->call('migrate', [
+ '--force' => true,
+ '--path' => [
+ 'database/migrations',
+ 'vendor/laravel/passport/database/migrations',
+ ],
+ ]);
+
+ $siteUrl = url('/');
+ if (Str::endsWith($siteUrl, '/index.php')) {
+ $siteUrl = substr($siteUrl, 0, -10); // @codeCoverageIgnore
+ }
+ option([
+ 'site_name' => $request->input('site_name'),
+ 'site_url' => $siteUrl,
+ ]);
+
+ $whip = new Whip();
+ $ip = $whip->getValidIpAddress();
+
+ // Register super admin
+ $user = new User();
+ $user->email = $data['email'];
+ $user->nickname = $data['nickname'];
+ $user->score = option('user_initial_score');
+ $user->avatar = 0;
+ $user->password = app('cipher')->hash($data['password'], config('secure.salt'));
+ $user->ip = $ip;
+ $user->permission = User::SUPER_ADMIN;
+ $user->register_at = Carbon::now();
+ $user->last_sign_at = Carbon::now()->subDay();
+ $user->verified = true;
+
+ $user->save();
+
+ $filesystem->put(storage_path('install.lock'), '');
+
+ return view('setup.wizard.finish');
+ }
+}
diff --git a/app/Http/Controllers/SkinlibController.php b/app/Http/Controllers/SkinlibController.php
new file mode 100755
index 0000000..454c428
--- /dev/null
+++ b/app/Http/Controllers/SkinlibController.php
@@ -0,0 +1,441 @@
+middleware(function (Request $request, $next) {
+ /** @var User */
+ $user = $request->user();
+ /** @var Texture */
+ $texture = $request->route('texture');
+
+ if ($texture->uploader != $user->uid && !$user->isAdmin()) {
+ return json(trans('skinlib.no-permission'), 1)
+ ->setStatusCode(403);
+ }
+
+ return $next($request);
+ })->only(['rename', 'privacy', 'type', 'delete']);
+
+ $this->middleware(function (Request $request, $next) {
+ /** @var User */
+ $user = $request->user();
+ /** @var Texture */
+ $texture = $request->route('texture');
+
+ if (!$texture->public) {
+ if (!Auth::check() || ($user->uid != $texture->uploader && !$user->isAdmin())) {
+ $statusCode = (int) option('status_code_for_private');
+ if ($statusCode === 404) {
+ abort($statusCode, trans('skinlib.show.deleted'));
+ } else {
+ abort(403, trans('skinlib.show.private'));
+ }
+ }
+ }
+
+ return $next($request);
+ })->only(['show', 'info']);
+ }
+
+ public function library(Request $request)
+ {
+ $user = Auth::user();
+
+ // Available filters: skin, steve, alex, cape
+ $type = $request->input('filter', 'skin');
+ $uploader = $request->input('uploader');
+ $keyword = $request->input('keyword');
+ $sort = $request->input('sort', 'time');
+ $sortBy = $sort == 'time' ? 'upload_at' : $sort;
+
+ return Texture::orderBy($sortBy, 'desc')
+ ->when(
+ $type === 'skin',
+ fn (Builder $query) => $query->whereIn('type', ['steve', 'alex']),
+ fn (Builder $query) => $query->where('type', $type),
+ )
+ ->when($keyword, fn (Builder $query, $keyword) => $query->like('name', $keyword))
+ ->when($uploader, fn (Builder $query, $uploader) => $query->where('uploader', $uploader))
+ ->when($user, function (Builder $query, User $user) {
+ if (!$user->isAdmin()) {
+ // use closure-style `where` clause to lift up SQL priority
+ return $query->where(function (Builder $query) use ($user) {
+ $query
+ ->where('public', true)
+ ->orWhere('uploader', $user->uid);
+ });
+ }
+ }, function (Builder $query) {
+ // show public textures only to anonymous visitors
+ return $query->where('public', true);
+ })
+ ->join('users', 'uid', 'uploader')
+ ->select(['tid', 'name', 'type', 'uploader', 'public', 'likes', 'nickname'])
+ ->paginate(20);
+ }
+
+ public function show(Filter $filter, Texture $texture)
+ {
+ /** @var User */
+ $user = Auth::user();
+ /** @var FilesystemAdapter */
+ $disk = Storage::disk('textures');
+
+ if ($disk->missing($texture->hash)) {
+ if (option('auto_del_invalid_texture')) {
+ $texture->delete();
+ }
+ abort(404, trans('skinlib.show.deleted'));
+ }
+
+ $badges = [];
+ $uploader = $texture->owner;
+ if ($uploader) {
+ if ($uploader->isAdmin()) {
+ $badges[] = ['text' => 'STAFF', 'color' => 'primary'];
+ }
+
+ $badges = $filter->apply('user_badges', $badges, [$uploader]);
+ }
+
+ $grid = [
+ 'layout' => [
+ ['md-8', 'md-4'],
+ ],
+ 'widgets' => [
+ [
+ ['shared.previewer'],
+ ['skinlib.widgets.show.side'],
+ ],
+ ],
+ ];
+ $grid = $filter->apply('grid:skinlib.show', $grid);
+
+ return view('skinlib.show')
+ ->with('texture', $texture)
+ ->with('grid', $grid)
+ ->with('extra', [
+ 'download' => option('allow_downloading_texture'),
+ 'currentUid' => $user ? $user->uid : 0,
+ 'admin' => $user && $user->isAdmin(),
+ 'inCloset' => $user && $user->closet()->where('tid', $texture->tid)->count() > 0,
+ 'uploaderExists' => (bool) $uploader,
+ 'nickname' => optional($uploader)->nickname ?? trans('general.unexistent-user'),
+ 'report' => intval(option('reporter_score_modification', 0)),
+ 'badges' => $badges,
+ ]);
+ }
+
+ public function info(Texture $texture)
+ {
+ return $texture;
+ }
+
+ public function upload(Filter $filter)
+ {
+ $grid = [
+ 'layout' => [
+ ['md-6', 'md-6'],
+ ],
+ 'widgets' => [
+ [
+ ['skinlib.widgets.upload.input'],
+ ['shared.previewer'],
+ ],
+ ],
+ ];
+ $grid = $filter->apply('grid:skinlib.upload', $grid);
+
+ $converter = new GithubFlavoredMarkdownConverter();
+
+ return view('skinlib.upload')
+ ->with('grid', $grid)
+ ->with('extra', [
+ 'rule' => ($regexp = option('texture_name_regexp'))
+ ? trans('skinlib.upload.name-rule-regexp', compact('regexp'))
+ : trans('skinlib.upload.name-rule'),
+ 'privacyNotice' => trans(
+ 'skinlib.upload.private-score-notice',
+ ['score' => option('private_score_per_storage')]
+ ),
+ 'score' => (int) auth()->user()->score,
+ 'scorePublic' => (int) option('score_per_storage'),
+ 'scorePrivate' => (int) option('private_score_per_storage'),
+ 'closetItemCost' => (int) option('score_per_closet_item'),
+ 'award' => (int) option('score_award_per_texture'),
+ 'contentPolicy' => $converter->convertToHtml(option_localized('content_policy'))->getContent(),
+ ]);
+ }
+
+ public function handleUpload(
+ Request $request,
+ Filter $filter,
+ Dispatcher $dispatcher
+ ) {
+ $file = $request->file('file');
+ if ($file && !$file->isValid()) {
+ Log::error($file->getErrorMessage());
+ }
+
+ $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',
+ ]);
+
+ /** @var UploadedFile */
+ $file = $filter->apply('uploaded_texture_file', $file);
+
+ $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);
+ }
+
+ $type = $data['type'];
+ $size = getimagesize($file);
+
+ if ($size[0] % 64 != 0 || $size[1] % 32 != 0) {
+ $message = trans('skinlib.upload.invalid-size', [
+ 'type' => $type === 'cape' ? trans('general.cape') : trans('general.skin'),
+ 'width' => $size[0],
+ 'height' => $size[1],
+ ]);
+
+ return json($message, 1);
+ }
+
+ $ratio = $size[0] / $size[1];
+ if ($type == 'steve' || $type == 'alex') {
+ if ($ratio != 2 && $ratio != 1 || $type === 'alex' && $ratio === 2) {
+ $message = trans('skinlib.upload.invalid-size', [
+ '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]);
+
+ /** @var User */
+ $user = Auth::user();
+
+ $duplicated = Texture::where('hash', $hash)
+ ->where(
+ fn (Builder $query) => $query->where('public', true)->orWhere('uploader', $user->uid)
+ )
+ ->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' => $duplicated->tid]);
+ }
+
+ $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);
+ }
+
+ $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)) {
+ $file->storePubliclyAs('', $hash, ['disk' => 'textures']);
+ }
+
+ $user->score -= $cost;
+ $user->closet()->attach($texture->tid, ['item_name' => $name]);
+ $user->save();
+
+ $dispatcher->dispatch('texture.uploaded', [$texture, $file]);
+
+ return json(trans('skinlib.upload.success', ['name' => $name]), 0, [
+ 'tid' => $texture->tid,
+ ]);
+ }
+
+ public function delete(Texture $texture, Dispatcher $dispatcher, Filter $filter)
+ {
+ $can = $filter->apply('can_delete_texture', true, [$texture]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ $dispatcher->dispatch('texture.deleting', [$texture]);
+
+ // check if file occupied
+ if (Texture::where('hash', $texture->hash)->count() === 1) {
+ Storage::disk('textures')->delete($texture->hash);
+ }
+
+ $texture->delete();
+
+ $dispatcher->dispatch('texture.deleted', [$texture]);
+
+ return json(trans('skinlib.delete.success'), 0);
+ }
+
+ public function privacy(Texture $texture, Dispatcher $dispatcher, Filter $filter)
+ {
+ $can = $filter->apply('can_update_texture_privacy', true, [$texture]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ $uploader = $texture->owner;
+ $score_diff = $texture->size
+ * (option('private_score_per_storage') - option('score_per_storage'))
+ * ($texture->public ? -1 : 1);
+ if ($texture->public && option('take_back_scores_after_deletion', true)) {
+ $score_diff -= option('score_award_per_texture', 0);
+ }
+ if ($uploader->score + $score_diff < 0) {
+ return json(trans('skinlib.upload.lack-score'), 1);
+ }
+
+ if (!$texture->public) {
+ $duplicated = Texture::where('hash', $texture->hash)
+ ->where('public', true)
+ ->first();
+ if ($duplicated) {
+ return json(trans('skinlib.upload.repeated'), 2, ['tid' => $duplicated->tid]);
+ }
+ }
+
+ $dispatcher->dispatch('texture.privacy.updating', [$texture]);
+
+ $uploader->score += $score_diff;
+ $uploader->save();
+
+ $texture->public = !$texture->public;
+ $texture->save();
+
+ $dispatcher->dispatch('texture.privacy.updated', [$texture]);
+
+ $message = trans('skinlib.privacy.success', [
+ 'privacy' => (
+ $texture->public
+ ? trans('general.public')
+ : trans('general.private')),
+ ]);
+
+ return json($message, 0);
+ }
+
+ public function rename(
+ Request $request,
+ Dispatcher $dispatcher,
+ Filter $filter,
+ Texture $texture
+ ) {
+ $data = $request->validate(['name' => [
+ 'required',
+ option('texture_name_regexp')
+ ? 'regex:'.option('texture_name_regexp')
+ : 'string',
+ ]]);
+ $name = $data['name'];
+
+ $can = $filter->apply('can_update_texture_name', true, [$texture, $name]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ $dispatcher->dispatch('texture.name.updating', [$texture, $name]);
+
+ $old = $texture->replicate();
+ $texture->name = $name;
+ $texture->save();
+
+ $dispatcher->dispatch('texture.name.updated', [$texture, $old]);
+
+ return json(trans('skinlib.rename.success', ['name' => $name]), 0);
+ }
+
+ public function type(
+ Request $request,
+ Dispatcher $dispatcher,
+ Filter $filter,
+ Texture $texture
+ ) {
+ $data = $request->validate([
+ 'type' => ['required', Rule::in(['steve', 'alex', 'cape'])],
+ ]);
+ $type = $data['type'];
+
+ $can = $filter->apply('can_update_texture_type', true, [$texture, $type]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ $dispatcher->dispatch('texture.type.updating', [$texture, $type]);
+
+ $old = $texture->replicate();
+ $texture->type = $type;
+ $texture->save();
+
+ $dispatcher->dispatch('texture.type.updated', [$texture, $old]);
+
+ return json(trans('skinlib.model.success', ['model' => $type]), 0);
+ }
+}
diff --git a/app/Http/Controllers/TextureController.php b/app/Http/Controllers/TextureController.php
new file mode 100755
index 0000000..c1fcec6
--- /dev/null
+++ b/app/Http/Controllers/TextureController.php
@@ -0,0 +1,177 @@
+middleware('cache.headers:public;max_age='.option('cache_expire_time'))
+ ->only(['json']);
+
+ $this->middleware('cache.headers:etag;public;max_age='.option('cache_expire_time'))
+ ->only([
+ 'preview',
+ 'raw',
+ 'texture',
+ 'avatarByPlayer',
+ 'avatarByUser',
+ 'avatarByTexture',
+ ]);
+ }
+
+ public function json($player)
+ {
+ $player = Player::where('name', $player)->firstOrFail();
+ $isBanned = $player->user->permission === User::BANNED;
+ abort_if($isBanned, 403, trans('general.player-banned'));
+
+ return response()->json($player)->setLastModified($player->last_modified);
+ }
+
+ public function previewByHash(Minecraft $minecraft, Request $request, $hash)
+ {
+ $texture = Texture::where('hash', $hash)->firstOrFail();
+
+ return $this->preview($minecraft, $request, $texture);
+ }
+
+ public function preview(Minecraft $minecraft, Request $request, Texture $texture)
+ {
+ $tid = $texture->tid;
+ $hash = $texture->hash;
+ $usePNG = $request->has('png') || !(imagetypes() & IMG_WEBP);
+ $format = $usePNG ? 'png' : 'webp';
+
+ $disk = Storage::disk('textures');
+ abort_if($disk->missing($hash), 404);
+
+ $height = (int) $request->query('height', 200);
+ $now = Carbon::now();
+ $response = Cache::remember(
+ 'preview-t'.$tid."-$format",
+ option('enable_preview_cache') ? $now->addYear() : $now->addMinute(),
+ function () use ($minecraft, $disk, $texture, $hash, $height, $usePNG) {
+ $file = $disk->get($hash);
+ if ($texture->type === 'cape') {
+ $image = $minecraft->renderCape($file, $height);
+ } else {
+ $image = $minecraft->renderSkin($file, 12, $texture->type === 'alex');
+ }
+
+ $lastModified = $disk->lastModified($hash);
+
+ return Image::make($image)
+ ->response($usePNG ? 'png' : 'webp', 100)
+ ->setLastModified(Carbon::createFromTimestamp($lastModified));
+ }
+ );
+
+ return $response;
+ }
+
+ public function raw($tid)
+ {
+ abort_unless(option('allow_downloading_texture'), 403);
+
+ $texture = Texture::findOrFail($tid);
+
+ return $this->texture($texture->hash);
+ }
+
+ public function texture(string $hash)
+ {
+ $disk = Storage::disk('textures');
+ abort_if($disk->missing($hash), 404);
+
+ $lastModified = Carbon::createFromTimestamp($disk->lastModified($hash));
+
+ return response($disk->get($hash))
+ ->withHeaders([
+ 'Content-Type' => 'image/png',
+ 'Content-Length' => $disk->size($hash),
+ ])
+ ->setLastModified($lastModified);
+ }
+
+ public function avatarByPlayer(Minecraft $minecraft, Request $request, $name)
+ {
+ $player = Player::where('name', $name)->firstOrFail();
+
+ return $this->avatar($minecraft, $request, $player->skin);
+ }
+
+ public function avatarByUser(Minecraft $minecraft, Request $request, $uid)
+ {
+ $texture = Texture::find(optional(User::find($uid))->avatar);
+
+ return $this->avatar($minecraft, $request, $texture);
+ }
+
+ public function avatarByHash(Minecraft $minecraft, Request $request, $hash)
+ {
+ $texture = Texture::where('hash', $hash)->first();
+
+ return $this->avatar($minecraft, $request, $texture);
+ }
+
+ public function avatarByTexture(Minecraft $minecraft, Request $request, $tid)
+ {
+ $texture = Texture::find($tid);
+
+ return $this->avatar($minecraft, $request, $texture);
+ }
+
+ protected function avatar(Minecraft $minecraft, Request $request, ?Texture $texture)
+ {
+ if (!empty($texture) && $texture->type !== 'steve' && $texture->type !== 'alex') {
+ return abort(422);
+ }
+
+ $size = (int) $request->query('size', 100);
+ $mode = $request->has('3d') ? '3d' : '2d';
+ $usePNG = $request->has('png') || !(imagetypes() & IMG_WEBP);
+ $format = $usePNG ? 'png' : 'webp';
+
+ $disk = Storage::disk('textures');
+ if (is_null($texture) || $disk->missing($texture->hash)) {
+ return Image::make(resource_path("misc/textures/avatar$mode.png"))
+ ->resize($size, $size)
+ ->response($usePNG ? 'png' : 'webp', 100);
+ }
+
+ $hash = $texture->hash;
+ $now = Carbon::now();
+ $response = Cache::remember(
+ 'avatar-'.$mode.'-t'.$texture->tid.'-s'.$size."-$format",
+ option('enable_avatar_cache') ? $now->addYear() : $now->addMinute(),
+ function () use ($minecraft, $disk, $hash, $size, $mode, $usePNG) {
+ $file = $disk->get($hash);
+ if ($mode === '3d') {
+ $image = $minecraft->render3dAvatar($file, 25);
+ } else {
+ $image = $minecraft->render2dAvatar($file, 25);
+ }
+
+ $lastModified = Carbon::createFromTimestamp($disk->lastModified($hash));
+
+ return Image::make($image)
+ ->resize($size, $size)
+ ->response($usePNG ? 'png' : 'webp', 100)
+ ->setLastModified($lastModified);
+ }
+ );
+
+ return $response;
+ }
+}
diff --git a/app/Http/Controllers/TranslationsController.php b/app/Http/Controllers/TranslationsController.php
new file mode 100755
index 0000000..5e011b0
--- /dev/null
+++ b/app/Http/Controllers/TranslationsController.php
@@ -0,0 +1,70 @@
+validate([
+ 'group' => 'required|string',
+ 'key' => 'required|string',
+ 'text' => 'required|string',
+ ]);
+
+ $line = new LanguageLine();
+ $line->group = $data['group'];
+ $line->key = $data['key'];
+ $line->setTranslation($app->getLocale(), $data['text']);
+ $line->save();
+
+ if ($data['group'] === 'front-end') {
+ $js->resetTime($app->getLocale());
+ }
+ $request->session()->put('success', true);
+
+ return redirect('/admin/i18n');
+ }
+
+ public function update(
+ Request $request,
+ Application $app,
+ JavaScript $js,
+ LanguageLine $line
+ ) {
+ $data = $request->validate(['text' => 'required|string']);
+
+ $line->setTranslation($app->getLocale(), $data['text']);
+ $line->save();
+
+ if ($line->group === 'front-end') {
+ $js->resetTime($app->getLocale());
+ }
+
+ return json(trans('admin.i18n.updated'), 0);
+ }
+
+ public function delete(
+ Application $app,
+ JavaScript $js,
+ LanguageLine $line
+ ) {
+ $line->delete();
+
+ if ($line->group === 'front-end') {
+ $js->resetTime($app->getLocale());
+ }
+
+ return json(trans('admin.i18n.deleted'), 0);
+ }
+}
diff --git a/app/Http/Controllers/UpdateController.php b/app/Http/Controllers/UpdateController.php
new file mode 100755
index 0000000..73eb5ef
--- /dev/null
+++ b/app/Http/Controllers/UpdateController.php
@@ -0,0 +1,93 @@
+getUpdateInfo();
+ $canUpdate = $this->canUpdate(Arr::get($info, 'info'));
+
+ return view('admin.update', [
+ 'info' => [
+ 'latest' => Arr::get($info, 'info.latest'),
+ 'current' => config('app.version'),
+ ],
+ 'error' => Arr::get($info, 'error', $canUpdate['reason']),
+ 'can_update' => $canUpdate['can'],
+ ]);
+ }
+
+ public function download(Unzip $unzip, Filesystem $filesystem)
+ {
+ $info = $this->getUpdateInfo();
+ if (!$info['ok'] || !$this->canUpdate($info['info'])['can']) {
+ return json(trans('admin.update.info.up-to-date'), 1);
+ }
+
+ $info = $info['info'];
+ $path = tempnam(sys_get_temp_dir(), 'bs');
+
+ $response = Http::withOptions([
+ 'sink' => $path,
+ 'verify' => CaBundle::getSystemCaRootBundlePath(),
+ ])->get($info['url']);
+
+ if ($response->ok()) {
+ $unzip->extract($path, base_path());
+
+ // Delete options cache. This allows us to update the version.
+ $filesystem->delete(storage_path('options.php'));
+
+ return json(trans('admin.update.complete'), 0);
+ } else {
+ return json(trans('admin.download.errors.download', ['error' => $response->status()]), 1);
+ }
+ }
+
+ protected function getUpdateInfo()
+ {
+ $response = Http::withOptions([
+ 'verify' => CaBundle::getSystemCaRootBundlePath(),
+ ])->get(config('app.update_source'));
+
+ if ($response->ok()) {
+ $info = $response->json();
+ if (Arr::get($info, 'spec') === self::SPEC) {
+ return ['ok' => true, 'info' => $info];
+ } else {
+ return ['ok' => false, 'error' => trans('admin.update.errors.spec')];
+ }
+ } else {
+ return ['ok' => false, 'error' => 'HTTP status code: '.$response->status()];
+ }
+ }
+
+ protected function canUpdate($info = [])
+ {
+ $php = Arr::get($info, 'php');
+ preg_match('/(\d+\.\d+\.\d+)/', PHP_VERSION, $matches);
+ $version = $matches[1];
+ if (Comparator::lessThan($version, $php)) {
+ return [
+ 'can' => false,
+ 'reason' => trans('admin.update.errors.php', ['version' => $php]),
+ ];
+ }
+
+ $can = Comparator::greaterThan(Arr::get($info, 'latest'), config('app.version'));
+
+ return ['can' => $can, 'reason' => ''];
+ }
+}
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
new file mode 100755
index 0000000..1e02b43
--- /dev/null
+++ b/app/Http/Controllers/UserController.php
@@ -0,0 +1,360 @@
+user();
+
+ return $user
+ ->makeHidden(['password', 'ip', 'remember_token', 'verification_token']);
+ }
+
+ public function index(Filter $filter)
+ {
+ $user = Auth::user();
+
+ [$min, $max] = explode(',', option('sign_score'));
+ $scoreIntro = trans('user.score-intro.introduction', [
+ 'initial_score' => option('user_initial_score'),
+ 'score-from' => $min,
+ 'score-to' => $max,
+ 'return-score' => option('return_score')
+ ? trans('user.score-intro.will-return-score')
+ : trans('user.score-intro.no-return-score'),
+ ]);
+
+ $grid = [
+ 'layout' => [
+ ['md-7', 'md-5'],
+ ],
+ 'widgets' => [
+ [
+ [
+ 'user.widgets.email-verification',
+ 'user.widgets.dashboard.usage',
+ ],
+ ['user.widgets.dashboard.announcement'],
+ ],
+ ],
+ ];
+ $grid = $filter->apply('grid:user.index', $grid);
+
+ $converter = new GithubFlavoredMarkdownConverter();
+
+ return view('user.index')->with([
+ 'score_intro' => $scoreIntro,
+ 'rates' => [
+ 'storage' => option('score_per_storage'),
+ 'player' => option('score_per_player'),
+ 'closet' => option('score_per_closet_item'),
+ ],
+ 'announcement' => $converter->convertToHtml(option_localized('announcement')),
+ 'grid' => $grid,
+ 'extra' => ['unverified' => option('require_verification') && !$user->verified],
+ ]);
+ }
+
+ public function scoreInfo()
+ {
+ /** @var User */
+ $user = Auth::user();
+
+ return response()->json([
+ 'user' => [
+ 'score' => $user->score,
+ 'lastSignAt' => $user->last_sign_at,
+ ],
+ 'rate' => [
+ 'storage' => (int) option('score_per_storage'),
+ 'players' => (int) option('score_per_player'),
+ ],
+ 'usage' => [
+ 'players' => $user->players()->count(),
+ 'storage' => (int) Texture::where('uploader', $user->uid)->sum('size'),
+ ],
+ 'signAfterZero' => (bool) option('sign_after_zero'),
+ 'signGapTime' => (int) option('sign_gap_time'),
+ ]);
+ }
+
+ public function sign(Dispatcher $dispatcher, Filter $filter)
+ {
+ /** @var User */
+ $user = Auth::user();
+
+ $can = $filter->apply('can_sign', true);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 2);
+ }
+
+ $lastSignTime = Carbon::parse($user->last_sign_at);
+ $remainingTime = option('sign_after_zero')
+ ? Carbon::now()->diffInSeconds(
+ $lastSignTime <= Carbon::today() ? $lastSignTime : Carbon::tomorrow(),
+ false
+ )
+ : Carbon::now()->diffInSeconds(
+ $lastSignTime->addHours((int) option('sign_gap_time')),
+ false
+ );
+
+ if ($remainingTime <= 0) {
+ [$min, $max] = explode(',', option('sign_score'));
+ $acquiredScore = rand((int) $min, (int) $max);
+ $acquiredScore = $filter->apply('sign_score', $acquiredScore);
+
+ $dispatcher->dispatch('user.sign.before', [$acquiredScore]);
+
+ $user->score += $acquiredScore;
+ $user->last_sign_at = Carbon::now();
+ $user->save();
+
+ $dispatcher->dispatch('user.sign.after', [$acquiredScore]);
+
+ return json(trans('user.sign-success', ['score' => $acquiredScore]), 0, [
+ 'score' => $user->score,
+ ]);
+ } else {
+ return json('', 1);
+ }
+ }
+
+ public function sendVerificationEmail()
+ {
+ if (!option('require_verification')) {
+ return json(trans('user.verification.disabled'), 1);
+ }
+
+ // Rate limit of 60s
+ $remain = 60 + session('last_mail_time', 0) - time();
+
+ if ($remain > 0) {
+ return json(trans('user.verification.frequent-mail'), 1);
+ }
+
+ $user = Auth::user();
+
+ if ($user->verified) {
+ return json(trans('user.verification.verified'), 1);
+ }
+
+ $url = URL::signedRoute('auth.verify', ['user' => $user], null, false);
+
+ try {
+ Mail::to($user->email)->send(new EmailVerification(url($url)));
+ } catch (\Exception $e) {
+ report($e);
+
+ return json(trans('user.verification.failed', ['msg' => $e->getMessage()]), 2);
+ }
+
+ Session::put('last_mail_time', time());
+
+ return json(trans('user.verification.success'), 0);
+ }
+
+ public function profile(Filter $filter)
+ {
+ $user = Auth::user();
+
+ $grid = [
+ 'layout' => [
+ ['md-6', 'md-6'],
+ ],
+ 'widgets' => [
+ [
+ [
+ 'user.widgets.profile.avatar',
+ 'user.widgets.profile.password',
+ ],
+ [
+ 'user.widgets.profile.nickname',
+ 'user.widgets.profile.email',
+ 'user.widgets.profile.delete-account',
+ ],
+ ],
+ ],
+ ];
+ $grid = $filter->apply('grid:user.profile', $grid);
+
+ return view('user.profile')
+ ->with('user', $user)
+ ->with('grid', $grid)
+ ->with('site_name', option_localized('site_name'));
+ }
+
+ public function handleProfile(Request $request, Filter $filter, Dispatcher $dispatcher)
+ {
+ $action = $request->input('action', '');
+ /** @var User */
+ $user = Auth::user();
+ $addition = $request->except('action');
+
+ $can = $filter->apply('user_can_edit_profile', true, [$action, $addition]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ $dispatcher->dispatch('user.profile.updating', [$user, $action, $addition]);
+
+ switch ($action) {
+ case 'nickname':
+ $request->validate(['new_nickname' => 'required']);
+
+ $nickname = $request->input('new_nickname');
+ $user->nickname = $nickname;
+ $user->save();
+
+ $dispatcher->dispatch('user.profile.updated', [$user, $action, $addition]);
+ event(new UserProfileUpdated($action, $user));
+
+ return json(trans('user.profile.nickname.success', ['nickname' => $nickname]), 0);
+
+ case 'password':
+ $request->validate([
+ 'current_password' => 'required|min:6|max:32',
+ 'new_password' => 'required|min:8|max:32',
+ ]);
+
+ if (!$user->verifyPassword($request->input('current_password'))) {
+ return json(trans('user.profile.password.wrong-password'), 1);
+ }
+
+ $user->changePassword($request->input('new_password'));
+ $dispatcher->dispatch('user.profile.updated', [$user, $action, $addition]);
+ event(new UserProfileUpdated($action, $user));
+
+ Auth::logout();
+
+ return json(trans('user.profile.password.success'), 0);
+
+ case 'email':
+ $data = $request->validate([
+ 'email' => 'required|email',
+ 'password' => 'required|min:6|max:32',
+ ]);
+
+ if (User::where('email', $data['email'])->count() > 0) {
+ return json(trans('user.profile.email.existed'), 1);
+ }
+
+ if (!$user->verifyPassword($data['password'])) {
+ return json(trans('user.profile.email.wrong-password'), 1);
+ }
+
+ $user->email = $data['email'];
+ $user->verified = false;
+ $user->save();
+
+ $dispatcher->dispatch('user.profile.updated', [$user, $action, $addition]);
+ event(new UserProfileUpdated($action, $user));
+
+ Auth::logout();
+
+ return json(trans('user.profile.email.success'), 0);
+
+ case 'delete':
+ $request->validate([
+ 'password' => 'required|min:6|max:32',
+ ]);
+
+ if ($user->isAdmin()) {
+ return json(trans('user.profile.delete.admin'), 1);
+ }
+
+ if (!$user->verifyPassword($request->input('password'))) {
+ return json(trans('user.profile.delete.wrong-password'), 1);
+ }
+
+ Auth::logout();
+
+ $dispatcher->dispatch('user.deleting', [$user]);
+
+ $user->delete();
+ $dispatcher->dispatch('user.deleted', [$user]);
+ session()->flush();
+
+ return json(trans('user.profile.delete.success'), 0);
+
+ default:
+ return json(trans('general.illegal-parameters'), 1);
+ }
+ }
+
+ public function setAvatar(Request $request, Filter $filter, Dispatcher $dispatcher)
+ {
+ $request->validate(['tid' => 'required|integer']);
+ $tid = $request->input('tid');
+ /** @var User */
+ $user = auth()->user();
+
+ $can = $filter->apply('user_can_update_avatar', true, [$user, $tid]);
+ if ($can instanceof Rejection) {
+ return json($can->getReason(), 1);
+ }
+
+ $dispatcher->dispatch('user.avatar.updating', [$user, $tid]);
+
+ if ($tid == 0) {
+ $user->avatar = 0;
+ $user->save();
+
+ $dispatcher->dispatch('user.avatar.updated', [$user, $tid]);
+
+ return json(trans('user.profile.avatar.success'), 0);
+ }
+
+ $texture = Texture::find($tid);
+ if ($texture) {
+ if ($texture->type == 'cape') {
+ return json(trans('user.profile.avatar.wrong-type'), 1);
+ }
+
+ if (
+ !$texture->public &&
+ $user->uid !== $texture->uploader &&
+ !$user->isAdmin()
+ ) {
+ return json(trans('skinlib.show.private'), 1);
+ }
+
+ $user->avatar = $tid;
+ $user->save();
+
+ $dispatcher->dispatch('user.avatar.updated', [$user, $tid]);
+
+ return json(trans('user.profile.avatar.success'), 0);
+ } else {
+ return json(trans('skinlib.non-existent'), 1);
+ }
+ }
+
+ public function toggleDarkMode()
+ {
+ /** @var User */
+ $user = auth()->user();
+ $user->is_dark_mode = !$user->is_dark_mode;
+ $user->save();
+
+ return response()->noContent();
+ }
+}
diff --git a/app/Http/Controllers/UsersManagementController.php b/app/Http/Controllers/UsersManagementController.php
new file mode 100755
index 0000000..92cbe5c
--- /dev/null
+++ b/app/Http/Controllers/UsersManagementController.php
@@ -0,0 +1,174 @@
+middleware(function (Request $request, $next) {
+ /** @var User */
+ $targetUser = $request->route('user');
+ /** @var User */
+ $authUser = $request->user();
+
+ if (
+ $targetUser->isNot($authUser) &&
+ $targetUser->permission >= $authUser->permission
+ ) {
+ return json(trans('admin.users.operations.no-permission'), 1)
+ ->setStatusCode(403);
+ }
+
+ return $next($request);
+ })->except(['list']);
+ }
+
+ public function list(Request $request)
+ {
+ $q = $request->input('q');
+
+ return User::usingSearchString($q)->paginate(10);
+ }
+
+ public function email(User $user, Request $request, Dispatcher $dispatcher)
+ {
+ $data = $request->validate([
+ 'email' => [
+ 'required', 'email', Rule::unique('users')->ignore($user),
+ ],
+ ]);
+ $email = $data['email'];
+
+ $dispatcher->dispatch('user.email.updating', [$user, $email]);
+
+ $old = $user->replicate();
+ $user->email = $email;
+ $user->save();
+
+ $dispatcher->dispatch('user.email.updated', [$user, $old]);
+
+ return json(trans('admin.users.operations.email.success'), 0);
+ }
+
+ public function verification(User $user, Dispatcher $dispatcher)
+ {
+ $dispatcher->dispatch('user.verification.updating', [$user]);
+
+ $user->verified = !$user->verified;
+ $user->save();
+
+ $dispatcher->dispatch('user.verification.updated', [$user]);
+
+ return json(trans('admin.users.operations.verification.success'), 0);
+ }
+
+ public function nickname(User $user, Request $request, Dispatcher $dispatcher)
+ {
+ $data = $request->validate([
+ 'nickname' => 'required|string',
+ ]);
+ $nickname = $data['nickname'];
+
+ $dispatcher->dispatch('user.nickname.updating', [$user, $nickname]);
+
+ $old = $user->replicate();
+ $user->nickname = $nickname;
+ $user->save();
+
+ $dispatcher->dispatch('user.nickname.updated', [$user, $old]);
+
+ return json(trans('admin.users.operations.nickname.success', [
+ 'new' => $request->input('nickname'),
+ ]), 0);
+ }
+
+ public function password(User $user, Request $request, Dispatcher $dispatcher)
+ {
+ $data = $request->validate([
+ 'password' => 'required|string|min:8|max:16',
+ ]);
+ $password = $data['password'];
+
+ $dispatcher->dispatch('user.password.updating', [$user, $password]);
+
+ $user->changePassword($password);
+ $user->save();
+
+ $dispatcher->dispatch('user.password.updated', [$user]);
+
+ return json(trans('admin.users.operations.password.success'), 0);
+ }
+
+ public function score(User $user, Request $request, Dispatcher $dispatcher)
+ {
+ $data = $request->validate([
+ 'score' => 'required|integer',
+ ]);
+ $score = (int) $data['score'];
+
+ $dispatcher->dispatch('user.score.updating', [$user, $score]);
+
+ $old = $user->replicate();
+ $user->score = $score;
+ $user->save();
+
+ $dispatcher->dispatch('user.score.updated', [$user, $old]);
+
+ return json(trans('admin.users.operations.score.success'), 0);
+ }
+
+ public function permission(User $user, Request $request, Dispatcher $dispatcher)
+ {
+ $data = $request->validate([
+ 'permission' => [
+ 'required',
+ Rule::in([User::BANNED, User::NORMAL, User::ADMIN]),
+ ],
+ ]);
+ $permission = (int) $data['permission'];
+
+ if (
+ $permission === User::ADMIN &&
+ $request->user()->permission < User::SUPER_ADMIN
+ ) {
+ return json(trans('admin.users.operations.no-permission'), 1)
+ ->setStatusCode(403);
+ }
+
+ if ($user->is($request->user())) {
+ return json(trans('admin.users.operations.no-permission'), 1)
+ ->setStatusCode(403);
+ }
+
+ $dispatcher->dispatch('user.permission.updating', [$user, $permission]);
+
+ $old = $user->replicate();
+ $user->permission = $permission;
+ $user->save();
+
+ if ($permission === User::BANNED) {
+ $dispatcher->dispatch('user.banned', [$user]);
+ }
+
+ $dispatcher->dispatch('user.permission.updated', [$user, $old]);
+
+ return json(trans('admin.users.operations.permission'), 0);
+ }
+
+ public function delete(User $user, Dispatcher $dispatcher)
+ {
+ $dispatcher->dispatch('user.deleting', [$user]);
+
+ $user->delete();
+
+ $dispatcher->dispatch('user.deleted', [$user]);
+
+ return json(trans('admin.users.operations.delete.success'), 0);
+ }
+}
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
new file mode 100755
index 0000000..47de09a
--- /dev/null
+++ b/app/Http/Kernel.php
@@ -0,0 +1,71 @@
+ [
+ \Illuminate\Cookie\Middleware\EncryptCookies::class,
+ \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
+ \Illuminate\Session\Middleware\StartSession::class,
+ \Illuminate\View\Middleware\ShareErrorsFromSession::class,
+ \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class,
+ \App\Http\Middleware\EnforceEverGreen::class,
+ \App\Http\Middleware\RedirectToSetup::class,
+ 'bindings',
+ ],
+
+ 'api' => [
+ 'bindings',
+ ],
+
+ 'authorize' => [
+ 'auth:web',
+ \App\Http\Middleware\RejectBannedUser::class,
+ \App\Http\Middleware\EnsureEmailFilled::class,
+ \App\Http\Middleware\FireUserAuthenticated::class,
+ ],
+ ];
+
+ /**
+ * The application's route middleware.
+ *
+ * These middleware may be assigned to groups or used individually.
+ *
+ * @var array
+ */
+ protected $routeMiddleware = [
+ 'auth' => \App\Http\Middleware\Authenticate::class,
+ 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
+ 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
+ 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
+ 'role' => \App\Http\Middleware\CheckRole::class,
+ 'setup' => \App\Http\Middleware\CheckInstallation::class,
+ 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
+ 'verified' => \App\Http\Middleware\CheckUserVerified::class,
+ 'scope' => \Laravel\Passport\Http\Middleware\CheckForAnyScope::class,
+ 'scopes' => \Laravel\Passport\Http\Middleware\CheckScopes::class,
+ ];
+}
diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php
new file mode 100755
index 0000000..5e2aa13
--- /dev/null
+++ b/app/Http/Middleware/Authenticate.php
@@ -0,0 +1,20 @@
+expectsJson()) {
+ session([
+ 'last_requested_path' => $request->fullUrl(),
+ 'msg' => trans('auth.check.anonymous'),
+ ]);
+
+ return '/auth/login';
+ }
+ }
+}
diff --git a/app/Http/Middleware/CheckInstallation.php b/app/Http/Middleware/CheckInstallation.php
new file mode 100755
index 0000000..100f408
--- /dev/null
+++ b/app/Http/Middleware/CheckInstallation.php
@@ -0,0 +1,18 @@
+exists(storage_path('install.lock'));
+ if ($hasLock) {
+ return response()->view('setup.locked');
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/CheckRole.php b/app/Http/Middleware/CheckRole.php
new file mode 100755
index 0000000..88e15a6
--- /dev/null
+++ b/app/Http/Middleware/CheckRole.php
@@ -0,0 +1,24 @@
+ -1,
+ 'normal' => 0,
+ 'admin' => 1,
+ 'super-admin' => 2,
+ ];
+
+ public function handle(Request $request, Closure $next, $role)
+ {
+ $permission = $request->user()->permission;
+ abort_if($permission < $this->roles[$role], 403);
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/CheckUserVerified.php b/app/Http/Middleware/CheckUserVerified.php
new file mode 100755
index 0000000..f393c07
--- /dev/null
+++ b/app/Http/Middleware/CheckUserVerified.php
@@ -0,0 +1,13 @@
+user()->verified, 403, trans('auth.check.verified'));
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/ConvertEmptyStringsToNull.php b/app/Http/Middleware/ConvertEmptyStringsToNull.php
new file mode 100755
index 0000000..4c7403e
--- /dev/null
+++ b/app/Http/Middleware/ConvertEmptyStringsToNull.php
@@ -0,0 +1,25 @@
+path(), $this->excepts)) {
+ return $next($request);
+ }
+
+ return parent::handle($request, $next);
+ }
+}
diff --git a/app/Http/Middleware/DetectLanguagePrefer.php b/app/Http/Middleware/DetectLanguagePrefer.php
new file mode 100755
index 0000000..69eed5b
--- /dev/null
+++ b/app/Http/Middleware/DetectLanguagePrefer.php
@@ -0,0 +1,35 @@
+input('lang')
+ ?? $request->cookie('locale')
+ ?? $request->getPreferredLanguage();
+ if (
+ ($info = Arr::get(config('locales'), $locale)) &&
+ ($alias = Arr::get($info, 'alias'))
+ ) {
+ $locale = $alias;
+ }
+ $locale ?? app()->getLocale();
+ if (!Arr::has(config('locales'), $locale)) {
+ $locale = config('app.fallback_locale');
+ }
+
+ app()->setLocale($locale);
+
+ /** @var Response */
+ $response = $next($request);
+ $response->cookie('locale', $locale, 120);
+
+ return $response;
+ }
+}
diff --git a/app/Http/Middleware/EnforceEverGreen.php b/app/Http/Middleware/EnforceEverGreen.php
new file mode 100755
index 0000000..1c01ece
--- /dev/null
+++ b/app/Http/Middleware/EnforceEverGreen.php
@@ -0,0 +1,25 @@
+userAgent();
+
+ preg_match('/Chrome\/(\d+)/', $userAgent, $matches);
+ $isOldChrome = Arr::has($matches, 1) && $matches[1] < 55;
+
+ if ($isOldChrome || Str::contains($userAgent, ['Trident', 'MSIE'])) {
+ throw new PrettyPageException(trans('errors.http.ie'));
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/EnsureEmailFilled.php b/app/Http/Middleware/EnsureEmailFilled.php
new file mode 100755
index 0000000..50e4904
--- /dev/null
+++ b/app/Http/Middleware/EnsureEmailFilled.php
@@ -0,0 +1,19 @@
+user()->email != '' && $request->is('auth/bind')) {
+ return redirect('/user');
+ } elseif ($request->user()->email == '' && !$request->is('auth/bind')) {
+ return redirect('/auth/bind');
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/FireUserAuthenticated.php b/app/Http/Middleware/FireUserAuthenticated.php
new file mode 100755
index 0000000..d590e2d
--- /dev/null
+++ b/app/Http/Middleware/FireUserAuthenticated.php
@@ -0,0 +1,17 @@
+check()) {
+ event(new \App\Events\UserAuthenticated($request->user()));
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php
new file mode 100755
index 0000000..3e6bb7f
--- /dev/null
+++ b/app/Http/Middleware/RedirectIfAuthenticated.php
@@ -0,0 +1,13 @@
+exists(storage_path('install.lock'));
+
+ // If lock isn't existed, it means that BS isn't installed.
+ // Database is unavailable at this time, so we should disable the loader.
+ if (!$hasLock) {
+ config(['translation-loader.translation_loaders' => []]);
+ }
+
+ if ($hasLock && !$request->is('setup*') && Comparator::greaterThan($version, option('version', $version))) {
+ Artisan::call('update');
+ }
+
+ if ($hasLock || $request->is('setup*')) {
+ return $next($request);
+ }
+
+ return redirect('/setup');
+ }
+}
diff --git a/app/Http/Middleware/RejectBannedUser.php b/app/Http/Middleware/RejectBannedUser.php
new file mode 100755
index 0000000..41df44c
--- /dev/null
+++ b/app/Http/Middleware/RejectBannedUser.php
@@ -0,0 +1,25 @@
+user()->permission == User::BANNED) {
+ if ($request->expectsJson()) {
+ $response = json(trans('auth.check.banned'), -1);
+ $response->setStatusCode(403);
+
+ return $response;
+ } else {
+ abort(403, trans('auth.check.banned'));
+ }
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/View/Composers/FootComposer.php b/app/Http/View/Composers/FootComposer.php
new file mode 100755
index 0000000..3db0e81
--- /dev/null
+++ b/app/Http/View/Composers/FootComposer.php
@@ -0,0 +1,57 @@
+request = $request;
+ $this->javascript = $javascript;
+ $this->dispatcher = $dispatcher;
+ $this->filter = $filter;
+ }
+
+ public function compose(View $view)
+ {
+ $this->injectJavaScript($view);
+ $this->addExtra($view);
+ }
+
+ public function injectJavaScript(View $view)
+ {
+ $scripts = [];
+ $scripts = $this->filter->apply('scripts', $scripts);
+
+ $view->with([
+ 'i18n' => $this->javascript->generate(app()->getLocale()),
+ 'scripts' => $scripts,
+ 'inline_js' => option('custom_js'),
+ ]);
+ }
+
+ public function addExtra(View $view)
+ {
+ $content = [];
+ $this->dispatcher->dispatch(new \App\Events\RenderingFooter($content));
+ $view->with('extra_foot', $content);
+ }
+}
diff --git a/app/Http/View/Composers/HeadComposer.php b/app/Http/View/Composers/HeadComposer.php
new file mode 100755
index 0000000..85c4da3
--- /dev/null
+++ b/app/Http/View/Composers/HeadComposer.php
@@ -0,0 +1,107 @@
+dispatcher = $dispatcher;
+ $this->request = $request;
+ $this->filter = $filter;
+ }
+
+ public function compose(View $view)
+ {
+ $this->addFavicon($view);
+ $this->applyThemeColor($view);
+ $this->seo($view);
+ $this->injectStyles($view);
+ $this->addExtra($view);
+ $this->serializeGlobals($view);
+ }
+
+ public function addFavicon(View $view)
+ {
+ $url = option('favicon_url', config('options.favicon_url'));
+ $url = Str::startsWith($url, 'http') ? $url : url($url);
+ $view->with('favicon', $url);
+ }
+
+ public function applyThemeColor(View $view)
+ {
+ $colors = [
+ 'primary' => '#007bff',
+ 'secondary' => '#6c757d',
+ 'success' => '#28a745',
+ 'warning' => '#ffc107',
+ 'danger' => '#dc3545',
+ 'navy' => '#001f3f',
+ 'olive' => '#3d9970',
+ 'lime' => '#01ff70',
+ 'fuchsia' => '#f012be',
+ 'maroon' => '#d81b60',
+ 'indigo' => '#6610f2',
+ 'purple' => '#6f42c1',
+ 'pink' => '#e83e8c',
+ 'orange' => '#fd7e14',
+ 'teal' => '#20c997',
+ 'cyan' => '#17a2b8',
+ 'gray' => '#6c757d',
+ ];
+ $view->with('theme_color', Arr::get($colors, option('navbar_color')));
+ }
+
+ public function seo(View $view)
+ {
+ $view->with('seo', [
+ 'keywords' => option('meta_keywords'),
+ 'description' => option('meta_description'),
+ 'extra' => option('meta_extras'),
+ ]);
+ }
+
+ public function injectStyles(View $view)
+ {
+ $links = [];
+ $links = $this->filter->apply('head_links', $links);
+ $view->with('links', $links);
+ $view->with('inline_css', option('custom_css'));
+ $view->with('custom_cdn_host', option('cdn_address'));
+ }
+
+ public function addExtra(View $view)
+ {
+ $content = [];
+ $this->dispatcher->dispatch(new \App\Events\RenderingHeader($content));
+ $view->with('extra_head', $content);
+ }
+
+ public function serializeGlobals(View $view)
+ {
+ $blessing = [
+ 'version' => config('app.version'),
+ 'locale' => config('app.locale'),
+ 'base_url' => url('/'),
+ 'site_name' => option_localized('site_name'),
+ 'route' => request()->path(),
+ ];
+ $view->with('blessing', $blessing);
+ }
+}
diff --git a/app/Http/View/Composers/LanguagesMenuComposer.php b/app/Http/View/Composers/LanguagesMenuComposer.php
new file mode 100755
index 0000000..89b8049
--- /dev/null
+++ b/app/Http/View/Composers/LanguagesMenuComposer.php
@@ -0,0 +1,38 @@
+request = $request;
+ }
+
+ public function compose(View $view)
+ {
+ $query = $this->request->query();
+ $path = $this->request->path();
+
+ $langs = collect(config('locales'))
+ ->reject(fn ($locale) => Arr::has($locale, 'alias'))
+ ->map(function ($locale, $id) use ($query, $path) {
+ $query = array_merge($query, ['lang' => $id]);
+ $locale['url'] = url($path.'?'.http_build_query($query));
+
+ return $locale;
+ });
+
+ $current = 'locales.'.app()->getLocale();
+ $view->with([
+ 'current' => config($current.'.name'),
+ 'langs' => $langs,
+ ]);
+ }
+}
diff --git a/app/Http/View/Composers/SideMenuComposer.php b/app/Http/View/Composers/SideMenuComposer.php
new file mode 100755
index 0000000..deb8d53
--- /dev/null
+++ b/app/Http/View/Composers/SideMenuComposer.php
@@ -0,0 +1,105 @@
+request = $request;
+ $this->filter = $filter;
+ }
+
+ public function compose(View $view)
+ {
+ $type = $view->gatherData()['type'];
+
+ $menu = config('menu');
+ switch ($type) {
+ case 'user':
+ event(new Events\ConfigureUserMenu($menu));
+ break;
+ case 'explore':
+ event(new Events\ConfigureExploreMenu($menu));
+ break;
+ case 'admin':
+ event(new Events\ConfigureAdminMenu($menu));
+ $menu['admin'] = $this->collectPluginConfigs($menu['admin']);
+ break;
+ }
+
+ $menu = $menu[$type];
+ $menu = $this->filter->apply('side_menu', $menu, [$type]);
+
+ $view->with('items', array_map(fn ($item) => $this->transform($item), $menu));
+ }
+
+ public function transform(array $item): array
+ {
+ $isActive = $this->request->is(Arr::get($item, 'link'));
+ foreach (Arr::get($item, 'children', []) as $k => $v) {
+ if ($this->request->is(Arr::get($v, 'link'))) {
+ $isActive = true;
+ break;
+ }
+ }
+
+ $classes = [];
+ if ($isActive) {
+ $item['active'] = true;
+ $classes[] = 'active menu-open';
+ }
+
+ if (Arr::has($item, 'children')) {
+ $item['children'] = array_map(
+ fn ($item) => $this->transform($item),
+ $item['children'],
+ );
+ }
+
+ $item['classes'] = $classes;
+
+ return $item;
+ }
+
+ public function collectPluginConfigs(array &$menu)
+ {
+ $menu = array_map(function ($item) {
+ if (Arr::get($item, 'id') === 'plugin-configs') {
+ $pluginConfigs = resolve(PluginManager::class)
+ ->getEnabledPlugins()
+ ->filter(fn (Plugin $plugin) => $plugin->hasConfig())
+ ->map(function ($plugin) {
+ return [
+ 'title' => trans($plugin->title),
+ 'link' => 'admin/plugins/config/'.$plugin->name,
+ 'icon' => 'fa-circle',
+ ];
+ });
+
+ // Don't display this menu item when no plugin config is available
+ if ($pluginConfigs->isNotEmpty()) {
+ array_push($item['children'], ...$pluginConfigs->values()->all());
+
+ return $item;
+ }
+ } else {
+ return $item;
+ }
+ }, $menu);
+
+ return array_filter($menu); // Remove empty items
+ }
+}
diff --git a/app/Http/View/Composers/UserMenuComposer.php b/app/Http/View/Composers/UserMenuComposer.php
new file mode 100755
index 0000000..27fd199
--- /dev/null
+++ b/app/Http/View/Composers/UserMenuComposer.php
@@ -0,0 +1,41 @@
+request = $request;
+ $this->filter = $filter;
+ }
+
+ public function compose(View $view)
+ {
+ $user = auth()->user();
+ $avatarUrl = route('avatar.texture', ['tid' => $user->avatar, 'size' => 36], false);
+ $avatar = $this->filter->apply('user_avatar', $avatarUrl, [$user]);
+ $avatarPNG = route(
+ 'avatar.texture',
+ ['tid' => $user->avatar, 'size' => 36, 'png' => true],
+ false
+ );
+ $avatarPNG = $this->filter->apply('user_avatar', $avatarPNG, [$user]);
+ $cli = $this->request->is('admin', 'admin/*');
+
+ $view->with([
+ 'user' => $user,
+ 'avatar' => $avatar,
+ 'avatar_png' => $avatarPNG,
+ 'cli' => $cli,
+ ]);
+ }
+}
diff --git a/app/Http/View/Composers/UserPanelComposer.php b/app/Http/View/Composers/UserPanelComposer.php
new file mode 100755
index 0000000..2ec2c56
--- /dev/null
+++ b/app/Http/View/Composers/UserPanelComposer.php
@@ -0,0 +1,49 @@
+dispatcher = $dispatcher;
+ $this->filter = $filter;
+ }
+
+ public function compose(View $view)
+ {
+ /** @var User */
+ $user = auth()->user();
+ $avatarUrl = route('avatar.texture', ['tid' => $user->avatar, 'size' => 45], false);
+ $avatar = $this->filter->apply('user_avatar', $avatarUrl, [$user]);
+ $avatarPNG = route(
+ 'avatar.texture',
+ ['tid' => $user->avatar, 'size' => 45, 'png' => true],
+ false
+ );
+ $avatarPNG = $this->filter->apply('user_avatar', $avatarPNG, [$user]);
+
+ $badges = [];
+ if ($user->isAdmin()) {
+ $badges[] = ['text' => 'STAFF', 'color' => 'primary'];
+ }
+ $this->dispatcher->dispatch(new \App\Events\RenderingBadges($badges));
+ $badges = $this->filter->apply('user_badges', $badges, [$user]);
+
+ $view->with([
+ 'user' => $user,
+ 'avatar' => $avatar,
+ 'avatar_png' => $avatarPNG,
+ 'badges' => $badges,
+ ]);
+ }
+}
diff --git a/app/Listeners/CleanUpCloset.php b/app/Listeners/CleanUpCloset.php
new file mode 100755
index 0000000..8edb9c4
--- /dev/null
+++ b/app/Listeners/CleanUpCloset.php
@@ -0,0 +1,34 @@
+exists && $texture->public) {
+ return;
+ }
+
+ $likers = $texture
+ ->likers()
+ ->where('user_uid', '!=', $texture->uploader)
+ ->get();
+ $likers->each(function (User $user) use ($texture) {
+ $user->closet()->detach($texture->tid);
+ if (option('return_score')) {
+ $user->score += (int) option('score_per_closet_item');
+ $user->save();
+ }
+ });
+
+ if ($texture->exists) {
+ $texture->decrement('likes', $likers->count());
+ }
+ }
+}
diff --git a/app/Listeners/CleanUpFrontEndLocaleFiles.php b/app/Listeners/CleanUpFrontEndLocaleFiles.php
new file mode 100755
index 0000000..a645b86
--- /dev/null
+++ b/app/Listeners/CleanUpFrontEndLocaleFiles.php
@@ -0,0 +1,26 @@
+filesystem = $filesystem;
+ }
+
+ public function handle()
+ {
+ $files = $this->filesystem->allFiles(public_path('lang'));
+ array_walk($files, function (SplFileInfo $file) {
+ if ($file->getExtension() === 'js') {
+ $this->filesystem->delete($file->getPathname());
+ }
+ });
+ }
+}
diff --git a/app/Listeners/CopyPluginAssets.php b/app/Listeners/CopyPluginAssets.php
new file mode 100755
index 0000000..fb4e9e6
--- /dev/null
+++ b/app/Listeners/CopyPluginAssets.php
@@ -0,0 +1,28 @@
+filesystem = $filesystem;
+ }
+
+ public function handle($event)
+ {
+ $plugin = $event instanceof Plugin ? $event : $event->plugin;
+ $dir = public_path('plugins/'.$plugin->name);
+ $this->filesystem->deleteDirectory($dir);
+
+ $this->filesystem->copyDirectory(
+ $plugin->getPath().DIRECTORY_SEPARATOR.'assets',
+ $dir.'/assets'
+ );
+ }
+}
diff --git a/app/Listeners/NotifyFailedPlugin.php b/app/Listeners/NotifyFailedPlugin.php
new file mode 100755
index 0000000..2e9f32c
--- /dev/null
+++ b/app/Listeners/NotifyFailedPlugin.php
@@ -0,0 +1,22 @@
+plugin;
+ Event::listen(\App\Events\RenderingFooter::class, function ($event) use ($plugin) {
+ /** @var User */
+ $user = auth()->user();
+ if ($user && $user->isAdmin()) {
+ $message = trans('errors.plugins.boot', ['plugin' => trans($plugin->title)]);
+ $event->addContent("");
+ }
+ });
+ }
+}
diff --git a/app/Listeners/ResetPlayerForRemovedClosetItem.php b/app/Listeners/ResetPlayerForRemovedClosetItem.php
new file mode 100755
index 0000000..785f595
--- /dev/null
+++ b/app/Listeners/ResetPlayerForRemovedClosetItem.php
@@ -0,0 +1,16 @@
+type === 'cape' ? 'tid_cape' : 'tid_skin';
+
+ $user->players()->where($type, $texture->tid)->update([$type => 0]);
+ }
+}
diff --git a/app/Listeners/ResetPlayers.php b/app/Listeners/ResetPlayers.php
new file mode 100755
index 0000000..74fd5aa
--- /dev/null
+++ b/app/Listeners/ResetPlayers.php
@@ -0,0 +1,28 @@
+exists && $texture->public) {
+ return;
+ }
+
+ $type = $texture->type == 'cape' ? 'tid_cape' : 'tid_skin';
+ $query = Player::where($type, $texture->tid);
+
+ // texture was switched from "private" to "public"
+ if ($texture->exists) {
+ $query = $query->where('uid', '<>', $texture->uploader);
+ }
+
+ $query->update([$type => 0]);
+ }
+}
diff --git a/app/Listeners/SendEmailVerification.php b/app/Listeners/SendEmailVerification.php
new file mode 100755
index 0000000..097fd16
--- /dev/null
+++ b/app/Listeners/SendEmailVerification.php
@@ -0,0 +1,24 @@
+ $user->uid], null, false);
+
+ try {
+ Mail::to($user->email)->send(new EmailVerification(url($url)));
+ } catch (\Exception $e) {
+ report($e);
+ }
+ }
+ }
+}
diff --git a/app/Listeners/SetAppLocale.php b/app/Listeners/SetAppLocale.php
new file mode 100755
index 0000000..9391390
--- /dev/null
+++ b/app/Listeners/SetAppLocale.php
@@ -0,0 +1,34 @@
+request = $request;
+ }
+
+ public function handle($event)
+ {
+ /** @var User */
+ $user = $event->user;
+
+ if ($this->request->has('lang')) {
+ $user->locale = $this->request->input('lang');
+ $user->save();
+
+ return;
+ }
+
+ $locale = $user->locale;
+ if ($locale) {
+ app()->setLocale($locale);
+ }
+ }
+}
diff --git a/app/Listeners/UpdateScoreForDeletedTexture.php b/app/Listeners/UpdateScoreForDeletedTexture.php
new file mode 100755
index 0000000..84cc23a
--- /dev/null
+++ b/app/Listeners/UpdateScoreForDeletedTexture.php
@@ -0,0 +1,28 @@
+owner;
+ if ($uploader) {
+ $ret = 0;
+ if (option('return_score')) {
+ $ret += $texture->size * (
+ $texture->public
+ ? (int) option('score_per_storage')
+ : (int) option('private_score_per_storage')
+ );
+ }
+
+ if ($texture->public && option('take_back_scores_after_deletion', true)) {
+ $ret -= (int) option('score_award_per_texture', 0);
+ }
+
+ $uploader->score += $ret;
+ $uploader->save();
+ }
+ }
+}
diff --git a/app/Mail/EmailVerification.php b/app/Mail/EmailVerification.php
new file mode 100755
index 0000000..43ec8cf
--- /dev/null
+++ b/app/Mail/EmailVerification.php
@@ -0,0 +1,29 @@
+url = $url;
+ }
+
+ public function build()
+ {
+ $site_name = option_localized('site_name');
+
+ return $this
+ ->subject(trans('user.verification.mail.title', ['sitename' => $site_name]))
+ ->view('mails.email-verification');
+ }
+}
diff --git a/app/Mail/ForgotPassword.php b/app/Mail/ForgotPassword.php
new file mode 100755
index 0000000..d9ff681
--- /dev/null
+++ b/app/Mail/ForgotPassword.php
@@ -0,0 +1,29 @@
+url = $url;
+ }
+
+ public function build()
+ {
+ $site_name = option_localized('site_name');
+
+ return $this
+ ->subject(trans('auth.forgot.mail.title', ['sitename' => $site_name]))
+ ->view('mails.password-reset');
+ }
+}
diff --git a/app/Models/Concerns/HasPassword.php b/app/Models/Concerns/HasPassword.php
new file mode 100755
index 0000000..316e907
--- /dev/null
+++ b/app/Models/Concerns/HasPassword.php
@@ -0,0 +1,33 @@
+password;
+ $user = $this;
+
+ $passed = $cipher->verify($raw, $password, config('secure.salt'));
+ $passed = $filter->apply('verify_password', $passed, [$raw, $user]);
+
+ return $passed;
+ }
+
+ public function changePassword(string $password): bool
+ {
+ $password = resolve('cipher')->hash($password, config('secure.salt'));
+ $password = resolve(Filter::class)->apply('user_password', $password);
+ $this->password = $password;
+
+ return $this->save();
+ }
+}
diff --git a/app/Models/Player.php b/app/Models/Player.php
new file mode 100755
index 0000000..f716017
--- /dev/null
+++ b/app/Models/Player.php
@@ -0,0 +1,99 @@
+ 'integer',
+ 'uid' => 'integer',
+ 'tid_skin' => 'integer',
+ 'tid_cape' => 'integer',
+ ];
+
+ protected $dispatchesEvents = [
+ 'retrieved' => \App\Events\PlayerRetrieved::class,
+ 'updated' => PlayerProfileUpdated::class,
+ ];
+
+ protected $searchStringColumns = [
+ 'pid', 'uid',
+ 'tid_skin' => '/^(?:tid_)?skin$/',
+ 'tid_cape' => '/^(?:tid_)?cape$/',
+ 'name' => ['searchable' => true],
+ 'last_modified' => ['date' => true],
+ ];
+
+ public function user()
+ {
+ return $this->belongsTo(Models\User::class, 'uid');
+ }
+
+ public function skin()
+ {
+ return $this->belongsTo(Models\Texture::class, 'tid_skin');
+ }
+
+ public function cape()
+ {
+ return $this->belongsTo(Models\Texture::class, 'tid_cape');
+ }
+
+ public function getModelAttribute()
+ {
+ return optional($this->skin)->model ?? 'default';
+ }
+
+ /**
+ * CustomSkinAPI R1.
+ */
+ public function toJson($options = 0)
+ {
+ $model = $this->model;
+ $profile = [
+ 'username' => $this->name,
+ 'skins' => [
+ $model => optional($this->skin)->hash,
+ ],
+ 'cape' => optional($this->cape)->hash,
+ ];
+
+ return json_encode($profile, $options | JSON_UNESCAPED_UNICODE);
+ }
+
+ protected function serializeDate(DateTimeInterface $date)
+ {
+ return $date->format('Y-m-d H:i:s');
+ }
+}
diff --git a/app/Models/Report.php b/app/Models/Report.php
new file mode 100755
index 0000000..5d28799
--- /dev/null
+++ b/app/Models/Report.php
@@ -0,0 +1,68 @@
+ 'integer',
+ 'uploader' => 'integer',
+ 'reporter' => 'integer',
+ 'status' => 'integer',
+ ];
+
+ protected $searchStringColumns = [
+ 'id', 'tid', 'uploader', 'reporter',
+ 'reason', 'status',
+ 'report_at' => ['date' => true],
+ ];
+
+ public function texture()
+ {
+ return $this->belongsTo(Texture::class, 'tid', 'tid');
+ }
+
+ public function textureUploader()
+ {
+ return $this->belongsTo(User::class, 'uploader', 'uid');
+ }
+
+ public function informer()
+ {
+ return $this->belongsTo(User::class, 'reporter', 'uid');
+ }
+
+ protected function serializeDate(DateTimeInterface $date)
+ {
+ return $date->format('Y-m-d H:i:s');
+ }
+}
diff --git a/app/Models/Scope.php b/app/Models/Scope.php
new file mode 100755
index 0000000..1f27892
--- /dev/null
+++ b/app/Models/Scope.php
@@ -0,0 +1,19 @@
+ 'integer',
+ 'size' => 'integer',
+ 'uploader' => 'integer',
+ 'public' => 'boolean',
+ 'likes' => 'integer',
+ ];
+
+ protected $dispatchesEvents = [
+ 'deleting' => \App\Events\TextureDeleting::class,
+ ];
+
+ public function getModelAttribute()
+ {
+ // Don't worry about cape...
+ return $this->type === 'alex' ? 'slim' : 'default';
+ }
+
+ public function scopeLike($query, $field, $value)
+ {
+ return $query->where($field, 'LIKE', "%$value%");
+ }
+
+ public function owner()
+ {
+ return $this->belongsTo(User::class, 'uploader');
+ }
+
+ public function likers()
+ {
+ return $this->belongsToMany(User::class, 'user_closet')->withPivot('item_name');
+ }
+
+ protected function serializeDate(DateTimeInterface $date)
+ {
+ return $date->format('Y-m-d H:i:s');
+ }
+}
diff --git a/app/Models/User.php b/app/Models/User.php
new file mode 100755
index 0000000..113314f
--- /dev/null
+++ b/app/Models/User.php
@@ -0,0 +1,115 @@
+ 'integer',
+ 'score' => 'integer',
+ 'avatar' => 'integer',
+ 'permission' => 'integer',
+ 'verified' => 'bool',
+ 'is_dark_mode' => 'bool',
+ ];
+
+ protected $hidden = ['password', 'remember_token'];
+
+ protected $searchStringColumns = [
+ 'uid',
+ 'email' => ['searchable' => true],
+ 'nickname' => ['searchable' => true],
+ 'avatar', 'score', 'permission', 'ip',
+ 'last_sign_at' => ['date' => true],
+ 'register_at' => ['date' => true],
+ 'verified' => ['boolean' => true],
+ 'is_dark_mode' => ['boolean' => true],
+ ];
+
+ public function isAdmin(): bool
+ {
+ return $this->permission >= static::ADMIN;
+ }
+
+ public function closet()
+ {
+ return $this->belongsToMany(Texture::class, 'user_closet')->withPivot('item_name');
+ }
+
+ public function getPlayerNameAttribute()
+ {
+ $player = $this->players->first();
+
+ return $player ? $player->name : '';
+ }
+
+ public function setPlayerNameAttribute($value)
+ {
+ $player = $this->players->first();
+ if ($player) {
+ $player->name = $value;
+ $player->save();
+ }
+ }
+
+ public function delete()
+ {
+ Player::where('uid', $this->uid)->delete();
+
+ return parent::delete();
+ }
+
+ public function players()
+ {
+ return $this->hasMany(Player::class, 'uid');
+ }
+
+ public function getAuthIdentifier()
+ {
+ return $this->uid;
+ }
+}
diff --git a/app/Notifications/SiteMessage.php b/app/Notifications/SiteMessage.php
new file mode 100755
index 0000000..0f3a05f
--- /dev/null
+++ b/app/Notifications/SiteMessage.php
@@ -0,0 +1,51 @@
+title = $title;
+ $this->content = $content;
+ }
+
+ /**
+ * Get the notification's delivery channels.
+ *
+ * @param mixed $notifiable
+ *
+ * @return array
+ */
+ public function via($notifiable)
+ {
+ return ['database'];
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @param mixed $notifiable
+ *
+ * @return array
+ */
+ public function toArray($notifiable)
+ {
+ return [
+ 'title' => $this->title,
+ 'content' => $this->content,
+ ];
+ }
+}
diff --git a/app/Observers/ScopeObserver.php b/app/Observers/ScopeObserver.php
new file mode 100755
index 0000000..38b5256
--- /dev/null
+++ b/app/Observers/ScopeObserver.php
@@ -0,0 +1,37 @@
+refreshCachedScopes();
+ }
+
+ /**
+ * Handle the Scope "deleted" event.
+ *
+ * @return void
+ */
+ public function deleted()
+ {
+ $this->refreshCachedScopes();
+ }
+
+ protected function refreshCachedScopes()
+ {
+ Cache::forget('scopes');
+ Cache::rememberForever('scopes', function () {
+ return Scope::pluck('description', 'name')->toArray();
+ });
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
new file mode 100755
index 0000000..219b480
--- /dev/null
+++ b/app/Providers/AppServiceProvider.php
@@ -0,0 +1,59 @@
+app->singleton('cipher', 'App\Services\Cipher\\'.config('secure.cipher'));
+ $this->app->singleton(Services\Option::class);
+ $this->app->alias(Services\Option::class, 'options');
+ }
+
+ public function boot(Request $request)
+ {
+ Paginator::useBootstrap();
+
+ $this->configureUrlGenerator($request);
+ }
+
+ /**
+ * Control the URL generated by url() function.
+ *
+ * @codeCoverageIgnore
+ */
+ protected function configureUrlGenerator(Request $request): void
+ {
+ if (!option('auto_detect_asset_url')) {
+ $rootUrl = option('site_url');
+
+ // Replace HTTP_HOST with site_url set in options,
+ // to prevent CDN source problems.
+ if (URL::isValidUrl($rootUrl)) {
+ URL::forceRootUrl($rootUrl);
+ }
+ }
+
+ /**
+ * Check whether the request is secure or not.
+ * True is always returned when "X-Forwarded-Proto" header is set.
+ *
+ * We define this function because Symfony's "Request::isSecure()" method
+ * needs "setTrustedProxies()" which sucks when load balancer is enabled.
+ */
+ $isRequestSecure = $request->server('HTTPS') === 'on'
+ || $request->server('HTTP_X_FORWARDED_PROTO') === 'https'
+ || $request->server('HTTP_X_FORWARDED_SSL') === 'on';
+
+ if (option('force_ssl') || $isRequestSecure) {
+ URL::forceScheme('https');
+ }
+ }
+}
diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php
new file mode 100755
index 0000000..64773c0
--- /dev/null
+++ b/app/Providers/AuthServiceProvider.php
@@ -0,0 +1,54 @@
+registerPolicies();
+
+ Passport::routes();
+
+ $defaultScopes = [
+ 'User.Read' => 'auth.oauth.scope.user.read',
+ 'Notification.Read' => 'auth.oauth.scope.notification.read',
+ 'Notification.ReadWrite' => 'auth.oauth.scope.notification.readwrite',
+ 'Player.Read' => 'auth.oauth.scope.player.read',
+ 'Player.ReadWrite' => 'auth.oauth.scope.player.readwrite',
+ 'Closet.Read' => 'auth.oauth.scope.closet.read',
+ 'Closet.ReadWrtie' => 'auth.oauth.scope.closet.readwrite',
+ 'UsersManagement.Read' => 'auth.oauth.scope.users-management.read',
+ 'UsersManagement.ReadWrite' => 'auth.oauth.scope.users-management.readwrite',
+ 'PlayersManagement.Read' => 'auth.oauth.scope.players-management.read',
+ 'PlayersManagement.ReadWrite' => 'auth.oauth.scope.players-management.readwrite',
+ 'ClosetManagement.Read' => 'auth.oauth.scope.closet-management.read',
+ 'ClosetManagement.ReadWrite' => 'auth.oauth.scope.closet-management.readwrite',
+ 'ReportsManagement.Read' => 'auth.oauth.scope.reports-management.read',
+ 'ReportsManagement.ReadWrite' => 'auth.oauth.scope.reports-management.readwrite',
+ ];
+
+ $scopes = Cache::get('scopes', []);
+
+ Passport::tokensCan(array_merge($defaultScopes, $scopes));
+
+ Passport::setDefaultScope(['User.Read']);
+ }
+}
diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php
new file mode 100755
index 0000000..deda698
--- /dev/null
+++ b/app/Providers/EventServiceProvider.php
@@ -0,0 +1,54 @@
+ [
+ Listeners\CopyPluginAssets::class,
+ Listeners\CleanUpFrontEndLocaleFiles::class,
+ ],
+ 'plugin.versionChanged' => [
+ Listeners\CopyPluginAssets::class,
+ Listeners\CleanUpFrontEndLocaleFiles::class,
+ ],
+ 'App\Events\PluginBootFailed' => [
+ Listeners\NotifyFailedPlugin::class,
+ ],
+ 'auth.registration.completed' => [
+ Listeners\SendEmailVerification::class,
+ ],
+ 'texture.privacy.updated' => [
+ Listeners\ResetPlayers::class,
+ Listeners\CleanUpCloset::class,
+ ],
+ 'texture.deleted' => [
+ Listeners\UpdateScoreForDeletedTexture::class,
+ Listeners\ResetPlayers::class,
+ Listeners\CleanUpCloset::class,
+ ],
+ 'closet.removed' => [
+ Listeners\ResetPlayerForRemovedClosetItem::class,
+ ],
+ 'Illuminate\Auth\Events\Authenticated' => [
+ Listeners\SetAppLocale::class,
+ ],
+ ];
+
+ /**
+ * Register any events for your application.
+ *
+ * @return void
+ */
+ public function boot()
+ {
+ Scope::observe(ScopeObserver::class);
+ }
+}
diff --git a/app/Providers/PluginServiceProvider.php b/app/Providers/PluginServiceProvider.php
new file mode 100755
index 0000000..68debc0
--- /dev/null
+++ b/app/Providers/PluginServiceProvider.php
@@ -0,0 +1,20 @@
+app->singleton(PluginManager::class);
+ $this->app->alias(PluginManager::class, 'plugins');
+ }
+
+ public function boot(PluginManager $plugins)
+ {
+ $plugins->boot();
+ }
+}
diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php
new file mode 100755
index 0000000..f237362
--- /dev/null
+++ b/app/Providers/RouteServiceProvider.php
@@ -0,0 +1,75 @@
+mapStaticRoutes($router);
+
+ $this->mapWebRoutes($router);
+
+ $this->mapApiRoutes();
+
+ Passport::routes();
+ foreach ($router->getRoutes()->getRoutesByName() as $name => $route) {
+ if (Str::startsWith($name, ['passport.authorizations', 'passport.tokens', 'passport.clients'])) {
+ $route->middleware('verified');
+ }
+ }
+
+ event(new ConfigureRoutes($router));
+ }
+
+ /**
+ * Define the "web" routes for the application.
+ * These routes all receive session state, CSRF protection, etc.
+ */
+ protected function mapWebRoutes(Router $router)
+ {
+ Route::middleware(['web'])
+ ->namespace($this->namespace)
+ ->group(base_path('routes/web.php'));
+ }
+
+ /**
+ * Define the "static" routes for the application.
+ * These routes will not load session, etc.
+ */
+ protected function mapStaticRoutes(Router $router)
+ {
+ Route::namespace($this->namespace)
+ ->group(base_path('routes/static.php'));
+ }
+
+ /**
+ * Define the "api" routes for the application.
+ * These routes are typically stateless.
+ */
+ protected function mapApiRoutes()
+ {
+ Route::prefix('api')
+ ->middleware(
+ config('app.env') == 'testing' ? ['api'] : ['api', 'throttle:60,1']
+ )
+ ->namespace($this->namespace)
+ ->group(base_path('routes/api.php'));
+ }
+}
diff --git a/app/Providers/ViewServiceProvider.php b/app/Providers/ViewServiceProvider.php
new file mode 100755
index 0000000..3bec892
--- /dev/null
+++ b/app/Providers/ViewServiceProvider.php
@@ -0,0 +1,82 @@
+with([
+ 'site_name' => option_localized('site_name'),
+ 'navbar_color' => $color,
+ 'color_mode' => in_array($color, $lightColors) ? 'light' : 'dark',
+ 'dark_mode' => (bool) optional(auth()->user())->is_dark_mode,
+ 'locale' => str_replace('_', '-', app()->getLocale()),
+ ]);
+ });
+
+ View::composer('shared.head', Composers\HeadComposer::class);
+
+ View::composer('shared.notifications', function ($view) {
+ $notifications = auth()->user()->unreadNotifications->map(function ($notification) {
+ return [
+ 'id' => $notification->id,
+ 'title' => $notification->data['title'],
+ ];
+ });
+ $view->with(['notifications' => $notifications]);
+ });
+
+ View::composer(
+ ['shared.languages', 'errors.*'],
+ Composers\LanguagesMenuComposer::class
+ );
+
+ View::composer('shared.user-menu', Composers\UserMenuComposer::class);
+
+ View::composer('shared.sidebar', function ($view) {
+ $isDarkMode = (bool) optional(auth()->user())->is_dark_mode;
+ $color = option('sidebar_color');
+ $color = $isDarkMode ? str_replace('light', 'dark', $color) : $color;
+
+ $view->with('sidebar_color', $color);
+ });
+
+ View::composer('shared.side-menu', Composers\SideMenuComposer::class);
+
+ View::composer('shared.user-panel', Composers\UserPanelComposer::class);
+
+ View::composer('shared.copyright', function ($view) {
+ $view->with([
+ 'copyright' => option_localized('copyright_prefer', 0),
+ 'custom_copyright' => option_localized('copyright_text'),
+ 'site_name' => option_localized('site_name'),
+ 'site_url' => option('site_url'),
+ ]);
+ });
+
+ View::composer('shared.foot', Composers\FootComposer::class);
+
+ View::composer('shared.dark-mode', function ($view) {
+ $view->with([
+ 'dark_mode' => (bool) optional(auth()->user())->is_dark_mode,
+ ]);
+ });
+
+ View::composer('assets.*', function ($view) {
+ $view->with('cdn_base', option('cdn_address', ''));
+ });
+ }
+}
diff --git a/app/Rules/Captcha.php b/app/Rules/Captcha.php
new file mode 100755
index 0000000..33e9954
--- /dev/null
+++ b/app/Rules/Captcha.php
@@ -0,0 +1,36 @@
+withOptions(['verify' => CaBundle::getSystemCaRootBundlePath()])
+ ->post('https://www.recaptcha.net/recaptcha/api/siteverify', [
+ 'secret' => $secretkey,
+ 'response' => $value,
+ ])
+ ->json()['success'];
+ }
+
+ $builder = new CaptchaBuilder(session()->pull('captcha'));
+
+ return $builder->testPhrase($value);
+ }
+
+ public function message()
+ {
+ return option('recaptcha_secretkey')
+ ? trans('validation.recaptcha')
+ : trans('validation.captcha');
+ }
+}
diff --git a/app/Rules/PlayerName.php b/app/Rules/PlayerName.php
new file mode 100755
index 0000000..10195c9
--- /dev/null
+++ b/app/Rules/PlayerName.php
@@ -0,0 +1,39 @@
+hash($password, $salt));
+ }
+}
diff --git a/app/Services/Cipher/MD5.php b/app/Services/Cipher/MD5.php
new file mode 100755
index 0000000..8404765
--- /dev/null
+++ b/app/Services/Cipher/MD5.php
@@ -0,0 +1,11 @@
+ 'Title', # will be translated by translator
+ * 'link' => 'user/config', # route link
+ * 'icon' => 'fa-book', # font-awesome icon
+ * 'new-tab' => false, # open the link in new tab or not, false by default
+ * ]
+ */
+ public static function addMenuItem(string $category, int $position, array $menu): void
+ {
+ $class = 'App\Events\Configure'.Str::title($category).'Menu';
+
+ Event::listen($class, function ($event) use ($menu, $position, $category) {
+ $new = [];
+
+ $offset = 0;
+ foreach ($event->menu[$category] as $item) {
+ // Push new menu items at the given position
+ if ($offset == $position) {
+ $new[] = $menu;
+ }
+
+ $new[] = $item;
+ $offset++;
+ }
+
+ if ($position >= $offset) {
+ $new[] = $menu;
+ }
+
+ $event->menu[$category] = $new;
+ });
+ }
+
+ public static function addRoute(Closure $callback): void
+ {
+ Event::listen(Events\ConfigureRoutes::class, function ($event) use ($callback) {
+ return call_user_func($callback, $event->router);
+ });
+ }
+
+ public static function addStyleFileToPage($urls, $pages = ['*']): void
+ {
+ $urls = collect($urls);
+ $pages = collect($pages);
+ resolve(Filter::class)->add('head_links', function ($links) use ($urls, $pages) {
+ $matched = $pages->some(fn ($page) => request()->is($page));
+ if ($matched) {
+ $urls->each(function ($url) use (&$links) {
+ $links[] = [
+ 'rel' => 'stylesheet',
+ 'href' => $url,
+ 'crossorigin' => 'anonymous',
+ ];
+ });
+ }
+
+ return $links;
+ });
+ }
+
+ public static function addScriptFileToPage($urls, $pages = ['*']): void
+ {
+ $urls = collect($urls);
+ $pages = collect($pages);
+ resolve(Filter::class)->add('scripts', function ($scripts) use ($urls, $pages) {
+ $matched = $pages->some(fn ($page) => request()->is($page));
+ if ($matched) {
+ $urls->each(function ($url) use (&$scripts) {
+ $scripts[] = ['src' => $url, 'crossorigin' => 'anonymous'];
+ });
+ }
+
+ return $scripts;
+ });
+ }
+
+ /** @deprecated */
+ public static function addUserBadge(string $text, $color = 'primary'): void
+ {
+ resolve(Filter::class)->add('user_badges', function ($badges) use ($text, $color) {
+ $badges[] = ['text' => $text, 'color' => $color];
+
+ return $badges;
+ });
+ }
+
+ public static function sendNotification($users, string $title, $content = ''): void
+ {
+ Notification::send(Arr::wrap($users), new Notifications\SiteMessage($title, $content));
+ }
+
+ public static function pushMiddleware($middleware)
+ {
+ app()->make('Illuminate\Contracts\Http\Kernel')->pushMiddleware($middleware);
+ }
+}
diff --git a/app/Services/Option.php b/app/Services/Option.php
new file mode 100755
index 0000000..79b3927
--- /dev/null
+++ b/app/Services/Option.php
@@ -0,0 +1,83 @@
+exists($cachePath)) {
+ $this->items = collect($filesystem->getRequire($cachePath));
+
+ return;
+ }
+
+ try {
+ $this->items = DB::table('options')
+ ->get()
+ ->mapWithKeys(fn ($item) => [$item->option_name => $item->option_value]);
+ } catch (QueryException $e) {
+ $this->items = collect();
+ }
+ }
+
+ public function get($key, $default = null, $raw = false)
+ {
+ if (!$this->items->has($key) && Arr::has(config('options'), $key)) {
+ $this->set($key, config("options.$key"));
+ }
+
+ $value = $this->items->get($key, $default);
+ if ($raw) {
+ return $value;
+ }
+
+ switch (strtolower($value)) {
+ case 'true':
+ case '(true)':
+ return true;
+
+ case 'false':
+ case '(false)':
+ return false;
+
+ case 'null':
+ case '(null)':
+ return null;
+
+ default:
+ return $value;
+ }
+ }
+
+ public function set($key, $value = null)
+ {
+ if (is_array($key)) {
+ foreach ($key as $k => $v) {
+ $this->set($k, $v);
+ }
+ } else {
+ $this->items->put($key, $value);
+ try {
+ DB::table('options')->updateOrInsert(
+ ['option_name' => $key],
+ ['option_value' => $value]
+ );
+ } catch (QueryException $e) {
+ }
+ }
+ }
+
+ public function all(): array
+ {
+ return $this->items->all();
+ }
+}
diff --git a/app/Services/OptionForm.php b/app/Services/OptionForm.php
new file mode 100755
index 0000000..0e4484d
--- /dev/null
+++ b/app/Services/OptionForm.php
@@ -0,0 +1,563 @@
+id = $id;
+
+ if ($title == self::AUTO_DETECT) {
+ $this->title = trans("options.$id.title");
+ } else {
+ $this->title = $title;
+ }
+ }
+
+ /**
+ * @throws \BadMethodCallException
+ */
+ public function __call(string $method, array $params): OptionFormItem
+ {
+ if (!in_array($method, ['text', 'checkbox', 'textarea', 'select', 'group'])) {
+ throw new BadMethodCallException("Method [$method] does not exist on option form.");
+ }
+
+ // Assign name for option item
+ if (!isset($params[1]) || Arr::get($params, 1) == OptionForm::AUTO_DETECT) {
+ $params[1] = Arr::get(trans("options.$this->id.$params[0]"), 'title', trans("options.$this->id.$params[0]"));
+ }
+
+ $class = new ReflectionClass('App\Services\OptionForm'.Str::title($method));
+ // Use ReflectionClass to create a new OptionFormItem instance
+ $item = $class->newInstanceArgs($params);
+ $item->setParentId($this->id);
+ $this->items[] = $item;
+
+ return $item;
+ }
+
+ /** Set the box type of option form. */
+ public function type(string $type): self
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ /** Add a hint to option form. */
+ public function hint($hintContent = self::AUTO_DETECT): self
+ {
+ if ($hintContent == self::AUTO_DETECT) {
+ $hintContent = trans("options.$this->id.hint");
+ }
+
+ $this->hint = view('forms.hint')->with('hint', $hintContent)->render();
+
+ return $this;
+ }
+
+ /**
+ * Add a piece of data to the option form.
+ *
+ * @param string|array $key
+ * @param mixed $value
+ */
+ public function with($key, $value = null): self
+ {
+ if (is_array($key)) {
+ $this->values = array_merge($this->values, $key);
+ } else {
+ $this->values[$key] = $value;
+ }
+
+ return $this;
+ }
+
+ /** Add a button at the footer of option form. */
+ public function addButton(array $info): self
+ {
+ $info = array_merge([
+ 'style' => 'default',
+ 'class' => [],
+ 'href' => '',
+ 'text' => 'BUTTON',
+ 'type' => 'button',
+ 'name' => '',
+ ], $info);
+
+ $info['class'] = array_merge(
+ ['btn', 'btn-'.$info['style']],
+ (array) Arr::get($info, 'class')
+ );
+ $this->buttons[] = $info;
+
+ return $this;
+ }
+
+ /**
+ * Add a message to the top of option form.
+ *
+ * @param string $msg
+ */
+ public function addMessage($msg = self::AUTO_DETECT, string $style = 'info'): self
+ {
+ if ($msg == self::AUTO_DETECT) {
+ $msg = trans("options.$this->id.message");
+ }
+
+ $this->messages[] = ['content' => $msg, 'type' => $style];
+
+ return $this;
+ }
+
+ /**
+ * Add an alert to the top of option form.
+ *
+ * @param string $msg
+ */
+ public function addAlert($msg = self::AUTO_DETECT, string $style = 'info'): self
+ {
+ if ($msg == self::AUTO_DETECT) {
+ $msg = trans("options.$this->id.alert");
+ }
+
+ $this->alerts[] = ['content' => $msg, 'type' => $style];
+
+ return $this;
+ }
+
+ /**
+ * Add callback which will be executed before handling options.
+ */
+ public function before(callable $callback): self
+ {
+ $this->hookBefore = $callback;
+
+ return $this;
+ }
+
+ /**
+ * Add callback which will be executed after handling options.
+ */
+ public function after(callable $callback): self
+ {
+ $this->hookAfter = $callback;
+
+ return $this;
+ }
+
+ /**
+ * Add callback which will be always executed.
+ */
+ public function always(callable $callback): self
+ {
+ $this->alwaysCallback = $callback;
+
+ return $this;
+ }
+
+ /**
+ * Handle the HTTP post request and update modified options.
+ */
+ public function handle(callable $callback = null): self
+ {
+ $request = request();
+ $allPostData = $request->all();
+
+ if ($request->isMethod('POST') && Arr::get($allPostData, 'option') == $this->id) {
+ if (!is_null($callback)) {
+ call_user_func($callback, $this);
+ }
+
+ if (!is_null($this->hookBefore)) {
+ call_user_func($this->hookBefore, $this);
+ }
+
+ $postOptionQueue = [];
+
+ foreach ($this->items as $item) {
+ if ($item instanceof OptionFormGroup) {
+ foreach ($item->items as $innerItem) {
+ if ($innerItem['type'] == 'text') {
+ $postOptionQueue[] = new OptionFormText($innerItem['id']);
+ }
+ }
+ continue;
+ }
+ // Push item to the queue
+ $postOptionQueue[] = $item;
+ }
+
+ foreach ($postOptionQueue as $item) {
+ if ($item instanceof OptionFormCheckbox && !isset($allPostData[$item->id])) {
+ // preset value for checkboxes which are not checked
+ $allPostData[$item->id] = false;
+ }
+
+ // Compare with raw option value
+ if (($data = Arr::get($allPostData, $item->id)) != option($item->id, null, true)) {
+ $formatted = is_null($item->format) ? $data : call_user_func($item->format, $data);
+ Option::set($item->id, $formatted);
+ }
+ }
+
+ if (!is_null($this->hookAfter)) {
+ call_user_func($this->hookAfter, $this);
+ }
+
+ $this->addAlert(trans('options.option-saved'), 'success');
+ }
+
+ return $this;
+ }
+
+ /**
+ * Load value from $this->values & options by given id.
+ */
+ protected function getValueById(string $id)
+ {
+ return Arr::get($this->values, $id, option_localized($id));
+ }
+
+ /**
+ * Assign value for option items whose value haven't been set.
+ */
+ protected function assignValues(): void
+ {
+ // Load values for items if not set manually
+ foreach ($this->items as $item) {
+ if ($item instanceof OptionFormGroup) {
+ foreach ($item->items as &$groupItem) {
+ if ($groupItem['id'] && is_null($groupItem['value'])) {
+ $groupItem['value'] = $this->getValueById($groupItem['id']);
+ }
+ }
+ continue;
+ }
+
+ if (is_null($item->value)) {
+ $item->value = $this->getValueById($item->id);
+ }
+ }
+ }
+
+ public function renderWithoutTable(): self
+ {
+ $this->renderWithoutTable = true;
+
+ return $this;
+ }
+
+ public function renderInputTagsOnly(): self
+ {
+ $this->renderInputTagsOnly = true;
+
+ return $this;
+ }
+
+ public function renderWithoutSubmitButton(): self
+ {
+ $this->renderWithoutSubmitButton = true;
+
+ return $this;
+ }
+
+ /**
+ * Get the string contents of the option form.
+ */
+ public function render(): string
+ {
+ if (!is_null($this->alwaysCallback)) {
+ call_user_func($this->alwaysCallback, $this);
+ }
+
+ // attach submit button to the form
+ if (!$this->renderWithoutSubmitButton) {
+ $this->addButton([
+ 'style' => 'primary',
+ 'text' => trans('general.submit'),
+ 'type' => 'submit',
+ 'name' => 'submit_'.$this->id,
+ ]);
+ }
+
+ $this->assignValues();
+
+ return view('forms.form')
+ ->with(get_object_vars($this))
+ ->render();
+ }
+
+ /**
+ * Get the string contents of the option form.
+ */
+ public function __toString(): string
+ {
+ return $this->render();
+ }
+}
+
+class OptionFormItem
+{
+ public $id;
+
+ public $name;
+
+ public $hint;
+
+ public $format;
+
+ public $value = null;
+
+ public $disabled;
+
+ public $description;
+
+ protected $parentId;
+
+ public function __construct(string $id, $name = null)
+ {
+ $this->id = $id;
+ $this->name = $name;
+ }
+
+ public function setParentId($id)
+ {
+ $this->parentId = $id;
+
+ return $this;
+ }
+
+ public function value($value)
+ {
+ $this->value = $value;
+
+ return $this;
+ }
+
+ public function hint($hintContent = OptionForm::AUTO_DETECT)
+ {
+ if ($hintContent == OptionForm::AUTO_DETECT) {
+ $hintContent = trans("options.$this->parentId.$this->id.hint");
+ }
+
+ $this->hint = view('forms.hint')->with('hint', $hintContent)->render();
+
+ return $this;
+ }
+
+ public function format(callable $callback)
+ {
+ $this->format = $callback;
+
+ return $this;
+ }
+
+ public function disabled($disabled = 'disabled')
+ {
+ $this->disabled = $disabled;
+
+ return $this;
+ }
+
+ public function description($description = OptionForm::AUTO_DETECT)
+ {
+ if ($description == OptionForm::AUTO_DETECT) {
+ $description = trans("options.$this->parentId.$this->id.description");
+ }
+
+ $this->description = $description;
+
+ return $this;
+ }
+}
+
+class OptionFormText extends OptionFormItem
+{
+ protected $placeholder = '';
+
+ public function placeholder($placeholder = OptionForm::AUTO_DETECT)
+ {
+ if ($placeholder == OptionForm::AUTO_DETECT) {
+ $key = "options.$this->parentId.$this->id.placeholder";
+ $placeholder = trans()->has($key) ? trans($key) : '';
+ }
+
+ $this->placeholder = $placeholder;
+
+ return $this;
+ }
+
+ public function render()
+ {
+ return view('forms.text')->with([
+ 'id' => $this->id,
+ 'value' => $this->value,
+ 'disabled' => $this->disabled,
+ 'placeholder' => $this->placeholder,
+ ]);
+ }
+}
+
+class OptionFormCheckbox extends OptionFormItem
+{
+ protected $label;
+
+ public function label($label = OptionForm::AUTO_DETECT)
+ {
+ if ($label == OptionForm::AUTO_DETECT) {
+ $label = trans("options.$this->parentId.$this->id.label");
+ }
+
+ $this->label = $label;
+
+ return $this;
+ }
+
+ public function render()
+ {
+ return view('forms.checkbox')->with([
+ 'id' => $this->id,
+ 'value' => $this->value,
+ 'label' => $this->label,
+ 'disabled' => $this->disabled,
+ ]);
+ }
+}
+
+class OptionFormTextarea extends OptionFormItem
+{
+ protected $rows = 3;
+
+ public function rows($rows)
+ {
+ $this->rows = $rows;
+
+ return $this;
+ }
+
+ public function render()
+ {
+ return view('forms.textarea')->with([
+ 'id' => $this->id,
+ 'rows' => $this->rows,
+ 'value' => $this->value,
+ 'disabled' => $this->disabled,
+ ]);
+ }
+}
+
+class OptionFormSelect extends OptionFormItem
+{
+ protected $options;
+
+ public function option($value, $name)
+ {
+ $this->options[] = compact('value', 'name');
+
+ return $this;
+ }
+
+ public function render()
+ {
+ return view('forms.select')->with([
+ 'id' => $this->id,
+ 'options' => (array) $this->options,
+ 'selected' => $this->value,
+ 'disabled' => $this->disabled,
+ ]);
+ }
+}
+
+class OptionFormGroup extends OptionFormItem
+{
+ public $items = [];
+
+ public function text($id, $value = null, $placeholder = OptionForm::AUTO_DETECT)
+ {
+ if ($placeholder == OptionForm::AUTO_DETECT) {
+ $key = "options.$this->parentId.$this->id.placeholder";
+ $placeholder = trans()->has($key) ? trans($key) : '';
+ }
+
+ $this->items[] = [
+ 'type' => 'text',
+ 'id' => $id,
+ 'value' => $value,
+ 'placeholder' => $placeholder,
+ ];
+
+ return $this;
+ }
+
+ public function addon($value = OptionForm::AUTO_DETECT)
+ {
+ if ($value == OptionForm::AUTO_DETECT) {
+ $value = trans("options.$this->parentId.$this->id.addon");
+ }
+
+ $this->items[] = ['type' => 'addon', 'id' => null, 'value' => $value];
+
+ return $this;
+ }
+
+ public function render()
+ {
+ $rendered = [];
+
+ foreach ($this->items as $item) {
+ $rendered[] = view('forms.'.$item['type'])->with([
+ 'id' => $item['id'],
+ 'value' => $item['value'],
+ 'placeholder' => Arr::get($item, 'placeholder'),
+ ]);
+ }
+
+ return view('forms.group')->with('items', $rendered);
+ }
+}
diff --git a/app/Services/Plugin.php b/app/Services/Plugin.php
new file mode 100755
index 0000000..70f1c90
--- /dev/null
+++ b/app/Services/Plugin.php
@@ -0,0 +1,114 @@
+path = $path;
+ $this->manifest = $manifest;
+ }
+
+ public function __get(string $name)
+ {
+ return $this->getManifestAttr(Str::snake($name, '-'));
+ }
+
+ public function getManifest()
+ {
+ return $this->manifest;
+ }
+
+ public function getManifestAttr(string $name, $default = null)
+ {
+ return Arr::get($this->manifest, $name, $default);
+ }
+
+ public function assets(string $relativeUri): string
+ {
+ $baseUrl = config('plugins.url') ?: url('plugins');
+
+ return "$baseUrl/{$this->name}/assets/$relativeUri?v=".$this->version;
+ }
+
+ public function getReadme()
+ {
+ return Arr::first(
+ self::README_FILES,
+ fn ($filename) => file_exists($this->path.'/'.$filename)
+ );
+ }
+
+ public function hasConfig(): bool
+ {
+ return $this->hasConfigClass() || $this->hasConfigView();
+ }
+
+ public function hasConfigClass(): bool
+ {
+ return Arr::has($this->manifest, 'enchants.config');
+ }
+
+ public function getConfigClass()
+ {
+ $name = Arr::get($this->manifest, 'enchants.config');
+
+ return Str::start($name, $this->manifest['namespace'].'\\');
+ }
+
+ public function getViewPath(string $filename): string
+ {
+ return $this->path."/views/$filename";
+ }
+
+ public function getConfigView()
+ {
+ return $this->hasConfigView()
+ ? view()->file($this->getViewPath(Arr::get($this->manifest, 'config', 'config.blade.php')))
+ : null;
+ }
+
+ public function hasConfigView(): bool
+ {
+ $filename = Arr::get($this->manifest, 'config', 'config.blade.php');
+
+ return $filename && file_exists($this->getViewPath($filename));
+ }
+
+ public function setEnabled(bool $enabled): self
+ {
+ $this->enabled = $enabled;
+
+ return $this;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
+ public function getPath(): string
+ {
+ return $this->path;
+ }
+}
diff --git a/app/Services/PluginManager.php b/app/Services/PluginManager.php
new file mode 100755
index 0000000..0a68fe2
--- /dev/null
+++ b/app/Services/PluginManager.php
@@ -0,0 +1,407 @@
+app = $app;
+ $this->option = $option;
+ $this->dispatcher = $dispatcher;
+ $this->filesystem = $filesystem;
+ $this->enabled = collect();
+ $this->loader = new ClassLoader();
+ }
+
+ public function all(): Collection
+ {
+ if (isset($this->plugins)) {
+ return $this->plugins;
+ }
+
+ $this->enabled = collect(json_decode($this->option->get('plugins_enabled', '[]'), true))
+ ->reject(fn ($item) => is_string($item))
+ ->mapWithKeys(fn ($item) => [$item['name'] => ['version' => $item['version']]]);
+ $plugins = collect();
+ $versionChanged = [];
+
+ $this->getPluginsDirs()
+ ->flatMap(fn ($dir) => $this->filesystem->directories($dir))
+ ->unique()
+ ->filter(fn ($dir) => $this->filesystem->exists($dir.DIRECTORY_SEPARATOR.'package.json'))
+ ->each(function ($directory) use (&$plugins, &$versionChanged) {
+ $manifest = json_decode(
+ $this->filesystem->get($directory.DIRECTORY_SEPARATOR.'package.json'),
+ true
+ );
+
+ $name = $manifest['name'];
+ if ($plugins->has($name)) {
+ throw new PrettyPageException(trans('errors.plugins.duplicate', ['dir1' => $plugins->get($name)->getPath(), 'dir2' => $directory]), 5);
+ }
+
+ $plugin = new Plugin($directory, $manifest);
+ $plugins->put($name, $plugin);
+ if ($this->getUnsatisfied($plugin)->isNotEmpty() || $this->getConflicts($plugin)->isNotEmpty()) {
+ $this->disable($plugin);
+ }
+ if ($this->enabled->has($name)) {
+ $plugin->setEnabled(true);
+ if (Comparator::notEqualTo(
+ $manifest['version'],
+ $this->enabled->get($name)['version']
+ )) {
+ $this->enabled->put($name, ['version' => $manifest['version']]);
+ $versionChanged[] = $plugin;
+ }
+ }
+ });
+
+ $this->plugins = $plugins;
+ if (count($versionChanged) > 0) {
+ $this->saveEnabled();
+ }
+ array_walk($versionChanged, function ($plugin) {
+ $this->dispatcher->dispatch('plugin.versionChanged', [$plugin]);
+ });
+
+ return $plugins;
+ }
+
+ /**
+ * Boot all enabled plugins.
+ */
+ public function boot()
+ {
+ if ($this->booted) {
+ return;
+ }
+
+ $this->all()->each(fn ($plugin) => $this->loadViewsAndTranslations($plugin));
+
+ $enabled = $this->getEnabledPlugins();
+ $enabled->each(fn ($plugin) => $this->registerPlugin($plugin));
+ $this->loader->register();
+ $enabled->each(fn ($plugin) => $this->bootPlugin($plugin));
+ $this->registerLifecycleHooks();
+
+ $this->booted = true;
+ }
+
+ /**
+ * Register resources of a plugin.
+ */
+ public function registerPlugin(Plugin $plugin)
+ {
+ $this->registerAutoload($plugin);
+ $this->loadVendor($plugin);
+ }
+
+ /**
+ * Boot a plugin.
+ */
+ public function bootPlugin(Plugin $plugin)
+ {
+ $this->registerServiceProviders($plugin);
+ $this->loadBootstrapper($plugin);
+ }
+
+ /**
+ * Register classes autoloading.
+ */
+ protected function registerAutoload(Plugin $plugin)
+ {
+ $this->loader->addPsr4(
+ Str::finish($plugin->namespace, '\\'),
+ $plugin->getPath().'/src'
+ );
+ }
+
+ /**
+ * Load Composer dumped autoload file.
+ */
+ protected function loadVendor(Plugin $plugin)
+ {
+ $path = $plugin->getPath().'/vendor/autoload.php';
+ if ($this->filesystem->exists($path)) {
+ $this->filesystem->getRequire($path);
+ }
+ }
+
+ /**
+ * Load views and translations.
+ */
+ protected function loadViewsAndTranslations(Plugin $plugin)
+ {
+ $namespace = $plugin->namespace;
+ $path = $plugin->getPath();
+
+ $translations = $this->app->make('translation.loader');
+ $translations->addNamespace($namespace, $path.'/lang');
+
+ $view = $this->app->make('view');
+ $view->addNamespace($namespace, $path.'/views');
+ }
+
+ protected function registerServiceProviders(Plugin $plugin)
+ {
+ $providers = Arr::get($plugin->getManifest(), 'enchants.providers', []);
+ array_walk($providers, function ($provider) use ($plugin) {
+ $class = (string) Str::of($provider)
+ ->finish('ServiceProvider')
+ ->start($plugin->namespace.'\\');
+ if (class_exists($class)) {
+ $this->app->register($class);
+ }
+ });
+ }
+
+ /**
+ * Load plugin's bootstrapper.
+ */
+ protected function loadBootstrapper(Plugin $plugin)
+ {
+ $path = $plugin->getPath().'/bootstrap.php';
+ if ($this->filesystem->exists($path)) {
+ try {
+ $this->app->call($this->filesystem->getRequire($path), ['plugin' => $plugin]);
+ } catch (\Throwable $th) {
+ report($th);
+ $this->dispatcher->dispatch(new Events\PluginBootFailed($plugin));
+ // @codeCoverageIgnoreStart
+ if (config('app.debug')) {
+ throw $th;
+ }
+ // @codeCoverageIgnoreEnd
+ if (is_a($th, \Exception::class)) {
+ $handler = $this->app->make(\App\Exceptions\Handler::class);
+ if (!$handler->shouldReport($th)) {
+ throw $th;
+ }
+ }
+ }
+ }
+ }
+
+ protected function registerLifecycleHooks()
+ {
+ $this->dispatcher->listen([
+ Events\PluginWasEnabled::class,
+ Events\PluginWasDisabled::class,
+ Events\PluginWasDeleted::class,
+ ], function ($event) {
+ $plugin = $event->plugin;
+ $path = $plugin->getPath().'/callbacks.php';
+ if ($this->filesystem->exists($path)) {
+ $callbacks = $this->filesystem->getRequire($path);
+ $callback = Arr::get($callbacks, get_class($event));
+ if ($callback) {
+ return $this->app->call($callback, ['plugin' => $plugin]);
+ }
+ }
+ });
+ }
+
+ public function get(string $name): ?Plugin
+ {
+ return $this->all()->get($name);
+ }
+
+ /**
+ * @return bool|array return `true` if succeeded, or return information if failed
+ */
+ public function enable($plugin)
+ {
+ /** @var Plugin|null */
+ $plugin = is_string($plugin) ? $this->get($plugin) : $plugin;
+ if (empty($plugin)) {
+ return false;
+ }
+
+ if ($plugin->isEnabled()) {
+ return true;
+ }
+
+ $unsatisfied = $this->getUnsatisfied($plugin);
+ $conflicts = $this->getConflicts($plugin);
+ if ($unsatisfied->isNotEmpty() || $conflicts->isNotEmpty()) {
+ return compact('unsatisfied', 'conflicts');
+ }
+
+ $this->enabled->put($plugin->name, ['version' => $plugin->version]);
+ $this->saveEnabled();
+
+ $plugin->setEnabled(true);
+
+ $this->dispatcher->dispatch(new Events\PluginWasEnabled($plugin));
+
+ return true;
+ }
+
+ public function disable($plugin)
+ {
+ /** @var Plugin|null */
+ $plugin = is_string($plugin) ? $this->get($plugin) : $plugin;
+ if ($plugin && $plugin->isEnabled()) {
+ $this->enabled->pull($plugin->name);
+ $this->saveEnabled();
+
+ $plugin->setEnabled(false);
+
+ $this->dispatcher->dispatch(new Events\PluginWasDisabled($plugin));
+ }
+ }
+
+ public function delete($plugin)
+ {
+ $plugin = is_string($plugin) ? $this->get($plugin) : $plugin;
+ if ($plugin) {
+ $this->disable($plugin);
+
+ // dispatch event before deleting plugin files
+ $this->dispatcher->dispatch(new Events\PluginWasDeleted($plugin));
+
+ $this->filesystem->deleteDirectory($plugin->getPath());
+
+ $this->plugins->pull($plugin->name);
+ }
+ }
+
+ public function getEnabledPlugins(): Collection
+ {
+ return $this->all()->filter(fn ($plugin) => $plugin->isEnabled());
+ }
+
+ /**
+ * Persist the currently enabled plugins.
+ */
+ protected function saveEnabled()
+ {
+ $this->option->set(
+ 'plugins_enabled',
+ $this->enabled
+ ->map(fn ($info, $name) => array_merge(['name' => $name], $info))
+ ->values()
+ ->toJson()
+ );
+ }
+
+ public function getUnsatisfied(Plugin $plugin)
+ {
+ return collect(Arr::get($plugin->getManifest(), 'require', []))
+ ->mapWithKeys(function ($constraint, $name) {
+ if ($name == 'blessing-skin-server') {
+ $version = config('app.version');
+
+ return (!Semver::satisfies($version, $constraint))
+ ? [$name => compact('version', 'constraint')]
+ : [];
+ } elseif ($name == 'php') {
+ preg_match('/(\d+\.\d+\.\d+)/', PHP_VERSION, $matches);
+ $version = $matches[1];
+
+ return (!Semver::satisfies($version, $constraint))
+ ? [$name => compact('version', 'constraint')]
+ : [];
+ } elseif (!$this->enabled->has($name)) {
+ return [$name => ['version' => null, 'constraint' => $constraint]];
+ } else {
+ $version = $this->enabled->get($name)['version'];
+
+ return (!Semver::satisfies($version, $constraint))
+ ? [$name => compact('version', 'constraint')]
+ : [];
+ }
+ });
+ }
+
+ public function getConflicts(Plugin $plugin): Collection
+ {
+ return collect($plugin->getManifestAttr('enchants.conflicts', []))
+ ->mapWithKeys(function ($constraint, $name) {
+ $info = $this->enabled->get($name);
+ if ($info && Semver::satisfies($info['version'], $constraint)) {
+ return [$name => ['version' => $info['version'], 'constraint' => $constraint]];
+ } else {
+ return [];
+ }
+ });
+ }
+
+ /**
+ * Format the "unresolved" information into human-readable text.
+ */
+ public function formatUnresolved(
+ Collection $unsatisfied,
+ Collection $conflicts
+ ): array {
+ $unsatisfied = $unsatisfied->map(function ($detail, $name) {
+ if ($name === 'blessing-skin-server') {
+ $title = 'Blessing Skin Server';
+ } elseif ($name === 'php') {
+ $title = 'PHP';
+ } else {
+ $plugin = $this->get($name);
+ $title = $plugin ? trans($plugin->title) : $name;
+ }
+
+ $constraint = $detail['constraint'];
+
+ return $detail['version']
+ ? trans('admin.plugins.operations.unsatisfied.version', compact('title', 'constraint'))
+ : trans('admin.plugins.operations.unsatisfied.disabled', ['name' => $title]);
+ })->values()->all();
+
+ $conflicts = $conflicts->map(function ($detail, $name) {
+ $title = trans($this->get($name)->title);
+
+ return trans('admin.plugins.operations.unsatisfied.conflict', compact('title'));
+ })->values()->all();
+
+ return array_merge($unsatisfied, $conflicts);
+ }
+
+ public function getPluginsDirs(): Collection
+ {
+ $config = config('plugins.directory');
+ if ($config) {
+ return collect(preg_split('/,\s*/', $config))
+ ->map(fn ($directory) => realpath($directory) ?: $directory);
+ } else {
+ return collect([base_path('plugins')]);
+ }
+ }
+}
diff --git a/app/Services/Translations/JavaScript.php b/app/Services/Translations/JavaScript.php
new file mode 100755
index 0000000..2495845
--- /dev/null
+++ b/app/Services/Translations/JavaScript.php
@@ -0,0 +1,62 @@
+filesystem = $filesystem;
+ $this->cache = $cache;
+ $this->plugins = $plugins;
+ }
+
+ public function generate(string $locale): string
+ {
+ $plugins = $this->plugins->getEnabledPlugins();
+ $sourceFiles = $plugins
+ ->map(fn (Plugin $plugin) => $plugin->getPath()."/lang/$locale/front-end.yml")
+ ->filter(fn ($path) => $this->filesystem->exists($path));
+ $sourceFiles->push(resource_path("lang/$locale/front-end.yml"));
+ $sourceModified = $sourceFiles->max(fn ($path) => $this->filesystem->lastModified($path));
+
+ $compiled = public_path("lang/$locale.js");
+ $compiledModified = (int) $this->cache->get($this->prefix.$locale, 0);
+
+ if ($sourceModified > $compiledModified || !$this->filesystem->exists($compiled)) {
+ $translations = trans('front-end');
+ foreach ($plugins as $plugin) {
+ $translations[$plugin->name] = trans($plugin->namespace.'::front-end');
+ }
+
+ $content = 'blessing.i18n = '.json_encode($translations, JSON_UNESCAPED_UNICODE);
+ $this->filesystem->put($compiled, $content);
+ $this->cache->put($this->prefix.$locale, $sourceModified);
+
+ return url()->asset("lang/$locale.js?t=$sourceModified");
+ }
+
+ return url()->asset("lang/$locale.js?t=$compiledModified");
+ }
+
+ public function resetTime(string $locale): void
+ {
+ $this->cache->put($this->prefix.$locale, 0);
+ }
+}
diff --git a/app/Services/Translations/Loader.php b/app/Services/Translations/Loader.php
new file mode 100755
index 0000000..0b6ddb4
--- /dev/null
+++ b/app/Services/Translations/Loader.php
@@ -0,0 +1,19 @@
+files->exists($full)
+ ? resolve(Yaml::class)->parse($full)
+ : [];
+ }
+}
diff --git a/app/Services/Translations/Yaml.php b/app/Services/Translations/Yaml.php
new file mode 100755
index 0000000..457290e
--- /dev/null
+++ b/app/Services/Translations/Yaml.php
@@ -0,0 +1,35 @@
+cache = $cache;
+ }
+
+ public function parse(string $path)
+ {
+ $prefix = $this->prefix.md5($path).'-';
+ $cacheTime = intval($this->cache->get($prefix.'time', 0));
+ $fileTime = filemtime($path);
+
+ if ($fileTime > $cacheTime) {
+ $content = YamlParser::parseFile($path);
+ $this->cache->put($prefix.'content', $content);
+ $this->cache->put($prefix.'time', $fileTime);
+
+ return $content;
+ }
+
+ return $this->cache->get($prefix.'content', []);
+ }
+}
diff --git a/app/Services/Unzip.php b/app/Services/Unzip.php
new file mode 100755
index 0000000..7032493
--- /dev/null
+++ b/app/Services/Unzip.php
@@ -0,0 +1,35 @@
+filesystem = $filesystem;
+ $this->zipper = $zipper;
+ }
+
+ public function extract(string $file, string $destination): void
+ {
+ $zip = $this->zipper;
+ $resource = $zip->open($file);
+
+ if ($resource === true && $zip->extractTo($destination)) {
+ $zip->close();
+ $this->filesystem->delete($file);
+ } else {
+ throw new Exception(trans('admin.download.errors.unzip'));
+ }
+ }
+}
diff --git a/app/helpers.php b/app/helpers.php
new file mode 100755
index 0000000..0f9ba1e
--- /dev/null
+++ b/app/helpers.php
@@ -0,0 +1,85 @@
+get($name);
+ }
+}
+
+if (!function_exists('plugin_assets')) {
+ /** @deprecated */
+ function plugin_assets(string $name, string $relativeUri): string
+ {
+ $plugin = plugin($name);
+ if ($plugin) {
+ return $plugin->assets($relativeUri);
+ } else {
+ throw new InvalidArgumentException('No such plugin.');
+ }
+ }
+}
+
+if (!function_exists('json')) {
+ function json()
+ {
+ $args = func_get_args();
+
+ if (count($args) === 1 && is_array($args[0])) {
+ return response()->json($args[0]);
+ } elseif (count($args) === 3 && is_array($args[2])) {
+ // The third argument is array of extra fields
+ return response()->json([
+ 'code' => $args[1],
+ 'message' => $args[0],
+ 'data' => $args[2],
+ ]);
+ } else {
+ return response()->json([
+ 'code' => Arr::get($args, 1, 1),
+ 'message' => $args[0],
+ ]);
+ }
+ }
+}
+
+if (!function_exists('option')) {
+ /**
+ * Get / set the specified option value.
+ *
+ * If an array is passed as the key, we will assume you want to set an array of values.
+ *
+ * @param array|string $key
+ * @param mixed $default
+ * @param bool $raw return raw value without convertion
+ *
+ * @return mixed
+ */
+ function option($key = null, $default = null, $raw = false)
+ {
+ $options = app('options');
+
+ if (is_null($key)) {
+ return $options;
+ }
+
+ if (is_array($key)) {
+ $options->set($key);
+
+ return;
+ }
+
+ return $options->get($key, $default, $raw);
+ }
+}
+
+if (!function_exists('option_localized')) {
+ function option_localized($key = null, $default = null, $raw = false)
+ {
+ return option($key.'_'.config('app.locale'), option($key, $default), $raw);
+ }
+}
diff --git a/artisan b/artisan
new file mode 100755
index 0000000..5c23e2e
--- /dev/null
+++ b/artisan
@@ -0,0 +1,53 @@
+#!/usr/bin/env php
+make(Illuminate\Contracts\Console\Kernel::class);
+
+$status = $kernel->handle(
+ $input = new Symfony\Component\Console\Input\ArgvInput,
+ new Symfony\Component\Console\Output\ConsoleOutput
+);
+
+/*
+|--------------------------------------------------------------------------
+| Shutdown The Application
+|--------------------------------------------------------------------------
+|
+| Once Artisan has finished running, we will fire off the shutdown events
+| so that any final work may be done by the application before we shut
+| down the process. This is the last thing to happen to the request.
+|
+*/
+
+$kernel->terminate($input, $status);
+
+exit($status);
diff --git a/bootstrap/app.php b/bootstrap/app.php
new file mode 100755
index 0000000..037e17d
--- /dev/null
+++ b/bootstrap/app.php
@@ -0,0 +1,55 @@
+singleton(
+ Illuminate\Contracts\Http\Kernel::class,
+ App\Http\Kernel::class
+);
+
+$app->singleton(
+ Illuminate\Contracts\Console\Kernel::class,
+ App\Console\Kernel::class
+);
+
+$app->singleton(
+ Illuminate\Contracts\Debug\ExceptionHandler::class,
+ App\Exceptions\Handler::class
+);
+
+/*
+|--------------------------------------------------------------------------
+| Return The Application
+|--------------------------------------------------------------------------
+|
+| This script returns the application instance. The instance is given to
+| the calling script so we can separate the building of the instances
+| from the actual running of the application and sending responses.
+|
+*/
+
+return $app;
diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore
new file mode 100755
index 0000000..d6b7ef3
--- /dev/null
+++ b/bootstrap/cache/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/bootstrap/chkenv.php b/bootstrap/chkenv.php
new file mode 100755
index 0000000..f9f5c8d
--- /dev/null
+++ b/bootstrap/chkenv.php
@@ -0,0 +1,109 @@
+'.
+ '[错误] 根目录下未发现 vendor 文件夹,请使用正式的已构建好的 release 包。'
+ );
+ }
+
+ $envPath = __DIR__.'/../.env';
+ if (!file_exists($envPath)) {
+ copy(__DIR__.'/../.env.example', $envPath);
+ }
+
+ $envFile = file_get_contents($envPath);
+ if (preg_match('/APP_KEY\s*=\s*\n/', $envFile)) {
+ $key = 'base64:'.base64_encode(Encrypter::generateKey('AES-256-CBC'));
+ file_put_contents($envPath, preg_replace('/APP_KEY\s*=\s*/', 'APP_KEY='.$key."\n\n", $envFile));
+ }
+
+ $requiredFunctions = ['symlink', 'readlink', 'putenv', 'realpath'];
+ $disabledFunctions = preg_split('/,\s*/', ini_get('disable_functions'));
+ foreach ($requiredFunctions as $fn) {
+ if (in_array($fn, $disabledFunctions)) {
+ die_with_utf8_encoding(
+ '[Error] Please don\'t disable built-in function "'.$fn.'", which is specified in "php.ini" file.
'.
+ "[错误] 请不要在 php.ini 中禁用 $fn 函数。".
+ '我们不建议使用您使用宝塔等面板软件,因为容易引起兼容性问题。'
+ );
+ }
+ }
+
+ if (!empty(ini_get('open_basedir'))) {
+ die_with_utf8_encoding(
+ '[Error] Please disable "open_basedir" option by editing "php.ini" file.
'.
+ '[错误] 请修改 php.ini 以关闭 "open_basedir" 选项。'.
+ '我们不建议使用您使用宝塔等面板软件,因为容易引起兼容性问题。'
+ );
+ }
+
+ $requiredVersion = '8.0.2';
+ preg_match('/(\d+\.\d+\.\d+)/', PHP_VERSION, $matches);
+ $version = $matches[1];
+ if (version_compare($version, $requiredVersion, '<')) {
+ die_with_utf8_encoding(
+ '[Error] Blessing Skin requires PHP version >= '.$requiredVersion.', you are now using '.$version.'
'.
+ '[错误] 你的 PHP 版本过低('.$version.'),Blessing Skin 要求至少为 '.$requiredVersion
+ );
+ }
+
+ $requirements = [
+ 'extensions' => [
+ 'pdo',
+ 'openssl',
+ 'gd',
+ 'mbstring',
+ 'tokenizer',
+ 'ctype',
+ 'xml',
+ 'json',
+ 'fileinfo',
+ 'zip',
+ ],
+ 'write_permission' => [
+ 'bootstrap/cache',
+ 'storage',
+ 'plugins',
+ 'public',
+ ],
+ ];
+
+ foreach ($requirements['extensions'] as $extension) {
+ if (!extension_loaded($extension)) {
+ die_with_utf8_encoding(
+ "[Error] You have not installed the $extension extension
".
+ "[错误] 你尚未安装 $extension 扩展!安装方法请自行搜索。"
+ );
+ }
+ }
+
+ foreach ($requirements['write_permission'] as $dir) {
+ $realPath = realpath(__DIR__."/../$dir");
+
+ if (!is_writable($realPath)) {
+ die_with_utf8_encoding(
+ "[Error] The directory '$dir' is not writable.
".
+ "[错误] 目录 '$dir' 不可写,请检查该目录的权限。"
+ );
+ }
+
+ if (!is_writable($realPath)) {
+ die_with_utf8_encoding(
+ "[Error] The program lacks write permission to directory '$dir'
".
+ "[错误] 程序缺少对 '$dir' 目录的写权限,请手动授权。"
+ );
+ }
+ }
+})();
diff --git a/config/app.php b/config/app.php
new file mode 100755
index 0000000..4b77856
--- /dev/null
+++ b/config/app.php
@@ -0,0 +1,233 @@
+ '6.0.2',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Update Source
+ |--------------------------------------------------------------------------
+ |
+ | Where to get information of new versions.
+ |
+ */
+ 'update_source' => env(
+ 'UPDATE_SOURCE',
+ 'https://dev.azure.com/blessing-skin/51010f6d-9f99-40f1-a262-0a67f788df32/_apis/git/'.
+ 'repositories/a9ff8df7-6dc3-4ff8-bb22-4871d3a43936/Items?path=%2Fupdate.json'
+ ),
+
+ 'name' => env('APP_NAME', 'blessing_skin'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Application Environment
+ |--------------------------------------------------------------------------
+ |
+ | This value determines the "environment" your application is currently
+ | running in. This may determine how you prefer to configure various
+ | services the application utilizes. Set this in your ".env" file.
+ |
+ */
+
+ 'env' => env('APP_ENV', 'production'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Application Debug Mode
+ |--------------------------------------------------------------------------
+ |
+ | When your application is in debug mode, detailed error messages with
+ | stack traces will be shown on every error that occurs within your
+ | application. If disabled, a simple generic error page is shown.
+ |
+ */
+
+ 'debug' => (bool) env('APP_DEBUG', false),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Application URL
+ |--------------------------------------------------------------------------
+ |
+ | This URL is used by the console to properly generate URLs when using
+ | the Artisan command line tool. You should set this to the root of
+ | your application so that it is used when running Artisan tasks.
+ |
+ */
+
+ 'url' => env('APP_URL', 'http://localhost'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Application Timezone
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify the default timezone for your application, which
+ | will be used by the PHP date and date-time functions. We have gone
+ | ahead and set this to a sensible default for you out of the box.
+ |
+ */
+
+ 'timezone' => 'Asia/Shanghai',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Application Locale Configuration
+ |--------------------------------------------------------------------------
+ |
+ | The application locale determines the default locale that will be used
+ | by the translation service provider. You are free to set this value
+ | to any of the locales which will be supported by the application.
+ |
+ */
+
+ 'locale' => 'zh_CN',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Application Fallback Locale
+ |--------------------------------------------------------------------------
+ |
+ | The fallback locale determines the locale to use when the current one
+ | is not available. You may change the value to correspond to any of
+ | the language folders that are provided through your application.
+ |
+ */
+
+ 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Faker Locale
+ |--------------------------------------------------------------------------
+ |
+ | This locale will be used by the Faker PHP library when generating fake
+ | data for your database seeds. For example, this will be used to get
+ | localized telephone numbers, street address information and more.
+ |
+ */
+
+ 'faker_locale' => 'en_US',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Encryption Key
+ |--------------------------------------------------------------------------
+ |
+ | This key is used by the Illuminate encrypter service and should be set
+ | to a random, 32 character string, otherwise these encrypted strings
+ | will not be safe. Please do this before deploying an application!
+ |
+ */
+
+ 'key' => env('APP_KEY'),
+
+ 'cipher' => 'AES-256-CBC',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Autoloaded Service Providers
+ |--------------------------------------------------------------------------
+ |
+ | The service providers listed here will be automatically loaded on the
+ | request to your application. Feel free to add your own services to
+ | this array to grant expanded functionality to your applications.
+ |
+ */
+
+ 'providers' => [
+
+ /*
+ * Laravel Framework Service Providers...
+ */
+ Illuminate\Auth\AuthServiceProvider::class,
+ Illuminate\Bus\BusServiceProvider::class,
+ Illuminate\Cache\CacheServiceProvider::class,
+ Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
+ Illuminate\Cookie\CookieServiceProvider::class,
+ Illuminate\Database\DatabaseServiceProvider::class,
+ Illuminate\Encryption\EncryptionServiceProvider::class,
+ Illuminate\Filesystem\FilesystemServiceProvider::class,
+ Illuminate\Foundation\Providers\FoundationServiceProvider::class,
+ Illuminate\Hashing\HashServiceProvider::class,
+ Illuminate\Mail\MailServiceProvider::class,
+ Illuminate\Notifications\NotificationServiceProvider::class,
+ Illuminate\Pagination\PaginationServiceProvider::class,
+ Illuminate\Queue\QueueServiceProvider::class,
+ Illuminate\Redis\RedisServiceProvider::class,
+ Illuminate\Session\SessionServiceProvider::class,
+ Illuminate\Validation\ValidationServiceProvider::class,
+ Illuminate\View\ViewServiceProvider::class,
+
+ /*
+ * Application Service Providers...
+ */
+ App\Providers\AppServiceProvider::class,
+ App\Providers\AuthServiceProvider::class,
+ App\Providers\EventServiceProvider::class,
+ App\Providers\PluginServiceProvider::class,
+ App\Providers\RouteServiceProvider::class,
+ App\Providers\ViewServiceProvider::class,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Class Aliases
+ |--------------------------------------------------------------------------
+ |
+ | This array of class aliases will be registered when this application
+ | is started. However, feel free to register as many as you wish as
+ | the aliases are "lazy" loaded so they don't hinder performance.
+ |
+ */
+
+ 'aliases' => [
+
+ 'App' => Illuminate\Support\Facades\App::class,
+ 'Artisan' => Illuminate\Support\Facades\Artisan::class,
+ 'Auth' => Illuminate\Support\Facades\Auth::class,
+ 'Blade' => Illuminate\Support\Facades\Blade::class,
+ 'Cache' => Illuminate\Support\Facades\Cache::class,
+ 'Config' => Illuminate\Support\Facades\Config::class,
+ 'Cookie' => Illuminate\Support\Facades\Cookie::class,
+ 'Crypt' => Illuminate\Support\Facades\Crypt::class,
+ 'DB' => Illuminate\Support\Facades\DB::class,
+ 'Eloquent' => Illuminate\Database\Eloquent\Model::class,
+ 'Event' => Illuminate\Support\Facades\Event::class,
+ 'File' => Illuminate\Support\Facades\File::class,
+ 'Gate' => Illuminate\Support\Facades\Gate::class,
+ 'Hash' => Illuminate\Support\Facades\Hash::class,
+ 'Http' => Illuminate\Support\Facades\Http::class,
+ 'Lang' => Illuminate\Support\Facades\Lang::class,
+ 'Log' => Illuminate\Support\Facades\Log::class,
+ 'Mail' => Illuminate\Support\Facades\Mail::class,
+ 'Notification' => Illuminate\Support\Facades\Notification::class,
+ 'Password' => Illuminate\Support\Facades\Password::class,
+ 'Queue' => Illuminate\Support\Facades\Queue::class,
+ 'Redirect' => Illuminate\Support\Facades\Redirect::class,
+ 'Request' => Illuminate\Support\Facades\Request::class,
+ 'Response' => Illuminate\Support\Facades\Response::class,
+ 'Route' => Illuminate\Support\Facades\Route::class,
+ 'Schema' => Illuminate\Support\Facades\Schema::class,
+ 'Session' => Illuminate\Support\Facades\Session::class,
+ 'Storage' => Illuminate\Support\Facades\Storage::class,
+ 'URL' => Illuminate\Support\Facades\URL::class,
+ 'Validator' => Illuminate\Support\Facades\Validator::class,
+ 'View' => Illuminate\Support\Facades\View::class,
+
+ /*
+ * Blessing Skin
+ */
+ 'Option' => App\Services\Facades\Option::class,
+ ],
+
+];
diff --git a/config/auth.php b/config/auth.php
new file mode 100755
index 0000000..19dd93c
--- /dev/null
+++ b/config/auth.php
@@ -0,0 +1,107 @@
+ [
+ 'guard' => 'web',
+ 'passwords' => 'users',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Authentication Guards
+ |--------------------------------------------------------------------------
+ |
+ | Next, you may define every authentication guard for your application.
+ | Of course, a great default configuration has been defined for you
+ | here which uses session storage and the Eloquent user provider.
+ |
+ | All authentication drivers have a user provider. This defines how the
+ | users are actually retrieved out of your database or other storage
+ | mechanisms used by this application to persist your user's data.
+ |
+ | Supported: "session", "token"
+ |
+ */
+
+ 'guards' => [
+ 'web' => [
+ 'driver' => 'session',
+ 'provider' => 'users',
+ ],
+
+ 'jwt' => [
+ 'driver' => 'jwt',
+ 'provider' => 'users',
+ ],
+
+ 'oauth' => [
+ 'driver' => 'passport',
+ 'provider' => 'users',
+ ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | User Providers
+ |--------------------------------------------------------------------------
+ |
+ | All authentication drivers have a user provider. This defines how the
+ | users are actually retrieved out of your database or other storage
+ | mechanisms used by this application to persist your user's data.
+ |
+ | If you have multiple user tables or models you may configure multiple
+ | sources which represent each model / table. These sources may then
+ | be assigned to any extra authentication guards you have defined.
+ |
+ | Supported: "database", "eloquent"
+ |
+ */
+
+ 'providers' => [
+ 'users' => [
+ 'driver' => 'eloquent',
+ 'model' => App\Models\User::class,
+ ],
+
+ // 'users' => [
+ // 'driver' => 'database',
+ // 'table' => 'users',
+ // ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Resetting Passwords
+ |--------------------------------------------------------------------------
+ |
+ | You may specify multiple password reset configurations if you have more
+ | than one user table or model in the application and you want to have
+ | separate password reset settings based on the specific user types.
+ |
+ | The expire time is the number of minutes that the reset token should be
+ | considered valid. This security feature keeps tokens short-lived so
+ | they have less time to be guessed. You may change this as needed.
+ |
+ */
+
+ 'passwords' => [
+ 'users' => [
+ 'provider' => 'users',
+ 'table' => 'password_resets',
+ 'expire' => 60,
+ ],
+ ],
+
+];
diff --git a/config/broadcasting.php b/config/broadcasting.php
new file mode 100755
index 0000000..3bba110
--- /dev/null
+++ b/config/broadcasting.php
@@ -0,0 +1,59 @@
+ env('BROADCAST_DRIVER', 'null'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Broadcast Connections
+ |--------------------------------------------------------------------------
+ |
+ | Here you may define all of the broadcast connections that will be used
+ | to broadcast events to other systems or over websockets. Samples of
+ | each available type of connection are provided inside this array.
+ |
+ */
+
+ 'connections' => [
+
+ 'pusher' => [
+ 'driver' => 'pusher',
+ 'key' => env('PUSHER_APP_KEY'),
+ 'secret' => env('PUSHER_APP_SECRET'),
+ 'app_id' => env('PUSHER_APP_ID'),
+ 'options' => [
+ 'cluster' => env('PUSHER_APP_CLUSTER'),
+ 'useTLS' => true,
+ ],
+ ],
+
+ 'redis' => [
+ 'driver' => 'redis',
+ 'connection' => 'default',
+ ],
+
+ 'log' => [
+ 'driver' => 'log',
+ ],
+
+ 'null' => [
+ 'driver' => 'null',
+ ],
+
+ ],
+
+];
diff --git a/config/cache.php b/config/cache.php
new file mode 100755
index 0000000..34f171d
--- /dev/null
+++ b/config/cache.php
@@ -0,0 +1,104 @@
+ env('CACHE_DRIVER', 'file'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Cache Stores
+ |--------------------------------------------------------------------------
+ |
+ | Here you may define all of the cache "stores" for your application as
+ | well as their drivers. You may even define multiple stores for the
+ | same cache driver to group types of items stored in your caches.
+ |
+ */
+
+ 'stores' => [
+
+ 'apc' => [
+ 'driver' => 'apc',
+ ],
+
+ 'array' => [
+ 'driver' => 'array',
+ 'serialize' => false,
+ ],
+
+ 'database' => [
+ 'driver' => 'database',
+ 'table' => 'cache',
+ 'connection' => null,
+ ],
+
+ 'file' => [
+ 'driver' => 'file',
+ 'path' => storage_path('framework/cache/data'),
+ ],
+
+ 'memcached' => [
+ 'driver' => 'memcached',
+ 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
+ 'sasl' => [
+ env('MEMCACHED_USERNAME'),
+ env('MEMCACHED_PASSWORD'),
+ ],
+ 'options' => [
+ // Memcached::OPT_CONNECT_TIMEOUT => 2000,
+ ],
+ 'servers' => [
+ [
+ 'host' => env('MEMCACHED_HOST', '127.0.0.1'),
+ 'port' => env('MEMCACHED_PORT', 11211),
+ 'weight' => 100,
+ ],
+ ],
+ ],
+
+ 'redis' => [
+ 'driver' => 'redis',
+ 'connection' => 'cache',
+ ],
+
+ 'dynamodb' => [
+ 'driver' => 'dynamodb',
+ 'key' => env('AWS_ACCESS_KEY_ID'),
+ 'secret' => env('AWS_SECRET_ACCESS_KEY'),
+ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
+ 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
+ 'endpoint' => env('DYNAMODB_ENDPOINT'),
+ ],
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Cache Key Prefix
+ |--------------------------------------------------------------------------
+ |
+ | When utilizing a RAM based store such as APC or Memcached, there might
+ | be other applications utilizing the same cache. So, we'll specify a
+ | value to get prefixed to all our keys so we can avoid collisions.
+ |
+ */
+
+ 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'blessing_skin'), '_').'_cache'),
+
+];
diff --git a/config/database.php b/config/database.php
new file mode 100755
index 0000000..f46b37b
--- /dev/null
+++ b/config/database.php
@@ -0,0 +1,147 @@
+ env('DB_CONNECTION', 'mysql'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Database Connections
+ |--------------------------------------------------------------------------
+ |
+ | Here are each of the database connections setup for your application.
+ | Of course, examples of configuring each database platform that is
+ | supported by Laravel is shown below to make development simple.
+ |
+ |
+ | All database work in Laravel is done through the PHP PDO facilities
+ | so make sure you have the driver for your particular database of
+ | choice installed on your machine before you begin development.
+ |
+ */
+
+ 'connections' => [
+
+ 'sqlite' => [
+ 'driver' => 'sqlite',
+ 'url' => env('DATABASE_URL'),
+ 'database' => env('DB_DATABASE', database_path('database.sqlite')),
+ 'prefix' => env('DB_PREFIX', ''),
+ 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
+ ],
+
+ 'mysql' => [
+ 'driver' => 'mysql',
+ 'url' => env('DATABASE_URL'),
+ 'host' => env('DB_HOST', '127.0.0.1'),
+ 'port' => env('DB_PORT', '3306'),
+ 'database' => env('DB_DATABASE', 'forge'),
+ 'username' => env('DB_USERNAME', 'forge'),
+ 'password' => env('DB_PASSWORD', ''),
+ 'unix_socket' => env('DB_SOCKET', ''),
+ 'charset' => 'utf8mb4',
+ 'collation' => 'utf8mb4_unicode_ci',
+ 'prefix' => env('DB_PREFIX', ''),
+ 'prefix_indexes' => true,
+ 'strict' => false,
+ 'engine' => null,
+ 'options' => extension_loaded('pdo_mysql') ? array_filter([
+ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
+ ]) : [],
+ ],
+
+ 'pgsql' => [
+ 'driver' => 'pgsql',
+ 'url' => env('DATABASE_URL'),
+ 'host' => env('DB_HOST', '127.0.0.1'),
+ 'port' => env('DB_PORT', '5432'),
+ 'database' => env('DB_DATABASE', 'forge'),
+ 'username' => env('DB_USERNAME', 'forge'),
+ 'password' => env('DB_PASSWORD', ''),
+ 'charset' => 'utf8',
+ 'prefix' => env('DB_PREFIX', ''),
+ 'prefix_indexes' => true,
+ 'schema' => 'public',
+ 'sslmode' => 'prefer',
+ ],
+
+ 'sqlsrv' => [
+ 'driver' => 'sqlsrv',
+ 'url' => env('DATABASE_URL'),
+ 'host' => env('DB_HOST', 'localhost'),
+ 'port' => env('DB_PORT', '1433'),
+ 'database' => env('DB_DATABASE', 'forge'),
+ 'username' => env('DB_USERNAME', 'forge'),
+ 'password' => env('DB_PASSWORD', ''),
+ 'charset' => 'utf8',
+ 'prefix' => env('DB_PREFIX', ''),
+ 'prefix_indexes' => true,
+ ],
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Migration Repository Table
+ |--------------------------------------------------------------------------
+ |
+ | This table keeps track of all the migrations that have already run for
+ | your application. Using this information, we can determine which of
+ | the migrations on disk haven't actually been run in the database.
+ |
+ */
+
+ 'migrations' => 'migrations',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Redis Databases
+ |--------------------------------------------------------------------------
+ |
+ | Redis is an open source, fast, and advanced key-value store that also
+ | provides a richer body of commands than a typical key-value system
+ | such as APC or Memcached. Laravel makes it easy to dig right in.
+ |
+ */
+
+ 'redis' => [
+
+ 'client' => env('REDIS_CLIENT', 'phpredis'),
+
+ 'options' => [
+ 'cluster' => env('REDIS_CLUSTER', 'redis'),
+ 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'blessing_skin'), '_').'_database_'),
+ ],
+
+ 'default' => [
+ 'url' => env('REDIS_URL'),
+ 'host' => env('REDIS_HOST', '127.0.0.1'),
+ 'password' => env('REDIS_PASSWORD', null),
+ 'port' => env('REDIS_PORT', '6379'),
+ 'database' => env('REDIS_DB', '0'),
+ ],
+
+ 'cache' => [
+ 'url' => env('REDIS_URL'),
+ 'host' => env('REDIS_HOST', '127.0.0.1'),
+ 'password' => env('REDIS_PASSWORD', null),
+ 'port' => env('REDIS_PORT', '6379'),
+ 'database' => env('REDIS_CACHE_DB', '1'),
+ ],
+
+ ],
+
+];
diff --git a/config/debugbar.php b/config/debugbar.php
new file mode 100755
index 0000000..5e080d2
--- /dev/null
+++ b/config/debugbar.php
@@ -0,0 +1,201 @@
+ env('DEBUGBAR_ENABLED', null),
+ 'except' => [
+ //
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Storage settings
+ |--------------------------------------------------------------------------
+ |
+ | DebugBar stores data for session/ajax requests.
+ | You can disable this, so the debugbar stores data in headers/session,
+ | but this can cause problems with large data collectors.
+ | By default, file storage (in the storage folder) is used. Redis and PDO
+ | can also be used. For PDO, run the package migrations first.
+ |
+ */
+ 'storage' => [
+ 'enabled' => true,
+ 'driver' => 'file', // redis, file, pdo, custom
+ 'path' => storage_path('debugbar'), // For file driver
+ 'connection' => null, // Leave null for default connection (Redis/PDO)
+ 'provider' => '', // Instance of StorageInterface for custom driver
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Vendors
+ |--------------------------------------------------------------------------
+ |
+ | Vendor files are included by default, but can be set to false.
+ | This can also be set to 'js' or 'css', to only include javascript or css vendor files.
+ | Vendor files are for css: font-awesome (including fonts) and highlight.js (css files)
+ | and for js: jquery and and highlight.js
+ | So if you want syntax highlighting, set it to true.
+ | jQuery is set to not conflict with existing jQuery scripts.
+ |
+ */
+
+ 'include_vendors' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Capture Ajax Requests
+ |--------------------------------------------------------------------------
+ |
+ | The Debugbar can capture Ajax requests and display them. If you don't want this (ie. because of errors),
+ | you can use this option to disable sending the data through the headers.
+ |
+ | Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools.
+ */
+
+ 'capture_ajax' => true,
+ 'add_ajax_timing' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Custom Error Handler for Deprecated warnings
+ |--------------------------------------------------------------------------
+ |
+ | When enabled, the Debugbar shows deprecated warnings for Symfony components
+ | in the Messages tab.
+ |
+ */
+ 'error_handler' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Clockwork integration
+ |--------------------------------------------------------------------------
+ |
+ | The Debugbar can emulate the Clockwork headers, so you can use the Chrome
+ | Extension, without the server-side code. It uses Debugbar collectors instead.
+ |
+ */
+ 'clockwork' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | DataCollectors
+ |--------------------------------------------------------------------------
+ |
+ | Enable/disable DataCollectors
+ |
+ */
+
+ 'collectors' => [
+ 'phpinfo' => true, // Php version
+ 'messages' => true, // Messages
+ 'time' => true, // Time Datalogger
+ 'memory' => true, // Memory usage
+ 'exceptions' => true, // Exception displayer
+ 'log' => true, // Logs from Monolog (merged in messages if enabled)
+ 'db' => true, // Show database (PDO) queries and bindings
+ 'views' => true, // Views with their data
+ 'route' => true, // Current route information
+ 'auth' => true, // Display Laravel authentication status
+ 'gate' => false, // Display Laravel Gate checks
+ 'session' => true, // Display session data
+ 'symfony_request' => true, // Only one can be enabled..
+ 'mail' => false, // Catch mail messages
+ 'laravel' => false, // Laravel version and environment
+ 'events' => true, // All events fired
+ 'default_request' => false, // Regular or special Symfony request logger
+ 'logs' => false, // Add the latest log messages
+ 'files' => false, // Show the included files
+ 'config' => false, // Display config settings
+ 'cache' => false, // Display cache events
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Extra options
+ |--------------------------------------------------------------------------
+ |
+ | Configure some DataCollectors
+ |
+ */
+
+ 'options' => [
+ 'auth' => [
+ 'show_name' => true, // Also show the users name/email in the debugbar
+ ],
+ 'db' => [
+ 'with_params' => true, // Render SQL with the parameters substituted
+ 'backtrace' => true, // Use a backtrace to find the origin of the query in your files.
+ 'timeline' => false, // Add the queries to the timeline
+ 'explain' => [ // Show EXPLAIN output on queries
+ 'enabled' => false,
+ 'types' => ['SELECT'], // ['SELECT', 'INSERT', 'UPDATE', 'DELETE']; for MySQL 5.6.3+
+ ],
+ 'hints' => true, // Show hints for common mistakes
+ ],
+ 'mail' => [
+ 'full_log' => false,
+ ],
+ 'views' => [
+ 'data' => false, //Note: Can slow down the application, because the data can be quite large..
+ ],
+ 'route' => [
+ 'label' => true, // show complete route on bar
+ ],
+ 'logs' => [
+ 'file' => null,
+ ],
+ 'cache' => [
+ 'values' => true, // collect cache values
+ ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Inject Debugbar in Response
+ |--------------------------------------------------------------------------
+ |
+ | Usually, the debugbar is added just before
+
+ 请将 Web 根目录设为当前目录下的 public 目录。
+
+
+ Please define your web server root directory as the "public" directory under current directory.
+
+
+, by listening to the
+ | Response after the App is done. If you disable this, you have to add them
+ | in your template yourself. See http://phpdebugbar.com/docs/rendering.html
+ |
+ */
+
+ 'inject' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | DebugBar route prefix
+ |--------------------------------------------------------------------------
+ |
+ | Sometimes you want to set route prefix to be used by DebugBar to load
+ | its resources from. Usually the need comes from misconfigured web server or
+ | from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97
+ |
+ */
+ 'route_prefix' => '_debugbar',
+
+ /*
+ |--------------------------------------------------------------------------
+ | DebugBar route domain
+ |--------------------------------------------------------------------------
+ |
+ | By default DebugBar route served from the same domain that request served.
+ | To override default domain, specify it as a non-empty value.
+ */
+ 'route_domain' => null,
+];
diff --git a/config/filesystems.php b/config/filesystems.php
new file mode 100755
index 0000000..158a769
--- /dev/null
+++ b/config/filesystems.php
@@ -0,0 +1,67 @@
+ env('FILESYSTEM_DRIVER', 'local'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Default Cloud Filesystem Disk
+ |--------------------------------------------------------------------------
+ |
+ | Many applications store files both locally and in the cloud. For this
+ | reason, you may specify a default "cloud" driver here. This driver
+ | will be bound as the Cloud disk implementation in the container.
+ |
+ */
+
+ 'cloud' => env('FILESYSTEM_CLOUD', 's3'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Filesystem Disks
+ |--------------------------------------------------------------------------
+ |
+ | Here you may configure as many filesystem "disks" as you wish, and you
+ | may even configure multiple disks of the same driver. Defaults have
+ | been setup for each driver as an example of the required options.
+ |
+ | Supported Drivers: "local", "ftp", "sftp", "s3"
+ |
+ */
+
+ 'disks' => [
+
+ 'local' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app'),
+ ],
+
+ 'root' => [
+ 'driver' => 'local',
+ 'root' => base_path(),
+ ],
+
+ 'textures' => [
+ 'driver' => env('FS_DRIVER', 'local'),
+ 'root' => env('TEXTURES_DIR', storage_path('textures')),
+ ],
+
+ 'testing' => [
+ 'driver' => 'memory',
+ ],
+
+ ],
+
+];
diff --git a/config/hashing.php b/config/hashing.php
new file mode 100755
index 0000000..8425770
--- /dev/null
+++ b/config/hashing.php
@@ -0,0 +1,52 @@
+ 'bcrypt',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Bcrypt Options
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify the configuration options that should be used when
+ | passwords are hashed using the Bcrypt algorithm. This will allow you
+ | to control the amount of time it takes to hash the given password.
+ |
+ */
+
+ 'bcrypt' => [
+ 'rounds' => env('BCRYPT_ROUNDS', 10),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Argon Options
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify the configuration options that should be used when
+ | passwords are hashed using the Argon algorithm. These will allow you
+ | to control the amount of time it takes to hash the given password.
+ |
+ */
+
+ 'argon' => [
+ 'memory' => 1024,
+ 'threads' => 2,
+ 'time' => 2,
+ ],
+
+];
diff --git a/config/jwt.php b/config/jwt.php
new file mode 100755
index 0000000..900ce98
--- /dev/null
+++ b/config/jwt.php
@@ -0,0 +1,304 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+return [
+
+ /*
+ |--------------------------------------------------------------------------
+ | JWT Authentication Secret
+ |--------------------------------------------------------------------------
+ |
+ | Don't forget to set this in your .env file, as it will be used to sign
+ | your tokens. A helper command is provided for this:
+ | `php artisan jwt:secret`
+ |
+ | Note: This will be used for Symmetric algorithms only (HMAC),
+ | since RSA and ECDSA use a private/public key combo (See below).
+ |
+ */
+
+ 'secret' => env('JWT_SECRET'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | JWT Authentication Keys
+ |--------------------------------------------------------------------------
+ |
+ | The algorithm you are using, will determine whether your tokens are
+ | signed with a random string (defined in `JWT_SECRET`) or using the
+ | following public & private keys.
+ |
+ | Symmetric Algorithms:
+ | HS256, HS384 & HS512 will use `JWT_SECRET`.
+ |
+ | Asymmetric Algorithms:
+ | RS256, RS384 & RS512 / ES256, ES384 & ES512 will use the keys below.
+ |
+ */
+
+ 'keys' => [
+
+ /*
+ |--------------------------------------------------------------------------
+ | Public Key
+ |--------------------------------------------------------------------------
+ |
+ | A path or resource to your public key.
+ |
+ | E.g. 'file://path/to/public/key'
+ |
+ */
+
+ 'public' => env('JWT_PUBLIC_KEY'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Private Key
+ |--------------------------------------------------------------------------
+ |
+ | A path or resource to your private key.
+ |
+ | E.g. 'file://path/to/private/key'
+ |
+ */
+
+ 'private' => env('JWT_PRIVATE_KEY'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Passphrase
+ |--------------------------------------------------------------------------
+ |
+ | The passphrase for your private key. Can be null if none set.
+ |
+ */
+
+ 'passphrase' => env('JWT_PASSPHRASE'),
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | JWT time to live
+ |--------------------------------------------------------------------------
+ |
+ | Specify the length of time (in minutes) that the token will be valid for.
+ | Defaults to 1 hour.
+ |
+ | You can also set this to null, to yield a never expiring token.
+ | Some people may want this behaviour for e.g. a mobile app.
+ | This is not particularly recommended, so make sure you have appropriate
+ | systems in place to revoke the token if necessary.
+ | Notice: If you set this to null you should remove 'exp' element from 'required_claims' list.
+ |
+ */
+
+ 'ttl' => env('JWT_TTL', 60),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Refresh time to live
+ |--------------------------------------------------------------------------
+ |
+ | Specify the length of time (in minutes) that the token can be refreshed
+ | within. I.E. The user can refresh their token within a 2 week window of
+ | the original token being created until they must re-authenticate.
+ | Defaults to 2 weeks.
+ |
+ | You can also set this to null, to yield an infinite refresh time.
+ | Some may want this instead of never expiring tokens for e.g. a mobile app.
+ | This is not particularly recommended, so make sure you have appropriate
+ | systems in place to revoke the token if necessary.
+ |
+ */
+
+ 'refresh_ttl' => env('JWT_REFRESH_TTL', 20160),
+
+ /*
+ |--------------------------------------------------------------------------
+ | JWT hashing algorithm
+ |--------------------------------------------------------------------------
+ |
+ | Specify the hashing algorithm that will be used to sign the token.
+ |
+ | See here: https://github.com/namshi/jose/tree/master/src/Namshi/JOSE/Signer/OpenSSL
+ | for possible values.
+ |
+ */
+
+ 'algo' => env('JWT_ALGO', 'HS256'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Required Claims
+ |--------------------------------------------------------------------------
+ |
+ | Specify the required claims that must exist in any token.
+ | A TokenInvalidException will be thrown if any of these claims are not
+ | present in the payload.
+ |
+ */
+
+ 'required_claims' => [
+ 'iss',
+ 'iat',
+ 'exp',
+ 'nbf',
+ 'sub',
+ 'jti',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Persistent Claims
+ |--------------------------------------------------------------------------
+ |
+ | Specify the claim keys to be persisted when refreshing a token.
+ | `sub` and `iat` will automatically be persisted, in
+ | addition to the these claims.
+ |
+ | Note: If a claim does not exist then it will be ignored.
+ |
+ */
+
+ 'persistent_claims' => [
+ // 'foo',
+ // 'bar',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Lock Subject
+ |--------------------------------------------------------------------------
+ |
+ | This will determine whether a `prv` claim is automatically added to
+ | the token. The purpose of this is to ensure that if you have multiple
+ | authentication models e.g. `App\User` & `App\OtherPerson`, then we
+ | should prevent one authentication request from impersonating another,
+ | if 2 tokens happen to have the same id across the 2 different models.
+ |
+ | Under specific circumstances, you may want to disable this behaviour
+ | e.g. if you only have one authentication model, then you would save
+ | a little on token size.
+ |
+ */
+
+ 'lock_subject' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Leeway
+ |--------------------------------------------------------------------------
+ |
+ | This property gives the jwt timestamp claims some "leeway".
+ | Meaning that if you have any unavoidable slight clock skew on
+ | any of your servers then this will afford you some level of cushioning.
+ |
+ | This applies to the claims `iat`, `nbf` and `exp`.
+ |
+ | Specify in seconds - only if you know you need it.
+ |
+ */
+
+ 'leeway' => env('JWT_LEEWAY', 0),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Blacklist Enabled
+ |--------------------------------------------------------------------------
+ |
+ | In order to invalidate tokens, you must have the blacklist enabled.
+ | If you do not want or need this functionality, then set this to false.
+ |
+ */
+
+ 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),
+
+ /*
+ | -------------------------------------------------------------------------
+ | Blacklist Grace Period
+ | -------------------------------------------------------------------------
+ |
+ | When multiple concurrent requests are made with the same JWT,
+ | it is possible that some of them fail, due to token regeneration
+ | on every request.
+ |
+ | Set grace period in seconds to prevent parallel request failure.
+ |
+ */
+
+ 'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Cookies encryption
+ |--------------------------------------------------------------------------
+ |
+ | By default Laravel encrypt cookies for security reason.
+ | If you decide to not decrypt cookies, you will have to configure Laravel
+ | to not encrypt your cookie token by adding its name into the $except
+ | array available in the middleware "EncryptCookies" provided by Laravel.
+ | see https://laravel.com/docs/master/responses#cookies-and-encryption
+ | for details.
+ |
+ | Set it to true if you want to decrypt cookies.
+ |
+ */
+
+ 'decrypt_cookies' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Providers
+ |--------------------------------------------------------------------------
+ |
+ | Specify the various providers used throughout the package.
+ |
+ */
+
+ 'providers' => [
+
+ /*
+ |--------------------------------------------------------------------------
+ | JWT Provider
+ |--------------------------------------------------------------------------
+ |
+ | Specify the provider that is used to create and decode the tokens.
+ |
+ */
+
+ 'jwt' => Tymon\JWTAuth\Providers\JWT\Namshi::class,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Authentication Provider
+ |--------------------------------------------------------------------------
+ |
+ | Specify the provider that is used to authenticate users.
+ |
+ */
+
+ 'auth' => Tymon\JWTAuth\Providers\Auth\Illuminate::class,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Storage Provider
+ |--------------------------------------------------------------------------
+ |
+ | Specify the provider that is used to store tokens in the blacklist.
+ |
+ */
+
+ 'storage' => Tymon\JWTAuth\Providers\Storage\Illuminate::class,
+
+ ],
+
+];
diff --git a/config/locales.php b/config/locales.php
new file mode 100755
index 0000000..5a77789
--- /dev/null
+++ b/config/locales.php
@@ -0,0 +1,41 @@
+ [
+ 'name' => '中文 (简体)',
+ 'short_name' => 'zh-CN',
+ ],
+ 'zh_HANS_CN' => [
+ 'alias' => 'zh_CN',
+ ],
+ 'zh_TW' => [
+ 'name' => '中文 (正體)',
+ 'short_name' => 'zh-TW',
+ ],
+ 'en' => [
+ 'name' => 'English',
+ 'short_name' => 'en',
+ ],
+ 'en_US' => [
+ 'alias' => 'en',
+ ],
+ 'es_ES' => [
+ 'name' => 'Español',
+ 'short_name' => 'es'
+ ],
+ 'ru' => [
+ 'alias' => 'ru_RU',
+ ],
+ 'ru_RU' => [
+ 'name' => 'Русский язык',
+ 'short_name' => 'ru',
+ ],
+];
diff --git a/config/logging.php b/config/logging.php
new file mode 100755
index 0000000..1f06d5e
--- /dev/null
+++ b/config/logging.php
@@ -0,0 +1,104 @@
+ env('LOG_CHANNEL', 'daily'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Log Channels
+ |--------------------------------------------------------------------------
+ |
+ | Here you may configure the log channels for your application. Out of
+ | the box, Laravel uses the Monolog PHP logging library. This gives
+ | you a variety of powerful log handlers / formatters to utilize.
+ |
+ | Available Drivers: "single", "daily", "slack", "syslog",
+ | "errorlog", "monolog",
+ | "custom", "stack"
+ |
+ */
+
+ 'channels' => [
+ 'stack' => [
+ 'driver' => 'stack',
+ 'channels' => ['single'],
+ 'ignore_exceptions' => false,
+ ],
+
+ 'single' => [
+ 'driver' => 'single',
+ 'path' => storage_path('logs/laravel.log'),
+ 'level' => 'debug',
+ ],
+
+ 'daily' => [
+ 'driver' => 'daily',
+ 'path' => storage_path('logs/laravel.log'),
+ 'level' => 'debug',
+ 'days' => 14,
+ ],
+
+ 'slack' => [
+ 'driver' => 'slack',
+ 'url' => env('LOG_SLACK_WEBHOOK_URL'),
+ 'username' => 'Laravel Log',
+ 'emoji' => ':boom:',
+ 'level' => 'critical',
+ ],
+
+ 'papertrail' => [
+ 'driver' => 'monolog',
+ 'level' => 'debug',
+ 'handler' => SyslogUdpHandler::class,
+ 'handler_with' => [
+ 'host' => env('PAPERTRAIL_URL'),
+ 'port' => env('PAPERTRAIL_PORT'),
+ ],
+ ],
+
+ 'stderr' => [
+ 'driver' => 'monolog',
+ 'handler' => StreamHandler::class,
+ 'formatter' => env('LOG_STDERR_FORMATTER'),
+ 'with' => [
+ 'stream' => 'php://stderr',
+ ],
+ ],
+
+ 'syslog' => [
+ 'driver' => 'syslog',
+ 'level' => 'debug',
+ ],
+
+ 'errorlog' => [
+ 'driver' => 'errorlog',
+ 'level' => 'debug',
+ ],
+
+ 'null' => [
+ 'driver' => 'monolog',
+ 'handler' => NullHandler::class,
+ ],
+
+ 'emergency' => [
+ 'path' => storage_path('logs/laravel.log'),
+ ],
+ ],
+
+];
diff --git a/config/mail.php b/config/mail.php
new file mode 100755
index 0000000..cfef410
--- /dev/null
+++ b/config/mail.php
@@ -0,0 +1,108 @@
+ env('MAIL_MAILER', 'smtp'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Mailer Configurations
+ |--------------------------------------------------------------------------
+ |
+ | Here you may configure all of the mailers used by your application plus
+ | their respective settings. Several examples have been configured for
+ | you and you are free to add your own as your application requires.
+ |
+ | Laravel supports a variety of mail "transport" drivers to be used while
+ | sending an e-mail. You will specify which one you are using for your
+ | mailers below. You are free to add additional mailers as required.
+ |
+ | Supported: "smtp", "sendmail", "mailgun", "ses",
+ | "postmark", "log", "array"
+ |
+ */
+
+ 'mailers' => [
+ 'smtp' => [
+ 'transport' => 'smtp',
+ 'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
+ 'port' => env('MAIL_PORT', 587),
+ 'encryption' => env('MAIL_ENCRYPTION', 'tls'),
+ 'username' => env('MAIL_USERNAME'),
+ 'password' => env('MAIL_PASSWORD'),
+ ],
+
+ 'ses' => [
+ 'transport' => 'ses',
+ ],
+
+ 'mailgun' => [
+ 'transport' => 'mailgun',
+ ],
+
+ 'postmark' => [
+ 'transport' => 'postmark',
+ ],
+
+ 'sendmail' => [
+ 'transport' => 'sendmail',
+ 'path' => '/usr/sbin/sendmail -bs',
+ ],
+
+ 'log' => [
+ 'transport' => 'log',
+ 'channel' => env('MAIL_LOG_CHANNEL'),
+ ],
+
+ 'array' => [
+ 'transport' => 'array',
+ ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Global "From" Address
+ |--------------------------------------------------------------------------
+ |
+ | You may wish for all e-mails sent by your application to be sent from
+ | the same address. Here, you may specify a name and address that is
+ | used globally for all e-mails that are sent by your application.
+ |
+ */
+
+ 'from' => [
+ 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
+ 'name' => env('MAIL_FROM_NAME', 'Example'),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Markdown Mail Settings
+ |--------------------------------------------------------------------------
+ |
+ | If you are using Markdown based email rendering, you may configure your
+ | theme and component paths here, allowing you to customize the design
+ | of the emails. Or, you may simply stick with the Laravel defaults!
+ |
+ */
+
+ 'markdown' => [
+ 'theme' => 'default',
+
+ 'paths' => [
+ resource_path('views/vendor/mail'),
+ ],
+ ],
+
+];
diff --git a/config/menu.php b/config/menu.php
new file mode 100755
index 0000000..38b4fe1
--- /dev/null
+++ b/config/menu.php
@@ -0,0 +1,47 @@
+ 'general.dashboard', 'link' => 'user', 'icon' => 'fa-tachometer-alt'],
+ ['title' => 'general.my-closet', 'link' => 'user/closet', 'icon' => 'fa-star'],
+ ['title' => 'general.player-manage', 'link' => 'user/player', 'icon' => 'fa-users'],
+ ['title' => 'general.my-reports', 'link' => 'user/reports', 'icon' => 'fa-flag'],
+ ['title' => 'general.profile', 'link' => 'user/profile', 'icon' => 'fa-user'],
+ [
+ 'title' => 'general.developer',
+ 'icon' => 'fa-code-branch',
+ 'children' => [
+ ['title' => 'general.oauth-manage', 'link' => 'user/oauth/manage', 'icon' => 'fa-feather-alt'],
+ ],
+ ],
+];
+
+$menu['admin'] = [
+ ['title' => 'general.dashboard', 'link' => 'admin', 'icon' => 'fa-tachometer-alt'],
+ ['title' => 'general.user-manage', 'link' => 'admin/users', 'icon' => 'fa-users'],
+ ['title' => 'general.player-manage', 'link' => 'admin/players', 'icon' => 'fa-gamepad'],
+ ['title' => 'general.report-manage', 'link' => 'admin/reports', 'icon' => 'fa-flag'],
+ ['title' => 'general.customize', 'link' => 'admin/customize', 'icon' => 'fa-paint-brush'],
+ ['title' => 'general.i18n', 'link' => 'admin/i18n', 'icon' => 'fa-globe'],
+ ['title' => 'general.score-options', 'link' => 'admin/score', 'icon' => 'fa-credit-card'],
+ ['title' => 'general.options', 'link' => 'admin/options', 'icon' => 'fa-cog'],
+ ['title' => 'general.res-options', 'link' => 'admin/resource', 'icon' => 'fa-atom'],
+ ['title' => 'general.status', 'link' => 'admin/status', 'icon' => 'fa-battery-three-quarters'],
+ ['title' => 'general.plugin-manage', 'link' => 'admin/plugins/manage', 'icon' => 'fa-plug'],
+ ['title' => 'general.plugin-market', 'link' => 'admin/plugins/market', 'icon' => 'fa-shopping-bag'],
+ ['title' => 'general.plugin-configs', 'id' => 'plugin-configs', 'icon' => 'fa-cogs', 'children' => []],
+ ['title' => 'general.check-update', 'link' => 'admin/update', 'icon' => 'fa-arrow-up'],
+];
+
+$menu['explore'] = [
+ ['title' => 'general.skinlib', 'link' => 'skinlib', 'icon' => 'fa-archive'],
+];
+
+return $menu;
diff --git a/config/options.php b/config/options.php
new file mode 100755
index 0000000..4002cf6
--- /dev/null
+++ b/config/options.php
@@ -0,0 +1,56 @@
+ '',
+ 'site_name' => 'Blessing Skin',
+ 'site_description' => 'Open-source PHP Minecraft Skin Hosting Service',
+ 'register_with_player_name' => 'true',
+ 'require_verification' => 'false',
+ 'regs_per_ip' => '3',
+ 'announcement' => 'Welcome to Blessing Skin {version}!',
+ 'home_pic_url' => './app/bg.webp',
+ 'custom_css' => '',
+ 'custom_js' => '',
+ 'player_name_rule' => 'official',
+ 'custom_player_name_regexp' => '',
+ 'player_name_length_min' => '3',
+ 'player_name_length_max' => '16',
+ 'user_initial_score' => '1000',
+ 'sign_gap_time' => '24',
+ 'sign_score' => '10,100',
+ 'score_per_storage' => 'true',
+ 'private_score_per_storage' => '10',
+ 'return_score' => 'true',
+ 'score_per_player' => '100',
+ 'sign_after_zero' => 'false',
+ 'version' => '',
+ 'copyright_text' => 'Copyright © {year} {site_name}. All rights reserved.',
+ 'auto_del_invalid_texture' => 'false',
+ 'allow_downloading_texture' => 'true',
+ 'texture_name_regexp' => '',
+ 'cache_expire_time' => '31536000',
+ 'max_upload_file_size' => '1024',
+ 'force_ssl' => 'false',
+ 'auto_detect_asset_url' => 'true',
+ 'plugins_enabled' => '',
+ 'copyright_prefer' => '0',
+ 'score_per_closet_item' => '0',
+ 'favicon_url' => 'app/favicon.ico',
+ 'score_award_per_texture' => '0',
+ 'take_back_scores_after_deletion' => 'true',
+ 'score_award_per_like' => '0',
+ 'meta_keywords' => '',
+ 'meta_description' => '',
+ 'meta_extras' => '',
+ 'cdn_address' => '',
+ 'recaptcha_sitekey' => '',
+ 'recaptcha_secretkey' => '',
+ 'recaptcha_invisible' => 'false',
+ 'reporter_score_modification' => '0',
+ 'reporter_reward_score' => '0',
+ 'content_policy' => '',
+ 'transparent_navbar' => 'false',
+ 'status_code_for_private' => '403',
+ 'navbar_color' => 'cyan',
+ 'sidebar_color' => 'dark-maroon',
+];
diff --git a/config/plugins.php b/config/plugins.php
new file mode 100755
index 0000000..c484114
--- /dev/null
+++ b/config/plugins.php
@@ -0,0 +1,48 @@
+ env('PLUGINS_DIR'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Plugins Assets URL
+ |--------------------------------------------------------------------------
+ |
+ | The URL to access plugin's assets (CSS, JavaScript etc.).
+ | Defaults to `http://site_url/plugins`.
+ |
+ */
+ 'url' => env('PLUGINS_URL'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Plugins Market Source
+ |--------------------------------------------------------------------------
+ |
+ | Specify where to get plugins' metadata for plugin market.
+ |
+ */
+ 'registry' => env(
+ 'PLUGINS_REGISTRY',
+ 'https://gplane.coding.net/p/blessing-skin-plugins-dist/d/blessing-skin-plugins-dist/git/raw/master/registry_{lang}.json'
+ ),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Plugins Market Localization
+ |--------------------------------------------------------------------------
+ |
+ | Supported languages of plugins market registry will be listed here.
+ |
+ */
+ 'locales' => ['en', 'zh_CN'],
+];
diff --git a/config/queue.php b/config/queue.php
new file mode 100755
index 0000000..00b76d6
--- /dev/null
+++ b/config/queue.php
@@ -0,0 +1,89 @@
+ env('QUEUE_CONNECTION', 'sync'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Queue Connections
+ |--------------------------------------------------------------------------
+ |
+ | Here you may configure the connection information for each server that
+ | is used by your application. A default configuration has been added
+ | for each back-end shipped with Laravel. You are free to add more.
+ |
+ | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
+ |
+ */
+
+ 'connections' => [
+
+ 'sync' => [
+ 'driver' => 'sync',
+ ],
+
+ 'database' => [
+ 'driver' => 'database',
+ 'table' => 'jobs',
+ 'queue' => 'default',
+ 'retry_after' => 90,
+ ],
+
+ 'beanstalkd' => [
+ 'driver' => 'beanstalkd',
+ 'host' => 'localhost',
+ 'queue' => 'default',
+ 'retry_after' => 90,
+ 'block_for' => 0,
+ ],
+
+ 'sqs' => [
+ 'driver' => 'sqs',
+ 'key' => env('AWS_ACCESS_KEY_ID'),
+ 'secret' => env('AWS_SECRET_ACCESS_KEY'),
+ 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
+ 'queue' => env('SQS_QUEUE', 'your-queue-name'),
+ 'suffix' => env('SQS_SUFFIX'),
+ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
+ ],
+
+ 'redis' => [
+ 'driver' => 'redis',
+ 'connection' => 'default',
+ 'queue' => env('REDIS_QUEUE', 'default'),
+ 'retry_after' => 90,
+ 'block_for' => null,
+ ],
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Failed Queue Jobs
+ |--------------------------------------------------------------------------
+ |
+ | These options configure the behavior of failed queue job logging so you
+ | can control which database and table are used to store the jobs that
+ | have failed. You may change them to any database / table you wish.
+ |
+ */
+
+ 'failed' => [
+ 'driver' => env('QUEUE_FAILED_DRIVER', 'database'),
+ 'database' => env('DB_CONNECTION', 'mysql'),
+ 'table' => 'failed_jobs',
+ ],
+
+];
diff --git a/config/secure.php b/config/secure.php
new file mode 100755
index 0000000..cf44729
--- /dev/null
+++ b/config/secure.php
@@ -0,0 +1,14 @@
+ env('PWD_METHOD', 'BCRYPT'),
+ 'salt' => env('SALT', ''),
+];
diff --git a/config/services.php b/config/services.php
new file mode 100755
index 0000000..2a1d616
--- /dev/null
+++ b/config/services.php
@@ -0,0 +1,33 @@
+ [
+ 'domain' => env('MAILGUN_DOMAIN'),
+ 'secret' => env('MAILGUN_SECRET'),
+ 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
+ ],
+
+ 'postmark' => [
+ 'token' => env('POSTMARK_TOKEN'),
+ ],
+
+ 'ses' => [
+ 'key' => env('AWS_ACCESS_KEY_ID'),
+ 'secret' => env('AWS_SECRET_ACCESS_KEY'),
+ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
+ ],
+
+];
diff --git a/config/session.php b/config/session.php
new file mode 100755
index 0000000..3b698da
--- /dev/null
+++ b/config/session.php
@@ -0,0 +1,194 @@
+ env('SESSION_DRIVER', 'file'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Lifetime
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify the number of minutes that you wish the session
+ | to be allowed to remain idle before it expires. If you want them
+ | to immediately expire on the browser closing, set that option.
+ |
+ */
+
+ 'lifetime' => env('SESSION_LIFETIME', 120),
+
+ 'expire_on_close' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Encryption
+ |--------------------------------------------------------------------------
+ |
+ | This option allows you to easily specify that all of your session data
+ | should be encrypted before it is stored. All encryption will be run
+ | automatically by Laravel and you can use the Session like normal.
+ |
+ */
+
+ 'encrypt' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session File Location
+ |--------------------------------------------------------------------------
+ |
+ | When using the native session driver, we need a location where session
+ | files may be stored. A default has been set for you but a different
+ | location may be specified. This is only needed for file sessions.
+ |
+ */
+
+ 'files' => storage_path('framework/sessions'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Database Connection
+ |--------------------------------------------------------------------------
+ |
+ | When using the "database" or "redis" session drivers, you may specify a
+ | connection that should be used to manage these sessions. This should
+ | correspond to a connection in your database configuration options.
+ |
+ */
+
+ 'connection' => env('SESSION_CONNECTION', null),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Database Table
+ |--------------------------------------------------------------------------
+ |
+ | When using the "database" session driver, you may specify the table we
+ | should use to manage the sessions. Of course, a sensible default is
+ | provided for you; however, you are free to change this as needed.
+ |
+ */
+
+ 'table' => 'sessions',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Cache Store
+ |--------------------------------------------------------------------------
+ |
+ | When using the "apc", "memcached", or "dynamodb" session drivers you may
+ | list a cache store that should be used for these sessions. This value
+ | must match with one of the application's configured cache "stores".
+ |
+ */
+
+ 'store' => env('SESSION_STORE', null),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Sweeping Lottery
+ |--------------------------------------------------------------------------
+ |
+ | Some session drivers must manually sweep their storage location to get
+ | rid of old sessions from storage. Here are the chances that it will
+ | happen on a given request. By default, the odds are 2 out of 100.
+ |
+ */
+
+ 'lottery' => [2, 100],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Cookie Name
+ |--------------------------------------------------------------------------
+ |
+ | Here you may change the name of the cookie used to identify a session
+ | instance by ID. The name specified here will get used every time a
+ | new session cookie is created by the framework for every driver.
+ |
+ */
+
+ 'cookie' => env('SESSION_COOKIE', 'BS_SESSION'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Cookie Path
+ |--------------------------------------------------------------------------
+ |
+ | The session cookie path determines the path for which the cookie will
+ | be regarded as available. Typically, this will be the root path of
+ | your application but you are free to change this when necessary.
+ |
+ */
+
+ 'path' => '/',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Cookie Domain
+ |--------------------------------------------------------------------------
+ |
+ | Here you may change the domain of the cookie used to identify a session
+ | in your application. This will determine which domains the cookie is
+ | available to in your application. A sensible default has been set.
+ |
+ */
+
+ 'domain' => env('SESSION_DOMAIN', null),
+
+ /*
+ |--------------------------------------------------------------------------
+ | HTTPS Only Cookies
+ |--------------------------------------------------------------------------
+ |
+ | By setting this option to true, session cookies will only be sent back
+ | to the server if the browser has a HTTPS connection. This will keep
+ | the cookie from being sent to you if it can not be done securely.
+ |
+ */
+
+ 'secure' => env('SESSION_SECURE_COOKIE'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | HTTP Access Only
+ |--------------------------------------------------------------------------
+ |
+ | Setting this value to true will prevent JavaScript from accessing the
+ | value of the cookie and the cookie will only be accessible through
+ | the HTTP protocol. You are free to modify this option if needed.
+ |
+ */
+
+ 'http_only' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Same-Site Cookies
+ |--------------------------------------------------------------------------
+ |
+ | This option determines how your cookies behave when cross-site requests
+ | take place, and can be used to mitigate CSRF attacks. By default, we
+ | do not enable this as other CSRF protection services are in place.
+ |
+ | Supported: "lax", "strict", "none"
+ |
+ */
+
+ 'same_site' => 'lax',
+
+];
diff --git a/config/translation-loader.php b/config/translation-loader.php
new file mode 100755
index 0000000..50f5b9b
--- /dev/null
+++ b/config/translation-loader.php
@@ -0,0 +1,24 @@
+ [
+ Spatie\TranslationLoader\TranslationLoaders\Db::class,
+ ],
+
+ /*
+ * This is the model used by the Db Translation loader. You can put any model here
+ * that extends Spatie\TranslationLoader\LanguageLine.
+ */
+ 'model' => Spatie\TranslationLoader\LanguageLine::class,
+
+ /*
+ * This is the translation manager which overrides the default Laravel `translation.loader`
+ */
+ 'translation_manager' => App\Services\Translations\Loader::class,
+
+];
diff --git a/config/twigbridge.php b/config/twigbridge.php
new file mode 100755
index 0000000..c3cc931
--- /dev/null
+++ b/config/twigbridge.php
@@ -0,0 +1,214 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Configuration options for Twig.
+ */
+return [
+
+ 'twig' => [
+ /*
+ |--------------------------------------------------------------------------
+ | Extension
+ |--------------------------------------------------------------------------
+ |
+ | File extension for Twig view files.
+ |
+ */
+ 'extension' => 'twig',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Accepts all Twig environment configuration options
+ |--------------------------------------------------------------------------
+ |
+ | http://twig.sensiolabs.org/doc/api.html#environment-options
+ |
+ */
+ 'environment' => [
+
+ // When set to true, the generated templates have a __toString() method
+ // that you can use to display the generated nodes.
+ // default: false
+ 'debug' => env('APP_DEBUG', false),
+
+ // The charset used by the templates.
+ // default: utf-8
+ 'charset' => 'utf-8',
+
+ // The base template class to use for generated templates.
+ // default: TwigBridge\Twig\Template
+ 'base_template_class' => 'TwigBridge\Twig\Template',
+
+ // An absolute path where to store the compiled templates, or false to disable caching. If null
+ // then the cache file path is used.
+ // default: cache file storage path
+ 'cache' => null,
+
+ // When developing with Twig, it's useful to recompile the template
+ // whenever the source code changes. If you don't provide a value
+ // for the auto_reload option, it will be determined automatically based on the debug value.
+ 'auto_reload' => true,
+
+ // If set to false, Twig will silently ignore invalid variables
+ // (variables and or attributes/methods that do not exist) and
+ // replace them with a null value. When set to true, Twig throws an exception instead.
+ // default: false
+ 'strict_variables' => false,
+
+ // If set to true, auto-escaping will be enabled by default for all templates.
+ // default: 'html'
+ 'autoescape' => 'html',
+
+ // A flag that indicates which optimizations to apply
+ // (default to -1 -- all optimizations are enabled; set it to 0 to disable)
+ 'optimizations' => -1,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Global variables
+ |--------------------------------------------------------------------------
+ |
+ | These will always be passed in and can be accessed as Twig variables.
+ | NOTE: these will be overwritten if you pass data into the view with the same key.
+ |
+ */
+ 'globals' => [],
+ ],
+
+ 'extensions' => [
+
+ /*
+ |--------------------------------------------------------------------------
+ | Extensions
+ |--------------------------------------------------------------------------
+ |
+ | Enabled extensions.
+ |
+ | `Twig\Extension\DebugExtension` is enabled automatically if twig.debug is TRUE.
+ |
+ */
+ 'enabled' => [
+ 'Twig\Extension\StringLoaderExtension',
+
+ 'TwigBridge\Extension\Loader\Facades',
+ 'TwigBridge\Extension\Loader\Filters',
+ 'TwigBridge\Extension\Loader\Functions',
+
+ 'TwigBridge\Extension\Laravel\Auth',
+ 'TwigBridge\Extension\Laravel\Config',
+ 'TwigBridge\Extension\Laravel\Dump',
+ // 'TwigBridge\Extension\Laravel\Input',
+ 'TwigBridge\Extension\Laravel\Session',
+ // 'TwigBridge\Extension\Laravel\Str',
+ 'TwigBridge\Extension\Laravel\Translator',
+ 'TwigBridge\Extension\Laravel\Url',
+ // 'TwigBridge\Extension\Laravel\Model',
+ // 'TwigBridge\Extension\Laravel\Gate',
+
+ // 'TwigBridge\Extension\Laravel\Form',
+ // 'TwigBridge\Extension\Laravel\Html',
+ // 'TwigBridge\Extension\Laravel\Legacy\Facades',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Facades
+ |--------------------------------------------------------------------------
+ |
+ | Available facades. Access like `{{ Config.get('foo.bar') }}`.
+ |
+ | Each facade can take an optional array of options. To mark the whole facade
+ | as safe you can set the option `'is_safe' => true`. Setting the facade as
+ | safe means that any HTML returned will not be escaped.
+ |
+ | It is advisable to not set the whole facade as safe and instead mark the
+ | each appropriate method as safe for security reasons. You can do that with
+ | the following syntax:
+ |
+ |
+ | 'Form' => [
+ | 'is_safe' => [
+ | 'open'
+ | ]
+ | ]
+ |
+ |
+ | The values of the `is_safe` array must match the called method on the facade
+ | in order to be marked as safe.
+ |
+ */
+ 'facades' => [],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Functions
+ |--------------------------------------------------------------------------
+ |
+ | Available functions. Access like `{{ secure_url(...) }}`.
+ |
+ | Each function can take an optional array of options. These options are
+ | passed directly to `Twig\TwigFunction`.
+ |
+ | So for example, to mark a function as safe you can do the following:
+ |
+ |
+ | 'link_to' => [
+ | 'is_safe' => ['html']
+ | ]
+ |
+ |
+ | The options array also takes a `callback` that allows you to name the
+ | function differently in your Twig templates than what it's actually called.
+ |
+ |
+ | 'link' => [
+ | 'callback' => 'link_to'
+ | ]
+ |
+ |
+ */
+ 'functions' => [],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Filters
+ |--------------------------------------------------------------------------
+ |
+ | Available filters. Access like `{{ variable|filter }}`.
+ |
+ | Each filter can take an optional array of options. These options are
+ | passed directly to `Twig\TwigFilter`.
+ |
+ | So for example, to mark a filter as safe you can do the following:
+ |
+ |
+ | 'studly_case' => [
+ | 'is_safe' => ['html']
+ | ]
+ |
+ |
+ | The options array also takes a `callback` that allows you to name the
+ | filter differently in your Twig templates than what is actually called.
+ |
+ |
+ | 'snake' => [
+ | 'callback' => 'snake_case'
+ | ]
+ |
+ |
+ */
+ 'filters' => [
+ 'get' => 'data_get',
+ ],
+ ],
+];
diff --git a/config/view.php b/config/view.php
new file mode 100755
index 0000000..b0e29ae
--- /dev/null
+++ b/config/view.php
@@ -0,0 +1,37 @@
+ [
+ resource_path('views/overrides'),
+ resource_path('views'),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Compiled View Path
+ |--------------------------------------------------------------------------
+ |
+ | This option determines where all the compiled Blade templates will be
+ | stored for your application. Typically, this is within the storage
+ | directory. However, as usual, you are free to change this value.
+ |
+ */
+
+ 'compiled' => env(
+ 'VIEW_COMPILED_PATH',
+ realpath(storage_path('framework/views'))
+ ),
+
+];
diff --git a/database/factories/PlayerFactory.php b/database/factories/PlayerFactory.php
new file mode 100755
index 0000000..23b6587
--- /dev/null
+++ b/database/factories/PlayerFactory.php
@@ -0,0 +1,21 @@
+ User::factory(),
+ 'name' => $this->faker->firstName,
+ 'tid_skin' => 0,
+ ];
+ }
+}
diff --git a/database/factories/TextureFactory.php b/database/factories/TextureFactory.php
new file mode 100755
index 0000000..ceb90ad
--- /dev/null
+++ b/database/factories/TextureFactory.php
@@ -0,0 +1,41 @@
+ $this->faker->firstName,
+ 'type' => 'steve',
+ 'hash' => $this->faker->sha256,
+ 'size' => rand(1, 2048),
+ 'likes' => rand(1, 10),
+ 'uploader' => User::factory(),
+ 'public' => true,
+ 'upload_at' => $this->faker->dateTime,
+ ];
+ }
+
+ public function alex()
+ {
+ return $this->state(['type' => 'alex']);
+ }
+
+ public function cape()
+ {
+ return $this->state(['type' => 'cape']);
+ }
+
+ public function private()
+ {
+ return $this->state(['public' => false]);
+ }
+}
diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php
new file mode 100755
index 0000000..6ecea60
--- /dev/null
+++ b/database/factories/UserFactory.php
@@ -0,0 +1,44 @@
+ $this->faker->email,
+ 'nickname' => $this->faker->name,
+ 'locale' => null,
+ 'score' => 1000,
+ 'avatar' => 0,
+ 'password' => app('cipher')->hash(Str::random(10), config('secure.salt')),
+ 'ip' => $this->faker->ipv4,
+ 'permission' => 0,
+ 'verified' => true,
+ 'last_sign_at' => $this->faker->dateTime->format('d-M-Y H:i:s'),
+ 'register_at' => $this->faker->dateTime->format('d-M-Y H:i:s'),
+ ];
+ }
+
+ public function admin()
+ {
+ return $this->state(['permission' => 1]);
+ }
+
+ public function superAdmin()
+ {
+ return $this->state(['permission' => 2]);
+ }
+
+ public function banned()
+ {
+ return $this->state(['permission' => -1]);
+ }
+}
diff --git a/database/migrations/2016_11_18_133939_create_all_tables.php b/database/migrations/2016_11_18_133939_create_all_tables.php
new file mode 100755
index 0000000..b2c3496
--- /dev/null
+++ b/database/migrations/2016_11_18_133939_create_all_tables.php
@@ -0,0 +1,56 @@
+increments('uid');
+ $table->string('email', 100);
+ $table->string('nickname', 50)->default('');
+ $table->integer('score');
+ $table->integer('avatar')->default('0');
+ $table->string('password', 255);
+ $table->string('ip', 32);
+ $table->integer('permission')->default('0');
+ $table->dateTime('last_sign_at');
+ $table->dateTime('register_at');
+ });
+
+ Schema::create('players', function (Blueprint $table) {
+ $table->increments('pid');
+ $table->integer('uid');
+ $table->string('player_name', 50);
+ $table->integer('tid_cape')->default('0');
+ $table->dateTime('last_modified');
+ });
+
+ Schema::create('textures', function (Blueprint $table) {
+ $table->increments('tid');
+ $table->string('name', 50);
+ $table->string('type', 10);
+ $table->string('hash', 64);
+ $table->integer('size');
+ $table->integer('uploader');
+ $table->tinyInteger('public');
+ $table->dateTime('upload_at');
+ });
+
+ Schema::create('options', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('option_name', 50);
+ $table->longText('option_value');
+ });
+ }
+
+ public function down()
+ {
+ Schema::drop('users');
+ Schema::drop('players');
+ Schema::drop('textures');
+ Schema::drop('options');
+ }
+}
diff --git a/database/migrations/2016_11_18_134542_import_options.php b/database/migrations/2016_11_18_134542_import_options.php
new file mode 100755
index 0000000..b99d2df
--- /dev/null
+++ b/database/migrations/2016_11_18_134542_import_options.php
@@ -0,0 +1,35 @@
+year,
+ $options['copyright_text']
+ );
+
+ foreach ($options as $key => $value) {
+ Option::set($key, $value);
+ }
+ }
+
+ public function down()
+ {
+ DB::table('options')->delete();
+ }
+}
diff --git a/database/migrations/2018_07_26_130617_add_verification_to_users_table.php b/database/migrations/2018_07_26_130617_add_verification_to_users_table.php
new file mode 100755
index 0000000..2d10ebd
--- /dev/null
+++ b/database/migrations/2018_07_26_130617_add_verification_to_users_table.php
@@ -0,0 +1,22 @@
+boolean('verified')->default(false);
+ $table->string('verification_token')->default('');
+ });
+ }
+
+ public function down()
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn(['verified', 'verification_token']);
+ });
+ }
+}
diff --git a/database/migrations/2018_08_21_105514_add_remember_token_to_users_table.php b/database/migrations/2018_08_21_105514_add_remember_token_to_users_table.php
new file mode 100755
index 0000000..61e9d9e
--- /dev/null
+++ b/database/migrations/2018_08_21_105514_add_remember_token_to_users_table.php
@@ -0,0 +1,22 @@
+rememberToken();
+ });
+ }
+
+ public function down()
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn('remember_token');
+ });
+ }
+}
diff --git a/database/migrations/2019_03_01_131420_add_tid_skin.php b/database/migrations/2019_03_01_131420_add_tid_skin.php
new file mode 100755
index 0000000..e79f1aa
--- /dev/null
+++ b/database/migrations/2019_03_01_131420_add_tid_skin.php
@@ -0,0 +1,30 @@
+integer('tid_skin')->default(-1);
+
+ if (Schema::hasColumn('players', 'preference')) {
+ $table->string('preference', 10)->nullable()->change();
+ }
+ });
+ }
+
+ public function down()
+ {
+ Schema::table('players', function (Blueprint $table) {
+ $table->dropColumn('tid_skin');
+
+ if (Schema::hasColumn('players', 'preference')) {
+ $table->string('preference', 10)->nullable(false)->change();
+ }
+ });
+ }
+}
diff --git a/database/migrations/2019_03_13_130311_rename_players_table_columns.php b/database/migrations/2019_03_13_130311_rename_players_table_columns.php
new file mode 100755
index 0000000..abbab18
--- /dev/null
+++ b/database/migrations/2019_03_13_130311_rename_players_table_columns.php
@@ -0,0 +1,22 @@
+renameColumn('player_name', 'name');
+ });
+ }
+
+ public function down()
+ {
+ Schema::table('players', function (Blueprint $table) {
+ $table->renameColumn('name', 'player_name');
+ });
+ }
+}
diff --git a/database/migrations/2019_03_14_174727_create_closet.php b/database/migrations/2019_03_14_174727_create_closet.php
new file mode 100755
index 0000000..12eafdd
--- /dev/null
+++ b/database/migrations/2019_03_14_174727_create_closet.php
@@ -0,0 +1,22 @@
+integer('user_uid');
+ $table->integer('texture_tid');
+ $table->text('item_name')->nullable();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('user_closet');
+ }
+}
diff --git a/database/migrations/2019_03_16_162603_remove_likes_field.php b/database/migrations/2019_03_16_162603_remove_likes_field.php
new file mode 100755
index 0000000..94b9627
--- /dev/null
+++ b/database/migrations/2019_03_16_162603_remove_likes_field.php
@@ -0,0 +1,17 @@
+dropColumn('likes');
+ });
+ }
+ }
+}
diff --git a/database/migrations/2019_03_23_171728_create_report_table.php b/database/migrations/2019_03_23_171728_create_report_table.php
new file mode 100755
index 0000000..d0c4f62
--- /dev/null
+++ b/database/migrations/2019_03_23_171728_create_report_table.php
@@ -0,0 +1,28 @@
+increments('id');
+ $table->integer('tid');
+ $table->integer('uploader');
+ $table->integer('reporter');
+ $table->longText('reason');
+ $table->integer('status');
+ $table->dateTime('report_at');
+ });
+ }
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('reports');
+ }
+}
diff --git a/database/migrations/2019_05_05_103143_add_likes_field.php b/database/migrations/2019_05_05_103143_add_likes_field.php
new file mode 100755
index 0000000..462aadc
--- /dev/null
+++ b/database/migrations/2019_05_05_103143_add_likes_field.php
@@ -0,0 +1,26 @@
+integer('likes')->unsigned()->default(0);
+ });
+ }
+ }
+
+ public function down()
+ {
+ if (Schema::hasColumn('textures', 'likes')) {
+ Schema::table('textures', function (Blueprint $table) {
+ $table->dropColumn('likes');
+ });
+ }
+ }
+}
diff --git a/database/migrations/2019_07_03_094434_create_notifications_table.php b/database/migrations/2019_07_03_094434_create_notifications_table.php
new file mode 100755
index 0000000..3f953a9
--- /dev/null
+++ b/database/migrations/2019_07_03_094434_create_notifications_table.php
@@ -0,0 +1,25 @@
+uuid('id')->primary();
+ $table->string('type');
+ $table->morphs('notifiable');
+ $table->text('data');
+ $table->timestamp('read_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('notifications');
+ }
+}
diff --git a/database/migrations/2019_07_05_222912_create_jobs_table.php b/database/migrations/2019_07_05_222912_create_jobs_table.php
new file mode 100755
index 0000000..b24ba8d
--- /dev/null
+++ b/database/migrations/2019_07_05_222912_create_jobs_table.php
@@ -0,0 +1,26 @@
+bigIncrements('id');
+ $table->string('queue')->index();
+ $table->longText('payload');
+ $table->unsignedTinyInteger('attempts');
+ $table->unsignedInteger('reserved_at')->nullable();
+ $table->unsignedInteger('available_at');
+ $table->unsignedInteger('created_at');
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('jobs');
+ }
+}
diff --git a/database/migrations/2019_09_05_130811_create_language_lines_table.php b/database/migrations/2019_09_05_130811_create_language_lines_table.php
new file mode 100755
index 0000000..9d24bc7
--- /dev/null
+++ b/database/migrations/2019_09_05_130811_create_language_lines_table.php
@@ -0,0 +1,24 @@
+increments('id');
+ $table->string('group');
+ $table->index('group');
+ $table->string('key');
+ $table->text('text');
+ $table->timestamps();
+ });
+ }
+
+ public function down()
+ {
+ Schema::drop('language_lines');
+ }
+}
diff --git a/database/migrations/2019_12_14_095751_update_ip_field.php b/database/migrations/2019_12_14_095751_update_ip_field.php
new file mode 100755
index 0000000..d417981
--- /dev/null
+++ b/database/migrations/2019_12_14_095751_update_ip_field.php
@@ -0,0 +1,22 @@
+string('ip', 39)->change();
+ });
+ }
+
+ public function down()
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->string('ip', 32)->change();
+ });
+ }
+}
diff --git a/database/migrations/2020_03_10_145738_lengthen_ip_field.php b/database/migrations/2020_03_10_145738_lengthen_ip_field.php
new file mode 100755
index 0000000..5173eaa
--- /dev/null
+++ b/database/migrations/2020_03_10_145738_lengthen_ip_field.php
@@ -0,0 +1,22 @@
+string('ip', 45)->change();
+ });
+ }
+
+ public function down()
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->string('ip', 39)->change();
+ });
+ }
+}
diff --git a/database/migrations/2020_06_26_090510_add_o_auth_provider_field.php b/database/migrations/2020_06_26_090510_add_o_auth_provider_field.php
new file mode 100755
index 0000000..5ea7edd
--- /dev/null
+++ b/database/migrations/2020_06_26_090510_add_o_auth_provider_field.php
@@ -0,0 +1,17 @@
+string('provider')->after('secret')->nullable();
+ });
+ }
+ }
+}
diff --git a/database/migrations/2020_06_28_155519_add_user_locale_field.php b/database/migrations/2020_06_28_155519_add_user_locale_field.php
new file mode 100755
index 0000000..b71b186
--- /dev/null
+++ b/database/migrations/2020_06_28_155519_add_user_locale_field.php
@@ -0,0 +1,22 @@
+string('locale')->nullable()->after('nickname');
+ });
+ }
+
+ public function down()
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->removeColumn('locale');
+ });
+ }
+}
diff --git a/database/migrations/2021_04_14_181300_create_scope_table.php b/database/migrations/2021_04_14_181300_create_scope_table.php
new file mode 100755
index 0000000..166dca1
--- /dev/null
+++ b/database/migrations/2021_04_14_181300_create_scope_table.php
@@ -0,0 +1,34 @@
+increments('id');
+ $table->string('name')->unique();
+ $table->string('description');
+ });
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('scopes');
+ }
+}
diff --git a/database/migrations/2021_06_06_111049_add_is_dark_mode_field.php b/database/migrations/2021_06_06_111049_add_is_dark_mode_field.php
new file mode 100755
index 0000000..3c2aa09
--- /dev/null
+++ b/database/migrations/2021_06_06_111049_add_is_dark_mode_field.php
@@ -0,0 +1,22 @@
+boolean('is_dark_mode')->after('ip')->default(false);
+ });
+ }
+
+ public function down()
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn('is_dark_mode');
+ });
+ }
+}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..a31f73b
--- /dev/null
+++ b/index.html
@@ -0,0 +1,37 @@
+
+
+
+
+
Error
+
+
+
+