首次提交

This commit is contained in:
root 2024-08-17 18:43:48 +08:00
parent 42f5f087b4
commit 9b2ceeb224
8568 changed files with 840298 additions and 0 deletions

48
.env Normal file

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

45
.env.example Normal file

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

1
.htaccess Executable file

@ -0,0 +1 @@

9
404.html Executable file

@ -0,0 +1,9 @@
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>

21
LICENSE Normal file

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

@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
class BsInstallCommand extends Command
{
protected $signature = 'bs:install {email} {password} {nickname}';
protected $description = 'Execute installation and create a super administrator.';
public function handle(Filesystem $filesystem)
{
if ($filesystem->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.');
}
}

@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use App\Services\Option;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Foundation\Application;
class OptionsCacheCommand extends Command
{
protected $signature = 'options:cache';
protected $description = 'Cache Blessing Skin options';
public function handle(Filesystem $filesystem, Application $app)
{
$path = storage_path('options.php');
$filesystem->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 = '<?php'.PHP_EOL.$notice.'return '.$content.';';
$filesystem->put($path, $content);
$this->info('Options cached successfully.');
}
}

@ -0,0 +1,25 @@
<?php
namespace App\Console\Commands;
use App\Services\PluginManager;
use Illuminate\Console\Command;
class PluginDisableCommand extends Command
{
protected $signature = 'plugin:disable {name}';
protected $description = 'Disable a plugin';
public function handle(PluginManager $plugins)
{
$plugin = $plugins->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'));
}
}
}

@ -0,0 +1,26 @@
<?php
namespace App\Console\Commands;
use App\Services\PluginManager;
use Illuminate\Console\Command;
class PluginEnableCommand extends Command
{
protected $signature = 'plugin:enable {name}';
protected $description = 'Enable a plugin';
public function handle(PluginManager $plugins)
{
$name = $this->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'));
}
}
}

@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class SaltRandomCommand extends Command
{
protected $signature = 'salt:random {--show : Display the salt instead of modifying files}';
protected $description = 'Set the application salt';
public function handle()
{
$salt = $this->generateRandomSalt();
if ($this->option('show')) {
return $this->line('<comment>'.$salt.'</comment>');
}
// 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'));
}
}

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use Composer\Semver\Comparator;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel as Artisan;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\Cache;
class UpdateCommand extends Command
{
protected $signature = 'update';
protected $description = 'Execute update.';
public function handle(Artisan $artisan, Filesystem $filesystem)
{
$this->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']);
}
},
]);
}
}

18
app/Console/Kernel.php Executable file

@ -0,0 +1,18 @@
<?php
namespace App\Console;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected $commands = [
\Laravel\Passport\Console\KeysCommand::class,
Commands\BsInstallCommand::class,
Commands\OptionsCacheCommand::class,
Commands\PluginDisableCommand::class,
Commands\PluginEnableCommand::class,
Commands\SaltRandomCommand::class,
Commands\UpdateCommand::class,
];
}

@ -0,0 +1,14 @@
<?php
namespace App\Events;
class ConfigureAdminMenu extends Event
{
public $menu;
public function __construct(array &$menu)
{
// Pass array by reference
$this->menu = &$menu;
}
}

@ -0,0 +1,14 @@
<?php
namespace App\Events;
class ConfigureExploreMenu extends Event
{
public $menu;
public function __construct(array &$menu)
{
// Pass array by reference
$this->menu = &$menu;
}
}

15
app/Events/ConfigureRoutes.php Executable file

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use Illuminate\Routing\Router;
class ConfigureRoutes extends Event
{
public $router;
public function __construct(Router $router)
{
$this->router = $router;
}
}

@ -0,0 +1,13 @@
<?php
namespace App\Events;
class ConfigureUserMenu extends Event
{
public $menu;
public function __construct(array &$menu)
{
$this->menu = &$menu;
}
}

7
app/Events/Event.php Executable file

@ -0,0 +1,7 @@
<?php
namespace App\Events;
abstract class Event
{
}

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Models\Player;
class PlayerProfileUpdated extends Event
{
public $player;
public function __construct(Player $player)
{
$this->player = $player;
}
}

