add two new ways to install plugin

This commit is contained in:
Pig Fang 2020-03-12 10:41:41 +08:00
parent ba757d98a8
commit 271c950ad9
10 changed files with 440 additions and 77 deletions

View File

@ -4,7 +4,10 @@ namespace App\Http\Controllers;
use App\Services\Plugin;
use App\Services\PluginManager;
use App\Services\Unzip;
use Composer\CaBundle\CaBundle;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Parsedown;
class PluginController extends Controller
@ -105,4 +108,33 @@ class PluginController extends Controller
})
->values();
}
public function upload(Request $request, PluginManager $manager, Unzip $unzip)
{
$request->validate(['file' => 'required|file|mimetypes:application/zip']);
$path = $request->file('file')->getPathname();
$unzip->extract($path, $manager->getPluginsDirs()->first());
return json(trans('admin.plugins.market.install-success'), 0);
}
public function wget(Request $request, PluginManager $manager, Unzip $unzip)
{
$data = $request->validate(['url' => 'required|url']);
$path = tempnam(sys_get_temp_dir(), 'wget-plugin');
$response = Http::withOptions([
'sink' => $path,
'verify' => CaBundle::getSystemCaRootBundlePath(),
])->get($data['url']);
if ($response->ok()) {
$unzip->extract($path, $manager->getPluginsDirs()->first());
return json(trans('admin.plugins.market.install-success'), 0);
} else {
return json(trans('admin.download.errors.download', ['error' => $response->status()]), 1);
}
}
}

View File

