Add plugin marketplace
This commit is contained in:
parent
f25fdad9dc
commit
6b4812b251
|
|
@ -6,7 +6,6 @@ git:
|
|||
cache:
|
||||
directories:
|
||||
- vendor
|
||||
- plugins
|
||||
- node_modules
|
||||
|
||||
env:
|
||||
|
|
|
|||
156
app/Http/Controllers/MarketController.php
Normal file
156
app/Http/Controllers/MarketController.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Exception;
|
||||
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 marketData()
|
||||
{
|
||||
$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 $plugins;
|
||||
}
|
||||
|
||||
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->isNotEmpty(),
|
||||
'plugins' => $pluginsHaveUpdate->values()->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 (app()->environment('testing') || ! $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', []);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ use App\Services\PluginManager;
|
|||
|
||||
class PluginController extends Controller
|
||||
{
|
||||
public function config($name, Request $request)
|
||||
public function config($name)
|
||||
{
|
||||
$plugin = plugin($name);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 market.
|
||||
|
|
||||
*/
|
||||
'registry' => menv('PLUGINS_REGISTRY', 'https://coding.net/u/printempw/p/bs-plugins-archive/git/raw/master/plugins.json'),
|
||||
];
|
||||
|
|
|
|||
207
resources/assets/src/components/admin/Market.vue
Normal file
207
resources/assets/src/components/admin/Market.vue
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<template>
|
||||
<section class="content">
|
||||
<vue-good-table
|
||||
:rows="plugins"
|
||||
:columns="columns"
|
||||
:search-options="tableOptions.search"
|
||||
:pagination-options="tableOptions.pagination"
|
||||
styleClass="vgt-table striped"
|
||||
>
|
||||
<template slot="table-row" slot-scope="props">
|
||||
<span v-if="props.column.field === 'title'">
|
||||
<strong>{{ props.formattedRow[props.column.field] }}</strong>
|
||||
<div>{{ props.row.name }}</div>
|
||||
</span>
|
||||
<span v-else-if="props.column.field === 'dependencies'">
|
||||
<span
|
||||
v-if="props.row.dependencies.requirements.length === 0"
|
||||
><i v-t="'admin.noDependencies'"></i></span>
|
||||
<div v-else>
|
||||
<span
|
||||
v-for="(semver, dep) in props.row.dependencies.requirements"
|
||||
:key="dep"
|
||||
class="label"
|
||||
:class="`bg-${dep in props.row.dependencies.unsatisfiedRequirements ? 'red' : 'green'}`"
|
||||
>
|
||||
{{ dep }}: {{ semver }}
|
||||
<br>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<span v-else-if="props.column.field === 'operations'">
|
||||
<template v-if="props.row.installed">
|
||||
<button
|
||||
v-if="props.row.update_available"
|
||||
class="btn btn-success btn-sm"
|
||||
@click="updatePlugin(props.row)"
|
||||
:disabled="installing === props.row.name"
|
||||
>
|
||||
<template v-if="installing === props.row.name">
|
||||
<i class="fas fa-spinner fa-spin"></i> {{ $t('admin.pluginUpdating') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="fas fa-sync-alt"></i> {{ $t('admin.updatePlugin') }}
|
||||
</template>
|
||||
</button>
|
||||
<button v-else-if="props.row.enabled" class="btn btn-primary btn-sm" disabled>
|
||||
<i class="fas fa-check"></i> {{ $t('admin.statusEnabled') }}
|
||||
</button>
|
||||
<button v-else class="btn btn-primary btn-sm" @click="enablePlugin(props.row)">
|
||||
<i class="fas fa-plug"></i> {{ $t('admin.enablePlugin') }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-default btn-sm"
|
||||
@click="installPlugin(props.row)"
|
||||
:disabled="installing === props.row.name"
|
||||
>
|
||||
<template v-if="installing === props.row.name">
|
||||
<i class="fas fa-spinner fa-spin"></i> {{ $t('admin.pluginInstalling') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="fas fa-download"></i> {{ $t('admin.installPlugin') }}
|
||||
</template>
|
||||
</button>
|
||||
</span>
|
||||
<span v-else v-text="props.formattedRow[props.column.field]" />
|
||||
</template>
|
||||
</vue-good-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { VueGoodTable } from 'vue-good-table';
|
||||
import 'vue-good-table/dist/vue-good-table.min.css';
|
||||
import toastr from 'toastr';
|
||||
import { swal } from '../../js/notify';
|
||||
|
||||
export default {
|
||||
name: 'Market',
|
||||
components: {
|
||||
VueGoodTable
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
plugins: [],
|
||||
columns: [
|
||||
{ field: 'title', label: this.$t('admin.pluginTitle') },
|
||||
{
|
||||
field: 'description',
|
||||
label: this.$t('admin.pluginDescription'),
|
||||
sortable: false,
|
||||
width: '37%'
|
||||
},
|
||||
{ field: 'author', label: this.$t('admin.pluginAuthor') },
|
||||
{
|
||||
field: 'version',
|
||||
label: this.$t('admin.pluginVersion'),
|
||||
sortable: false,
|
||||
globalSearchDisabled: true
|
||||
},
|
||||
{
|
||||
field: 'dependencies',
|
||||
label: this.$t('admin.pluginDependencies'),
|
||||
sortable: false,
|
||||
globalSearchDisabled: true
|
||||
},
|
||||
{
|
||||
field: 'operations',
|
||||
label: this.$t('admin.operationsTitle'),
|
||||
sortable: false,
|
||||
globalSearchDisabled: true
|
||||
},
|
||||
],
|
||||
tableOptions: {
|
||||
search: {
|
||||
enabled: true,
|
||||
placeholder: this.$t('vendor.datatable.search')
|
||||
},
|
||||
pagination: {
|
||||
enabled: true,
|
||||
nextLabel: this.$t('vendor.datatable.next'),
|
||||
prevLabel: this.$t('vendor.datatable.prev'),
|
||||
rowsPerPageLabel: this.$t('vendor.datatable.rowsPerPage'),
|
||||
allLabel: this.$t('vendor.datatable.all'),
|
||||
ofLabel: this.$t('vendor.datatable.of')
|
||||
}
|
||||
},
|
||||
installing: '',
|
||||
};
|
||||
},
|
||||
beforeMount() {
|
||||
this.fetchData();
|
||||
},
|
||||
methods: {
|
||||
async fetchData() {
|
||||
this.plugins = await this.$http.get('/admin/plugins/market-data');
|
||||
},
|
||||
async installPlugin({ name, originalIndex }) {
|
||||
this.installing = name;
|
||||
|
||||
const { errno, msg } = await this.$http.post(
|
||||
'/admin/plugins/market/download',
|
||||
{ name }
|
||||
);
|
||||
if (errno === 0) {
|
||||
toastr.success(msg);
|
||||
this.plugins[originalIndex].update_available = false;
|
||||
this.plugins[originalIndex].installed = true;
|
||||
} else {
|
||||
swal({ type: 'warning', text: msg });
|
||||
}
|
||||
|
||||
this.installing = '';
|
||||
},
|
||||
async updatePlugin(plugin) {
|
||||
const { dismiss } = await swal({
|
||||
text: this.$t('admin.confirmUpdate', { plugin: plugin.title, old: plugin.installed, new: plugin.version }),
|
||||
type: 'warning',
|
||||
showCancelButton: true
|
||||
});
|
||||
if (dismiss) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.installPlugin(plugin);
|
||||
},
|
||||
async enablePlugin({ name, dependencies: { requirements }, originalIndex }) {
|
||||
if (requirements.length === 0) {
|
||||
const { dismiss } = await swal({
|
||||
text: this.$t('admin.noDependenciesNotice'),
|
||||
type: 'warning',
|
||||
showCancelButton: true
|
||||
});
|
||||
if (dismiss) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { errno, msg, reason } = await this.$http.post(
|
||||
'/admin/plugins/manage',
|
||||
{ action: 'enable', name }
|
||||
);
|
||||
if (errno === 0) {
|
||||
toastr.success(msg);
|
||||
this.$set(this.plugins[originalIndex], 'enabled', true);
|
||||
} else {
|
||||
const div = document.createElement('div');
|
||||
const p = document.createElement('p');
|
||||
p.textContent = msg;
|
||||
div.appendChild(p);
|
||||
const ul = document.createElement('ul');
|
||||
reason.forEach(item => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = item;
|
||||
ul.appendChild(li);
|
||||
});
|
||||
div.appendChild(ul);
|
||||
swal({
|
||||
type: 'warning',
|
||||
html: div.innerHTML.replace(/`([\w-_]+)`/g, '<code>$1</code>')
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -34,6 +34,11 @@ export default [
|
|||
component: () => import('./admin/Customization'),
|
||||
el: '#change-color'
|
||||
},
|
||||
{
|
||||
path: 'admin/plugins/market',
|
||||
component: () => import('./admin/Market'),
|
||||
el: '.content'
|
||||
},
|
||||
{
|
||||
path: 'auth/login',
|
||||
component: () => import('./auth/Login'),
|
||||
|
|
|
|||
131
resources/assets/tests/components/admin/Market.test.js
Normal file
131
resources/assets/tests/components/admin/Market.test.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import Vue from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import Market from '@/components/admin/Market';
|
||||
import { flushPromises } from '../../utils';
|
||||
import { swal } from '@/js/notify';
|
||||
|
||||
jest.mock('@/js/notify');
|
||||
|
||||
test('render dependencies', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue([
|
||||
{ name: 'a', dependencies: { requirements: [] } },
|
||||
{ name: 'b', dependencies: {
|
||||
requirements: { 'a': '^1.0.0', 'c': '^2.0.0' }, unsatisfiedRequirements: { 'c': {} }
|
||||
} }
|
||||
]);
|
||||
const wrapper = mount(Market);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('admin.noDependencies');
|
||||
expect(wrapper.find('span.label.bg-green').text()).toBe('a: ^1.0.0');
|
||||
expect(wrapper.find('span.label.bg-red').text()).toBe('c: ^2.0.0');
|
||||
});
|
||||
|
||||
test('render operation buttons', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue([
|
||||
{ name: 'a', dependencies: { requirements: [] }, installed: true, update_available: true },
|
||||
{ name: 'b', dependencies: { requirements: [] }, installed: true, enabled: true },
|
||||
{ name: 'c', dependencies: { requirements: [] }, installed: true },
|
||||
{ name: 'd', dependencies: { requirements: [] }, installed: false },
|
||||
]);
|
||||
const wrapper = mount(Market);
|
||||
await flushPromises();
|
||||
const tbody = wrapper.find('tbody');
|
||||
|
||||
expect(tbody.find('tr:nth-child(1)').text()).toContain('admin.updatePlugin');
|
||||
expect(tbody.find('tr:nth-child(2)').text()).toContain('admin.statusEnabled');
|
||||
expect(tbody.find('tr:nth-child(3)').text()).toContain('admin.enablePlugin');
|
||||
expect(tbody.find('tr:nth-child(4)').text()).toContain('admin.installPlugin');
|
||||
});
|
||||
|
||||
test('install plugin', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue([
|
||||
{ name: 'd', dependencies: { requirements: [] }, installed: false },
|
||||
]);
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({ errno: 1, msg: '1' })
|
||||
.mockResolvedValueOnce({ errno: 0, msg: '0' });
|
||||
const wrapper = mount(Market);
|
||||
await flushPromises();
|
||||
const button = wrapper.find('button');
|
||||
|
||||
button.trigger('click');
|
||||
await flushPromises();
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith(
|
||||
'/admin/plugins/market/download',
|
||||
{ name: 'd' }
|
||||
);
|
||||
|
||||
button.trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.text()).toContain('admin.enablePlugin');
|
||||
});
|
||||
|
||||
test('update plugin', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue([
|
||||
{
|
||||
name: 'a',
|
||||
version: '2.0.0',
|
||||
dependencies: { requirements: [] },
|
||||
installed: '1.0.0',
|
||||
update_available: true
|
||||
},
|
||||
]);
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({ errno: 1, msg: '1' });
|
||||
swal.mockResolvedValueOnce({ dismiss: 1 })
|
||||
.mockResolvedValue({});
|
||||
const wrapper = mount(Market);
|
||||
await flushPromises();
|
||||
const button = wrapper.find('button');
|
||||
|
||||
button.trigger('click');
|
||||
await flushPromises();
|
||||
expect(Vue.prototype.$http.post).not.toBeCalled();
|
||||
|
||||
button.trigger('click');
|
||||
await flushPromises();
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith(
|
||||
'/admin/plugins/market/download',
|
||||
{ name: 'a' }
|
||||
);
|
||||
});
|
||||
|
||||
test('enable installed plugin', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue([
|
||||
{ name: 'a', dependencies: { requirements: [] }, installed: true },
|
||||
{ name: 'b', dependencies: { requirements: {} }, installed: true },
|
||||
]);
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({ errno: 1, msg: '1', reason: ['`a<div></div>`b'] })
|
||||
.mockResolvedValue({ errno: 0, msg: '0' });
|
||||
swal.mockResolvedValueOnce({ dismiss: 1 })
|
||||
.mockResolvedValueOnce({});
|
||||
const wrapper = mount(Market);
|
||||
await flushPromises();
|
||||
const buttons = wrapper.findAll('button');
|
||||
|
||||
buttons.at(0).trigger('click');
|
||||
await flushPromises();
|
||||
expect(swal).toBeCalledWith({
|
||||
text: 'admin.noDependenciesNotice',
|
||||
type: 'warning',
|
||||
showCancelButton: true
|
||||
});
|
||||
expect(Vue.prototype.$http.post).not.toBeCalled();
|
||||
|
||||
buttons.at(0).trigger('click');
|
||||
await flushPromises();
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith(
|
||||
'/admin/plugins/manage',
|
||||
{ action: 'enable', name: 'a' }
|
||||
);
|
||||
expect(swal).toBeCalledWith({
|
||||
type: 'warning',
|
||||
html: '<p>1</p><ul><li>`a<div></div>`b</li></ul>'
|
||||
});
|
||||
|
||||
buttons.at(1).trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('admin.statusEnabled');
|
||||
});
|
||||
|
|
@ -102,23 +102,26 @@ plugins:
|
|||
version: Version
|
||||
dependencies: Dependencies
|
||||
|
||||
status:
|
||||
title: Status
|
||||
enabled: Enabled
|
||||
disabled: Disabled
|
||||
|
||||
operations:
|
||||
title: Operations
|
||||
enabled: :plugin has been enabled.
|
||||
unsatisfied:
|
||||
notice: There are unsatisfied dependencies in the plugin, therefore we can't enable it. Please install or update the plugins listed below.
|
||||
disabled: The <code>:name</code> plugin is not enabled
|
||||
version: The version of <code>:name</code> does not satisfies the constraint <code>:constraint</code>
|
||||
disabled: "The `:name` plugin is not enabled"
|
||||
version: "The version of `:name` does not satisfies the constraint `:constraint`"
|
||||
disabled: :plugin has been disabled.
|
||||
deleted: The plugin was deleted successfully.
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -271,6 +271,17 @@ admin:
|
|||
whyDependencies: What's this?
|
||||
statusEnabled: Enabled
|
||||
statusDisabled: Disabled
|
||||
pluginTitle: Plugin
|
||||
pluginAuthor: Author
|
||||
pluginVersion: Version
|
||||
pluginName: Name
|
||||
pluginDescription: Description
|
||||
pluginDependencies: Dependencies
|
||||
installPlugin: Install
|
||||
pluginInstalling: Installing...
|
||||
updatePlugin: Update
|
||||
pluginUpdating: Updating...
|
||||
confirmUpdate: Are you sure to update ":plugin" from :old to :new?
|
||||
enablePlugin: Enable
|
||||
disablePlugin: Disable
|
||||
confirmDeletion: Are you sure to delete this plugin?
|
||||
|
|
|
|||
|
|
@ -112,13 +112,21 @@ plugins:
|
|||
enabled: :plugin 已启用
|
||||
unsatisfied:
|
||||
notice: 无法启用此插件,因为其仍有未满足的依赖关系。请检查以下插件的版本,更新或安装它们:
|
||||
disabled: <code>:name</code> 插件未启用
|
||||
version: <code>:name</code> 的版本不符合要求 <code>:constraint</code>
|
||||
disabled: "`:name` 插件未启用"
|
||||
version: "`:name` 的版本不符合要求 `:constraint`"
|
||||
disabled: :plugin 已禁用
|
||||
deleted: 插件已被成功删除
|
||||
no-config-notice: 插件未安装或未提供配置页面
|
||||
not-found: 插件不存在
|
||||
|
||||
market:
|
||||
connection-error: 无法连接至插件市场源,错误信息::error
|
||||
non-existent: 插件 :plugin 不存在
|
||||
download-failed: 插件下载失败,错误信息::error
|
||||
shasum-failed: 文件校验失败,请尝试重新下载
|
||||
unzip-failed: 插件解压缩失败,错误信息::error
|
||||
install-success: 插件安装成功
|
||||
|
||||
empty: 无结果
|
||||
|
||||
update:
|
||||
|
|
|
|||
|
|
@ -267,6 +267,17 @@ admin:
|
|||
whyDependencies: 为什么会这样?
|
||||
statusEnabled: 已启用
|
||||
statusDisabled: 已禁用
|
||||
pluginTitle: 插件
|
||||
pluginAuthor: 作者
|
||||
pluginVersion: 版本
|
||||
pluginName: 插件标识
|
||||
pluginDescription: 描述
|
||||
pluginDependencies: 依赖关系
|
||||
installPlugin: 安装
|
||||
pluginInstalling: 正在安装...
|
||||
updatePlugin: 更新
|
||||
pluginUpdating: 正在更新...
|
||||
confirmUpdate: 确定将「:plugin」从 :old 升级至 :new?
|
||||
enablePlugin: 启用插件
|
||||
disablePlugin: 禁用插件
|
||||
confirmDeletion: 真的要删除这个插件吗?
|
||||
|
|
|
|||
20
resources/views/admin/market.blade.php
Normal file
20
resources/views/admin/market.blade.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
@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>
|
||||
@lang('general.plugin-market')
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
<!-- Main content -->
|
||||
<section class="content"></section>
|
||||
</div><!-- /.content-wrapper -->
|
||||
|
||||
@endsection
|
||||
|
|
@ -123,11 +123,15 @@ Route::group(['middleware' => ['auth', 'admin'], 'prefix' => 'admin'], function
|
|||
|
||||
Route::group(['prefix' => 'plugins'], function () {
|
||||
Route::get ('/data', 'PluginController@getPluginData');
|
||||
Route::redirect('/market', 'https://github.com/printempw/blessing-skin-server/wiki/Plugins');
|
||||
|
||||
Route::view('/manage', 'admin.plugins');
|
||||
Route::post('/manage', 'PluginController@manage');
|
||||
Route::any ('/config/{name}', 'PluginController@config');
|
||||
|
||||
Route::view('/market', 'admin.market');
|
||||
Route::get ('/market-data', 'MarketController@marketData');
|
||||
Route::get ('/market/check', 'MarketController@checkUpdates');
|
||||
Route::post('/market/download', 'MarketController@download');
|
||||
});
|
||||
|
||||
Route::group(['prefix' => 'update'], function () {
|
||||
|
|
|
|||
143
tests/Concerns/GeneratesFakePlugins.php
Normal file
143
tests/Concerns/GeneratesFakePlugins.php
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Concerns;
|
||||
|
||||
use ZipArchive;
|
||||
|
||||
trait GeneratesFakePlugins
|
||||
{
|
||||
/**
|
||||
* Generate fake content of a plugin's package.json.
|
||||
*
|
||||
* @param array $info
|
||||
* @return array
|
||||
*/
|
||||
protected function generateFakePlguinInfo($info = [])
|
||||
{
|
||||
return array_replace([
|
||||
'name' => str_random(10),
|
||||
'version' => '0.0.'.rand(1, 9),
|
||||
'title' => str_random(20),
|
||||
'description' => str_random(60),
|
||||
'author' => array_get($info, 'author', str_random(10)),
|
||||
'url' => 'https://'.str_random(10).'.test',
|
||||
'namespace' => str_random(10),
|
||||
'require' => [
|
||||
'blessing-skin-server' => '^3.4.0'
|
||||
]
|
||||
], $info);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate plugin information for plugins registry (with "dist" field).
|
||||
*
|
||||
* @param array $info
|
||||
* @return array
|
||||
*/
|
||||
protected function generateFakePluginsRegistryPackage($info = [])
|
||||
{
|
||||
return $this->generateFakePlguinInfo(array_replace([
|
||||
'dist' => [
|
||||
'type' => 'zip',
|
||||
'url' => 'https://plugins-registry.test/'.str_random(10).'.zip',
|
||||
'shasum' => strtolower(str_random(40))
|
||||
]
|
||||
], $info));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate fake content of a plugins registry.
|
||||
* You can also pass two arguments (name and version) as a shortcut.
|
||||
* If no argument is passed, we will randomly generate 10 fake plugins.
|
||||
*
|
||||
* @param array $plugins An array of plugin information.
|
||||
* @return string JSON encoded content.
|
||||
*/
|
||||
protected function generateFakePluginsRegistry($plugins = [])
|
||||
{
|
||||
if (func_num_args() == 2) {
|
||||
$plugins = [
|
||||
[
|
||||
'name' => func_get_arg(0),
|
||||
'version' => func_get_arg(1)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$packages = [];
|
||||
|
||||
if (count($plugins) == 0) {
|
||||
// Randomly generate 10 fake plugins
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$packages[] = $this->generateFakePluginsRegistryPackage();
|
||||
}
|
||||
} else {
|
||||
foreach ($plugins as $info) {
|
||||
$packages[] = $this->generateFakePluginsRegistryPackage($info);
|
||||
}
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
'packages' => $packages
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fake plugin in plugins directory with given information.
|
||||
*
|
||||
* @param array $info The "name" field is required.
|
||||
* @return void
|
||||
*/
|
||||
protected function generateFakePlugin($info)
|
||||
{
|
||||
$plugin_dir = base_path("plugins/{$info['name']}");
|
||||
|
||||
if (! is_dir($plugin_dir)) {
|
||||
mkdir($plugin_dir);
|
||||
}
|
||||
|
||||
// Generate fake config view
|
||||
if ($config = array_get($info, 'config')) {
|
||||
$views_path = "$plugin_dir/views";
|
||||
|
||||
if (! is_dir($views_path)) {
|
||||
mkdir($views_path);
|
||||
}
|
||||
|
||||
file_put_contents("$views_path/$config", str_random(64));
|
||||
}
|
||||
|
||||
file_put_contents("$plugin_dir/package.json", json_encode(
|
||||
$this->generateFakePlguinInfo($info)
|
||||
));
|
||||
|
||||
file_put_contents("$plugin_dir/bootstrap.php", "<?php return function () { return '{$info['name']}'; };");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fake zip archive of given plugin.
|
||||
*
|
||||
* @param array $info Plugin information.
|
||||
* @return string File path of generated zip archive.
|
||||
*/
|
||||
protected function generateFakePluginArchive($info)
|
||||
{
|
||||
$name = array_get($info, 'name');
|
||||
$version = array_get($info, 'version');
|
||||
$zipPath = storage_path("testing/{$name}_{$version}.zip");
|
||||
|
||||
if (file_exists($zipPath)) {
|
||||
unlink($zipPath);
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
$zip->addEmptyDir($name);
|
||||
$zip->addFromString("$name/package.json", json_encode(
|
||||
$this->generateFakePlguinInfo($info)
|
||||
));
|
||||
$zip->close();
|
||||
|
||||
return $zipPath;
|
||||
}
|
||||
}
|
||||
195
tests/MarketControllerTest.php
Normal file
195
tests/MarketControllerTest.php
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\Concerns\MocksGuzzleClient;
|
||||
use Tests\Concerns\GeneratesFakePlugins;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
|
||||
class MarketControllerTest extends TestCase
|
||||
{
|
||||
use MocksGuzzleClient;
|
||||
use GeneratesFakePlugins;
|
||||
|
||||
protected function setUp()
|
||||
{
|
||||
parent::setUp();
|
||||
return $this->actAs('superAdmin');
|
||||
}
|
||||
|
||||
public function testDownload()
|
||||
{
|
||||
$this->setupGuzzleClientMock();
|
||||
|
||||
// Try to download a non-existent plugin
|
||||
$this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry());
|
||||
$this->postJson('/admin/plugins/market/download', [
|
||||
'name' => 'non-existent-plugin'
|
||||
])->assertJson([
|
||||
'errno' => 1,
|
||||
'msg' => trans('admin.plugins.market.non-existent', ['plugin' => 'non-existent-plugin'])
|
||||
]);
|
||||
|
||||
// Can't download due to connection error
|
||||
$this->appendToGuzzleQueue([
|
||||
new Response(200, [], $this->generateFakePluginsRegistry('fake-test-download', '0.0.1')),
|
||||
new RequestException('Connection Error', new Request('GET', 'whatever')),
|
||||
]);
|
||||
$this->postJson('/admin/plugins/market/download', [
|
||||
'name' => 'fake-test-download'
|
||||
])->assertJson([
|
||||
'errno' => 2,
|
||||
'msg' => trans('admin.plugins.market.download-failed', ['error' => 'Connection Error'])
|
||||
]);
|
||||
|
||||
// Downloaded plugin archive was tampered
|
||||
$fakeArchive = $this->generateFakePluginArchive(['name' => 'fake-test-download', 'version' => '0.0.1']);
|
||||
$this->appendToGuzzleQueue([
|
||||
new Response(200, [], $this->generateFakePluginsRegistry('fake-test-download', '0.0.1')),
|
||||
new Response(200, [], fopen($fakeArchive, 'r')),
|
||||
]);
|
||||
$this->postJson('/admin/plugins/market/download', [
|
||||
'name' => 'fake-test-download'
|
||||
])->assertJson([
|
||||
'errno' => 3,
|
||||
'msg' => trans('admin.plugins.market.shasum-failed')
|
||||
]);
|
||||
|
||||
// Download and extract plugin
|
||||
$shasum = sha1_file($fakeArchive);
|
||||
$this->appendToGuzzleQueue([
|
||||
new Response(200, [], $this->generateFakePluginsRegistry([
|
||||
[
|
||||
'name' => 'fake-test-download',
|
||||
'version' => '0.0.1',
|
||||
'dist' => [
|
||||
'url' => 'whatever',
|
||||
'shasum' => $shasum
|
||||
]
|
||||
]
|
||||
])),
|
||||
new Response(200, [], fopen($fakeArchive, 'r')),
|
||||
]);
|
||||
$this->postJson('/admin/plugins/market/download', [
|
||||
'name' => 'fake-test-download'
|
||||
])->assertJson([
|
||||
'errno' => 0,
|
||||
'msg' => trans('admin.plugins.market.install-success')
|
||||
]);
|
||||
$this->assertTrue(is_dir(base_path('plugins/fake-test-download')));
|
||||
$this->assertTrue(empty(glob(base_path('plugins/fake-test-download_*.zip'))));
|
||||
|
||||
// Broken archive
|
||||
file_put_contents($fakeArchive, 'broken');
|
||||
$shasum = sha1_file($fakeArchive);
|
||||
$this->appendToGuzzleQueue([
|
||||
new Response(200, [], $this->generateFakePluginsRegistry([
|
||||
[
|
||||
'name' => 'fake-test-download',
|
||||
'version' => '0.0.1',
|
||||
'dist' => [
|
||||
'url' => 'whatever',
|
||||
'shasum' => $shasum
|
||||
]
|
||||
]
|
||||
])),
|
||||
new Response(200, [], fopen($fakeArchive, 'r')),
|
||||
]);
|
||||
$this->postJson('/admin/plugins/market/download', [
|
||||
'name' => 'fake-test-download'
|
||||
])->assertJson([
|
||||
'errno' => 4,
|
||||
'msg' => trans('admin.plugins.market.unzip-failed', ['error' => 19])
|
||||
]);
|
||||
}
|
||||
|
||||
public function testCheckUpdates()
|
||||
{
|
||||
$this->setupGuzzleClientMock();
|
||||
|
||||
// Not installed
|
||||
$this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry('fake-test-update', '0.0.1'));
|
||||
$this->getJson('/admin/plugins/market/check')
|
||||
->assertJson([
|
||||
'available' => false,
|
||||
'plugins' => []
|
||||
]);
|
||||
|
||||
// Generate fake plugin and refresh plugin manager
|
||||
$this->generateFakePlugin(['name' => 'fake-test-update', 'version' => '0.0.1']);
|
||||
$this->app->singleton('plugins', \App\Services\PluginManager::class);
|
||||
|
||||
// Plugin up-to-date
|
||||
$this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry('fake-test-update', '0.0.1'));
|
||||
$this->getJson('/admin/plugins/market/check')
|
||||
->assertJson([
|
||||
'available' => false,
|
||||
'plugins' => []
|
||||
]);
|
||||
|
||||
// New version available
|
||||
$this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry('fake-test-update', '2.3.3'));
|
||||
$this->getJson('/admin/plugins/market/check')
|
||||
->assertJson([
|
||||
'available' => true,
|
||||
'plugins' => [[
|
||||
'name' => 'fake-test-update'
|
||||
]]
|
||||
]);
|
||||
}
|
||||
|
||||
public function testMarketData()
|
||||
{
|
||||
$this->setupGuzzleClientMock([
|
||||
new RequestException('Connection Error', new Request('POST', 'whatever')),
|
||||
new Response(200, [], $this->generateFakePluginsRegistry()),
|
||||
]);
|
||||
|
||||
// Expected an exception, but unable to be asserted.
|
||||
$this->getJson('/admin/plugins/market-data');
|
||||
|
||||
$this->getJson('/admin/plugins/market-data')
|
||||
->assertJsonStructure([
|
||||
[
|
||||
'name',
|
||||
'title',
|
||||
'version',
|
||||
'installed',
|
||||
'description',
|
||||
'author',
|
||||
'dist',
|
||||
'dependencies'
|
||||
]
|
||||
]);
|
||||
|
||||
// Detect installed plugins
|
||||
$this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry('fake', '0.0.1'));
|
||||
$this->generateFakePlugin(['name' => 'fake', 'version' => '0.0.1']);
|
||||
$this->getJson('/admin/plugins/market-data')
|
||||
->assertJsonStructure([
|
||||
[
|
||||
'name',
|
||||
'title',
|
||||
'version',
|
||||
'installed',
|
||||
'description',
|
||||
'author',
|
||||
'dist',
|
||||
'dependencies'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
protected function tearDown()
|
||||
{
|
||||
// Clean fake plugins
|
||||
File::deleteDirectory(base_path('plugins/fake-test-download'));
|
||||
File::deleteDirectory(base_path('plugins/fake-test-update'));
|
||||
File::deleteDirectory(base_path('plugins/fake'));
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user