15
app/Events/PlayerRetrieved.php Executable file

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Models\Player;
class PlayerRetrieved extends Event
{
public $player;
public function __construct(Player $player)
{
$this->player = $player;
}
}

15
app/Events/PlayerWasAdded.php Executable file

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Models\Player;
class PlayerWasAdded extends Event
{
public $player;
public function __construct(Player $player)
{
$this->player = $player;
}
}

13
app/Events/PlayerWasDeleted.php Executable file

@ -0,0 +1,13 @@
<?php
namespace App\Events;
class PlayerWasDeleted extends Event
{
public $playerName;
public function __construct($playerName)
{
$this->playerName = $playerName;
}
}

@ -0,0 +1,13 @@
<?php
namespace App\Events;
class PlayerWillBeAdded extends Event
{
public $playerName;
public function __construct($playerName)
{
$this->playerName = $playerName;
}
}

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Models\Player;
class PlayerWillBeDeleted extends Event
{
public $player;
public function __construct(Player $player)
{
$this->player = $player;
}
}

15
app/Events/PluginBootFailed.php Executable file

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Services\Plugin;
class PluginBootFailed extends Event
{
public Plugin $plugin;
public function __construct(Plugin $plugin)
{
$this->plugin = $plugin;
}
}

15
app/Events/PluginWasDeleted.php Executable file

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Services\Plugin;
class PluginWasDeleted extends Event
{
public $plugin;
public function __construct(Plugin $plugin)
{
$this->plugin = $plugin;
}
}

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Services\Plugin;
class PluginWasDisabled extends Event
{
public $plugin;
public function __construct(Plugin $plugin)
{
$this->plugin = $plugin;
}
}

15
app/Events/PluginWasEnabled.php Executable file

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Services\Plugin;
class PluginWasEnabled extends Event
{
public $plugin;
public function __construct(Plugin $plugin)
{
$this->plugin = $plugin;
}
}

13
app/Events/RenderingBadges.php Executable file

@ -0,0 +1,13 @@
<?php
namespace App\Events;
class RenderingBadges extends Event
{
public $badges;
public function __construct(array &$badges)
{
$this->badges = &$badges;
}
}

18
app/Events/RenderingFooter.php Executable file

@ -0,0 +1,18 @@
<?php
namespace App\Events;
class RenderingFooter extends Event
{
public $contents;
public function __construct(array &$contents)
{
$this->contents = &$contents;
}
public function addContent(string $content)
{
$this->contents[] = $content;
}
}

18
app/Events/RenderingHeader.php Executable file

@ -0,0 +1,18 @@
<?php
namespace App\Events;
class RenderingHeader extends Event
{
public $contents;
public function __construct(array &$contents)
{
$this->contents = &$contents;
}
public function addContent(string $content)
{
$this->contents[] = $content;
}
}

13
app/Events/TextureDeleting.php Executable file

@ -0,0 +1,13 @@
<?php
namespace App\Events;
class TextureDeleting extends Event
{
public $texture;
public function __construct(\App\Models\Texture $texture)
{
$this->texture = $texture;
}
}

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Models\User;
class UserAuthenticated extends Event
{
public $user;
public function __construct(User $user)
{
$this->user = $user;
}
}

15
app/Events/UserLoggedIn.php Executable file

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Models\User;
class UserLoggedIn extends Event
{
public $user;
public function __construct(User $user)
{
$this->user = $user;
}
}

@ -0,0 +1,17 @@
<?php
namespace App\Events;
use App\Models\User;
class UserProfileUpdated extends Event
{
public $type;
public $user;
public function __construct($type, User $user)
{
$this->type = $type;
$this->user = $user;
}
}

15
app/Events/UserRegistered.php Executable file

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Models\User;
class UserRegistered extends Event
{
public $user;
public function __construct(User $user)
{
$this->user = $user;
}
}

16
app/Events/UserTryToLogin.php Executable file

@ -0,0 +1,16 @@
<?php
namespace App\Events;
class UserTryToLogin extends Event
{
public $identification;
public $authType;
public function __construct($identification, $authType)
{
$this->identification = $identification;
$this->authType = $authType;
}
}

64
app/Exceptions/Handler.php Executable file