@ -1,43 +1,39 @@
@use '../../../styles/utils';
.box {
cursor: default;
transition-property: box-shadow;
transition-duration: 0.3s;
width: 32%;
@media (max-width: 1280px) {
width: 47%;
}
@media (max-width: 768px) {
width: 100%;
}
&:hover {
box-shadow: 0 0.5rem 1rem rgba(#000, 0.15);
}
}
.content {
max-width: 85%;
max-width: calc(100% - 70px);
}
.actions {
margin-top: -7px;
.header {
max-width: calc(100% - 40px);
}
a {
transition-property: color;
transition-duration: 0.3s;
color: #000;
&:hover {
color: #999;
}
&:not(:last-child) {
margin-right: 9px;
}
.title {
@include utils.truncate-text;
}
.actions a {
transition-property: color;
transition-duration: 0.3s;
color: #000;
&:hover {
color: #999;
}
&:not(:last-child) {
margin-right: 9px;
}
}
.description {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@include utils.truncate-text;
}

View File

@ -1,5 +1,5 @@
import React from 'react'
import { trans } from '../../../scripts/i18n'
import { t } from '@/scripts/i18n'
import { Plugin } from './types'
import styles from './InfoBox.scss'
@ -33,26 +33,30 @@ const InfoBox: React.FC<Props> = props => {
</span>
<div className={`info-box-content ${styles.content}`}>
<div className="d-flex justify-content-between">
<div>
<div className={`d-flex ${styles.header}`}>
<input
className="mr-2"
className="mr-2 d-inline-block"
type="checkbox"
checked={plugin.enabled}
title={
plugin.enabled
? trans('admin.disablePlugin')
: trans('admin.enablePlugin')
? t('admin.disablePlugin')
: t('admin.enablePlugin')
}
onChange={handleChange}
/>
<strong className="mr-2">{plugin.title}</strong>
<span className="text-gray">v{plugin.version}</span>
<strong className={`d-inline-block mr-2 ${styles.title}`}>
{plugin.title}
</strong>
<span className="d-none d-sm-inline-block text-gray">
v{plugin.version}
</span>
</div>
<div className={styles.actions}>
{plugin.readme && (
<a
href={`${props.baseUrl}/admin/plugins/readme/${plugin.name}`}
title={trans('admin.pluginReadme')}
title={t('admin.pluginReadme')}
>
<i className="fas fa-question" />
</a>
@ -60,16 +64,12 @@ const InfoBox: React.FC<Props> = props => {
{plugin.enabled && plugin.config && (
<a
href={`${props.baseUrl}/admin/plugins/config/${plugin.name}`}
title={trans('admin.configurePlugin')}
title={t('admin.configurePlugin')}
>
<i className="fas fa-cog" />
</a>
)}
<a
href="#"
title={trans('admin.deletePlugin')}
onClick={handleDelete}
>
<a href="#" title={t('admin.deletePlugin')} onClick={handleDelete}>
<i className="fas fa-trash" />
</a>
</div>
@ -80,4 +80,4 @@ const InfoBox: React.FC<Props> = props => {
)
}
export default React.memo(InfoBox)
export default InfoBox

View File

@ -1,15 +1,21 @@
import React, { useState, useEffect } from 'react'
import { hot } from 'react-hot-loader/root'
import { trans } from '../../../scripts/i18n'
import * as fetch from '../../../scripts/net'
import { toast, showModal } from '../../../scripts/notify'
import Loading from '../../../components/Loading'
import { t } from '@/scripts/i18n'
import * as fetch from '@/scripts/net'
import { toast, showModal } from '@/scripts/notify'
import FileInput from '@/components/FileInput'
import Loading from '@/components/Loading'
import InfoBox from './InfoBox'
import { Plugin } from './types'
const PluginsManagement: React.FC = () => {
const [loading, setLoading] = useState(false)
const [plugins, setPlugins] = useState<Plugin[]>([])
const [file, setFile] = useState<File | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [url, setUrl] = useState('')
const [isDownloading, setIsDownloading] = useState(false)
useEffect(() => {
const getPlugins = async () => {
setLoading(true)
@ -73,7 +79,7 @@ const PluginsManagement: React.FC = () => {
try {
await showModal({
title: plugin.title,
text: trans('admin.confirmDeletion'),
text: t('admin.confirmDeletion'),
okButtonType: 'danger',
})
} catch {
@ -93,20 +99,132 @@ const PluginsManagement: React.FC = () => {
}
}
return loading ? (
<Loading />
) : (
<div className="d-flex flex-wrap">
{plugins.map((plugin, i) => (
<InfoBox
key={plugin.name}
plugin={plugin}
onEnable={plugin => handleEnable(plugin, i)}
onDisable={plugin => handleDisable(plugin, i)}
onDelete={handleDelete}
baseUrl={blessing.base_url}
/>
))}
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setFile(event.target.files![0])
}
const handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setUrl(event.target.value)
}
const handleUpload = async () => {
if (!file) {
return
}
setIsUploading(true)
const formData = new FormData()
formData.append('file', file, file.name)
const { code, message } = await fetch.post<fetch.ResponseBody>(
'/admin/plugins/upload',
formData,
)
setIsUploading(false)
if (code === 0) {
toast.success(message)
setFile(null)
setPlugins(await fetch.get('/admin/plugins/data'))
} else {
toast.error(message)
}
}
const handleSubmitUrl = async () => {
setIsDownloading(true)
const { code, message } = await fetch.post<fetch.ResponseBody>(
'/admin/plugins/wget',
{ url },
)
setIsDownloading(false)
if (code === 0) {
toast.success(message)
setUrl('')
setPlugins(await fetch.get('/admin/plugins/data'))
} else {
toast.error(message)
}
}
const chunks = Array(Math.ceil(plugins.length / 2))
.fill(null)
.map((_, i) => plugins.slice(i * 2, (i + 1) * 2))
return (
<div className="row">
<div className="col-lg-8">
{loading ? (
<Loading />
) : (
chunks.map((chunk, i) => (
<div className="row" key={`${chunk[0].name}&${chunk[1]?.name}`}>
{chunk.map((plugin, j) => (
<div className="col-md-6" key={plugin.name}>
<InfoBox
plugin={plugin}
onEnable={plugin => handleEnable(plugin, i * 2 + j)}
onDisable={plugin => handleDisable(plugin, i * 2 + j)}
onDelete={handleDelete}
baseUrl={blessing.base_url}
/>
</div>
))}
</div>
))
)}
</div>
<div className="col-lg-4">
<div className="card card-primary card-outline">
<div className="card-header">
<h3 className="card-title">{t('admin.uploadArchive')}</h3>
</div>
<div className="card-body">
<p>{t('admin.uploadArchiveNotice')}</p>
<FileInput
file={file}
accept="application/zip"
onChange={handleFileChange}
/>
</div>
<div className="card-footer">
<button
className="btn btn-primary float-right"
disabled={isUploading}
onClick={handleUpload}
>
{isUploading ? <Loading /> : t('general.submit')}
</button>
</div>
</div>
<div className="card card-primary card-outline">
<div className="card-header">
<h3 className="card-title">{t('admin.downloadRemote')}</h3>
</div>
<div className="card-body">
<p>{t('admin.downloadRemoteNotice')}</p>
<div className="form-group">
<label htmlFor="zip-url">URL</label>
<input
type="text"
id="zip-url"
className="form-control"
value={url}
onChange={handleUrlChange}
/>
</div>
</div>
<div className="card-footer">
<button
className="btn btn-primary float-right"
disabled={isDownloading}
onClick={handleSubmitUrl}
>
{isDownloading ? <Loading /> : t('general.submit')}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
import React from 'react'
import { render, wait, fireEvent } from '@testing-library/react'
import { trans } from '@/scripts/i18n'
import { t } from '@/scripts/i18n'
import * as fetch from '@/scripts/net'
import PluginsManagement from '@/views/admin/PluginsManagement'
@ -24,13 +24,23 @@ test('plugin info box', async () => {
icon: {},
enabled: true,
},
{
name: 'b',
title: 'Another Plugin',
version: '0.1.0',
description: '',
config: true,
readme: true,
icon: {},
enabled: true,
},
])
const { queryByTitle, queryByText } = render(<PluginsManagement />)
await wait()
expect(queryByTitle(trans('admin.configurePlugin'))).not.toBeNull()
expect(queryByTitle(trans('admin.pluginReadme'))).not.toBeNull()
expect(queryByTitle(t('admin.configurePlugin'))).not.toBeNull()
expect(queryByTitle(t('admin.pluginReadme'))).not.toBeNull()
expect(queryByText('My Plugin')).not.toBeNull()
expect(queryByText('v1.0.0')).not.toBeNull()
expect(queryByText('desc')).not.toBeNull()
@ -47,7 +57,7 @@ describe('enable plugin', () => {
])
})
it('successfully', async () => {
it('succeeded', async () => {
fetch.get.mockResolvedValue([
{
name: 'a',
@ -60,7 +70,7 @@ describe('enable plugin', () => {
const { getByTitle, getByRole, queryByText } = render(<PluginsManagement />)
await wait()
fireEvent.click(getByTitle(trans('admin.enablePlugin')))
fireEvent.click(getByTitle(t('admin.enablePlugin')))
await wait()
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
@ -69,7 +79,7 @@ describe('enable plugin', () => {
})
expect(queryByText('success')).toBeInTheDocument()
expect(getByRole('status')).toHaveClass('alert-success')
expect(getByTitle(trans('admin.disablePlugin'))).toBeChecked()
expect(getByTitle(t('admin.disablePlugin'))).toBeChecked()
})
it('failed', async () => {
@ -82,7 +92,7 @@ describe('enable plugin', () => {
const { getByTitle, getByText, queryByText } = render(<PluginsManagement />)
await wait()
fireEvent.click(getByTitle(trans('admin.enablePlugin')))
fireEvent.click(getByTitle(t('admin.enablePlugin')))
await wait()
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
@ -92,7 +102,7 @@ describe('enable plugin', () => {
expect(queryByText('unresolved')).toBeInTheDocument()
expect(queryByText('abc')).toBeInTheDocument()
fireEvent.click(getByText(trans('general.confirm')))
fireEvent.click(getByText(t('general.confirm')))
})
})
@ -107,13 +117,13 @@ describe('disable plugin', () => {
])
})
it('successfully', async () => {
it('succeeded', async () => {
fetch.post.mockResolvedValue({ code: 0, message: 'success' })
const { getByTitle, getByRole, queryByText } = render(<PluginsManagement />)
await wait()
fireEvent.click(getByTitle(trans('admin.disablePlugin')))
fireEvent.click(getByTitle(t('admin.disablePlugin')))
await wait()
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
@ -122,7 +132,7 @@ describe('disable plugin', () => {
})
expect(queryByText('success')).toBeInTheDocument()
expect(getByRole('status')).toHaveClass('alert-success')
expect(getByTitle(trans('admin.enablePlugin'))).not.toBeChecked()
expect(getByTitle(t('admin.enablePlugin'))).not.toBeChecked()
})
it('failed', async () => {
@ -131,7 +141,7 @@ describe('disable plugin', () => {
const { getByTitle, getByRole, queryByText } = render(<PluginsManagement />)
await wait()
fireEvent.click(getByTitle(trans('admin.disablePlugin')))
fireEvent.click(getByTitle(t('admin.disablePlugin')))
await wait()
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
@ -159,14 +169,14 @@ describe('delete plugin', () => {
const { getByTitle, getByText } = render(<PluginsManagement />)
await wait()
fireEvent.click(getByTitle(trans('admin.deletePlugin')))
fireEvent.click(getByText(trans('general.cancel')))
fireEvent.click(getByTitle(t('admin.deletePlugin')))
fireEvent.click(getByText(t('general.cancel')))
await wait()
expect(fetch.post).not.toBeCalled()
})
it('successfully', async () => {
it('succeeded', async () => {
fetch.post.mockResolvedValue({ code: 0, message: 'success' })
const { getByTitle, getByText, getByRole, queryByText } = render(
@ -174,8 +184,8 @@ describe('delete plugin', () => {
)
await wait()
fireEvent.click(getByTitle(trans('admin.deletePlugin')))
fireEvent.click(getByText(trans('general.confirm')))
fireEvent.click(getByTitle(t('admin.deletePlugin')))
fireEvent.click(getByText(t('general.confirm')))
await wait()
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
@ -195,8 +205,8 @@ describe('delete plugin', () => {
)
await wait()
fireEvent.click(getByTitle(trans('admin.deletePlugin')))
fireEvent.click(getByText(trans('general.confirm')))
fireEvent.click(getByTitle(t('admin.deletePlugin')))
fireEvent.click(getByText(t('general.confirm')))
await wait()
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
@ -208,3 +218,129 @@ describe('delete plugin', () => {
expect(queryByText('My Plugin')).not.toBeNull()
})
})
describe('upload plugin archive', () => {
it('no selected file', async () => {
fetch.get.mockResolvedValue([])
const { getAllByText } = render(<PluginsManagement />)
await wait()
fireEvent.click(getAllByText(t('general.submit'))[0])
expect(fetch.post).not.toBeCalled()
})
it('succeeded', async () => {
fetch.get.mockResolvedValue([])
fetch.post.mockResolvedValue({ code: 0, message: 'ok' })
const { getByTitle, getAllByText, getByRole, queryByText } = render(
<PluginsManagement />,
)
await wait()
const file = new File([], 'plugin.zip')
fireEvent.change(getByTitle(t('skinlib.upload.select-file')), {
target: { files: [file] },
})
fireEvent.click(getAllByText(t('general.submit'))[0])
await wait()
expect(fetch.get).toBeCalledTimes(2)
expect(fetch.post).toBeCalledWith(
'/admin/plugins/upload',
expect.any(FormData),
)
const formData: FormData = fetch.post.mock.calls[0][1]
expect(formData.get('file')).toStrictEqual(file)
expect(queryByText('plugin.zip')).not.toBeInTheDocument()
expect(queryByText('ok')).toBeInTheDocument()
expect(getByRole('status')).toHaveClass('alert-success')
})
it('failed', async () => {
fetch.get.mockResolvedValue([])
fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
const { getByTitle, getAllByText, getByRole, queryByText } = render(
<PluginsManagement />,
)
await wait()
const file = new File([], 'plugin.zip')
fireEvent.change(getByTitle(t('skinlib.upload.select-file')), {
target: { files: [file] },
})
fireEvent.click(getAllByText(t('general.submit'))[0])
await wait()
expect(fetch.get).toBeCalledTimes(1)
expect(fetch.post).toBeCalledWith(
'/admin/plugins/upload',
expect.any(FormData),
)
expect(queryByText('plugin.zip')).toBeInTheDocument()
expect(queryByText('failed')).toBeInTheDocument()
expect(getByRole('alert')).toHaveClass('alert-danger')
})
})
describe('submit remote URL', () => {
it('succeeded', async () => {
fetch.get.mockResolvedValue([])
fetch.post.mockResolvedValue({ code: 0, message: 'ok' })
const {
getByLabelText,
getAllByText,
getByRole,
queryByText,
queryByDisplayValue,
} = render(<PluginsManagement />)
await wait()
fireEvent.input(getByLabelText('URL'), {
target: { value: 'https://example.com/a.zip' },
})
fireEvent.click(getAllByText(t('general.submit'))[1])
await wait()
expect(fetch.get).toBeCalledTimes(2)
expect(fetch.post).toBeCalledWith('/admin/plugins/wget', {
url: 'https://example.com/a.zip',
})
expect(
queryByDisplayValue('https://example.com/a.zip'),
).not.toBeInTheDocument()
expect(queryByText('ok')).toBeInTheDocument()
expect(getByRole('status')).toHaveClass('alert-success')
})
it('failed', async () => {
fetch.get.mockResolvedValue([])
fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
const {
getByLabelText,
getAllByText,
getByRole,
queryByText,
queryByDisplayValue,
} = render(<PluginsManagement />)
await wait()
fireEvent.input(getByLabelText('URL'), {
target: { value: 'https://example.com/a.zip' },
})
fireEvent.click(getAllByText(t('general.submit'))[1])
await wait()
expect(fetch.get).toBeCalledTimes(1)
expect(fetch.post).toBeCalledWith('/admin/plugins/wget', {
url: 'https://example.com/a.zip',
})
expect(queryByDisplayValue('https://example.com/a.zip')).toBeInTheDocument()
expect(queryByText('failed')).toBeInTheDocument()
expect(getByRole('alert')).toHaveClass('alert-danger')
})
})

View File

@ -237,6 +237,10 @@ admin:
enablePlugin: Enable
disablePlugin: Disable
confirmDeletion: Are you sure to delete this plugin?
uploadArchive: Upload Archive
uploadArchiveNotice: Install a plugin by uploading a Zip archive.
downloadRemote: Download From Remote
downloadRemoteNotice: Install a plugin by downloading a Zip archive from remote URL.
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

View File

@ -24,6 +24,8 @@
- Added Blessing Skin Shell.
- Support specifying "from" email address and name when sending email.
- 3D skin viewer can be with background now.
- Added support of installing plugin by uploading archive.
- Added support of installing plugin by submitting remote URL.
## Tweaked

View File

@ -24,6 +24,8 @@
- 新增 Blessing Skin Shell
- 支持单独指定邮件发件人的地址和名称
- 3D 皮肤预览现在是带背景的
- 可通过上传压缩包来安装插件
- 可通过提交 URL 来安装插件
## 调整

View File

@ -161,6 +161,8 @@ Route::prefix('admin')
Route::post('manage', 'PluginController@manage');
Route::any('config/{name}', 'PluginController@config');
Route::get('readme/{name}', 'PluginController@readme');
Route::post('upload', 'PluginController@upload');
Route::post('wget', 'PluginController@wget');
Route::prefix('market')->group(function () {
Route::view('', 'admin.market');

View File

@ -4,7 +4,11 @@ namespace Tests;
use App\Services\Plugin;
use App\Services\PluginManager;
use App\Services\Unzip;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Mockery\MockInterface;
class PluginControllerTest extends TestCase
{
@ -263,4 +267,71 @@ class PluginControllerTest extends TestCase
],
]);
}
public function testUpload()
{
// Missing file.
$this->postJson('/admin/plugins/upload')->assertJsonValidationErrors('file');
// Not a file.
$this->postJson('/admin/plugins/upload', ['file' => 'f'])
->assertJsonValidationErrors('file');
// Not a zip.
$file = UploadedFile::fake()->create('plugin.zip', 0, 'application/x-tar');
$this->postJson('/admin/plugins/upload', ['file' => $file])
->assertJsonValidationErrors('file');
// Success.
$file = UploadedFile::fake()->create('plugin.zip', 0, 'application/zip');
$this->mock(Unzip::class, function (MockInterface $mock) {
$mock->shouldReceive('extract')->withArgs(function ($path, $dest) {
$this->assertEquals(
resolve(PluginManager::class)->getPluginsDirs()->first(),
$dest
);
return true;
})->once();
});
$this->postJson('/admin/plugins/upload', ['file' => $file])
->assertJson([
'code' => 0,
'message' => trans('admin.plugins.market.install-success'),
]);
}
public function testWget()
{
// Missing url.
$this->postJson('/admin/plugins/wget')->assertJsonValidationErrors('url');
// Not a url.
$this->postJson('/admin/plugins/wget', ['url' => 'f'])
->assertJsonValidationErrors('url');
Http::fakeSequence()->pushStatus(404)->pushStatus(200);
$this->postJson('/admin/plugins/wget', ['url' => 'https://down.org/a.zip'])
->assertJson([
'code' => 1,
'message' => trans('admin.download.errors.download', ['error' => 404]),
]);
$this->mock(Unzip::class, function (MockInterface $mock) {
$mock->shouldReceive('extract')->withArgs(function ($path, $dest) {
$this->assertEquals(
resolve(PluginManager::class)->getPluginsDirs()->first(),
$dest
);
return true;
})->once();
});
$this->postJson('/admin/plugins/wget', ['url' => 'https://down.org/a.zip'])
->assertJson([
'code' => 0,
'message' => trans('admin.plugins.market.install-success'),
]);
}
}