Compare commits

...

141 Commits
dev ... v3.x

Author SHA1 Message Date
printempw
badcc7bbf1 Bump version to 3.5.0 2018-08-21 13:50:28 +08:00
printempw
2d90498d80 Set model preference to slim when applying slim skin to new players 2018-08-21 12:55:13 +08:00
printempw
c83037a3f7 Fix big offset for Hook::addMenuItem 2018-08-21 12:55:13 +08:00
printempw
95b7a0781e Update email template 2018-08-21 12:55:05 +08:00
printempw
8c337704df Fix logging out at homepage 2018-08-21 11:08:13 +08:00
printempw
780275f87e Update notice of unavailable plugins registry 2018-08-21 11:02:40 +08:00
printempw
0db0b8a01c Update default plugins registry url 2018-08-21 10:37:28 +08:00
printempw
be2b3cd9b3 Update skinview3d to v1.1.0 2018-08-21 10:28:58 +08:00
printempw
2fa1d88c22 Add update script for v3.5.0 2018-08-17 12:13:33 +08:00
printempw
8575a52ec1 Allow HTML in tips of update scripts 2018-08-17 12:09:48 +08:00
printempw
f5681c248d Bump version to 3.5.0-beta 2018-08-17 11:31:29 +08:00
printempw
06634a727b Fix csrf verification for binding email 2018-08-17 11:07:34 +08:00
printempw
d1712286fd Adjust booting order of service providers 2018-08-16 17:38:59 +08:00
printempw
46053a793e Update default APP_KEY 2018-08-16 17:36:37 +08:00
printempw
4e37fbd2b5 Fix sending feedbacks 2018-08-16 17:05:15 +08:00
printempw
79bcff0d26 Add helper function nl2p 2018-08-16 15:39:34 +08:00
printempw
ca8a01aced Update initializing copyright_text option 2018-08-16 15:15:46 +08:00
printempw
8f55aab848 Exclude "vendor/bin" from generated zip archive 2018-08-16 15:01:33 +08:00
printempw
79150ddca8 Fix CSRF verification compatibility with plugins 2018-08-16 14:02:37 +08:00
printempw
9b4f05d66a Fix style of plugin marketplace datatable 2018-08-16 11:05:28 +08:00
printempw
f7de3c026d Fix ServicesTest/HookTest 2018-08-16 01:26:23 +08:00
printempw
8aa3416260 Update traits for unit test 2018-08-16 01:01:22 +08:00
printempw
5051e8b2f8 Update UpdateControllerTest 2018-08-16 00:42:49 +08:00
printempw
dafe2583fe Update PluginControllerTest 2018-08-15 23:13:34 +08:00
printempw
b124c75e4b Update MarketControllerTest 2018-08-15 22:48:25 +08:00
printempw
f076049b68 Add trait GenerateFakePlugins 2018-08-15 22:39:29 +08:00
printempw
0b52c40661 Add trait MockGuzzleClient for tests 2018-08-15 22:22:55 +08:00
printempw
60dab4fa5b Get guzzle client instance from service container 2018-08-15 18:53:54 +08:00
printempw
257a9fc5b9 Specify update source by environment variable 2018-08-15 18:47:08 +08:00
printempw
82e78d548d Use Guzzle to request update source 2018-08-15 18:21:32 +08:00
printempw
bf167639ff Fix CSRF verification for OptionForm 2018-08-15 18:20:44 +08:00
printempw
503cb20c83 Restrict PluginController access to super admin only 2018-08-15 17:08:05 +08:00
printempw
1a84ea1506 Make notice box on player page always expanded 2018-08-15 15:43:19 +08:00
printempw
4f5c2ffa98 Add likes count to items on skinlib page 2018-08-15 15:40:58 +08:00
printempw
3adf04ff6d Fix front-end XSS prevention 2018-08-15 14:59:09 +08:00
printempw
5e9da1e492 Fix form validation rules 2018-08-15 14:57:59 +08:00
printempw
89bc8d4ccc Tweak rendering HTTP exceptions 2018-08-14 22:08:44 +08:00
printempw
53435e27b4 Fix CSRF verification 2018-08-14 22:07:43 +08:00
printempw
be6a2fe873 Show FAQ link on fatal error modal 2018-08-14 18:13:54 +08:00
printempw
375d821fbd Enable session encryption 2018-08-14 11:50:51 +08:00
printempw
6c7076b39e Fix redirecting to setup wizard 2018-08-14 10:46:32 +08:00
printempw
0b1ed82cf7 Update translations 2018-08-14 01:45:26 +08:00
printempw
917617f657 Allow customizing homepage by overriding translation strings 2018-08-13 19:14:31 +08:00
printempw
971f2142d4 Display plugin name on market and manage page 2018-08-13 17:52:08 +08:00
printempw
6a3e567010 Update PluginControllerTest 2018-08-12 22:54:51 +08:00
printempw
6570e3327f Add check for plugin updates 2018-08-12 22:43:21 +08:00
printempw
4a2edb2291 Change view extension from .tpl to .blade.php 2018-08-12 21:30:26 +08:00
printempw
bffdb151ea Fix MarketControllerTest::testGetMarketData 2018-08-12 21:06:47 +08:00
printempw
59c9a599fc Add tests for plugin marketplace 2018-08-12 20:31:03 +08:00
printempw
5fa78b622f Fix PluginControllerTest::testGetPluginData 2018-08-12 14:54:11 +08:00
printempw
a3e65515f6 Add plugin marketplace 2018-08-12 12:25:40 +08:00
printempw
74d5a98f6d Fix test of getting plugin data 2018-08-11 23:36:28 +08:00
printempw
42a2cff7da Fix datatable column width on plugin manage page 2018-08-11 23:34:01 +08:00
printempw
9400d6f7c1 Retrieve plugin dependencies from datatable 2018-08-11 23:30:39 +08:00
printempw
70d59642b1 Update style of plugin manage page 2018-08-10 20:05:35 +08:00
printempw
7c7b8873de Update gulpfile.js 2018-08-09 23:36:12 +08:00
printempw
bb7dee63e4 Remove source mappings in front-end libraries 2018-08-09 16:38:12 +08:00
printempw
f10f5868bb Use POST method to get data for DataTables 2018-08-09 14:35:39 +08:00
printempw
d2642eea92 Fix handling drag and drop event on FireFox
See: https://github.com/kartik-v/bootstrap-fileinput/issues/1283
2018-08-08 22:13:26 +08:00
printempw
9e579a7cf4 Fix initializing skinview3d with specific model 2018-08-08 17:23:35 +08:00
printempw
a53af2a328 Tweak logs of setup wizard and update wizard 2018-08-08 13:17:52 +08:00
printempw
2e1b98007e Preserve logs when APP_DEBUG is set to false 2018-08-08 13:06:21 +08:00
printempw
6cb200bc39 Add integrity check for new columns in users table 2018-08-08 12:54:54 +08:00
printempw
3c840aca46 Make it still basically functional if new columns missing 2018-08-08 12:40:14 +08:00
printempw
5119a51012 Support more mail drivers 2018-08-08 00:46:43 +08:00
printempw
395670dd60 Prevent indexing session directory 2018-08-08 00:46:17 +08:00
printempw
1da1388079 Use guzzle to download update packages 2018-08-08 00:02:42 +08:00
printempw
4d8da4dce6 Remove Utils::download and Utils::getRemoteFileSize 2018-08-07 23:49:38 +08:00
printempw
cb7f4d9806 Add certificate and user agent config for http requests 2018-08-07 18:15:39 +08:00
printempw
32ad99f620 Add new package guzzlehttp/guzzle 2018-08-07 18:13:43 +08:00
printempw
1f63e2d24b Fix Hook::registerPluginTransScripts method 2018-08-07 13:17:28 +08:00
printempw
60a24c03b0 Deprecate Utils class and use helper functions instead 2018-08-07 11:52:05 +08:00
printempw
adb6aed94a Tweak code style of App\Services\Hook 2018-08-07 10:59:15 +08:00
printempw
72f6dc2bd0 Fix HTTP method of user signing
This follows commit 6f6e0a938aaf88b7b31eb25150033cfb5713f51b on branch v4.
2018-08-07 10:40:20 +08:00
printempw
b5e060980a Only catch QueryException when getting options
This partly reverts commit c53dafeb6741a722aa02b199b88666161ce33d12.
2018-08-06 15:12:56 +08:00
printempw
11ac5cbfc7 Remove "Expires" header from png responses 2018-08-05 16:47:50 +08:00
printempw
2bf7fe1239 Fix timezone of Last-Modified header 2018-08-05 16:38:46 +08:00
printempw
aafbc33664 Add helper function format_http_date 2018-08-05 16:37:28 +08:00
printempw
cdcbde29f4 Remove DetectLanguagePrefer middleware from static routes 2018-08-05 16:25:37 +08:00
printempw
9ee1f286a7 Support model detection when uploading textures 2018-07-31 16:46:15 +08:00
printempw
298791a4e5 Update text of bootstrap-fileinput DnD zone 2018-07-31 16:43:10 +08:00
printempw
d00407b410 Update front-end dependencies 2018-07-31 16:41:13 +08:00
printempw
d30b2cb4c6 Add quick-apply button to skinlib.show page 2018-07-31 11:50:36 +08:00
printempw
193fb75ec7 Add notice for applying textures from closet 2018-07-31 11:16:26 +08:00
printempw
2e5249d5c5 Add patch to fix getting column list from MySQL
See: https://github.com/laravel/framework/issues/20190
2018-07-31 10:42:45 +08:00
printempw
ccf6598ddc Allow overriding front-end translations 2018-07-28 21:09:28 +08:00
printempw
f40947c688 Add a option for requiring player name when register 2018-07-28 16:37:44 +08:00
printempw
7b8086b25b Stick version of doctrine/instantiator to 1.0.5 2018-07-28 01:42:49 +08:00
printempw
4904e1c2a4 Add option to disable email verification 2018-07-28 00:58:50 +08:00
printempw
0e70a88e13 Use random generated token for password resetting 2018-07-28 00:42:09 +08:00
printempw
e2575f8a9a Add helper function generate_random_token 2018-07-27 18:46:32 +08:00
printempw
459a232c26 Forward front-end query string parameters to datatables 2018-07-27 17:11:07 +08:00
printempw
fd5d8a06ce Enhance rate limit for sending password reset email 2018-07-27 16:47:49 +08:00
printempw
bcd4b059d5 Fix interacting with cache in tests 2018-07-27 14:26:32 +08:00
printempw
3c8f0c9e22 Add admin operation to change user's verification status 2018-07-27 12:54:55 +08:00
printempw
6c66898fc9 Update composer dependencies 2018-07-27 12:15:02 +08:00
printempw
a91d750b5c Fix migration on SQLite database 2018-07-27 02:34:35 +08:00
printempw
aaf701f364 Revert "Fix compatibility with SQLite database"
This reverts commit 4ad9d9e33df83f57058941e47b6d38649b2870d1.

Fucking SQLite.
2018-07-27 02:20:12 +08:00
printempw
d08996e509 Add tests for user email verification 2018-07-27 02:00:54 +08:00
printempw
58987edd12 Update User model 2018-07-27 01:50:55 +08:00
printempw
7fc3d2443b Hide actual remain time of rate limiting 2018-07-27 01:49:20 +08:00
printempw
83fa34eb75 Fix compatibility with SQLite database 2018-07-27 00:26:44 +08:00
printempw
c8b4124535 Update tests for AuthController::handleForgot 2018-07-27 00:25:12 +08:00
printempw
19e6d8575f Require verification after changing email 2018-07-26 18:22:21 +08:00
printempw
ebcc693ab4 Add user email verification 2018-07-26 18:20:02 +08:00
printempw
84f29de969 Add verification to users table 2018-07-26 17:32:24 +08:00
printempw
1490f202b3 Update mail template of password reset 2018-07-25 19:44:50 +08:00
printempw
0c1537446f Update web.config 2018-07-25 17:59:08 +08:00
printempw
a237037ade Update .htaccess 2018-07-25 17:54:42 +08:00
printempw
534224c212 Limit login attempts by IP address 2018-07-25 15:28:59 +08:00
printempw
8e43b185fe Update moderation panel of textures 2018-07-24 17:31:50 +08:00
printempw
e0c7292d35 Support changing texture model from skinlib 2018-07-24 17:22:18 +08:00
printempw
bcf8710019 Update .editorconfig 2018-07-24 16:04:02 +08:00
printempw
53b5c1eee8 Update .gitignore 2018-07-22 20:00:13 +08:00
printempw
271c50afa3 Update gulp zip task 2018-07-22 19:59:56 +08:00
printempw
8f86e768d0 Tweak service providers for code readability 2018-07-22 18:49:41 +08:00
printempw
e83722cd88 Add L10n support for program copyright style 2018-07-22 15:31:06 +08:00
printempw
89ef69ba28 Return 204 instead of 200 for CDN cache 2018-07-22 14:57:30 +08:00
printempw
8f3088bcb3 Update middleware CheckAdministrator 2018-07-22 14:56:15 +08:00
printempw
fe5becf235 Fix typo about Redis scheme 2018-07-22 13:41:57 +08:00
printempw
63d135071d Support connecting to Redis with unix socket 2018-07-22 12:25:54 +08:00
printempw
55c7109b98 Add read permission check for .env 2018-07-22 12:09:18 +08:00
printempw
1597f0fac0 Catch PDOException in OptionRepository 2018-07-22 12:03:54 +08:00
printempw
05cd77e963 Adjust service providers 2018-07-22 11:20:15 +08:00
printempw
5ae53acbb9 Throw PrettyPageException when cipher is invalid 2018-07-22 11:10:33 +08:00
printempw
09acc6c7d8 Add custom HTTP-500 error page 2018-07-22 10:39:52 +08:00
printempw
d31e0ebc7e Allow overriding translations 2018-07-22 09:42:46 +08:00
printempw
1eefaa104e Allow overriding views 2018-07-22 09:41:51 +08:00
printempw
c92f86d602 Update tests for TextureController::raw 2018-07-22 00:39:56 +08:00
printempw
df8381b7c6 Add option for denying directly downloading textures 2018-07-22 00:39:56 +08:00
printempw
8454ee4306 Generate random salt and app key by default 2018-07-22 00:39:56 +08:00
printempw
60b870488c Add error control for retrieving textures 2018-07-22 00:39:56 +08:00
printempw
bf9342847f Update path of log files 2018-07-22 00:39:56 +08:00
printempw
b047ee8fda Add report() helper function 2018-07-22 00:39:56 +08:00
LittleStudio-Little_Qiu
b6fb15cc10 Fix translation issue of empty CAPTCHA 2018-07-20 17:30:32 +08:00
printempw
df00c41237 Update .env files 2018-07-19 23:35:54 +08:00
printempw
b7a4259f29 Remove cipher CrazyCrypt1
This cipher was originally added for integration with CrazyLogin,
but now the cipher was included in the crazylogin-integration plugin.
2018-07-19 22:47:18 +08:00
printempw
335e8ca498 Add regexp rule support for texture name 2018-07-12 17:35:50 +08:00
printempw
34842787e3 Remove laravel-debugbar from dependencies
The removed dependencies will be added to the separated plugin.
See: https://github.com/bs-community/blessing-skin-plugins/tree/master/laravel-debugbar
2018-07-12 14:46:06 +08:00
printempw
eb62bd0b6f Use mail.driver to determine whether password reset is disabled 2018-07-12 14:45:37 +08:00
printempw
2a35e83939 Log the exceptions thrown by email service in detail 2018-07-11 23:31:20 +08:00
195 changed files with 9108 additions and 2554 deletions

View File

@ -9,5 +9,5 @@ indent_style = space
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
[*.{php,js,md,styl}] [*.{php,tpl,js,md,styl}]
indent_size = 4 indent_size = 4

View File

@ -1,5 +1,5 @@
################################### ###################################
# Blessing Skin Server V3 # # Blessing Skin Server v3 #
# Configuration # # Configuration #
################################### ###################################
@ -7,72 +7,71 @@
# 请访问 http://t.cn/RRZ1OWd 查看中文教程 # 请访问 http://t.cn/RRZ1OWd 查看中文教程
# Be sure to disable debug at production environment! # Be sure to disable debug at production environment!
APP_DEBUG = false APP_ENV = production
APP_ENV = production APP_DEBUG = false
# ========================= # Database Configuration
# = Database = DB_CONNECTION = mysql
# ========================= DB_HOST = localhost
DB_CONNECTION = mysql DB_PORT = 3306
DB_HOST = localhost DB_DATABASE = skin
DB_PORT = 3306 DB_USERNAME = username
DB_DATABASE = blessing-skin DB_PASSWORD = secret
DB_USERNAME = username
DB_PASSWORD = secret
# =========================
# Table Prefix # Table Prefix
# #
# Enable if you want to install multiple Blessing Skin Server into one database. # Change if you want to install multiple BS instances into one database.
# It should only contains characters, numbers and underscores. # The prefix may only contain letters, numbers, and underscores.
# #
DB_PREFIX = null DB_PREFIX = null
# Encrypt Method for Passwords. # Hash Algorithm for Passwords
# #
# Available values: # Available values:
# - PHP_PASSWORD_HASH, # - PHP_PASSWORD_HASH
# - (SALTED2)MD5, # - MD5, SALTED2MD5
# - (SALTED2)SHA256, # - SHA256, SALTED2SHA256
# - (SALTED2)SHA512, # - SHA512, SALTED2SHA512
# - CrazyCrypt1
# #
# New sites are *highly* recommend to use PHP_PASSWORD_HASH. # New sites are *highly* recommended to use PHP_PASSWORD_HASH.
# #
PWD_METHOD = PHP_PASSWORD_HASH PWD_METHOD = PHP_PASSWORD_HASH
# Salt # Salt
# Change it to any random string to secure your passwords & tokens.
# #
# Change it to any random string to secure your passwords & tokens.
# You can run [php artisan salt:random] to generate a new salt. # You can run [php artisan salt:random] to generate a new salt.
# #
SALT = 2c5ca184f017a9a1ffbd198ef69b0c0e SALT = bs893tnok114514tdkr1919yj810snpi
# App Key should be setted to any random, *32 character* string, # App Key
#
# Should be formatted as `"base64:".base64_encode(random_bytes(32))`.
# Run [php artisan key:generate] to generate a new app key,
# otherwise all the encrypted strings will not be safe. # otherwise all the encrypted strings will not be safe.
# #
# You can run [php artisan key:generate] to generate a new key. APP_KEY = base64:MfnScX0W/ViN8bZtRt0P481rWP3igcOK80QstjbXUxI=
#
APP_KEY = base64:gkb/zouNF6UOSfnr/o+izVMS57WQS3+62YqZBuDyBhU=
# Mail Configurations # Mail Configuration
# Leave MAIL_HOST empty to disable password resetting #
MAIL_DRIVER = smtp # Leave MAIL_DRIVER empty to disable features involving sending emails.
MAIL_HOST = null #
MAIL_PORT = 465 MAIL_DRIVER = smtp
MAIL_USERNAME = null MAIL_HOST = null
MAIL_PASSWORD = null MAIL_PORT = 465
MAIL_USERNAME = null
MAIL_PASSWORD = null
MAIL_ENCRYPTION = ssl MAIL_ENCRYPTION = ssl
# Change below lines only if you know what they mean! # Change below lines only if you know what they mean!
CACHE_DRIVER = file CACHE_DRIVER = file
SESSION_DRIVER = file SESSION_DRIVER = file
QUEUE_DRIVER = sync QUEUE_DRIVER = sync
FS_DRIVER = local FS_DRIVER = local
REDIS_HOST = 127.0.0.1 REDIS_HOST = 127.0.0.1
REDIS_PASSWORD = null REDIS_PASSWORD = null
REDIS_PORT = 6379 REDIS_PORT = 6379
PLUGINS_DIR = null PLUGINS_DIR = null
PLUGINS_URL = null PLUGINS_URL = null

View File

@ -1,36 +1,38 @@
################################### ###################################
# Blessing Skin Server V3 # # Blessing Skin Server v3 #
# Testing Configuration # # Testing Configuration #
################################### ###################################
APP_DEBUG = true APP_ENV = testing
APP_ENV = testing APP_DEBUG = true
DB_CONNECTION = mysql DB_CONNECTION = mysql
# Connect to MySQL server via TCP/IP instead of Unix Domain Socket DB_HOST = 127.0.0.1
DB_HOST = 127.0.0.1 DB_PORT = 3306
DB_PORT = 3306 DB_DATABASE = test
DB_DATABASE = test DB_USERNAME = root
DB_USERNAME = root DB_PASSWORD = null
DB_PASSWORD = null DB_PREFIX = null
DB_PREFIX = null
PWD_METHOD = PHP_PASSWORD_HASH
SALT = c67709dd8b7b733aca0d570681fe96cf PWD_METHOD = PHP_PASSWORD_HASH
APP_KEY = base64:eVX/xzF5NhpGB2luswliFx9XSBsbbAP21wOi68X/P34= SALT = bs893tnok114514tdkr1919yj810snpi
APP_KEY = base64:MfnScX0W/ViN8bZtRt0P481rWP3igcOK80QstjbXUxI=
MAIL_DRIVER = smtp MAIL_DRIVER = smtp
MAIL_HOST = localhost MAIL_HOST = localhost
MAIL_PORT = 465 MAIL_PORT = 465
MAIL_USERNAME = null MAIL_USERNAME = null
MAIL_PASSWORD = null MAIL_PASSWORD = null
MAIL_ENCRYPTION = ssl MAIL_ENCRYPTION = ssl
CACHE_DRIVER = array CACHE_DRIVER = array
SESSION_DRIVER = array SESSION_DRIVER = array
QUEUE_DRIVER = sync QUEUE_DRIVER = sync
FS_DRIVER = memory FS_DRIVER = memory
REDIS_HOST = 127.0.0.1 REDIS_HOST = 127.0.0.1
REDIS_PASSWORD = null REDIS_PASSWORD = null
REDIS_PORT = 6379 REDIS_PORT = 6379
PLUGINS_DIR = null
PLUGINS_URL = null

View File

@ -1,4 +1,3 @@
node_modules/ node_modules/
resources/assets/dist/ resources/assets/dist/
resources/assets/src/vendor/ resources/assets/src/vendor/
gulpfile.js

2
.gitattributes vendored
View File

@ -1,2 +0,0 @@
* text=auto
*.tpl linguist-language=php

2
.gitignore vendored
View File