@ -0,0 +1,64 @@
<?php
namespace App\Exceptions;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Laravel\Passport\Exceptions\MissingScopeException;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* A list of the exception types that should not be reported.
*/
protected $dontReport = [
\Illuminate\Auth\AuthenticationException::class,
\Illuminate\Auth\Access\AuthorizationException::class,
\Symfony\Component\HttpKernel\Exception\HttpException::class,
\Illuminate\Validation\ValidationException::class,
\Illuminate\Session\TokenMismatchException::class,
ModelNotFoundException::class,
PrettyPageException::class,
];
public function render($request, Throwable $exception)
{
if ($exception instanceof ModelNotFoundException) {
$model = $exception->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(),
];
}
}

@ -0,0 +1,11 @@
<?php
namespace App\Exceptions;
class PrettyPageException extends \Exception
{
public function render()
{
return response()->view('errors.pretty', ['code' => $this->code, 'message' => $this->message]);
}
}

@ -0,0 +1,149 @@
<?php
namespace App\Http\Controllers;
use App\Models\Player;
use App\Models\Texture;
use App\Models\User;
use App\Services\PluginManager;
use Blessing\Filter;
use Carbon\Carbon;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class AdminController extends Controller
{
public function index(Filter $filter)
{
$grid = [
'layout' => [
['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);
}
}

@ -0,0 +1,373 @@
<?php
namespace App\Http\Controllers;
use App\Events;
use App\Exceptions\PrettyPageException;
use App\Mail\ForgotPassword;
use App\Models\Player;
use App\Models\User;
use App\Rules;
use Auth;
use Blessing\Filter;
use Blessing\Rejection;
use Cache;
use Carbon\Carbon;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use Mail;
use Session;
use URL;
use Vectorface\Whip\Whip;
class AuthController extends Controller
{
public function login(Filter $filter)
{
$whip = new Whip();
$ip = $whip->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');
}
}

@ -0,0 +1,198 @@
<?php
namespace App\Http\Controllers;
use App\Models\Texture;
use App\Models\User;
use Auth;
use Blessing\Filter;
use Blessing\Rejection;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class ClosetController extends Controller
{
public function index(Filter $filter)
{
$grid = [
'layout' => [
['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);
}
}

@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Models\Texture;
use App\Models\User;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
class ClosetManagementController extends Controller
{
public function list(User $user)
{
return $user->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'));
}
}

@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use DispatchesJobs;
use ValidatesRequests;
}

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Arr;
class HomeController extends Controller
{
public function index()
{
return view('home')
->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'),
]);
}
}

@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers;
use App\Services\Plugin;
use App\Services\PluginManager;
use App\Services\Unzip;
use Composer\CaBundle\CaBundle;
use Composer\Semver\Comparator;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
class MarketController extends Controller
{
public function marketData(PluginManager $manager)
{
$plugins = $this->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;
}
}

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Notifications;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Notification;
use League\CommonMark\GithubFlavoredMarkdownConverter;
class NotificationsController extends Controller
{
public function send(Request $request)
{
$data = $request->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(),
];
}
}

@ -0,0 +1,266 @@
<?php
namespace App\Http\Controllers;
use App\Services\Facades\Option;
use App\Services\OptionForm;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class OptionsController extends Controller
{
public function customize(Request $request)
{
$homepage = Option::form('homepage', OptionForm::AUTO_DETECT, function ($form) {
$form->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'));
}
}

@ -0,0 +1,260 @@
<?php
namespace App\Http\Controllers;
use App\Events\PlayerWasAdded;
use App\Events\PlayerWasDeleted;
use App\Events\PlayerWillBeAdded;
use App\Events\PlayerWillBeDeleted;
use App\Models\Player;
use App\Models\Texture;
use App\Models\User;
use App\Rules;
use Auth;
use Blessing\Filter;
use Blessing\Rejection;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class PlayerController extends Controller
{
public function __construct()
{
$this->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());
}
}

@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers;
use App\Models\Player;
use App\Models\Texture;
use App\Models\User;
use App\Rules;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class PlayersManagementController extends Controller
{
public function __construct()
{
$this->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);
}
}

@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers;
use App\Services\Plugin;
use App\Services\PluginManager;
use App\Services\Unzip;
use Composer\CaBundle\CaBundle;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use League\CommonMark\GithubFlavoredMarkdownConverter;
class PluginController extends Controller
{
public function config(PluginManager $plugins, $name)
{
$plugin = $plugins->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);
}
}
}

