Add plugin marketplace
This commit is contained in:
parent
74d5a98f6d
commit
a3e65515f6
127
app/Http/Controllers/MarketController.php
Normal file
127
app/Http/Controllers/MarketController.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?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
|
||||
{
|
||||
protected $registryCache;
|
||||
|
||||
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 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;
|
||||
$client = new \GuzzleHttp\Client();
|
||||
|
||||
// Download
|
||||
try {
|
||||
$client->request('GET', $url, [
|
||||
'headers' => ['User-Agent' => config('secure.user_agent')],
|
||||
'verify' => config('secure.certificates'),
|
||||
'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) {
|
||||
$client = new \GuzzleHttp\Client();
|
||||
|
||||
try {
|
||||
$pluginsJson = $client->request('GET', config('plugins.registry'), [
|
||||
'headers' => ['User-Agent' => config('secure.user_agent')],
|
||||
'verify' => config('secure.certificates')
|
||||
])->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', []);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,16 +11,6 @@ use App\Services\PluginManager;
|
|||
|
||||
class PluginController extends Controller
|
||||
{
|
||||
/**
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function showMarket()
|
||||
{
|
||||
return redirect('/')->setTargetUrl(
|
||||
'https://github.com/printempw/blessing-skin-server/wiki/Plugins'
|
||||
);
|
||||
}
|
||||
|
||||
public function showManage()
|
||||
{
|
||||
return view('admin.plugins');
|
||||
|
|
|
|||
|
|
@ -277,20 +277,24 @@ class PluginManager
|
|||
/**
|
||||
* Get the unsatisfied requirements of plugin.
|
||||
*
|
||||
* @param string|Plugin $plugin
|
||||
* @param string|Plugin|array $plugin
|
||||
* @return array
|
||||
*/
|
||||
public function getUnsatisfiedRequirements($plugin)
|
||||
{
|
||||
if (! $plugin instanceof Plugin) {
|
||||
$plugin = $this->getPlugin($plugin);
|
||||
}
|
||||
if (is_array($plugin)) {
|
||||
$requirements = $plugin;
|
||||
} else {
|
||||
if (! $plugin instanceof Plugin) {
|
||||
$plugin = $this->getPlugin($plugin);
|
||||
}
|
||||
|
||||
if (! $plugin) {
|
||||
throw new \InvalidArgumentException('Plugin with given name does not exist.');
|
||||
}
|
||||
if (! $plugin) {
|
||||
throw new \InvalidArgumentException('Plugin with given name does not exist.');
|
||||
}
|
||||
|
||||
$requirements = $plugin->getRequirements();
|
||||
$requirements = $plugin->getRequirements();
|
||||
}
|
||||
|
||||
$unsatisfied = [];
|
||||
|
||||
|
|
@ -334,7 +338,7 @@ class PluginManager
|
|||
/**
|
||||
* Whether the plugin's requirements are satisfied.
|
||||
*
|
||||
* @param string|Plugin $plugin
|
||||
* @param string|Plugin|array $plugin
|
||||
* @return bool
|
||||
*/
|
||||
public function isRequirementsSatisfied($plugin)
|
||||
|
|
@ -347,7 +351,7 @@ class PluginManager
|
|||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getPluginsDir()
|
||||
public function getPluginsDir()
|
||||
{
|
||||
return config('plugins.directory') ?: base_path('plugins');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,4 +22,14 @@ return [
|
|||
|
|
||||
*/
|
||||
'url' => menv('PLUGINS_URL'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Plugins Market Source
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Specify where to get plugins' metadata for plugin merket.
|
||||
|
|
||||
*/
|
||||
'registry' => menv('PLUGINS_REGISTRY', 'https://coding.net/u/printempw/p/bs-plugins-archive/git/raw/master/plugins.json'),
|
||||
];
|
||||
|
|
|
|||
155
resources/assets/src/js/admin/market.js
Normal file
155
resources/assets/src/js/admin/market.js
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
'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 => `<strong>${ title }</strong>`
|
||||
},
|
||||
{
|
||||
targets: 1,
|
||||
title: trans('admin.pluginDescription'),
|
||||
data: 'description',
|
||||
orderable: false
|
||||
},
|
||||
{
|
||||
targets: 2,
|
||||
title: trans('admin.pluginAuthor'),
|
||||
data: 'author',
|
||||
orderable: false
|
||||
},
|
||||
{
|
||||
targets: 3,
|
||||
title: trans('admin.pluginVersion'),
|
||||
data: 'version',
|
||||
orderable: false
|
||||
},
|
||||
{
|
||||
targets: 4,
|
||||
data: 'dependencies',
|
||||
title: trans('admin.pluginDependencies'),
|
||||
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) {
|
||||
swal({ type: 'warning', html: 'not installed' });
|
||||
}
|
||||
|
||||
await swal({
|
||||
text: trans('admin.confirmUpdate', { plugin: data.title, old: data.installed, new: data.version }),
|
||||
type: 'warning',
|
||||
showCancelButton: true
|
||||
});
|
||||
|
||||
installPlugin(name, {
|
||||
beforeSend: () => {
|
||||
$(`#plugin-${name} .btn`).html(`<i class="fa fa-refresh fa-spin"></i> ${ trans('admin.pluginUpdating') }`).prop('disabled', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
module.exports = {
|
||||
initMarketTable,
|
||||
installPlugin,
|
||||
updatePlugin,
|
||||
};
|
||||
}
|
||||
|
|
@ -67,6 +67,7 @@ td {
|
|||
|
||||
td:first-child {
|
||||
border-left: 3px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-enabled {
|
||||
|
|
@ -77,3 +78,13 @@ td {
|
|||
border-left-color: #3c8dbc;
|
||||
}
|
||||
}
|
||||
|
||||
#market-table {
|
||||
.btn {
|
||||
float: right;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,11 +98,6 @@ customize:
|
|||
black-light: Black Light
|
||||
|
||||
plugins:
|
||||
status:
|
||||
title: Status
|
||||
enabled: Enabled
|
||||
disabled: Disabled
|
||||
|
||||
operations:
|
||||
title: Operations
|
||||
enabled: :plugin has been enabled.
|
||||
|
|
@ -115,6 +110,14 @@ plugins:
|
|||
no-config-notice: The plugin is not installed or doesn't provide configuration page.
|
||||
not-found: No such plugin.
|
||||
|
||||
market:
|
||||
connection-error: Unable to connect to the plugins registry. :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
|
||||
|
||||
update:
|
||||
|
|
|
|||
|
|
@ -181,13 +181,20 @@
|
|||
pluginTitle: 'Plugin',
|
||||
pluginAuthor: 'Author',
|
||||
pluginVersion: 'Version',
|
||||
pluginOperations: 'Operations',
|
||||
pluginDescription: 'Description',
|
||||
pluginDependencies: 'Dependencies',
|
||||
pluginEnabled: 'Enabled',
|
||||
enablePlugin: 'Enable',
|
||||
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?',
|
||||
pluginDescription: 'Description',
|
||||
pluginDependencies: 'Dependencies',
|
||||
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?',
|
||||
|
|
|
|||
|
|
@ -109,6 +109,14 @@ plugins:
|
|||
no-config-notice: 插件未安装或未提供配置页面
|
||||
not-found: 插件不存在
|
||||
|
||||
market:
|
||||
connection-error: 无法连接至插件市场源,错误信息::error
|
||||
non-existent: 插件 :plugin 不存在
|
||||
download-failed: 插件下载失败,错误信息::error
|
||||
shasum-failed: 文件校验失败,请尝试重新下载
|
||||
unzip-failed: 插件解压缩失败,错误信息::error
|
||||
install-success: 插件安装成功
|
||||
|
||||
empty: 无结果
|
||||
|
||||
update:
|
||||
|
|
|
|||
|
|
@ -183,13 +183,20 @@
|
|||
pluginTitle: '插件',
|
||||
pluginAuthor: '作者',
|
||||
pluginVersion: '版本',
|
||||
pluginOperations: '操作',
|
||||
pluginDescription: '描述',
|
||||
pluginDependencies: '依赖关系',
|
||||
pluginEnabled: '已启用',
|
||||
enablePlugin: '启用',
|
||||
disablePlugin: '禁用',
|
||||
configurePlugin: '配置',
|
||||
installPlugin: '安装',
|
||||
pluginInstalling: '正在安装...',
|
||||
updatePlugin: '更新',
|
||||
pluginUpdating: '正在更新...',
|
||||
confirmUpdate: '确定将「:plugin」从 :old 升级至 :new?',
|
||||
deletePlugin: '删除',
|
||||
confirmDeletion: '真的要删除这个插件吗?',
|
||||
pluginDescription: '描述',
|
||||
pluginDependencies: '依赖关系',
|
||||
noDependencies: '无要求',
|
||||
whyDependencies: '为什么会这样?',
|
||||
noDependenciesNotice: '此插件没有声明任何依赖关系,这代表它有可能并不兼容此版本的 Blessing Skin,请将此插件升级至可能的最新版本。强行启用可能导致无法预料的后果。你确定要启用此插件吗?',
|
||||
|
|
|
|||
26
resources/views/admin/market.tpl
Normal file
26
resources/views/admin/market.tpl
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
@extends('admin.master')
|
||||
|
||||
@section('title', trans('general.plugin-market'))
|
||||
|
||||
@section('content')
|
||||
|
||||
<!-- Content Wrapper. Contains page content -->
|
||||
<div class="content-wrapper">
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
{{ trans('general.plugin-market') }}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
<!-- Main content -->
|
||||
<section class="content">
|
||||
<div class="box">
|
||||
<div class="box-body table-bordered">
|
||||
<table id="market-table" class="table table-hover"></table>
|
||||
</div>
|
||||
</div>
|
||||
</section><!-- /.content -->
|
||||
</div><!-- /.content-wrapper -->
|
||||
|
||||
@endsection
|
||||
|
|
@ -122,14 +122,16 @@ Route::group(['middleware' => 'admin', 'prefix' => 'admin'], function ()
|
|||
Route::post('/players', 'AdminController@playerAjaxHandler');
|
||||
|
||||
Route::group(['prefix' => 'plugins'], function () {
|
||||
Route::any ('/market', 'PluginController@showMarket');
|
||||
|
||||
// Allow using POST method to get data for DataTables,
|
||||
// otherwise it may cause a "414 Request-URI Too Large" error.
|
||||
Route::any ('/data', 'PluginController@getPluginData');
|
||||
Route::get ('/manage', 'PluginController@showManage');
|
||||
Route::post('/data', 'PluginController@getPluginData');
|
||||
Route::post('/manage', 'PluginController@manage');
|
||||
Route::any ('/config/{name}', 'PluginController@config');
|
||||
|
||||
Route::get ('/market', 'MarketController@showMarket');
|
||||
Route::post('/market-data', 'MarketController@getMarketData');
|
||||
Route::post('/market/download', 'MarketController@download');
|
||||
});
|
||||
|
||||
Route::group(['prefix' => 'update'], function () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user