From 616206ea5cf887982fb90af9debd4b3f9f673ac3 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Fri, 15 Dec 2023 15:16:01 +0100 Subject: [PATCH] fea: allow adding repositories and auth methods --- .../src/admin/components/AuthMethodModal.tsx | 87 ++++++++++++ .../js/src/admin/components/ConfigureAuth.tsx | 105 ++++++++++++++ .../admin/components/ConfigureComposer.tsx | 128 ++++++++++-------- .../js/src/admin/components/ConfigureJson.tsx | 94 +++++++++++++ .../src/admin/components/RepositoryModal.tsx | 76 +++++++++++ .../js/src/admin/components/SettingsPage.tsx | 7 +- extensions/package-manager/less/admin.less | 25 ++++ extensions/package-manager/locale/en.yml | 40 ++++++ .../ConfigureComposerController.php | 79 ++++++++++- .../src/Command/RemoveExtensionHandler.php | 2 +- .../src/Command/RequireExtensionHandler.php | 2 +- .../src/ConfigureComposerValidator.php | 17 ++- 12 files changed, 596 insertions(+), 66 deletions(-) create mode 100644 extensions/package-manager/js/src/admin/components/AuthMethodModal.tsx create mode 100644 extensions/package-manager/js/src/admin/components/ConfigureAuth.tsx create mode 100644 extensions/package-manager/js/src/admin/components/ConfigureJson.tsx create mode 100644 extensions/package-manager/js/src/admin/components/RepositoryModal.tsx diff --git a/extensions/package-manager/js/src/admin/components/AuthMethodModal.tsx b/extensions/package-manager/js/src/admin/components/AuthMethodModal.tsx new file mode 100644 index 0000000000..37269091b3 --- /dev/null +++ b/extensions/package-manager/js/src/admin/components/AuthMethodModal.tsx @@ -0,0 +1,87 @@ +import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; +import Mithril from 'mithril'; +import app from 'flarum/admin/app'; +import Select from 'flarum/common/components/Select'; +import Stream from 'flarum/common/utils/Stream'; +import Button from 'flarum/common/components/Button'; +import extractText from 'flarum/common/utils/extractText'; + +export interface IAuthMethodModalAttrs extends IInternalModalAttrs { + onsubmit: (type: string, host: string, token: string) => void; + type?: string; + host?: string; + token?: string; +} + +export default class AuthMethodModal extends Modal { + protected type!: Stream; + protected host!: Stream; + protected token!: Stream; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.type = Stream(this.attrs.type || 'bearer'); + this.host = Stream(this.attrs.host || ''); + this.token = Stream(this.attrs.token || ''); + } + + className(): string { + return 'AuthMethodModal Modal--small'; + } + + title(): Mithril.Children { + return app.translator.trans('flarum-package-manager.admin.auth_config.add_button_label'); + } + + content(): Mithril.Children { + const types = { + 'github-oauth': app.translator.trans('flarum-package-manager.admin.auth_config.types.github-oauth'), + 'gitlab-oauth': app.translator.trans('flarum-package-manager.admin.auth_config.types.gitlab-oauth'), + 'gitlab-token': app.translator.trans('flarum-package-manager.admin.auth_config.types.gitlab-token'), + bearer: app.translator.trans('flarum-package-manager.admin.auth_config.types.bearer'), + }; + + return ( +
+
+ + +
+
+ + +
+
+ +
+
+ ); + } + + submit() { + this.attrs.onsubmit(this.type(), this.host(), this.token()); + this.hide(); + } +} diff --git a/extensions/package-manager/js/src/admin/components/ConfigureAuth.tsx b/extensions/package-manager/js/src/admin/components/ConfigureAuth.tsx new file mode 100644 index 0000000000..5e579e4fba --- /dev/null +++ b/extensions/package-manager/js/src/admin/components/ConfigureAuth.tsx @@ -0,0 +1,105 @@ +import app from 'flarum/admin/app'; +import type Mithril from 'mithril'; +import ConfigureJson, { IConfigureJson } from './ConfigureJson'; +import Button from 'flarum/common/components/Button'; +import AuthMethodModal from './AuthMethodModal'; +import extractText from 'flarum/common/utils/extractText'; + +export default class ConfigureAuth extends ConfigureJson { + protected type = 'auth'; + + title(): Mithril.Children { + return app.translator.trans('flarum-package-manager.admin.auth_config.title'); + } + + className(): string { + return 'ConfigureAuth'; + } + + content(): Mithril.Children { + const authSettings = Object.keys(this.settings); + + return ( +
+ {authSettings.length ? ( + authSettings.map((type) => { + const hosts = this.settings[type](); + + return ( +
+ +
+ {Object.keys(hosts).map((host) => { + const data = hosts[host] as string | Record; + + return ( +
+ +
+ ); + })} +
+
+ ); + }) + ) : ( + {app.translator.trans('flarum-package-manager.admin.auth_config.no_auth_methods_configured')} + )} +
+ ); + } + + submitButton(): Mithril.Children[] { + const items = super.submitButton(); + + items.push( + + ); + + return items; + } + + onchange(type: string, host: string, token: string) { + this.setting(type)({ ...this.setting(type)(), [host]: token }); + } +} diff --git a/extensions/package-manager/js/src/admin/components/ConfigureComposer.tsx b/extensions/package-manager/js/src/admin/components/ConfigureComposer.tsx index 012fa97f47..bb08115061 100644 --- a/extensions/package-manager/js/src/admin/components/ConfigureComposer.tsx +++ b/extensions/package-manager/js/src/admin/components/ConfigureComposer.tsx @@ -1,30 +1,29 @@ import app from 'flarum/admin/app'; import type Mithril from 'mithril'; -import Component, { type ComponentAttrs } from 'flarum/common/Component'; -import { CommonSettingsItemOptions, type SettingsComponentOptions } from '@flarum/core/src/admin/components/AdminPage'; -import AdminPage from 'flarum/admin/components/AdminPage'; -import type ItemList from 'flarum/common/utils/ItemList'; -import Stream from 'flarum/common/utils/Stream'; +import ConfigureJson, { type IConfigureJson } from './ConfigureJson'; import Button from 'flarum/common/components/Button'; +import extractText from 'flarum/common/utils/extractText'; +import RepositoryModal from './RepositoryModal'; -export interface IConfigureComposer extends ComponentAttrs { - buildSettingComponent: (entry: ((this: this) => Mithril.Children) | SettingsComponentOptions) => Mithril.Children; -} +export type Repository = { + type: 'composer' | 'vcs' | 'path'; + url: string; +}; -export default class ConfigureComposer extends Component { - protected settings: Record> = {}; - protected initialSettings: Record | null = null; - protected loading: boolean = false; +export default class ConfigureComposer extends ConfigureJson { + protected type = 'composer'; - oninit(vnode: Mithril.Vnode) { - super.oninit(vnode); + title(): Mithril.Children { + return app.translator.trans('flarum-package-manager.admin.composer.title'); + } - this.submit(); + className(): string { + return 'ConfigureComposer'; } - view(): Mithril.Children { + content(): Mithril.Children { return ( -
+
{this.attrs.buildSettingComponent.call(this, { setting: 'minimum-stability', label: app.translator.trans('flarum-package-manager.admin.composer.minimum_stability.label'), @@ -38,53 +37,72 @@ export default class ConfigureComposer - + +
{app.translator.trans('flarum-package-manager.admin.composer.repositories.help')}
+
+ {Object.keys(this.setting('repositories')() || {}).map((key) => { + const repository = this.setting('repositories')()[key] as Repository; + + return ( +
+ +
+ ); + })} +
); } - customSettingComponents(): ItemList<(attributes: CommonSettingsItemOptions) => Mithril.Children> { - return AdminPage.prototype.customSettingComponents(); - } - - setting(key: string) { - return this.settings[key] ?? (() => null); - } - - submit() { - this.loading = true; + submitButton(): Mithril.Children[] { + const items = super.submitButton(); - const configuration: any = {}; - - Object.keys(this.settings).forEach((key) => { - configuration[key] = this.settings[key](); - }); - - app - .request({ - method: 'POST', - url: app.forum.attribute('apiUrl') + '/package-manager/composer', - body: { data: configuration }, - }) - .then(({ data }: any) => { - Object.keys(data).forEach((key) => { - this.settings[key] = Stream(data[key]); - }); + items.push( + + ); - this.initialSettings = data; - }) - .finally(() => { - this.loading = false; - m.redraw(); - }); + return items; } - isDirty() { - return JSON.stringify(this.initialSettings) !== JSON.stringify(this.settings); + onchange(repository: Repository, key: string) { + this.setting('repositories')({ + ...this.setting('repositories')(), + [key]: repository, + }); } } diff --git a/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx b/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx new file mode 100644 index 0000000000..e15ae82956 --- /dev/null +++ b/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx @@ -0,0 +1,94 @@ +import app from 'flarum/admin/app'; +import type Mithril from 'mithril'; +import Component, { type ComponentAttrs } from 'flarum/common/Component'; +import { CommonSettingsItemOptions, type SettingsComponentOptions } from '@flarum/core/src/admin/components/AdminPage'; +import AdminPage from 'flarum/admin/components/AdminPage'; +import type ItemList from 'flarum/common/utils/ItemList'; +import Stream from 'flarum/common/utils/Stream'; +import Button from 'flarum/common/components/Button'; +import classList from 'flarum/common/utils/classList'; + +export interface IConfigureJson extends ComponentAttrs { + buildSettingComponent: (entry: ((this: this) => Mithril.Children) | SettingsComponentOptions) => Mithril.Children; +} + +export default abstract class ConfigureJson extends Component { + protected settings: Record> = {}; + protected initialSettings: Record | null = null; + protected loading: boolean = false; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.submit(true); + } + + protected abstract type: string; + abstract title(): Mithril.Children; + abstract content(): Mithril.Children; + + className(): string { + return ''; + } + + view(): Mithril.Children { + return ( +
+ + {this.content()} +
{this.submitButton()}
+
+ ); + } + + submitButton(): Mithril.Children[] { + return [ + , + ]; + } + + customSettingComponents(): ItemList<(attributes: CommonSettingsItemOptions) => Mithril.Children> { + return AdminPage.prototype.customSettingComponents(); + } + + setting(key: string) { + return this.settings[key] ?? (this.settings[key] = Stream()); + } + + submit(readOnly: boolean) { + this.loading = true; + + const configuration: any = {}; + + Object.keys(this.settings).forEach((key) => { + configuration[key] = this.settings[key](); + }); + + app + .request({ + method: 'POST', + url: app.forum.attribute('apiUrl') + '/package-manager/composer', + body: { + type: this.type, + data: readOnly ? null : configuration, + }, + }) + .then(({ data }: any) => { + Object.keys(data).forEach((key) => { + this.settings[key] = Stream(data[key]); + }); + + this.initialSettings = Array.isArray(data) ? {} : data; + }) + .finally(() => { + this.loading = false; + m.redraw(); + }); + } + + isDirty() { + return JSON.stringify(this.initialSettings) !== JSON.stringify(this.settings); + } +} diff --git a/extensions/package-manager/js/src/admin/components/RepositoryModal.tsx b/extensions/package-manager/js/src/admin/components/RepositoryModal.tsx new file mode 100644 index 0000000000..5e8675a324 --- /dev/null +++ b/extensions/package-manager/js/src/admin/components/RepositoryModal.tsx @@ -0,0 +1,76 @@ +import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; +import Mithril from 'mithril'; +import app from 'flarum/admin/app'; +import Select from 'flarum/common/components/Select'; +import Stream from 'flarum/common/utils/Stream'; +import Button from 'flarum/common/components/Button'; +import { type Repository } from './ConfigureComposer'; + +export interface IRepositoryModalAttrs extends IInternalModalAttrs { + onsubmit: (repository: Repository, key: string) => void; + key?: string; + repository?: Repository; +} + +export default class RepositoryModal extends Modal { + protected key!: Stream; + protected repository!: Stream; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.key = Stream(this.attrs.key || ''); + this.repository = Stream(this.attrs.repository || { type: 'composer', url: '' }); + } + + className(): string { + return 'RepositoryModal Modal--small'; + } + + title(): Mithril.Children { + return app.translator.trans('flarum-package-manager.admin.composer.add_repository_label'); + } + + content(): Mithril.Children { + const types = { + composer: app.translator.trans('flarum-package-manager.admin.composer.repositories.types.composer'), + vcs: app.translator.trans('flarum-package-manager.admin.composer.repositories.types.vcs'), + path: app.translator.trans('flarum-package-manager.admin.composer.repositories.types.path'), + }; + + return ( +
+
+ + +
+
+ + this.repository({ ...this.repository(), url: (e.target as HTMLInputElement).value })} + value={this.repository().url} + /> +
+
+ +
+
+ ); + } + + submit() { + this.attrs.onsubmit(this.repository(), this.key()); + this.hide(); + } +} diff --git a/extensions/package-manager/js/src/admin/components/SettingsPage.tsx b/extensions/package-manager/js/src/admin/components/SettingsPage.tsx index 6be39cb504..1c64435f8f 100644 --- a/extensions/package-manager/js/src/admin/components/SettingsPage.tsx +++ b/extensions/package-manager/js/src/admin/components/SettingsPage.tsx @@ -8,6 +8,7 @@ import ControlSection from './ControlSection'; import ConfigureComposer from './ConfigureComposer'; import Alert from 'flarum/common/components/Alert'; import listItems from 'flarum/common/helpers/listItems'; +import ConfigureAuth from './ConfigureAuth'; export default class SettingsPage extends ExtensionPage { content() { @@ -28,10 +29,12 @@ export default class SettingsPage extends ExtensionPage { {settings ? (
- {settings.map(this.buildSettingComponent.bind(this))} -
{this.submitButton()}
+ +
{settings.map(this.buildSettingComponent.bind(this))}
+
{this.submitButton()}
+
) : (

{app.translator.trans('core.admin.extension.no_settings')}

diff --git a/extensions/package-manager/less/admin.less b/extensions/package-manager/less/admin.less index ec55d3a66f..d90532002a 100755 --- a/extensions/package-manager/less/admin.less +++ b/extensions/package-manager/less/admin.less @@ -27,3 +27,28 @@ opacity: 0.6; cursor: not-allowed; } + +.Form--controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + margin-top: auto; + padding-top: 16px; +} + +.ButtonGroup--full { + width: 100%; + display: flex; + + > .Button:first-child { + flex-grow: 1; + text-align: start; + } +} + +.ConfigureAuth-hosts, .ConfigureComposer-repositories { + > .ButtonGroup { + margin-bottom: 8px; + } +} diff --git a/extensions/package-manager/locale/en.yml b/extensions/package-manager/locale/en.yml index bc14fc936d..b2bc050646 100755 --- a/extensions/package-manager/locale/en.yml +++ b/extensions/package-manager/locale/en.yml @@ -1,6 +1,32 @@ flarum-package-manager: admin: + auth_config: + add_button_label: New authentication method + add_modal: + host_label: Host + host_placeholder: "example: extiverse.com" + submit_button: Submit + token_label: Token + type_label: Type + unchanged_token_placeholder: "(unchanged)" + delete_confirmation: Are you sure you want to delete this authentication method? + delete_label: Delete authentication method + fields: + host: Host + token: Token + no_auth_methods_configured: No authentication methods configured. This is an optional advanced feature to allow installing from private repositories. + remove_button_label: Remove authentication method + title: Authentication Methods + types: + github-oauth: GitHub OAuth + gitlab-oauth: GitLab OAuth + gitlab-token: GitLab Token + bearer: HTTP Bearer composer: + add_repository_label: Add Repository + delete_repository_confirmation: Are you sure you want to delete this repository? All extensions installed from this repository will be removed. + delete_repository_label: Delete repository + title: Composer minimum_stability: label: Minimum Stability help: The type of packages allowed to be installed. Do not change this unless you know what you are doing. @@ -10,6 +36,19 @@ flarum-package-manager: beta: Beta alpha: Alpha dev: Dev + repositories: + label: Repositories + help: > + Add additional repositories to install packages from. This is an advanced feature, do not add repositories that are not trusted, as they can be used to execute malicious code on your server. + types: + composer: composer + vcs: vcs + path: path + add_modal: + key_label: Name + type_label: Type + url: URL + submit_button: Submit exceptions: composer_command_failure: Failed to execute. Check the composer logs in storage/logs/composer. @@ -94,6 +133,7 @@ flarum-package-manager: title: Queue settings: + title: => core.ref.settings access_warning: Please be careful to who you give access to the admin area, the package manager could be misused by bad actors to install packages that can lead to security breaches. debug_mode_warning: You are running in debug mode, the package manager cannot properly install and update local development packages. Please use the command line interface instead for such purposes. queue_jobs: Run operations in the background queue diff --git a/extensions/package-manager/src/Api/Controller/ConfigureComposerController.php b/extensions/package-manager/src/Api/Controller/ConfigureComposerController.php index ecd716c7b9..54504cd2b3 100755 --- a/extensions/package-manager/src/Api/Controller/ConfigureComposerController.php +++ b/extensions/package-manager/src/Api/Controller/ConfigureComposerController.php @@ -13,16 +13,22 @@ use Flarum\Http\RequestUtil; use Flarum\PackageManager\Composer\ComposerJson; use Flarum\PackageManager\ConfigureComposerValidator; +use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Arr; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Used to both set and read the composer.json configuration. + * And other composer local configuration such as auth.json. + */ class ConfigureComposerController implements RequestHandlerInterface { protected $configurable = [ 'minimum-stability', + 'repositories', ]; /** @@ -40,21 +46,48 @@ class ConfigureComposerController implements RequestHandlerInterface */ protected $composerJson; - public function __construct(ConfigureComposerValidator $validator, Paths $paths, ComposerJson $composerJson) + /** + * @var Filesystem + */ + protected $filesystem; + + public function __construct(ConfigureComposerValidator $validator, Paths $paths, ComposerJson $composerJson, Filesystem $filesystem) { $this->validator = $validator; $this->paths = $paths; $this->composerJson = $composerJson; + $this->filesystem = $filesystem; } public function handle(ServerRequestInterface $request): ResponseInterface { $actor = RequestUtil::getActor($request); - $data = Arr::only(Arr::get($request->getParsedBody(), 'data'), $this->configurable); + $type = Arr::get($request->getParsedBody(), 'type'); $actor->assertAdmin(); - $this->validator->assertValid($data); + if (! in_array($type, ['composer', 'auth'])) { + return new JsonResponse([ + 'data' => [], + ]); + } + + if ($type === 'composer') { + $data = $this->composerConfig($request); + } else { + $data = $this->authConfig($request); + } + + return new JsonResponse([ + 'data' => $data, + ]); + } + + protected function composerConfig(ServerRequestInterface $request): array + { + $data = Arr::only(Arr::get($request->getParsedBody(), 'data') ?? [], $this->configurable); + + $this->validator->assertValid(['composer' => $data]); $composerJson = $this->composerJson->get(); if (! empty($data)) { @@ -65,8 +98,42 @@ public function handle(ServerRequestInterface $request): ResponseInterface $this->composerJson->set($composerJson); } - return new JsonResponse([ - 'data' => Arr::only($composerJson, $this->configurable), - ]); + return Arr::only($composerJson, $this->configurable); + } + + protected function authConfig(ServerRequestInterface $request): array + { + $data = Arr::get($request->getParsedBody(), 'data'); + + $this->validator->assertValid(['auth' => $data]); + + $authJson = json_decode($this->filesystem->get($this->paths->base.'/auth.json'), true); + + if (! is_null($data)) { + foreach ($data as $type => $hosts) { + foreach ($hosts as $host => $token) { + if (empty($token)) { + unset($authJson[$type][$host]); + continue; + } + + $data[$type][$host] = $token === '***' + ? $authJson[$type][$host] + : $token; + } + } + + $this->filesystem->put($this->paths->base.'/auth.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $authJson = $data; + } + + // Remove tokens from response. + foreach ($authJson as $type => $hosts) { + foreach ($hosts as $host => $token) { + $authJson[$type][$host] = '***'; + } + } + + return $authJson; } } diff --git a/extensions/package-manager/src/Command/RemoveExtensionHandler.php b/extensions/package-manager/src/Command/RemoveExtensionHandler.php index 8de9af2ba6..1eaa4d7993 100755 --- a/extensions/package-manager/src/Command/RemoveExtensionHandler.php +++ b/extensions/package-manager/src/Command/RemoveExtensionHandler.php @@ -70,7 +70,7 @@ public function handle(RemoveExtension $command) $json = $this->composerJson->get(); // If this extension is not a direct dependency, we can't actually remove it. - if (! isset($json['require'][$extension->name]) || ! isset($json['require-dev'][$extension->name])) { + if (! isset($json['require'][$extension->name]) && ! isset($json['require-dev'][$extension->name])) { throw new IndirectExtensionDependencyCannotBeRemovedException($command->extensionId); } diff --git a/extensions/package-manager/src/Command/RequireExtensionHandler.php b/extensions/package-manager/src/Command/RequireExtensionHandler.php index 9d27a82edf..8184ddde88 100755 --- a/extensions/package-manager/src/Command/RequireExtensionHandler.php +++ b/extensions/package-manager/src/Command/RequireExtensionHandler.php @@ -74,7 +74,7 @@ public function handle(RequireExtension $command) } $output = $this->composer->run( - new StringInput("require $packageName"), + new StringInput("require $packageName -W"), $command->task ?? null ); diff --git a/extensions/package-manager/src/ConfigureComposerValidator.php b/extensions/package-manager/src/ConfigureComposerValidator.php index 184b52fb92..44a39c4203 100644 --- a/extensions/package-manager/src/ConfigureComposerValidator.php +++ b/extensions/package-manager/src/ConfigureComposerValidator.php @@ -14,6 +14,21 @@ class ConfigureComposerValidator extends AbstractValidator { protected $rules = [ - 'minimum-stability' => ['sometimes', 'in:stable,RC,beta,alpha,dev'], + 'composer' => [ + 'minimum-stability' => ['sometimes', 'in:stable,RC,beta,alpha,dev'], + 'repositories' => ['sometimes', 'array'], + 'repositories.*.type' => ['sometimes', 'in:composer,vcs,path'], + 'repositories.*.url' => ['sometimes', 'string'], + ], + 'auth' => [ + 'github-oauth' => ['sometimes', 'array'], + 'github-oauth.*' => ['sometimes', 'string'], + 'gitlab-oauth' => ['sometimes', 'array'], + 'gitlab-oauth.*' => ['sometimes', 'string'], + 'gitlab-token' => ['sometimes', 'array'], + 'gitlab-token.*' => ['sometimes', 'string'], + 'bearer' => ['sometimes', 'array'], + 'bearer.*' => ['sometimes', 'string'], + ], ]; }