@ -0,0 +1,170 @@
<?php
namespace App\Http\Controllers;
use App\Models\Report;
use App\Models\Texture;
use App\Models\User;
use Blessing\Filter;
use Blessing\Rejection;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
class ReportController extends Controller
{
public function submit(Request $request, Dispatcher $dispatcher, Filter $filter)
{
$data = $request->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();
}
}
}

@ -0,0 +1,157 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\PrettyPageException;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Contracts\Console\Kernel as Artisan;
use Illuminate\Database\Connection;
use Illuminate\Database\DatabaseManager;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Vectorface\Whip\Whip;
class SetupController extends Controller
{
public function database(
Request $request,
Filesystem $filesystem,
Connection $connection,
DatabaseManager $manager
) {
if ($request->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');
}
}

@ -0,0 +1,441 @@
<?php
namespace App\Http\Controllers;
use App\Models\Texture;
use App\Models\User;
use Auth;
use Blessing\Filter;
use Blessing\Rejection;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use League\CommonMark\GithubFlavoredMarkdownConverter;
use Storage;
class SkinlibController extends Controller
{
public function __construct()
{
$this->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);
}
}

@ -0,0 +1,177 @@
<?php
namespace App\Http\Controllers;
use App\Models\Player;
use App\Models\Texture;
use App\Models\User;
use Blessing\Minecraft;
use Cache;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Image;
use Storage;
class TextureController extends Controller
{
public function __construct()
{
$this->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;
}
}

@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Services\Translations\JavaScript;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
use Spatie\TranslationLoader\LanguageLine;
class TranslationsController extends Controller
{
public function list()
{
return LanguageLine::paginate(10);
}
public function create(Request $request, Application $app, JavaScript $js)
{
$data = $request->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);
}
}

@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers;
use App\Services\Unzip;
use Cache;
use Composer\CaBundle\CaBundle;
use Composer\Semver\Comparator;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
class UpdateController extends Controller
{
public const SPEC = 2;
public function showUpdatePage()
{
$info = $this->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' => ''];
}
}

@ -0,0 +1,360 @@
<?php
namespace App\Http\Controllers;
use App\Events\UserProfileUpdated;
use App\Mail\EmailVerification;
use App\Models\Texture;
use App\Models\User;
use Auth;
use Blessing\Filter;
use Blessing\Rejection;
use Carbon\Carbon;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use League\CommonMark\GithubFlavoredMarkdownConverter;
use Mail;
use Session;
use URL;
class UserController extends Controller
{
public function user()
{
/** @var User */
$user = auth()->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();
}
}

@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class UsersManagementController extends Controller
{
public function __construct()
{
$this->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);
}
}

71
app/Http/Kernel.php Executable file

@ -0,0 +1,71 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
\Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode::class,
\Illuminate\Foundation\Http\Middleware\TrimStrings::class,
\App\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\DetectLanguagePrefer::class,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\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,
];
}

@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
class Authenticate extends Middleware
{
protected function redirectTo($request)
{
if (!$request->expectsJson()) {
session([
'last_requested_path' => $request->fullUrl(),
'msg' => trans('auth.check.anonymous'),
]);
return '/auth/login';
}
}
}

@ -0,0 +1,18 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Filesystem\Filesystem;
class CheckInstallation
{
public function handle($request, \Closure $next)
{
$hasLock = resolve(Filesystem::class)->exists(storage_path('install.lock'));
if ($hasLock) {
return response()->view('setup.locked');
}
return $next($request);
}
}

@ -0,0 +1,24 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class CheckRole
{
protected $roles = [
'banned' => -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);
}
}

@ -0,0 +1,13 @@
<?php
namespace App\Http\Middleware;
class CheckUserVerified
{
public function handle($request, \Closure $next)
{
abort_if(option('require_verification') && !auth()->user()->verified, 403, trans('auth.check.verified'));
return $next($request);
}
}

@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull as Converter;
class ConvertEmptyStringsToNull extends Converter
{
protected $excepts = [
'admin/options',
'admin/score',
'admin/resource',
'admin/customize',
];
public function handle($request, Closure $next)
{
if (in_array($request->path(), $this->excepts)) {
return $next($request);
}
return parent::handle($request, $next);
}
}

