Просмотр исходного кода

Add ability to search available plugins

Chocobozzz 4 лет назад
Родитель
Сommit
6702a1b2cc
27 измененных файлов с 512 добавлено и 80 удалено
  1. 2 2
      client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html
  2. 0 35
      client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.scss
  3. 2 1
      client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts
  4. 55 0
      client/src/app/+admin/plugins/plugin-search/plugin-search.component.html
  5. 21 0
      client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss
  6. 108 8
      client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts
  7. 1 2
      client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.ts
  8. 21 2
      client/src/app/+admin/plugins/shared/plugin-api.service.ts
  9. 37 0
      client/src/app/+admin/plugins/shared/plugin-list.component.scss
  10. 1 0
      client/src/app/shared/images/global-icon.component.ts
  11. 0 18
      client/src/app/shared/misc/utils.ts
  12. 11 0
      client/src/assets/images/global/search.html
  13. 7 0
      config/default.yaml
  14. 7 0
      config/production.yaml.example
  15. 25 3
      server/controllers/api/plugins.ts
  16. 6 0
      server/initializers/config.ts
  17. 4 1
      server/initializers/constants.ts
  18. 64 0
      server/lib/plugins/plugin-index.ts
  19. 4 0
      server/lib/plugins/plugin-manager.ts
  20. 60 0
      server/lib/schedulers/plugins-check-scheduler.ts
  21. 29 1
      server/middlewares/validators/plugins.ts
  22. 3 0
      server/middlewares/validators/sort.ts
  23. 15 4
      server/models/server/plugin.ts
  24. 19 1
      shared/core-utils/miscs/miscs.ts
  25. 1 1
      shared/models/plugins/peertube-plugin-index-list.model.ts
  26. 3 0
      shared/models/plugins/peertube-plugin-index.model.ts
  27. 6 1
      shared/models/plugins/peertube-plugin-latest-version.model.ts

+ 2 - 2
client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html

@@ -2,7 +2,7 @@
   <p-selectButton [options]="pluginTypeOptions" [(ngModel)]="pluginType" (ngModelChange)="reloadPlugins()"></p-selectButton>
 </div>
 
-<div class="no-results" i18n *ngIf="pagination.totalItems === 0">
+<div class="no-results" *ngIf="pagination.totalItems === 0">
   {{ getNoResultMessage() }}
 </div>
 
@@ -28,7 +28,7 @@
 
           <my-edit-button *ngIf="pluginType !== PluginType.THEME" [routerLink]="getShowRouterLink(plugin)" label="Settings" i18n-label></my-edit-button>
 
-          <my-button class="update-button" *ngIf="!isUpdateAvailable(plugin)" (click)="update(plugin)" [loading]="isUpdating(plugin)"
+          <my-button class="update-button" *ngIf="isUpdateAvailable(plugin)" (click)="update(plugin)" [loading]="isUpdating(plugin)"
                      [label]="getUpdateLabel(plugin)" icon="refresh" [attr.disabled]="isUpdating(plugin)"
           ></my-button>
 

+ 0 - 35
client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.scss

@@ -1,41 +1,6 @@
 @import '_variables';
 @import '_mixins';
 
-.first-row {
-  margin-bottom: 10px;
-
-  .plugin-name {
-    font-size: 16px;
-    margin-right: 10px;
-    font-weight: $font-semibold;
-  }
-
-  .plugin-version {
-    opacity: 0.6;
-  }
-}
-
-.second-row {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-
-  .description {
-    opacity: 0.8
-  }
-
-  .buttons {
-    > *:not(:last-child) {
-      margin-right: 10px;
-    }
-  }
-}
-
-.action-button {
-  @include peertube-button-link;
-  @include button-with-icon(21px, 0, -2px);
-}
-
 .update-button[disabled="true"] /deep/ .action-button {
   cursor: default !important;
 }

+ 2 - 1
client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts

@@ -6,13 +6,14 @@ import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pa
 import { ConfirmService, Notifier } from '@app/core'
 import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
 import { ActivatedRoute, Router } from '@angular/router'
