import { overviewsRouter } from './overviews'
import { videoPlaylistRouter } from './video-playlist'
import { CONFIG } from '../../initializers/config'
+import { pluginsRouter } from '../plugins'
const apiRouter = express.Router()
apiRouter.use('/jobs', jobsRouter)
apiRouter.use('/search', searchRouter)
apiRouter.use('/overviews', overviewsRouter)
+apiRouter.use('/plugins', pluginsRouter)
apiRouter.use('/ping', pong)
apiRouter.use('/*', badRequest)
--- /dev/null
+import * as express from 'express'
+import { getFormattedObjects } from '../../helpers/utils'
+import {
+ asyncMiddleware,
+ authenticate,
+ ensureUserHasRight,
+ paginationValidator,
+ setDefaultPagination,
+ setDefaultSort
+} from '../../middlewares'
+import { pluginsSortValidator } from '../../middlewares/validators'
+import { PluginModel } from '../../models/server/plugin'
+import { UserRight } from '../../../shared/models/users'
+import {
+ enabledPluginValidator,
+ installPluginValidator,
+ listPluginsValidator,
+ uninstallPluginValidator,
+ updatePluginSettingsValidator
+} from '../../middlewares/validators/plugins'
+import { PluginManager } from '../../lib/plugins/plugin-manager'
+import { InstallPlugin } from '../../../shared/models/plugins/install-plugin.model'
+import { ManagePlugin } from '../../../shared/models/plugins/manage-plugin.model'
+
+const pluginRouter = express.Router()
+
+pluginRouter.get('/',
+ authenticate,
+ ensureUserHasRight(UserRight.MANAGE_PLUGINS),
+ listPluginsValidator,
+ paginationValidator,
+ pluginsSortValidator,
+ setDefaultSort,
+ setDefaultPagination,
+ asyncMiddleware(listPlugins)
+)
+
+pluginRouter.get('/:pluginName/settings',
+ authenticate,
+ ensureUserHasRight(UserRight.MANAGE_PLUGINS),
+ asyncMiddleware(enabledPluginValidator),
+ asyncMiddleware(listPluginSettings)
+)
+
+pluginRouter.put('/:pluginName/settings',
+ authenticate,
+ ensureUserHasRight(UserRight.MANAGE_PLUGINS),
+ updatePluginSettingsValidator,
+ asyncMiddleware(enabledPluginValidator),
+ asyncMiddleware(updatePluginSettings)
+)
+
+pluginRouter.post('/install',
+ authenticate,
+ ensureUserHasRight(UserRight.MANAGE_PLUGINS),
+ installPluginValidator,
+ asyncMiddleware(installPlugin)
+)
+
+pluginRouter.post('/uninstall',
+ authenticate,
+ ensureUserHasRight(UserRight.MANAGE_PLUGINS),
+ uninstallPluginValidator,
+ asyncMiddleware(uninstallPlugin)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+ pluginRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function listPlugins (req: express.Request, res: express.Response) {
+ const type = req.query.type
+
+ const resultList = await PluginModel.listForApi({
+ type,
+ start: req.query.start,
+ count: req.query.count,
+ sort: req.query.sort
+ })
+
+ return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
+
+async function installPlugin (req: express.Request, res: express.Response) {
+ const body: InstallPlugin = req.body
+
+ await PluginManager.Instance.install(body.npmName)
+
+ return res.sendStatus(204)
+}
+
+async function uninstallPlugin (req: express.Request, res: express.Response) {
+ const body: ManagePlugin = req.body
+
+ await PluginManager.Instance.uninstall(body.npmName)
+
+ return res.sendStatus(204)
+}
+
+async function listPluginSettings (req: express.Request, res: express.Response) {
+ const plugin = res.locals.plugin
+
+ const settings = await PluginManager.Instance.getSettings(plugin.name)
+
+ return res.json({
+ settings
+ })
+}
+
+async function updatePluginSettings (req: express.Request, res: express.Response) {
+ const plugin = res.locals.plugin
+
+ plugin.settings = req.body.settings
+ await plugin.save()
+
+ return res.sendStatus(204)
+}
function processVideoChannelExist (videoChannel: VideoChannelModel, res: express.Response) {
if (!videoChannel) {
- res.status(404)
- .json({ error: 'Video channel not found' })
- .end()
+ ``
return false
}
USER_NOTIFICATIONS: [ 'createdAt' ],
- VIDEO_PLAYLISTS: [ 'displayName', 'createdAt', 'updatedAt' ]
+ VIDEO_PLAYLISTS: [ 'displayName', 'createdAt', 'updatedAt' ],
+
+ PLUGINS: [ 'name', 'createdAt', 'updatedAt' ]
}
const OAUTH_LIFETIME = {
import { PluginModel } from '../../models/server/plugin'
import { logger } from '../../helpers/logger'
-import { RegisterHookOptions } from '../../../shared/models/plugins/register.model'
import { basename, join } from 'path'
import { CONFIG } from '../../initializers/config'
import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins'
import { PluginType } from '../../../shared/models/plugins/plugin.type'
import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn'
import { outputFile } from 'fs-extra'
-import { ServerConfigPlugin } from '../../../shared/models/server'
+import { RegisterSettingOptions } from '../../../shared/models/plugins/register-setting.model'
+import { RegisterHookOptions } from '../../../shared/models/plugins/register-hook.model'
+import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model'
export interface RegisteredPlugin {
name: string
private static instance: PluginManager
private registeredPlugins: { [ name: string ]: RegisteredPlugin } = {}
+ private settings: { [ name: string ]: RegisterSettingOptions[] } = {}
private hooks: { [ name: string ]: HookInformationValue[] } = {}
private constructor () {
}
- async registerPluginsAndThemes () {
- await this.resetCSSGlobalFile()
-
- const plugins = await PluginModel.listEnabledPluginsAndThemes()
-
- for (const plugin of plugins) {
- try {
- await this.registerPluginOrTheme(plugin)
- } catch (err) {
- logger.error('Cannot register plugin %s, skipping.', plugin.name, { err })
- }
- }
-
- this.sortHooksByPriority()
- }
+ // ###################### Getters ######################
getRegisteredPluginOrTheme (name: string) {
return this.registeredPlugins[name]
return this.getRegisteredPluginsOrThemes(PluginType.THEME)
}
+ getSettings (name: string) {
+ return this.settings[name] || []
+ }
+
+ // ###################### Hooks ######################
+
async runHook (hookName: string, param?: any) {
let result = param
for (const hook of this.hooks[hookName]) {
try {
- if (wait) result = await hook.handler(param)
- else result = hook.handler()
+ if (wait) {
+ result = await hook.handler(param)
+ } else {
+ result = hook.handler()
+ }
} catch (err) {
logger.error('Cannot run hook %s of plugin %s.', hookName, hook.pluginName, { err })
}
return result
}
+ // ###################### Registration ######################
+
+ async registerPluginsAndThemes () {
+ await this.resetCSSGlobalFile()
+
+ const plugins = await PluginModel.listEnabledPluginsAndThemes()
+
+ for (const plugin of plugins) {
+ try {
+ await this.registerPluginOrTheme(plugin)
+ } catch (err) {
+ logger.error('Cannot register plugin %s, skipping.', plugin.name, { err })
+ }
+ }
+
+ this.sortHooksByPriority()
+ }
+
async unregister (name: string) {
const plugin = this.getRegisteredPlugin(name)
await this.regeneratePluginGlobalCSS()
}
- async install (toInstall: string, version: string, fromDisk = false) {
+ // ###################### Installation ######################
+
+ async install (toInstall: string, version?: string, fromDisk = false) {
let plugin: PluginModel
let name: string
logger.info('Plugin %s uninstalled.', packageName)
}
+ // ###################### Private register ######################
+
private async registerPluginOrTheme (plugin: PluginModel) {
logger.info('Registering plugin or theme %s.', plugin.name)
})
}
+ const registerSetting = (options: RegisterSettingOptions) => {
+ if (!this.settings[plugin.name]) this.settings[plugin.name] = []
+
+ this.settings[plugin.name].push(options)
+ }
+
+ const settingsManager: PluginSettingsManager = {
+ getSetting: (name: string) => PluginModel.getSetting(plugin.name, name),
+
+ setSetting: (name: string, value: string) => PluginModel.setSetting(plugin.name, name, value)
+ }
+
const library: PluginLibrary = require(join(pluginPath, packageJSON.library))
if (!isLibraryCodeValid(library)) {
throw new Error('Library code is not valid (miss register or unregister function)')
}
- library.register({ registerHook })
+ library.register({ registerHook, registerSetting, settingsManager })
logger.info('Add plugin %s CSS to global file.', plugin.name)
return library
}
- private sortHooksByPriority () {
- for (const hookName of Object.keys(this.hooks)) {
- this.hooks[hookName].sort((a, b) => {
- return b.priority - a.priority
- })
- }
- }
+ // ###################### CSS ######################
private resetCSSGlobalFile () {
return outputFile(PLUGIN_GLOBAL_CSS_PATH, '')
})
}
+ private async regeneratePluginGlobalCSS () {
+ await this.resetCSSGlobalFile()
+
+ for (const key of Object.keys(this.registeredPlugins)) {
+ const plugin = this.registeredPlugins[key]
+
+ await this.addCSSToGlobalFile(plugin.path, plugin.css)
+ }
+ }
+
+ // ###################### Utils ######################
+
+ private sortHooksByPriority () {
+ for (const hookName of Object.keys(this.hooks)) {
+ this.hooks[hookName].sort((a, b) => {
+ return b.priority - a.priority
+ })
+ }
+ }
+
private getPackageJSON (pluginName: string, pluginType: PluginType) {
const pluginPath = join(this.getPluginPath(pluginName, pluginType), 'package.json')
return name.replace(/^peertube-((theme)|(plugin))-/, '')
}
- private async regeneratePluginGlobalCSS () {
- await this.resetCSSGlobalFile()
-
- for (const key of Object.keys(this.registeredPlugins)) {
- const plugin = this.registeredPlugins[key]
-
- await this.addCSSToGlobalFile(plugin.path, plugin.css)
- }
- }
+ // ###################### Private getters ######################
private getRegisteredPluginsOrThemes (type: PluginType) {
const plugins: RegisteredPlugin[] = []
import { outputJSON, pathExists } from 'fs-extra'
import { join } from 'path'
-async function installNpmPlugin (name: string, version: string) {
+async function installNpmPlugin (name: string, version?: string) {
// Security check
checkNpmPluginNameOrThrow(name)
- checkPluginVersionOrThrow(version)
+ if (version) checkPluginVersionOrThrow(version)
+
+ let toInstall = name
+ if (version) toInstall += `@${version}`
- const toInstall = `${name}@${version}`
await execYarn('add ' + toInstall)
}
import * as express from 'express'
-import { param } from 'express-validator/check'
+import { param, query, body } from 'express-validator/check'
import { logger } from '../../helpers/logger'
import { areValidationErrors } from './utils'
-import { isPluginNameValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins'
+import { isPluginNameValid, isPluginTypeValid, isPluginVersionValid, isNpmPluginNameValid } from '../../helpers/custom-validators/plugins'
import { PluginManager } from '../../lib/plugins/plugin-manager'
-import { isSafePath } from '../../helpers/custom-validators/misc'
+import { isBooleanValid, isSafePath } from '../../helpers/custom-validators/misc'
+import { PluginModel } from '../../models/server/plugin'
const servePluginStaticDirectoryValidator = [
param('pluginName').custom(isPluginNameValid).withMessage('Should have a valid plugin name'),
}
]
+const listPluginsValidator = [
+ query('type')
+ .optional()
+ .custom(isPluginTypeValid).withMessage('Should have a valid plugin type'),
+ query('uninstalled')
+ .optional()
+ .toBoolean()
+ .custom(isBooleanValid).withMessage('Should have a valid uninstalled attribute'),
+
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking listPluginsValidator parameters', { parameters: req.query })
+
+ if (areValidationErrors(req, res)) return
+
+ return next()
+ }
+]
+
+const installPluginValidator = [
+ body('npmName').custom(isNpmPluginNameValid).withMessage('Should have a valid npm name'),
+
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking installPluginValidator parameters', { parameters: req.body })
+
+ if (areValidationErrors(req, res)) return
+
+ return next()
+ }
+]
+
+const uninstallPluginValidator = [
+ body('npmName').custom(isNpmPluginNameValid).withMessage('Should have a valid npm name'),
+
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking managePluginValidator parameters', { parameters: req.body })
+
+ if (areValidationErrors(req, res)) return
+
+ return next()
+ }
+]
+
+const enabledPluginValidator = [
+ body('name').custom(isPluginNameValid).withMessage('Should have a valid plugin name'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking enabledPluginValidator parameters', { parameters: req.body })
+
+ if (areValidationErrors(req, res)) return
+
+ const plugin = await PluginModel.load(req.body.name)
+ if (!plugin) {
+ return res.status(404)
+ .json({ error: 'Plugin not found' })
+ .end()
+ }
+
+ res.locals.plugin = plugin
+
+ return next()
+ }
+]
+
+const updatePluginSettingsValidator = [
+ body('settings').exists().withMessage('Should have settings'),
+
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking enabledPluginValidator parameters', { parameters: req.body })
+
+ if (areValidationErrors(req, res)) return
+
+ return next()
+ }
+]
+
// ---------------------------------------------------------------------------
export {
- servePluginStaticDirectoryValidator
+ servePluginStaticDirectoryValidator,
+ updatePluginSettingsValidator,
+ uninstallPluginValidator,
+ enabledPluginValidator,
+ installPluginValidator,
+ listPluginsValidator
}
const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
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 usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS)
const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS)
const videoPlaylistsSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_COLUMNS)
+const pluginsSortValidator = checkSort(SORTABLE_PLUGINS_COLUMNS)
// ---------------------------------------------------------------------------
accountsBlocklistSortValidator,
serversBlocklistSortValidator,
userNotificationsSortValidator,
- videoPlaylistsSortValidator
+ videoPlaylistsSortValidator,
+ pluginsSortValidator
}
-import { AllowNull, Column, CreatedAt, DataType, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
-import { throwIfNotValid } from '../utils'
+import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
+import { getSort, throwIfNotValid } from '../utils'
import {
isPluginDescriptionValid,
isPluginNameValid,
isPluginTypeValid,
isPluginVersionValid
} from '../../helpers/custom-validators/plugins'
+import { PluginType } from '../../../shared/models/plugins/plugin.type'
+import { PeerTubePlugin } from '../../../shared/models/plugins/peertube-plugin.model'
+import { FindAndCountOptions } from 'sequelize'
+
+@DefaultScope(() => ({
+ attributes: {
+ exclude: [ 'storage' ]
+ }
+}))
@Table({
tableName: 'plugin',
return PluginModel.findOne(query)
}
- static uninstall (pluginName: string) {
+ static getSetting (pluginName: string, settingName: string) {
+ const query = {
+ attributes: [ 'settings' ],
+ where: {
+ name: pluginName
+ }
+ }
+
+ return PluginModel.findOne(query)
+ .then(p => p.settings)
+ .then(settings => {
+ if (!settings) return undefined
+
+ return settings[settingName]
+ })
+ }
+
+ static setSetting (pluginName: string, settingName: string, settingValue: string) {
const query = {
where: {
name: pluginName
}
}
- return PluginModel.update({ enabled: false, uninstalled: true }, query)
+ const toSave = {
+ [`settings.${settingName}`]: settingValue
+ }
+
+ return PluginModel.update(toSave, query)
+ .then(() => undefined)
+ }
+
+ static listForApi (options: {
+ type?: PluginType,
+ uninstalled?: boolean,
+ start: number,
+ count: number,
+ sort: string
+ }) {
+ const query: FindAndCountOptions = {
+ offset: options.start,
+ limit: options.count,
+ order: getSort(options.sort),
+ where: {}
+ }
+
+ if (options.type) query.where['type'] = options.type
+ if (options.uninstalled) query.where['uninstalled'] = options.uninstalled
+
+ return PluginModel
+ .findAndCountAll(query)
+ .then(({ rows, count }) => {
+ return { total: count, data: rows }
+ })
+ }
+
+ toFormattedJSON (): PeerTubePlugin {
+ return {
+ name: this.name,
+ type: this.type,
+ version: this.version,
+ enabled: this.enabled,
+ uninstalled: this.uninstalled,
+ peertubeEngine: this.peertubeEngine,
+ description: this.description,
+ settings: this.settings,
+ createdAt: this.createdAt,
+ updatedAt: this.updatedAt
+ }
}
}
import { VideoCaptionModel } from '../models/video/video-caption'
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
import { RegisteredPlugin } from '../lib/plugins/plugin-manager'
+import { PluginModel } from '../models/server/plugin'
declare module 'express' {
-
interface Response {
locals: {
video?: VideoModel
authenticated?: boolean
registeredPlugin?: RegisteredPlugin
+
+ plugin?: PluginModel
}
}
}
--- /dev/null
+export interface InstallPlugin {
+ npmName: string
+}
--- /dev/null
+export interface ManagePlugin {
+ npmName: string
+}
--- /dev/null
+export interface PeerTubePlugin {
+ name: string
+ type: number
+ version: string
+ enabled: boolean
+ uninstalled: boolean
+ peertubeEngine: string
+ description: string
+ settings: any
+ createdAt: Date
+ updatedAt: Date
+}
-import { RegisterOptions } from './register-options.type'
+import { RegisterOptions } from './register-options.model'
export interface PluginLibrary {
register: (options: RegisterOptions) => void
+
unregister: () => Promise<any>
}
--- /dev/null
+import * as Bluebird from 'bluebird'
+
+export interface PluginSettingsManager {
+ getSetting: (name: string) => Bluebird<string>
+
+ setSetting: (name: string, value: string) => Bluebird<any>
+}
--- /dev/null
+export interface RegisterHookOptions {
+ target: string
+ handler: Function
+ priority?: number
+}
--- /dev/null
+import { RegisterHookOptions } from './register-hook.model'
+import { RegisterSettingOptions } from './register-setting.model'
+import { PluginSettingsManager } from './plugin-settings-manager.model'
+
+export type RegisterOptions = {
+ registerHook: (options: RegisterHookOptions) => void
+
+ registerSetting: (options: RegisterSettingOptions) => void
+
+ settingsManager: PluginSettingsManager
+}
+++ /dev/null
-import { RegisterHookOptions } from './register.model'
-
-export type RegisterOptions = {
- registerHook: (options: RegisterHookOptions) => void
-}
--- /dev/null
+export interface RegisterSettingOptions {
+ name: string
+ label: string
+ type: 'input'
+ default?: string
+}
+++ /dev/null
-export interface RegisterHookOptions {
- target: string
- handler: Function
- priority?: number
-}
UPDATE_ANY_VIDEO_PLAYLIST,
SEE_ALL_VIDEOS,
- CHANGE_VIDEO_OWNERSHIP
+ CHANGE_VIDEO_OWNERSHIP,
+
+ MANAGE_PLUGINS
}