@ -0,0 +1,35 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
class DetectLanguagePrefer
{
public function handle(Request $request, \Closure $next)
{
$locale = $request->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;
}
}

@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use App\Exceptions\PrettyPageException;
use Closure;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class EnforceEverGreen
{
public function handle($request, Closure $next)
{
$userAgent = $request->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);
}
}

@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
class EnsureEmailFilled
{
public function handle($request, Closure $next)
{
if ($request->user()->email != '' && $request->is('auth/bind')) {
return redirect('/user');
} elseif ($request->user()->email == '' && !$request->is('auth/bind')) {
return redirect('/auth/bind');
}
return $next($request);
}
}

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Closure;
class FireUserAuthenticated
{
public function handle($request, Closure $next)
{
if (auth()->check()) {
event(new \App\Events\UserAuthenticated($request->user()));
}
return $next($request);
}
}

@ -0,0 +1,13 @@
<?php
namespace App\Http\Middleware;
use Auth;
class RedirectIfAuthenticated
{
public function handle($request, \Closure $next)
{
return Auth::check() ? redirect('user') : $next($request);
}
}

@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Composer\Semver\Comparator;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\Artisan;
class RedirectToSetup
{
public function handle($request, Closure $next)
{
$version = config('app.version');
$hasLock = resolve(Filesystem::class)->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');
}
}

@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
class RejectBannedUser
{
public function handle($request, Closure $next)
{
if ($request->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);
}
}

@ -0,0 +1,57 @@
<?php
namespace App\Http\View\Composers;
use App\Services\Translations\JavaScript;
use Blessing\Filter;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use Illuminate\View\View;
class FootComposer
{
protected Request $request;
protected JavaScript $javascript;
protected Dispatcher $dispatcher;
protected Filter $filter;
public function __construct(
Request $request,
JavaScript $javascript,
Dispatcher $dispatcher,
Filter $filter
) {
$this->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);
}
}

@ -0,0 +1,107 @@
<?php
namespace App\Http\View\Composers;
use Blessing\Filter;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\View\View;
class HeadComposer
{
protected Dispatcher $dispatcher;
protected Request $request;
protected Filter $filter;
public function __construct(
Dispatcher $dispatcher,
Request $request,
Filter $filter
) {
$this->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);
}
}

@ -0,0 +1,38 @@
<?php
namespace App\Http\View\Composers;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\View\View;
class LanguagesMenuComposer
{
protected Request $request;
public function __construct(Request $request)
{
$this->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,
]);
}
}

@ -0,0 +1,105 @@
<?php
namespace App\Http\View\Composers;
use App\Events;
use App\Services\Plugin;
use App\Services\PluginManager;
use Blessing\Filter;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\View\View;
class SideMenuComposer
{
protected Request $request;
protected Filter $filter;
public function __construct(Request $request, Filter $filter)
{
$this->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
}
}

@ -0,0 +1,41 @@
<?php
namespace App\Http\View\Composers;
use Blessing\Filter;
use Illuminate\Http\Request;
use Illuminate\View\View;
class UserMenuComposer
{
protected Request $request;
protected Filter $filter;
public function __construct(Request $request, Filter $filter)
{
$this->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,
]);
}
}

@ -0,0 +1,49 @@
<?php
namespace App\Http\View\Composers;
use App\Models\User;
use Blessing\Filter;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\View\View;
class UserPanelComposer
{
protected Dispatcher $dispatcher;
protected Filter $filter;
public function __construct(Dispatcher $dispatcher, Filter $filter)
{
$this->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,
]);
}
}

34
app/Listeners/CleanUpCloset.php Executable file

@ -0,0 +1,34 @@
<?php
namespace App\Listeners;
use App\Models\Texture;
use App\Models\User;
class CleanUpCloset
{
public function handle(Texture $texture)
{
// no need to update users' closet
// if texture was switched from "private" to "public"
if ($texture->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());
}
}
}

@ -0,0 +1,26 @@
<?php
namespace App\Listeners;
use Illuminate\Filesystem\Filesystem;
use Symfony\Component\Finder\SplFileInfo;
class CleanUpFrontEndLocaleFiles
{
protected Filesystem $filesystem;
public function __construct(Filesystem $filesystem)
{
$this->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());
}
});
}
}