-import { compareSemVer } from '@app/shared/misc/utils'
+import { compareSemVer } from '@shared/core-utils/miscs/miscs'
 
 @Component({
   selector: 'my-plugin-list-installed',
   templateUrl: './plugin-list-installed.component.html',
   styleUrls: [
     '../shared/toggle-plugin-type.scss',
+    '../shared/plugin-list.component.scss',
     './plugin-list-installed.component.scss'
   ]
 })

+ 55 - 0
client/src/app/+admin/plugins/plugin-search/plugin-search.component.html

@@ -0,0 +1,55 @@
+<div class="toggle-plugin-type">
+  <p-selectButton [options]="pluginTypeOptions" [(ngModel)]="pluginType" (ngModelChange)="reloadPlugins()"></p-selectButton>
+</div>
+
+<div class="search-bar">
+  <input type="text" (input)="onSearchChange($event.target.value)" i18n-placeholder placeholder="Search..."/>
+</div>
+
+<div class="result-title" *ngIf="!isSearching">
+  <ng-container *ngIf="!search">
+    <my-global-icon iconName="trending"></my-global-icon>
+    <ng-container i18n>Popular</ng-container>
+  </ng-container>
+
+  <ng-container i18n *ngIf="!!search">
+    <my-global-icon iconName="search"></my-global-icon>
+
+    <ng-container i18n>
+      {{ pagination.totalItems }} {pagination.totalItems, plural, =1 {result} other {results}} for "{{ search }}"
+      </ng-container>
+  </ng-container>
+</div>
+
+<div class="no-results" i18n *ngIf="pagination.totalItems === 0">
+  No results.
+</div>
+
+<div class="plugins" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true">
+  <div class="card plugin" *ngFor="let plugin of plugins">
+    <div class="card-body">
+      <div class="first-row">
+        <span class="plugin-name">{{ plugin.name }}</span>
+
+        <span class="plugin-version">{{ plugin.latestVersion }}</span>
+      </div>
+
+      <div class="second-row">
+        <div class="description">{{ plugin.description }}</div>
+
+        <div class="buttons">
+          <a class="action-button action-button-edit grey-button" target="_blank" rel="noopener noreferrer"
+             [href]="plugin.homepage" i18n-title title="Go to the plugin homepage"
+          >
+            <my-global-icon iconName="go"></my-global-icon>
+            <span i18n class="button-label">Homepage</span>
+          </a>
+
+          <my-button class="update-button" *ngIf="plugin.installed === false" (click)="install(plugin)" [loading]="isInstalling(plugin)"
+                     label="Install" icon="cloud-download" [attr.disabled]="isInstalling(plugin)"
+          ></my-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>

+ 21 - 0
client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss

@@ -1,2 +1,23 @@
 @import '_variables';
 @import '_mixins';
+
+.search-bar {
+  display: flex;
+  justify-content: center;
+  margin: 30px 0;
+
+  input {
+    @include peertube-input-text(60%);
+    height: 35px;
+  }
+}
+
+.result-title {
+  font-size: 22px;
+  font-weight: 600;
+  margin-bottom: 15px;
+
+  my-global-icon {
+    margin-right: 5px;
+  }
+}

+ 108 - 8
client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts

@@ -1,33 +1,133 @@
-import { Component, OnInit, ViewChild } from '@angular/core'
+import { Component, OnInit } from '@angular/core'
 import { Notifier } from '@app/core'
-import { SortMeta } from 'primeng/components/common/sortmeta'
-import { ConfirmService, ServerService } from '../../../core'
-import { RestPagination, RestTable, UserService } from '../../../shared'
+import { ConfirmService } from '../../../core'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { User } from '../../../../../../shared'
-import { UserBanModalComponent } from '@app/shared/moderation'
-import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
 import { PluginType } from '@shared/models/plugins/plugin.type'
 import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