@ -1,10 +1,8 @@
.DS_Store .DS_Store
.env .env
.sass-cache
coverage coverage
.idea/ .idea/
vendor/* vendor/*
storage/textures/*
storage/update_cache/* storage/update_cache/*
node_modules/* node_modules/*
yarn-error.log yarn-error.log

View File

@ -1,10 +1,26 @@
RewriteEngine On <IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteBase / RewriteEngine On
RewriteRule (^\.|/\.) - [F] # You may need to uncomment the following line for some hosting environments,
# if you have installed to a subdirectory, enter the name here also.
#
# RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f # Black list protected files
RewriteCond %{REQUEST_FILENAME} !-d RewriteRule (^\.|/\.) - [F]
RewriteRule ^storage/.* - [F]
RewriteRule ^.*$ index.php [L] # Redirect trailing slashes if not a folder
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Handle front controller
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

View File

@ -6,7 +6,6 @@ git:
cache: cache:
directories: directories:
- vendor - vendor
- plugins
- node_modules - node_modules
env: env:

View File

@ -5,6 +5,7 @@ namespace App\Exceptions;
use Exception; use Exception;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use App\Exceptions\PrettyPageException; use App\Exceptions\PrettyPageException;
use Illuminate\Session\TokenMismatchException;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Validation\ValidationException; use Illuminate\Foundation\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
@ -22,6 +23,7 @@ class Handler extends ExceptionHandler
protected $dontReport = [ protected $dontReport = [
HttpException::class, HttpException::class,
ModelNotFoundException::class, ModelNotFoundException::class,
TokenMismatchException::class,
ValidationException::class, ValidationException::class,
PrettyPageException::class, PrettyPageException::class,
MethodNotAllowedHttpException::class, MethodNotAllowedHttpException::class,
@ -52,7 +54,14 @@ class Handler extends ExceptionHandler
} }
if ($e instanceof MethodNotAllowedHttpException) { if ($e instanceof MethodNotAllowedHttpException) {
abort(403, 'Method not allowed.'); abort(403, trans('errors.http.method-not-allowed'));
}
if ($e instanceof TokenMismatchException) {
if (request()->ajax()) {
return json(trans('errors.http.csrf-token-mismatch'), 1);
}
abort(403, trans('errors.http.csrf-token-mismatch'));
} }
if ($e instanceof PrettyPageException) { if ($e instanceof PrettyPageException) {
@ -79,6 +88,40 @@ class Handler extends ExceptionHandler
} }
} }
/**
* Render the given HttpException.
*
* @param \Symfony\Component\HttpKernel\Exception\HttpException $e
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function renderHttpException(HttpException $e)
{
$status = $e->getStatusCode();
$message = $e->getMessage();
// Get message from exception itself > translation > standard status texts
if (! $message) {
if (trans()->has($transKey = "errors.http.msg-{$status}")) {
$message = trans($transKey);
} else {
$message = array_get(Response::$statusTexts, $status, "Status code: $status");
}
}
if (request()->ajax()) {
return response($message, $status, $e->getHeaders());
}
if (view()->exists("errors.{$status}")) {
return response()->view("errors.{$status}", ['exception' => $e], $status, $e->getHeaders());
}
return response()->view('errors.http', [
'title' => "HTTP {$status}",
'message' => $message
], $status, $e->getHeaders());
}
/** /**
* Render an exception using Whoops. * Render an exception using Whoops.
* *
@ -91,28 +134,28 @@ class Handler extends ExceptionHandler
{ {
$whoops = new \Whoops\Run; $whoops = new \Whoops\Run;
$handler = (request()->isMethod('GET')) ? $handler = (request()->isMethod('GET')) ?
new \Whoops\Handler\PrettyPageHandler : new \Whoops\Handler\PlainTextHandler; new \Whoops\Handler\PrettyPageHandler : new \Whoops\Handler\PlainTextHandler;
$whoops->pushHandler($handler); $whoops->pushHandler($handler);
return new Response( return response($whoops->handleException($e), $code, $headers);
$whoops->handleException($e),
$code,
$headers
);
} }
/** /**
* Render an exception in a short word. * Render an exception with error messages only.
* *
* @param Exception $e * @param Exception $e
* @param int $code
* @param array $headers
* @return Response * @return Response
*/ */
protected function renderExceptionInBrief(Exception $e) protected function renderExceptionInBrief(Exception $e, $code = 200, $headers = [])
{ {
if (request()->isMethod('GET') && !request()->ajax()) { if (request()->ajax()) {
return response()->view('errors.exception', ['message' => $e->getMessage()]); return response($e->getMessage(), $code, $headers);
} else {
return $e->getMessage();
} }
return response()->view('errors.exception', [
'message' => $e->getMessage()
], $code, $headers);
} }
} }

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Option; use Option;
use Schema;
use Datatables; use Datatables;
use App\Events; use App\Events;
use Carbon\Carbon; use Carbon\Carbon;
@ -95,9 +96,8 @@ class AdminController extends Controller
$form->textarea('copyright_text')->rows(6)->description(); $form->textarea('copyright_text')->rows(6)->description();
})->with('copyright_text', })->handle(function () {
option('copyright_text_'.config('app.locale'), option('copyright_text')) Option::set('copyright_prefer_'.config('app.locale'), request('copyright_prefer'));
)->handle(function () {
Option::set('copyright_text_'.config('app.locale'), request('copyright_text')); Option::set('copyright_text_'.config('app.locale'), request('copyright_text'));
}); });
@ -172,6 +172,8 @@ class AdminController extends Controller
}); });
$form->checkbox('user_can_register')->label(); $form->checkbox('user_can_register')->label();
$form->checkbox('register_with_player_name')->label();
$form->checkbox('require_verification')->label();
$form->text('regs_per_ip'); $form->text('regs_per_ip');
@ -204,6 +206,10 @@ class AdminController extends Controller
$form->checkbox('auto_del_invalid_texture')->label()->hint(); $form->checkbox('auto_del_invalid_texture')->label()->hint();
$form->checkbox('allow_downloading_texture')->label();
$form->text('texture_name_regexp')->hint()->placeholder();
$form->textarea('comment_script')->rows(6)->description(); $form->textarea('comment_script')->rows(6)->description();
$form->checkbox('allow_sending_statistics')->label()->hint(); $form->checkbox('allow_sending_statistics')->label()->hint();
@ -223,7 +229,7 @@ class AdminController extends Controller
{ {
$form->checkbox('force_ssl')->label()->hint(); $form->checkbox('force_ssl')->label()->hint();
$form->checkbox('auto_detect_asset_url')->label()->description(); $form->checkbox('auto_detect_asset_url')->label()->description();
$form->checkbox('return_200_when_notfound')->label()->description(); $form->checkbox('return_204_when_notfound')->label()->description();
$form->text('cache_expire_time')->hint(OptionForm::AUTO_DETECT); $form->text('cache_expire_time')->hint(OptionForm::AUTO_DETECT);
@ -245,24 +251,27 @@ class AdminController extends Controller
public function getUserData(Request $request) public function getUserData(Request $request)
{ {
$users = collect(); // Make it still basically functional if new columns are not ready
$columns = array_merge([
'uid', 'email', 'nickname', 'score', 'permission', 'register_at'
], Schema::hasColumn('users', 'verified') ? ['verified'] : []);
$query = User::select($columns);
if ($request->has('uid')) { if ($request->has('uid')) {
$users = User::select(['uid', 'email', 'nickname', 'score', 'permission', 'register_at']) $query->where('uid', $request->get('uid'));
->where('uid', intval($request->input('uid')));
} else {
$users = User::select(['uid', 'email', 'nickname', 'score', 'permission', 'register_at']);
} }
return Datatables::of($users)->editColumn('email', function ($user) { return Datatables::of($query)
return $user->email ?: 'EMPTY'; ->setRowId('uid')
}) ->editColumn('email', function ($user) {
->setRowId('uid') return $user->email ?: 'EMPTY';
->addColumn('operations', app('user.current')->getPermission()) })
->addColumn('players_count', function ($user) { ->addColumn('operations', app('user.current')->getPermission())
return Player::where('uid', $user->uid)->count(); ->addColumn('players_count', function ($user) {
}) return Player::where('uid', $user->uid)->count();
->make(true); })
->make(true);
} }
/** /**
@ -278,15 +287,19 @@ class AdminController extends Controller
public function getPlayerData(Request $request) public function getPlayerData(Request $request)
{ {
$players = collect(); $query = Player::select([
'pid', 'uid', 'player_name', 'preference', 'tid_steve', 'tid_alex', 'tid_cape', 'last_modified'
]);
if ($request->has('uid')) { if ($request->has('uid')) {
$players = Player::select(['pid', 'uid', 'player_name', 'preference', 'tid_steve', 'tid_alex', 'tid_cape', 'last_modified']) $query->where('uid', $request->get('uid'));
->where('uid', intval($request->input('uid')));
} else {
$players = Player::select(['pid', 'uid', 'player_name', 'preference', 'tid_steve', 'tid_alex', 'tid_cape', 'last_modified']);
} }
return Datatables::of($players)->setRowId('pid')->make(true); if ($request->has('pid')) {
$query->where('pid', $request->get('pid'));
}
return Datatables::of($query)->setRowId('pid')->make(true);
} }
/** /**
@ -323,6 +336,13 @@ class AdminController extends Controller
return json(trans('admin.users.operations.email.success'), 0); return json(trans('admin.users.operations.email.success'), 0);
} elseif ($action == "verification") {
$user->verified = !$user->verified;
$user->save();
return json(trans('admin.users.operations.verification.success'), 0);
} elseif ($action == "nickname") { } elseif ($action == "nickname") {
$this->validate($request, [ $this->validate($request, [
'nickname' => 'required|no_special_chars' 'nickname' => 'required|no_special_chars'
@ -444,7 +464,7 @@ class AdminController extends Controller
return json(trans('admin.players.delete.success'), 0); return json(trans('admin.players.delete.success'), 0);
} elseif ($action == "name") { } elseif ($action == "name") {
$this->validate($request, [ $this->validate($request, [
'name' => 'required' 'name' => 'required|player_name|min:'.option('player_name_length_min').'|max:'.option('player_name_length_max')
]); ]);
$player->rename($request->input('name')); $player->rename($request->input('name'));

View File

@ -5,12 +5,14 @@ namespace App\Http\Controllers;
use Log; use Log;
use Mail; use Mail;
use View; use View;
use Utils; use Cache;
use Cookie; use Cookie;
use Option; use Option;
use Schema;
use Session; use Session;
use App\Events; use App\Events;
use App\Models\User; use App\Models\User;
use App\Models\Player;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Exceptions\PrettyPageException; use App\Exceptions\PrettyPageException;
use App\Services\Repositories\UserRepository; use App\Services\Repositories\UserRepository;
@ -41,7 +43,11 @@ class AuthController extends Controller
// it will return a null value. // it will return a null value.
$user = $users->get($identification, $authType); $user = $users->get($identification, $authType);
if (session('login_fails', 0) > 3) { // Require CAPTCHA if user fails to login more than 3 times
$loginFailsCacheKey = sha1('login_fails_'.get_client_ip());
$loginFails = (int) Cache::get($loginFailsCacheKey, 0);
if ($loginFails > 3) {
if (strtolower($request->input('captcha')) != strtolower(session('phrase'))) if (strtolower($request->input('captcha')) != strtolower(session('phrase')))
return json(trans('auth.validation.captcha'), 1); return json(trans('auth.validation.captcha'), 1);
} }
@ -50,7 +56,7 @@ class AuthController extends Controller
return json(trans('auth.validation.user'), 2); return json(trans('auth.validation.user'), 2);
} else { } else {
if ($user->verifyPassword($request->input('password'))) { if ($user->verifyPassword($request->input('password'))) {
Session::forget('login_fails'); Cache::forget($loginFailsCacheKey);
Session::put('uid' , $user->uid); Session::put('uid' , $user->uid);
Session::put('token', $user->getToken()); Session::put('token', $user->getToken());
@ -68,10 +74,11 @@ class AuthController extends Controller
->withCookie('uid', $user->uid, $time) ->withCookie('uid', $user->uid, $time)
->withCookie('token', $user->getToken(), $time); ->withCookie('token', $user->getToken(), $time);
} else { } else {
Session::put('login_fails', session('login_fails', 0) + 1); // Increase the counter
Cache::put($loginFailsCacheKey, ++$loginFails, 60);
return json(trans('auth.validation.password'), 1, [ return json(trans('auth.validation.password'), 1, [
'login_fails' => session('login_fails') 'login_fails' => $loginFails
]); ]);
} }
} }
@ -106,60 +113,82 @@ class AuthController extends Controller
if (! $this->checkCaptcha($request)) if (! $this->checkCaptcha($request))
return json(trans('auth.validation.captcha'), 1); return json(trans('auth.validation.captcha'), 1);
$this->validate($request, [ if (! option('user_can_register'))
'email' => 'required|email',
'password' => 'required|min:8|max:32',
'nickname' => 'required|no_special_chars|max:255'
]);
if (! option('user_can_register')) {
return json(trans('auth.register.close'), 7); return json(trans('auth.register.close'), 7);
// Validate nickname or player name
$rule = option('register_with_player_name') ?
['player_name' => 'required|player_name|min:'.option('player_name_length_min').'|max:'.option('player_name_length_max')] :
['nickname' => 'required|no_special_chars|max:255'];
$this->validate($request, array_merge([
'email' => 'required|email',
'password' => 'required|min:8|max:32'
], $rule));
if (option('register_with_player_name')) {
event(new Events\CheckPlayerExists($request->get('player_name')));
if (Player::where('player_name', $request->get('player_name'))->first()) {
return json(trans('user.player.add.repeated'), 2);
}
} }
// If amount of registered accounts of IP is more than allowed amounts, // If amount of registered accounts of IP is more than allowed amounts,
// then reject the register. // then reject the register.
if (User::where('ip', Utils::getClientIp())->count() < option('regs_per_ip')) if (User::where('ip', get_client_ip())->count() >= option('regs_per_ip')) {
{
// Register a new user.
// If the email is already registered,
// it will return a false value.
$user = User::register(
$request->input('email'),
$request->input('password'), function($user) use ($request)
{
$user->ip = Utils::getClientIp();
$user->score = option('user_initial_score');
$user->register_at = Utils::getTimeFormatted();
$user->last_sign_at = Utils::getTimeFormatted(time() - 86400);
$user->permission = User::NORMAL;
$user->nickname = $request->input('nickname');
});
if (! $user) {
return json(trans('auth.register.registered'), 5);
}
event(new Events\UserRegistered($user));
return json([
'errno' => 0,
'msg' => trans('auth.register.success'),
'token' => $user->getToken(),
]) // Set cookies
->withCookie('uid', $user->uid, 60)
->withCookie('token', $user->getToken(), 60);
} else {
return json(trans('auth.register.max', ['regs' => option('regs_per_ip')]), 7); return json(trans('auth.register.max', ['regs' => option('regs_per_ip')]), 7);
} }
// Register a new user.
// If the email is already registered,
// it will return a false value.
$user = User::register(
$request->get('email'),
$request->get('password'), function($user) use ($request)
{
$user->ip = get_client_ip();
$user->score = option('user_initial_score');
$user->register_at = get_datetime_string();
$user->last_sign_at = get_datetime_string(time() - 86400);
$user->permission = User::NORMAL;
$user->nickname = $request->get(
option('register_with_player_name') ? 'player_name' : 'nickname'
);
});
if (! $user) {
return json(trans('auth.register.registered'), 5);
}
event(new Events\UserRegistered($user));
// Add player with chosen name
if (option('register_with_player_name')) {
$player = new Player;
$player->uid = $user->uid;
$player->player_name = $request->get('player_name');
$player->preference = 'default';
$player->last_modified = get_datetime_string();
$player->save();
event(new Events\PlayerWasAdded($player));
}
return json([
'errno' => 0,
'msg' => trans('auth.register.success'),
'token' => $user->getToken(),
]) // Set cookies
->withCookie('uid', $user->uid, 60)
->withCookie('token', $user->getToken(), 60);
} }
public function forgot() public function forgot()
{ {
if (config('mail.host') != "") { if (config('mail.driver') != "") {
return view('auth.forgot'); return view('auth.forgot');
} else { } else {
throw new PrettyPageException(trans('auth.forgot.close'), 8); throw new PrettyPageException(trans('auth.forgot.disabled'), 8);
} }
} }
@ -168,11 +197,22 @@ class AuthController extends Controller
if (! $this->checkCaptcha($request)) if (! $this->checkCaptcha($request))
return json(trans('auth.validation.captcha'), 1); return json(trans('auth.validation.captcha'), 1);
if (config('mail.host') == "") if (! config('mail.driver')) {
return json(trans('auth.forgot.close'), 1); return json(trans('auth.forgot.disabled'), 1);
}
if (Session::has('last_mail_time') && (time() - session('last_mail_time')) < 60) $rateLimit = 180;
return json(trans('auth.forgot.frequent-mail'), 1); $lastMailCacheKey = sha1('last_mail_'.get_client_ip());
$remain = $rateLimit + Cache::get($lastMailCacheKey, 0) - time();
// Rate limit
if ($remain > 0) {
return json([
'errno' => 2,
'msg' => trans('auth.forgot.frequent-mail'),
'remain' => $remain
]);
}
// Get user instance // Get user instance
$user = $users->get($request->input('email'), 'email'); $user = $users->get($request->input('email'), 'email');
@ -182,55 +222,48 @@ class AuthController extends Controller
$uid = $user->uid; $uid = $user->uid;
// Generate token for password resetting // Generate token for password resetting
$token = base64_encode($user->getToken().substr(time(), 4, 6).str_random(16)); $token = generate_random_token();
$url = Option::get('site_url')."/auth/reset?uid=$uid&token=$token"; $url = Option::get('site_url')."/auth/reset?uid=$uid&token=$token";
try { try {
Mail::send('auth.mail', ['reset_url' => $url], function ($m) use ($request) { Mail::send('mails.password-reset', compact('url'), function ($m) use ($request) {
$site_name = Option::get('site_name'); $site_name = option_localized('site_name');
$m->from(config('mail.username'), $site_name); $m->from(config('mail.username'), $site_name);
$m->to($request->input('email'))->subject(trans('auth.mail.title', ['sitename' => $site_name])); $m->to($request->input('email'))->subject(trans('auth.forgot.mail.title', ['sitename' => $site_name]));
}); });
} catch (\Exception $e) {
// Write the exception to log
report($e);
Log::info("[Password Reset] Mail has been sent to [{$request->input('email')}] with token [$token]"); return json(trans('auth.forgot.failed', ['msg' => $e->getMessage()]), 2);
} catch(\Exception $e) {
return json(trans('auth.mail.failed', ['msg' => $e->getMessage()]), 2);
} }
Session::put('last_mail_time', time()); Cache::put("pwd_reset_token_$uid", $token, 60);
Cache::put($lastMailCacheKey, time(), 60);
return json(trans('auth.mail.success'), 0); return json(trans('auth.forgot.success'), 0);
} }
public function reset(UserRepository $users, Request $request) public function reset(UserRepository $users, Request $request)
{ {
if ($request->has('uid') && $request->has('token')) { // Retrieve token from cache
// Get user instance from repository $uid = $request->get('uid');
$user = $users->get($request->input('uid')); $token = Cache::get("pwd_reset_token_$uid");
if (! $user) // Get user instance from repository
return redirect('auth/forgot')->with('msg', trans('auth.reset.invalid')); $user = $users->get($uid);
// Unpack to get user token & timestamp if (! $user) {
$decoded = base64_decode($request->input('token')); return redirect('auth/forgot')->with('msg', trans('auth.reset.invalid'));
$token = substr($decoded, 0, -22);
$timestamp = substr($decoded, strlen($token), 6);
if ($user->getToken() != $token) {
return redirect('auth/forgot')->with('msg', trans('auth.reset.invalid'));
}
// More than 1 hour
if ((substr(time(), 4, 6) - $timestamp) > 3600) {
return redirect('auth/forgot')->with('msg', trans('auth.reset.expired'));
}
return view('auth.reset')->with('user', $user);
} else {
return redirect('auth/login')->with('msg', trans('auth.check.anonymous'));
} }
// No token exist or token mismatch (maybe expired)
if (is_null($token) || $token != $request->get('token')) {
return redirect('auth/forgot')->with('msg', trans('auth.reset.expired'));
}
return view('auth.reset')->with('user', $user);
} }
public function handleReset(Request $request, UserRepository $users) public function handleReset(Request $request, UserRepository $users)
@ -241,30 +274,50 @@ class AuthController extends Controller
'token' => 'required', 'token' => 'required',
]); ]);
$decoded = base64_decode($request->input('token')); // Retrieve token from cache
$token = substr($decoded, 0, -22); $uid = $request->get('uid');
$timestamp = intval(substr($decoded, strlen($token), 6)); $token = Cache::get("pwd_reset_token_$uid");
$user = $users->get($request->input('uid')); // Get user instance from repository
if (! $user) $user = $users->get($uid);
return json(trans('auth.reset.invalid'), 1);
if ($user->getToken() != $token) { if (! $user) {
return json(trans('auth.reset.invalid'), 1); return json(trans('auth.reset.invalid'), 1);
} }
// More than 1 hour // No token exist or token mismatch (maybe expired)
if ((intval(substr(time(), 4, 6)) - $timestamp) > 3600) { if (is_null($token) || $token != $request->get('token')) {
return json(trans('auth.reset.expired'), 1); return json(trans('auth.reset.expired'), 1);
} }
$users->get($request->input('uid'))->changePasswd($request->input('password')); $user->changePasswd($request->get('password'));
Log::info("[Password Reset] Password of user [{$request->input('uid')}] has been changed");
return json(trans('auth.reset.success'), 0); return json(trans('auth.reset.success'), 0);
} }
public function verify(Request $request, UserRepository $users)
{
if (!option('require_verification') || !Schema::hasColumn('users', 'verified')) {
throw new PrettyPageException(trans('user.verification.disabled'), 1);
}
// Get user instance from repository
$user = $users->get($request->get('uid'));
if (!$user || $user->verified) {
throw new PrettyPageException(trans('auth.verify.invalid'), 1);
}
if ($user->verification_token != $request->get('token')) {
throw new PrettyPageException(trans('auth.verify.expired'), 1);
}
$user->verified = true;
$user->save();
return view('auth.verify');
}
public function captcha() public function captcha()
{ {
$builder = new \Gregwar\Captcha\CaptchaBuilder; $builder = new \Gregwar\Captcha\CaptchaBuilder;

View File

@ -0,0 +1,162 @@
<?php
namespace App\Http\Controllers;
use Exception;
use Datatables;
use ZipArchive;
use Illuminate\Http\Request;
use Composer\Semver\Comparator;
use App\Services\PluginManager;
class MarketController extends Controller
{
/**
* Guzzle HTTP client.
*
* @var \GuzzleHttp\Client
*/
protected $guzzle;
/**
* Default request options for Guzzle HTTP client.
*
* @var array
*/
protected $guzzleConfig;
/**
* Cache for plugins registry.
*
* @var array
*/
protected $registryCache;
public function __construct(\GuzzleHttp\Client $guzzle)
{
$this->guzzle = $guzzle;
$this->guzzleConfig = [
'headers' => ['User-Agent' => config('secure.user_agent')],
'verify' => config('secure.certificates')
];
}
public function showMarket()
{
return view('admin.market');
}
public function getMarketData()
{
$plugins = collect($this->getAllAvailablePlugins())->map(function ($item) {
$plugin = plugin($item['name']);
$manager = app('plugins');
if ($plugin) {
$item['enabled'] = $plugin->isEnabled();
$item['installed'] = $plugin->version;
$item['update_available'] = Comparator::greaterThan($item['version'], $item['installed']);
} else {
$item['installed'] = false;
}
$requirements = array_get($item, 'require', []);
unset($item['require']);
$item['dependencies'] = [
'isRequirementsSatisfied' => $manager->isRequirementsSatisfied($requirements),
'requirements' => $requirements,
'unsatisfiedRequirements' => $manager->getUnsatisfiedRequirements($requirements)
];
return $item;
});
return Datatables::of($plugins)->setRowId('plugin-{{ $name }}')->make(true);
}
public function checkUpdates()
{
$pluginsHaveUpdate = collect($this->getAllAvailablePlugins())->filter(function ($item) {
$plugin = plugin($item['name']);
return $plugin && Comparator::greaterThan($item['version'], $plugin->version);
});
return json([
'available' => !$pluginsHaveUpdate->isEmpty(),
'plugins' => array_values($pluginsHaveUpdate->all())
]);
}
public function download(Request $request, PluginManager $manager)
{
$name = $request->get('name');
$metadata = $this->getPluginMetadata($name);
if (! $metadata) {
return json(trans('admin.plugins.market.non-existent', ['plugin' => $name]), 1);
}
// Gather plugin distribution URL
$url = $metadata['dist']['url'];
$filename = array_last(explode('/', $url));
$plugins_dir = $manager->getPluginsDir();
$tmp_path = $plugins_dir.DIRECTORY_SEPARATOR.$filename;
// Download
try {
$this->guzzle->request('GET', $url, array_merge($this->guzzleConfig, [
'sink' => $tmp_path
]));
} catch (Exception $e) {
report($e);
return json(trans('admin.plugins.market.download-failed', ['error' => $e->getMessage()]), 2);
}
// Check file's sha1 hash
if (sha1_file($tmp_path) !== $metadata['dist']['shasum']) {
@unlink($tmp_path);
return json(trans('admin.plugins.market.shasum-failed'), 3);
}
// Unzip
$zip = new ZipArchive();
$res = $zip->open($tmp_path);
if ($res === true) {
if ($zip->extractTo($plugins_dir) === false) {
return json(trans('admin.plugins.market.unzip-failed', ['error' => 'Unable to extract the file.']), 4);
}
} else {
return json(trans('admin.plugins.market.unzip-failed', ['error' => $res]), 4);
}
$zip->close();
@unlink($tmp_path);
return json(trans('admin.plugins.market.install-success'), 0);
}
protected function getPluginMetadata($name)
{
return collect($this->getAllAvailablePlugins())->where('name', $name)->first();
}
protected function getAllAvailablePlugins()
{
if (! $this->registryCache) {
try {
$pluginsJson = $this->guzzle->request(
'GET', config('plugins.registry'), $this->guzzleConfig
)->getBody();
} catch (Exception $e) {
throw new Exception(trans('admin.plugins.market.connection-error', [
'error' => htmlentities($e->getMessage())
]));
}
$this->registryCache = json_decode($pluginsJson, true);
}
return array_get($this->registryCache, 'packages', []);
}
}

View File

@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use View; use View;
use Event; use Event;
use Utils;
use Option; use Option;
use App\Models\User; use App\Models\User;
use App\Models\Player; use App\Models\Player;
@ -79,7 +78,7 @@ class PlayerController extends Controller
$player->uid = $this->user->uid; $player->uid = $this->user->uid;
$player->player_name = $request->input('player_name'); $player->player_name = $request->input('player_name');
$player->preference = "default"; $player->preference = "default";
$player->last_modified = Utils::getTimeFormatted(); $player->last_modified = get_datetime_string();
$player->save(); $player->save();
event(new PlayerWasAdded($player)); event(new PlayerWasAdded($player));
@ -150,6 +149,13 @@ class PlayerController extends Controller
$this->player->setTexture([$fieldName => $value]); $this->player->setTexture([$fieldName => $value]);
} }
// When user applies an alex skin to a newly added player,
// which textures and model preference are all default value,
// we will automatically set the player's model preference to "slim".
if ($this->player->preference == 'default' && $this->player->tid_steve == 0 && $this->player->tid_alex != 0) {
$this->player->setPreference('slim');
}
return json(trans('user.player.set.success', ['name' => $this->player->player_name]), 0); return json(trans('user.player.set.success', ['name' => $this->player->player_name]), 0);
} }

View File