@ -0,0 +1,28 @@
<?php
namespace App\Listeners;
use App\Services\Plugin;
use Illuminate\Filesystem\Filesystem;
class CopyPluginAssets
{
protected Filesystem $filesystem;
public function __construct(Filesystem $filesystem)
{
$this->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'
);
}
}

@ -0,0 +1,22 @@
<?php
namespace App\Listeners;
use App\Models\User;
use Event;
class NotifyFailedPlugin
{
public function handle($event)
{
$plugin = $event->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("<script>blessing.notify.toast.error('$message')</script>");
}
});
}
}

@ -0,0 +1,16 @@
<?php
namespace App\Listeners;
use App\Models\Texture;
use App\Models\User;
class ResetPlayerForRemovedClosetItem
{
public function handle(Texture $texture, User $user)
{
$type = $texture->type === 'cape' ? 'tid_cape' : 'tid_skin';
$user->players()->where($type, $texture->tid)->update([$type => 0]);
}
}

28
app/Listeners/ResetPlayers.php Executable file

@ -0,0 +1,28 @@
<?php
namespace App\Listeners;
use App\Models\Player;
use App\Models\Texture;
class ResetPlayers
{
public function handle(Texture $texture)
{
// no need to update players
// if texture was switched from "private" to "public"
if ($texture->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]);
}
}

@ -0,0 +1,24 @@
<?php
namespace App\Listeners;
use App\Mail\EmailVerification;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
class SendEmailVerification
{
public function handle(User $user)
{
if (option('require_verification')) {
$url = URL::signedRoute('auth.verify', ['user' => $user->uid], null, false);
try {
Mail::to($user->email)->send(new EmailVerification(url($url)));
} catch (\Exception $e) {
report($e);
}
}
}
}

34
app/Listeners/SetAppLocale.php Executable file

@ -0,0 +1,34 @@
<?php
namespace App\Listeners;
use App\Models\User;
use Illuminate\Http\Request;
class SetAppLocale
{
protected Request $request;
public function __construct(Request $request)
{
$this->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);
}
}
}

@ -0,0 +1,28 @@
<?php
namespace App\Listeners;
class UpdateScoreForDeletedTexture
{
public function handle($texture)
{
$uploader = $texture->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();
}
}
}

29
app/Mail/EmailVerification.php Executable file

@ -0,0 +1,29 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class EmailVerification extends Mailable
{
use Queueable;
use SerializesModels;
public $url;
public function __construct($url)
{
$this->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');
}
}

29
app/Mail/ForgotPassword.php Executable file

@ -0,0 +1,29 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class ForgotPassword extends Mailable
{
use Queueable;
use SerializesModels;
public $url = '';
public function __construct(string $url)
{
$this->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');
}
}

@ -0,0 +1,33 @@
<?php
namespace App\Models\Concerns;
use App\Services\Cipher\BaseCipher;
use Blessing\Filter;
trait HasPassword
{
public function verifyPassword(string $raw)
{
/** @var BaseCipher */
$cipher = resolve('cipher');
/** @var Filter */
$filter = resolve(Filter::class);
$password = $this->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();
}
}

99
app/Models/Player.php Executable file

@ -0,0 +1,99 @@
<?php
namespace App\Models;
use App\Events\PlayerProfileUpdated;
use App\Models;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Lorisleiva\LaravelSearchString\Concerns\SearchString;
/**
* @property int $pid
* @property int $uid
* @property string $name
* @property int $tid_skin
* @property int $tid_cape
* @property Carbon $last_modified
* @property User $user
* @property Texture $skin
* @property Texture $cape
* @property string $model
*/
class Player extends Model
{
use HasFactory;
use SearchString;
public const CREATED_AT = null;
public const UPDATED_AT = 'last_modified';
public $primaryKey = 'pid';
protected $fillable = [
'uid', 'name', 'tid_skin', 'tid_cape', 'last_modified',
];
protected $casts = [
'pid' => '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');
}
}

68
app/Models/Report.php Executable file

