diff --git a/app/Http/Controllers/PluginController.php b/app/Http/Controllers/PluginController.php index 9c59a36a..7823a2b6 100644 --- a/app/Http/Controllers/PluginController.php +++ b/app/Http/Controllers/PluginController.php @@ -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); + } + } } diff --git a/resources/assets/src/views/admin/PluginsManagement/InfoBox.scss b/resources/assets/src/views/admin/PluginsManagement/InfoBox.scss index ba450302..532f4562 100644 --- a/resources/assets/src/views/admin/PluginsManagement/InfoBox.scss +++ b/resources/assets/src/views/admin/PluginsManagement/InfoBox.scss @@ -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; } diff --git a/resources/assets/src/views/admin/PluginsManagement/InfoBox.tsx b/resources/assets/src/views/admin/PluginsManagement/InfoBox.tsx index 831554a3..83093a00 100644 --- a/resources/assets/src/views/admin/PluginsManagement/InfoBox.tsx +++ b/resources/assets/src/views/admin/PluginsManagement/InfoBox.tsx @@ -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 => {
-
+
- {plugin.title} - v{plugin.version} + + {plugin.title} + + + v{plugin.version} +
{plugin.readme && ( @@ -60,16 +64,12 @@ const InfoBox: React.FC = props => { {plugin.enabled && plugin.config && ( )} - +
@@ -80,4 +80,4 @@ const InfoBox: React.FC = props => { ) } -export default React.memo(InfoBox) +export default InfoBox diff --git a/resources/assets/src/views/admin/PluginsManagement/index.tsx b/resources/assets/src/views/admin/PluginsManagement/index.tsx index 2c723921..b263f574 100644 --- a/resources/assets/src/views/admin/PluginsManagement/index.tsx +++ b/resources/assets/src/views/admin/PluginsManagement/index.tsx @@ -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([]) + const [file, setFile] = useState(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 ? ( - - ) : ( -
- {plugins.map((plugin, i) => ( - handleEnable(plugin, i)} - onDisable={plugin => handleDisable(plugin, i)} - onDelete={handleDelete} - baseUrl={blessing.base_url} - /> - ))} + const handleFileChange = (event: React.ChangeEvent) => { + setFile(event.target.files![0]) + } + + const handleUrlChange = (event: React.ChangeEvent) => { + 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( + '/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( + '/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 ( +
+
+ {loading ? ( + + ) : ( + chunks.map((chunk, i) => ( +
+ {chunk.map((plugin, j) => ( +
+ handleEnable(plugin, i * 2 + j)} + onDisable={plugin => handleDisable(plugin, i * 2 + j)} + onDelete={handleDelete} + baseUrl={blessing.base_url} + /> +
+ ))} +
+ )) + )} +
+
+
+
+

{t('admin.uploadArchive')}

+
+
+

{t('admin.uploadArchiveNotice')}

+ +
+
+ +
+
+
+
+

{t('admin.downloadRemote')}

+
+
+

{t('admin.downloadRemoteNotice')}

+
+ + +
+
+
+ +
+
+
) } diff --git a/resources/assets/tests/views/admin/PluginsManagement.test.tsx b/resources/assets/tests/views/admin/PluginsManagement.test.tsx index 29a911e7..51ebd2b5 100644 --- a/resources/assets/tests/views/admin/PluginsManagement.test.tsx +++ b/resources/assets/tests/views/admin/PluginsManagement.test.tsx @@ -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() 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() 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() 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() 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() 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() 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() + 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( + , + ) + 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( + , + ) + 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() + 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() + 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') + }) +}) diff --git a/resources/lang/en/front-end.yml b/resources/lang/en/front-end.yml index 43ae5d1b..2df502bb 100644 --- a/resources/lang/en/front-end.yml +++ b/resources/lang/en/front-end.yml @@ -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 diff --git a/resources/misc/changelogs/en/5.0.0.md b/resources/misc/changelogs/en/5.0.0.md index 9581e7f3..0d2d4784 100644 --- a/resources/misc/changelogs/en/5.0.0.md +++ b/resources/misc/changelogs/en/5.0.0.md @@ -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 diff --git a/resources/misc/changelogs/zh_CN/5.0.0.md b/resources/misc/changelogs/zh_CN/5.0.0.md index 2bea1cd3..89f7a964 100644 --- a/resources/misc/changelogs/zh_CN/5.0.0.md +++ b/resources/misc/changelogs/zh_CN/5.0.0.md @@ -24,6 +24,8 @@ - 新增 Blessing Skin Shell - 支持单独指定邮件发件人的地址和名称 - 3D 皮肤预览现在是带背景的 +- 可通过上传压缩包来安装插件 +- 可通过提交 URL 来安装插件 ## 调整 diff --git a/routes/web.php b/routes/web.php index 853ca1b1..dd6b0194 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/HttpTest/ControllersTest/PluginControllerTest.php b/tests/HttpTest/ControllersTest/PluginControllerTest.php index a487cc5c..7eb9afef 100644 --- a/tests/HttpTest/ControllersTest/PluginControllerTest.php +++ b/tests/HttpTest/ControllersTest/PluginControllerTest.php @@ -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'), + ]); + } }