+import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
+import { ActivatedRoute, Router } from '@angular/router'
+import { PeerTubePluginIndex } from '@shared/models/plugins/peertube-plugin-index.model'
+import { Subject } from 'rxjs'
+import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
 
 @Component({
   selector: 'my-plugin-search',
   templateUrl: './plugin-search.component.html',
   styleUrls: [
     '../shared/toggle-plugin-type.scss',
+    '../shared/plugin-list.component.scss',
     './plugin-search.component.scss'
   ]
 })
 export class PluginSearchComponent implements OnInit {
   pluginTypeOptions: { label: string, value: PluginType }[] = []
+  pluginType: PluginType = PluginType.PLUGIN
+
+  pagination: ComponentPagination = {
+    currentPage: 1,
+    itemsPerPage: 10
+  }
+  sort = '-popularity'
+
+  search = ''
+  isSearching = false
+
+  plugins: PeerTubePluginIndex[] = []
+  installing: { [name: string]: boolean } = {}
+
+  private searchSubject = new Subject<string>()
 
   constructor (
     private i18n: I18n,
-    private pluginService: PluginApiService
+    private pluginService: PluginApiService,
+    private notifier: Notifier,
+    private confirmService: ConfirmService,
+    private router: Router,
+    private route: ActivatedRoute
   ) {
     this.pluginTypeOptions = this.pluginService.getPluginTypeOptions()
   }
 
   ngOnInit () {
+    const query = this.route.snapshot.queryParams
+    if (query['pluginType']) this.pluginType = parseInt(query['pluginType'], 10)
+
+    this.searchSubject.asObservable()
+        .pipe(
+          debounceTime(400),
+          distinctUntilChanged()
+        )
+        .subscribe(search => {
+          this.search = search
+          this.reloadPlugins()
+        })
+
+    this.reloadPlugins()
+  }
+
+  onSearchChange (search: string) {
+    this.searchSubject.next(search)
+  }
+
+  reloadPlugins () {
+    this.pagination.currentPage = 1
+    this.plugins = []
+
+    this.router.navigate([], { queryParams: { pluginType: this.pluginType } })
+
+    this.loadMorePlugins()
+  }
+
+  loadMorePlugins () {
+    this.isSearching = true
+
+    this.pluginService.searchAvailablePlugins(this.pluginType, this.pagination, this.sort, this.search)
+        .subscribe(
+          res => {
+            this.isSearching = false
+
+            this.plugins = this.plugins.concat(res.data)
+            this.pagination.totalItems = res.total
+          },
+
+          err => this.notifier.error(err.message)
+        )
+  }
+
+  onNearOfBottom () {
+    if (!hasMoreItems(this.pagination)) return
+
+    this.pagination.currentPage += 1
+
+    this.loadMorePlugins()
+  }
+
+  isInstalling (plugin: PeerTubePluginIndex) {
+    return !!this.installing[plugin.npmName]
+  }
+
+  async install (plugin: PeerTubePluginIndex) {
+    if (this.installing[plugin.npmName]) return
+
+    const res = await this.confirmService.confirm(
+      this.i18n('Please only install plugins or themes you trust, since they can execute any code on your instance.'),
+      this.i18n('Install {{pluginName}}?', { pluginName: plugin.name })
+    )
+    if (res === false) return
+
+    this.installing[plugin.npmName] = true
+
+    this.pluginService.install(plugin.npmName)
+        .subscribe(
+          () => {
+            this.installing[plugin.npmName] = false
+
+            this.notifier.success(this.i18n('{{pluginName}} installed.', { pluginName: plugin.name }))
+
+            plugin.installed = true
+          },
+
+          err => this.notifier.error(err.message)
+        )
   }
 }

+ 1 - 2
client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.ts

@@ -7,7 +7,7 @@ import { ActivatedRoute } from '@angular/router'
 import { Subscription } from 'rxjs'
 import { map, switchMap } from 'rxjs/operators'
 import { RegisterSettingOptions } from '@shared/models/plugins/register-setting.model'