@ -0,0 +1,68 @@
<?php
namespace App\Models;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Lorisleiva\LaravelSearchString\Concerns\SearchString;
/**
* @property int $id
* @property int $tid
* @property int $uploader
* @property int $reporter
* @property string $reason
* @property int $status
* @property Carbon $report_at
* @property Texture $texture
* @property User $informer The reporter.
*/
class Report extends Model
{
use SearchString;
public const CREATED_AT = 'report_at';
public const UPDATED_AT = null;
public const PENDING = 0;
public const RESOLVED = 1;
public const REJECTED = 2;
protected $fillable = [
'uploader', 'reporter', 'reason', 'status',
];
protected $casts = [
'tid' => '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');
}
}

19
app/Models/Scope.php Executable file

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $name
* @property string $description
*/
class Scope extends Model
{
public $timestamps = false;
protected $fillable = [
'name', 'description',
];
}

74
app/Models/Texture.php Executable file

@ -0,0 +1,74 @@
<?php
namespace App\Models;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property int $tid
* @property string $name
* @property string $type
* @property string $hash
* @property int $size
* @property int $uploader
* @property bool $public
* @property Carbon $upload_at
* @property int $likes
* @property string $model
* @property User $owner
* @property Collection $likers
*/
class Texture extends Model
{
use HasFactory;
public $primaryKey = 'tid';
public const CREATED_AT = 'upload_at';
public const UPDATED_AT = null;
protected $fillable = [
'name', 'type', 'uploader', 'public', 'likes',
];
protected $casts = [
'tid' => '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');
}
}

115
app/Models/User.php Executable file

@ -0,0 +1,115 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasPassword;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Passport\HasApiTokens;
use Lorisleiva\LaravelSearchString\Concerns\SearchString;
/**
* @property int $uid
* @property string $email
* @property string $password
* @property string $nickname
* @property string|null $locale
* @property int $avatar
* @property int $score
* @property int $permission
* @property string $ip
* @property bool $is_dark_mode
* @property string $last_sign_at
* @property string $register_at
* @property bool $verified
* @property string $player_name
* @property Collection $players
* @property Collection $closet
*/
class User extends Authenticatable
{
use Notifiable;
use HasFactory;
use HasPassword;
use HasApiTokens;
use SearchString;
public const BANNED = -1;
public const NORMAL = 0;
public const ADMIN = 1;
public const SUPER_ADMIN = 2;
protected $primaryKey = 'uid';
public $timestamps = false;
protected $fillable = [
'email', 'nickname', 'avatar', 'score', 'permission', 'last_sign_at',
];
protected $casts = [
'uid' => '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;
}
}

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class SiteMessage extends Notification implements ShouldQueue
{
use Queueable;
public $title;
public $content;
public function __construct(string $title, $content = '')
{
$this->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,
];
}
}

37
app/Observers/ScopeObserver.php Executable file

@ -0,0 +1,37 @@
<?php
namespace App\Observers;
use App\Models\Scope;
use Illuminate\Support\Facades\Cache;
class ScopeObserver
{
/**
* Handle the Scope "saved" event.
*
* @return void
*/
public function saved()
{
$this->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();
});
}
}

@ -0,0 +1,59 @@
<?php
namespace App\Providers;
use App\Services;
use Illuminate\Http\Request;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register()
{
$this->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');
}
}
}

@ -0,0 +1,54 @@
<?php
namespace App\Providers;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Cache;
use Laravel\Passport\Passport;
class AuthServiceProvider extends ServiceProvider
{
/**
* The policy mappings for the application.
*
* @var array
*/
protected $policies = [
];
/**
* Register any authentication / authorization services.
*
* @return void
*/
public function boot()
{
$this->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']);
}
}

@ -0,0 +1,54 @@
<?php
namespace App\Providers;
use App\Listeners;
use App\Models\Scope;
use App\Observers\ScopeObserver;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
// The event listener mappings for the application.
protected $listen = [
'App\Events\PluginWasEnabled' => [
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);
}
}

@ -0,0 +1,20 @@
<?php
namespace App\Providers;
use App\Services\PluginManager;
use Illuminate\Support\ServiceProvider;
class PluginServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton(PluginManager::class);
$this->app->alias(PluginManager::class, 'plugins');
}
public function boot(PluginManager $plugins)
{
$plugins->boot();
}
}

Some files were not shown because too many files have changed in this diff Show More