@ -11,16 +11,6 @@ use App\Services\PluginManager;
class PluginController extends Controller class PluginController extends Controller
{ {
/**
* @codeCoverageIgnore
*/
public function showMarket()
{
return redirect('/')->setTargetUrl(
'https://github.com/printempw/blessing-skin-server/wiki/Plugins'
);
}
public function showManage() public function showManage()
{ {
return view('admin.plugins'); return view('admin.plugins');
@ -71,9 +61,6 @@ class PluginController extends Controller
return json(trans('admin.plugins.operations.enabled', ['plugin' => $plugin->title]), 0); return json(trans('admin.plugins.operations.enabled', ['plugin' => $plugin->title]), 0);
case 'requirements':
return json($this->getPluginDependencies($plugin));
case 'disable': case 'disable':
$plugins->disable($name); $plugins->disable($name);
@ -98,24 +85,13 @@ class PluginController extends Controller
return Datatables::of($installed) return Datatables::of($installed)
->setRowId('plugin-{{ $name }}') ->setRowId('plugin-{{ $name }}')
->editColumn('title', function ($plugin) { ->editColumn('title', '{{ trans($title ?: "EMPTY") }}')
return trans($plugin->title ?: 'EMPTY'); ->editColumn('description', '{{ trans($description ?: "EMPTY") }}')
}) ->addColumn('enabled', function ($plugin) { return $plugin->isEnabled(); })
->editColumn('description', function ($plugin) { ->addColumn('config', function ($plugin) { return $plugin->hasConfigView(); })
return trans($plugin->description ?: 'EMPTY');
})
->editColumn('author', function ($plugin) {
return ['author' => trans($plugin->author ?: 'EMPTY'), 'url' => $plugin->url];
})
->addColumn('dependencies', function ($plugin) { ->addColumn('dependencies', function ($plugin) {
return $this->getPluginDependencies($plugin); return $this->getPluginDependencies($plugin);
}) })
->addColumn('status', function ($plugin) {
return trans('admin.plugins.status.'.($plugin->isEnabled() ? 'enabled' : 'disabled'));
})
->addColumn('operations', function ($plugin) {
return ['enabled' => $plugin->isEnabled(), 'hasConfigView' => $plugin->hasConfigView()];
})
->make(true); ->make(true);
} }

View File

@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use Log; use Log;
use File; use File;
use Utils;
use Schema; use Schema;
use Option; use Option;
use Storage; use Storage;
@ -39,7 +38,7 @@ class SetupController extends Controller
// Not installed completely // Not installed completely
if (count($existingTables) > 0) { if (count($existingTables) > 0) {
Log::info('Remaining tables detected, exit setup wizard now', [$existingTables]); Log::info('[SetupWizard] Remaining tables detected, exit now', [$existingTables]);
$existingTables = array_map(function ($item) { $existingTables = array_map(function ($item) {
return get_db_config()['prefix'].$item; return get_db_config()['prefix'].$item;
@ -71,22 +70,19 @@ class SetupController extends Controller
Artisan::call('key:random'); Artisan::call('key:random');
Artisan::call('salt:random'); Artisan::call('salt:random');
Log::info("[SetupWizard] Random application key & salt set successfully.", [ Log::info('[SetupWizard] Random application key & salt set successfully');
'key' => config('app.key'),
'salt' => config('secure.salt')
]);
} else { } else {
// @codeCoverageIgnoreStart // @codeCoverageIgnoreStart
Log::warning("[SetupWizard] Failed to set application key. No write permission."); Log::warning('[SetupWizard] Failed to set application key since .env is not writable');
// @codeCoverageIgnoreEnd // @codeCoverageIgnoreEnd
} }
} }
// Create tables // Create tables
Artisan::call('migrate', ['--force' => true]); Artisan::call('migrate', ['--force' => true]);
Log::info("[SetupWizard] Tables migrated."); Log::info('[SetupWizard] Database migrated');
Option::set('site_name', $request->input('site_name')); Option::set('site_name', $request->get('site_name'));
$siteUrl = url('/'); $siteUrl = url('/');
@ -98,23 +94,23 @@ class SetupController extends Controller
// Register super admin // Register super admin
$user = User::register( $user = User::register(
$request->input('email'), $request->get('email'),
$request->input('password'), function ($user) $request->get('password'), function ($user)
{ {
$user->ip = Utils::getClientIp(); $user->ip = get_client_ip();
$user->score = option('user_initial_score'); $user->score = option('user_initial_score');
$user->register_at = Utils::getTimeFormatted(); $user->register_at = get_datetime_string();
$user->last_sign_at = Utils::getTimeFormatted(time() - 86400); $user->last_sign_at = get_datetime_string(time() - 86400);
$user->permission = User::SUPER_ADMIN; $user->permission = User::SUPER_ADMIN;
}); });
Log::info("[SetupWizard] Super Admin registered.", ['user' => $user]); Log::info('[SetupWizard] Super administrator registered');
$this->createDirectories(); $this->createDirectories();
Log::info("[SetupWizard] Installation completed."); Log::info('[SetupWizard] Installation completed');
return view('setup.wizard.finish')->with([ return view('setup.wizard.finish')->with([
'email' => $request->input('email'), 'email' => $request->get('email'),
'password' => $request->input('password') 'password' => $request->get('password')
]); ]);
} }
@ -174,7 +170,7 @@ class SetupController extends Controller
try { try {
Artisan::call('view:clear'); Artisan::call('view:clear');
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Error occured when processing view:clear', [$e]); Log::error('[UpdateWizard] Error occured when processing view:clear', [$e]);
$files = collect(File::files(storage_path('framework/views'))); $files = collect(File::files(storage_path('framework/views')));
$files->reject(function ($path) { $files->reject(function ($path) {
@ -194,7 +190,8 @@ class SetupController extends Controller
* @param bool $returnExisting * @param bool $returnExisting
* @return bool|array * @return bool|array
*/ */
public static function checkTablesExist($tables = [], $returnExistingTables = false) { public static function checkTablesExist($tables = [], $returnExistingTables = false)
{
$existingTables = []; $existingTables = [];
$tables = $tables ?: ['users', 'closets', 'players', 'textures', 'options']; $tables = $tables ?: ['users', 'closets', 'players', 'textures', 'options'];
@ -212,6 +209,28 @@ class SetupController extends Controller
} }
} }
/**
* Check if the given columns exist in specific table.
* By default, we will check the columns newly added to users table in BS v3.5.0.
*
* @param string $table
* @param array $columns
* @return void
*/
public static function checkNewColumnsExist($table = 'users', $columns = [])
{
$existingColumns = [];
$columns = $columns ?: ['verified', 'verification_token'];
foreach ($columns as $column) {
if (Schema::hasColumn($table, $column)) {
$existingColumns[] = $column;
}
}
return count($existingColumns) === count($columns);
}
public static function checkDirectories() public static function checkDirectories()
{ {
$directories = ['storage/textures', 'plugins']; $directories = ['storage/textures', 'plugins'];

View File

@ -3,7 +3,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use View; use View;
use Utils;
use Option; use Option;
use Storage; use Storage;
use Session; use Session;
@ -20,6 +19,23 @@ class SkinlibController extends Controller
{ {
protected $user = null; protected $user = null;
/**
* Map error code of file uploading to human-readable text.
*
* @see http://php.net/manual/en/features.file-upload.errors.php
* @var array
*/
public static $phpFileUploadErrors = [
0 => 'There is no error, the file uploaded with success',
1 => 'The uploaded file exceeds the upload_max_filesize directive in php.ini',
2 => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form',
3 => 'The uploaded file was only partially uploaded',
4 => 'No file was uploaded',
6 => 'Missing a temporary folder',
7 => 'Failed to write file to disk.',
8 => 'A PHP extension stopped the file upload.',
];
public function __construct(UserRepository $users) public function __construct(UserRepository $users)
{ {
// Try to load user by uid stored in session. // Try to load user by uid stored in session.
@ -167,7 +183,7 @@ class SkinlibController extends Controller
$t->size = ceil($request->file('file')->getSize() / 1024); $t->size = ceil($request->file('file')->getSize() / 1024);
$t->public = ($request->input('public') == 'true') ? "1" : "0"; $t->public = ($request->input('public') == 'true') ? "1" : "0";
$t->uploader = $this->user->uid; $t->uploader = $this->user->uid;
$t->upload_at = Utils::getTimeFormatted(); $t->upload_at = get_datetime_string();
$cost = $t->size * (($t->public == "1") ? Option::get('score_per_storage') : Option::get('private_score_per_storage')); $cost = $t->size * (($t->public == "1") ? Option::get('score_per_storage') : Option::get('private_score_per_storage'));
$cost += option('score_per_closet_item'); $cost += option('score_per_closet_item');
@ -295,6 +311,35 @@ class SkinlibController extends Controller
} }
} // @codeCoverageIgnore } // @codeCoverageIgnore
public function model(Request $request) {
$this->validate($request, [
'tid' => 'required|integer',
'model' => 'required|in:steve,alex,cape'
]);
$t = Texture::find($request->input('tid'));
if (! $t)
return json(trans('skinlib.non-existent'), 1);
if ($t->uploader != $this->user->uid && !$this->user->isAdmin())
return json(trans('skinlib.no-permission'), 1);
$duplicate = Texture::where('hash', $t->hash)
->where('type', $request->input('model'))
->where('tid', '<>', $t->tid)
->first();
if ($duplicate && $duplicate->public) {
return json(trans('skinlib.model.duplicate', ['tid' => $duplicate->tid]), 1);
}
$t->type = $request->input('model');
$t->save();
return json(trans('skinlib.model.success', ['model' => request('model')]), 0);
}
/** /**
* Check Uploaded Files * Check Uploaded Files
* *
@ -305,12 +350,12 @@ class SkinlibController extends Controller
{ {
if ($file = $request->files->get('file')) { if ($file = $request->files->get('file')) {
if ($file->getError() !== UPLOAD_ERR_OK) { if ($file->getError() !== UPLOAD_ERR_OK) {
return json(Utils::convertUploadFileError($file->getError()), $file->getError()); return json(static::$phpFileUploadErrors[$file->getError()], $file->getError());
} }
} }
$this->validate($request, [ $this->validate($request, [
'name' => 'required|no_special_chars', 'name' => 'required|'.(option('texture_name_regexp') ? 'texture_name_regexp' : 'no_special_chars'),
'file' => 'required|max:'.option('max_upload_file_size'), 'file' => 'required|max:'.option('max_upload_file_size'),
'public' => 'required' 'public' => 'required'
]); ]);

View File

@ -7,6 +7,7 @@ use Option;
use Storage; use Storage;
use Response; use Response;
use Minecraft; use Minecraft;
use Exception;
use Carbon\Carbon; use Carbon\Carbon;
use App\Models\User; use App\Models\User;
use App\Models\Player; use App\Models\Player;
@ -16,6 +17,7 @@ use App\Events\GetSkinPreview;
use App\Events\GetAvatarPreview; use App\Events\GetAvatarPreview;
use App\Exceptions\PrettyPageException; use App\Exceptions\PrettyPageException;
use App\Services\Repositories\UserRepository; use App\Services\Repositories\UserRepository;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class TextureController extends Controller class TextureController extends Controller
{ {
@ -38,10 +40,8 @@ class TextureController extends Controller
$content = $player->getJsonProfile(Option::get('api_type')); $content = $player->getJsonProfile(Option::get('api_type'));
} }
return Response::rawJson($content, 200, [ return Response::jsonProfile($content, 200, [
'Last-Modified' => Carbon::createFromTimestamp( 'Last-Modified' => strtotime($player->last_modified)
$player->getLastModified()
)->format('D, d M Y H:i:s \G\M\T')
]); ]);
} }
@ -50,16 +50,21 @@ class TextureController extends Controller
return $this->json($player_name, $api); return $this->json($player_name, $api);
} }
public function texture($hash) { public function texture($hash, $headers = [], $message = '') {
if (Storage::disk('textures')->has($hash)) { try {
return Response::png(Storage::disk('textures')->get($hash), 200, [ if (Storage::disk('textures')->has($hash)) {
'Last-Modified' => Storage::disk('textures')->lastModified($hash), return Response::png(Storage::disk('textures')->get($hash), 200, array_merge([
'Accept-Ranges' => 'bytes', 'Last-Modified' => Storage::disk('textures')->lastModified($hash),
'Content-Length' => Storage::disk('textures')->size($hash), 'Accept-Ranges' => 'bytes',
]); 'Content-Length' => Storage::disk('textures')->size($hash),
} else { ], $headers));
return abort(404); }
} catch (Exception $e) {
// Let it fallback to 404
report($e);
} }
return abort(404, $message);
} }
public function textureWithApi($api, $hash) { public function textureWithApi($api, $hash) {
@ -98,16 +103,9 @@ class TextureController extends Controller
$player = $this->getPlayerInstance($player_name); $player = $this->getPlayerInstance($player_name);
if ($hash = $player->getTexture($type)) { if ($hash = $player->getTexture($type)) {
if (Storage::disk('textures')->has($hash)) { return $this->texture($hash, [
// Cache friendly 'Last-Modified' => strtotime($player->last_modified)
return Response::png(Storage::disk('textures')->read($hash), 200, [ ], trans('general.texture-deleted'));
'Last-Modified' => $player->getLastModified(),
'Accept-Ranges' => 'bytes',
'Content-Length' => Storage::disk('textures')->size($hash),
]);
} else {
abort(404, trans('general.texture-deleted'));
}
} else { } else {
abort(404, trans('general.texture-not-uploaded', ['type' => $type])); abort(404, trans('general.texture-not-uploaded', ['type' => $type]));
} }
@ -121,12 +119,14 @@ class TextureController extends Controller
$tid = $user->getAvatarId(); $tid = $user->getAvatarId();
if ($t = Texture::find($tid)) { if ($t = Texture::find($tid)) {
if (Storage::disk('textures')->has($t->hash)) { try {
$responses = event(new GetAvatarPreview($t, $size)); if (Storage::disk('textures')->has($t->hash)) {
$responses = event(new GetAvatarPreview($t, $size));
if (isset($responses[0]) && $responses[0] instanceof SymfonyResponse) {
return $responses[0]; // @codeCoverageIgnore
}
if (isset($responses[0]) && $responses[0] instanceof \Symfony\Component\HttpFoundation\Response) {
return $responses[0]; // @codeCoverageIgnore
} else {
$png = Minecraft::generateAvatarFromSkin(Storage::disk('textures')->read($t->hash), $size); $png = Minecraft::generateAvatarFromSkin(Storage::disk('textures')->read($t->hash), $size);
ob_start(); ob_start();
@ -136,7 +136,11 @@ class TextureController extends Controller
ob_end_clean(); ob_end_clean();
return Response::png($image); return Response::png($image);
} }
} catch (Exception $e) {
// Let it fallback to default avatar
report($e);
} }
} }
} }
@ -159,12 +163,14 @@ class TextureController extends Controller
public function preview($tid, $size = 250) public function preview($tid, $size = 250)
{ {
if ($t = Texture::find($tid)) { if ($t = Texture::find($tid)) {
if (Storage::disk('textures')->has($t->hash)) { try {
$responses = event(new GetSkinPreview($t, $size)); if (Storage::disk('textures')->has($t->hash)) {
$responses = event(new GetSkinPreview($t, $size));
if (isset($responses[0]) && $responses[0] instanceof SymfonyResponse) {
return $responses[0]; // @codeCoverageIgnore
}
if (isset($responses[0]) && $responses[0] instanceof \Symfony\Component\HttpFoundation\Response) {
return $responses[0]; // @codeCoverageIgnore
} else {
$binary = Storage::disk('textures')->read($t->hash); $binary = Storage::disk('textures')->read($t->hash);
if ($t->type == "cape") { if ($t->type == "cape") {
@ -181,6 +187,10 @@ class TextureController extends Controller
return Response::png($image); return Response::png($image);
} }
} catch (Exception $e) {
// Let it fallback to default preview
report($e);
} }
} }
@ -202,16 +212,10 @@ class TextureController extends Controller
public function raw($tid) { public function raw($tid) {
if ($t = Texture::find($tid)) { if ($t = Texture::find($tid)) {
return $this->texture($t->hash);
if (Storage::disk('textures')->has($t->hash)) {
return Response::png(Storage::disk('textures')->get($t->hash));
} else {
return abort(404, trans('general.texture-deleted'));
}
} else { } else {
return abort(404, trans('skinlib.non-existent')); return abort(404, trans('skinlib.non-existent'));
} }
} }
protected function getPlayerInstance($player_name) protected function getPlayerInstance($player_name)

View File

@ -2,12 +2,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Arr;
use Log; use Log;
use Utils;
use File; use File;
use Cache;
use Option; use Option;
use Storage; use Storage;
use Exception;
use ZipArchive; use ZipArchive;
use App\Services\OptionForm; use App\Services\OptionForm;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -15,19 +15,58 @@ use Composer\Semver\Comparator;
class UpdateController extends Controller class UpdateController extends Controller
{ {
public $currentVersion; /**
* Current application version.
*
* @var string
*/
protected $currentVersion;
public $latestVersion; /**
* Latest application version in update source.
*
* @var string
*/
protected $latestVersion;
public $updateSource; /**
* Where to get information of new application versions.
*
* @var string
*/
protected $updateSource;
/**
* Updates information fetched from update source.
*
* @var array|null
*/
protected $updateInfo; protected $updateInfo;
public function __construct() /**
{ * Guzzle HTTP client.
$this->updateSource = option('update_source'); *
* @var \GuzzleHttp\Client
*/
protected $guzzle;
/**
* Default request options for Guzzle HTTP client.
*
* @var array
*/
protected $guzzleConfig;
public function __construct(\GuzzleHttp\Client $guzzle)
{
$this->updateSource = config('app.update_source');
$this->currentVersion = config('app.version'); $this->currentVersion = config('app.version');
$this->guzzle = $guzzle;
$this->guzzleConfig = [
'headers' => ['User-Agent' => config('secure.user_agent')],
'verify' => config('secure.certificates')
];
} }
public function showUpdatePage() public function showUpdatePage()
@ -53,7 +92,7 @@ class UpdateController extends Controller
); );
if ($detail = $this->getReleaseInfo($info['latest_version'])) { if ($detail = $this->getReleaseInfo($info['latest_version'])) {
$info = array_merge($info, Arr::only($detail, [ $info = array_merge($info, array_only($detail, [
'release_note', 'release_note',
'release_url', 'release_url',
'release_time', 'release_time',
@ -65,24 +104,19 @@ class UpdateController extends Controller
} }
if (! $info['new_version_available']) { if (! $info['new_version_available']) {
$info['release_time'] = Arr::get($this->getReleaseInfo($this->currentVersion), 'release_time'); $info['release_time'] = array_get($this->getReleaseInfo($this->currentVersion), 'release_time');
} }
} }
$update = Option::form('update', OptionForm::AUTO_DETECT, function($form) $connectivity = true;
{
$form->checkbox('check_update', OptionForm::AUTO_DETECT)->label(OptionForm::AUTO_DETECT);
$form->text('update_source', OptionForm::AUTO_DETECT)
->description(OptionForm::AUTO_DETECT);
})->handle()->always(function($form) {
try {
$response = file_get_contents(option('update_source'));
} catch (\Exception $e) {
$form->addMessage(trans('admin.update.errors.connection').$e->getMessage(), 'danger');
}
});
return view('admin.update')->with('info', $info)->with('update', $update); try {
$this->guzzle->request('GET', $this->updateSource, $this->guzzleConfig);
} catch (Exception $e) {
$connectivity = $e->getMessage();
}
return view('admin.update', compact('info', 'connectivity'));
} }
public function checkUpdates() public function checkUpdates()
@ -102,17 +136,17 @@ class UpdateController extends Controller
public function download(Request $request) public function download(Request $request)
{ {
$action = $request->input('action'); if (! $this->newVersionAvailable())
return;
if (! $this->newVersionAvailable()) return;
$action = $request->get('action');
$release_url = $this->getReleaseInfo($this->latestVersion)['release_url']; $release_url = $this->getReleaseInfo($this->latestVersion)['release_url'];
$file_size = Utils::getRemoteFileSize($release_url); $tmp_path = Cache::get('tmp_path');
$tmp_path = session('tmp_path');
switch ($action) { switch ($action) {
case 'prepare-download': case 'prepare-download':
Cache::forget('download-progress');
$update_cache = storage_path('update_cache'); $update_cache = storage_path('update_cache');
if (! is_dir($update_cache)) { if (! is_dir($update_cache)) {
@ -121,38 +155,50 @@ class UpdateController extends Controller
} }
} }
$tmp_path = $update_cache."/update_".time().".zip"; // Set temporary path for the update package
$tmp_path = $update_cache.'/update_'.time().'.zip';
Cache::put('tmp_path', $tmp_path, 60);
Log::info('[UpdateWizard] Prepare to download update package', compact('release_url', 'tmp_path'));
session(['tmp_path' => $tmp_path]); // We won't get remote file size here since HTTP HEAD method is not always reliable
return json(compact('release_url', 'tmp_path'));
return json(compact('release_url', 'tmp_path', 'file_size'));
case 'start-download': case 'start-download':
if (! session()->has('tmp_path')) { if (! $tmp_path) {
return "No temp path is set."; return 'No temp path available, please try again.';
} }
@set_time_limit(0);
$GLOBALS['last_downloaded'] = 0;
Log::info('[UpdateWizard] Start downloading update package');
try { try {
Utils::download($release_url, $tmp_path); $this->guzzle->request('GET', $release_url, array_merge($this->guzzleConfig, [
'sink' => $tmp_path,
} catch (\Exception $e) { 'progress' => function ($total, $downloaded) {
File::delete($tmp_path); if ($total == 0) return;
// Log current progress per 100 KiB
if ($total == $downloaded || floor($downloaded / 102400) > floor($GLOBALS['last_downloaded'] / 102400)) {
$GLOBALS['last_downloaded'] = $downloaded;
Log::info('[UpdateWizard] Download progress (in bytes):', [$total, $downloaded]);
Cache::put('download-progress', compact('total', 'downloaded'), 60);
}
}
]));
} catch (Exception $e) {
@unlink($tmp_path);
return response(trans('admin.update.errors.prefix').$e->getMessage()); return response(trans('admin.update.errors.prefix').$e->getMessage());
} }
Log::info('[UpdateWizard] Finished downloading update package');
return json(compact('tmp_path')); return json(compact('tmp_path'));
case 'get-file-size': case 'get-progress':
if (! session()->has('tmp_path')) { return json((array) Cache::get('download-progress'));
return "No temp path is set.";
}
if (file_exists($tmp_path)) {
return json(['size' => filesize($tmp_path)]);
}
case 'extract': case 'extract':
@ -166,7 +212,7 @@ class UpdateController extends Controller
$res = $zip->open($tmp_path); $res = $zip->open($tmp_path);
if ($res === true) { if ($res === true) {
Log::info("[ZipArchive] Extracting file $tmp_path"); Log::info("[UpdateWizard] Extracting file $tmp_path");
if ($zip->extractTo($extract_dir) === false) { if ($zip->extractTo($extract_dir) === false) {
return response(trans('admin.update.errors.prefix').'Cannot unzip file.'); return response(trans('admin.update.errors.prefix').'Cannot unzip file.');
@ -179,29 +225,31 @@ class UpdateController extends Controller
try { try {
File::copyDirectory("$extract_dir/vendor", base_path('vendor')); File::copyDirectory("$extract_dir/vendor", base_path('vendor'));
} catch (\Exception $e) { } catch (Exception $e) {
Log::error('[Extracter] Unable to extract vendors', [$e]); report($e);
Log::error('[UpdateWizard] Unable to extract vendors');
// Skip copying vendor // Skip copying vendor
File::deleteDirectory("$extract_dir/vendor"); File::deleteDirectory("$extract_dir/vendor");
} }
try { try {
File::copyDirectory($extract_dir, base_path()); File::copyDirectory($extract_dir, base_path());
Log::info("[Extracter] Covering files"); Log::info('[UpdateWizard] Overwrite with extracted files');
} catch (\Exception $e) { } catch (Exception $e) {
Log::error("[Extracter] Error occured when covering files", [$e]); report($e);
Log::error('[UpdateWizard] Error occured when overwriting files');
// Response can be returned, while cache will be cleared // Response can be returned, while cache will be cleared
// @see https://gist.github.com/g-plane/2f88ad582826a78e0a26c33f4319c1e0 // @see https://gist.github.com/g-plane/2f88ad582826a78e0a26c33f4319c1e0
return response(trans('admin.update.errors.overwrite').$e->getMessage()); return response(trans('admin.update.errors.overwrite').$e->getMessage());
} finally { } finally {
File::deleteDirectory(storage_path('update_cache')); File::deleteDirectory(storage_path('update_cache'));
Log::info("[Extracter] Cleaning cache"); Log::info('[UpdateWizard] Cleaning cache');
} }
Log::info('[UpdateWizard] Done');
return json(trans('admin.update.complete'), 0); return json(trans('admin.update.complete'), 0);
default: default:
@ -218,21 +266,20 @@ class UpdateController extends Controller
: $this->updateSource; : $this->updateSource;
try { try {
$response = file_get_contents($url); $response = $this->guzzle->request('GET', $url, $this->guzzleConfig)->getBody();
} catch (\Exception $e) { } catch (Exception $e) {
Log::error("[CheckingUpdate] Failed to get update information: ".$e->getMessage()); Log::error("[CheckingUpdate] Failed to get update information: ".$e->getMessage());
} }
if (isset($response)) { if (isset($response)) {
$this->updateInfo = json_decode($response, true); $this->updateInfo = json_decode($response, true);
} }
} }
$this->latestVersion = Arr::get($this->updateInfo, 'latest_version', $this->currentVersion); $this->latestVersion = array_get($this->updateInfo, 'latest_version', $this->currentVersion);
if (! is_null($key)) { if (! is_null($key)) {
return Arr::get($this->updateInfo, $key); return array_get($this->updateInfo, $key);
} }
return $this->updateInfo; return $this->updateInfo;
@ -240,7 +287,7 @@ class UpdateController extends Controller
protected function getReleaseInfo($version) protected function getReleaseInfo($version)
{ {
return Arr::get($this->getUpdateInfo('releases'), $version); return array_get($this->getUpdateInfo('releases'), $version);
} }
} }

View File

@ -3,8 +3,10 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App; use App;
use Mail;
use View; use View;
use Utils; use Schema;
use Session;
use App\Models\User; use App\Models\User;
use App\Models\Texture; use App\Models\Texture;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -24,6 +26,12 @@ class UserController extends Controller
public function __construct(UserRepository $users) public function __construct(UserRepository $users)
{ {
$this->user = $users->get(session('uid')); $this->user = $users->get(session('uid'));
// Do nothing if new columns are not ready
if (Schema::hasColumn('users', 'verified') && option('require_verification')) {
// Send email verification link to newly registered users
$this->user->verification_token || $this->sendVerificationEmail();
}
} }
public function index() public function index()
@ -94,6 +102,49 @@ class UserController extends Controller
return $hours > 1 ? round($hours) : $hours; return $hours > 1 ? round($hours) : $hours;
} }
public function sendVerificationEmail()
{
if (!option('require_verification') || !Schema::hasColumn('users', 'verified')) {
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);
}
if ($this->user->verified) {
return json(trans('user.verification.verified'), 1);
}
$token = generate_random_token();
$this->user->verification_token = $token;
$this->user->save();
$email = $this->user->email;
$url = option('site_url')."/auth/verify?uid={$this->user->uid}&token=$token";
try {
Mail::send('mails.email-verification', compact('url'), function ($m) use ($email) {
$site_name = option_localized('site_name');
$m->from(config('mail.username'), $site_name);
$m->to($email)->subject(trans('user.verification.mail.title', ['sitename' => $site_name]));
});
} catch (\Exception $e) {
// Write the exception to log
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() public function profile()
{ {
return view('user.profile')->with('user', $this->user); return view('user.profile')->with('user', $this->user);
@ -160,6 +211,11 @@ class UserController extends Controller
return json(trans('user.profile.email.wrong-password'), 1); return json(trans('user.profile.email.wrong-password'), 1);
if ($this->user->setEmail($request->input('new_email'))) { if ($this->user->setEmail($request->input('new_email'))) {
// Set account status to unverified
$this->user->verified = false;
$this->user->verification_token = '';
$this->user->save();
event(new UserProfileUpdated($action, $this->user)); event(new UserProfileUpdated($action, $this->user));
return json(trans('user.profile.email.success'), 0) return json(trans('user.profile.email.success'), 0)

View File

@ -15,7 +15,6 @@ class Kernel extends HttpKernel
*/ */
protected $middleware = [ protected $middleware = [
\Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode::class, \Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode::class,
\App\Http\Middleware\DetectLanguagePrefer::class,
]; ];
/** /**
@ -31,6 +30,7 @@ class Kernel extends HttpKernel
\Illuminate\Session\Middleware\StartSession::class, \Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\AfterSessionBooted::class, \App\Http\Middleware\AfterSessionBooted::class,
\App\Http\Middleware\DetectLanguagePrefer::class,
], ],
'static' => [], 'static' => [],
@ -44,10 +44,13 @@ class Kernel extends HttpKernel
* @var array * @var array
*/ */
protected $routeMiddleware = [ protected $routeMiddleware = [
'auth' => \App\Http\Middleware\CheckAuthenticated::class, 'csrf' => \App\Http\Middleware\VerifyCsrfToken::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'auth' => \App\Http\Middleware\CheckAuthenticated::class,
'admin' => \App\Http\Middleware\CheckAdministrator::class, 'verified' => \App\Http\Middleware\CheckUserVerified::class,
'player' => \App\Http\Middleware\CheckPlayerExist::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'setup' => \App\Http\Middleware\CheckInstallation::class, 'admin' => \App\Http\Middleware\CheckAdministrator::class,
'super-admin' => \App\Http\Middleware\CheckSuperAdmin::class,
'player' => \App\Http\Middleware\CheckPlayerExist::class,
'setup' => \App\Http\Middleware\CheckInstallation::class,
]; ];
} }

View File

@ -2,13 +2,15 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use Symfony\Component\HttpFoundation\Response;
class CheckAdministrator class CheckAdministrator
{ {
public function handle($request, \Closure $next) public function handle($request, \Closure $next)
{ {
$result = (new CheckAuthenticated)->handle($request, $next, true); $result = (new CheckAuthenticated)->handle($request, $next, true);
if ($result instanceof \Illuminate\Http\RedirectResponse) { if ($result instanceof Response) {
return $result; return $result;
} }

View File

@ -38,12 +38,10 @@ class CheckPlayerExist
if (! Player::where('player_name', $player_name)->get()->isEmpty()) if (! Player::where('player_name', $player_name)->get()->isEmpty())
return $next($request); return $next($request);
if (option('return_200_when_notfound')) { if (option('return_204_when_notfound')) {
return json([ return response('', 204, [
'player_name' => $player_name, 'Cache-Control' => 'public, max-age='.option('cache_expire_time')
'errno' => 404, ]);
'msg' => 'Player Not Found.'
])->header('Cache-Control', 'public, max-age='.option('cache_expire_time'));
} else { } else {
return abort(404, trans('general.unexistent-player')); return abort(404, trans('general.unexistent-player'));
} }

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Symfony\Component\HttpFoundation\Response;
class CheckSuperAdmin
{
public function handle($request, \Closure $next)
{
$result = (new CheckAuthenticated)->handle($request, $next, true);
if ($result instanceof Response) {
return $result;
}
if (! $result->isSuperAdmin()) {
abort(403, trans('auth.check.super-admin'));
}
return $next($request);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
class CheckUserVerified
{
public function handle($request, \Closure $next)
{
$result = (new CheckAuthenticated)->handle($request, $next, true);
if ($result instanceof Response) {
return $result;
}
if (option('require_verification') && !$result->verified) {
abort(403, trans('auth.check.verified'));
}
return $next($request);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as BaseVerifier;
class VerifyCsrfToken extends BaseVerifier
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array
*/
protected $except = [
//
];
}

View File

@ -3,7 +3,6 @@
namespace App\Models; namespace App\Models;
use Event; use Event;
use Utils;
use Response; use Response;
use App\Models\User; use App\Models\User;
use App\Events\GetPlayerJson; use App\Events\GetPlayerJson;
@ -98,7 +97,7 @@ class Player extends Model
} }
} }
$this->last_modified = Utils::getTimeFormatted(); $this->last_modified = get_datetime_string();
$this->save(); $this->save();
@ -159,7 +158,7 @@ class Player extends Model
{ {
$this->update([ $this->update([
'preference' => $type, 'preference' => $type,
'last_modified' => Utils::getTimeFormatted() 'last_modified' => get_datetime_string()
]); ]);
event(new PlayerProfileUpdated($this)); event(new PlayerProfileUpdated($this));
@ -187,7 +186,7 @@ class Player extends Model
{ {
$this->update([ $this->update([
'player_name' => $newName, 'player_name' => $newName,
'last_modified' => Utils::getTimeFormatted() 'last_modified' => get_datetime_string()
]); ]);
$this->player_name = $newName; $this->player_name = $newName;
@ -249,7 +248,7 @@ class Player extends Model
$sec_model = ($model == 'default') ? 'slim' : 'default'; $sec_model = ($model == 'default') ? 'slim' : 'default';
if ($api_type == self::USM_API) { if ($api_type == self::USM_API) {
$json['last_update'] = $this->getLastModified(); $json['last_update'] = strtotime($this->last_modified);
$json['model_preference'] = [$model, $sec_model]; $json['model_preference'] = [$model, $sec_model];
} }
@ -272,17 +271,7 @@ class Player extends Model
public function updateLastModified() public function updateLastModified()
{ {
// @see http://stackoverflow.com/questions/2215354/php-date-format-when-inserting-into-datetime-in-mysql // @see http://stackoverflow.com/questions/2215354/php-date-format-when-inserting-into-datetime-in-mysql
$this->update(['last_modified' => Utils::getTimeFormatted()]); $this->update(['last_modified' => get_datetime_string()]);
return event(new PlayerProfileUpdated($this)); return event(new PlayerProfileUpdated($this));
} }
/**
* Get time of last modified.
*
* @return int|false
*/
public function getLastModified()
{
return strtotime($this['last_modified']);
}
} }

View File

@ -3,7 +3,6 @@
namespace App\Models; namespace App\Models;
use DB; use DB;
use Utils;
use Carbon\Carbon; use Carbon\Carbon;
use App\Events\EncryptUserPassword; use App\Events\EncryptUserPassword;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -47,6 +46,7 @@ class User extends Model
'score' => 'integer', 'score' => 'integer',
'avatar' => 'integer', 'avatar' => 'integer',
'permission' => 'integer', 'permission' => 'integer',
'verified' => 'bool',
]; ];
/** /**
@ -66,6 +66,16 @@ class User extends Model
return ($this->permission >= static::ADMIN); return ($this->permission >= static::ADMIN);
} }
/**
* Check if user is super admin.
*
* @return bool
*/
public function isSuperAdmin()
{
return ($this->permission == static::SUPER_ADMIN);
}
/** /**
* Get closet instance. * Get closet instance.
* *
@ -300,7 +310,7 @@ class User extends Model
$acquiredScore = rand($scoreLimits[0], $scoreLimits[1]); $acquiredScore = rand($scoreLimits[0], $scoreLimits[1]);
$this->setScore($acquiredScore, 'plus'); $this->setScore($acquiredScore, 'plus');
$this->last_sign_at = Utils::getTimeFormatted(); $this->last_sign_at = get_datetime_string();
$this->save(); $this->save();
return $acquiredScore; return $acquiredScore;

View File

@ -2,10 +2,15 @@
namespace App\Providers; namespace App\Providers;
use View;
use Event; use Event;
use Utils; use Parsedown;
use App\Events; use App\Events;
use ReflectionException;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use App\Exceptions\PrettyPageException;
use App\Services\Repositories\UserRepository;
use App\Services\Repositories\OptionRepository;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -16,21 +21,16 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot() public function boot()
{ {
// Replace HTTP_HOST with site url setted in options to prevent CDN source problems // Support *.tpl extension name
if (! option('auto_detect_asset_url')) { View::addExtension('tpl', 'blade');
$rootUrl = option('site_url'); // Make the priority of *.blade.php higher than *.tpl
View::addExtension('blade.php', 'blade');
if ($this->app['url']->isValidUrl($rootUrl)) { // Control the URL generated by url() function
$this->app['url']->forceRootUrl($rootUrl); $this->configureUrlGenerator();
}
}
if (option('force_ssl') || Utils::isRequestSecure()) { // Expose some app information to front-end
$this->app['url']->forceSchema('https'); Event::listen(Events\RenderingHeader::class, function ($event) {
}
Event::listen(Events\RenderingHeader::class, function($event) {
// Provide some application information for javascript
$blessing = array_merge(array_except(config('app'), ['key', 'providers', 'aliases', 'cipher', 'log', 'url']), [ $blessing = array_merge(array_except(config('app'), ['key', 'providers', 'aliases', 'cipher', 'log', 'url']), [
'base_url' => url('/'), 'base_url' => url('/'),
'site_name' => option_localized('site_name') 'site_name' => option_localized('site_name')
@ -38,6 +38,34 @@ class AppServiceProvider extends ServiceProvider
$event->addContent('<script>var blessing = '.json_encode($blessing).';</script>'); $event->addContent('<script>var blessing = '.json_encode($blessing).';</script>');
}); });
try {
$this->app->make('cipher');
} catch (ReflectionException $e) {
throw new PrettyPageException(trans('errors.cipher.unsupported', ['cipher' => config('secure.cipher')]));
}
}
/**
* Configure the \Illuminate\Routing\UrlGenerator.
*
* @return void
*/
protected function configureUrlGenerator()
{
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 ($this->app['url']->isValidUrl($rootUrl)) {
$this->app['url']->forceRootUrl($rootUrl);
}
}
if (option('force_ssl') || is_request_secure()) {
$this->app['url']->forceSchema('https');
}
} }
/** /**
@ -47,19 +75,9 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register() public function register()
{ {
// Register default cipher $this->app->singleton('cipher', 'App\Services\Cipher\\'.config('secure.cipher'));
$className = "App\Services\Cipher\\".config('secure.cipher'); $this->app->singleton('parsedown', Parsedown::class);
$this->app->singleton('users', UserRepository::class);
if (class_exists($className)) { $this->app->singleton('options', OptionRepository::class);
$this->app->singleton('cipher', $className);
} else {
die_with_utf8_encoding(sprintf(
'[Error] Unsupported encryption method: < %1$s >, please check your .env configuration <br>'.
'[错误] 不支持的密码加密方式 < %1$s >,请检查你的 .env 配置文件'
, config('secure.cipher')));
}
$this->app->singleton('users', \App\Services\Repositories\UserRepository::class);
$this->app->singleton('parsedown', \Parsedown::class);
} }
} }

View File

@ -0,0 +1,41 @@
<?php
namespace App\Providers;
use Log;
use Illuminate\Support\ServiceProvider;
class LogServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
Log::getMonolog()->popHandler();
Log::useFiles($this->getLogPath());
if (! config('app.debug')) {
@unlink(storage_path('logs/laravel.log'));
}
}
/**
* Register any application services.
*
* @return void
*/
public function register()
{
//
}
protected static function getLogPath()
{
$mask = substr(md5(implode(',', array_values(get_db_config()))), 0, 16);
return storage_path("logs/bs-$mask.log");
}
}

View File

@ -16,41 +16,41 @@ class ResponseMacroServiceProvider extends ServiceProvider
*/ */
public function boot() public function boot()
{ {
Response::macro('png', function ($src = "", $status = 200, $header = []) { Response::macro('png', function ($src = '', $status = 200, $header = []) {
// Handle fucking cache control
$last_modified = Arr::pull($header, 'Last-Modified', time()); $last_modified = Arr::pull($header, 'Last-Modified', time());
$if_modified_since = strtotime(request()->headers->get('If-Modified-Since'));
$if_none_match = strtotime(request()->headers->get('If-None-Match'));
$etag = md5($src); $etag = md5($src);
// Checking if the client is validating his cache and if it is current. // Return `304 Not Modified` if given `If-Modified-Since` header
if ((strtotime(Arr::get($_SERVER, 'If-Modified-Since')) == $last_modified) || // is newer than our `Last-Modified` time or the `Etag` matches.
trim(Arr::get($_SERVER, 'HTTP_IF_NONE_MATCH')) == $etag if ($if_modified_since >= $last_modified || $if_none_match == $etag) {
) { $src = '';
// Client's cache IS current, so we just respond '304 Not Modified'.
$status = 304; $status = 304;
$src = "";
} }
return Response::stream(function() use ($src, $status) { return Response::make($src, $status, array_merge([
echo $src;
}, $status, array_merge([
'Content-type' => 'image/png', 'Content-type' => 'image/png',
'Last-Modified' => gmdate('D, d M Y H:i:s', $last_modified).' GMT', 'Last-Modified' => format_http_date($last_modified),
'Cache-Control' => 'public, max-age='.option('cache_expire_time'), // 365 days 'Cache-Control' => 'public, max-age='.option('cache_expire_time'),
'Expires' => gmdate('D, d M Y H:i:s', $last_modified + option('cache_expire_time')).' GMT',
'Etag' => $etag 'Etag' => $etag
], $header)); ], $header));
}); });
Response::macro('rawJson', function ($src = "", $status = 200, $header = []) { Response::macro('jsonProfile', function ($src = '', $status = 200, $header = []) {
$last_modified = Arr::get($header, 'Last-Modified', time()); $last_modified = Arr::pull($header, 'Last-Modified', time());
$if_modified_since = strtotime(request()->headers->get('If-Modified-Since'));
if (strtotime(Arr::get($_SERVER, 'If-Modified-Since')) >= $last_modified) { if ($if_modified_since && $if_modified_since >= $last_modified) {
$src = '';
$status = 304; $status = 304;
$src = "";
} }
return Response::make($src, $status, array_merge([ return Response::make($src, $status, array_merge([
'Content-type' => 'application/json', 'Content-type' => 'application/json',
'Cache-Control' => 'public, max-age='.option('cache_expire_time') // 365 days 'Cache-Control' => 'public, max-age='.option('cache_expire_time'),
'Last-Modified' => format_http_date($last_modified),
], $header)); ], $header));
}); });
} }

View File

@ -59,7 +59,7 @@ class RouteServiceProvider extends ServiceProvider
protected function mapWebRoutes(Router $router) protected function mapWebRoutes(Router $router)
{ {
$router->group([ $router->group([
'middleware' => ['web', CheckSessionUserValid::class], 'middleware' => ['web', CheckSessionUserValid::class, 'csrf'],
'namespace' => $this->namespace, 'namespace' => $this->namespace,
], function ($router) { ], function ($router) {
require base_path('routes/web.php'); require base_path('routes/web.php');

View File

@ -3,16 +3,15 @@
namespace App\Providers; namespace App\Providers;
use DB; use DB;
use View; use Schema;
use Utils; use Artisan;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Composer\Semver\Comparator; use Composer\Semver\Comparator;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use App\Exceptions\PrettyPageException; use App\Exceptions\PrettyPageException;
use App\Http\Controllers\SetupController; use App\Http\Controllers\SetupController;
use App\Services\Repositories\OptionRepository;
class BootServiceProvider extends ServiceProvider class RuntimeCheckServiceProvider extends ServiceProvider
{ {
/** /**
* Bootstrap any application services. * Bootstrap any application services.
@ -27,10 +26,13 @@ class BootServiceProvider extends ServiceProvider
$this->checkFilePermissions(); $this->checkFilePermissions();
$this->checkDatabaseConnection(); $this->checkDatabaseConnection();
// Skip the installation check when setup or under CLI // Skip the installation check on setup wizard or under CLI
if (! $request->is('setup*') && PHP_SAPI != "cli") { if ($request->is('setup*') || $this->app->runningInConsole()) {
$this->checkInstallation(); return;
} }
// Redirect to setup wizard if not installed
$this->checkInstallation();
} }
protected function checkFilePermissions() protected function checkFilePermissions()
@ -40,6 +42,10 @@ class BootServiceProvider extends ServiceProvider
throw new PrettyPageException(trans('setup.file.no-dot-env'), -1); throw new PrettyPageException(trans('setup.file.no-dot-env'), -1);
} }
if (! is_readable(app()->environmentFile())) {
throw new PrettyPageException(trans('setup.file.dot-env-no-read-permission'), -1);
}
// Check permissions of storage path // Check permissions of storage path
if (! is_writable(storage_path())) { if (! is_writable(storage_path())) {
throw new PrettyPageException(trans('setup.permissions.storage'), -1); throw new PrettyPageException(trans('setup.permissions.storage'), -1);
@ -78,11 +84,20 @@ class BootServiceProvider extends ServiceProvider
{ {
// Redirect to setup wizard // Redirect to setup wizard
if (! SetupController::checkTablesExist()) { if (! SetupController::checkTablesExist()) {
return redirect('/setup')->send(); redirect('/setup')->send();
exit;
} }
if (Comparator::greaterThan(config('app.version'), option('version'))) { if (Comparator::greaterThan(config('app.version'), option('version'))) {
return redirect('/setup/update')->send(); redirect('/setup/update')->send();
exit;
}
if (! SetupController::checkNewColumnsExist()) {
// Disable the email verification feature temporarily
option(['require_verification' => false]);
// Try to prepare the new columns
Artisan::call('migrate', ['--force' => true]);
} }
return true; return true;
@ -95,8 +110,6 @@ class BootServiceProvider extends ServiceProvider
*/ */
public function register() public function register()
{ {
View::addExtension('tpl', 'blade'); //
$this->app->singleton('options', OptionRepository::class);
} }
} }

View File

@ -0,0 +1,16 @@
<?php
namespace App\Providers;
use App\Services\TranslationLoader;
use Illuminate\Translation\TranslationServiceProvider as IlluminateTranslationServiceProvider;
class TranslationServiceProvider extends IlluminateTranslationServiceProvider
{
protected function registerLoader()
{
$this->app->singleton('translation.loader', function ($app) {
return new TranslationLoader($app['files'], $app['path.lang']);
});
}
}

View File

@ -21,7 +21,7 @@ class ValidatorExtendServiceProvider extends ServiceProvider
* @param $v validator * @param $v validator
*/ */
Validator::extend('no_special_chars', function ($a, $value, $p, $v) { Validator::extend('no_special_chars', function ($a, $value, $p, $v) {
return $value === addslashes(trim($value)); return $value === e(addslashes(trim($value)));
}); });
Validator::extend('player_name', function ($a, $value, $p, $v) { Validator::extend('player_name', function ($a, $value, $p, $v) {
@ -46,6 +46,10 @@ class ValidatorExtendServiceProvider extends ServiceProvider
return preg_match($regexp, $value); return preg_match($regexp, $value);
}); });
Validator::extend('texture_name_regexp', function ($a, $value, $p, $v) {
return preg_match(option('texture_name_regexp'), $value);
});
Validator::extend('preference', function ($a, $value, $p, $v) { Validator::extend('preference', function ($a, $value, $p, $v) {
return preg_match('/^(default|slim)$/', $value); return preg_match('/^(default|slim)$/', $value);
}); });
@ -65,7 +69,7 @@ class ValidatorExtendServiceProvider extends ServiceProvider
protected function registerExpiredRules() protected function registerExpiredRules()
{ {
Validator::extend('nickname', function ($a, $value, $p, $v) { Validator::extend('nickname', function ($a, $value, $p, $v) {
return $value === addslashes(trim($value)); return $value === e(addslashes(trim($value)));
}); });
Validator::extend('playername', function($a, $value, $p, $v) { Validator::extend('playername', function($a, $value, $p, $v) {

View File

@ -1,22 +0,0 @@
<?php
namespace App\Services\Cipher;
class CrazyCrypt1 extends BaseCipher
{
/**
* Once SHA512 hash
*/
public function hash($value, $salt = "")
{
// fucking CrazyCrypt1 uses username as salt
$username = $salt;
$text = "ÜÄaeut//&/=I " . $value . "7421€547" . $username . "__+IÄIH§%NK " . $value;
$t1 = unpack("H*", $text);
$t2 = substr($t1[1], 0, mb_strlen($text, 'UTF-8')*2);
$t3 = pack("H*", $t2);
return hash('sha512', $t3);
}
}

View File

@ -23,10 +23,9 @@ class Hook
*/ */
public static function addMenuItem($category, $position, array $menu) public static function addMenuItem($category, $position, array $menu)
{ {
$class = $category == "user" ? Events\ConfigureUserMenu::class : Events\ConfigureAdminMenu::class; $class = $category == 'user' ? Events\ConfigureUserMenu::class : Events\ConfigureAdminMenu::class;
Event::listen($class, function ($event) use ($menu, $position, $category) Event::listen($class, function ($event) use ($menu, $position, $category) {
{
$new = []; $new = [];
$offset = 0; $offset = 0;
@ -40,6 +39,10 @@ class Hook
$offset++; $offset++;
} }
if ($position >= $offset) {
$new[] = $menu;
}
$event->menu[$category] = $new; $event->menu[$category] = $new;
}); });
} }
@ -53,29 +56,36 @@ class Hook
*/ */
public static function addRoute(Closure $callback) public static function addRoute(Closure $callback)
{ {
Event::listen(Events\ConfigureRoutes::class, function($event) use ($callback) Event::listen(Events\ConfigureRoutes::class, function ($event) use ($callback) {
{
return call_user_func($callback, $event->router); return call_user_func($callback, $event->router);
}); });
} }
public static function registerPluginTransScripts($id) public static function registerPluginTransScripts($id, $pages = ['*'], $priority = 999)
{ {
Event::listen(Events\RenderingFooter::class, function($event) use ($id) Event::listen(Events\RenderingFooter::class, function ($event) use ($id, $pages) {
{ foreach ($pages as $pattern) {
$path = app('plugins')->getPlugin($id)->getPath().'/'; if (! app('request')->is($pattern))
$script = 'lang/'.config('app.locale').'/locale.js'; continue;
if (file_exists($path.$script)) { // We will determine current locale in the event callback,
$event->addContent('<script src="'.plugin_assets($id, $script).'"></script>'); // otherwise the locale is not properly detected.
$basepath = plugin($id)->getPath().'/';
$relative = 'lang/'.config('app.locale').'/locale.js';
if (file_exists($basepath.$relative)) {
$event->addContent('<script src="'.plugin_assets($id, $relative).'"></script>');
}
return;
} }
}, 999); }, $priority);
} }
public static function addStyleFileToPage($urls, $pages = ['*'], $priority = 1) public static function addStyleFileToPage($urls, $pages = ['*'], $priority = 1)
{ {
Event::listen(Events\RenderingHeader::class, function($event) use ($urls, $pages) Event::listen(Events\RenderingHeader::class, function ($event) use ($urls, $pages) {
{
foreach ($pages as $pattern) { foreach ($pages as $pattern) {
if (! app('request')->is($pattern)) if (! app('request')->is($pattern))
continue; continue;
@ -92,8 +102,8 @@ class Hook
public static function addScriptFileToPage($urls, $pages = ['*'], $priority = 1) public static function addScriptFileToPage($urls, $pages = ['*'], $priority = 1)
{ {
Event::listen(Events\RenderingFooter::class, function($event) use ($urls, $pages) Event::listen(Events\RenderingFooter::class, function ($event) use ($urls, $pages) {
{
foreach ($pages as $pattern) { foreach ($pages as $pattern) {
if (! app('request')->is($pattern)) if (! app('request')->is($pattern))
continue; continue;

View File

@ -277,20 +277,24 @@ class PluginManager
/** /**
* Get the unsatisfied requirements of plugin. * Get the unsatisfied requirements of plugin.
* *
* @param string|Plugin $plugin * @param string|Plugin|array $plugin
* @return array * @return array
*/ */
public function getUnsatisfiedRequirements($plugin) public function getUnsatisfiedRequirements($plugin)
{ {
if (! $plugin instanceof Plugin) { if (is_array($plugin)) {
$plugin = $this->getPlugin($plugin); $requirements = $plugin;
} } else {
if (! $plugin instanceof Plugin) {
$plugin = $this->getPlugin($plugin);
}
if (! $plugin) { if (! $plugin) {
throw new \InvalidArgumentException('Plugin with given name does not exist.'); throw new \InvalidArgumentException('Plugin with given name does not exist.');
} }
$requirements = $plugin->getRequirements(); $requirements = $plugin->getRequirements();
}
$unsatisfied = []; $unsatisfied = [];
@ -334,7 +338,7 @@ class PluginManager
/** /**
* Whether the plugin's requirements are satisfied. * Whether the plugin's requirements are satisfied.
* *
* @param string|Plugin $plugin * @param string|Plugin|array $plugin
* @return bool * @return bool
*/ */
public function isRequirementsSatisfied($plugin) public function isRequirementsSatisfied($plugin)
@ -347,7 +351,7 @@ class PluginManager
* *
* @return string * @return string
*/ */
protected function getPluginsDir() public function getPluginsDir()
{ {
return config('plugins.directory') ?: base_path('plugins'); return config('plugins.directory') ?: base_path('plugins');
} }

View File

@ -3,6 +3,7 @@
namespace App\Services\Repositories; namespace App\Services\Repositories;
use DB; use DB;
use PDOException;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
@ -103,6 +104,8 @@ class OptionRepository extends Repository
} }
} catch (QueryException $e) { } catch (QueryException $e) {
return; return;
} catch (PDOException $e) {
return;
} }
} }
@ -132,6 +135,8 @@ class OptionRepository extends Repository
$this->itemsModified = []; $this->itemsModified = [];
} catch (QueryException $e) { } catch (QueryException $e) {
return; return;
} catch (PDOException $e) {
return;
} }
} }

View File

@ -0,0 +1,41 @@
<?php
namespace App\Services;
use Devitek\Core\Translation\YamlFileLoader;
class TranslationLoader extends YamlFileLoader
{
/**
* Load the messages for the given locale.
*
* @param string $locale
* @param string $group
* @param string $namespace
* @return array
*/
public function load($locale, $group, $namespace = null)
{
if (is_null($namespace) || $namespace == '*') {
// Overrides original translations with custom ones
return array_replace_recursive(
$this->loadPath($this->path, $locale, $group),
$this->loadPathOverrides($locale, $group)
);
}
return $this->loadNamespaced($locale, $group, $namespace);
}
/**
* Load custom messages from /resources/lang/overrides path.
*
* @param string $locale
* @param string $group
* @return array
*/
protected function loadPathOverrides($locale, $group)
{
return $this->loadPath("$this->path/overrides", $locale, $group);
}
}

View File

@ -3,10 +3,6 @@
namespace App\Services; namespace App\Services;
use Log; use Log;
use Storage;
use Carbon\Carbon;
use Illuminate\Support\Str;
use App\Exceptions\PrettyPageException;
class Utils class Utils
{ {
@ -16,21 +12,12 @@ class Utils
* This method is defined because Symfony's Request::getClientIp() needs "setTrustedProxies()" * This method is defined because Symfony's Request::getClientIp() needs "setTrustedProxies()"
* which sucks when load balancer is enabled. * which sucks when load balancer is enabled.
* *
* @deprecated Use the helper function instead.
* @return string * @return string
*/ */
public static function getClientIp() public static function getClientIp()
{ {
if (option('ip_get_method') == "0") { return get_client_ip();
// Fallback to REMOTE_ADDR
$ip = array_get(
$_SERVER, 'HTTP_X_FORWARDED_FOR',
array_get($_SERVER, 'HTTP_CLIENT_IP', $_SERVER['REMOTE_ADDR'])
);
} else {
$ip = array_get($_SERVER, 'REMOTE_ADDR');
}
return $ip;
} }
/** /**
@ -40,125 +27,37 @@ class Utils
* This method is defined because Symfony's Request::isSecure() needs "setTrustedProxies()" * This method is defined because Symfony's Request::isSecure() needs "setTrustedProxies()"
* which sucks when load balancer is enabled. * which sucks when load balancer is enabled.
* *
* @deprecated Use the helper function instead.
* @return bool * @return bool
*/ */
public static function isRequestSecure() public static function isRequestSecure()
{ {
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') return is_request_secure();
return true;
if (! empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https')
return true;
if (! empty($_SERVER['HTTP_X_FORWARDED_SSL']) && $_SERVER['HTTP_X_FORWARDED_SSL'] == 'on')
return true;
return false;
}
public static function download($url, $path)
{
@set_time_limit(0);
touch($path);
Log::info("[File Downloader] Download started, source: $url");
Log::info("[File Downloader] ======================================");
$context = stream_context_create(['http' => [
'method' => 'GET',
'header' => 'User-Agent: '.menv('USER_AGENT', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36')
]]);
if ($fp = fopen($url, 'rb', false, $context)) {
if (! $download_fp = fopen($path, 'wb')) {
return false;
}
while (! feof($fp)) {
if (! file_exists($path)) {
// Cancel downloading if destination is no longer available
fclose($download_fp);
return false;
}
Log::info('[File Downloader] 1024 bytes wrote');
fwrite($download_fp, fread($fp, 1024 * 8 ), 1024 * 8);
}
fclose($download_fp);
fclose($fp);
Log::info("[File Downloader] Finished downloading, data stored to: $path");
Log::info("[File Downloader] ===========================================");
return true;
} else {
return false;
}
}
public static function getRemoteFileSize($url)
{
$regex = '/^Content-Length: *+\K\d++$/im';
if (! $fp = @fopen($url, 'rb')) {
return false;
}
if (
isset($http_response_header) &&
preg_match($regex, implode("\n", $http_response_header), $matches)
) {
return (int)$matches[0];
}
return strlen(stream_get_contents($fp));
} }
/**
* Get date time string in "Y-m-d H:i:s" format.
*
* @deprecated Use the helper function instead.
* @param integer $timestamp
* @return string
*/
public static function getTimeFormatted($timestamp = 0) public static function getTimeFormatted($timestamp = 0)
{ {
return ($timestamp == 0) ? Carbon::now()->toDateTimeString() : Carbon::createFromTimestamp($timestamp)->toDateTimeString(); return get_datetime_string($timestamp);
} }
/** /**
* Replace content of string according to given rules. * Replace content of string according to given rules.
* *
* @deprecated Use the helper function instead.
* @param string $str * @param string $str
* @param array $rules * @param array $rules
* @return string * @return string
*/ */
public static function getStringReplaced($str, $rules) public static function getStringReplaced($str, $rules)
{ {
foreach ($rules as $search => $replace) { return get_string_replaced($str, $rules);
$str = str_replace($search, $replace, $str);
}
return $str;
}
/**
* Convert error number of uploading files to human-readable text.
*
* @param int $errno
* @return string
*/
public static function convertUploadFileError($errno = 0)
{
$phpFileUploadErrors = [
0 => 'There is no error, the file uploaded with success',
1 => 'The uploaded file exceeds the upload_max_filesize directive in php.ini',
2 => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form',
3 => 'The uploaded file was only partially uploaded',
4 => 'No file was uploaded',
6 => 'Missing a temporary folder',
7 => 'Failed to write file to disk.',
8 => 'A PHP extension stopped the file upload.',
];
return $phpFileUploadErrors[$errno];
} }
} }

View File

@ -1,5 +1,6 @@
<?php <?php
use Carbon\Carbon;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@ -239,7 +240,7 @@ if (! function_exists('bs_copyright')) {
function bs_copyright($prefer = null) function bs_copyright($prefer = null)
{ {
$prefer = is_null($prefer) ? Option::get('copyright_prefer', 0, false) : $prefer; $prefer = is_null($prefer) ? option_localized('copyright_prefer', 0) : $prefer;
$base64CopyrightText = [ $base64CopyrightText = [
'UG93ZXJlZCB3aXRoIOKdpCBieSA8YSBocmVmPSJodHRwczovL2dpdGh1Yi5jb20vcHJpbnRlbXB3L2JsZXNzaW5nLXNraW4tc2VydmVyIj5CbGVzc2luZyBTa2luIFNlcnZlcjwvYT4u', 'UG93ZXJlZCB3aXRoIOKdpCBieSA8YSBocmVmPSJodHRwczovL2dpdGh1Yi5jb20vcHJpbnRlbXB3L2JsZXNzaW5nLXNraW4tc2VydmVyIj5CbGVzc2luZyBTa2luIFNlcnZlcjwvYT4u',
@ -257,7 +258,7 @@ if (! function_exists('bs_custom_copyright')) {
function bs_custom_copyright() function bs_custom_copyright()
{ {
return Utils::getStringReplaced(option_localized('copyright_text'), [ return get_string_replaced(option_localized('copyright_text'), [
'{site_name}' => option_localized('site_name'), '{site_name}' => option_localized('site_name'),
'{site_url}' => option('site_url') '{site_url}' => option('site_url')
]); ]);
@ -493,3 +494,155 @@ if (! function_exists('get_db_config')) {
return config("database.connections.$type"); return config("database.connections.$type");
} }
} }
if (! function_exists('report')) {
/**
* Report an exception.
*
* @param \Exception $exception
* @return void
*/
function report($exception)
{
app(Illuminate\Contracts\Debug\ExceptionHandler::class)->report($exception);
}
}
if (! function_exists('can_moderate_texture')) {
function can_moderate_texture($user, $texture) {
if (! $user) {
return false;
}
// Only uploader and admins can moderate textures
return ($texture->uploader == $user->uid || $user->isAdmin());
}
}
if (! function_exists('generate_random_token')) {
function generate_random_token($key = null) {
if (is_null($key)) {
$key = config('app.key');
$key = starts_with($key, 'base64:') ? base64_decode(substr($key, 7)) : $key;
}
return hash_hmac('sha256', str_random(40), $key);
}
}
if (! function_exists('format_http_date')) {
/**
* Format a UNIX timestamp to string for HTTP headers.
*
* e.g. Wed, 21 Oct 2015 07:28:00 GMT
*
* @param int $timestamp
* @return string
*/
function format_http_date($timestamp) {
return Carbon::createFromTimestampUTC($timestamp)->format('D, d M Y H:i:s \G\M\T');
}
}
if (! function_exists('get_datetime_string')) {
/**
* Get date time string in "Y-m-d H:i:s" format.
*
* @param integer $timestamp
* @return string
*/
function get_datetime_string($timestamp = 0) {
return $timestamp == 0 ? Carbon::now()->toDateTimeString() : Carbon::createFromTimestamp($timestamp)->toDateTimeString();
}
}
if (! function_exists('get_client_ip')) {
/**
* Return the client IP address.
*
* We define this function because Symfony's "Request::getClientIp()" method
* needs "setTrustedProxies()", which sucks when load balancer is enabled.
*
* @return string
*/
function get_client_ip() {
if (option('ip_get_method') == "0") {
// Use `HTTP_X_FORWARDED_FOR` if available first
$ip = array_get(
$_SERVER,
'HTTP_X_FORWARDED_FOR',
// Fallback to `HTTP_CLIENT_IP`
array_get(
$_SERVER,
'HTTP_CLIENT_IP',
// Fallback to `REMOTE_ADDR`
array_get($_SERVER, 'REMOTE_ADDR')
)
);
} else {
$ip = array_get($_SERVER, 'REMOTE_ADDR');
}
return $ip;
}
}
if (! function_exists('get_string_replaced')) {
/**
* Replace content of string according to given rules.
*
* @param string $str
* @param array $rules
* @return string
*/
function get_string_replaced($str, $rules)
{
foreach ($rules as $search => $replace) {
$str = str_replace($search, $replace, $str);
}
return $str;
}
}
if (! function_exists('is_request_secure')) {
/**
* 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.
*
* @return bool
*/
function is_request_secure()
{
if (array_get($_SERVER, 'HTTPS') == 'on')
return true;
if (array_get($_SERVER, 'HTTP_X_FORWARDED_PROTO') == 'https')
return true;
if (array_get($_SERVER, 'HTTP_X_FORWARDED_SSL') == 'on')
return true;
return false;
}
}
if (! function_exists('nl2p')) {
/**
* Wrap blocks of text (delimited by \n) in p tags (similar to nl2br).
*
* @param string $text
* @return string
*/
function nl2p($text) {
$parts = explode("\n", $text);
$result = '<p>'.implode('</p><p>', $parts).'</p>';
// Remove empty paragraphs
return str_replace('<p></p>', '', $result);
}
}

View File

@ -13,16 +13,17 @@
"laravel/framework": "5.2.*", "laravel/framework": "5.2.*",
"devitek/yaml-translation": "^2.0", "devitek/yaml-translation": "^2.0",
"printempw/laravel-datatables-lite": "^1.0", "printempw/laravel-datatables-lite": "^1.0",
"composer/semver": "^1.4" "composer/semver": "^1.4",
"guzzlehttp/guzzle": "^6.3"
}, },
"require-dev": { "require-dev": {
"fzaninotto/faker": "~1.4", "fzaninotto/faker": "~1.4",
"mockery/mockery": "0.9.*", "mockery/mockery": "0.9.*",
"phpdocumentor/reflection-docblock": "3.2.2", "phpdocumentor/reflection-docblock": "3.2.2",
"phpunit/phpunit": "~4.0", "phpunit/phpunit": "~4.0",
"doctrine/instantiator": "1.0.5",
"symfony/css-selector": "2.8.*|3.0.*", "symfony/css-selector": "2.8.*|3.0.*",
"symfony/dom-crawler": "2.8.*|3.0.*", "symfony/dom-crawler": "2.8.*|3.0.*",
"barryvdh/laravel-debugbar": "^2.3",
"league/flysystem-memory": "^1.0", "league/flysystem-memory": "^1.0",
"mikey179/vfsStream": "1.6.4" "mikey179/vfsStream": "1.6.4"
}, },
@ -45,6 +46,7 @@
}, },
"scripts": { "scripts": {
"post-install-cmd": [ "post-install-cmd": [
"unzip -o storage/patches/bs_column_name_patch_180731.zip",
"unzip -o storage/patches/bs_php72_patch_180224.zip" "unzip -o storage/patches/bs_php72_patch_180224.zip"
] ]
}, },

1266
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,10 +6,23 @@ return [
| Application Version | Application Version
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Version of Blessing Skin Server | Version of Blessing Skin Server.
| |
*/ */
'version' => '3.4.0', 'version' => '3.5.0',
/*
|--------------------------------------------------------------------------
| Update Source
|--------------------------------------------------------------------------
|
| Where to get information of new versions.
|
*/
'update_source' => menv(
'UPDATE_SOURCE',
'https://work.prinzeugen.net/blessing-skin-server/update.json'
),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -100,7 +113,7 @@ return [
| |
*/ */
'key' => menv('APP_KEY', 'xrjmRQ/RE3B0ICoYqfWzPYizA7MPZz3fNXdnxQ7Cbcg='), 'key' => menv('APP_KEY', 'base64:MfnScX0W/ViN8bZtRt0P481rWP3igcOK80QstjbXUxI='),
'cipher' => 'AES-256-CBC', 'cipher' => 'AES-256-CBC',
@ -132,7 +145,7 @@ return [
'providers' => [ 'providers' => [
/* /**
* Laravel Framework Service Providers... * Laravel Framework Service Providers...
*/ */
Illuminate\Auth\AuthServiceProvider::class, Illuminate\Auth\AuthServiceProvider::class,
@ -157,22 +170,23 @@ return [
Illuminate\View\ViewServiceProvider::class, Illuminate\View\ViewServiceProvider::class,
/** /**
* Third-party libraries * Third-party Libraries...
*/ */
Devitek\Core\Translation\TranslationServiceProvider::class,
Swiggles\Memcache\MemcacheServiceProvider::class, Swiggles\Memcache\MemcacheServiceProvider::class,
Yajra\Datatables\DatatablesServiceProvider::class, Yajra\Datatables\DatatablesServiceProvider::class,
/** /**
* Application Service Providers... * Application Service Providers...
*/ */
App\Providers\BootServiceProvider::class, App\Providers\RuntimeCheckServiceProvider::class,
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\PluginServiceProvider::class,
App\Providers\EventServiceProvider::class, App\Providers\EventServiceProvider::class,
App\Providers\LogServiceProvider::class,
App\Providers\MemoryServiceProvider::class, App\Providers\MemoryServiceProvider::class,
App\Providers\RouteServiceProvider::class, App\Providers\PluginServiceProvider::class,
App\Providers\ResponseMacroServiceProvider::class, App\Providers\ResponseMacroServiceProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\TranslationServiceProvider::class,
App\Providers\ValidatorExtendServiceProvider::class, App\Providers\ValidatorExtendServiceProvider::class,
], ],
@ -230,7 +244,6 @@ return [
'Option' => App\Services\Facades\Option::class, 'Option' => App\Services\Facades\Option::class,
'Utils' => App\Services\Utils::class, 'Utils' => App\Services\Utils::class,
'Minecraft' => App\Services\Minecraft::class, 'Minecraft' => App\Services\Minecraft::class,
'Updater' => App\Services\Updater::class,
], ],
]; ];

View File

@ -109,9 +109,11 @@ return [
'cluster' => false, 'cluster' => false,
'default' => [ 'default' => [
'scheme' => menv('REDIS_SCHEME', 'tcp'),
'host' => menv('REDIS_HOST', 'localhost'), 'host' => menv('REDIS_HOST', 'localhost'),
'password' => menv('REDIS_PASSWORD', null),
'port' => menv('REDIS_PORT', 6379), 'port' => menv('REDIS_PORT', 6379),
'path' => menv('REDIS_SOCKET_PATH'),
'password' => menv('REDIS_PASSWORD', null),
'database' => 0, 'database' => 0,
], ],

View File

@ -16,7 +16,7 @@ return [
| |
*/ */
'driver' => menv('MAIL_DRIVER', 'smtp'), 'driver' => menv('MAIL_DRIVER'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -107,6 +107,6 @@ return [
| |
*/ */
'sendmail' => '/usr/sbin/sendmail -bs', 'sendmail' => menv('SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'),
]; ];

View File

@ -5,6 +5,8 @@ return [
'site_name' => 'Blessing Skin', 'site_name' => 'Blessing Skin',
'site_description' => 'Open-source PHP Minecraft Skin Hosting Service', 'site_description' => 'Open-source PHP Minecraft Skin Hosting Service',
'user_can_register' => 'true', 'user_can_register' => 'true',
'register_with_player_name' => 'true',
'require_verification' => 'false',
'regs_per_ip' => '3', 'regs_per_ip' => '3',
'ip_get_method' => '0', 'ip_get_method' => '0',
'api_type' => 'false', 'api_type' => 'false',
@ -28,11 +30,11 @@ return [
'score_per_player' => '100', 'score_per_player' => '100',
'sign_after_zero' => 'false', 'sign_after_zero' => 'false',
'version' => '', 'version' => '',
'check_update' => 'true', 'copyright_text' => '<strong>Copyright &copy; {year} <a href="{site_url}">{site_name}</a>.</strong> All rights reserved.',
'update_source' => 'https://work.prinzeugen.net/update.json',
'copyright_text' => '<strong>Copyright &copy; '.getdate()['year'].' <a href="{site_url}">{site_name}</a>.</strong> All rights reserved.',
'auto_del_invalid_texture' => 'false', 'auto_del_invalid_texture' => 'false',
'return_200_when_notfound' => 'false', 'allow_downloading_texture' => 'true',
'texture_name_regexp' => '',
'return_204_when_notfound' => 'false',
'cache_expire_time' => '31536000', 'cache_expire_time' => '31536000',
'max_upload_file_size' => '1024', 'max_upload_file_size' => '1024',
'force_ssl' => 'false', 'force_ssl' => 'false',

View File

@ -22,4 +22,14 @@ return [
| |
*/ */
'url' => menv('PLUGINS_URL'), 'url' => menv('PLUGINS_URL'),
/*
|--------------------------------------------------------------------------
| Plugins Market Source
|--------------------------------------------------------------------------
|
| Specify where to get plugins' metadata for plugin merket.
|
*/
'registry' => menv('PLUGINS_REGISTRY', 'https://work.prinzeugen.net/blessing-skin-server/plugins.json'),
]; ];

View File

@ -3,12 +3,26 @@
return [ return [
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Configuration about security | Security Configuration
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Load them from env to config, preventing cache problems | Load them from env to config, preventing cache problems
| |
*/ */
'cipher' => menv('PWD_METHOD', 'SALTED2MD5'), 'cipher' => menv('PWD_METHOD', 'SALTED2MD5'),
'salt' => menv('SALT', '') 'salt' => menv('SALT', ''),
/*
|--------------------------------------------------------------------------
| SSL Certificates
|--------------------------------------------------------------------------
|
| Describes the SSL certificate verification behavior of all Guzzle requests.
| By default, we use the CA bundle provided by Mozilla.
|
| See: http://docs.guzzlephp.org/en/stable/request-options.html#verify
|
*/
'certificates' => menv('SSL_CERT', storage_path('patches/ca-bundle.crt')),
'user_agent' => menv('USER_AGENT', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36'),
]; ];

48
config/services.php Normal file
View File

@ -0,0 +1,48 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Stripe, Mailgun, Mandrill, and others. This file provides a sane
| default location for this type of information, allowing packages
| to have a conventional place to find your various credentials.
|
*/
'mailgun' => [
'domain' => env('MAILGUN_DOMAIN'),
'secret' => env('MAILGUN_SECRET'),
'guzzle' => [
'verify' => config('secure.certificates')
],
],
'mandrill' => [
'secret' => env('MANDRILL_SECRET'),
'guzzle' => [
'verify' => config('secure.certificates')
],
],
'ses' => [
'key' => env('SES_KEY'),
'secret' => env('SES_SECRET'),
'region' => menv('SES_REGION'),
'guzzle' => [
'verify' => config('secure.certificates')
],
],
'sparkpost' => [
'secret' => env('SPARKPOST_SECRET'),
'guzzle' => [
'verify' => config('secure.certificates')
],
],
];

View File

@ -44,7 +44,7 @@ return [
| |
*/ */
'encrypt' => false, 'encrypt' => true,
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@ -14,6 +14,7 @@ return [
*/ */
'paths' => [ 'paths' => [
realpath(base_path('resources/views/overrides')),
realpath(base_path('resources/views')), realpath(base_path('resources/views')),
], ],

View File

@ -12,7 +12,24 @@ $factory->define(User::class, function (Faker\Generator $faker) {
'ip' => '127.0.0.1', 'ip' => '127.0.0.1',
'permission' => 0, 'permission' => 0,
'last_sign_at' => $faker->dateTime, 'last_sign_at' => $faker->dateTime,
'register_at' => $faker->dateTime 'register_at' => $faker->dateTime,
'verified' => 1
];
});
$factory->defineAs(User::class, 'unverified', function (Faker\Generator $faker) {
return [
'email' => $faker->email,
'nickname' => $faker->name,
'score' => 1000,
'avatar' => 0,
'password' => app('cipher')->hash(str_random(10), config('secure.salt')),
'ip' => '127.0.0.1',
'permission' => 1,
'last_sign_at' => $faker->dateTime,
'register_at' => $faker->dateTime,
'verified' => 0,
'verification_token' => generate_random_token()
]; ];
}); });
@ -26,7 +43,8 @@ $factory->defineAs(User::class, 'admin', function (Faker\Generator $faker) {
'ip' => '127.0.0.1', 'ip' => '127.0.0.1',
'permission' => 1, 'permission' => 1,
'last_sign_at' => $faker->dateTime, 'last_sign_at' => $faker->dateTime,
'register_at' => $faker->dateTime 'register_at' => $faker->dateTime,
'verified' => 1
]; ];
}); });
@ -40,7 +58,8 @@ $factory->defineAs(User::class, 'superAdmin', function (Faker\Generator $faker)
'ip' => '127.0.0.1', 'ip' => '127.0.0.1',
'permission' => 2, 'permission' => 2,
'last_sign_at' => $faker->dateTime, 'last_sign_at' => $faker->dateTime,
'register_at' => $faker->dateTime 'register_at' => $faker->dateTime,
'verified' => 1
]; ];
}); });
@ -54,6 +73,7 @@ $factory->defineAs(User::class, 'banned', function (Faker\Generator $faker) {
'ip' => '127.0.0.1', 'ip' => '127.0.0.1',
'permission' => -1, 'permission' => -1,
'last_sign_at' => $faker->dateTime, 'last_sign_at' => $faker->dateTime,
'register_at' => $faker->dateTime 'register_at' => $faker->dateTime,
'verified' => 1
]; ];
}); });

View File

@ -15,13 +15,20 @@ class ImportOptions extends Migration
// import options // import options
$options = config('options'); $options = config('options');
$options['version'] = config('app.version'); $options['version'] = config('app.version');
$options['announcement'] = str_replace( $options['announcement'] = str_replace(
'{version}', '{version}',
$options['version'], $options['version'],
$options['announcement'] $options['announcement']
); );
$options['copyright_text'] = str_replace(
'{year}',
Carbon\Carbon::now()->year,
$options['copyright_text']
);
foreach ($options as $key => $value) { foreach ($options as $key => $value) {
Option::set($key, $value); Option::set($key, $value);
} }

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddVerificationToUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('verified')->default(false);
$table->string('verification_token')->default('');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
if (config('database.default') == 'sqlite') {
// Dropping columns from a SQLite database requires `doctrine/dbal` dependency.
// However, we won't install it because it's too hard to specify the version of
// all the new dependencies exactly to make them support PHP ^5.5.9. Damn it.
return;
}
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('verified');
$table->dropColumn('verification_token');
});
}
}

View File

@ -0,0 +1,34 @@
<?php
$msg = [];
try {
Artisan::call('migrate', ['--force' => true]);
$msg[] = '【数据库】升级成功!现在你可以正常使用 v3.5.0 内置的用户邮箱验证功能了';
} catch (Exception $e) {
$msg[] = '【数据库】更新数据表失败,错误信息:'.$e->getMessage();
$msg[] = '【数据库】这并不影响 v3.5.0 的基本功能,你可以参考 <a href="https://github.com/printempw/blessing-skin-server/wiki/%E6%89%8B%E5%8A%A8%E5%AE%89%E8%A3%85-Blessing-Skin#-%E5%8D%87%E7%BA%A7%E8%87%B3-bs-v350" target="_blank">这篇文章</a> 手动升级你的数据库';
}
$plugins_enabled = (array) json_decode(option('plugins_enabled'), true);
if (in_array('data-integration', $plugins_enabled)) {
$plugins_enabled = '["data-integration"]';
$msg[] = '【数据对接】原有的数据对接插件已经不再维护,并且有可能在 v3.5.0 上出现奇怪的问题';
$msg[] = '【数据对接】请参考 <a href="https://github.com/printempw/blessing-skin-server/wiki/%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8%E6%95%B0%E6%8D%AE%E5%AF%B9%E6%8E%A5" target="_blank">这篇文章</a> 升级你的数据对接插件';
} else {
$plugins_enabled = '';
}
$msg[] = '【插件系统】升级程序已经自动禁用了所有已安装的插件,因为这些插件的版本可能过旧';
$msg[] = '【插件系统】请在后台的「插件市场」页面升级你的所有插件,确保其为最新版后再启用它们';
$msg[] = '【插件系统】在 v3.5.0 上强行启用旧版的插件可能造成无法预知的问题!';
option(['plugins_enabled' => $plugins_enabled]);
option(['return_204_when_notfound' => option('return_404_when_notfound')]);
option(['version' => config('app.version')]);
$msg[] = '【升级成功】升级完成后请【务必】清空你的浏览器缓存,否则可能会出现奇怪的问题';
$msg[] = '【升级成功】使用愉快!<a href="https://github.com/printempw/blessing-skin-server/wiki/CHANGELOG" target="_blank">查看完整更新日志</a>';
return $msg;

View File

@ -1,27 +1,29 @@
'use strict'; 'use strict';
var gulp = require('gulp'), const
babel = require('gulp-babel'), babel = require('gulp-babel'),
eslint = require('gulp-eslint'), chalk = require('chalk'),
uglify = require('gulp-uglify'),
stylus = require('gulp-stylus'),
cleanCss = require('gulp-clean-css'), cleanCss = require('gulp-clean-css'),
del = require('del'),
exec = require('child_process').exec,
concat = require('gulp-concat'), concat = require('gulp-concat'),
zip = require('gulp-zip'), del = require('del'),
replace = require('gulp-batch-replace'), eslint = require('gulp-eslint'),
notify = require('gulp-notify'), execSync = require('child_process').execSync,
sourcemaps = require('gulp-sourcemaps'), gulp = require('gulp'),
merge = require('merge2'), merge = require('merge2'),
runSequence = require('run-sequence'); replace = require('gulp-batch-replace'),
runSequence = require('run-sequence'),
sourcemaps = require('gulp-sourcemaps'),
stylus = require('gulp-stylus'),
through2 = require('through2'),
uglify = require('gulp-uglify'),
zip = require('gulp-zip');
var version = require('./package.json').version; const version = require('./package.json').version;
var srcPath = 'resources/assets/src'; const srcPath = 'resources/assets/src';
var distPath = 'resources/assets/dist'; const distPath = 'resources/assets/dist';
var vendorScripts = [ const vendorScripts = [
'jquery/dist/jquery.min.js', 'jquery/dist/jquery.min.js',
'bootstrap/dist/js/bootstrap.min.js', 'bootstrap/dist/js/bootstrap.min.js',
'admin-lte/dist/js/adminlte.min.js', 'admin-lte/dist/js/adminlte.min.js',
@ -33,14 +35,14 @@ var vendorScripts = [
'jqPaginator/dist/1.2.0/jqPaginator.min.js', 'jqPaginator/dist/1.2.0/jqPaginator.min.js',
]; ];
var vendorScriptsToBeMinified = [ const vendorScriptsToBeMinified = [
'regenerator-runtime/runtime.js', 'regenerator-runtime/runtime.js',
'datatables.net/js/jquery.dataTables.js', 'datatables.net/js/jquery.dataTables.js',
'datatables.net-bs/js/dataTables.bootstrap.js', 'datatables.net-bs/js/dataTables.bootstrap.js',
'resources/assets/dist/js/common.js', 'resources/assets/dist/js/common.js',
]; ];
var vendorStyles = [ const vendorStyles = [
'bootstrap/dist/css/bootstrap.min.css', 'bootstrap/dist/css/bootstrap.min.css',
'admin-lte/dist/css/AdminLTE.min.css', 'admin-lte/dist/css/AdminLTE.min.css',
'datatables.net-bs/css/dataTables.bootstrap.css', 'datatables.net-bs/css/dataTables.bootstrap.css',
@ -51,22 +53,22 @@ var vendorStyles = [
'sweetalert2/dist/sweetalert2.min.css', 'sweetalert2/dist/sweetalert2.min.css',
]; ];
var styleReplacements = [ const styleReplacements = [
['blue.png', '"../images/blue.png"'], ['blue.png', '"../images/blue.png"'],
['blue@2x.png', '"../images/blue@2x.png"'], ['blue@2x.png', '"../images/blue@2x.png"'],
['../img/loading.gif', '"../images/loading.gif"'], ['../img/loading.gif', '"../images/loading.gif"'],
['../img/loading-sm.gif', '"../images/loading-sm.gif"'], ['../img/loading-sm.gif', '"../images/loading-sm.gif"'],
]; ];
var scriptReplacements = []; const scriptReplacements = [];
var fonts = [ const fonts = [
'font-awesome/fonts/**', 'font-awesome/fonts/**',
'bootstrap/dist/fonts/**', 'bootstrap/dist/fonts/**',
'resources/assets/src/fonts/**', 'resources/assets/src/fonts/**',
]; ];
var images = [ const images = [
'icheck/skins/square/blue.png', 'icheck/skins/square/blue.png',
'icheck/skins/square/blue@2x.png', 'icheck/skins/square/blue@2x.png',
'resources/assets/src/images/**', 'resources/assets/src/images/**',
@ -74,17 +76,21 @@ var images = [
'bootstrap-fileinput/img/loading-sm.gif', 'bootstrap-fileinput/img/loading-sm.gif',
]; ];
const argv = require('minimist')(process.argv.slice(2));
// Determine if we are in production mode,
// run `gulp [task] --production` to enable.
if (argv.production) {
console.log(chalk.green('>> Running in PRODUCTION mode <<'));
process.env.NODE_ENV = 'production';
}
// aka. `yarn run build` // aka. `yarn run build`
gulp.task('default', ['build']); gulp.task('default', ['build']);
// Build the things! // Build the things!
gulp.task('build', callback => { gulp.task('build', callback => {
runSequence('clean', 'lint', ['compile-es6', 'compile-stylus'], 'publish-vendor', 'notify', callback); runSequence('clean', 'lint', ['compile-scripts', 'compile-stylus'], 'publish-vendor', callback);
});
// Send a notification
gulp.task('notify', () => {
return gulp.src('').pipe(notify('Assets compiled!'));
}); });
// Check JavaScript files with ESLint // Check JavaScript files with ESLint
@ -96,35 +102,39 @@ gulp.task('lint', () => {
}); });
// Concentrate all vendor scripts & styles to one dist file // Concentrate all vendor scripts & styles to one dist file
gulp.task('publish-vendor', ['compile-es6'], callback => { gulp.task('publish-vendor', callback => {
// Collect pre-complied and raw library files
const vendorJs = gulp.src(collect(vendorScripts)).pipe(replace(scriptReplacements));
const rawVendorJs = gulp.src(collect(vendorScriptsToBeMinified)).pipe(uglify());
// JavaScript files // JavaScript files
var js = gulp.src(convertNpmRelativePath(vendorScripts)) merge(vendorJs, rawVendorJs)
.pipe(replace(scriptReplacements)); .pipe(sourcemaps.init({ loadMaps: true }))
var jsToBeMinified = gulp.src(convertNpmRelativePath(vendorScriptsToBeMinified))
.pipe(uglify());
merge(js, jsToBeMinified)
.pipe(concat('app.js')) .pipe(concat('app.js'))
// Remove source mappings in the pre-compiled files
.pipe(sourcemaps.write({ addComment: false }))
.pipe(gulp.dest(`${distPath}/js/`)); .pipe(gulp.dest(`${distPath}/js/`));
// CSS files // CSS files
gulp.src(convertNpmRelativePath(vendorStyles)) gulp.src(collect(vendorStyles))
.pipe(sourcemaps.init({ loadMaps: true }))
.pipe(concat('style.css')) .pipe(concat('style.css'))
.pipe(replace(styleReplacements)) .pipe(replace(styleReplacements))
.pipe(sourcemaps.write({ addComment: false }))
.pipe(gulp.dest(`${distPath}/css/`)); .pipe(gulp.dest(`${distPath}/css/`));
// Fonts // Fonts
gulp.src(convertNpmRelativePath(fonts)) gulp.src(collect(fonts))
.pipe(gulp.dest(`${distPath}/fonts/`)); .pipe(gulp.dest(`${distPath}/fonts/`));
// Images // Images
gulp.src(convertNpmRelativePath(images)) gulp.src(collect(images))
.pipe(gulp.dest(`${distPath}/images/`)); .pipe(gulp.dest(`${distPath}/images/`));
// AdminLTE skins // AdminLTE skins
gulp.src(convertNpmRelativePath(['admin-lte/dist/css/skins/*.min.css'])) gulp.src(collect(['admin-lte/dist/css/skins/*.min.css']))
.pipe(gulp.dest(`${distPath}/css/skins/`)); .pipe(gulp.dest(`${distPath}/css/skins/`));
// 3D skin preview // Libraries for 3D skin preview
gulp.src(convertNpmRelativePath(['three/build/three.min.js', 'skinview3d/build/skinview3d.min.js'])) gulp.src(collect(['three/build/three.min.js', 'skinview3d/build/skinview3d.min.js']))
.pipe(concat('skinview3d.js')) .pipe(concat('skinview3d.js'))
.pipe(gulp.dest(`${distPath}/js/`)); .pipe(gulp.dest(`${distPath}/js/`));
// Chart.js // Chart.js
gulp.src(convertNpmRelativePath(['chart.js/dist/Chart.min.js'])) gulp.src(collect(['chart.js/dist/Chart.min.js']))
.pipe(concat('chart.js')) .pipe(concat('chart.js'))
.pipe(gulp.dest(`${distPath}/js/`)); .pipe(gulp.dest(`${distPath}/js/`));
@ -134,116 +144,123 @@ gulp.task('publish-vendor', ['compile-es6'], callback => {
// Compile stylus to css // Compile stylus to css
gulp.task('compile-stylus', () => { gulp.task('compile-stylus', () => {
return gulp.src(`${srcPath}/stylus/*.styl`) return gulp.src(`${srcPath}/stylus/*.styl`)
.pipe(sourcemaps.init()) .pipe(dev(sourcemaps.init()))
.pipe(stylus()) .pipe(stylus())
.pipe(cleanCss()) .pipe(cleanCss())
.pipe(sourcemaps.write('./maps')) .pipe(dev(sourcemaps.write('./maps')))
.pipe(gulp.dest(`${distPath}/css`)); .pipe(gulp.dest(`${distPath}/css`));
}); });
// Compile ES6 scripts to ES5 // Compile ES6 scripts to ES5
gulp.task('compile-es6', callback => { gulp.task('compile-scripts', callback => {
['common', 'admin', 'auth', 'skinlib', 'user'].forEach(moduleName => { ['common', 'admin', 'auth', 'skinlib', 'user'].forEach(moduleName => {
return gulp.src(`${srcPath}/js/${moduleName}/*.js`) return gulp.src(`${srcPath}/js/${moduleName}/*.js`)
.pipe(sourcemaps.init()) .pipe(dev(sourcemaps.init()))
.pipe(babel()) .pipe(babel())
.pipe(concat(`${moduleName}.js`)) .pipe(concat(`${moduleName}.js`))
.pipe(uglify()) .pipe(uglify())
.pipe(sourcemaps.write('./maps')) .pipe(dev(sourcemaps.write('./maps')))
.pipe(gulp.dest(`${distPath}/js`)); .pipe(gulp.dest(`${distPath}/js`));
}); });
callback(); callback();
}); });
// Delete cache files // Delete cache and built files
gulp.task('clean', () => { gulp.task('clean', callback => {
del([`${distPath}/**/*`]);
clearCache(); clearCache();
callback();
return clearDist();
}); });
// Release archive file // Release a zip archive file
// aka. `yarn run release` // aka. `yarn run release`
gulp.task('zip', () => { gulp.task('zip', () => {
console.log(`Don't forget to run ${ chalk.underline.yellow('gulp build --production') } first!`);
console.log('Cleaning cache files');
clearCache(); clearCache();
console.log('Cache file deleted');
exec('composer dump-autoload --no-dev', () => { // Generate autoload files without autoload-dev
console.log('Autoload files generated without autoload-dev'); execSync('composer dump-autoload --no-dev', { stdio: 'inherit' });
});
let zipPath = `blessing-skin-server-v${version}.zip`; const savePath = argv['save-to'] || '..';
const zipFile = `blessing-skin-server-v${version}.zip`;
console.log(`Zip archive will be saved to ${zipPath}.`); console.log('Zip archive will be saved to ' + chalk.underline.blue(
require('path').join(savePath, zipFile)
));
return gulp.src([ return gulp.src([
'**/*.*', '**/*',
'artisan', '**/.gitignore',
'LICENSE', '**/.htaccess',
'!.babelrc', '.env.example',
'!.eslintrc.js', // Exclude unnecessary files
'!.eslintignore', '!.gitignore',
'!.editorconfig', '!composer.*',
'!.travis.yml', '!gulpfile.js',
'!{.env,.env.testing}', '!ISSUE_TEMPLATE.md',
'!{.git,.git/**}', '!package.json',
'!{.gitignore,.gitmodules,.gitattributes}', '!phpunit.xml',
'!gulpfile.js', '!yarn.lock',
'!composer.*', // Exclude unnecessary directories
'!yarn.lock', '!plugins/**',
'!plugins/**', '!resources/assets/{src,src/**}',
'plugins/', '!resources/assets/dist/**/{maps,maps/**}',
'!phpunit.xml', '!resources/lang/overrides/**',
'!package.json', '!resources/views/overrides/**',
'!{tests,tests/**}', '!storage/textures/**',
'!ISSUE_TEMPLATE.md', '!{coverage,coverage/**}',
'!{coverage,coverage/**}', '!{node_modules,node_modules/**,node_modules/**/.gitignore}',
'!{node_modules,node_modules/**}', '!{tests,tests/**}',
'!storage/textures/**', // Extracted symbol links are always weird, I don't know exactly why
'!resources/assets/{src,src/**}', '!vendor/bin/**',
'!resources/assets/dist/**/{maps,maps/**}', // Exclude "require-dev" packages
// do not pack packages for developments '!vendor/fzaninotto/**',
'!vendor/fzaninotto/**', '!vendor/mikey179/**',
'!vendor/mockery/**', '!vendor/mockery/**',
'!vendor/phpunit/**', '!vendor/phpunit/**',
'!vendor/symfony/css-selector/**', '!vendor/symfony/css-selector/**',
'!vendor/symfony/dom-crawler/**', '!vendor/symfony/dom-crawler/**',
'!vendor/mikey179/vfsStream/**', ])
], { dot: true }) .pipe(zip(zipFile))
.pipe(zip(zipPath)) .pipe(gulp.dest(savePath))
.pipe(notify('Don\'t forget to compile Stylus & ES2015 files before publishing a release!')) .pipe(through2.obj(function (chunk, enc, callback) {
.pipe(gulp.dest('../')) console.log('Zip archive saved!');
.pipe(notify({ message: `Zip archive saved to ${zipPath}!` })); // Generate autoload files with autoload-dev
execSync('composer dump-autoload', { stdio: 'inherit' });
callback();
}));
}); });
gulp.task('watch', ['compile-stylus', 'compile-es6'], () => { gulp.task('watch', ['compile-stylus', 'compile-scripts'], () => {
// watch .scss files gulp.watch(`${srcPath}/stylus/*.styl`, ['compile-stylus']);
gulp.watch(`${srcPath}/stylus/*.scss`, ['compile-stylus'], () => notify('Stylus files compiled!')); gulp.watch(`${srcPath}/js/**/*.js`, ['compile-scripts']);
// watch .js files gulp.watch(`${srcPath}/js/common/*.js`, ['publish-vendor']);
gulp.watch(`${srcPath}/js/**/*.js`, ['compile-es6'], () => notify('ES6 scripts compiled!'));
gulp.watch(`${srcPath}/js/general.js`, ['publish-vendor']);
}); });
function convertNpmRelativePath(paths) { function dev(transformFunction) {
return argv.production ? through2.obj() : transformFunction;
}
const collect = function convertNpmRelativePath(paths) {
return paths.map(relativePath => { return paths.map(relativePath => {
return relativePath.startsWith('resources') ? relativePath : `node_modules/${relativePath}`; return relativePath.startsWith('resources') ? relativePath : `node_modules/${relativePath}`;
}); });
} };
function clearCache() { function clearCache() {
return del([ return del([
'storage/logs/*', 'storage/logs/*.log',
'storage/testing/*', 'storage/testing/*',
'storage/debugbar/*', 'storage/debugbar/*',
'storage/update_cache/*', 'storage/update_cache/*',
'storage/update_cache',
'storage/yaml-translation/*', 'storage/yaml-translation/*',
'storage/framework/cache/*', 'storage/framework/cache/*',
'storage/framework/sessions/*', 'storage/framework/sessions/*',
'storage/framework/views/*' 'storage/framework/views/*',
'!storage/framework/sessions/index.html'
]); ]);
} }
function clearDist() {
return del([`${distPath}/**/*`]);
}

View File

@ -1,6 +1,6 @@
{ {
"name": "blessing-skin-server", "name": "blessing-skin-server",
"version": "3.4.0", "version": "3.5.0",
"description": "A web application brings your custom skins back in offline Minecraft servers.", "description": "A web application brings your custom skins back in offline Minecraft servers.",
"repository": { "repository": {
"type": "git", "type": "git",
@ -32,7 +32,7 @@
"jqPaginator": "^1.2.0", "jqPaginator": "^1.2.0",
"jquery": "^3.3.1", "jquery": "^3.3.1",
"regenerator": "^0.12.3", "regenerator": "^0.12.3",
"skinview3d": "^1.1.0-alpha.2", "skinview3d": "^1.1.0",
"sweetalert2": "^6.11.5", "sweetalert2": "^6.11.5",
"toastr": "^2.1.4" "toastr": "^2.1.4"
}, },
@ -40,6 +40,7 @@
"babel-plugin-transform-inline-environment-variables": "^0.2.0", "babel-plugin-transform-inline-environment-variables": "^0.2.0",
"babel-plugin-transform-remove-console": "^6.9.0", "babel-plugin-transform-remove-console": "^6.9.0",
"babel-preset-env": "^1.6.1", "babel-preset-env": "^1.6.1",
"chalk": "^2.4.1",
"codecov": "^3.0.0", "codecov": "^3.0.0",
"del": "^3.0.0", "del": "^3.0.0",
"gulp": "^3.9.1", "gulp": "^3.9.1",
@ -55,6 +56,7 @@
"gulp-zip": "^4.1.0", "gulp-zip": "^4.1.0",
"jest": "^20.0.4", "jest": "^20.0.4",
"merge2": "^1.2.1", "merge2": "^1.2.1",
"minimist": "^1.2.0",
"run-sequence": "^2.2.1", "run-sequence": "^2.2.1",
"stylus": "^0.54.5" "stylus": "^0.54.5"
}, },

View File

@ -2,6 +2,11 @@
const $ = require('jquery'); const $ = require('jquery');
window.$ = window.jQuery = $; window.$ = window.jQuery = $;
$.fn.dataTable = {
defaults: {},
ext: { errMode: '' },
render: { text: () => ({ filter: text => text }) }
};
jest.useFakeTimers(); jest.useFakeTimers();
@ -407,16 +412,18 @@ describe('tests for "plugins" module', () => {
it('enable a plugin', async () => { it('enable a plugin', async () => {
const fetch = jest.fn() const fetch = jest.fn()
.mockReturnValueOnce(Promise.resolve({ requirements: [] }))
.mockReturnValueOnce(Promise.resolve({ requirements: [] }))
.mockReturnValueOnce(Promise.resolve({ errno: 0, msg: 'success' })) .mockReturnValueOnce(Promise.resolve({ errno: 0, msg: 'success' }))
.mockReturnValueOnce(Promise.resolve({
isRequirementsSatisfied: false,
requirements: { 'a': '^1.1.0', 'b': '^2.1.0', 'c': '^3.3.0' },
unsatisfiedRequirements: { 'c': '^3.3.0' }
}))
.mockReturnValueOnce(Promise.resolve({ errno: 1, msg: 'notice', reason: ['reason1', 'reason2'] })) .mockReturnValueOnce(Promise.resolve({ errno: 1, msg: 'notice', reason: ['reason1', 'reason2'] }))
.mockReturnValueOnce(Promise.reject()); .mockReturnValueOnce(Promise.reject());
const getPluginDependencies = jest.fn()
.mockReturnValueOnce({ dependencies: { requirements: [] } })
.mockReturnValue({
dependencies: {
isRequirementsSatisfied: false,
requirements: { 'a': '^1.1.0', 'b': '^2.1.0', 'c': '^3.3.0' },
unsatisfiedRequirements: { 'c': '^3.3.0' }
}
});
const url = jest.fn(path => path); const url = jest.fn(path => path);
const swal = jest.fn() const swal = jest.fn()
.mockReturnValueOnce(Promise.reject()) .mockReturnValueOnce(Promise.reject())
@ -435,23 +442,22 @@ describe('tests for "plugins" module', () => {
$.pluginsTable = { $.pluginsTable = {
ajax: { ajax: {
reload: reloadTable reload: reloadTable
} },
row: () => ({
data: getPluginDependencies
})
}; };
const enablePlugin = require(modulePath).enablePlugin; const enablePlugin = require(modulePath).enablePlugin;
await enablePlugin('plugin'); await enablePlugin('plugin');
expect(fetch).toBeCalledWith({ expect(getPluginDependencies).toBeCalled();
type: 'POST',
url: 'admin/plugins/manage?action=requirements&name=plugin',
dataType: 'json'
});
expect(swal).toBeCalledWith({ expect(swal).toBeCalledWith({
text: 'admin.noDependenciesNotice', text: 'admin.noDependenciesNotice',
type: 'warning', type: 'warning',
showCancelButton: true showCancelButton: true
}); });
expect(fetch.mock.calls.length).toBe(1); expect(fetch).not.toBeCalled();
await enablePlugin('plugin'); await enablePlugin('plugin');
expect(fetch).toBeCalledWith({ expect(fetch).toBeCalledWith({
@ -565,6 +571,160 @@ describe('tests for "plugins" module', () => {
}); });
}); });
describe('tests for "market" module', () => {
const modulePath = '../admin/market';
// TODO: test initializing market table
it('install a plugin', async () => {
const fetch = jest.fn()
.mockReturnValueOnce(Promise.resolve({ errno: 0, msg: 'success' }))
.mockReturnValueOnce(Promise.resolve({ errno: 1, msg: 'failed' }))
.mockReturnValueOnce(Promise.reject());
const url = jest.fn(path => path);
const swal = jest.fn();
const toastr = {
success: jest.fn(),
warning: jest.fn()
};
const trans = jest.fn(key => key);
const reloadTable = jest.fn();
const showAjaxError = jest.fn();
window.trans = trans;
window.fetch = fetch;
window.url = url;
window.swal = swal;
window.toastr = toastr;
window.showAjaxError = showAjaxError;
$.marketTable = {
ajax: {
reload: reloadTable
}
};
document.body.innerHTML = `
<tr id="plugin-foo-bar"><button class=".btn">Download</button></tr>
`;
const installPlugin = require(modulePath).installPlugin;
await installPlugin('foo-bar');
expect(fetch).toBeCalledWith({
type: 'POST',
url: 'admin/plugins/market/download',
dataType: 'json',
data: { name: 'foo-bar' },
beforeSend: expect.any(Function)
});
expect(swal).not.toBeCalledWith({ type: 'warning' });
expect(toastr.success).toBeCalledWith('success');
expect(reloadTable).toBeCalledWith(null, false);
await installPlugin('foo-bar');
expect(swal).toBeCalledWith({ type: 'warning', html: 'failed' });
await installPlugin('foo-bar');
expect(showAjaxError).toBeCalled();
});
it('update a plugin', async () => {
const fetch = jest.fn()
.mockReturnValueOnce(Promise.resolve({ errno: 0, msg: 'success' }));
const getPluginRowData = jest.fn()
.mockReturnValueOnce({ installed: false })
.mockReturnValue({
title: 'Foo Bar',
version: '5.1.4',
installed: '1.1.4'
});
const url = jest.fn(path => path);
const swal = jest.fn()
.mockReturnValueOnce(Promise.resolve())
.mockReturnValueOnce(Promise.reject())
.mockReturnValueOnce(Promise.resolve());
const toastr = {
success: jest.fn(),
warning: jest.fn()
};
const trans = jest.fn(key => key);
const reloadTable = jest.fn();
const showAjaxError = jest.fn();
window.trans = trans;
window.fetch = fetch;
window.url = url;
window.swal = swal;
window.toastr = toastr;
window.showAjaxError = showAjaxError;
$.marketTable = {
ajax: {
reload: reloadTable
},
row: () => ({
data: getPluginRowData
})
};
document.body.innerHTML = `
<tr id="plugin-foo-bar"><button class=".btn">Update</button></tr>
`;
const updatePlugin = require(modulePath).updatePlugin;
await updatePlugin('foo-bar');
expect(swal).toBeCalledWith({ type: 'warning', html: 'not installed' });
expect(fetch).not.toBeCalled();
await updatePlugin('foo-bar');
expect(swal).toBeCalledWith({ type: 'warning', text: 'admin.confirmUpdate', showCancelButton: true });
expect(fetch).not.toBeCalled();
await updatePlugin('foo-bar');
expect(fetch).toBeCalledWith({
type: 'POST',
url: 'admin/plugins/market/download',
dataType: 'json',
data: { name: 'foo-bar' },
beforeSend: expect.any(Function)
});
expect(toastr.success).toBeCalledWith('success');
expect(reloadTable).toBeCalledWith(null, false);
});
it('check for plugin updates', async () => {
const fetch = jest.fn()
.mockReturnValueOnce(Promise.resolve({
available: false,
plugins: []
}))
.mockReturnValueOnce(Promise.resolve({
available: true,
plugins: [{
name: 'hello-dolly',
version: '8.1.0'
}]
}));
const url = jest.fn(path => path);
window.fetch = fetch;
window.url = url;
document.body.innerHTML = `
<a id="target" href="admin/plugins/market"><i class="fa fa-shopping-bag"></i> <span>Plugin Market</span></a>
`;
const checkForPluginUpdates = require(modulePath).checkForPluginUpdates;
await checkForPluginUpdates();
expect($('#target').html()).toBe(
'<i class="fa fa-shopping-bag"></i> <span>Plugin Market</span>'
);
await checkForPluginUpdates();
expect($('#target').html()).toBe(
'<i class="fa fa-shopping-bag"></i> <span>Plugin Market</span>'+
'<span class="label label-success pull-right">1</span>'
);
});
});
describe('tests for "update" module', () => { describe('tests for "update" module', () => {
const modulePath = '../admin/update'; const modulePath = '../admin/update';
@ -573,7 +733,8 @@ describe('tests for "update" module', () => {
.mockImplementationOnce(({ beforeSend }) => { .mockImplementationOnce(({ beforeSend }) => {
beforeSend && beforeSend(); beforeSend && beforeSend();
return Promise.resolve({ return Promise.resolve({
file_size: 5000 release_url: 'http://skin.test/update.zip',
tmp_path: '/tmp/update.zip'
}); });
}) })
.mockImplementationOnce(() => Promise.resolve()) .mockImplementationOnce(() => Promise.resolve())
@ -620,7 +781,6 @@ describe('tests for "update" module', () => {
dataType: 'json', dataType: 'json',
})); }));
expect($('#update-button').prop('disabled')).toBe(true); expect($('#update-button').prop('disabled')).toBe(true);
expect($('#file-size').html()).toBe('5000');
expect(modal).toBeCalledWith({ expect(modal).toBeCalledWith({
backdrop: 'static', backdrop: 'static',
keyboard: false keyboard: false
@ -643,23 +803,30 @@ describe('tests for "update" module', () => {
}); });
it('download progress polling', async () => { it('download progress polling', async () => {
const fetch = jest.fn().mockReturnValueOnce(Promise.resolve({ size: 50 })); const fetch = jest.fn()
.mockReturnValueOnce(Promise.resolve([]))
.mockReturnValueOnce(Promise.resolve({ total: 810, downloaded: 405 }));
const url = jest.fn(path => path); const url = jest.fn(path => path);
window.fetch = fetch; window.fetch = fetch;
window.url = url; window.url = url;
document.body.innerHTML = ` document.body.innerHTML = `
<div id="imported-progress"></div> <span id="file-size"></span>
<div id="download-progress"></div>
<div class="progress-bar"></div> <div class="progress-bar"></div>
`; `;
const { progressPolling } = require(modulePath); const { progressPolling } = require(modulePath);
await progressPolling(100)(); await progressPolling();
expect(fetch).toBeCalledWith({ expect(fetch).toBeCalledWith({
url: 'admin/update/download?action=get-file-size', url: 'admin/update/download?action=get-progress',
type: 'GET' type: 'GET'
}); });
expect($('#imported-progress').html()).toBe('50.00'); expect($('#file-size').html()).toBe('');
await progressPolling();
expect($('#file-size').html()).toBe('810');
expect($('#download-progress').html()).toBe('50.00');
expect($('.progress-bar').css('width')).toBe('50%'); expect($('.progress-bar').css('width')).toBe('50%');
expect($('.progress-bar').attr('aria-valuenow')).toBe('50.00'); expect($('.progress-bar').attr('aria-valuenow')).toBe('50.00');
}); });
@ -1156,7 +1323,6 @@ describe('tests for "common" module', () => {
const fetch = jest.fn() const fetch = jest.fn()
.mockReturnValue(Promise.resolve({ errno: 0, msg: 'Recorded.' })); .mockReturnValue(Promise.resolve({ errno: 0, msg: 'Recorded.' }));
$.fn.dataTable = { defaults: {}, ext: { errMode: '' } };
window.document.cookie = ''; window.document.cookie = '';
window.fetch = fetch; window.fetch = fetch;
window.blessing = { window.blessing = {
@ -1172,6 +1338,7 @@ describe('tests for "common" module', () => {
url: 'https://work.prinzeugen.net/statistics/feedback', url: 'https://work.prinzeugen.net/statistics/feedback',
type: 'POST', type: 'POST',
dataType: 'json', dataType: 'json',
xhr: expect.any(Function),
data: { site_name: 'inm', site_url: 'http://tdkr.mur', version: '8.1.0' } data: { site_name: 'inm', site_url: 'http://tdkr.mur', version: '8.1.0' }
}); });
expect(window.document.cookie).not.toBe(''); expect(window.document.cookie).not.toBe('');
@ -1185,7 +1352,6 @@ describe('tests for "common" module', () => {
const showModal = jest.fn(); const showModal = jest.fn();
window.trans = jest.fn(t => t); window.trans = jest.fn(t => t);
window.showModal = showModal; window.showModal = showModal;
$.fn.dataTable = { defaults: {}, ext: { errMode: '' } };
handleDataTablesAjaxError(undefined, undefined, '{}'); handleDataTablesAjaxError(undefined, undefined, '{}');
expect(showModal).not.toBeCalled(); expect(showModal).not.toBeCalled();

View File

@ -145,6 +145,7 @@ describe('tests for "register" module', () => {
document.body.innerHTML = ` document.body.innerHTML = `
<input id="email" /> <input id="email" />
<input id="nickname" /> <input id="nickname" />
<input id="player-name" />
<input id="password" /> <input id="password" />
<input id="confirm-pwd" /> <input id="confirm-pwd" />
<div id="captcha-form"></div> <div id="captcha-form"></div>
@ -194,6 +195,16 @@ describe('tests for "register" module', () => {
expect($('#confirm-pwd').is(':focus')).toBe(true); expect($('#confirm-pwd').is(':focus')).toBe(true);
$('#confirm-pwd').val('password'); $('#confirm-pwd').val('password');
// Register with player name
$('#nickname').attr('id', 'nickname-alter');
$('button').click();
expect(trans).toBeCalledWith('auth.emptyPlayerName');
expect($('#player-name').is(':focus')).toBe(true);
// Register with nickname
$('#nickname-alter').attr('id', 'nickname');
$('#player-name').attr('id', 'player-name-alter');
$('button').click(); $('button').click();
expect(trans).toBeCalledWith('auth.emptyNickname'); expect(trans).toBeCalledWith('auth.emptyNickname');
expect($('#nickname').is(':focus')).toBe(true); expect($('#nickname').is(':focus')).toBe(true);
@ -222,7 +233,23 @@ describe('tests for "register" module', () => {
expect($('button').prop('disabled')).toBe(true); expect($('button').prop('disabled')).toBe(true);
expect(swal).toBeCalledWith({ type: 'success', html: 'success' }); expect(swal).toBeCalledWith({ type: 'success', html: 'success' });
// Register with player name
$('#nickname').attr('id', 'nickname-alter');
$('#player-name-alter').attr('id', 'player-name');
$('#player-name').val('Player Name');
await $('button').click(); await $('button').click();
expect(fetch).toBeCalledWith(expect.objectContaining({
type: 'POST',
url: 'auth/register',
dataType: 'json',
data: {
email: 'a@b.c',
player_name: 'Player Name',
password: 'password',
captcha: 'captcha'
}
}));
expect(refreshCaptcha).toBeCalled(); expect(refreshCaptcha).toBeCalled();
expect(showMsg).toBeCalledWith('warning', 'warning'); expect(showMsg).toBeCalledWith('warning', 'warning');
expect($('button').html()).toBe('auth.register'); expect($('button').html()).toBe('auth.register');
@ -232,6 +259,8 @@ describe('tests for "register" module', () => {
}); });
}); });
jest.useFakeTimers();
describe('tests for "forgot" module', () => { describe('tests for "forgot" module', () => {
const modulePath = '../auth/forgot'; const modulePath = '../auth/forgot';
@ -263,10 +292,20 @@ describe('tests for "forgot" module', () => {
<input id="email" /> <input id="email" />
<div id="captcha-form"></div> <div id="captcha-form"></div>
<input id="captcha" /> <input id="captcha" />
<button id="forgot-button"></button> <button id="forgot-button" data-remain="60"></button>
`; `;
require(modulePath); require(modulePath)();
expect(setInterval).toHaveBeenCalledTimes(1);
jest.runTimersToTime(1000);
$('button').click();
expect($('button').prop('disabled')).toBe(true);
expect(fetch).not.toBeCalled();
jest.runTimersToTime(60000);
expect($('button').html()).toBe('auth.send');
expect($('button').prop('disabled')).toBe(false);
$('button').click(); $('button').click();
expect(trans).toBeCalledWith('auth.emptyEmail'); expect(trans).toBeCalledWith('auth.emptyEmail');
@ -294,7 +333,7 @@ describe('tests for "forgot" module', () => {
captcha: 'captcha' captcha: 'captcha'
} }
})); }));
expect($('button').html()).toBe('auth.send'); expect($('button').html()).toEqual(expect.stringContaining('auth.send'));
expect($('button').prop('disabled')).toBe(true); expect($('button').prop('disabled')).toBe(true);
expect(showMsg).toBeCalledWith('success', 'success'); expect(showMsg).toBeCalledWith('success', 'success');
@ -310,6 +349,8 @@ describe('tests for "forgot" module', () => {
}); });
}); });
jest.useRealTimers();
describe('tests for "reset" module', () => { describe('tests for "reset" module', () => {
const modulePath = '../auth/reset'; const modulePath = '../auth/reset';

View File

@ -2,6 +2,7 @@
const $ = require('jquery'); const $ = require('jquery');
window.$ = window.jQuery = $; window.$ = window.jQuery = $;
$.fn.dataTable = { render: { text: () => ({ filter: text => text }) } };
window.getQueryString = jest.fn((key, defaultValue) => defaultValue); window.getQueryString = jest.fn((key, defaultValue) => defaultValue);
@ -503,12 +504,64 @@ describe('tests for "operations" module', () => {
expect(showAjaxError).toBeCalled(); expect(showAjaxError).toBeCalled();
}); });
it('change texture model', async () => {
const fetch = jest.fn()
.mockReturnValueOnce(Promise.resolve({ errno: 0, msg: 'success' }))
.mockReturnValueOnce(Promise.resolve({ errno: 1, msg: 'warning' }))
.mockReturnValueOnce(Promise.reject());
window.fetch = fetch;
const url = jest.fn(path => path);
window.url = url;
const trans = jest.fn(key => key);
window.trans = trans;
const swal = jest.fn()
.mockImplementationOnce(() => Promise.reject())
.mockImplementationOnce(() => Promise.resolve('alex'));
window.swal = swal;
const modal = jest.fn();
const toastr = {
success: jest.fn(),
warning: jest.fn()
};
window.toastr = toastr;
const showAjaxError = jest.fn();
window.showAjaxError = showAjaxError;
document.body.innerHTML = '<div id="model"></div>';
const changeTextureModel = require(modulePath).changeTextureModel;
await changeTextureModel(1, 'steve');
expect(fetch).not.toBeCalled();
await changeTextureModel(1, 'steve');
expect(swal).toBeCalledWith(expect.objectContaining({
text: trans('skinlib.setNewTextureModel'),
input: 'select',
inputValue: 'steve',
}));
expect(fetch).toBeCalledWith({
type: 'POST',
url: 'skinlib/model',
dataType: 'json',
data: { tid: 1, model: 'alex' }
});
expect($('div').text()).toBe('alex');
expect(toastr.success).toBeCalledWith('success');
await changeTextureModel(1, 'steve');
expect(toastr.warning).toBeCalledWith('warning');
await changeTextureModel(1, 'steve');
expect(showAjaxError).toBeCalled();
});
it('update texture status', () => { it('update texture status', () => {
window.trans = jest.fn(key => key); window.trans = jest.fn(key => key);
document.body.innerHTML = ` document.body.innerHTML = `
<div id="likes">5</div> <div id="likes">5</div>
<a tid="1"></a> <a id="quick-apply" style="display: none;"></a>
<a id="1"></a> <a class="like" tid="1"></a>
<a class="btn" id="1"></a>
`; `;
const updateTextureStatus = require(modulePath).updateTextureStatus; const updateTextureStatus = require(modulePath).updateTextureStatus;
@ -519,6 +572,7 @@ describe('tests for "operations" module', () => {
expect($('#1').attr('onclick')).toBe('removeFromCloset(1);'); expect($('#1').attr('onclick')).toBe('removeFromCloset(1);');
expect($('#1').html()).toBe('skinlib.removeFromCloset'); expect($('#1').html()).toBe('skinlib.removeFromCloset');
expect($('div').html()).toBe('6'); expect($('div').html()).toBe('6');
expect($('#quick-apply').css('display')).not.toBe('none');
updateTextureStatus(1, 'remove'); updateTextureStatus(1, 'remove');
expect($('a[tid=1]').attr('onclick')).toBe('addToCloset(1);'); expect($('a[tid=1]').attr('onclick')).toBe('addToCloset(1);');
@ -527,6 +581,7 @@ describe('tests for "operations" module', () => {
expect($('#1').attr('onclick')).toBe('addToCloset(1);'); expect($('#1').attr('onclick')).toBe('addToCloset(1);');
expect($('#1').html()).toBe('skinlib.addToCloset'); expect($('#1').html()).toBe('skinlib.addToCloset');
expect($('div').html()).toBe('5'); expect($('div').html()).toBe('5');
expect($('#quick-apply').css('display')).toBe('none');
}); });
it('click changing privacy button', async () => { it('click changing privacy button', async () => {

View File

@ -2,6 +2,55 @@
const $ = require('jquery'); const $ = require('jquery');
window.$ = window.jQuery = $; window.$ = window.jQuery = $;
$.fn.dataTable = { render: { text: () => ({ filter: text => text }) } };
describe('tests for "verification" module', () => {
const modulePath = '../user/verification';
it('send verification email', async () => {
const url = jest.fn(path => path);
const swal = jest.fn();
const showAjaxError = jest.fn();
window.url = url;
window.swal = swal;
window.showAjaxError = showAjaxError;
const fetch = jest.fn()
.mockImplementationOnce(option => {
option.beforeSend();
return Promise.resolve({ errno: 0, msg: 'success' });
})
.mockImplementationOnce(() => Promise.resolve(
{ errno: 1, msg: 'warning' }
))
.mockImplementationOnce(() => Promise.reject(new Error));
window.fetch = fetch;
document.body.innerHTML = `
<a id="send-verification-email">Send</a>
<span id="sending-indicator" style="display:none;">Sending</span>
`;
require(modulePath);
await $('a').click();
expect(fetch).toBeCalledWith(expect.objectContaining({
type: 'POST',
url: 'user/email-verification',
dataType: 'json'
}));
expect(swal).toBeCalledWith({ type: 'success', html: 'success' });
// I don't know why $(el).is(':visible') does not work here
expect($('#send-verification-email').css('display')).toBe('');
expect($('#sending-indicator').css('display')).toBe('none');
await $('a').click();
expect(swal).toBeCalledWith({ type: 'warning', html: 'warning' });
await $('a').click();
expect(showAjaxError).toBeCalled();
});
});
describe('tests for "closet" module', () => { describe('tests for "closet" module', () => {
const modulePath = '../user/closet'; const modulePath = '../user/closet';

View File

@ -31,6 +31,17 @@ async function sendFeedback() {
site_name: blessing.site_name, site_name: blessing.site_name,
site_url: blessing.base_url, site_url: blessing.base_url,
version: blessing.version version: blessing.version
},
xhr: () => {
// Don't send 'X-CSRF-TOKEN' header to a cross-origin server
// @see https://gist.github.com/7kfpun/a8d1326db44aa7857660
const xhr = $.ajaxSettings.xhr();
const setRequestHeader = xhr.setRequestHeader;
xhr.setRequestHeader = function (name, value) {
if (name === 'X-CSRF-TOKEN') return;
setRequestHeader.call(this, name, value);
};
return xhr;
} }
}); });
if (errno === 0) { if (errno === 0) {

View File

@ -0,0 +1,182 @@
'use strict';
if ($('#market-table').length === 1) {
$(document).ready(initMarketTable);
}
function initMarketTable() {
$.marketTable = $('#market-table').DataTable({
columnDefs: marketTableColumnDefs,
ajax: {
url: url('admin/plugins/market-data'),
type: 'POST'
}
}).on('xhr.dt', handleDataTablesAjaxError);
}
const marketTableColumnDefs = [
{
targets: 0,
title: trans('admin.pluginTitle'),
data: 'title',
render: (title, type, row) => {
const filter = $.fn.dataTable.render.text().filter;
return `
<strong>${ filter(title) }</strong>
<div class="plugin-name">${ filter(row.name) }</div>
`;
}
},
{
targets: 1,
title: trans('admin.pluginDescription'),
data: 'description',
render: $.fn.dataTable.render.text(),
orderable: false
},
{
targets: 2,
title: trans('admin.pluginAuthor'),
data: 'author',
render: $.fn.dataTable.render.text(),
orderable: false
},
{
targets: 3,
title: trans('admin.pluginVersion'),
data: 'version',
render: $.fn.dataTable.render.text(),
orderable: false
},
{
targets: 4,
title: trans('admin.pluginDependencies'),
data: 'dependencies',
searchable: false,
orderable: false,
render: data => {
if (data.requirements.length === 0) {
return `<i>${trans('admin.noDependencies')}</i>`;
}
let result = '';
for (const name in data.requirements) {
const constraint = data.requirements[name];
const color = (name in data.unsatisfiedRequirements) ? 'red' : 'green';
result += `<span class="label bg-${color}">${name}: ${constraint}</span><br>`;
}
return result;
}
},
{
targets: 5,
title: trans('admin.pluginOperations'),
orderable: false,
render: (data, type, row) => {
if (row.installed) {
if (row.update_available) {
return `
<button class="btn btn-success btn-sm" onclick="updatePlugin('${row.name}');">
<i class="fa fa-refresh" aria-hidden="true"></i> ${ trans('admin.updatePlugin') }
</button>
`;
}
if (row.enabled) {
return `
<button class="btn btn-primary btn-sm" disabled>
${ trans('admin.pluginEnabled') }
</button>
`;
}
return `
<button class="btn btn-primary btn-sm" onclick="enablePlugin('${row.name}');">
<i class="fa fa-plug" aria-hidden="true"></i> ${ trans('admin.enablePlugin') }
</button>
`;
}
return `
<button class="btn btn-default btn-sm" onclick="installPlugin('${row.name}');">
<i class="fa fa-download" aria-hidden="true"></i> ${ trans('admin.installPlugin') }
</button>
`;
}
}
];
async function installPlugin(name, option = {}) {
const button = $(`#plugin-${name} .btn`);
const originalBtnText = button.html();
try {
const { errno, msg } = await fetch($.extend(true, {
type: 'POST',
url: url('admin/plugins/market/download'),
dataType: 'json',
data: { name },
beforeSend: () => {
button.html(`<i class="fa fa-spinner fa-spin"></i> ${ trans('admin.pluginInstalling') }`).prop('disabled', true);
}
}, option));
if (errno === 0) {
toastr.success(msg);
$.marketTable.ajax.reload(null, false);
} else {
button.html(originalBtnText).prop('disabled', false);
swal({ type: 'warning', html: msg });
}
} catch (error) {
button.html(originalBtnText).prop('disabled', false);
showAjaxError(error);
}
}
async function updatePlugin(name) {
const data = $.marketTable.row(`#plugin-${name}`).data();
if (data.installed === false) {
return swal({ type: 'warning', html: 'not installed' });
}
try {
await swal({
text: trans('admin.confirmUpdate', { plugin: data.title, old: data.installed, new: data.version }),
type: 'warning',
showCancelButton: true
});
} catch (error) {
return;
}
installPlugin(name, {
beforeSend: () => {
$(`#plugin-${name} .btn`).html(`<i class="fa fa-refresh fa-spin"></i> ${ trans('admin.pluginUpdating') }`).prop('disabled', true);
}
});
}
async function checkForPluginUpdates() {
try {
const data = await fetch({ url: url('admin/plugins/market/check') });
if (data.available === true) {
const dom = `<span class="label label-success pull-right">${data.plugins.length}</span>`;
$(`[href="${url('admin/plugins/market')}"]`).append(dom);
}
} catch (error) {
//
}
}
if (process.env.NODE_ENV === 'test') {
module.exports = {
checkForPluginUpdates,
initMarketTable,
installPlugin,
updatePlugin,
};
}

View File

@ -5,14 +5,16 @@ if ($('#player-table').length === 1) {
} }
function initPlayersTable() { function initPlayersTable() {
const specificUid = getQueryString('uid'); const query = location.href.split('?')[1];
const query = specificUid ? `?uid=${specificUid}` : '';
$('#player-table').DataTable({ $('#player-table').DataTable({
ajax: url(`admin/player-data${query}`), columnDefs: playersTableColumnDefs,
scrollY: ($('.content-wrapper').height() - $('.content-header').outerHeight()) * 0.7, scrollY: ($('.content-wrapper').height() - $('.content-header').outerHeight()) * 0.7,
fnDrawCallback: () => $('[data-toggle="tooltip"]').tooltip(), fnDrawCallback: () => $('[data-toggle="tooltip"]').tooltip(),
columnDefs: playersTableColumnDefs ajax: {
url: url(`admin/player-data${ query ? ('?'+query) : '' }`),
type: 'POST'
}
}).on('xhr.dt', handleDataTablesAjaxError); }).on('xhr.dt', handleDataTablesAjaxError);
} }
@ -29,7 +31,8 @@ const playersTableColumnDefs = [
}, },
{ {
targets: 2, targets: 2,
data: 'player_name' data: 'player_name',
render: $.fn.dataTable.render.text()
}, },
{ {
targets: 3, targets: 3,

View File

@ -6,36 +6,61 @@ if ($('#plugin-table').length === 1) {
function initPluginsTable() { function initPluginsTable() {
$.pluginsTable = $('#plugin-table').DataTable({ $.pluginsTable = $('#plugin-table').DataTable({
ajax: url('admin/plugins/data'), columnDefs: pluginsTableColumnDefs,
fnDrawCallback: () => $('[data-toggle="tooltip"]').tooltip(), fnDrawCallback: () => $('[data-toggle="tooltip"]').tooltip(),
columnDefs: pluginsTableColumnDefs rowCallback: (row, data) => $(row).addClass(data.enabled ? 'plugin-enabled' : ''),
ajax: {
url: url('admin/plugins/data'),
type: 'POST'
}
}).on('xhr.dt', handleDataTablesAjaxError); }).on('xhr.dt', handleDataTablesAjaxError);
} }
const pluginsTableColumnDefs = [ const pluginsTableColumnDefs = [
{ {
targets: 0, targets: 0,
data: 'title' data: 'title',
title: trans('admin.pluginTitle'),
width: '10%',
render: (title, type, row) => {
const actions = [];
if (row.enabled) {
row.config && actions.push(`<a href="${url('admin/plugins/config/'+row.name)}" class="text-primary">${ trans('admin.configurePlugin') }</a>`);
actions.push(`<a onclick="disablePlugin('${row.name}');" class="text-primary">${ trans('admin.disablePlugin') }</a>`);
} else {
actions.push(
`<a onclick="enablePlugin('${row.name}');" class="text-primary">${ trans('admin.enablePlugin') }</a>`,
`<a onclick="deletePlugin('${row.name}');" class="text-danger">${ trans('admin.deletePlugin') }</a>`
);
}
return `
<strong>${ $.fn.dataTable.render.text().filter(title) }</strong>
<div class="actions">${ actions.join(' | ') }</div>
`;
}
}, },
{ {
targets: 1, targets: 1,
data: 'description', data: 'description',
title: trans('admin.pluginDescription'),
orderable: false, orderable: false,
width: '35%' render: (description, type, row) => {
return `
<div class="plugin-description"><p>${ $.fn.dataTable.render.text().filter(description) }</p></div>
<div class="plugin-version-author">
${ trans('admin.pluginVersion') } <span class="text-primary">${ row.version }</span> |
${ trans('admin.pluginAuthor') } <a href="${ row.url }">${ row.author }</a> |
${ trans('admin.pluginName') } <span>${ row.name }</span>
</div>
`;
}
}, },
{ {
targets: 2, targets: 2,
data: 'author',
render: data => isEmpty(data.url) ? data.author : `<a href="${data.url}" target="_blank">${data.author}</a>`
},
{
targets: 3,
data: 'version',
orderable: false
},
{
targets: 4,
data: 'dependencies', data: 'dependencies',
title: trans('admin.pluginDependencies'),
searchable: false, searchable: false,
orderable: false, orderable: false,
render: data => { render: data => {
@ -54,45 +79,14 @@ const pluginsTableColumnDefs = [
return result; return result;
} }
},
{
targets: 5,
data: 'status'
},
{
targets: 6,
data: 'operations',
searchable: false,
orderable: false,
render: (data, type, row) => {
let toggleButton, configViewButton;
if (data.enabled) {
toggleButton = `<a class="btn btn-warning btn-sm" onclick="disablePlugin('${row.name}');">${trans('admin.disablePlugin')}</a>`;
} else {
toggleButton = `<a class="btn btn-primary btn-sm" onclick="enablePlugin('${row.name}');">${trans('admin.enablePlugin')}</a>`;
}
if (data.enabled && data.hasConfigView) {
configViewButton = `<a class="btn btn-default btn-sm" href="${url('/')}admin/plugins/config/${row.name}">${trans('admin.configurePlugin')}</a>`;
} else {
configViewButton = `<a class="btn btn-default btn-sm" disabled="disabled" title="${trans('admin.noPluginConfigNotice')}" data-toggle="tooltip" data-placement="top">${trans('admin.configurePlugin')}</a>`;
}
const deletePluginButton = `<a class="btn btn-danger btn-sm" onclick="deletePlugin('${row.name}');">${trans('admin.deletePlugin')}</a>`;
return toggleButton + configViewButton + deletePluginButton;
}
} }
]; ];
async function enablePlugin(name) { async function enablePlugin(name) {
const dataTable = $.pluginsTable || $.marketTable;
try { try {
const { requirements } = await fetch({ const { requirements } = dataTable.row(`#plugin-${name}`).data().dependencies;
type: 'POST',
url: url(`admin/plugins/manage?action=requirements&name=${name}`),
dataType: 'json'
});
if (requirements.length === 0) { if (requirements.length === 0) {
await swal({ await swal({
@ -111,7 +105,7 @@ async function enablePlugin(name) {
if (errno === 0) { if (errno === 0) {
toastr.success(msg); toastr.success(msg);
$.pluginsTable.ajax.reload(null, false); dataTable.ajax.reload(null, false);
} else { } else {
swal({ type: 'warning', html: `<p>${msg}</p><ul><li>${reason.join('</li><li>')}</li></ul>` }); swal({ type: 'warning', html: `<p>${msg}</p><ul><li>${reason.join('</li><li>')}</li></ul>` });
} }

View File

@ -1,7 +1,9 @@
'use strict'; 'use strict';
async function downloadUpdates() { async function downloadUpdates() {
console.log('Prepare trno download'); console.log('Prepare to download');
let intervalId;
try { try {
const preparation = await fetch({ const preparation = await fetch({
@ -10,16 +12,12 @@ async function downloadUpdates() {
dataType: 'json', dataType: 'json',
beforeSend: function() { beforeSend: function() {
$('#update-button').html( $('#update-button').html(
'<i class="fa fa-spinner fa-spin"></i> ' + trans('admin.preparing') `<i class="fa fa-spinner fa-spin"></i> ${ trans('admin.preparing') }`
).prop('disabled', 'disabled'); ).prop('disabled', true);
} }
}); });
console.log(preparation); console.log(preparation);
const { file_size: fileSize } = preparation;
$('#file-size').html(fileSize);
$('#modal-start-download').modal({ $('#modal-start-download').modal({
'backdrop': 'static', 'backdrop': 'static',
'keyboard': false 'keyboard': false
@ -27,8 +25,8 @@ async function downloadUpdates() {
console.log('Start downloading'); console.log('Start downloading');
// Downloading progress polling // Start downloading progress polling
const interval_id = setInterval(progressPolling(fileSize), 300); intervalId = setInterval(progressPolling, 1000);
const download = await fetch({ const download = await fetch({
url: url('admin/update/download?action=start-download'), url: url('admin/update/download?action=start-download'),
@ -36,7 +34,7 @@ async function downloadUpdates() {
dataType: 'json' dataType: 'json'
}); });
clearInterval(interval_id); clearInterval(intervalId);
console.log('Downloading finished'); console.log('Downloading finished');
console.log(download); console.log(download);
@ -65,27 +63,32 @@ async function downloadUpdates() {
}); });
} catch (error) { } catch (error) {
showAjaxError(error); showAjaxError(error);
clearInterval(intervalId);
} }
} }
function progressPolling(fileSize) { async function progressPolling() {
return async () => { try {
try { const { total, downloaded } = await fetch({
const { size } = await fetch({ url: url('admin/update/download?action=get-progress'),
url: url('admin/update/download?action=get-file-size'), type: 'GET'
type: 'GET' });
});
const progress = (size / fileSize * 100).toFixed(2); if (total === undefined) {
return;
$('#imported-progress').html(progress);
$('.progress-bar')
.css('width', progress + '%')
.attr('aria-valuenow', progress);
} catch (error) {
// No need to show error if failed to get size
} }
};
const progress = (downloaded / total * 100).toFixed(2);
console.log(`Download progress: ${downloaded}/${total}`);
$('#file-size').html(total);
$('#download-progress').html(progress);
$('.progress-bar')
.css('width', progress + '%')
.attr('aria-valuenow', progress);
} catch (error) {
// No need to show error if failed to get size
}
} }
async function checkForUpdates() { async function checkForUpdates() {

View File

@ -5,15 +5,17 @@ if ($('#user-table').length === 1) {
} }
function initUsersTable() { function initUsersTable() {
const specificUid = getQueryString('uid'); const query = location.href.split('?')[1];
const query = specificUid ? `?uid=${specificUid}` : '';
$('#user-table').DataTable({ $('#user-table').DataTable({
ajax: url(`admin/user-data${query}`), columnDefs: usersTableColumnDefs,
scrollY: ($('.content-wrapper').height() - $('.content-header').outerHeight()) * 0.7, scrollY: ($('.content-wrapper').height() - $('.content-header').outerHeight()) * 0.7,
fnDrawCallback: () => $('[data-toggle="tooltip"]').tooltip(), fnDrawCallback: () => $('[data-toggle="tooltip"]').tooltip(),
rowCallback: (row, data) => $(row).attr('id', `user-${data.uid}`), rowCallback: (row, data) => $(row).attr('id', `user-${data.uid}`),
columnDefs: usersTableColumnDefs ajax: {
url: url(`admin/user-data${ query ? ('?'+query) : '' }`),
type: 'POST'
}
}).on('xhr.dt', handleDataTablesAjaxError); }).on('xhr.dt', handleDataTablesAjaxError);
} }
@ -36,7 +38,8 @@ const usersTableColumnDefs = [
}, },
{ {
targets: 2, targets: 2,
data: 'nickname' data: 'nickname',
render: $.fn.dataTable.render.text()
}, },
{ {
targets: 3, targets: 3,
@ -58,10 +61,16 @@ const usersTableColumnDefs = [
}, },
{ {
targets: 6, targets: 6,
data: 'register_at' data: 'verified',
className: 'verification',
render: data => trans('admin.' + (data ? 'verified' : 'unverified'))
}, },
{ {
targets: 7, targets: 7,
data: 'register_at'
},
{
targets: 8,
data: 'operations', data: 'operations',
searchable: false, searchable: false,
orderable: false, orderable: false,
@ -108,6 +117,7 @@ function renderUsersTableOperations(currentUserPermission, type, row) {
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a onclick="changeUserEmail(${row.uid});">${trans('admin.changeEmail')}</a></li> <li><a onclick="changeUserEmail(${row.uid});">${trans('admin.changeEmail')}</a></li>
<li><a onclick="changeUserVerification(${row.uid});">${trans('admin.changeVerification')}</a></li>
<li><a onclick="changeUserNickName(${row.uid});">${trans('admin.changeNickName')}</a></li> <li><a onclick="changeUserNickName(${row.uid});">${trans('admin.changeNickName')}</a></li>
<li><a onclick="changeUserPwd(${row.uid});">${trans('admin.changePassword')}</a></li> <li><a onclick="changeUserPwd(${row.uid});">${trans('admin.changePassword')}</a></li>
${adminOption} ${adminOption}
@ -155,6 +165,31 @@ async function changeUserEmail(uid) {
} }
} }
async function changeUserVerification(uid) {
try {
const { errno, msg } = await fetch({
type: 'POST',
url: url('admin/users?action=verification'),
dataType: 'json',
data: { uid: uid }
});
if (errno === 0) {
const original = $(`#user-${uid} > td.verification`).text();
$(`#user-${uid} > td.verification`).text(
original === trans('admin.unverified') ? trans('admin.verified') : trans('admin.unverified')
);
toastr.success(msg);
} else {
toastr.warning(msg);
}
} catch (error) {
showAjaxError(error);
}
}
async function changeUserNickName(uid) { async function changeUserNickName(uid) {
const dom = $(`tr#user-${uid} > td:nth-child(3)`); const dom = $(`tr#user-${uid} > td:nth-child(3)`);
let newNickName = ''; let newNickName = '';
@ -342,5 +377,6 @@ if (process.env.NODE_ENV === 'test') {
changeAdminStatus, changeAdminStatus,
deleteUserAccount, deleteUserAccount,
changeUserNickName, changeUserNickName,
changeUserVerification,
}; };
} }

View File

@ -24,7 +24,7 @@ $('#forgot-button').click(e => {
} }
})(data, async () => { })(data, async () => {
try { try {
const { errno, msg } = await fetch({ const { errno, msg, remain } = await fetch({
type: 'POST', type: 'POST',
url: url('auth/forgot'), url: url('auth/forgot'),
dataType: 'json', dataType: 'json',
@ -32,20 +32,55 @@ $('#forgot-button').click(e => {
beforeSend: () => { beforeSend: () => {
$('#forgot-button').html( $('#forgot-button').html(
'<i class="fa fa-spinner fa-spin"></i> ' + trans('auth.sending') '<i class="fa fa-spinner fa-spin"></i> ' + trans('auth.sending')
).prop('disabled', 'disabled'); ).prop('disabled', true);
} }
}); });
if (errno === 0) { if (errno === 0) {
showMsg(msg, 'success'); showMsg(msg, 'success');
$('#forgot-button').html(trans('auth.send')).prop('disabled', 'disabled'); showRemainTimeIndicator(180);
} else { } else {
showMsg(msg, 'warning'); showMsg(msg, 'warning');
refreshCaptcha(); refreshCaptcha();
$('#forgot-button').html(trans('auth.send')).prop('disabled', '');
if (remain) {
showRemainTimeIndicator(remain);
} else {
$('#forgot-button').html(trans('auth.send')).prop('disabled', false);
}
} }
} catch (error) { } catch (error) {
showAjaxError(error); showAjaxError(error);
$('#forgot-button').html(trans('auth.send')).prop('disabled', ''); $('#forgot-button').html(trans('auth.send')).prop('disabled', false);
} }
}); });
}); });
function showRemainTimeIndicator(seconds, intervalID) {
// Get remain time from elem data if not specified
if (seconds === undefined) {
seconds = $('#forgot-button').data('remain');
}
if (seconds > 0) {
$('#forgot-button').html(`${trans('auth.send')} (${seconds})`).prop('disabled', true);
} else {
$('#forgot-button').html(trans('auth.send')).prop('disabled', false);
// Stop timer
if (intervalID) clearInterval(intervalID);
}
// Create timer for decreasing remain time by second
if (! intervalID) {
const intervalID = window.setInterval(function () {
showRemainTimeIndicator(--seconds, intervalID);
}, 1000);
}
}
// Start timer
$(document).ready(() => showRemainTimeIndicator());
if (process.env.NODE_ENV === 'test') {
module.exports = showRemainTimeIndicator;
}

View File

@ -9,10 +9,11 @@ $('#register-button').click(e => {
email: $('#email').val(), email: $('#email').val(),
password: $('#password').val(), password: $('#password').val(),
nickname: $('#nickname').val(), nickname: $('#nickname').val(),
player_name: $('#player-name').val(),
captcha: $('#captcha').val(), captcha: $('#captcha').val(),
}; };
(function validate({ email, password, nickname, captcha }, callback) { (function validate({ email, password, nickname, player_name, captcha }, callback) {
// Massive form validation // Massive form validation
if (email === '') { if (email === '') {
showMsg(trans('auth.emptyEmail')); showMsg(trans('auth.emptyEmail'));
@ -31,9 +32,12 @@ $('#register-button').click(e => {
} else if (password !== $('#confirm-pwd').val()) { } else if (password !== $('#confirm-pwd').val()) {
showMsg(trans('auth.invalidConfirmPwd'), 'warning'); showMsg(trans('auth.invalidConfirmPwd'), 'warning');
$('#confirm-pwd').focus(); $('#confirm-pwd').focus();
} else if (nickname === '') { } else if ($('#nickname').length > 0 && nickname === '') {
showMsg(trans('auth.emptyNickname')); showMsg(trans('auth.emptyNickname'));
$('#nickname').focus(); $('#nickname').focus();
} else if ($('#player-name').length > 0 && player_name === '') {
showMsg(trans('auth.emptyPlayerName'));
$('#player-name').focus();
} else if (captcha === '') { } else if (captcha === '') {
showMsg(trans('auth.emptyCaptcha')); showMsg(trans('auth.emptyCaptcha'));
$('#captcha').focus(); $('#captcha').focus();

View File

@ -20,6 +20,9 @@ function initSkinViewer(cameraPositionZ = 70) {
$.msp.viewer = new skinview3d.SkinViewer($.msp.config); $.msp.viewer = new skinview3d.SkinViewer($.msp.config);
$.msp.viewer.camera.position.z = cameraPositionZ; $.msp.viewer.camera.position.z = cameraPositionZ;
// Disable auto model detection
$.msp.viewer.detectModel = false;
$.msp.viewer.playerObject.skin.slim = $.msp.config.slim;
$.msp.viewer.animation = new skinview3d.CompositeAnimation(); $.msp.viewer.animation = new skinview3d.CompositeAnimation();
// Init all available animations and pause them // Init all available animations and pause them

View File

@ -9,6 +9,12 @@ console.log(
'font-style:italic;', '' 'font-style:italic;', ''
); );
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
/** /**
* Check if given value is empty. * Check if given value is empty.
* *

View File

@ -105,7 +105,10 @@ function renderSkinlibItemComponent(item) {
title = item.liked ? trans('skinlib.removeFromCloset') : trans('skinlib.addToCloset'); title = item.liked ? trans('skinlib.removeFromCloset') : trans('skinlib.addToCloset');
} }
return `<a href="${ url('skinlib/show/' + item.tid) }"> item.name = $.fn.dataTable.render.text().filter(item.name);
return `
<a href="${ url('skinlib/show/' + item.tid) }">
<div class="item" tid="${ item.tid }"> <div class="item" tid="${ item.tid }">
<div class="item-body"> <div class="item-body">
<img src="${ url('preview/' + item.tid + '.png') }"> <img src="${ url('preview/' + item.tid + '.png') }">
@ -118,6 +121,7 @@ function renderSkinlibItemComponent(item) {
</span> </span>
</p> </p>
<span class="pull-right likes-count">${ item.likes }</span>
<a title="${title}" class="more like ${liked} ${anonymous}" tid="${ item.tid }" href="javascript:;" data-placement="top" data-toggle="tooltip"><i class="fa fa-heart"></i></a> <a title="${title}" class="more like ${liked} ${anonymous}" tid="${ item.tid }" href="javascript:;" data-placement="top" data-toggle="tooltip"><i class="fa fa-heart"></i></a>
<small class="more private-label ${(item.public === 0) ? '' : 'hide'}" tid="${ item.tid }"> <small class="more private-label ${(item.public === 0) ? '' : 'hide'}" tid="${ item.tid }">

View File

@ -20,6 +20,7 @@ function addToCloset(tid) {
try { try {
const result = await swal({ const result = await swal({
title: trans('skinlib.setItemName'), title: trans('skinlib.setItemName'),
text: trans('skinlib.applyNotice'),
inputValue: name, inputValue: name,
input: 'text', input: 'text',
showCancelButton: true, showCancelButton: true,
@ -130,6 +131,47 @@ async function changeTextureName(tid, oldName) {
} }
} }
async function changeTextureModel(tid, oldModel) {
const models = {
steve: 'steve',
alex: 'alex',
cape: trans('general.cape')
};
let newTextureModel = '';
try {
newTextureModel = await swal({
text: trans('skinlib.setNewTextureModel'),
input: 'select',
inputValue: oldModel,
inputOptions: models,
showCancelButton: true,
inputClass: 'form-control'
});
} catch (error) {
return;
}
try {
const { errno, msg } = await fetch({
type: 'POST',
url: url('skinlib/model'),
dataType: 'json',
data: { tid: tid, model: newTextureModel }
});
if (errno === 0) {
$('#model').text(models[newTextureModel]);
toastr.success(msg);
} else {
toastr.warning(msg);
}
} catch (error) {
showAjaxError(error);
}
}
/** /**
* Update button action & likes of texture. * Update button action & likes of texture.
* *
@ -138,17 +180,22 @@ async function changeTextureName(tid, oldName) {
* @return {null} * @return {null}
*/ */
function updateTextureStatus(tid, action) { function updateTextureStatus(tid, action) {
const likes = parseInt($('#likes').html()) + (action === 'add' ? 1 : -1); const likesCounter = $('#likes').length ? $('#likes') : $(`.item[tid=${tid}] .likes-count`);
action = (action === 'add') ? 'removeFromCloset' : 'addToCloset'; const likes = parseInt(likesCounter.html()) + (action === 'add' ? 1 : -1);
const buttonAction = (action === 'add') ? 'removeFromCloset' : 'addToCloset';
likesCounter.html(likes);
$(`a[tid=${tid}]`) // On "skinlib" page
.attr('onclick', `${action}(${tid});`) $(`a.like[tid=${tid}]`)
.attr('title', trans(`skinlib.${action}`)) .attr('onclick', `${buttonAction}(${tid});`)
.attr('title', trans(`skinlib.${buttonAction}`))
.toggleClass('liked'); .toggleClass('liked');
$(`#${tid}`)
.attr('onclick', `${action}(${tid});`) // On "skinlib/show" page
.html(trans(`skinlib.${action}`)); $(`.btn#${tid}`)
$('#likes').html(likes); .attr('onclick', `${buttonAction}(${tid});`)
.html(trans(`skinlib.${buttonAction}`));
$('#quick-apply').toggle(action === 'add');
} }
$(document).on('click', '.private-label', async function () { $(document).on('click', '.private-label', async function () {
@ -231,6 +278,7 @@ if (process.env.NODE_ENV === 'test') {
ajaxAddToCloset, ajaxAddToCloset,
removeFromCloset, removeFromCloset,
changeTextureName, changeTextureName,
changeTextureModel,
updateTextureStatus, updateTextureStatus,
}; };
} }

View File

@ -1,4 +1,4 @@
/* global initSkinViewer, defaultSteveSkin, defaultAlexSkin */ /* global skinview3d, initSkinViewer, defaultSteveSkin, defaultAlexSkin */
// TODO: Help wanted. This file needs to be tested. // TODO: Help wanted. This file needs to be tested.
@ -11,14 +11,14 @@ function initUploadListeners() {
$('body') $('body')
.on('change', '#file', () => handleFiles()) .on('change', '#file', () => handleFiles())
.on('filebatchselected', '#file', () => handleFiles())
.on('ifToggled', '#type-cape', () => handleFiles()) .on('ifToggled', '#type-cape', () => handleFiles())
.on('change', '#skin-type', function () { .on('change', '#skin-type', function () {
if ($('#file').prop('files').length === 0) { if ($('#file').prop('files').length === 0) {
$.msp.config.slim = ($(this).val() === 'alex'); // Load default skin
$.msp.config.skinUrl = getDefaultSkin(); $.msp.viewer.skinUrl = getDefaultSkin();
initSkinViewer();
} }
handleFiles(); $.msp.viewer.playerObject.skin.slim = ($(this).val() === 'alex');
}) })
.on('ifToggled', '#type-skin', function () { .on('ifToggled', '#type-skin', function () {
$(this).prop('checked') ? $('#skin-type').show() : $('#skin-type').hide(); $(this).prop('checked') ? $('#skin-type').show() : $('#skin-type').hide();
@ -66,14 +66,16 @@ function handleFiles(files, type) {
if (img.width === img.height || img.width / img.height === 2) { if (img.width === img.height || img.width / img.height === 2) {
$.msp.config.skinUrl = img.src; $.msp.config.skinUrl = img.src;
$.msp.config.capeUrl = null; $.msp.config.capeUrl = null;
// Determine model from texture image
$.msp.config.slim = skinview3d.isSlimSkin(img);
$('#skin-type').val($.msp.config.slim ? 'alex' : 'steve');
} else { } else {
$.msp.config.skinUrl = getDefaultSkin(); $.msp.config.skinUrl = getDefaultSkin();
toastr.warning(trans('skinlib.badSkinSize')); toastr.warning(trans('skinlib.badSkinSize'));
} }
} }
$.msp.config.slim = ($('#skin-type').val() === 'alex');
initSkinViewer(); initSkinViewer();
if ($name.val() === '' || $name.val() === $name.attr('data-last-file-name')) { if ($name.val() === '' || $name.val() === $name.attr('data-last-file-name')) {

View File

@ -2,7 +2,16 @@
'use strict'; 'use strict';
$(document).ready(initCloset); $(document).ready(async () => {
await initCloset();
const tid = getQueryString('tid');
// Select closet item if texture id is specified by query string
if (tid !== undefined) {
$(`[tid=${tid}]`).children('.item-body').click();
}
});
$('body').on('click', '.item-body', async function () { $('body').on('click', '.item-body', async function () {
$('.item-selected').parent().removeClass('item-selected'); $('.item-selected').parent().removeClass('item-selected');
@ -106,26 +115,30 @@ async function initCloset() {
* @param {{ name: string, tid: number, type: 'steve' | 'alex' | 'cape' }} item * @param {{ name: string, tid: number, type: 'steve' | 'alex' | 'cape' }} item
*/ */
function renderClosetItemComponent(item) { function renderClosetItemComponent(item) {
// Prevent XSS
item.name = $.fn.dataTable.render.text().filter(item.name);
return ` return `
<div class="item" tid="${item.tid}" data-texture-type="${item.type}"> <div class="item" tid="${item.tid}" data-texture-type="${item.type}">
<div class="item-body"> <div class="item-body">
<img src="${url('/')}preview/${item.tid}.png"> <img src="${url('/')}preview/${item.tid}.png">
</div> </div>
<div class="item-footer"> <div class="item-footer">
<p class="texture-name"> <p class="texture-name">
<span title="${item.name}">${item.name} <small>(${item.type})</small></span> <span title="${item.name}">${item.name} <small>(${item.type})</small></span>
</p> </p>
<a href="${url('/')}skinlib/show/${item.tid}" title="${trans('user.viewInSkinlib')}" class="more" data-toggle="tooltip" data-placement="bottom"><i class="fa fa-share"></i></a> <a href="${url('/')}skinlib/show/${item.tid}" title="${trans('user.viewInSkinlib')}" class="more" data-toggle="tooltip" data-placement="bottom"><i class="fa fa-share"></i></a>
<span title="${trans('general.more')}" class="more" data-toggle="dropdown" aria-haspopup="true" id="more-button"><i class="fa fa-cog"></i></span> <span title="${trans('general.more')}" class="more" data-toggle="dropdown" aria-haspopup="true" id="more-button"><i class="fa fa-cog"></i></span>
<ul class="dropup dropdown-menu" aria-labelledby="more-button"> <ul class="dropup dropdown-menu" aria-labelledby="more-button">
<li><a onclick="renameClosetItem(${item.tid}, '${item.name}');">${trans('user.renameItem')}</a></li> <li><a onclick="renameClosetItem(${item.tid}, '${item.name}');">${trans('user.renameItem')}</a></li>
<li><a onclick="removeFromCloset(${item.tid});">${trans('user.removeItem')}</a></li> <li><a onclick="removeFromCloset(${item.tid});">${trans('user.removeItem')}</a></li>
<li><a onclick="setAsAvatar(${item.tid});">${trans('user.setAsAvatar')}</a></li> <li><a onclick="setAsAvatar(${item.tid});">${trans('user.setAsAvatar')}</a></li>
</ul> </ul>
</div>
</div> </div>
</div>`; `;
} }
/** /**

View File

@ -0,0 +1,24 @@
$('#send-verification-email').click(async () => {
try {
const { errno, msg } = await fetch({
type: 'POST',
url: url('user/email-verification'),
dataType: 'json',
beforeSend: () => {
$('#send-verification-email').hide();
$('#sending-indicator').show();
}
});
swal({
type: errno === 0 ? 'success' : 'warning',
html: msg
});
} catch (error) {
showAjaxError(error);
}
$('#send-verification-email').show();
$('#sending-indicator').hide();
});

View File

@ -45,3 +45,51 @@ td {
color: #3c8dbc; color: #3c8dbc;
} }
} }
#plugin-table {
.actions {
margin-top: 5px;
color: #ddd;
}
.plugin-version-author {
color: #777;
font-size: small;
a {
color: #337ab7;
}
}
td {
padding: 10px;
}
td:first-child {
border-left: 3px solid transparent;
white-space: nowrap;
}
.plugin-enabled {
background-color: #f7fcfe;
}
.plugin-enabled td:first-child {
border-left-color: #3c8dbc;
}
}
#market-table {
.btn {
float: right;
}
td:first-child {
white-space: nowrap;
}
.plugin-name {
color: #777;
font-size: small;
}
}

View File

@ -215,3 +215,12 @@ input:-webkit-autofill {
td[class='key'], td[class='value'] { td[class='key'], td[class='value'] {
border-top: 0 !important; border-top: 0 !important;
} }
.modal-danger .modal-title small {
color: #fff;
a {
text-decoration: underline;
color: #fff;
}
}

View File

@ -16,6 +16,8 @@ users:
banned: Banned banned: Banned
admin: Admin admin: Admin
super-admin: Super Admin super-admin: Super Admin
verification:
title: Account Verification
operations: operations:
title: Operations title: Operations
non-existent: No such user. non-existent: No such user.
@ -25,6 +27,8 @@ users:
change: Edit Email change: Edit Email
existed: :email is existed. existed: :email is existed.
success: Email changed successfully. success: Email changed successfully.
verification:
success: Account verification status switched successfully.
nickname: nickname:
change: Edit Nickname change: Edit Nickname
success: Nickname changed successfully. success: Nickname changed successfully.
@ -46,21 +50,21 @@ users:
success: The account has been banned. success: The account has been banned.
unban: unban:
text: Unban text: Unban
success: The account has been unbanned. success: The account was not banned anymore.
cant-super-admin: You can't ban super admin. cant-super-admin: You can't ban a super admin.
cant-admin: Only super admins are able to ban admins. cant-admin: Only super admins are able to ban admins.
delete: delete:
delete: Delete User delete: Delete User
success: The account has been deleted successfully. success: The account has been deleted successfully.
cant-super-admin: You can't delete super admin in this way cant-super-admin: You can't delete a super admin.
cant-admin: You can't delete admins. cant-admin: You can't delete a admin account.
players: players:
no-permission: You have no permission to operate this player. no-permission: You have no permission to operate this player.
operations: operations:
title: Operations title: Operations
preference: preference:
success: The preference of player [:player] has been changed to :preference success: The preference of player ":player" has been changed to :preference
textures: textures:
change: Change Textures change: Change Textures
non-existent: No such texture tid.:tid non-existent: No such texture tid.:tid
@ -76,7 +80,7 @@ players:
customize: customize:
change-color: change-color:
title: Change theme color title: Change Theme Color
success: Theme color updated. success: Theme color updated.
colors: colors:
@ -94,17 +98,6 @@ customize:
black-light: Black Light black-light: Black Light
plugins: plugins:
name: Name
description: Description
author: Author
version: Version
dependencies: Dependencies
status:
title: Status
enabled: Enabled
disabled: Disabled
operations: operations:
title: Operations title: Operations
enabled: :plugin has been enabled. enabled: :plugin has been enabled.
@ -114,9 +107,17 @@ plugins:
version: The version of <code>:name</code> does not satisfies the constraint <code>:constraint</code> version: The version of <code>:name</code> does not satisfies the constraint <code>:constraint</code>
disabled: :plugin has been disabled. disabled: :plugin has been disabled.
deleted: The plugin was deleted successfully. deleted: The plugin was deleted successfully.
no-config-notice: The plugin is not installed or doesn't provide configuration page. no-config-notice: The plugin is not installed or doesn't provide a configuration page.
not-found: No such plugin. not-found: No such plugin.
market:
connection-error: Unable to connect to the plugins registry (to change the plugins registry URL, please refer to http://t.cn/Rk6X37l). :error
non-existent: The plugin :plugin does not exist.
download-failed: Unable to download the plugin. :error
shasum-failed: The downloaded file failed hash check, please retry.
unzip-failed: Unable to extract the plugin. :error
install-success: Plugin was installed.
empty: No result empty: No result
update: update:
@ -142,7 +143,7 @@ update:
downloads: downloads:
text: "Download Link:" text: "Download Link:"
link: Click to download full program package. link: Click to download full update package.
check-github: <a href=":url" target="_blank" class="btn btn-default pull-right">Check GitHub Releases</a> check-github: <a href=":url" target="_blank" class="btn btn-default pull-right">Check GitHub Releases</a>
button: Update Now button: Update Now
@ -150,18 +151,19 @@ update:
cautions: cautions:
title: Cautions title: Cautions
text: | text: |
Please choose update source according to your host location. Please choose update source according to your host's network environment.
Low-speed connection between update source and your host will cause long-time loading at checking/downloading page. Low-speed connection between update source and your host will cause long-time loading at checking and downloading page.
To change the default update source, please refer to <a target="_blank" href="https://github.com/printempw/blessing-skin-server/wiki/%E6%9B%B4%E6%96%B0%E6%BA%90%E5%88%97%E8%A1%A8">this wiki article</a>.
download: download:
downloading: Downloading update package... downloading: Downloading update package...
size: "Size of package:" size: "Size of package:"
errors: errors:
prefix: "An error occured: " prefix: "An error occured: "
connection: "Unable to access to current update source. Details:" connection: We can't connect to the update source. :error
write-permission: Unable to make cache directory. Please sure permission. write-permission: Unable to create the cache directory. Please check the permission.
unzip: "Failed to unzip update file. Error code: " unzip: "Failed to extract update package. Error code: "
overwrite: Unable to overwrite files. overwrite: Unable to overwrite files.
invalid-action: Invalid action invalid-action: Invalid action

View File

@ -3,70 +3,80 @@ login:
button: Log In button: Log In
message: Log in to manage your skin & players message: Log in to manage your skin & players
keep: Remember me keep: Remember me
success: Logged in successfully~ success: Logged in successfully.
check: check:
anonymous: Illegal access. Please log in first. anonymous: Illegal access. Please log in first.
verified: To access this page, you should verify your email address first.
admin: Only admins are permitted to access this page. admin: Only admins are permitted to access this page.
super-admin: Only super admin is permitted to access this page.
banned: You are banned on this site. Please contact the admin. banned: You are banned on this site. Please contact the admin.
token: Invalid token. Please log in. token: Token expired. Please log in.
register: register:
title: Register title: Register
button: Register button: Register
message: Welcome to :sitename! message: Welcome to :sitename!
player-name-intro: Player name in Minecraft, can be changed later
nickname-intro: Whatever you like expect special characters nickname-intro: Whatever you like expect special characters
repeat-pwd: Repeat your password repeat-pwd: Repeat your password
close: Well, this site doesn't allow any register. close: We don't accept any registration.
success: Registered successfully. Redirecting... success: Your account was registered. Redirecting...
max: You can't register more than :regs accounts. max: You can't register more than :regs accounts.
registered: The email address is already registered. registered: The email address was already registered.
forgot: forgot:
title: Forgot Password title: Forgot Password
button: Send button: Send
message: We will send you an E-mail to verify. message: We will send you an E-mail to verify.
login-link: I do remember it login-link: I do remember it
close: Password resetting is not available now. disabled: Password resetting is not available.
frequent-mail: You click the send button too fast. Wait for 60 secs, guy. frequent-mail: You click the send button too fast. Wait for some minutes.
unregistered: The email address is not registered. unregistered: The email address is not registered.
success: Mail sent, please check your inbox. The link will be expired in 1 hour.
mail: failed: Failed to send verification mail. :msg
title: Reset your password on :sitename mail:
success: Mail is sent. Will be expired in 1 hour, please check. title: Reset your password on :sitename
failed: Fail to send mail, detailed message :msg message: You are receiving this email because we received a password reset request for your account on :sitename.
message: You are receiving this email because this email address was used to reset your password on :sitename reset: 'To reset your password, please visit: <a href=":url">:url</a>'
ignore: If you haven't signed up on our site, please ignore this email. No unsubscribing is required. ignore: If you did not request a password reset, no further action is required.
reset: Reset your password
notice: This mail is sending automatically, no reponses will be sent if you reply.
reset: reset:
title: Reset Password title: Reset Password
button: Reset button: Reset
invalid: Invalid link. invalid: Invalid link.
expired: This link is expired. expired: This link is expired, please resend a verification email.
message: :username, reset your email address here. message: :username, reset your password here.
success: Password resetted successfully. success: Your password was reset successfully.
bind: bind:
title: Bind Email title: Bind Email
button: Bind button: Bind
message: You need to fill your email adderss to continue. message: You need to provide your email adderss to continue.
introduction: Email addresses will be used for password resetting. We won't send you any spam. introduction: We won't send you any spam.
registered: The email address is already registered. registered: The email address was already taken.
verify:
title: Email Verification
success: Your account was now verified.
message: Welcome to :sitename!
button: Homepage
invalid: Invalid link.
expired: This link is expired, please resend a verification email.
validation: validation:
identification: Invalid format of email or player name. identification: The email or player name is invalid.
email: Email format is invalid. email: Email format is invalid.
captcha: Wrong CAPTCHA. captcha: Wrong CAPTCHA.
user: Unexistent user. user: No such user.
password: Wrong password. password: Wrong password.
logout: logout:
success: Logged out successfully~ success: You are now logged out.
fail: No valid session. fail: No valid session.
nickname: Nickname nickname: Nickname
player-name: Minecraft player name
email: Email email: Email
identification: Email or player name identification: Email or player name
password: Password password: Password

View File

@ -1,7 +1,10 @@
http: http:
msg-403: You have no permission to access this page. msg-403: You have no permission to access this page.
msg-404: Nothing here. msg-404: Nothing here.
msg-500: Please try again later.
msg-503: The application is now in maintenance mode. msg-503: The application is now in maintenance mode.
method-not-allowed: Method not allowed.
csrf-token-mismatch: Token does not match, try reloading the page.
general: general:
title: Error occurred title: Error occurred
@ -15,3 +18,6 @@ exception:
plugins: plugins:
duplicate: The plugin [:dir1] has a duplicated plugin name definition which is same to plugin [:dir2]. Please check your plugins directory, remove one of them or use another name definition. duplicate: The plugin [:dir1] has a duplicated plugin name definition which is same to plugin [:dir2]. Please check your plugins directory, remove one of them or use another name definition.
directory: We can't approach the path for loading plugins specified by the PLUGINS_DIR in .env file. Please check your configuration. Error :msg directory: We can't approach the path for loading plugins specified by the PLUGINS_DIR in .env file. Please check your configuration. Error :msg
cipher:
unsupported: Unsupported password hashing method `:cipher`, please check your `.env` configuration

View File

@ -19,7 +19,6 @@ plugin-market: Plugin Market
plugin-configs: Plugin Configs plugin-configs: Plugin Configs
customize: Customize customize: Customize
options: Options options: Options
import-v2: Import Data
score-options: Score Options score-options: Score Options
check-update: Check Update check-update: Check Update
download-update: Download Updates download-update: Download Updates
@ -39,19 +38,19 @@ reset: Reset
submit: Submit submit: Submit
notice: Notice notice: Notice
switch-2d-preview: Switch to 2D Preview switch-2d-preview: Switch to 2D preview
illegal-parameters: Illegal parameters. illegal-parameters: Illegal parameters.
private: Private private: Private
public: Public public: Public
unexistent-user: Un-existent user unexistent-user: No such user.
unexistent-player: Un-existent player unexistent-player: No such player.
player-banned: The owner of this player has been banned player-banned: The owner of this player has been banned.
texture-deleted: The requested texture has been deleted. texture-deleted: The requested texture has been deleted.
texture-not-uploaded: The user haven not uploaded the texture of :type model yet. texture-not-uploaded: The user haven't uploaded the texture of :type model yet.
operations: Operations operations: Operations

View File

@ -1,16 +1,19 @@
features: features:
multi-player: first:
icon: fa-users
name: Multi Player name: Multi Player
desc: You can add many players with only one account. desc: You can add multiple players with only one account.
sharing: second:
icon: fa-share-alt
name: Sharing name: Sharing
desc: Explore the skin library, give a 'like' and share them with your friends. desc: Explore the skin library, send a "like" and share them with your friends.
free: third:
icon: fa-cloud
name: Free name: Free
desc: It is free forever. No ads. No subscription fees. desc: It is free forever. No ads. No subscription fees.
introduction: :sitename provides the service of uploading and hosting Minecraft skins. By coordinating with skin mods e.g. CustomSkinLoader, you can set skins & capes for your game character, and make it visible to other players. introduction: :sitename provides the service of uploading and hosting Minecraft skins. By coordinating with skin mods (e.g. CustomSkinLoader), you can choose skin and cape for your game character, and make it visible to other players in Minecraft.
start: Join Us start: Join Us

View File

@ -1,12 +1,3 @@
/*!
* Blessing Skin English Translations
*
* @see https://github.com/printempw/blessing-skin-server
* @author printempw <h@prinzeugen.net>
*
* NOTE: this file must be saved in UTF-8 encoding.
*/
(function ($) { (function ($) {
'use strict'; 'use strict';
@ -15,7 +6,7 @@
// Login // Login
emptyIdentification: 'Empty email/player name.', emptyIdentification: 'Empty email/player name.',
emptyPassword: 'Password is required.', emptyPassword: 'Password is required.',
emptyCaptcha: 'Empty password.', emptyCaptcha: 'Please enter the CAPTCHA.',
login: 'Log In', login: 'Log In',
loggingIn: 'Logging In', loggingIn: 'Logging In',
tooManyFails: 'You fails too many times! Please enter the CAPTCHA.', tooManyFails: 'You fails too many times! Please enter the CAPTCHA.',
@ -27,6 +18,7 @@
emptyConfirmPwd: 'Empty confirming password.', emptyConfirmPwd: 'Empty confirming password.',
invalidConfirmPwd: 'Confirming password is not equal with password.', invalidConfirmPwd: 'Confirming password is not equal with password.',
emptyNickname: 'Empty nickname.', emptyNickname: 'Empty nickname.',
emptyPlayerName: 'Empty player name.',
register: 'Register', register: 'Register',
registering: 'Registering', registering: 'Registering',
@ -41,12 +33,16 @@
addToCloset: 'Add to closet', addToCloset: 'Add to closet',
removeFromCloset: 'Remove from closet', removeFromCloset: 'Remove from closet',
setItemName: 'Set a name for this texture', setItemName: 'Set a name for this texture',
applyNotice: 'You can apply it to player at your closet',
emptyItemName: 'Empty texture name.', emptyItemName: 'Empty texture name.',
// Rename // Rename
setNewTextureName: 'Please enter the new texture name:', setNewTextureName: 'Please enter the new texture name:',
emptyNewTextureName: 'Empty new texture name.', emptyNewTextureName: 'Empty new texture name.',
// Change Model
setNewTextureModel: 'Please select a new texture model:',
// Skinlib // Skinlib
filter: { filter: {
skin: '(Any Model)', skin: '(Any Model)',
@ -76,8 +72,8 @@
redirecting: 'Redirecting...', redirecting: 'Redirecting...',
// Change Privacy // Change Privacy
setAsPrivate: 'Set as Private', setAsPrivate: 'Set as private',
setAsPublic: 'Set as Public', setAsPublic: 'Set as public',
setPublicNotice: 'Sure to set this as public texture?', setPublicNotice: 'Sure to set this as public texture?',
deleteNotice: 'Are you sure to delete this texture?' deleteNotice: 'Are you sure to delete this texture?'
@ -94,8 +90,8 @@
removeItem: 'Remove from closet', removeItem: 'Remove from closet',
setAsAvatar: 'Set as avatar', setAsAvatar: 'Set as avatar',
viewInSkinlib: 'View in skin library', viewInSkinlib: 'View in skin library',
switch2dPreview: 'Switch to 2D Preview', switch2dPreview: 'Switch to 2D preview',
switch3dPreview: 'Switch to 3D Preview', switch3dPreview: 'Switch to 3D preview',
removeFromClosetNotice: 'Sure to remove this texture from your closet?', removeFromClosetNotice: 'Sure to remove this texture from your closet?',
emptySelectedPlayer: 'No player is selected.', emptySelectedPlayer: 'No player is selected.',
emptySelectedTexture: 'No texture is selected.', emptySelectedTexture: 'No texture is selected.',
@ -129,16 +125,17 @@
unban: 'Unban', unban: 'Unban',
setAdmin: 'Set as admin', setAdmin: 'Set as admin',
unsetAdmin: 'Remove admin', unsetAdmin: 'Remove admin',
deleteUser: 'Delete User', deleteUser: 'Delete user',
cannotDeleteAdmin: 'You can\'t delete admins.', cannotDeleteAdmin: 'You can\'t delete admins.',
cannotDeleteSuperAdmin: 'You can\'t delete super admin in this way', cannotDeleteSuperAdmin: 'You can\'t delete super admins.',
changeEmail: 'Edit Email', changeEmail: 'Edit email',
changeNickName: 'Edit Nickname', changeNickName: 'Edit nickname',
changePassword: 'Edit Password', changePassword: 'Edit password',
changeVerification: 'Switch verification status',
newUserEmail: 'Please enter the new email:', newUserEmail: 'Please enter the new email:',
newUserNickname: 'Please enter the new nickname:', newUserNickname: 'Please enter the new nickname:',
newUserPassword: 'Please enter the new password:', newUserPassword: 'Please enter the new password:',
deleteUserNotice: 'Are you sure to delete this user? It\' permanent.', deleteUserNotice: 'Are you sure to delete this user? It\'s permanent.',
scoreTip: 'Press ENTER to submit new score', scoreTip: 'Press ENTER to submit new score',
inspectHisOwner: 'Click to inspect the owner of this player', inspectHisOwner: 'Click to inspect the owner of this player',
inspectHisPlayers: 'Click to inspect the players he owns', inspectHisPlayers: 'Click to inspect the players he owns',
@ -149,6 +146,10 @@
admin: 'Admin', admin: 'Admin',
superAdmin: 'Super Admin', superAdmin: 'Super Admin',
// Verification
unverified: 'Unverified',
verified: 'Verified',
// Players // Players
textureType: 'Texture Type', textureType: 'Texture Type',
skin: 'Skin (:model Model)', skin: 'Skin (:model Model)',
@ -156,39 +157,49 @@
pid: 'Texture ID', pid: 'Texture ID',
pidNotice: 'Please enter the tid of texture. Inputting 0 can clear texture of this player.', pidNotice: 'Please enter the tid of texture. Inputting 0 can clear texture of this player.',
changePlayerTexture: 'Change textures of :player', changePlayerTexture: 'Change textures of :player',
changeTexture: 'Change Textures', changeTexture: 'Change textures',
changePlayerName: 'Change Player Name', changePlayerName: 'Change player name',
changeOwner: 'Change Owner', changeOwner: 'Change owner',
deletePlayer: 'Delete', deletePlayer: 'Delete',
changePlayerOwner: 'Please enter the id of user which this player should be transferred to:', changePlayerOwner: 'Please enter the id of user which this player is transfering to:',
deletePlayerNotice: 'Are you sure to delete this player? It\' permanent.', deletePlayerNotice: 'Are you sure to delete this player? It\' permanent.',
targetUser: 'Target user is :nickname', targetUser: 'Target user is :nickname',
noSuchUser: 'No such user', noSuchUser: 'No such user.',
changePlayerNameNotice: 'Please input new player name:', changePlayerNameNotice: 'Please input new player name:',
emptyPlayerName: 'Player name cannot be empty.', emptyPlayerName: 'Player name cannot be empty.',
// Plugins // Plugins
configurePlugin: 'Configure', pluginTitle: 'Plugin',
noPluginConfigNotice: 'The plugin has been disabled or no configuration is provided.', pluginAuthor: 'Author',
deletePlugin: 'Delete', pluginVersion: 'Version',
noDependencies: 'No Dependencies', pluginName: 'Name',
whyDependencies: 'What\'s this?', pluginOperations: 'Operations',
statusEnabled: 'Enabled', pluginDescription: 'Description',
statusDisabled: 'Disabled', pluginDependencies: 'Dependencies',
pluginEnabled: 'Enabled',
enablePlugin: 'Enable', enablePlugin: 'Enable',
disablePlugin: 'Disable', disablePlugin: 'Disable',
configurePlugin: 'Configure',
installPlugin: 'Install',
pluginInstalling: 'Installing...',
updatePlugin: 'Update',
pluginUpdating: 'Updating...',
confirmUpdate: 'Are you sure to update ":plugin" from :old to :new?',
deletePlugin: 'Delete',
confirmDeletion: 'Are you sure to delete this plugin?', confirmDeletion: 'Are you sure to delete this plugin?',
noDependencies: 'No Dependencies',
whyDependencies: 'What\'s this?',
noDependenciesNotice: 'There is no dependency definition in the plugin. It means that the plugin may be not compatible with the current version of Blessing Skin, and enabling it may cause unexpected problems. Do you really want to enable the plugin?', noDependenciesNotice: 'There is no dependency definition in the plugin. It means that the plugin may be not compatible with the current version of Blessing Skin, and enabling it may cause unexpected problems. Do you really want to enable the plugin?',
// Update // Update
preparing: 'Preparing', preparing: 'Preparing',
downloadCompleted: 'Update package download completed.', downloadCompleted: 'Update package download completed.',
extracting: 'Extracting update package..' extracting: 'Extracting update package...'
}, },
general: { general: {
skin: 'Skin', skin: 'Skin',
cape: 'Cape', cape: 'Cape',
fatalError: 'Fatal Error (Please contact the author)', fatalError: 'Fatal Error <small>(Please read the <a target="_blank" href="https://github.com/printempw/blessing-skin-server/wiki/FAQ">FAQ</a> before asking questions)</small>',
confirmLogout: 'Sure to log out?', confirmLogout: 'Sure to log out?',
confirm: 'OK', confirm: 'OK',
cancel: 'Cancel', cancel: 'Cancel',

View File

@ -1,18 +1,18 @@
option-saved: Option Saved. option-saved: Option saved.
homepage: homepage:
title: Homepage title: Homepage
home_pic_url: home_pic_url:
title: Picture URL at Homepage title: Picture URL at Homepage
hint: Path relative to homepage or full URL, leave empty to use default image hint: Path relative to homepage or full URL, leave empty to use default image.
favicon_url: favicon_url:
title: Website Icon title: Website Icon
hint: Path relative to resources/assets/ or full URL. hint: Path relative to resources/assets/ or full URL.
description: The given image must have same width and height (leave blank to use default icon). description: The given image must have same width and height (leave blank to use default icon).
copyright_prefer: copyright_prefer:
title: Program Copyright title: Program Copyright
description: Any evil modification applied on the footer program copyright (including deleting, modifying author, changing link target) with out permission is <b>FORBIDDEN</b>. The author reserves the right to pursue relevant responsibilities. description: "You can specify a different style of program copyright for each language. To edit a specific language's corresponding program copyright style, please switch to that language and submit your edit. <br><b>Warning:</b> Any evil modification applied on the footer program copyright (including deleting, modifying author, changing link target) with out permission is <b>FORBIDDEN</b>. The author reserves the right to pursue relevant responsibilities."
copyright_text: copyright_text:
title: Custom Copyright Text title: Custom Copyright Text
description: Placeholders are available in custom copyright text. e.g. <code>{site_name}</code> & <code>{site_url}</code>. You can also specify a different footer for each language. To edit a specific language's corresponding footer, please switch to that language and submit your edit. description: Placeholders are available in custom copyright text. e.g. <code>{site_name}</code> & <code>{site_url}</code>. You can also specify a different footer for each language. To edit a specific language's corresponding footer, please switch to that language and submit your edit.
@ -27,7 +27,7 @@ customJsCss:
custom_js: JavaScript custom_js: JavaScript
rate: rate:
title: About Scores title: Scores
score_per_storage: score_per_storage:
title: Storage title: Storage
@ -71,19 +71,25 @@ general:
description: You can also specify a different site name and description for each language. To edit a specific language's corresponding site name or description text, please switch to that language and submit your edit. description: You can also specify a different site name and description for each language. To edit a specific language's corresponding site name or description text, please switch to that language and submit your edit.
site_url: site_url:
title: Site URL title: Site URL
hint: Begin with http(s)://, nerver ends with slash hint: Begin with http(s)://, nerver ends with slash.
user_can_register: user_can_register:
title: Open Registration title: Open Registration
label: Everyone is allowed to register. label: Everyone is allowed to register.
register_with_player_name:
title: Register with Player Name
label: Require Minecraft's player name when user register
require_verification:
title: Account Verification
label: Users must verify their email address first.
regs_per_ip: Max accounts of one IP regs_per_ip: Max accounts of one IP
ip_get_method: ip_get_method:
title: Get IP via title: Get IP via
HTTP_X_FORWARDED_FOR: HTTP_X_FORWARDED_FOR (can be fabricated) HTTP_X_FORWARDED_FOR: HTTP_X_FORWARDED_FOR (can be fabricated)
REMOTE_ADDR: REMOTE_ADDR (isn't suit for sites under load balancer) REMOTE_ADDR: REMOTE_ADDR (NOT suitable for sites under load balancer)
hint: We have no method to get the real IP address of client with PHP. hint: Unfortunately, we have no method to get the accurate client IP address with pure PHP.
max_upload_file_size: max_upload_file_size:
title: Max Upload Size title: Max Upload Size
hint: "Limit of PHP in php.ini: :size" hint: "Limit specified in php.ini: :size"
player_name_rule: player_name_rule:
title: Player Name Rule title: Player Name Rule
official: Letters, numbers and underscores (Mojang's official rule) official: Letters, numbers and underscores (Mojang's official rule)
@ -103,13 +109,20 @@ general:
title: Invalid Textures title: Invalid Textures
label: Delete invalid textures automatically. label: Delete invalid textures automatically.
hint: Delete textures records whose file no longer exists from skinlib. hint: Delete textures records whose file no longer exists from skinlib.
allow_downloading_texture:
title: Downloading Textures
label: Allow users to directly download the source file of a skinlib item.
texture_name_regexp:
title: Texture Name Rules
hint: The RegExp for validating name of uploaded textures. Leave empty to allow any character except single, double quote and backslash.
placeholder: Regular Expressions
comment_script: comment_script:
title: Comment Script title: Comment Script
description: Placeholders are available in comment scripts. <code>{tid}</code> will be replaced with texture id, <code>{name}</code> will be replaced with texture name, <code>{url}</code> will be replaced with current URL. description: Placeholder is available, <code>{tid}</code> will be replaced with texture id, <code>{name}</code> will be replaced with texture name, <code>{url}</code> will be replaced with current URL.
allow_sending_statistics: allow_sending_statistics:
title: Statistics title: Statistics
label: Send usage statistics anonymously. label: Send usage statistics anonymously.
hint: Information about privacy will nerver be sent. hint: Privacy information will nerver be sent.
announ: announ:
title: Announcement title: Announcement
@ -119,30 +132,20 @@ announ:
resources: resources:
title: Resource Files title: Resource Files
hint: Please adjust these options when CDN cache is on hint: Please check these options if you enabled CDN for your site.
force_ssl: force_ssl:
title: Force SSL title: Force SSL
label: Use HTTPS protocol to load resources forcely. label: Use HTTPS protocol to load all front-end assets.
hint: Check SSL available before turning on hint: Please check if SSL really available before turning on.
auto_detect_asset_url: auto_detect_asset_url:
title: Assets URL title: Assets URL
label: Determine assets url automatically. label: Determine assets url automatically.
description: Load asset files according to current URL. The site url will be used if this is not enabled. Please unable this if requests don't go through CDN. description: Please unable this if assets URLs are wrongly generated under a CDN. The site url will be used if this is not enabled.
return_200_when_notfound: return_204_when_notfound:
title: HTTP Response Code title: HTTP Response Code
label: Return 200 instead of 404 when requesting un-existent player. label: Return 204 instead of 404 when requesting non-existent player.
description: If your CDN doesn't cache 404 pages, please turn this on. A flood of requests to un-existent players will greatly slow down the site. description: If your CDN doesn't cache 404 pages, please turn this on. A flood of requests to non-existent players will greatly slow down the site.
cache_expire_time: cache_expire_time:
title: Cache Exipre Time title: Cache Exipre Time
hint: In seconds, 86400 = one day, 31536000 = one year hint: In seconds, 86400 = one day, 31536000 = one year.
update:
title: Check Update
check_update:
title: Check Update
label: Check update automatically and notify me.
update_source:
title: Update Source
description: 'Available update source list can be found at: <a href="https://github.com/printempw/blessing-skin-server/wiki/%E6%9B%B4%E6%96%B0%E6%BA%90%E5%88%97%E8%A1%A8">@GitHub Wiki</a>'

View File

@ -1,11 +1,12 @@
database: database:
connection-error: "Unable to connect to the target :type database, please check your configuration. The server replied with: :msg" connection-error: "Unable to connect to the target :type database, please check your configuration. The server replied with: :msg"
connection-success: Connect to the target :type database [:server] successfully, just click NEXT to start installation. connection-success: Connect to the target :type database [:server] successfully, just click NEXT to start installation.
table-already-exists: There are some tables already exist in target database, which names conflict with ones we are going to create. To avoid data loss, please manually delete these tables :tables, or set a different table prefix. table-already-exists: There are some tables already exist in target database, whose names conflict with ones we are going to create. To avoid data loss, please manually delete these tables :tables, or set a different table prefix.
file: file:
permission-error: Unable to create textures folder, please check the directory permissions or place one manually. permission-error: Unable to create textures folder, please check the directory permissions or place one manually.
no-dot-env: Unable to find environment configuration file. Please rename .env.example to .env (please refer to setup manual). no-dot-env: Unable to find environment configuration file. Please rename .env.example to .env (please refer to setup manual).
dot-env-no-read-permission: Unable to read .env configuration file. Please check the file permission.
permissions: permissions:
storage: Unable to write to storage directory, please check the permissions. storage: Unable to write to storage directory, please check the permissions.
@ -15,7 +16,7 @@ disabled-functions:
locked: locked:
title: Already installed title: Already installed
text: You appear to have already installed Blessing Skin Server. To reinstall please clear your old database tables first, or use a new database table prefix. text: It appears that you have already installed Blessing Skin Server. To reinstall, please delete the tables in your database first, or use a new table prefix.
button: Back to homepage button: Back to homepage
updates: updates:
@ -25,7 +26,7 @@ updates:
welcome: welcome:
title: One more step title: One more step
text: | text: |
Welcome to update to Blessing Skin Server v:version! Welcome! You are going to update to Blessing Skin Server v:version.
We need to apply some updates to your database, click NEXT to continue. We need to apply some updates to your database, click NEXT to continue.
button: Next button: Next
@ -46,7 +47,7 @@ wizard:
info: info:
title: Information needed title: Information needed
button: Run install button: Run install
text: To proceed with the installation fill this form with the details of the initial admin account. Don't worry, you can always change these settings later. text: To proceed with the installation, please fill this form with the details of the initial admin account. Don't worry, you can always change these settings later.
admin-email: Admin Email admin-email: Admin Email
admin-notice: This is the <b>UNIQUE</b> super admin account who can GIVE or CANCEL other users' admin privilege. admin-notice: This is the <b>UNIQUE</b> super admin account who can GIVE or CANCEL other users' admin privilege.
@ -54,10 +55,14 @@ wizard:
pwd-notice: <b>Attention:</b> You will need the password to log in. Please keep it at a secure place. pwd-notice: <b>Attention:</b> You will need the password to log in. Please keep it at a secure place.
confirm-pwd: Confirm password confirm-pwd: Confirm password
site-name: Site name site-name: Site name
site-name-notice: This will be shown at title bar and homepage. site-name-notice: This will be shown on every page.
secure: Security secure: Security
secure-notice: Generate random APP_KEY and SALT to make your site secured. secure-notice: Generate random APP_KEY and SALT to make your site secured.
finish: finish:
title: Installation complete title: Installation complete
text: Blessing Skin Server has been installed. Thank you, and enjoy! text: Blessing Skin Server has been installed. Thank you, and enjoy!
integrity-check:
title: We could not complete the installation.
description: The automatic upgrade script failed to update your database. <a target="_blank" href="https://github.com/printempw/blessing-skin-server/wiki/%E6%89%8B%E5%8A%A8%E5%AE%89%E8%A3%85-Blessing-Skin#-%E5%8D%87%E7%BA%A7%E8%87%B3-bs-v350">Learn more</a>

View File

@ -1,9 +1,9 @@
general: general:
filter: Filter filter: Filter
my-upload: Uploaded by Me my-upload: Uploaded by me
sort: Sort sort: Sort
search-textures: Search For Textures search-textures: Search for textures...
upload-new-skin: Upload New Skin upload-new-skin: Upload new skin
no-result: No result. no-result: No result.
filter: filter:
@ -13,17 +13,18 @@ filter:
any-model: (Any Model) any-model: (Any Model)
steve-model: (Steve Model) steve-model: (Steve Model)
alex-model: (Alex Model) alex-model: (Alex Model)
uploader: User (:name) Uploaded uploader: User (:name) uploaded
clean-filter: Clean Filter clean-filter: Clean filter
sort: sort:
newest-uploaded: Newestly Uploaded newest-uploaded: Newestly uploaded
most-likes: Most Likes most-likes: Most likes
item: item:
steve: (Steve) steve: (Steve)
alex: (Alex) alex: (Alex)
cape: (Cape) cape: (Cape)
apply: Quick apply
remove-from-closet: Remove from closet remove-from-closet: Remove from closet
add-to-closet: Add to closet add-to-closet: Add to closet
anonymous: Please login first anonymous: Please login first
@ -31,27 +32,26 @@ item:
show: show:
title: Texture Details title: Texture Details
anonymous: You must login to use closets anonymous: You must login to use closets.
likes: People who like this likes: People who liked this
detail: Details detail: Details
name: Texture Name name: Texture Name
edit-name: Edit Name edit: Edit
model: Applicable Model model: Applicable Model
download-raw: Click to download raw texture download-raw: Click to download raw texture
size: File Size size: File Size
uploader: Uploader uploader: Uploader
upload-at: Upload At upload-at: Upload At
manage-panel: Manage Panel manage-panel:
delete-texture: Delete Texture title: Manage Panel
notice: You can delete this texture or make it private. The operation will also remove it from the closet of everyone who had favorited it.
notice: The texture which was deleted or setted to private will be removed from the closet of everyone who had favorited it.
notice-admin: You are able to delete this texture or make it private. The operations will make it removed from the closet of everyone who had favorited it.
comment: Comment comment: Comment
comment-not-available: Comment is not available. comment-not-available: Comment is not available.
delete-texture: Delete texture
deleted: The requested texture was already deleted. deleted: The requested texture was already deleted.
contact-admin: Please contact the admins to remove this entry. contact-admin: Please contact the admins to remove this entry.
private: The requested texture is private and only visible to the uploader and admins. private: The requested texture is private and only visible to the uploader and admins.
@ -61,17 +61,18 @@ upload:
texture-name: Texture Name texture-name: Texture Name
name-rule: Less than 32 characters and must not contain any special one. name-rule: Less than 32 characters and must not contain any special one.
name-rule-regexp: Custom name rules are applied. :regexp
texture-type: Texture Type texture-type: Texture Type
select-file: Select File select-file: Select File
private-score-notice: It will spend you more scores to set it as private. You will be charged :score scores for every KB storage. private-score-notice: It will spend you more scores for setting it as private. You will be charged :score scores for per KB storage.
privacy-notice: Prevent it from being visible at skin library. privacy-notice: Prevent it from being visible at skin library.
set-as-private: Make it Private set-as-private: Make it private
button: Upload button: Upload
type-error: Incorrect mime type of uploaded file. type-error: Incorrect mime type of uploaded file.
invalid-size: Invalid :type file (Width :width, Height :height) invalid-size: Invalid :type file (width :width, height :height)
invalid-hd-skin: Invalid HD skin (Width and height can not be devided by 32) invalid-hd-skin: Invalid HD skin (width and height should be divisible by 32)
lack-score: You don't have enough score to upload this texture. lack-score: You don't have enough score to upload this texture.
repeated: The texture is already uploaded by someone else. You can add it to your closet directly. repeated: The texture is already uploaded by someone else. You can add it to your closet directly.
@ -81,13 +82,17 @@ delete:
success: The texture was deleted successfully. success: The texture was deleted successfully.
privacy: privacy:
change-privacy: Change Privacy change-privacy: Change privacy
set-as-private: Set as Private set-as-private: Set as private
set-as-public: Set as Public set-as-public: Set as public
success: The texture was setted to :privacy successfully. success: The texture was set to :privacy successfully.
rename: rename:
success: The texture was renamed to :name successfully. success: The texture was renamed to :name successfully.
no-permission: You aren't the uploader of this texture. model:
non-existent: Non-existent texture. success: The texture's model was changed to :model successfully.
duplicate: "The same texture available for the chosen model already exists in skinlib (TID: :tid). You can add it to your closet directly."
no-permission: You have no permission to moderate this texture.
non-existent: No such texture.

View File

@ -14,6 +14,23 @@ last-sign: Last signed at :time
sign-remain-time: Available after :time :unit sign-remain-time: Available after :time :unit
announcement: Announcement announcement: Announcement
verification:
disabled: Email verification is not available.
frequent-mail: You click the send button too fast. Wait for 60 secs, guy.
verified: Your account is already verified.
success: Verification link was sent, please check your inbox.
failed: We failed to send you the verification link. Detailed message :msg
notice:
title: Verify Your Account
message: You must verify your email address before using the skin hosting service. Haven't received the email?
resend: Click here to send again.
sending: Sending...
mail:
title: Verify Your Account on :sitename
message: You are receiving this email because someone registered an account with this email address on :sitename.
reset: 'Click here to verify your account: <a href=":url">:url</a>'
ignore: If you did not register an account, no further action is required.
score-intro: score-intro:
title: What is score? title: What is score?
introduction: | introduction: |
@ -43,10 +60,10 @@ closet:
reset: Clear selected reset: Clear selected
title: Which player should be applied to? title: Which player should be applied to?
empty: It seems that you own no player... empty: It seems that you own no player...
add: Add new player add: Add a new player
add: add:
success: Added :name to closet successfully~ success: Added :name to closet successfully.
repeated: You have already added this texture. repeated: You have already added this texture.
not-found: We cannot find this texture. not-found: We cannot find this texture.
lack-score: You don't have enough score to add it to closet. lack-score: You don't have enough score to add it to closet.
@ -57,7 +74,7 @@ closet:
remove: remove:
title: Remove from closet title: Remove from closet
success: Texture was removed from closet successfully. success: The texture was removed from closet successfully.
non-existent: The texture does not exist in your closet. non-existent: The texture does not exist in your closet.
player: player:
@ -79,9 +96,6 @@ player:
cape: Cape cape: Cape
empty: Nothing empty: Nothing
pname-rule: Could only contain letters, numbers and dashes.
pname-rule-chinese: Could only contain chinese characters, letters, numbers and dashes.
player-name-rule: player-name-rule:
official: Player name may only contains letters, numbers and underscores. official: Player name may only contains letters, numbers and underscores.
cjk: Player name may contains letters, numbers, underscores and CJK Unified Ideographs. cjk: Player name may contains letters, numbers, underscores and CJK Unified Ideographs.
@ -115,22 +129,22 @@ player:
profile: profile:
avatar: avatar:
title: Change Avatar? title: Change Avatar?
notice: Click the gear icon「<i class="fa fa-cog"></i>」of any skin in your skinlib, then click 「Set as avatar」. We will cut the head segment of that skin for you. If there is no icon like this, please unable the extensions like ADBlock. notice: Click the gear icon "<i class="fa fa-cog"></i>" of any skin in your closet, then click "Set as avatar". We will cut the head segment of that skin for you. If there is no icon like this, please try to unable your ADs blocking extension.
wrong-type: You can't set a cape as avatar wrong-type: You can't set a cape as avatar.
success: Avatar setted successfully success: New avatar was set successfully.
password: password:
title: Change Password title: Change Password
old: Old Password old: Old Password
new: New Password new: New Password
confirm: Repeat Password confirm: Repeat Password
button: Change Password button: Change password
wrong-password: Original password is not correct. wrong-password: Wrong original password.
success: Password updated successfully, please log in again. success: Password updated successfully, please log in again.
nickname: nickname:
title: Change Nickname title: Change Nickname
empty: No nickname is setted now. empty: No nickname is set now.
rule: Whatever you like expect special characters rule: Whatever you like expect special characters
success: Nickname is successfully updated to :nickname success: Nickname is successfully updated to :nickname
@ -138,16 +152,16 @@ profile:
title: Change Email title: Change Email
new: New Email new: New Email
password: Current Password password: Current Password
button: Change Email button: Change email
wrong-password: Wrong password. wrong-password: Wrong password.
existed: This email address is used. existed: This email address is occupied.
success: Email address updated successfully, please log in again. success: Email address updated successfully, please log in again.
delete: delete:
title: Delete Account title: Delete Account
notice: Sure to delete your account on :site? notice: Sure to delete your account on :site?
admin: Admin account can not be deleted. admin: Admin account can not be deleted.
button: Delete My Account button: Delete my account
modal-title: You need to enter your password to continue modal-title: You need to enter your password to continue
modal-notice: | modal-notice: |

View File

@ -1,9 +1,10 @@
# Blessing Skin # Blessing Skin
username: ':attribute format is invalid.' username: ':attribute format is invalid.'
player_name: 'The :attribute contains invalid character.' player_name: 'The :attribute contains invalid character.'
no_special_chars: 'The :attribute must not contain special characters.' texture_name_regexp: 'The :attribute contains invalid character.'
preference: 'The :attribute must be default or slim.' no_special_chars: 'The :attribute must not contain special characters.'
model: 'The :attribute must be steve, alex or cape.' preference: 'The :attribute must be default or slim.'
model: 'The :attribute must be steve, alex or cape.'
accepted: 'The :attribute must be accepted.' accepted: 'The :attribute must be accepted.'
active_url: 'The :attribute is not a valid URL.' active_url: 'The :attribute is not a valid URL.'
@ -14,10 +15,10 @@ alpha_num: 'The :attribute may only contain letters and numbers.'
array: 'The :attribute must be an array.' array: 'The :attribute must be an array.'
before: 'The :attribute must be a date before :date.' before: 'The :attribute must be a date before :date.'
between: between:
numeric: 'The :attribute must be between :min and :max.' numeric: 'The :attribute must be between :min and :max.'
file: 'The :attribute must be between :min and :max kilobytes.' file: 'The :attribute must be between :min and :max kilobytes.'
string: 'The :attribute must be between :min and :max characters.' string: 'The :attribute must be between :min and :max characters.'
array: 'The :attribute must have between :min and :max items.' array: 'The :attribute must have between :min and :max items.'
boolean: 'The :attribute field must be true or false.' boolean: 'The :attribute field must be true or false.'
confirmed: 'The :attribute confirmation does not match.' confirmed: 'The :attribute confirmation does not match.'
date: 'The :attribute is not a valid date.' date: 'The :attribute is not a valid date.'
@ -36,16 +37,16 @@ integer: 'The :attribute must be an integer.'
ip: 'The :attribute must be a valid IP address.' ip: 'The :attribute must be a valid IP address.'
json: 'The :attribute must be a valid JSON string.' json: 'The :attribute must be a valid JSON string.'
max: max:
numeric: 'The :attribute may not be greater than :max.' numeric: 'The :attribute may not be greater than :max.'
file: 'The :attribute may not be greater than :max kilobytes.' file: 'The :attribute may not be greater than :max kilobytes.'
string: 'The :attribute may not be greater than :max characters.' string: 'The :attribute may not be greater than :max characters.'
array: 'The :attribute may not have more than :max items.' array: 'The :attribute may not have more than :max items.'
mimes: 'The :attribute must be a file of type: :values.' mimes: 'The :attribute must be a file of type: :values.'
min: min:
numeric: 'The :attribute must be at least :min.' numeric: 'The :attribute must be at least :min.'
file: 'The :attribute must be at least :min kilobytes.' file: 'The :attribute must be at least :min kilobytes.'
string: 'The :attribute must be at least :min characters.' string: 'The :attribute must be at least :min characters.'
array: 'The :attribute must have at least :min items.' array: 'The :attribute must have at least :min items.'
not_in: 'The selected :attribute is invalid.' not_in: 'The selected :attribute is invalid.'
numeric: 'The :attribute must be a number.' numeric: 'The :attribute must be a number.'
present: 'The :attribute field must be present.' present: 'The :attribute field must be present.'
@ -59,10 +60,10 @@ required_without: 'The :attribute field is required when :values is not present.
required_without_all: 'The :attribute field is required when none of :values are present.' required_without_all: 'The :attribute field is required when none of :values are present.'
same: 'The :attribute and :other must match.' same: 'The :attribute and :other must match.'
size: size:
numeric: 'The :attribute must be :size.' numeric: 'The :attribute must be :size.'
file: 'The :attribute must be :size kilobytes.' file: 'The :attribute must be :size kilobytes.'
string: 'The :attribute must be :size characters.' string: 'The :attribute must be :size characters.'
array: 'The :attribute must contain :size items.' array: 'The :attribute must contain :size items.'
string: 'The :attribute must be a string.' string: 'The :attribute must be a string.'
timezone: 'The :attribute must be a valid zone.' timezone: 'The :attribute must be a valid zone.'
unique: 'The :attribute has already been taken.' unique: 'The :attribute has already been taken.'
@ -75,8 +76,10 @@ url: 'The :attribute format is invalid.'
# Here you may specify custom validation messages for attributes using the # Here you may specify custom validation messages for attributes using the
# convention "attribute.rule" to name the lines. This makes it quick to # convention "attribute.rule" to name the lines. This makes it quick to
# specify a specific custom language line for a given attribute rule. # specify a specific custom language line for a given attribute rule.
#
custom: custom:
attribute-name: { rule-name: custom-message } attribute-name:
rule-name: custom-message
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Custom Validation Attributes # Custom Validation Attributes
@ -87,9 +90,7 @@ custom:
# of "email". This simply helps us make messages a little cleaner. # of "email". This simply helps us make messages a little cleaner.
# #
attributes: attributes:
file: File player_name: player name
name: Name new_player_name: player name
player_name: Player Name identification: email or player name
new_player_name: Player Name sitename: site name
identification: Email or player name
site name: Site Name

2
resources/lang/overrides/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -16,15 +16,19 @@ users:
banned: 封禁 banned: 封禁
admin: 管理员 admin: 管理员
super-admin: 超级管理员 super-admin: 超级管理员
verification:
title: 邮箱验证
operations: operations:
title: 更多操作 title: 更多操作
non-existent: 用户不存在 non-existent: 用户不存在
no-permission: 你无权操作此用户 no-permission: 你无权操作此用户
invalid: 无效 action invalid: 无效 action
email: email:
change: 修改邮箱 change: 修改邮箱
existed: :email 已被占用 existed: 邮箱 :email 已被占用
success: 邮箱修改成功 success: 邮箱修改成功
verification:
success: 用户的邮箱验证状态已修改
nickname: nickname:
change: 修改昵称 change: 修改昵称
success: 昵称已成功设置为 :new success: 昵称已成功设置为 :new
@ -52,8 +56,8 @@ users:
delete: delete:
delete: 删除用户 delete: 删除用户
success: 账号已被成功删除 success: 账号已被成功删除
cant-super-admin: 超级管理员账号不能被这样删除的啦 cant-super-admin: 超级管理员账号不能被删除
cant-admin: 你不能删除管理员账号哦 cant-admin: 无法删除管理员账号
players: players:
no-permission: 你无权操作此角色 no-permission: 你无权操作此角色
@ -86,27 +90,15 @@ customize:
yellow-light: 黄色主题 - 白色侧边栏 yellow-light: 黄色主题 - 白色侧边栏
green: 绿色主题 green: 绿色主题
green-light: 绿色主题 - 白色侧边栏 green-light: 绿色主题 - 白色侧边栏
purple: 基佬 purple: 色主题
purple-light: 紫色主题 - 白色侧边栏 purple-light: 紫色主题 - 白色侧边栏
red: 喜庆红(笑) red: 红色主题
red-light: 红色主题 - 白色侧边栏 red-light: 红色主题 - 白色侧边栏
black: 高端 black: 色主题
black-light: 黑色主题 - 白色侧边栏 black-light: 黑色主题 - 白色侧边栏
plugins: plugins:
name: 名称
description: 描述
author: 作者
version: 版本
dependencies: 依赖关系
status:
title: 状态
enabled: 已启用
disabled: 已禁用
operations: operations:
title: 操作
enabled: :plugin 已启用 enabled: :plugin 已启用
unsatisfied: unsatisfied:
notice: 无法启用此插件,因为其仍有未满足的依赖关系。请检查以下插件的版本,更新或安装它们: notice: 无法启用此插件,因为其仍有未满足的依赖关系。请检查以下插件的版本,更新或安装它们:
@ -117,6 +109,14 @@ plugins:
no-config-notice: 插件未安装或未提供配置页面 no-config-notice: 插件未安装或未提供配置页面
not-found: 插件不存在 not-found: 插件不存在
market:
connection-error: 无法连接至插件市场源更换市场源请参考http://t.cn/Rk6X37l错误信息:error
non-existent: 插件 :plugin 不存在
download-failed: 插件下载失败,错误信息::error
shasum-failed: 文件校验失败,请尝试重新下载
unzip-failed: 插件解压缩失败,错误信息::error
install-success: 插件安装成功
empty: 无结果 empty: 无结果
update: update:
@ -151,7 +151,8 @@ update:
title: 注意事项 title: 注意事项
text: | text: |
请根据你的主机所在位置(国内/国外)选择更新源。 请根据你的主机所在位置(国内/国外)选择更新源。
如错选至相对于你的主机速度较慢的源,可能会造成检查/下载更新页面长时间无响应。 如错选至相对于你的主机速度较慢的源,可能会造成检查与下载更新页面长时间无响应。
如何更换更新源?<a target="_blank" href="https://github.com/printempw/blessing-skin-server/wiki/%E6%9B%B4%E6%96%B0%E6%BA%90%E5%88%97%E8%A1%A8">点击了解详情</a>。
download: download:
downloading: 正在下载更新包 downloading: 正在下载更新包
@ -159,9 +160,9 @@ update:
errors: errors:
prefix: 发生错误: prefix: 发生错误:
connection: 无法访问当前更新源。详细信息: connection: 无法访问当前更新源。详细信息::error
write-permission: 的服务器不支持自动更新:创建下载缓存文件夹失败,请检查目录权限。 write-permission: 的服务器不支持自动更新:创建下载缓存文件夹失败,请检查目录权限。
unzip: 更新包解压缩失败。错误代码: unzip: 更新包解压缩失败。错误代码:
overwrite: 的服务器不支持自动更新:无法覆盖文件。 overwrite: 的服务器不支持自动更新:无法覆盖文件。
invalid-action: 无效的操作名 invalid-action: 无效的操作名

View File

@ -1,50 +1,51 @@
login: login:
title: 登录 title: 登录
button: 登录 button: 登录
message: 登录以管理您的角色及皮肤 message: 登录以管理你的角色与皮肤
keep: 保持登录状态 keep: 保持登录状态
success: 登录成功,欢迎回来~ success: 登录成功,欢迎回来
check: check:
anonymous: 非法访问,请先登录 anonymous: 未授权的访问,请先登录
admin: 看起来你并不是管理员哦 verified: 你必须验证邮箱后才能访问此页面
banned: 你已经被本站封禁啦,请联系管理员解决 admin: 只有管理员才能访问此页面
token: 无效的 token请重新登录 super-admin: 只有超级管理员才能访问此页面
banned: 你已被本站封禁,详情请联系站点管理员
token: 登录状态已过期,请重新登录
register: register:
title: 注册 title: 注册
button: 注册 button: 注册
message: 欢迎使用 :sitename message: 欢迎使用 :sitename
player-name-intro: 游戏内的角色名,注册后可修改
nickname-intro: 昵称可使用汉字,不可包含特殊字符 nickname-intro: 昵称可使用汉字,不可包含特殊字符
repeat-pwd: 重复密码 repeat-pwd: 重复密码
close: 残念。。皮肤站已关闭注册咯 QAQ close: 本站已关闭注册
success: 注册成功,正在跳转~ success: 注册成功,正在跳转...
max: 最多只能注册 :regs 个账户哦 max: 在本站注册的账号已达到上限 :regs 个,无法继续注册
registered: 这个邮箱已经注册过啦,换一个吧 registered: 此邮箱已被占用
forgot: forgot:
title: 忘记密码 title: 忘记密码
button: 发送 button: 发送
message: 我们将会向您发送一封验证邮件 message: 我们将会向您发送一封验证邮件
login-link: 我又想起来了 login-link: 我又想起来了
close: 本站已关闭重置密码功能 disabled: 本站已关闭重置密码功能
frequent-mail: 你邮件发送得太频繁啦,过 60 秒后再点发送吧 frequent-mail: 你邮件发送得太频繁啦,过会儿再点发送吧
unregistered: 该邮箱尚未注册 unregistered: 该邮箱尚未注册
mail:
title: 重置您在 :sitename 上的账户密码
success: 邮件已发送,一小时内有效,请注意查收。 success: 邮件已发送,一小时内有效,请注意查收。
failed: 邮件发送失败,详细信息::msg failed: 邮件发送失败,详细信息::msg
message: 您收到这封邮件,是因为在 :sitename 的用户重置密码功能使用了您的地址。 mail:
ignore: 如果您并没有访问过我们的网站,或没有进行上述操作,请忽略这封邮件。 您不需要退订或进行其他进一步的操作。 title: 重置您在 :sitename 上的账户密码
reset: 重置密码 message: 您收到这封邮件,是因为有人在 :sitename 的密码重置功能中使用了您的地址。
notice: 本邮件由系统自动发送,就算你回复了我们也不会回复你哦 reset: 点击此链接重置您的密码:<a href=":url">:url</a>
ignore: 如果您并没有访问过我们的网站,或没有进行上述操作,请忽略这封邮件。
reset: reset:
title: 重置密码 title: 重置密码
button: 重置 button: 重置
invalid: 无效的链接 invalid: 无效的链接
expired: 链接已过期 expired: 链接已失效,请重新发送验证邮件
message: :username在这里重置你的密码 message: :username在这里重置你的密码
success: 密码重置成功 success: 密码重置成功
@ -52,27 +53,35 @@ bind:
title: 绑定邮箱 title: 绑定邮箱
button: 绑定 button: 绑定
message: 你需要绑定邮箱地址以继续使用本站 message: 你需要绑定邮箱地址以继续使用本站
introduction: 邮箱地址仅用于重置密码,我们不会向您发送任何垃圾邮件 introduction: 邮箱地址仅用于重置密码,我们不会向您发送任何垃圾邮件
registered: 邮箱已被占用 registered: 邮箱已被占用
verify:
title: 邮箱验证
success: 邮箱验证成功
message: 欢迎使用 :sitename
button: 返回首页
invalid: 无效的链接
expired: 链接已失效,请重新发送验证邮件
validation: validation:
identification: 邮箱或角色名格式错误 identification: 邮箱或角色名格式错误
email: 邮箱格式错误 email: 邮箱格式错误
captcha: 验证码填写错误 captcha: 验证码填写错误
user: 用户不存在哦 user: 用户不存在
password: 密码不对哦~ password: 密码错误
logout: logout:
success: 登出成功~ success: 登出成功
fail: 并没有有效的 session fail: 未找到已保存的登录信息
nickname: 昵称 nickname: 昵称
player-name: 游戏内角色名
email: Email email: Email
identification: Email 或角色名 identification: Email 或角色名
password: 密码 password: 密码
captcha: 请输入验证码 captcha: 请输入验证码
change-captcha: 点击以更换图片 change-captcha: 点击以更换图片
login-link: 有账号?登录 login-link: 有账号?点击登录
forgot-link: 忘记密码? forgot-link: 忘记密码?
register-link: 注册新账号 register-link: 注册新账号

View File

@ -1,7 +1,10 @@
http: http:
msg-403: 没有权限查看此页面 msg-403: 没有权限查看此页面
msg-404: 这里啥都没有哦 msg-404: 这里啥都没有哦
msg-500: 内部服务器错误,请稍后再试
msg-503: 网站维护中 msg-503: 网站维护中
method-not-allowed: 不允许的 HTTP 请求方法
csrf-token-mismatch: Token 不正确,请尝试刷新页面
general: general:
title: 出现错误 title: 出现错误
@ -10,9 +13,12 @@ exception:
code: '错误码::code' code: '错误码::code'
detail: '详细信息::msg' detail: '详细信息::msg'
message: | message: |
如果是访客,这说明网站程序出现了一些错误,请稍后再试或联系站长。 如果是访客,这说明网站程序出现了一些错误,请稍后再试或联系站长。
如果您是站长,请开启 .env 中的 APP_DEBUG 以查看详细信息。 如果你是站长,那么请开启 .env 中的 APP_DEBUG 查看详细信息。
plugins: plugins:
duplicate: 【插件定义重复】:dir1 目录下的插件与 :dir2 目录下的插件使用了相同的 name 定义并造成了冲突。请检查您的插件目录,移除其中一个插件或者使用不同的 name 属性。 duplicate: 【插件定义重复】:dir1 目录下的插件与 :dir2 目录下的插件使用了相同的 name 定义并造成了冲突。请检查你的插件目录,移除其中一个插件或者使用不同的 name 属性。
directory: 配置文件 .env 中指定的插件加载目录PLUGINS_DIR不存在或无法打开请检查您的配置。错误信息:msg directory: 配置文件 .env 中指定的插件加载目录PLUGINS_DIR不存在或无法打开请检查你的配置。错误信息:msg
cipher:
unsupported: 不支持的密码加密方式 `:cipher`,请检查你的 .env 配置文件

View File

@ -19,7 +19,6 @@ plugin-market: 插件市场
plugin-configs: 插件配置 plugin-configs: 插件配置
customize: 个性化 customize: 个性化
options: 站点配置 options: 站点配置
import-v2: 导入数据
score-options: 积分配置 score-options: 积分配置
check-update: 检查更新 check-update: 检查更新
download-update: 下载更新 download-update: 下载更新

View File

@ -1,16 +1,19 @@
features: features:
multi-player: first:
icon: fa-users
name: 多角色 name: 多角色
desc: 一个账户可绑定多个游戏角色 desc: 一个账户可绑定多个游戏角色
sharing: second:
icon: fa-share-alt
name: 分享 name: 分享
desc: 浏览皮肤库,添加喜爱的皮肤并与好友分享 desc: 浏览皮肤库,添加喜爱的皮肤并与好友分享
free: third:
icon: fa-cloud
name: 永久免费 name: 永久免费
desc: 我们承诺永不收取任何费用 desc: 我们承诺永不收取任何费用
introduction: :sitename 提供 Minecraft 角色皮肤的上传以及托管服务。配合 CustomSkinLoader 等换肤 MOD您可以为您的游戏角色设置皮肤与披风,并让其他玩家在游戏中看到。 introduction: :sitename 提供 Minecraft 角色皮肤的上传以及托管服务。配合 CustomSkinLoader 等换肤 MOD你可以为你的游戏角色设置皮肤与披风,并让其他玩家在游戏中看到。
start: 开始使用 start: 开始使用

View File

@ -1,32 +1,24 @@
/*!
* Blessing Skin Chinese Translations
*
* @see https://github.com/printempw/blessing-skin-server
* @author printempw <h@prinzeugen.net>
*
* NOTE: this file must be saved in UTF-8 encoding.
*/
(function ($) { (function ($) {
'use strict'; 'use strict';
$.locales['zh_CN'] = { $.locales['zh_CN'] = {
auth: { auth: {
// Login // Login
emptyIdentification: '你还没有填写邮箱/角色名哦', emptyIdentification: '邮箱/角色名不能为空',
emptyPassword: '密码要好好填哦', emptyPassword: '密码不能为空',
emptyCaptcha: '你还没有填写验证码哦', emptyCaptcha: '验证码不能为空',
login: '登录', login: '登录',
loggingIn: '登录中', loggingIn: '登录中',
tooManyFails: '你尝试的次数太多啦,请输入验证码', tooManyFails: '你尝试的次数太多啦,请输入验证码',
// Register // Register
emptyEmail: '你还没有填写邮箱哦', emptyEmail: '邮箱不能为空',
invalidEmail: '邮箱格式不正确', invalidEmail: '邮箱格式不正确',
invalidPassword: '无效的密码。密码长度应该大于 8 并小于 32。', invalidPassword: '密码长度应该大于 8 并小于 32 个字符',
emptyConfirmPwd: '确认密码不能为空', emptyConfirmPwd: '确认密码不能为空',
invalidConfirmPwd: '密码和确认的密码不一样诶?', invalidConfirmPwd: '确认密码不一致',
emptyNickname: '你还没有填写昵称哦', emptyNickname: '昵称不能为空',
emptyPlayerName: '角色名不能为空',
register: '注册', register: '注册',
registering: '注册中', registering: '注册中',
@ -40,8 +32,9 @@
// Like // Like
addToCloset: '添加至衣柜', addToCloset: '添加至衣柜',
removeFromCloset: '从衣柜中移除', removeFromCloset: '从衣柜中移除',
setItemName: '给你的皮肤起个名字吧~', setItemName: '为收藏的衣柜物品命名:',
emptyItemName: '你还没有填写要收藏的材质名称啊', applyNotice: '收藏后可以在我的衣柜里将皮肤应用至角色',
emptyItemName: '收藏的衣柜物品名称不能为空',
anonymous: '请先登录', anonymous: '请先登录',
private: '私密', private: '私密',
@ -60,19 +53,22 @@
}, },
// Preview // Preview
badSkinSize: '所选皮肤文件的尺寸不对哦', badSkinSize: '所选的皮肤文件尺寸不符合要求',
badCapeSize: '所选披风文件的尺寸不对哦', badCapeSize: '所选的披风文件尺寸不符合要求',
// Rename // Rename
setNewTextureName: '请输入新的材质名称:', setNewTextureName: '请输入新的材质名称:',
emptyNewTextureName: '你还没有输入新名称啊', emptyNewTextureName: '材质名称不能为空',
// Change Model
setNewTextureModel: '请选择新的材质适用模型:',
// Upload // Upload
emptyTextureName: '给你的材质起个名字吧', emptyTextureName: '材质名称不能为空',
emptyTextureType: '请选择材质的类型', emptyTextureType: '请选择材质的类型',
emptyUploadFile: '你还没有上传任何文件哦', emptyUploadFile: '你还没有选择任何文件',
encodingError: '错误:这张图片编码不对哦', encodingError: '错误:图片文件编码不符合要求',
fileExtError: '错误:皮肤文件必须为 PNG 格式', fileExtError: '错误:材质文件必须为 PNG 格式',
upload: '确认上传', upload: '确认上传',
uploading: '上传中', uploading: '上传中',
redirecting: '正在跳转...', redirecting: '正在跳转...',
@ -91,7 +87,7 @@
timeUnitMin: '分钟', timeUnitMin: '分钟',
// Closet // Closet
emptyClosetMsg: '<p>衣柜里啥都没有哦~</p><p>去<a href=":url">皮肤库</a>看看吧~</p>', emptyClosetMsg: '<p>衣柜里什么都没有哦……</p><p>去<a href=":url">皮肤库</a>看看吧</p>',
renameItem: '重命名物品', renameItem: '重命名物品',
removeItem: '从衣柜中移除', removeItem: '从衣柜中移除',
setAsAvatar: '设为头像', setAsAvatar: '设为头像',
@ -99,27 +95,27 @@
switch2dPreview: '切换 2D 预览', switch2dPreview: '切换 2D 预览',
switch3dPreview: '切换 3D 预览', switch3dPreview: '切换 3D 预览',
removeFromClosetNotice: '确定要从衣柜中移除此材质吗?', removeFromClosetNotice: '确定要从衣柜中移除此材质吗?',
emptySelectedPlayer: '你还没有选择角色哦', emptySelectedPlayer: '请选择要应用选中材质的角色',
emptySelectedTexture: '你还没有选择要应用的材质哦', emptySelectedTexture: '请选择要应用的材质',
renameClosetItem: '请输入此衣柜物品的新名称:', renameClosetItem: '请输入此衣柜物品的新名称:',
// Player // Player
changePlayerName: '请输入角色名:', changePlayerName: '请输入角色名:',
emptyPlayerName: '你还没有填写名称哦', emptyPlayerName: '角色名不能为空',
clearTexture: '确定要重置该用户的皮肤/披风吗?', clearTexture: '确定要重置该用户的皮肤/披风吗?',
deletePlayer: '真的要删除该玩家吗?', deletePlayer: '真的要删除该角色吗?',
deletePlayerNotice: '这将是永久性的删除', deletePlayerNotice: '这将是永久性的删除',
chooseClearTexture: '选择要删除的材质类型', chooseClearTexture: '选择要删除的材质类型',
noClearChoice: '您还没选择要删除的材质类型', noClearChoice: '选择要删除的材质类型',
// Profile // Profile
setAvatar: '确定要将此材质设置为用户头像吗?', setAvatar: '确定要将此材质设置为用户头像吗?',
setAvatarNotice: '将会自动截取皮肤头部', setAvatarNotice: '将会自动截取皮肤头部',
emptyNewNickName: '你还没有填写新昵称啊', emptyNewNickName: '昵称不能为空',
changeNickName: '确定要将昵称设置为 :new_nickname 吗?', changeNickName: '确定要将昵称设置为 :new_nickname 吗?',
emptyPassword: '原密码不能为空', emptyPassword: '原密码不能为空',
emptyNewPassword: '新密码要好好填哦', emptyNewPassword: '新密码不能为空',
emptyNewEmail: '你还没有填写新邮箱啊', emptyNewEmail: '邮箱不能为空',
changeEmail: '确定要将用户邮箱更改为 :new_email 吗?', changeEmail: '确定要将用户邮箱更改为 :new_email 吗?',
emptyDeletePassword: '请先输入当前用户密码' emptyDeletePassword: '请先输入当前用户密码'
}, },
@ -132,11 +128,12 @@
setAdmin: '设为管理员', setAdmin: '设为管理员',
unsetAdmin: '解除管理员', unsetAdmin: '解除管理员',
deleteUser: '删除用户', deleteUser: '删除用户',
cannotDeleteAdmin: '你不能删除管理员账号哦', cannotDeleteAdmin: '你没有权限删除管理员账号',
cannotDeleteSuperAdmin: '超级管理员账号不能被这样删除的啦', cannotDeleteSuperAdmin: '超级管理员账号不能被删除',
changeEmail: '修改邮箱', changeEmail: '修改邮箱',
changeNickName: '修改昵称', changeNickName: '修改昵称',
changePassword: '更改密码', changePassword: '更改密码',
changeVerification: '修改邮箱验证状态',
newUserEmail: '请输入新邮箱:', newUserEmail: '请输入新邮箱:',
newUserNickname: '请输入新昵称:', newUserNickname: '请输入新昵称:',
newUserPassword: '请输入新密码:', newUserPassword: '请输入新密码:',
@ -151,6 +148,10 @@
admin: '管理员', admin: '管理员',
superAdmin: '超级管理员', superAdmin: '超级管理员',
// Verification
unverified: '未验证',
verified: '已验证',
// Players // Players
textureType: '材质类型', textureType: '材质类型',
skin: '皮肤(:model 模型)', skin: '皮肤(:model 模型)',
@ -165,21 +166,31 @@
changePlayerOwner: '请输入此角色要让渡至的用户 UID', changePlayerOwner: '请输入此角色要让渡至的用户 UID',
deletePlayerNotice: '真的要删除此角色吗?此操作不可恢复', deletePlayerNotice: '真的要删除此角色吗?此操作不可恢复',
targetUser: '目标用户::nickname', targetUser: '目标用户::nickname',
noSuchUser: '没有这个用户哦~', noSuchUser: '目标用户不存在',
changePlayerNameNotice: '请输入新的角色名:', changePlayerNameNotice: '请输入新的角色名:',
emptyPlayerName: '您还没填写角色名呢', emptyPlayerName: '角色名不能为空',
// Plugins // Plugins
configurePlugin: '插件配置', pluginTitle: '插件',
noPluginConfigNotice: '插件已被禁用或无配置页', pluginAuthor: '作者',
deletePlugin: '删除插件', pluginVersion: '版本',
pluginName: '插件标识',
pluginOperations: '操作',
pluginDescription: '描述',
pluginDependencies: '依赖关系',
pluginEnabled: '已启用',
enablePlugin: '启用',
disablePlugin: '禁用',
configurePlugin: '配置',
installPlugin: '安装',
pluginInstalling: '正在安装...',
updatePlugin: '更新',
pluginUpdating: '正在更新...',
confirmUpdate: '确定将「:plugin」从 :old 升级至 :new',
deletePlugin: '删除',
confirmDeletion: '真的要删除这个插件吗?',
noDependencies: '无要求', noDependencies: '无要求',
whyDependencies: '为什么会这样?', whyDependencies: '为什么会这样?',
statusEnabled: '已启用',
statusDisabled: '已禁用',
enablePlugin: '启用插件',
disablePlugin: '禁用插件',
confirmDeletion: '真的要删除这个插件吗?',
noDependenciesNotice: '此插件没有声明任何依赖关系,这代表它有可能并不兼容此版本的 Blessing Skin请将此插件升级至可能的最新版本。强行启用可能导致无法预料的后果。你确定要启用此插件吗', noDependenciesNotice: '此插件没有声明任何依赖关系,这代表它有可能并不兼容此版本的 Blessing Skin请将此插件升级至可能的最新版本。强行启用可能导致无法预料的后果。你确定要启用此插件吗',
// Update // Update
@ -190,7 +201,7 @@
general: { general: {
skin: '皮肤', skin: '皮肤',
cape: '披风', cape: '披风',
fatalError: '严重错误(请联系作者)', fatalError: '严重错误<small>(提问前请先查阅 <a target="_blank" href="https://github.com/printempw/blessing-skin-server/wiki/FAQ">常见问题</a></small>',
confirmLogout: '确定要登出吗?', confirmLogout: '确定要登出吗?',
confirm: '确定', confirm: '确定',
cancel: '取消', cancel: '取消',
@ -263,7 +274,7 @@
uploadBatch: '批量上传', uploadBatch: '批量上传',
uploadExtra: '表单数据上传' uploadExtra: '表单数据上传'
}, },
dropZoneTitle: '拖拽文件到这里 &hellip;<br>支持多文件同时上传', dropZoneTitle: '拖拽文件到这里 &hellip;',
dropZoneClickTitle: '<br>(或点击{files}按钮选择文件)', dropZoneClickTitle: '<br>(或点击{files}按钮选择文件)',
fileActionSettings: { fileActionSettings: {
removeTitle: '删除文件', removeTitle: '删除文件',

View File

@ -12,7 +12,7 @@ homepage:
description: 所使用的图像必须具有相同的宽度和高度(留空以使用默认图标) description: 所使用的图像必须具有相同的宽度和高度(留空以使用默认图标)
copyright_prefer: copyright_prefer:
title: 程序版权信息 title: 程序版权信息
description: 对于任何恶意修改页面<b>右下角</b>的版权信息(包括不限于删除、修改作者信息、修改链接指向)的用户,作者保留对其追究责任的权力。 description: 每种支持的语言都可以对应不同的程序版权信息,如果想要编辑某种特定语言下的版权信息,请在右上角切换至该语言后再提交修改。<b>对于任何恶意修改页面右下角的版权信息(包括不限于删除、修改作者信息、修改链接指向)的用户,作者保留对其追究责任的权利。</b>
copyright_text: copyright_text:
title: 自定义版权文字 title: 自定义版权文字
description: 自定义版权文字内可使用占位符,<code>{site_name}</code> 将会被自动替换为站点名称,<code>{site_url}</code> 会被替换为站点地址。每种支持的语言都可以对应不同的自定义版权文字,如果想要编辑某种特定语言下的版权文字,请在右上角切换至该语言后再提交修改。 description: 自定义版权文字内可使用占位符,<code>{site_name}</code> 将会被自动替换为站点名称,<code>{site_url}</code> 会被替换为站点地址。每种支持的语言都可以对应不同的自定义版权文字,如果想要编辑某种特定语言下的版权文字,请在右上角切换至该语言后再提交修改。
@ -75,6 +75,12 @@ general:
user_can_register: user_can_register:
title: 开放注册 title: 开放注册
label: 任何人都可以注册 label: 任何人都可以注册
register_with_player_name:
title: 使用角色名注册
label: 注册时要求填写游戏内角色名
require_verification:
title: 邮箱验证
label: 用户必须验证邮箱后才能使用皮肤托管等功能
regs_per_ip: 每个 IP 限制注册数 regs_per_ip: 每个 IP 限制注册数
ip_get_method: ip_get_method:
title: IP 获取方式 title: IP 获取方式
@ -103,6 +109,13 @@ general:
title: 失效材质 title: 失效材质
label: 自动删除失效材质 label: 自动删除失效材质
hint: 自动从皮肤库中删除文件不存在的材质记录 hint: 自动从皮肤库中删除文件不存在的材质记录
allow_downloading_texture:
title: 直接下载材质
label: 允许用户直接下载皮肤库中材质的原始文件
texture_name_regexp:
title: 材质名称规则
hint: 皮肤库上传材质时名称的正则表达式。留空表示允许使用除半角单双引号、反斜杠以外的任意字符。
placeholder: 正则表达式,不懂别乱填
comment_script: comment_script:
title: 评论代码 title: 评论代码
description: 评论代码内可使用占位符,<code>{tid}</code> 将会被自动替换为材质的 id<code>{name}</code> 会被替换为材质名称,<code>{url}</code> 会被替换为当前页面地址。 description: 评论代码内可使用占位符,<code>{tid}</code> 将会被自动替换为材质的 id<code>{name}</code> 会被替换为材质名称,<code>{url}</code> 会被替换为当前页面地址。
@ -129,20 +142,10 @@ resources:
title: 资源地址 title: 资源地址
label: 自动判断资源文件地址 label: 自动判断资源文件地址
description: 根据当前 URL 自动加载资源文件,如果关闭则将根据「站点地址」填写的内容加载。如果出现 CDN 回源问题请关闭 description: 根据当前 URL 自动加载资源文件,如果关闭则将根据「站点地址」填写的内容加载。如果出现 CDN 回源问题请关闭
return_200_when_notfound: return_204_when_notfound:
title: HTTP 响应码 title: HTTP 响应码
label: 请求不存在的角色时返回 200 而不是 404 label: 请求不存在的角色时返回 204 而不是 404
description: 如果你的 CDN 不缓存 404 页面,请打开此项。否则大量对不存在角色的 Profile 请求会加重站点负载。 description: 如果你的 CDN 不缓存 404 页面,请打开此项。否则大量对不存在角色的 Profile 请求会加重站点负载。
cache_expire_time: cache_expire_time:
title: 缓存失效时间 title: 缓存失效时间
hint: 秒数86400 = 一天31536000 = 一年 hint: 秒数86400 = 一天31536000 = 一年
update:
title: 更新选项
check_update:
title: 检查更新
label: 自动检查更新并提示
update_source:
title: 更新源
description: 可用的更新源列表可以在这里查看:<a href="https://github.com/printempw/blessing-skin-server/wiki/%E6%9B%B4%E6%96%B0%E6%BA%90%E5%88%97%E8%A1%A8">@GitHub Wiki</a>

View File

@ -1,11 +1,12 @@
database: database:
connection-error: 无法连接至 :type 服务器,请检查你的配置。服务器返回的信息::msg connection-error: 无法连接至 :type 服务器,请检查你的配置。服务器返回的信息::msg
connection-success: 成功连接至 :type 服务器 [:server] ,点击下一步以开始安装。 connection-success: 成功连接至 :type 服务器 [:server] ,点击下一步以开始安装。
table-already-exists: 检测到目标数据库中已存在如下数据表 :tables它们与本程序即将创建的数据表名称冲突为了避免原有数据被覆盖请手动删除它们或者为本程序选择一个不同的数据表前缀。 table-already-exists: 检测到目标数据库中已存在如下数据表 :tables它们与本程序即将创建的数据表名称冲突为了避免原有数据被覆盖请手动删除它们或者为本程序选择一个不同的数据表前缀。
file: file:
permission-error: textures 文件夹创建失败,请确认目录权限是否正确,或者手动放置一个。 permission-error: textures 文件夹创建失败,请确认目录权限是否正确,或者手动放置一个。
no-dot-env: 找不到配置文件,请将 .env.example 重命名至 .env 并仔细阅读安装指南。 no-dot-env: 找不到配置文件,请将 .env.example 重命名至 .env 并仔细阅读安装指南。
dot-env-no-read-permission: 无法读取 .env 配置文件,请检查文件权限。
permissions: permissions:
storage: 无法写入 storage 目录,请检查目录权限是否正确 storage: 无法写入 storage 目录,请检查目录权限是否正确
@ -26,7 +27,7 @@ updates:
title: 还差一小步 title: 还差一小步
text: | text: |
欢迎升级至 Blessing Skin Server v:version 欢迎升级至 Blessing Skin Server v:version
我们需要升级的数据库,点击下一步以继续。 我们需要升级的数据库,点击下一步以继续。
button: 下一步 button: 下一步
success: success:
@ -46,12 +47,12 @@ wizard:
info: info:
title: 填写信息 title: 填写信息
button: 开始安装 button: 开始安装
text: 需要填写一些基本信息。无需担心填错,这些信息以后可以再次修改。 text: 需要填写一些基本信息。无需担心填错,这些信息以后可以再次修改。
admin-email: 管理员邮箱 admin-email: 管理员邮箱
admin-notice: 这是<b>唯一</b>的超级管理员账号,可 添加/取消 其他管理员。 admin-notice: 这是<b>唯一</b>的超级管理员账号,可添加或移除其他管理员。
password: 密码 password: 密码
pwd-notice: <b>重要:</b>将需要此密码来登录管理皮肤站,请将其保存在安全的位置。 pwd-notice: <b>重要:</b>将需要此密码来登录管理皮肤站,请将其保存在安全的位置。
confirm-pwd: 重复密码 confirm-pwd: 重复密码
site-name: 站点名称 site-name: 站点名称
site-name-notice: 将会显示在首页以及标题栏 site-name-notice: 将会显示在首页以及标题栏
@ -60,4 +61,8 @@ wizard:
finish: finish:
title: 安装成功! title: 安装成功!
text: Blessing Skin Server 安装完成。您是否还沉浸在愉悦的安装过程中?很遗憾,一切皆已完成! :) text: Blessing Skin Server 安装完成。你是否还沉浸在愉悦的安装过程中?很遗憾,一切皆已完成! :)
integrity-check:
title: 安装不完全
description: 由于某些神秘的原因,我们无法自动完成数据库的更新。<a target="_blank" href="https://github.com/printempw/blessing-skin-server/wiki/%E6%89%8B%E5%8A%A8%E5%AE%89%E8%A3%85-Blessing-Skin#-%E5%8D%87%E7%BA%A7%E8%87%B3-bs-v350">了解详情</a>

View File

@ -24,6 +24,7 @@ item:
steve: Steve steve: Steve
alex: Alex alex: Alex
cape: (披风) cape: (披风)
apply: 立即使用
remove-from-closet: 从衣柜中移除 remove-from-closet: 从衣柜中移除
add-to-closet: 添加至衣柜 add-to-closet: 添加至衣柜
anonymous: 请先登录 anonymous: 请先登录
@ -36,30 +37,31 @@ show:
detail: 详细信息 detail: 详细信息
name: 名称 name: 名称
edit-name: 修改名称 edit: 修改
model: 适用模型 model: 适用模型
download-raw: 右键另存为即可下载原始皮肤文件 download-raw: 右键另存为即可下载原始皮肤文件
size: 文件大小 size: 文件大小
uploader: 上传者 uploader: 上传者
upload-at: 上传日期 upload-at: 上传日期
manage-panel: 管理面板 manage-panel:
delete-texture: 删除材质 title: 管理面板
notice: 材质设为隐私或被删除后将会从每一个收藏者的衣柜中移除。 notice: 你可以将此材质设为隐私或删除。这将会使此材质从每一个收藏者的衣柜中移除。
notice-admin: 你可以将此材质设为隐私或删除。这将会使此材质从每一个收藏者的衣柜中移除。
comment: 评论区 comment: 评论区
comment-not-available: 本站未开启评论服务 comment-not-available: 本站未开启评论服务
delete-texture: 删除材质
deleted: 请求的材质文件已经被删除 deleted: 请求的材质文件已经被删除
contact-admin: 请联系管理员删除该条目 contact-admin: 请联系管理员删除该条目
private: 请求的材质已经设为私,仅上传者和管理员可查看 private: 请求的材质已经设为,仅上传者和管理员可查看
upload: upload:
title: 上传材质 title: 上传材质
texture-name: 材质名称 texture-name: 材质名称
name-rule: 材质名称应该小于 32 个字节且不能包含奇怪的符号 name-rule: 材质名称应该小于 32 个字节且不能包含奇怪的符号
name-rule-regexp: 本站已应用特殊的名称规则::regexp
texture-type: 材质类型 texture-type: 材质类型
select-file: 选择文件 select-file: 选择文件
private-score-notice: 私密材质将会消耗更多的积分:每 KB 存储空间 :score 积分 private-score-notice: 私密材质将会消耗更多的积分:每 KB 存储空间 :score 积分
@ -68,11 +70,11 @@ upload:
button: 确认上传 button: 确认上传
type-error: 文件格式不对哦 type-error: 文件格式不符合要求,请检查你的材质文件
invalid-size: 不是有效的 :type 文件(宽 :width高 :height invalid-size: 不是有效的 :type 文件(宽 :width高 :height
invalid-hd-skin: 不是有效的高清皮肤(宽和高不是 32 的整数倍) invalid-hd-skin: 不是有效的高清皮肤(宽和高不是 32 的整数倍)
lack-score: 积分不够啦 lack-score: 积分不
repeated: 已经有人上传过这个材质了,直接添加到衣柜使用吧~ repeated: 已经有人上传过这个材质了,直接添加到衣柜使用吧~
success: 材质 :name 上传成功 success: 材质 :name 上传成功
@ -88,5 +90,9 @@ privacy:
rename: rename:
success: 材质名称已被成功设置为 :name success: 材质名称已被成功设置为 :name
no-permission: 你不是这个材质的上传者哦 model:
success: 材质的适用模型已被修改为 :model
duplicate: 已经有人上传过适用于该模型的相同材质了直接去皮肤库收藏使用吧TID:tid
no-permission: 你没有权限修改此材质
non-existent: 材质不存在 non-existent: 材质不存在

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