-import { BuildFormArgument, BuildFormDefaultValues, FormReactive, FormValidatorService } from '@app/shared'
+import { BuildFormArgument, FormReactive, FormValidatorService } from '@app/shared'
 
 @Component({
   selector: 'my-plugin-show-installed',
@@ -83,7 +83,6 @@ export class PluginShowInstalledComponent extends FormReactive implements OnInit
   }
 
   private buildSettingsForm () {
-    const defaultValues: BuildFormDefaultValues = {}
     const buildOptions: BuildFormArgument = {}
     const settingsValues: any = {}
 

+ 21 - 2
client/src/app/+admin/plugins/shared/plugin-api.service.ts

@@ -11,6 +11,7 @@ import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
 import { ManagePlugin } from '@shared/models/plugins/manage-plugin.model'
 import { InstallOrUpdatePlugin } from '@shared/models/plugins/install-plugin.model'
 import { RegisterSettingOptions } from '@shared/models/plugins/register-setting.model'
+import { PeerTubePluginIndex } from '@shared/models/plugins/peertube-plugin-index.model'
 
 @Injectable()
 export class PluginApiService {
@@ -45,7 +46,7 @@ export class PluginApiService {
   }
 
   getPlugins (
-    type: PluginType,
+    pluginType: PluginType,
     componentPagination: ComponentPagination,
     sort: string
   ) {
@@ -53,12 +54,30 @@ export class PluginApiService {
 
     let params = new HttpParams()
     params = this.restService.addRestGetParams(params, pagination, sort)
-    params = params.append('type', type.toString())
+    params = params.append('pluginType', pluginType.toString())
 
     return this.authHttp.get<ResultList<PeerTubePlugin>>(PluginApiService.BASE_APPLICATION_URL, { params })
                .pipe(catchError(res => this.restExtractor.handleError(res)))
   }
 
+  searchAvailablePlugins (
+    pluginType: PluginType,
+    componentPagination: ComponentPagination,
+    sort: string,
+    search?: string
+  ) {
+    const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination, sort)
+    params = params.append('pluginType', pluginType.toString())
+
+    if (search) params = params.append('search', search)
+
+    return this.authHttp.get<ResultList<PeerTubePluginIndex>>(PluginApiService.BASE_APPLICATION_URL + '/available', { params })
+               .pipe(catchError(res => this.restExtractor.handleError(res)))
+  }
+
   getPlugin (npmName: string) {
     const path = PluginApiService.BASE_APPLICATION_URL + '/' + npmName
 

+ 37 - 0
client/src/app/+admin/plugins/shared/plugin-list.component.scss

@@ -0,0 +1,37 @@
+@import '_variables';
+@import '_mixins';
+
+.first-row {
+  margin-bottom: 10px;
+
+  .plugin-name {
+    font-size: 16px;
+    margin-right: 10px;
+    font-weight: $font-semibold;
+  }
+
+  .plugin-version {
+    opacity: 0.6;
+  }
+}
+
+.second-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+
+  .description {
+    opacity: 0.8
+  }
+
+  .buttons {
+    > *:not(:last-child) {
+      margin-right: 10px;
+    }
+  }
+}
+
+.action-button {
+  @include peertube-button-link;
+  @include button-with-icon(21px, 0, -2px);
+}

+ 1 - 0
client/src/app/shared/images/global-icon.component.ts

@@ -45,6 +45,7 @@ const icons = {
   'administration': require('../../../assets/images/menu/administration.html'),
   'subscriptions': require('../../../assets/images/menu/subscriptions.html'),
   'users': require('../../../assets/images/global/users.html'),
+  'search': require('../../../assets/images/global/search.html'),
   'refresh': require('../../../assets/images/global/refresh.html')
 }
 

+ 0 - 18
client/src/app/shared/misc/utils.ts

@@ -134,23 +134,6 @@ function scrollToTop () {
   window.scroll(0, 0)
 }
 
-// Thanks https://stackoverflow.com/a/16187766
-function compareSemVer (a: string, b: string) {
-  const regExStrip0 = /(\.0+)+$/
-  const segmentsA = a.replace(regExStrip0, '').split('.')
-  const segmentsB = b.replace(regExStrip0, '').split('.')
-
-  const l = Math.min(segmentsA.length, segmentsB.length)
-
-  for (let i = 0; i < l; i++) {
-    const diff = parseInt(segmentsA[ i ], 10) - parseInt(segmentsB[ i ], 10)
-
-    if (diff) return diff
-  }
-
-  return segmentsA.length - segmentsB.length
-}
-
 export {
   sortBy,
   durationToString,
@@ -161,7 +144,6 @@ export {
   getAbsoluteAPIUrl,
   dateToHuman,
   immutableAssign,
-  compareSemVer,
   objectToFormData,
   objectLineFeedToHtml,
   removeElementFromArray,

+ 11 - 0
client/src/assets/images/global/search.html

@@ -0,0 +1,11 @@
+<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <defs></defs>
+    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Artboard-4" transform="translate(-136.000000, -115.000000)" stroke="#000" stroke-width="2">
+            <g id="3" transform="translate(136.000000, 115.000000)">
+                <circle id="Oval-3" cx="10" cy="10" r="7"></circle>
+                <path d="M15,15 L21,21" id="Path-3" stroke-linecap="round" stroke-linejoin="round"></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 7 - 0
config/default.yaml

@@ -155,6 +155,13 @@ views:
     remote:
       max_age: -1
 
+plugins:
+  # The website PeerTube will ask for available PeerTube plugins
+  # This is an unmoderated plugin index, so only install plugins you trust
+  index:
+    enabled: true
+    url: 'https://packages.joinpeertube.org'
+
 cache:
   previews:
     size: 500 # Max number of previews you want to cache

+ 7 - 0
config/production.yaml.example

@@ -156,6 +156,13 @@ views:
     remote:
       max_age: -1
 
+plugins:
+  # The website PeerTube will ask for available PeerTube plugins
+  # This is an unmoderated plugin index, so only install plugins you trust
+  index:
+    enabled: true
+    url: 'https://packages.joinpeertube.org'
+
 
 ###############################################################################
 #

+ 25 - 3
server/controllers/api/plugins.ts

@@ -8,12 +8,13 @@ import {
   setDefaultPagination,
   setDefaultSort
 } from '../../middlewares'
-import { pluginsSortValidator } from '../../middlewares/validators'
+import { availablePluginsSortValidator, pluginsSortValidator } from '../../middlewares/validators'
 import { PluginModel } from '../../models/server/plugin'
 import { UserRight } from '../../../shared/models/users'
 import {
   existingPluginValidator,
   installOrUpdatePluginValidator,
+  listAvailablePluginsValidator,
   listPluginsValidator,
   uninstallPluginValidator,
   updatePluginSettingsValidator
@@ -22,9 +23,22 @@ import { PluginManager } from '../../lib/plugins/plugin-manager'
 import { InstallOrUpdatePlugin } from '../../../shared/models/plugins/install-plugin.model'
 import { ManagePlugin } from '../../../shared/models/plugins/manage-plugin.model'
 import { logger } from '../../helpers/logger'
+import { listAvailablePluginsFromIndex } from '../../lib/plugins/plugin-index'
+import { PeertubePluginIndexList } from '../../../shared/models/plugins/peertube-plugin-index-list.model'
 
 const pluginRouter = express.Router()
 
+pluginRouter.get('/available',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_PLUGINS),
+  listAvailablePluginsValidator,
+  paginationValidator,
+  availablePluginsSortValidator,
+  setDefaultSort,
+  setDefaultPagination,
+  asyncMiddleware(listAvailablePlugins)
+)
+
 pluginRouter.get('/',
   authenticate,
   ensureUserHasRight(UserRight.MANAGE_PLUGINS),
@@ -88,10 +102,10 @@ export {
 // ---------------------------------------------------------------------------
 
 async function listPlugins (req: express.Request, res: express.Response) {
-  const type = req.query.type
+  const pluginType = req.query.pluginType
 
   const resultList = await PluginModel.listForApi({
-    type,
+    pluginType,
     start: req.query.start,
     count: req.query.count,
     sort: req.query.sort
@@ -160,3 +174,11 @@ async function updatePluginSettings (req: express.Request, res: express.Response
 
   return res.sendStatus(204)
 }
+
+async function listAvailablePlugins (req: express.Request, res: express.Response) {
+  const query: PeertubePluginIndexList = req.query
+
+  const resultList = await listAvailablePluginsFromIndex(query)
+
+  return res.json(resultList)
+}

+ 6 - 0
server/initializers/config.ts

@@ -134,6 +134,12 @@ const CONFIG = {
       }
     }
   },
+  PLUGINS: {
+    INDEX: {
+      ENABLED: config.get<boolean>('plugins.index.enabled'),
+      URL: config.get<boolean>('plugins.index.url')
+    }
+  },
   ADMIN: {
     get EMAIL () { return config.get<string>('admin.email') }
   },

+ 4 - 1
server/initializers/constants.ts

@@ -64,7 +64,9 @@ const SORTABLE_COLUMNS = {
 
   VIDEO_PLAYLISTS: [ 'displayName', 'createdAt', 'updatedAt' ],
 
-  PLUGINS: [ 'name', 'createdAt', 'updatedAt' ]
+  PLUGINS: [ 'name', 'createdAt', 'updatedAt' ],
+
+  AVAILABLE_PLUGINS: [ 'npmName', 'popularity' ]
 }
 
 const OAUTH_LIFETIME = {
@@ -165,6 +167,7 @@ const SCHEDULER_INTERVALS_MS = {
   removeOldJobs: 60000 * 60, // 1 hour
   updateVideos: 60000, // 1 minute
   youtubeDLUpdate: 60000 * 60 * 24, // 1 day
+  checkPlugins: 60000 * 60 * 24, // 1 day
   removeOldViews: 60000 * 60 * 24, // 1 day
   removeOldHistory: 60000 * 60 * 24 // 1 day
 }

+ 64 - 0
server/lib/plugins/plugin-index.ts

@@ -0,0 +1,64 @@
+import { doRequest } from '../../helpers/requests'
+import { CONFIG } from '../../initializers/config'
+import {
+  PeertubePluginLatestVersionRequest,
+  PeertubePluginLatestVersionResponse
+} from '../../../shared/models/plugins/peertube-plugin-latest-version.model'
+import { PeertubePluginIndexList } from '../../../shared/models/plugins/peertube-plugin-index-list.model'
+import { ResultList } from '../../../shared/models'
+import { PeerTubePluginIndex } from '../../../shared/models/plugins/peertube-plugin-index.model'
+import { PluginModel } from '../../models/server/plugin'
+import { PluginManager } from './plugin-manager'
+import { logger } from '../../helpers/logger'
+
+const packageJSON = require('../../../../package.json')
+
+async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) {
+  const { start = 0, count = 20, search, sort = 'npmName', pluginType } = options
+
+  const qs: PeertubePluginIndexList = {
+    start,
+    count,
+    sort,
+    pluginType,
+    search,
+    currentPeerTubeEngine: packageJSON.version
+  }
+
+  const uri = CONFIG.PLUGINS.INDEX.URL + '/api/v1/plugins'
+
+  const { body } = await doRequest({ uri, qs, json: true })
+
+  logger.debug('Got result from PeerTube index.', { body })
+
+  await addInstanceInformation(body)
+
+  return body as ResultList<PeerTubePluginIndex>
+}
+
+async function addInstanceInformation (result: ResultList<PeerTubePluginIndex>) {
+  for (const d of result.data) {
+    d.installed = PluginManager.Instance.isRegistered(d.npmName)
+    d.name = PluginModel.normalizePluginName(d.npmName)
+  }
+
+  return result
+}
+
+async function getLatestPluginsVersion (npmNames: string[]): Promise<PeertubePluginLatestVersionResponse> {
+  const bodyRequest: PeertubePluginLatestVersionRequest = {
+    npmNames,
+    currentPeerTubeEngine: packageJSON.version
+  }
+
+  const uri = CONFIG.PLUGINS.INDEX.URL + '/api/v1/plugins/latest-version'
+
+  const { body } = await doRequest({ uri, body: bodyRequest })
+
+  return body
+}
+
+export {
+  listAvailablePluginsFromIndex,
+  getLatestPluginsVersion
+}

+ 4 - 0
server/lib/plugins/plugin-manager.ts

@@ -55,6 +55,10 @@ export class PluginManager {
 
   // ###################### Getters ######################
 
+  isRegistered (npmName: string) {
+    return !!this.getRegisteredPluginOrTheme(npmName)
+  }
+
   getRegisteredPluginOrTheme (npmName: string) {
     return this.registeredPlugins[npmName]
   }

+ 60 - 0
server/lib/schedulers/plugins-check-scheduler.ts

@@ -0,0 +1,60 @@
+import { logger } from '../../helpers/logger'
+import { AbstractScheduler } from './abstract-scheduler'
+import { retryTransactionWrapper } from '../../helpers/database-utils'
+import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
+import { CONFIG } from '../../initializers/config'
+import { PluginModel } from '../../models/server/plugin'
+import { chunk } from 'lodash'
+import { getLatestPluginsVersion } from '../plugins/plugin-index'
+import { compareSemVer } from '../../../shared/core-utils/miscs/miscs'
+
+export class PluginsCheckScheduler extends AbstractScheduler {
+
+  private static instance: AbstractScheduler
+
+  protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.checkPlugins
+
+  private constructor () {
+    super()
+  }
+
+  protected async internalExecute () {
+    return retryTransactionWrapper(this.checkLatestPluginsVersion.bind(this))
+  }
+
+  private async checkLatestPluginsVersion () {
+    if (CONFIG.PLUGINS.INDEX.ENABLED === false) return
+
+    logger.info('Checkin latest plugins version.')
+
+    const plugins = await PluginModel.listInstalled()
+
+    // Process 10 plugins in 1 HTTP request
+    const chunks = chunk(plugins, 10)
+    for (const chunk of chunks) {
+      // Find plugins according to their npm name
+      const pluginIndex: { [npmName: string]: PluginModel} = {}
+      for (const plugin of chunk) {
+        pluginIndex[PluginModel.buildNpmName(plugin.name, plugin.type)] = plugin
+      }
+
+      const npmNames = Object.keys(pluginIndex)
+      const results = await getLatestPluginsVersion(npmNames)
+
+      for (const result of results) {
+        const plugin = pluginIndex[result.npmName]
+        if (!result.latestVersion) continue
+
+        if (plugin.latestVersion !== result.latestVersion && compareSemVer(plugin.latestVersion, result.latestVersion) < 0) {
+          plugin.latestVersion = result.latestVersion
+          await plugin.save()
+        }
+      }
+    }
+
+  }
+
+  static get Instance () {
+    return this.instance || (this.instance = new this())
+  }
+}

+ 29 - 1
server/middlewares/validators/plugins.ts

@@ -8,6 +8,7 @@ import { isBooleanValid, isSafePath } from '../../helpers/custom-validators/misc
 import { PluginModel } from '../../models/server/plugin'
 import { InstallOrUpdatePlugin } from '../../../shared/models/plugins/install-plugin.model'
 import { PluginType } from '../../../shared/models/plugins/plugin.type'
+import { CONFIG } from '../../initializers/config'
 
 const servePluginStaticDirectoryValidator = (pluginType: PluginType) => [
   param('pluginName').custom(isPluginNameValid).withMessage('Should have a valid plugin name'),
@@ -33,7 +34,7 @@ const servePluginStaticDirectoryValidator = (pluginType: PluginType) => [
 ]
 
 const listPluginsValidator = [
-  query('type')
+  query('pluginType')
     .optional()
     .custom(isPluginTypeValid).withMessage('Should have a valid plugin type'),
   query('uninstalled')
@@ -119,12 +120,39 @@ const updatePluginSettingsValidator = [
   }
 ]
 
+const listAvailablePluginsValidator = [
+  query('sort')
+    .optional()
+    .exists().withMessage('Should have a valid sort'),
+  query('search')
+    .optional()
+    .exists().withMessage('Should have a valid search'),
+  query('pluginType')
+    .optional()
+    .custom(isPluginTypeValid).withMessage('Should have a valid plugin type'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking enabledPluginValidator parameters', { parameters: req.query })
+
+    if (areValidationErrors(req, res)) return
+
+    if (CONFIG.PLUGINS.INDEX.ENABLED === false) {
+      return res.status(400)
+        .json({ error: 'Plugin index is not enabled' })
+        .end()
+    }
+
+    return next()
+  }
+]
+
 // ---------------------------------------------------------------------------
 
 export {
   servePluginStaticDirectoryValidator,
   updatePluginSettingsValidator,
   uninstallPluginValidator,
+  listAvailablePluginsValidator,
   existingPluginValidator,
   installOrUpdatePluginValidator,
   listPluginsValidator

+ 3 - 0
server/middlewares/validators/sort.ts

@@ -22,6 +22,7 @@ const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMN
 const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
 const SORTABLE_VIDEO_PLAYLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
 const SORTABLE_PLUGINS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.PLUGINS)
+const SORTABLE_AVAILABLE_PLUGINS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
 
 const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
 const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
@@ -43,6 +44,7 @@ const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUM
 const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS)
 const videoPlaylistsSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_COLUMNS)
 const pluginsSortValidator = checkSort(SORTABLE_PLUGINS_COLUMNS)
+const availablePluginsSortValidator = checkSort(SORTABLE_AVAILABLE_PLUGINS_COLUMNS)
 
 // ---------------------------------------------------------------------------
 
@@ -61,6 +63,7 @@ export {
   videoCommentThreadsSortValidator,
   videoRatesSortValidator,
   userSubscriptionsSortValidator,
+  availablePluginsSortValidator,
   videoChannelsSearchSortValidator,
   accountsBlocklistSortValidator,
   serversBlocklistSortValidator,

+ 15 - 4
server/models/server/plugin.ts

@@ -10,6 +10,7 @@ import {
 import { PluginType } from '../../../shared/models/plugins/plugin.type'
 import { PeerTubePlugin } from '../../../shared/models/plugins/peertube-plugin.model'
 import { FindAndCountOptions, json } from 'sequelize'
+import { PeerTubePluginIndex } from '../../../shared/models/plugins/peertube-plugin-index.model'
 
 @DefaultScope(() => ({
   attributes: {
@@ -177,7 +178,7 @@ export class PluginModel extends Model<PluginModel> {
   }
 
   static listForApi (options: {
-    type?: PluginType,
+    pluginType?: PluginType,
     uninstalled?: boolean,
     start: number,
     count: number,
@@ -193,7 +194,7 @@ export class PluginModel extends Model<PluginModel> {
       }
     }
 
-    if (options.type) query.where['type'] = options.type
+    if (options.pluginType) query.where['type'] = options.pluginType
 
     return PluginModel
       .findAndCountAll(query)
@@ -202,8 +203,18 @@ export class PluginModel extends Model<PluginModel> {
       })
   }
 
-  static normalizePluginName (name: string) {
-    return name.replace(/^peertube-((theme)|(plugin))-/, '')
+  static listInstalled () {
+    const query = {
+      where: {
+        uninstalled: false
+      }
+    }
+
+    return PluginModel.findAll(query)
+  }
+
+  static normalizePluginName (npmName: string) {
+    return npmName.replace(/^peertube-((theme)|(plugin))-/, '')
   }
 
   static getTypeFromNpmName (npmName: string) {

+ 19 - 1
shared/core-utils/miscs/miscs.ts

@@ -2,6 +2,24 @@ function randomInt (low: number, high: number) {
   return Math.floor(Math.random() * (high - low) + low)
 }
 
+// Thanks https://stackoverflow.com/a/16187766
+function compareSemVer (a: string, b: string) {
+  const regExStrip0 = /(\.0+)+$/
+  const segmentsA = a.replace(regExStrip0, '').split('.')
+  const segmentsB = b.replace(regExStrip0, '').split('.')
+
+  const l = Math.min(segmentsA.length, segmentsB.length)
+
+  for (let i = 0; i < l; i++) {
+    const diff = parseInt(segmentsA[ i ], 10) - parseInt(segmentsB[ i ], 10)
+
+    if (diff) return diff
+  }
+
+  return segmentsA.length - segmentsB.length
+}
+
 export {
-  randomInt
+  randomInt,
+  compareSemVer
 }

+ 1 - 1
shared/models/plugins/peertube-plugin-list.model.ts → shared/models/plugins/peertube-plugin-index-list.model.ts

@@ -1,6 +1,6 @@
 import { PluginType } from './plugin.type'
 
-export interface PeertubePluginList {
+export interface PeertubePluginIndexList {
   start: number
   count: number
   sort: string

+ 3 - 0
shared/models/plugins/peertube-plugin-index.model.ts

@@ -8,4 +8,7 @@ export interface PeerTubePluginIndex {
   popularity: number
 
   latestVersion: string
+
+  name?: string
+  installed?: boolean
 }

+ 6 - 1
shared/models/plugins/peertube-plugin-latest-version.model.ts

@@ -1,5 +1,10 @@
-export interface PeertubePluginLatestVersion {
+export interface PeertubePluginLatestVersionRequest {
   currentPeerTubeEngine?: string,
 
   npmNames: string[]
 }
+
+export type PeertubePluginLatestVersionResponse = {
+  npmName: string
+  latestVersion: string | null
+}[]