Add plugin marketplace

This commit is contained in:
printempw 2018-08-12 12:25:15 +08:00
parent 74d5a98f6d
commit a3e65515f6
12 changed files with 382 additions and 32 deletions

View 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', []);
}
}

View File

@ -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');

View File

@ -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');
}

View File

@ -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'),
];

View 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,
};
}

View File

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

View File

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

View File

@ -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?',

View File

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

View File

@ -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请将此插件升级至可能的最新版本。强行启用可能导致无法预料的后果。你确定要启用此插件吗',

View 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

View File

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