diff --git a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html index e80855a023b..5670643eaae 100644 --- a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html +++ b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html @@ -11,18 +11,21 @@ - - + + diff --git a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.scss b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.scss index 4039f2a0249..03fa728d347 100644 --- a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.scss +++ b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.scss @@ -8,4 +8,8 @@ my-edit-button, my-button { @include margin-right(10px); + + &[disabled=true] { + opacity: 0.6; + } } diff --git a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts index 61407a7b3ec..adbd8dd1c7b 100644 --- a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts +++ b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts @@ -1,11 +1,11 @@ -import { Subject } from 'rxjs' -import { Component, OnInit } from '@angular/core' +import { Subject, Subscription, filter } from 'rxjs' +import { Component, OnDestroy, OnInit } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service' -import { ComponentPagination, ConfirmService, hasMoreItems, Notifier } from '@app/core' +import { ComponentPagination, ConfirmService, hasMoreItems, Notifier, PeerTubeSocket } from '@app/core' import { PluginService } from '@app/core/plugins/plugin.service' import { compareSemVer } from '@peertube/peertube-core-utils' -import { PeerTubePlugin, PluginType, PluginType_Type } from '@peertube/peertube-models' +import { PeerTubePlugin, PluginManagePayload, PluginType, PluginType_Type, UserNotificationType } from '@peertube/peertube-models' import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component' import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component' import { EditButtonComponent } from '../../../shared/shared-main/buttons/edit-button.component' @@ -13,6 +13,8 @@ import { PluginCardComponent } from '../shared/plugin-card.component' import { InfiniteScrollerDirective } from '../../../shared/shared-main/angular/infinite-scroller.directive' import { NgIf, NgFor } from '@angular/common' import { PluginNavigationComponent } from '../shared/plugin-navigation.component' +import { JobService } from '@app/+admin/system' +import { logger } from '@root-helpers/logger' @Component({ selector: 'my-plugin-list-installed', @@ -30,7 +32,7 @@ import { PluginNavigationComponent } from '../shared/plugin-navigation.component DeleteButtonComponent ] }) -export class PluginListInstalledComponent implements OnInit { +export class PluginListInstalledComponent implements OnInit, OnDestroy { pluginType: PluginType_Type pagination: ComponentPagination = { @@ -41,18 +43,22 @@ export class PluginListInstalledComponent implements OnInit { sort = 'name' plugins: PeerTubePlugin[] = [] - updating: { [name: string]: boolean } = {} - uninstalling: { [name: string]: boolean } = {} + toBeUpdated: { [name: string]: boolean } = {} + toBeUninstalled: { [name: string]: boolean } = {} onDataSubject = new Subject() + private notificationSub: Subscription + constructor ( private pluginService: PluginService, private pluginApiService: PluginApiService, private notifier: Notifier, private confirmService: ConfirmService, private router: Router, - private route: ActivatedRoute + private route: ActivatedRoute, + private jobService: JobService, + private peertubeSocket: PeerTubeSocket ) { } @@ -63,6 +69,43 @@ export class PluginListInstalledComponent implements OnInit { this.router.navigate([], { queryParams, replaceUrl: true }) } + this.jobService.listUnfinishedJobs({ + jobType: 'plugin-manage', + pagination: { + count: 10, + start: 0 + }, + sort: { + field: 'createdAt', + order: -1 + } + }).subscribe({ + next: resultList => { + const jobs = resultList.data + + jobs.forEach((job) => { + let payload: PluginManagePayload + + try { + payload = JSON.parse(job.data) + } catch (err) {} + + if (payload.action === 'update') { + this.toBeUpdated[payload.npmName] = true + } + + if (payload.action === 'uninstall') { + this.toBeUninstalled[payload.npmName] = true + } + }) + }, + + error: err => { + logger.error('Could not fetch status of installed plugins.', { err }) + this.notifier.error($localize`Could not fetch status of installed plugins.`) + } + }) + this.route.queryParams.subscribe(query => { if (!query['pluginType']) return @@ -70,6 +113,12 @@ export class PluginListInstalledComponent implements OnInit { this.reloadPlugins() }) + + this.subscribeToNotifications() + } + + ngOnDestroy () { + if (this.notificationSub) this.notificationSub.unsubscribe() } reloadPlugins () { @@ -117,12 +166,12 @@ export class PluginListInstalledComponent implements OnInit { return $localize`Update to ${plugin.latestVersion}` } - isUpdating (plugin: PeerTubePlugin) { - return !!this.updating[this.getPluginKey(plugin)] + willUpdate (plugin: PeerTubePlugin) { + return !!this.toBeUpdated[this.getPluginKey(plugin)] } - isUninstalling (plugin: PeerTubePlugin) { - return !!this.uninstalling[this.getPluginKey(plugin)] + willUninstall (plugin: PeerTubePlugin) { + return !!this.toBeUninstalled[this.getPluginKey(plugin)] } isTheme (plugin: PeerTubePlugin) { @@ -131,7 +180,7 @@ export class PluginListInstalledComponent implements OnInit { async uninstall (plugin: PeerTubePlugin) { const pluginKey = this.getPluginKey(plugin) - if (this.uninstalling[pluginKey]) return + if (this.toBeUninstalled[pluginKey]) return const res = await this.confirmService.confirm( $localize`Do you really want to uninstall ${plugin.name}?`, @@ -139,29 +188,24 @@ export class PluginListInstalledComponent implements OnInit { ) if (res === false) return - this.uninstalling[pluginKey] = true + this.toBeUninstalled[pluginKey] = true this.pluginApiService.uninstall(plugin.name, plugin.type) .subscribe({ next: () => { - this.notifier.success($localize`${plugin.name} uninstalled.`) - - this.plugins = this.plugins.filter(p => p.name !== plugin.name) - this.pagination.totalItems-- - - this.uninstalling[pluginKey] = false + this.notifier.success($localize`${plugin.name} will be uninstalled.`) }, error: err => { this.notifier.error(err.message) - this.uninstalling[pluginKey] = false + this.toBeUninstalled[pluginKey] = false } }) } async update (plugin: PeerTubePlugin) { const pluginKey = this.getPluginKey(plugin) - if (this.updating[pluginKey]) return + if (this.toBeUpdated[pluginKey]) return if (this.isMajorUpgrade(plugin)) { const res = await this.confirmService.confirm( @@ -173,22 +217,18 @@ export class PluginListInstalledComponent implements OnInit { if (res === false) return } - this.updating[pluginKey] = true + this.toBeUpdated[pluginKey] = true this.pluginApiService.update(plugin.name, plugin.type) .pipe() .subscribe({ next: res => { - this.updating[pluginKey] = false - - this.notifier.success($localize`${plugin.name} updated.`) - - Object.assign(plugin, res) + this.notifier.success($localize`${plugin.name} will be updated.`) }, error: err => { this.notifier.error(err.message) - this.updating[pluginKey] = false + this.toBeUpdated[pluginKey] = false } }) } @@ -201,8 +241,36 @@ export class PluginListInstalledComponent implements OnInit { return this.pluginApiService.getPluginOrThemeHref(this.pluginType, name) } - private getPluginKey (plugin: PeerTubePlugin) { - return plugin.name + plugin.type + private async subscribeToNotifications () { + const obs = await this.peertubeSocket.getMyNotificationsSocket() + + this.notificationSub = obs + .pipe( + filter(d => d.notification?.type === UserNotificationType.PLUGIN_MANAGE_FINISHED) + ).subscribe(data => { + const pluginName = data.notification.plugin?.name + + if (pluginName) { + const npmName = this.getPluginKey(data.notification.plugin) + + if (this.toBeUninstalled[npmName]) { + this.toBeUninstalled[npmName] = false + + if (!data.notification.hasOperationFailed) { + this.plugins = this.plugins.filter(p => p.name !== pluginName) + } + } + + if (this.toBeUpdated[npmName]) { + this.toBeUpdated[npmName] = false + this.reloadPlugins() + } + } + }) + } + + private getPluginKey (plugin: Pick) { + return this.pluginService.nameToNpmName(plugin.name, plugin.type) } private isMajorUpgrade (plugin: PeerTubePlugin) { diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html index 189002eae01..24e9971a39a 100644 --- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html +++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html @@ -1,9 +1,5 @@ -
- To load your new installed plugins or themes, refresh the page. -
- diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss index 1406adbf218..5b7122f2c52 100644 --- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss +++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss @@ -29,3 +29,7 @@ .alert { margin-top: 15px; } + +my-button[disabled=true] { + opacity: 0.6; +} diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts index fbb59628b7d..21ad34222a5 100644 --- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts +++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts @@ -1,10 +1,10 @@ -import { Subject } from 'rxjs' -import { debounceTime, distinctUntilChanged } from 'rxjs/operators' -import { Component, OnInit } from '@angular/core' +import { Subject, Subscription } from 'rxjs' +import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators' +import { Component, OnDestroy, OnInit } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service' -import { ComponentPagination, ConfirmService, hasMoreItems, Notifier, PluginService } from '@app/core' -import { PeerTubePluginIndex, PluginType, PluginType_Type } from '@peertube/peertube-models' +import { ComponentPagination, ConfirmService, hasMoreItems, Notifier, PeerTubeSocket, PluginService } from '@app/core' +import { PeerTubePluginIndex, PluginManagePayload, PluginType, PluginType_Type, UserNotificationType } from '@peertube/peertube-models' import { logger } from '@root-helpers/logger' import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component' import { EditButtonComponent } from '../../../shared/shared-main/buttons/edit-button.component' @@ -14,6 +14,7 @@ import { AutofocusDirective } from '../../../shared/shared-main/angular/autofocu import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component' import { NgIf, NgFor } from '@angular/common' import { PluginNavigationComponent } from '../shared/plugin-navigation.component' +import { JobService } from '@app/+admin/system' @Component({ selector: 'my-plugin-search', @@ -32,7 +33,7 @@ import { PluginNavigationComponent } from '../shared/plugin-navigation.component ButtonComponent ] }) -export class PluginSearchComponent implements OnInit { +export class PluginSearchComponent implements OnInit, OnDestroy { pluginType: PluginType_Type pagination: ComponentPagination = { @@ -46,12 +47,13 @@ export class PluginSearchComponent implements OnInit { isSearching = false plugins: PeerTubePluginIndex[] = [] - installing: { [name: string]: boolean } = {} + toBeInstalled: { [name: string]: boolean } = {} pluginInstalled = false onDataSubject = new Subject() private searchSubject = new Subject() + private notificationSub: Subscription constructor ( private pluginService: PluginService, @@ -59,7 +61,9 @@ export class PluginSearchComponent implements OnInit { private notifier: Notifier, private confirmService: ConfirmService, private router: Router, - private route: ActivatedRoute + private route: ActivatedRoute, + private jobService: JobService, + private peertubeSocket: PeerTubeSocket ) { } @@ -70,6 +74,39 @@ export class PluginSearchComponent implements OnInit { this.router.navigate([], { queryParams }) } + this.jobService.listUnfinishedJobs({ + jobType: 'plugin-manage', + pagination: { + count: 10, + start: 0 + }, + sort: { + field: 'createdAt', + order: -1 + } + }).subscribe({ + next: resultList => { + const jobs = resultList.data + + jobs.forEach((job) => { + let payload: PluginManagePayload + + try { + payload = JSON.parse(job.data) + } catch (err) {} + + if (payload.action === 'install') { + this.toBeInstalled[payload.npmName] = true + } + }) + }, + + error: err => { + logger.error('Could not fetch status of installed plugins.', { err }) + this.notifier.error($localize`Could not fetch status of installed plugins.`) + } + }) + this.route.queryParams.subscribe(query => { if (!query['pluginType']) return @@ -85,6 +122,12 @@ export class PluginSearchComponent implements OnInit { distinctUntilChanged() ) .subscribe(search => this.router.navigate([], { queryParams: { search }, queryParamsHandling: 'merge' })) + + this.subscribeToNotifications() + } + + ngOnDestroy () { + if (this.notificationSub) this.notificationSub.unsubscribe() } onSearchChange (event: Event) { @@ -131,8 +174,8 @@ export class PluginSearchComponent implements OnInit { this.loadMorePlugins() } - isInstalling (plugin: PeerTubePluginIndex) { - return !!this.installing[plugin.npmName] + willInstall (plugin: PeerTubePluginIndex) { + return !!this.toBeInstalled[plugin.npmName] } getShowRouterLink (plugin: PeerTubePluginIndex) { @@ -144,7 +187,7 @@ export class PluginSearchComponent implements OnInit { } async install (plugin: PeerTubePluginIndex) { - if (this.installing[plugin.npmName]) return + if (this.toBeInstalled[plugin.npmName]) return const res = await this.confirmService.confirm( $localize`Please only install plugins or themes you trust, since they can execute any code on your instance.`, @@ -152,24 +195,46 @@ export class PluginSearchComponent implements OnInit { ) if (res === false) return - this.installing[plugin.npmName] = true + this.toBeInstalled[plugin.npmName] = true this.pluginApiService.install(plugin.npmName) .subscribe({ next: () => { - this.installing[plugin.npmName] = false - this.pluginInstalled = true - - this.notifier.success($localize`${plugin.name} installed.`) - - plugin.installed = true + this.notifier.success($localize`${plugin.name} will be installed.`) }, error: err => { - this.installing[plugin.npmName] = false + this.toBeInstalled[plugin.npmName] = false this.notifier.error(err.message) } }) } + + private async subscribeToNotifications () { + const obs = await this.peertubeSocket.getMyNotificationsSocket() + + this.notificationSub = obs + .pipe( + filter(d => d.notification?.type === UserNotificationType.PLUGIN_MANAGE_FINISHED) + ).subscribe(data => { + const pluginName = data.notification.plugin?.name + + if (pluginName) { + const npmName = this.pluginService.nameToNpmName(data.notification.plugin.name, data.notification.plugin.type) + + if (this.toBeInstalled[npmName]) { + this.toBeInstalled[npmName] = false + + if (!data.notification.hasOperationFailed) { + const plugin = this.plugins.find(p => p.name === pluginName) + + if (plugin) { + plugin.installed = true + } + } + } + } + }) + } } diff --git a/client/src/app/+admin/system/jobs/job.service.ts b/client/src/app/+admin/system/jobs/job.service.ts index 740adf742a7..59848a2ba04 100644 --- a/client/src/app/+admin/system/jobs/job.service.ts +++ b/client/src/app/+admin/system/jobs/job.service.ts @@ -20,19 +20,20 @@ export class JobService { ) {} listJobs (options: { - jobState?: JobStateClient + jobStates?: JobStateClient[] jobType: JobTypeClient pagination: RestPagination sort: SortMeta }): Observable> { - const { jobState, jobType, pagination, sort } = options + const { jobStates, jobType, pagination, sort } = options let params = new HttpParams() params = this.restService.addRestGetParams(params, pagination, sort) if (jobType !== 'all') params = params.append('jobType', jobType) + if (jobStates) params = params.append('states', jobStates.join(',')) - return this.authHttp.get>(JobService.BASE_JOB_URL + `/${jobState || ''}`, { params }) + return this.authHttp.get>(JobService.BASE_JOB_URL, { params }) .pipe( map(res => this.restExtractor.convertResultListDateToHuman(res, [ 'createdAt', 'processedOn', 'finishedOn' ], 'precise')), map(res => this.restExtractor.applyToResultListData(res, this.prettyPrintData.bind(this))), @@ -41,6 +42,17 @@ export class JobService { ) } + listUnfinishedJobs (options: { + jobType: JobTypeClient + pagination: RestPagination + sort: SortMeta + }): Observable> { + return this.listJobs({ + ...options, + jobStates: [ 'active', 'waiting', 'delayed', 'paused' ] + }) + } + private prettyPrintData (obj: Job) { const data = JSON.stringify(obj.data, null, 2) diff --git a/client/src/app/+admin/system/jobs/jobs.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts index 2ff60f8e052..8bfe6e56902 100644 --- a/client/src/app/+admin/system/jobs/jobs.component.ts +++ b/client/src/app/+admin/system/jobs/jobs.component.ts @@ -61,6 +61,7 @@ export class JobsComponent extends RestTable implements OnInit { 'move-to-file-system', 'move-to-object-storage', 'notify', + 'plugin-manage', 'transcoding-job-builder', 'video-channel-import', 'video-file-import', @@ -154,7 +155,7 @@ export class JobsComponent extends RestTable implements OnInit { this.jobsService .listJobs({ - jobState, + jobStates: [ jobState ], jobType: this.jobType, pagination: this.pagination, sort: this.sort diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts index 69a1b088285..d189b11bb8b 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts +++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts @@ -49,7 +49,8 @@ export class MyAccountNotificationPreferencesComponent implements OnInit { abuseStateChange: $localize`One of your abuse reports has been accepted or rejected by moderators`, newPeerTubeVersion: $localize`A new PeerTube version is available`, newPluginVersion: $localize`One of your plugin/theme has a new available version`, - myVideoStudioEditionFinished: $localize`Video studio edition has finished` + myVideoStudioEditionFinished: $localize`Video studio edition has finished`, + pluginManageFinished: $localize`Plugin or theme has been installed, updated or uninstalled` } this.notificationSettingGroups = [ { @@ -89,7 +90,8 @@ export class MyAccountNotificationPreferencesComponent implements OnInit { 'newInstanceFollower', 'autoInstanceFollowing', 'newPeerTubeVersion', - 'newPluginVersion' + 'newPluginVersion', + 'pluginManageFinished' ] } ] diff --git a/client/src/app/core/notification/peertube-socket.service.ts b/client/src/app/core/notification/peertube-socket.service.ts index 15af9a3109a..57d9b53d55e 100644 --- a/client/src/app/core/notification/peertube-socket.service.ts +++ b/client/src/app/core/notification/peertube-socket.service.ts @@ -1,4 +1,4 @@ -import { Subject } from 'rxjs' +import { Observable, Subject } from 'rxjs' import { ManagerOptions, Socket, SocketOptions } from 'socket.io-client' import { Injectable } from '@angular/core' import { LiveVideoEventPayload, LiveVideoEventType, UserNotification as UserNotificationServer } from '@peertube/peertube-models' @@ -12,6 +12,7 @@ export class PeerTubeSocket { private io: (uri: string, opts?: Partial) => Socket private notificationSubject = new Subject<{ type: NotificationEvent, notification?: UserNotificationServer }>() + private notificationObs: Observable<{ type: NotificationEvent, notification?: UserNotificationServer }> private liveVideosSubject = new Subject<{ type: LiveVideoEventType, payload: LiveVideoEventPayload }>() private notificationSocket: Socket @@ -24,7 +25,11 @@ export class PeerTubeSocket { async getMyNotificationsSocket () { await this.initNotificationSocket() - return this.notificationSubject.asObservable() + if (!this.notificationObs) { + this.notificationObs = this.notificationSubject.asObservable() + } + + return this.notificationObs } getLiveVideosObservable () { diff --git a/client/src/app/shared/shared-main/buttons/delete-button.component.ts b/client/src/app/shared/shared-main/buttons/delete-button.component.ts index a3d296516d4..6914e029ffa 100644 --- a/client/src/app/shared/shared-main/buttons/delete-button.component.ts +++ b/client/src/app/shared/shared-main/buttons/delete-button.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core' +import { Component, Input, OnInit, booleanAttribute } from '@angular/core' import { ButtonComponent } from './button.component' @Component({ @@ -7,6 +7,7 @@ import { ButtonComponent } from './button.component' `, @@ -18,6 +19,7 @@ export class DeleteButtonComponent implements OnInit { @Input() title: string @Input() responsiveLabel = false @Input() disabled: boolean + @Input({ transform: booleanAttribute }) loading = false ngOnInit () { if (this.label === undefined && !this.title) { diff --git a/client/src/app/shared/shared-main/buttons/edit-button.component.ts b/client/src/app/shared/shared-main/buttons/edit-button.component.ts index 69e9ec41354..64249b02404 100644 --- a/client/src/app/shared/shared-main/buttons/edit-button.component.ts +++ b/client/src/app/shared/shared-main/buttons/edit-button.component.ts @@ -1,11 +1,11 @@ -import { Component, Input, OnInit } from '@angular/core' +import { Component, Input, OnInit, booleanAttribute } from '@angular/core' import { ButtonComponent } from './button.component' @Component({ selector: 'my-edit-button', template: ` `, @@ -13,6 +13,7 @@ import { ButtonComponent } from './button.component' imports: [ ButtonComponent ] }) export class EditButtonComponent implements OnInit { + @Input({ transform: booleanAttribute }) disabled = false @Input() label: string @Input() title: string @Input() ptRouterLink: string[] | string = [] diff --git a/client/src/app/shared/shared-main/users/user-notification.model.ts b/client/src/app/shared/shared-main/users/user-notification.model.ts index 8e590725536..02e34d1c7f4 100644 --- a/client/src/app/shared/shared-main/users/user-notification.model.ts +++ b/client/src/app/shared/shared-main/users/user-notification.model.ts @@ -20,6 +20,7 @@ export class UserNotification implements UserNotificationServer { id: number type: UserNotificationType_Type read: boolean + hasOperationFailed: boolean video?: VideoInfo & { channel: ActorInfo & { avatarUrl?: string } @@ -119,10 +120,13 @@ export class UserNotification implements UserNotificationServer { pluginUrl?: string pluginQueryParams?: { [id: string]: string } = {} + jobUrl?: string + constructor (hash: UserNotificationServer, user: AuthUser) { this.id = hash.id this.type = hash.type this.read = hash.read + this.hasOperationFailed = hash.hasOperationFailed // We assume that some fields exist // To prevent a notification popup crash in case of bug, wrap it inside a try/catch @@ -250,6 +254,15 @@ export class UserNotification implements UserNotificationServer { this.pluginQueryParams.pluginType = this.plugin.type + '' break + case UserNotificationType.PLUGIN_MANAGE_FINISHED: + this.pluginUrl = '/admin/plugins/list-installed' + this.jobUrl = '/admin/system/jobs' + + if (this.plugin) { + this.pluginQueryParams.pluginType = this.plugin.type + '' + } + break + case UserNotificationType.MY_VIDEO_STUDIO_EDITION_FINISHED: this.videoUrl = this.buildVideoUrl(this.video) break diff --git a/client/src/app/shared/standalone-notifications/user-notifications.component.html b/client/src/app/shared/standalone-notifications/user-notifications.component.html index d3367ede9e0..6df520c6406 100644 --- a/client/src/app/shared/standalone-notifications/user-notifications.component.html +++ b/client/src/app/shared/standalone-notifications/user-notifications.component.html @@ -239,6 +239,24 @@ } + + + +
+ + The plugin/theme {{ notification.plugin.name }} + A plugin/theme + has been installed, updated or uninstalled. +
+ +
+ + The plugin/theme {{ notification.plugin.name }} + A plugin/theme + has failed to be installed, updated or uninstalled. +
+
+ diff --git a/packages/models/src/server/job.model.ts b/packages/models/src/server/job.model.ts index 7713a9d3e73..5814ba4feda 100644 --- a/packages/models/src/server/job.model.ts +++ b/packages/models/src/server/job.model.ts @@ -22,6 +22,7 @@ export type JobType = | 'move-to-object-storage' | 'move-to-file-system' | 'notify' + | 'plugin-manage' | 'video-channel-import' | 'video-file-import' | 'video-import' @@ -87,6 +88,18 @@ export type RefreshPayload = { export type EmailPayload = SendEmailOptions +export type PluginManagePayload = { + action: 'install' | 'update' + npmName: string + path?: string + version?: string + userId: number +} | { + action: 'uninstall' + npmName: string + userId: number +} + export type VideoFileImportPayload = { videoUUID: string filePath: string diff --git a/packages/models/src/users/user-notification-setting.model.ts b/packages/models/src/users/user-notification-setting.model.ts index fbd94994e50..084fcf93aea 100644 --- a/packages/models/src/users/user-notification-setting.model.ts +++ b/packages/models/src/users/user-notification-setting.model.ts @@ -29,6 +29,7 @@ export interface UserNotificationSetting { newPeerTubeVersion: UserNotificationSettingValueType newPluginVersion: UserNotificationSettingValueType + pluginManageFinished: UserNotificationSettingValueType myVideoStudioEditionFinished: UserNotificationSettingValueType } diff --git a/packages/models/src/users/user-notification.model.ts b/packages/models/src/users/user-notification.model.ts index e8435f031a8..8904a47260c 100644 --- a/packages/models/src/users/user-notification.model.ts +++ b/packages/models/src/users/user-notification.model.ts @@ -36,7 +36,9 @@ export const UserNotificationType = { NEW_USER_REGISTRATION_REQUEST: 20, - NEW_LIVE_FROM_SUBSCRIPTION: 21 + NEW_LIVE_FROM_SUBSCRIPTION: 21, + + PLUGIN_MANAGE_FINISHED: 22 } as const export type UserNotificationType_Type = typeof UserNotificationType[keyof typeof UserNotificationType] @@ -67,6 +69,7 @@ export interface UserNotification { id: number type: UserNotificationType_Type read: boolean + hasOperationFailed: boolean video?: VideoInfo & { channel: ActorInfo diff --git a/packages/server-commands/src/server/plugins-command.ts b/packages/server-commands/src/server/plugins-command.ts index 03ff7876e51..8377b1001b9 100644 --- a/packages/server-commands/src/server/plugins-command.ts +++ b/packages/server-commands/src/server/plugins-command.ts @@ -170,7 +170,7 @@ export class PluginsCommand extends AbstractCommand { path: apiPath, fields: { npmName, path, pluginVersion }, implicitToken: true, - defaultExpectedStatus: HttpStatusCode.OK_200 + defaultExpectedStatus: HttpStatusCode.CREATED_201 }) } @@ -187,7 +187,7 @@ export class PluginsCommand extends AbstractCommand { path: apiPath, fields: { npmName, path }, implicitToken: true, - defaultExpectedStatus: HttpStatusCode.OK_200 + defaultExpectedStatus: HttpStatusCode.CREATED_201 }) } @@ -203,7 +203,7 @@ export class PluginsCommand extends AbstractCommand { path: apiPath, fields: { npmName }, implicitToken: true, - defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + defaultExpectedStatus: HttpStatusCode.CREATED_201 }) } diff --git a/packages/tests/src/api/check-params/plugins.ts b/packages/tests/src/api/check-params/plugins.ts index 7d1a28a6282..4d9e5d13d01 100644 --- a/packages/tests/src/api/check-params/plugins.ts +++ b/packages/tests/src/api/check-params/plugins.ts @@ -9,7 +9,8 @@ import { makePostBodyRequest, makePutBodyRequest, PeerTubeServer, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' describe('Test server plugins API validators', function () { @@ -42,14 +43,16 @@ describe('Test server plugins API validators', function () { userAccessToken = await server.login.getAccessToken(user) { - const res = await server.plugins.install({ npmName: npmPlugin }) - const plugin = res.body as PeerTubePlugin + await server.plugins.install({ npmName: npmPlugin }) + await waitJobs(server) + const plugin = await server.plugins.get({ npmName: npmPlugin }) npmVersion = plugin.version } { - const res = await server.plugins.install({ npmName: themePlugin }) - const plugin = res.body as PeerTubePlugin + await server.plugins.install({ npmName: themePlugin }) + await waitJobs(server) + const plugin = await server.plugins.get({ npmName: themePlugin }) as PeerTubePlugin themeVersion = plugin.version } }) diff --git a/packages/tests/src/api/check-params/user-notifications.ts b/packages/tests/src/api/check-params/user-notifications.ts index cf20324a1f3..a38136d0263 100644 --- a/packages/tests/src/api/check-params/user-notifications.ts +++ b/packages/tests/src/api/check-params/user-notifications.ts @@ -171,7 +171,8 @@ describe('Test user notifications API validators', function () { abuseStateChange: UserNotificationSettingValue.WEB, newPeerTubeVersion: UserNotificationSettingValue.WEB, myVideoStudioEditionFinished: UserNotificationSettingValue.WEB, - newPluginVersion: UserNotificationSettingValue.WEB + newPluginVersion: UserNotificationSettingValue.WEB, + pluginManageFinished: UserNotificationSettingValue.WEB } it('Should fail with missing fields', async function () { diff --git a/packages/tests/src/api/notifications/admin-notifications.ts b/packages/tests/src/api/notifications/admin-notifications.ts index e183caf9579..a4ea600b103 100644 --- a/packages/tests/src/api/notifications/admin-notifications.ts +++ b/packages/tests/src/api/notifications/admin-notifications.ts @@ -3,7 +3,7 @@ import { expect } from 'chai' import { wait } from '@peertube/peertube-core-utils' import { PluginType, UserNotification, UserNotificationType } from '@peertube/peertube-models' -import { cleanupTests, PeerTubeServer } from '@peertube/peertube-server-commands' +import { cleanupTests, PeerTubeServer, waitJobs } from '@peertube/peertube-server-commands' import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' import { MockJoinPeerTubeVersions } from '@tests/shared/mock-servers/mock-joinpeertube-versions.js' import { CheckerBaseParams, prepareNotificationsTest, checkNewPeerTubeVersion, checkNewPluginVersion } from '@tests/shared/notifications.js' @@ -55,6 +55,7 @@ describe('Test admin notifications', function () { await server.plugins.install({ npmName: 'peertube-plugin-hello-world' }) await server.plugins.install({ npmName: 'peertube-theme-background-red' }) + await waitJobs(server) sqlCommand = new SQLCommand(server) }) diff --git a/packages/tests/src/api/server/plugins.ts b/packages/tests/src/api/server/plugins.ts index 1df22f15a81..160b059e8d8 100644 --- a/packages/tests/src/api/server/plugins.ts +++ b/packages/tests/src/api/server/plugins.ts @@ -12,7 +12,8 @@ import { makeGetRequest, PeerTubeServer, PluginsCommand, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' import { SQLCommand } from '@tests/shared/sql-command.js' import { testHelloWorldRegisteredSettings } from '@tests/shared/plugins.js' @@ -96,6 +97,7 @@ describe('Test plugins', function () { await command.install({ npmName: 'peertube-plugin-hello-world' }) await command.install({ npmName: 'peertube-theme-background-red' }) + await waitJobs(server) }) it('Should have the plugin loaded in the configuration', async function () { @@ -274,6 +276,7 @@ describe('Test plugins', function () { { await command.update({ npmName: `peertube-${type}-${name}` }) + await waitJobs(server) const plugin = await getPluginFromAPI() expect(plugin.version).to.equal(oldVersion) @@ -291,6 +294,7 @@ describe('Test plugins', function () { it('Should uninstall the plugin', async function () { await command.uninstall({ npmName: 'peertube-plugin-hello-world' }) + await waitJobs(server) const body = await command.list({ pluginType: PluginType.PLUGIN }) expect(body.total).to.equal(0) @@ -310,6 +314,7 @@ describe('Test plugins', function () { it('Should uninstall the theme', async function () { await command.uninstall({ npmName: 'peertube-theme-background-red' }) + await waitJobs(server) }) it('Should have updated the configuration', async function () { @@ -342,6 +347,7 @@ describe('Test plugins', function () { path: PluginsCommand.getPluginTestPath('-broken'), expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await waitJobs(server) await check() @@ -360,6 +366,7 @@ describe('Test plugins', function () { } await command.install({ path: PluginsCommand.getPluginTestPath('-native') }) + await waitJobs(server) await makeGetRequest({ url: server.url, diff --git a/packages/tests/src/api/users/users.ts b/packages/tests/src/api/users/users.ts index af4f193409e..0c99ce612ee 100644 --- a/packages/tests/src/api/users/users.ts +++ b/packages/tests/src/api/users/users.ts @@ -6,7 +6,8 @@ import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType import { cleanupTests, createSingleServer, PeerTubeServer, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' describe('Test users', function () { @@ -34,6 +35,7 @@ describe('Test users', function () { await setAccessTokensToServers([ server ]) await server.plugins.install({ npmName: 'peertube-theme-background-red' }) + await waitJobs(server) }) describe('Creating a user', function () { diff --git a/packages/tests/src/external-plugins/akismet.ts b/packages/tests/src/external-plugins/akismet.ts index 89d7784714a..7cd97820026 100644 --- a/packages/tests/src/external-plugins/akismet.ts +++ b/packages/tests/src/external-plugins/akismet.ts @@ -24,6 +24,7 @@ describe('Official plugin Akismet', function () { await servers[0].plugins.install({ npmName: 'peertube-plugin-akismet' }) + await waitJobs(servers[0]) if (!process.env.AKISMET_KEY) throw new Error('Missing AKISMET_KEY from env') diff --git a/packages/tests/src/external-plugins/auth-ldap.ts b/packages/tests/src/external-plugins/auth-ldap.ts index ad058110c16..76d71e882bc 100644 --- a/packages/tests/src/external-plugins/auth-ldap.ts +++ b/packages/tests/src/external-plugins/auth-ldap.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ import { expect } from 'chai' -import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@peertube/peertube-server-commands' import { HttpStatusCode } from '@peertube/peertube-models' describe('Official plugin auth-ldap', function () { @@ -16,6 +16,7 @@ describe('Official plugin auth-ldap', function () { await setAccessTokensToServers([ server ]) await server.plugins.install({ npmName: 'peertube-plugin-auth-ldap' }) + await waitJobs(server) }) it('Should not login with without LDAP settings', async function () { @@ -104,6 +105,7 @@ describe('Official plugin auth-ldap', function () { it('Should not login if the plugin is uninstalled', async function () { await server.plugins.uninstall({ npmName: 'peertube-plugin-auth-ldap' }) + await waitJobs(server) await server.login.login({ user: { username: 'fry@planetexpress.com', password: 'fry' }, diff --git a/packages/tests/src/external-plugins/auto-block-videos.ts b/packages/tests/src/external-plugins/auto-block-videos.ts index 6146c827cff..b7feb8b05c4 100644 --- a/packages/tests/src/external-plugins/auto-block-videos.ts +++ b/packages/tests/src/external-plugins/auto-block-videos.ts @@ -9,7 +9,8 @@ import { doubleFollow, killallServers, PeerTubeServer, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' import { MockBlocklist } from '../shared/mock-servers/index.js' @@ -38,6 +39,7 @@ describe('Official plugin auto-block videos', function () { for (const server of servers) { await server.plugins.install({ npmName: 'peertube-plugin-auto-block-videos' }) } + await waitJobs(servers) blocklistServer = new MockBlocklist() port = await blocklistServer.initialize() diff --git a/packages/tests/src/external-plugins/auto-mute.ts b/packages/tests/src/external-plugins/auto-mute.ts index b4050e236f2..2843018735a 100644 --- a/packages/tests/src/external-plugins/auto-mute.ts +++ b/packages/tests/src/external-plugins/auto-mute.ts @@ -10,7 +10,8 @@ import { killallServers, makeGetRequest, PeerTubeServer, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' import { MockBlocklist } from '../shared/mock-servers/index.js' @@ -29,6 +30,7 @@ describe('Official plugin auto-mute', function () { for (const server of servers) { await server.plugins.install({ npmName: 'peertube-plugin-auto-mute' }) } + await waitJobs(servers) blocklistServer = new MockBlocklist() port = await blocklistServer.initialize() diff --git a/packages/tests/src/external-plugins/privacy-remover.ts b/packages/tests/src/external-plugins/privacy-remover.ts index ed10fd868ee..0d054a7c219 100644 --- a/packages/tests/src/external-plugins/privacy-remover.ts +++ b/packages/tests/src/external-plugins/privacy-remover.ts @@ -24,6 +24,7 @@ describe('Official plugin Privacy Remover', function () { await servers[0].plugins.install({ npmName: 'peertube-plugin-privacy-remover' }) + await waitJobs(servers[0]) await doubleFollow(servers[0], servers[1]) }) diff --git a/packages/tests/src/feeds/feeds.ts b/packages/tests/src/feeds/feeds.ts index ed36a87bc54..609b97adf26 100644 --- a/packages/tests/src/feeds/feeds.ts +++ b/packages/tests/src/feeds/feeds.ts @@ -112,6 +112,7 @@ describe('Test syndication feeds', () => { await waitJobs([ ...servers, serverHLSOnly ]) await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-podcast-custom-tags') }) + await waitJobs(servers[0]) }) describe('All feed', function () { @@ -792,6 +793,7 @@ describe('Test syndication feeds', () => { after(async function () { await servers[0].plugins.uninstall({ npmName: 'peertube-plugin-test-podcast-custom-tags' }) + await waitJobs(servers[0]) await cleanupTests([ ...servers, serverHLSOnly ]) }) diff --git a/packages/tests/src/plugins/action-hooks.ts b/packages/tests/src/plugins/action-hooks.ts index 136c7671b30..70ee9aea53d 100644 --- a/packages/tests/src/plugins/action-hooks.ts +++ b/packages/tests/src/plugins/action-hooks.ts @@ -31,6 +31,7 @@ describe('Test plugin action hooks', function () { await setDefaultVideoChannel(servers) await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath() }) + await waitJobs(servers[0]) await killallServers([ servers[0] ]) diff --git a/packages/tests/src/plugins/external-auth.ts b/packages/tests/src/plugins/external-auth.ts index c7fe2218591..116c09c5dc0 100644 --- a/packages/tests/src/plugins/external-auth.ts +++ b/packages/tests/src/plugins/external-auth.ts @@ -9,7 +9,8 @@ import { decodeQueryString, PeerTubeServer, PluginsCommand, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' async function loginExternal (options: { @@ -71,6 +72,8 @@ describe('Test external auth plugins', function () { for (const suffix of [ 'one', 'two', 'three' ]) { await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-external-auth-' + suffix) }) } + + await waitJobs(server) }) it('Should display the correct configuration', async function () { @@ -327,6 +330,7 @@ describe('Test external auth plugins', function () { it('Should uninstall the plugin one and do not login Cyan', async function () { await server.plugins.uninstall({ npmName: 'peertube-plugin-test-external-auth-one' }) + await waitJobs(server) await loginExternal({ server, diff --git a/packages/tests/src/plugins/filter-hooks.ts b/packages/tests/src/plugins/filter-hooks.ts index efa9a0f5035..88b801763c0 100644 --- a/packages/tests/src/plugins/filter-hooks.ts +++ b/packages/tests/src/plugins/filter-hooks.ts @@ -44,6 +44,9 @@ describe('Test plugin filter hooks', function () { await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath() }) await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-filter-translations') }) + + await waitJobs(servers[0]) + { ({ uuid: videoPlaylistUUID } = await servers[0].playlists.create({ attributes: { diff --git a/packages/tests/src/plugins/html-injection.ts b/packages/tests/src/plugins/html-injection.ts index 269a45b987e..635d4eb80dd 100644 --- a/packages/tests/src/plugins/html-injection.ts +++ b/packages/tests/src/plugins/html-injection.ts @@ -7,7 +7,8 @@ import { makeHTMLRequest, PeerTubeServer, PluginsCommand, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' describe('Test plugins HTML injection', function () { @@ -39,6 +40,7 @@ describe('Test plugins HTML injection', function () { this.timeout(30000) await command.install({ npmName: 'peertube-plugin-hello-world' }) + await waitJobs(server) }) it('Should have the correct global css', async function () { @@ -55,6 +57,7 @@ describe('Test plugins HTML injection', function () { it('Should have an empty global css on uninstall', async function () { await command.uninstall({ npmName: 'peertube-plugin-hello-world' }) + await waitJobs(server) { const text = await command.getCSS() diff --git a/packages/tests/src/plugins/id-and-pass-auth.ts b/packages/tests/src/plugins/id-and-pass-auth.ts index 9fcdf5aa92b..4675b5dd042 100644 --- a/packages/tests/src/plugins/id-and-pass-auth.ts +++ b/packages/tests/src/plugins/id-and-pass-auth.ts @@ -8,7 +8,8 @@ import { createSingleServer, PeerTubeServer, PluginsCommand, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' describe('Test id and pass auth plugins', function () { @@ -30,6 +31,7 @@ describe('Test id and pass auth plugins', function () { for (const suffix of [ 'one', 'two', 'three' ]) { await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-id-pass-auth-' + suffix) }) } + await waitJobs(server) }) it('Should display the correct configuration', async function () { @@ -213,6 +215,7 @@ describe('Test id and pass auth plugins', function () { it('Should uninstall the plugin one and do not login existing Crash', async function () { await server.plugins.uninstall({ npmName: 'peertube-plugin-test-id-pass-auth-one' }) + await waitJobs(server) await server.login.login({ user: { username: 'crash', password: 'crash password' }, diff --git a/packages/tests/src/plugins/plugin-helpers.ts b/packages/tests/src/plugins/plugin-helpers.ts index d2bd8596e58..31cfd91d0c2 100644 --- a/packages/tests/src/plugins/plugin-helpers.ts +++ b/packages/tests/src/plugins/plugin-helpers.ts @@ -41,6 +41,7 @@ describe('Test plugin helpers', function () { await doubleFollow(servers[0], servers[1]) await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-four') }) + await waitJobs(servers[0]) }) describe('Logger', function () { diff --git a/packages/tests/src/plugins/plugin-router.ts b/packages/tests/src/plugins/plugin-router.ts index 82cf0321ea7..82d29596f7d 100644 --- a/packages/tests/src/plugins/plugin-router.ts +++ b/packages/tests/src/plugins/plugin-router.ts @@ -8,7 +8,8 @@ import { makePostBodyRequest, PeerTubeServer, PluginsCommand, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' import { HttpStatusCode } from '@peertube/peertube-models' @@ -26,6 +27,7 @@ describe('Test plugin helpers', function () { await setAccessTokensToServers([ server ]) await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-five') }) + await waitJobs(server) }) it('Should answer "pong"', async function () { @@ -100,6 +102,7 @@ describe('Test plugin helpers', function () { it('Should remove the plugin and remove the routes', async function () { await server.plugins.uninstall({ npmName: 'peertube-plugin-test-five' }) + await waitJobs(server) for (const path of basePaths) { await makeGetRequest({ diff --git a/packages/tests/src/plugins/plugin-settings.ts b/packages/tests/src/plugins/plugin-settings.ts index cd41c425d2a..a390fd9792d 100644 --- a/packages/tests/src/plugins/plugin-settings.ts +++ b/packages/tests/src/plugins/plugin-settings.ts @@ -6,7 +6,8 @@ import { createSingleServer, PeerTubeServer, PluginsCommand, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' describe('Test plugin settings', function () { @@ -24,6 +25,7 @@ describe('Test plugin settings', function () { await command.install({ path: PluginsCommand.getPluginTestPath() }) + await waitJobs(server) }) it('Should not have duplicate settings', async function () { diff --git a/packages/tests/src/plugins/plugin-storage.ts b/packages/tests/src/plugins/plugin-storage.ts index f9b0ead0c2c..9521838253d 100644 --- a/packages/tests/src/plugins/plugin-storage.ts +++ b/packages/tests/src/plugins/plugin-storage.ts @@ -11,7 +11,8 @@ import { makeGetRequest, PeerTubeServer, PluginsCommand, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' describe('Test plugin storage', function () { @@ -24,6 +25,7 @@ describe('Test plugin storage', function () { await setAccessTokensToServers([ server ]) await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-six') }) + await waitJobs(server) }) describe('DB storage', function () { @@ -76,6 +78,7 @@ describe('Test plugin storage', function () { it('Should still have the file after an uninstallation', async function () { await server.plugins.uninstall({ npmName: 'peertube-plugin-test-six' }) + await waitJobs(server) const content = await getFileContent() expect(content).to.equal('Prince Ali') @@ -83,6 +86,7 @@ describe('Test plugin storage', function () { it('Should still have the file after the reinstallation', async function () { await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-six') }) + await waitJobs(server) const content = await getFileContent() expect(content).to.equal('Prince Ali') diff --git a/packages/tests/src/plugins/plugin-transcoding.ts b/packages/tests/src/plugins/plugin-transcoding.ts index b36d32289a1..b0c538ffad8 100644 --- a/packages/tests/src/plugins/plugin-transcoding.ts +++ b/packages/tests/src/plugins/plugin-transcoding.ts @@ -105,6 +105,7 @@ describe('Test transcoding plugins', function () { before(async function () { await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-transcoding-one') }) + await waitJobs(server) }) it('Should have the appropriate available profiles', async function () { @@ -219,6 +220,7 @@ describe('Test transcoding plugins', function () { this.timeout(240000) await server.plugins.uninstall({ npmName: 'peertube-plugin-test-transcoding-one' }) + await waitJobs(server) const config = await server.config.getConfig() @@ -238,6 +240,7 @@ describe('Test transcoding plugins', function () { before(async function () { await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-transcoding-two') }) + await waitJobs(server) await updateConf(server, 'test-vod-profile', 'test-live-profile') }) diff --git a/packages/tests/src/plugins/plugin-unloading.ts b/packages/tests/src/plugins/plugin-unloading.ts index 70310bc8c1b..2b77845517e 100644 --- a/packages/tests/src/plugins/plugin-unloading.ts +++ b/packages/tests/src/plugins/plugin-unloading.ts @@ -7,7 +7,8 @@ import { makeGetRequest, PeerTubeServer, PluginsCommand, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' import { HttpStatusCode } from '@peertube/peertube-models' @@ -23,6 +24,7 @@ describe('Test plugins module unloading', function () { await setAccessTokensToServers([ server ]) await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-unloading') }) + await waitJobs(server) }) it('Should return a numeric value', async function () { @@ -48,6 +50,7 @@ describe('Test plugins module unloading', function () { it('Should uninstall the plugin and free the route', async function () { await server.plugins.uninstall({ npmName: 'peertube-plugin-test-unloading' }) + await waitJobs(server) await makeGetRequest({ url: server.url, @@ -58,6 +61,7 @@ describe('Test plugins module unloading', function () { it('Should return a different numeric value', async function () { await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-unloading') }) + await waitJobs(server) const res = await makeGetRequest({ url: server.url, diff --git a/packages/tests/src/plugins/plugin-websocket.ts b/packages/tests/src/plugins/plugin-websocket.ts index 832dcebd0e2..73fe9a28f00 100644 --- a/packages/tests/src/plugins/plugin-websocket.ts +++ b/packages/tests/src/plugins/plugin-websocket.ts @@ -6,7 +6,8 @@ import { createSingleServer, PeerTubeServer, PluginsCommand, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' function buildWebSocket (server: PeerTubeServer, path: string) { @@ -42,6 +43,7 @@ describe('Test plugin websocket', function () { await setAccessTokensToServers([ server ]) await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-websocket') }) + await waitJobs(server) }) it('Should not connect to the websocket without the appropriate path', async function () { diff --git a/packages/tests/src/plugins/translations.ts b/packages/tests/src/plugins/translations.ts index a69e1413422..38e9743c89b 100644 --- a/packages/tests/src/plugins/translations.ts +++ b/packages/tests/src/plugins/translations.ts @@ -6,7 +6,8 @@ import { createSingleServer, PeerTubeServer, PluginsCommand, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' describe('Test plugin translations', function () { @@ -23,6 +24,7 @@ describe('Test plugin translations', function () { await command.install({ path: PluginsCommand.getPluginTestPath() }) await command.install({ path: PluginsCommand.getPluginTestPath('-filter-translations') }) + await waitJobs(server) }) it('Should not have translations for locale pt', async function () { @@ -56,6 +58,7 @@ describe('Test plugin translations', function () { it('Should remove the plugin and remove the locales', async function () { await command.uninstall({ npmName: 'peertube-plugin-test-filter-translations' }) + await waitJobs(server) { const body = await command.getTranslations({ locale: 'fr-FR' }) diff --git a/packages/tests/src/plugins/video-constants.ts b/packages/tests/src/plugins/video-constants.ts index b81240a6491..cadc101180d 100644 --- a/packages/tests/src/plugins/video-constants.ts +++ b/packages/tests/src/plugins/video-constants.ts @@ -7,7 +7,8 @@ import { makeGetRequest, PeerTubeServer, PluginsCommand, - setAccessTokensToServers + setAccessTokensToServers, + waitJobs } from '@peertube/peertube-server-commands' import { HttpStatusCode, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models' @@ -21,6 +22,7 @@ describe('Test plugin altering video constants', function () { await setAccessTokensToServers([ server ]) await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-video-constants') }) + await waitJobs(server) }) it('Should have updated languages', async function () { @@ -93,6 +95,7 @@ describe('Test plugin altering video constants', function () { it('Should uninstall the plugin and reset languages, categories, licences and privacies', async function () { await server.plugins.uninstall({ npmName: 'peertube-plugin-test-video-constants' }) + await waitJobs(server) { const languages = await server.videos.getLanguages() @@ -145,6 +148,7 @@ describe('Test plugin altering video constants', function () { it('Should be able to reset categories', async function () { await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-video-constants') }) + await waitJobs(server) { const categories = await server.videos.getCategories() diff --git a/packages/tests/src/shared/notifications.ts b/packages/tests/src/shared/notifications.ts index 5f4b240f913..1b715a5e4a0 100644 --- a/packages/tests/src/shared/notifications.ts +++ b/packages/tests/src/shared/notifications.ts @@ -54,7 +54,8 @@ function getAllNotificationsSettings (): UserNotificationSetting { autoInstanceFollowing: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, newPeerTubeVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, myVideoStudioEditionFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - newPluginVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL + newPluginVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + pluginManageFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL } } diff --git a/server/core/assets/email-templates/plugin-manage-failed/html.pug b/server/core/assets/email-templates/plugin-manage-failed/html.pug new file mode 100644 index 00000000000..f61bf0b66f0 --- /dev/null +++ b/server/core/assets/email-templates/plugin-manage-failed/html.pug @@ -0,0 +1,8 @@ +extends ../common/greetings + +block title + | Failed to #{action} #{pluginType} #{pluginName} + +block content + p + | PeerTube failed to #{action} #{pluginType} #{pluginName}. To find more information about the issue you can visit #[a(href=jobsUrl) the PeerTube admin interface]. diff --git a/server/core/assets/email-templates/plugin-manage/html.pug b/server/core/assets/email-templates/plugin-manage/html.pug new file mode 100644 index 00000000000..e93030bade8 --- /dev/null +++ b/server/core/assets/email-templates/plugin-manage/html.pug @@ -0,0 +1,8 @@ +extends ../common/greetings + +block title + | The #{pluginType} #{pluginName} has been #{actionPerfect} + +block content + p + | To see all installed plugin and themes you can visit #[a(href=pluginUrl) the PeerTube admin interface]. diff --git a/server/core/controllers/api/jobs.ts b/server/core/controllers/api/jobs.ts index ffaca4a9e9e..68e44423b96 100644 --- a/server/core/controllers/api/jobs.ts +++ b/server/core/controllers/api/jobs.ts @@ -32,7 +32,7 @@ jobsRouter.post('/resume', resumeJobQueue ) -jobsRouter.get('/:state?', +jobsRouter.get('/', openapiOperationDoc({ operationId: 'getJobs' }), authenticate, ensureUserHasRight(UserRight.MANAGE_JOBS), @@ -65,31 +65,31 @@ function resumeJobQueue (req: express.Request, res: express.Response) { } async function listJobs (req: express.Request, res: express.Response) { - const state = req.params.state as JobState + const states = (req.query.states || '').split(',').filter(s => !!s) as JobState[] const asc = req.query.sort === 'createdAt' const jobType = req.query.jobType const jobs = await JobQueue.Instance.listForApi({ - state, + states, start: req.query.start, count: req.query.count, asc, jobType }) - const total = await JobQueue.Instance.count(state, jobType) + const total = await JobQueue.Instance.count(states, jobType) const result: ResultList = { total, - data: await Promise.all(jobs.map(j => formatJob(j, state))) + data: await Promise.all(jobs.map(j => formatJob(j))) } return res.json(result) } -async function formatJob (job: BullJob, state?: JobState): Promise { +async function formatJob (job: BullJob): Promise { return { id: job.id, - state: state || await job.getState(), + state: await job.getState(), type: job.queueName as JobType, data: job.data, parent: job.parent diff --git a/server/core/controllers/api/plugins.ts b/server/core/controllers/api/plugins.ts index 85e458c9ea9..57eba648df9 100644 --- a/server/core/controllers/api/plugins.ts +++ b/server/core/controllers/api/plugins.ts @@ -33,6 +33,8 @@ import { RegisteredServerSettings, UserRight } from '@peertube/peertube-models' +import { CreateJobArgument, JobQueue } from '@server/lib/job-queue/job-queue.js' +import { basename } from 'path' const pluginRouter = express.Router() @@ -144,45 +146,74 @@ function getPlugin (req: express.Request, res: express.Response) { async function installPlugin (req: express.Request, res: express.Response) { const body: InstallOrUpdatePlugin = req.body - - const fromDisk = !!body.path - const toInstall = body.npmName || body.path + const npmName = body.npmName ?? basename(body.path) const pluginVersion = body.pluginVersion && body.npmName ? body.pluginVersion : undefined + const options = { + type: 'plugin-manage', + payload: { + action: 'install', + npmName, + path: body.path, + version: pluginVersion, + userId: res.locals.oauth.token.user.id + } + } as CreateJobArgument try { - const plugin = await PluginManager.Instance.install({ toInstall, version: pluginVersion, fromDisk }) + await JobQueue.Instance.createJob(options) - return res.json(plugin.toFormattedJSON()) + return res.status(HttpStatusCode.CREATED_201).end() } catch (err) { - logger.warn('Cannot install plugin %s.', toInstall, { err }) - return res.fail({ message: 'Cannot install plugin ' + toInstall }) + logger.error('Cannot create plugin-install job.', { err, options }) + return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end() } } async function updatePlugin (req: express.Request, res: express.Response) { const body: InstallOrUpdatePlugin = req.body + const npmName = body.npmName ?? basename(body.path) + + const options = { + type: 'plugin-manage', + payload: { + action: 'update', + npmName, + path: body.path, + userId: res.locals.oauth.token.user.id + } + } as CreateJobArgument - const fromDisk = !!body.path - const toUpdate = body.npmName || body.path try { - const plugin = await PluginManager.Instance.update(toUpdate, fromDisk) + await JobQueue.Instance.createJob(options) - return res.json(plugin.toFormattedJSON()) + return res.status(HttpStatusCode.CREATED_201).end() } catch (err) { - logger.warn('Cannot update plugin %s.', toUpdate, { err }) - return res.fail({ message: 'Cannot update plugin ' + toUpdate }) + logger.error('Cannot create plugin-install job.', { err, options }) + return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end() } } async function uninstallPlugin (req: express.Request, res: express.Response) { const body: ManagePlugin = req.body - await PluginManager.Instance.uninstall({ npmName: body.npmName }) + try { + await JobQueue.Instance.createJob({ + type: 'plugin-manage', + payload: { + action: 'uninstall', + npmName: body.npmName, + userId: res.locals.oauth.token.user.id + } + }) - return res.status(HttpStatusCode.NO_CONTENT_204).end() + return res.status(HttpStatusCode.CREATED_201).end() + } catch (err) { + logger.error('Cannot create plugin-uninstall job for %s.', body.npmName, { err }) + return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end() + } } function getPublicPluginSettings (req: express.Request, res: express.Response) { diff --git a/server/core/controllers/api/users/my-notifications.ts b/server/core/controllers/api/users/my-notifications.ts index 3266dd811de..694de08f2b0 100644 --- a/server/core/controllers/api/users/my-notifications.ts +++ b/server/core/controllers/api/users/my-notifications.ts @@ -76,6 +76,7 @@ async function updateNotificationSettings (req: express.Request, res: express.Re abuseStateChange: body.abuseStateChange, newPeerTubeVersion: body.newPeerTubeVersion, newPluginVersion: body.newPluginVersion, + pluginManageFinished: body.pluginManageFinished, myVideoStudioEditionFinished: body.myVideoStudioEditionFinished } diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index ebb205a33ff..a60bb779fe8 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -47,7 +47,7 @@ import { cpus } from 'os' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 850 +const LAST_MIGRATION_VERSION = 860 // --------------------------------------------------------------------------- @@ -213,7 +213,8 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = { 'notify': 1, 'federate-video': 1, 'create-user-export': 1, - 'import-user-archive': 1 + 'import-user-archive': 1, + 'plugin-manage': 1 } // Excluded keys are jobs that can be configured by admins const JOB_CONCURRENCY: { [id in Exclude]: number } = { @@ -241,7 +242,8 @@ const JOB_CONCURRENCY: { [id in Exclude { + const { transaction } = utils + + { + const data = { + type: Sequelize.INTEGER, + defaultValue: null, + allowNull: true + } + await utils.queryInterface.addColumn('userNotificationSetting', 'pluginManageFinished', data, { transaction }) + } + + { + const query = 'UPDATE "userNotificationSetting" SET "pluginManageFinished" = 1' + await utils.sequelize.query(query, { transaction }) + } + + { + const data = { + type: Sequelize.INTEGER, + defaultValue: null, + allowNull: false + } + await utils.queryInterface.changeColumn('userNotificationSetting', 'pluginManageFinished', data, { transaction }) + } +} + +function down () { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/core/initializers/migrations/0860-user-notification-has-operation-failed.ts b/server/core/initializers/migrations/0860-user-notification-has-operation-failed.ts new file mode 100644 index 00000000000..0f4bae7165a --- /dev/null +++ b/server/core/initializers/migrations/0860-user-notification-has-operation-failed.ts @@ -0,0 +1,42 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize + db: any +}): Promise { + const { transaction } = utils + + { + const data = { + type: Sequelize.BOOLEAN, + defaultValue: null, + allowNull: true + } + await utils.queryInterface.addColumn('userNotification', 'hasOperationFailed', data, { transaction }) + } + + { + const query = 'UPDATE "userNotification" SET "hasOperationFailed" = false' + await utils.sequelize.query(query, { transaction }) + } + + { + const data = { + type: Sequelize.BOOLEAN, + defaultValue: null, + allowNull: false + } + await utils.queryInterface.changeColumn('userNotification', 'hasOperationFailed', data, { transaction }) + } +} + +function down () { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/core/lib/job-queue/handlers/plugin-manage.ts b/server/core/lib/job-queue/handlers/plugin-manage.ts new file mode 100644 index 00000000000..242c72f6000 --- /dev/null +++ b/server/core/lib/job-queue/handlers/plugin-manage.ts @@ -0,0 +1,64 @@ +import { Job } from 'bullmq' +import { PluginManagePayload } from '@peertube/peertube-models' +import { logger } from '../../../helpers/logger.js' +import { PluginManager } from '@server/lib/plugins/plugin-manager.js' +import { Notifier } from '@server/lib/notifier/index.js' +import { PluginModel } from '@server/models/server/plugin.js' + +async function processPluginManage (job: Job) { + const payload = job.data as PluginManagePayload + let hasError = false + let pluginId: number + logger.info('Processing plugin manage in job %s.', job.id) + + switch (payload.action) { + case 'install': { + const toInstall = payload.path || payload.npmName + const fromDisk = !!payload.path + + try { + const plugin = await PluginManager.Instance.install({ + fromDisk, + toInstall, + version: payload.version + }) + pluginId = plugin.id + } catch (err) { + hasError = true + logger.warn('Cannot install plugin %s.', toInstall, { err }) + } + break + } + case 'update': { + const toUpdate = payload.path || payload.npmName + const fromDisk = !!payload.path + + try { + const plugin = await PluginManager.Instance.update(toUpdate, fromDisk) + pluginId = plugin.id + } catch (err) { + hasError = true + logger.warn('Cannot update plugin %s.', toUpdate, { err }) + } + break + } + case 'uninstall': + try { + const plugin = await PluginModel.loadByNpmName(payload.npmName) + pluginId = plugin.id + await PluginManager.Instance.uninstall(payload) + } catch (err) { + hasError = true + logger.warn('Cannot uninstall plugin %s.', payload.npmName, { err }) + } + break + } + + Notifier.Instance.notifyOfPluginManageFinished(payload, pluginId, hasError) +} + +// --------------------------------------------------------------------------- + +export { + processPluginManage +} diff --git a/server/core/lib/job-queue/job-queue.ts b/server/core/lib/job-queue/job-queue.ts index 5559c8ea302..f591c579bac 100644 --- a/server/core/lib/job-queue/job-queue.ts +++ b/server/core/lib/job-queue/job-queue.ts @@ -29,6 +29,7 @@ import { ManageVideoTorrentPayload, MoveStoragePayload, NotifyPayload, + PluginManagePayload, RefreshPayload, TranscodingJobBuilderPayload, VideoChannelImportPayload, @@ -75,6 +76,7 @@ import { processVideosViewsStats } from './handlers/video-views-stats.js' import { onMoveToFileSystemFailure, processMoveToFileSystem } from './handlers/move-to-file-system.js' import { processCreateUserExport } from './handlers/create-user-export.js' import { processImportUserArchive } from './handlers/import-user-archive.js' +import { processPluginManage } from './handlers/plugin-manage.js' export type CreateJobArgument = { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | @@ -104,7 +106,8 @@ export type CreateJobArgument = { type: 'federate-video', payload: FederateVideoPayload } | { type: 'create-user-export', payload: CreateUserExportPayload } | { type: 'generate-video-storyboard', payload: GenerateStoryboardPayload } | - { type: 'import-user-archive', payload: ImportUserArchivePayload } + { type: 'import-user-archive', payload: ImportUserArchivePayload } | + { type: 'plugin-manage', payload: PluginManagePayload } export type CreateJobOptions = { delay?: number @@ -129,6 +132,7 @@ const handlers: { [id in JobType]: (job: Job) => Promise } = { 'move-to-object-storage': processMoveToObjectStorage, 'move-to-file-system': processMoveToFileSystem, 'notify': processNotify, + 'plugin-manage': processPluginManage, 'video-channel-import': processVideoChannelImport, 'video-file-import': processVideoFileImport, 'video-import': processVideoImport, @@ -164,6 +168,7 @@ const jobTypes: JobType[] = [ 'move-to-object-storage', 'move-to-file-system', 'notify', + 'plugin-manage', 'transcoding-job-builder', 'video-channel-import', 'video-file-import', @@ -413,15 +418,15 @@ class JobQueue { // --------------------------------------------------------------------------- async listForApi (options: { - state?: JobState + states: JobState[] start: number count: number asc?: boolean jobType: JobType }): Promise { - const { state, start, count, asc, jobType } = options + const { states, start, count, asc, jobType } = options - const states = this.buildStateFilter(state) + const totalStates = this.buildStateFilter(states) const filteredJobTypes = this.buildTypeFilter(jobType) let results: Job[] = [] @@ -434,7 +439,7 @@ class JobQueue { continue } - let jobs = await queue.getJobs(states, 0, start + count, asc) + let jobs = await queue.getJobs(totalStates, 0, start + count, asc) // FIXME: we have sometimes undefined values https://github.com/taskforcesh/bullmq/issues/248 jobs = jobs.filter(j => !!j) @@ -454,8 +459,8 @@ class JobQueue { return results.slice(start, start + count) } - async count (state: JobState, jobType?: JobType): Promise { - const states = this.buildStateFilter(state) + async count (states: JobState[], jobType?: JobType): Promise { + const totalStates = this.buildStateFilter(states) const filteredJobTypes = this.buildTypeFilter(jobType) let total = 0 @@ -469,7 +474,7 @@ class JobQueue { const counts = await queue.getJobCounts() - for (const s of states) { + for (const s of totalStates) { total += counts[s] } } @@ -477,18 +482,18 @@ class JobQueue { return total } - private buildStateFilter (state?: JobState) { - if (!state) return Array.from(jobStates) + private buildStateFilter (states: JobState[]) { + if (states.length === 0) return Array.from(jobStates) - const states = [ state ] + const totalStates = states // Include parent and prioritized if filtering on waiting - if (state === 'waiting') { - states.push('waiting-children') - states.push('prioritized') + if (states.includes('waiting')) { + totalStates.push('waiting-children') + totalStates.push('prioritized') } - return states + return totalStates } private buildTypeFilter (jobType?: JobType) { diff --git a/server/core/lib/notifier/notifier.ts b/server/core/lib/notifier/notifier.ts index 277f0afb749..86318b7fcab 100644 --- a/server/core/lib/notifier/notifier.ts +++ b/server/core/lib/notifier/notifier.ts @@ -1,4 +1,4 @@ -import { UserNotificationSettingValue, UserNotificationSettingValueType } from '@peertube/peertube-models' +import { PluginManagePayload, UserNotificationSettingValue, UserNotificationSettingValueType } from '@peertube/peertube-models' import { MRegistration, MUser, MUserDefault } from '@server/types/models/user/index.js' import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist.js' import { logger, loggerTagsFactory } from '../../helpers/logger.js' @@ -43,6 +43,7 @@ import { StudioEditionFinishedForOwner, UnblacklistForOwner } from './shared/index.js' +import { PluginManageFinished } from './shared/plugin-manage/plugin-manage-finished.js' const lTags = loggerTagsFactory('notifier') @@ -69,7 +70,8 @@ class Notifier { newAbuseMessage: [ NewAbuseMessageForReporter, NewAbuseMessageForModerators ], newPeertubeVersion: [ NewPeerTubeVersionForAdmins ], newPluginVersion: [ NewPluginVersionForAdmins ], - videoStudioEditionFinished: [ StudioEditionFinishedForOwner ] + videoStudioEditionFinished: [ StudioEditionFinishedForOwner ], + pluginManageFinished: [ PluginManageFinished ] } private static instance: Notifier @@ -266,6 +268,15 @@ class Notifier { .catch(err => logger.error('Cannot notify on new plugin version %s.', plugin.name, { err })) } + notifyOfPluginManageFinished (pluginManagePayload: PluginManagePayload, pluginId: number, hasError: boolean) { + const models = this.notificationModels.pluginManageFinished + + logger.debug('Notify on new plugin manage finished', { ...pluginManagePayload, ...lTags() }) + + this.sendNotifications(models, { hasError, pluginManagePayload, pluginId }) + .catch(err => logger.error('Cannot notify on plugin manage finished.', { err })) + } + notifyOfFinishedVideoStudioEdition (video: MVideoFullLight) { const models = this.notificationModels.videoStudioEditionFinished @@ -297,7 +308,7 @@ class Notifier { if (webNotificationEnabled) { await notification.save() - PeerTubeSocket.Instance.sendNotification(user.id, notification) + await PeerTubeSocket.Instance.sendNotification(user.id, notification) } if (emailNotificationEnabled) { @@ -307,9 +318,11 @@ class Notifier { Hooks.runAction('action:notifier.notification.created', { webNotificationEnabled, emailNotificationEnabled, user, notification }) } - for (const to of toEmails) { - const payload = await object.createEmail(to) - JobQueue.Instance.createJobAsync({ type: 'email', payload }) + if (object.createEmail) { + for (const to of toEmails) { + const payload = await object.createEmail(to) + JobQueue.Instance.createJobAsync({ type: 'email', payload }) + } } } diff --git a/server/core/lib/notifier/shared/plugin-manage/plugin-manage-finished.ts b/server/core/lib/notifier/shared/plugin-manage/plugin-manage-finished.ts new file mode 100644 index 00000000000..84cb51692c6 --- /dev/null +++ b/server/core/lib/notifier/shared/plugin-manage/plugin-manage-finished.ts @@ -0,0 +1,83 @@ +import { logger } from '@server/helpers/logger.js' +import { UserModel } from '@server/models/user/user.js' +import { UserNotificationModel } from '@server/models/user/user-notification.js' +import { MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models/index.js' +import { PluginManagePayload, PluginType, UserNotificationType } from '@peertube/peertube-models' +import { AbstractNotification } from '../common/abstract-notification.js' +import { PluginModel } from '@server/models/server/plugin.js' +import { WEBSERVER } from '@server/initializers/constants.js' + +export type PluginManageFinishedPayload = { + pluginManagePayload: PluginManagePayload + hasError: boolean + pluginId: number +} + +export class PluginManageFinished extends AbstractNotification { + private user: MUserDefault + + async prepare () { + this.user = await UserModel.loadByIdFull(this.payload.pluginManagePayload.userId) + } + + log () { + logger.info('Notifying user %s its plugin %s is finished.', this.user.username, this.payload.pluginManagePayload.action) + } + + getSetting (user: MUserWithNotificationSetting) { + return user.NotificationSetting.pluginManageFinished + } + + getTargetUsers () { + if (!this.user) return [] + + return [ this.user ] + } + + createNotification (user: MUserWithNotificationSetting) { + const notification = UserNotificationModel.build({ + type: UserNotificationType.PLUGIN_MANAGE_FINISHED, + pluginId: this.payload.pluginId, + userId: user.id, + hasOperationFailed: this.payload.hasError + }) + + return notification + } + + createEmail (to: string) { + const { hasError, pluginManagePayload } = this.payload + const { action, npmName } = pluginManagePayload + const pluginType = PluginModel.getTypeFromNpmName(npmName) === PluginType.PLUGIN ? 'plugin' : 'theme' + const pluginName = PluginModel.normalizePluginName(npmName) + const jobsUrl = WEBSERVER.URL + '/admin/system/jobs' + + if (hasError) { + return { + template: 'plugin-manage-failed', + to, + subject: `Failed to ${action} ${pluginType} ${pluginName}`, + locals: { + action, + pluginName, + pluginType, + jobsUrl + } + } + } + const pluginUrl = WEBSERVER.URL + '/admin/plugins/list-installed?pluginType=' + pluginType + const actionPerfect = action === 'update' ? 'updated' : action + 'ed' + + return { + template: 'plugin-manage', + to, + subject: `The ${pluginType} ${pluginName} has been ${actionPerfect}`, + locals: { + actionPerfect, + pluginName, + pluginType, + pluginUrl + } + } + } +} diff --git a/server/core/lib/peertube-socket.ts b/server/core/lib/peertube-socket.ts index 17168f4ad7d..85f2c2f8aad 100644 --- a/server/core/lib/peertube-socket.ts +++ b/server/core/lib/peertube-socket.ts @@ -8,6 +8,7 @@ import { UserNotificationModelForApi } from '@server/types/models/user/index.js' import { LiveVideoEventPayload, LiveVideoEventType } from '@peertube/peertube-models' import { logger } from '../helpers/logger.js' import { authenticateRunnerSocket, authenticateSocket } from '../middlewares/index.js' +import { PluginModel } from '@server/models/server/plugin.js' class PeerTubeSocket { @@ -76,12 +77,16 @@ class PeerTubeSocket { }) } - sendNotification (userId: number, notification: UserNotificationModelForApi) { + async sendNotification (userId: number, notification: UserNotificationModelForApi) { const sockets = this.userNotificationSockets[userId] if (!sockets) return logger.debug('Sending user notification to user %d.', userId) + await notification.reload({ + include: [ PluginModel ] + }) + const notificationMessage = notification.toFormattedJSON() for (const socket of sockets) { socket.emit('new-notification', notificationMessage) diff --git a/server/core/lib/plugins/plugin-helpers-builder.ts b/server/core/lib/plugins/plugin-helpers-builder.ts index 19c9f4ab5be..94921b585bd 100644 --- a/server/core/lib/plugins/plugin-helpers-builder.ts +++ b/server/core/lib/plugins/plugin-helpers-builder.ts @@ -1,7 +1,7 @@ import express from 'express' import { Server } from 'http' import { join } from 'path' -import { buildLogger } from '@server/helpers/logger.js' +import { buildLogger, logger } from '@server/helpers/logger.js' import { CONFIG } from '@server/initializers/config.js' import { WEBSERVER } from '@server/initializers/constants.js' import { sequelizeTypescript } from '@server/initializers/database.js' @@ -254,6 +254,7 @@ function buildSocketHelpers () { return { sendNotification: (userId: number, notification: UserNotificationModelForApi) => { PeerTubeSocket.Instance.sendNotification(userId, notification) + .catch(err => logger.error('Failed to send notification on behalf of plugin.', { err })) }, sendVideoLiveNewState: (video: MVideo) => { PeerTubeSocket.Instance.sendVideoLiveNewState(video) diff --git a/server/core/lib/user-import-export/importers/user-settings-importer.ts b/server/core/lib/user-import-export/importers/user-settings-importer.ts index 738f8123703..a67b0846b3a 100644 --- a/server/core/lib/user-import-export/importers/user-settings-importer.ts +++ b/server/core/lib/user-import-export/importers/user-settings-importer.ts @@ -93,7 +93,8 @@ export class UserSettingsImporter extends AbstractUserImporter throwIfNotValid(value, isUserNotificationSettingValid, 'pluginManageFinished') + ) + @Column + pluginManageFinished: UserNotificationSettingValueType + @ForeignKey(() => UserModel) @Column userId: number @@ -233,7 +242,8 @@ export class UserNotificationSettingModel extends SequelizeModel }) UserRegistration: Awaited + @AllowNull(false) + @Default(false) + @Is('HasOperationFailed', value => throwIfNotValid(value, isBooleanValid, 'hasOperationFailed')) + @Column + hasOperationFailed: boolean + static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) { const where = { userId } @@ -445,6 +451,7 @@ export class UserNotificationModel extends SequelizeModel id: this.id, type: this.type, read: this.read, + hasOperationFailed: this.hasOperationFailed, video, videoImport, comment,