ニジカ投稿局 https://tv.nizika.tv
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

plugin-manager.ts 20 KiB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. import express from 'express'
  2. import { createReadStream, createWriteStream } from 'fs'
  3. import { ensureDir, outputFile, readJSON } from 'fs-extra/esm'
  4. import { Server } from 'http'
  5. import { createRequire } from 'module'
  6. import { basename, join } from 'path'
  7. import { getCompleteLocale, getHookType, internalRunHook } from '@peertube/peertube-core-utils'
  8. import {
  9. ClientScriptJSON,
  10. PluginPackageJSON,
  11. PluginTranslation,
  12. PluginTranslationPathsJSON,
  13. PluginType,
  14. PluginType_Type,
  15. RegisterServerHookOptions,
  16. ServerHook,
  17. ServerHookName
  18. } from '@peertube/peertube-models'
  19. import { decachePlugin } from '@server/helpers/decache.js'
  20. import { ApplicationModel } from '@server/models/application/application.js'
  21. import { MOAuthTokenUser, MUser } from '@server/types/models/index.js'
  22. import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins.js'
  23. import { logger } from '../../helpers/logger.js'
  24. import { CONFIG } from '../../initializers/config.js'
  25. import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants.js'
  26. import { PluginModel } from '../../models/server/plugin.js'
  27. import {
  28. PluginLibrary,
  29. RegisterServerAuthExternalOptions,
  30. RegisterServerAuthPassOptions,
  31. RegisterServerOptions
  32. } from '../../types/plugins/index.js'
  33. import { ClientHtml } from '../html/client-html.js'
  34. import { RegisterHelpers } from './register-helpers.js'
  35. import { installNpmPlugin, installNpmPluginFromDisk, rebuildNativePlugins, removeNpmPlugin } from './yarn.js'
  36. const require = createRequire(import.meta.url)
  37. export interface RegisteredPlugin {
  38. npmName: string
  39. name: string
  40. version: string
  41. description: string
  42. peertubeEngine: string
  43. type: PluginType_Type
  44. path: string
  45. staticDirs: { [name: string]: string }
  46. clientScripts: { [name: string]: ClientScriptJSON }
  47. css: string[]
  48. // Only if this is a plugin
  49. registerHelpers?: RegisterHelpers
  50. unregister?: Function
  51. }
  52. export interface HookInformationValue {
  53. npmName: string
  54. pluginName: string
  55. handler: Function
  56. priority: number
  57. }
  58. type PluginLocalesTranslations = {
  59. [locale: string]: PluginTranslation
  60. }
  61. export class PluginManager implements ServerHook {
  62. private static instance: PluginManager
  63. private registeredPlugins: { [name: string]: RegisteredPlugin } = {}
  64. private hooks: { [name: string]: HookInformationValue[] } = {}
  65. private translations: PluginLocalesTranslations = {}
  66. private server: Server
  67. private constructor () {
  68. }
  69. init (server: Server) {
  70. this.server = server
  71. }
  72. registerWebSocketRouter () {
  73. this.server.on('upgrade', (request, socket, head) => {
  74. // Check if it's a plugin websocket connection
  75. // No need to destroy the stream when we abort the request
  76. // Other handlers in PeerTube will catch this upgrade event too (socket.io, tracker etc)
  77. const url = request.url
  78. const matched = url.match(`/plugins/([^/]+)/([^/]+/)?ws(/.*)`)
  79. if (!matched) return
  80. const npmName = PluginModel.buildNpmName(matched[1], PluginType.PLUGIN)
  81. const subRoute = matched[3]
  82. const result = this.getRegisteredPluginOrTheme(npmName)
  83. if (!result) return
  84. const routes = result.registerHelpers.getWebSocketRoutes()
  85. const wss = routes.find(r => r.route.startsWith(subRoute))
  86. if (!wss) return
  87. try {
  88. wss.handler(request, socket, head)
  89. } catch (err) {
  90. logger.error('Exception in plugin handler ' + npmName, { err })
  91. }
  92. })
  93. }
  94. // ###################### Getters ######################
  95. isRegistered (npmName: string) {
  96. return !!this.getRegisteredPluginOrTheme(npmName)
  97. }
  98. getRegisteredPluginOrTheme (npmName: string) {
  99. return this.registeredPlugins[npmName]
  100. }
  101. getRegisteredPluginByShortName (name: string) {
  102. const npmName = PluginModel.buildNpmName(name, PluginType.PLUGIN)
  103. const registered = this.getRegisteredPluginOrTheme(npmName)
  104. if (!registered || registered.type !== PluginType.PLUGIN) return undefined
  105. return registered
  106. }
  107. getRegisteredThemeByShortName (name: string) {
  108. const npmName = PluginModel.buildNpmName(name, PluginType.THEME)
  109. const registered = this.getRegisteredPluginOrTheme(npmName)
  110. if (!registered || registered.type !== PluginType.THEME) return undefined
  111. return registered
  112. }
  113. getRegisteredPlugins () {
  114. return this.getRegisteredPluginsOrThemes(PluginType.PLUGIN)
  115. }
  116. getRegisteredThemes () {
  117. return this.getRegisteredPluginsOrThemes(PluginType.THEME)
  118. }
  119. getIdAndPassAuths () {
  120. return this.getRegisteredPlugins()
  121. .map(p => ({
  122. npmName: p.npmName,
  123. name: p.name,
  124. version: p.version,
  125. idAndPassAuths: p.registerHelpers.getIdAndPassAuths()
  126. }))
  127. .filter(v => v.idAndPassAuths.length !== 0)
  128. }
  129. getExternalAuths () {
  130. return this.getRegisteredPlugins()
  131. .map(p => ({
  132. npmName: p.npmName,
  133. name: p.name,
  134. version: p.version,
  135. externalAuths: p.registerHelpers.getExternalAuths()
  136. }))
  137. .filter(v => v.externalAuths.length !== 0)
  138. }
  139. getRegisteredSettings (npmName: string) {
  140. const result = this.getRegisteredPluginOrTheme(npmName)
  141. if (!result || result.type !== PluginType.PLUGIN) return []
  142. return result.registerHelpers.getSettings()
  143. }
  144. getRouter (npmName: string) {
  145. const result = this.getRegisteredPluginOrTheme(npmName)
  146. if (!result || result.type !== PluginType.PLUGIN) return null
  147. return result.registerHelpers.getRouter()
  148. }
  149. getTranslations (locale: string) {
  150. return this.translations[locale] || {}
  151. }
  152. async isTokenValid (token: MOAuthTokenUser, type: 'access' | 'refresh') {
  153. const auth = this.getAuth(token.User.pluginAuth, token.authName)
  154. if (!auth) return true
  155. if (auth.hookTokenValidity) {
  156. try {
  157. const { valid } = await auth.hookTokenValidity({ token, type })
  158. if (valid === false) {
  159. logger.info('Rejecting %s token validity from auth %s of plugin %s', type, token.authName, token.User.pluginAuth)
  160. }
  161. return valid
  162. } catch (err) {
  163. logger.warn('Cannot run check token validity from auth %s of plugin %s.', token.authName, token.User.pluginAuth, { err })
  164. return true
  165. }
  166. }
  167. return true
  168. }
  169. // ###################### External events ######################
  170. async onLogout (npmName: string, authName: string, user: MUser, req: express.Request) {
  171. const auth = this.getAuth(npmName, authName)
  172. if (auth?.onLogout) {
  173. logger.info('Running onLogout function from auth %s of plugin %s', authName, npmName)
  174. try {
  175. // Force await, in case or onLogout returns a promise
  176. const result = await auth.onLogout(user, req)
  177. return typeof result === 'string'
  178. ? result
  179. : undefined
  180. } catch (err) {
  181. logger.warn('Cannot run onLogout function from auth %s of plugin %s.', authName, npmName, { err })
  182. }
  183. }
  184. return undefined
  185. }
  186. async onSettingsChanged (name: string, settings: any) {
  187. const registered = this.getRegisteredPluginByShortName(name)
  188. if (!registered) {
  189. logger.error('Cannot find plugin %s to call on settings changed.', name)
  190. }
  191. for (const cb of registered.registerHelpers.getOnSettingsChangedCallbacks()) {
  192. try {
  193. await cb(settings)
  194. } catch (err) {
  195. logger.error('Cannot run on settings changed callback for %s.', registered.npmName, { err })
  196. }
  197. }
  198. }
  199. // ###################### Hooks ######################
  200. async runHook<T> (hookName: ServerHookName, result?: T, params?: any): Promise<T> {
  201. if (!this.hooks[hookName]) return Promise.resolve(result)
  202. const hookType = getHookType(hookName)
  203. for (const hook of this.hooks[hookName]) {
  204. logger.debug('Running hook %s of plugin %s.', hookName, hook.npmName)
  205. result = await internalRunHook({
  206. handler: hook.handler,
  207. hookType,
  208. result,
  209. params,
  210. onError: err => { logger.error('Cannot run hook %s of plugin %s.', hookName, hook.pluginName, { err }) }
  211. })
  212. }
  213. return result
  214. }
  215. // ###################### Registration ######################
  216. async registerPluginsAndThemes () {
  217. await this.resetCSSGlobalFile()
  218. const plugins = await PluginModel.listEnabledPluginsAndThemes()
  219. for (const plugin of plugins) {
  220. try {
  221. await this.registerPluginOrTheme(plugin)
  222. } catch (err) {
  223. // Try to unregister the plugin
  224. try {
  225. await this.unregister(PluginModel.buildNpmName(plugin.name, plugin.type))
  226. } catch {
  227. // we don't care if we cannot unregister it
  228. }
  229. logger.error('Cannot register plugin %s, skipping.', plugin.name, { err })
  230. }
  231. }
  232. this.sortHooksByPriority()
  233. }
  234. // Don't need the plugin type since themes cannot register server code
  235. async unregister (npmName: string) {
  236. logger.info('Unregister plugin %s.', npmName)
  237. const plugin = this.getRegisteredPluginOrTheme(npmName)
  238. if (!plugin) {
  239. throw new Error(`Unknown plugin ${npmName} to unregister`)
  240. }
  241. delete this.registeredPlugins[plugin.npmName]
  242. this.deleteTranslations(plugin.npmName)
  243. if (plugin.type === PluginType.PLUGIN) {
  244. await plugin.unregister()
  245. // Remove hooks of this plugin
  246. for (const key of Object.keys(this.hooks)) {
  247. this.hooks[key] = this.hooks[key].filter(h => h.npmName !== npmName)
  248. }
  249. const store = plugin.registerHelpers
  250. store.reinitVideoConstants(plugin.npmName)
  251. store.reinitTranscodingProfilesAndEncoders(plugin.npmName)
  252. logger.info('Regenerating registered plugin CSS to global file.')
  253. await this.regeneratePluginGlobalCSS()
  254. }
  255. ClientHtml.invalidateCache()
  256. }
  257. // ###################### Installation ######################
  258. async install (options: {
  259. toInstall: string
  260. version?: string
  261. fromDisk?: boolean // default false
  262. register?: boolean // default true
  263. }) {
  264. const { toInstall, version, fromDisk = false, register = true } = options
  265. let plugin: PluginModel
  266. let npmName: string
  267. logger.info('Installing plugin %s.', toInstall)
  268. try {
  269. fromDisk
  270. ? await installNpmPluginFromDisk(toInstall)
  271. : await installNpmPlugin(toInstall, version)
  272. npmName = fromDisk ? basename(toInstall) : toInstall
  273. const pluginType = PluginModel.getTypeFromNpmName(npmName)
  274. const pluginName = PluginModel.normalizePluginName(npmName)
  275. const packageJSON = await this.getPackageJSON(pluginName, pluginType)
  276. this.sanitizeAndCheckPackageJSONOrThrow(packageJSON, pluginType);
  277. [ plugin ] = await PluginModel.upsert({
  278. name: pluginName,
  279. description: packageJSON.description,
  280. homepage: packageJSON.homepage,
  281. type: pluginType,
  282. version: packageJSON.version,
  283. enabled: true,
  284. uninstalled: false,
  285. peertubeEngine: packageJSON.engine.peertube
  286. }, { returning: true })
  287. logger.info('Successful installation of plugin %s.', toInstall)
  288. if (register) {
  289. await this.registerPluginOrTheme(plugin)
  290. }
  291. } catch (rootErr) {
  292. logger.error('Cannot install plugin %s, removing it...', toInstall, { err: rootErr })
  293. if (npmName) {
  294. try {
  295. await this.uninstall({ npmName })
  296. } catch (err) {
  297. logger.error('Cannot uninstall plugin %s after failed installation.', toInstall, { err })
  298. try {
  299. await removeNpmPlugin(npmName)
  300. } catch (err) {
  301. logger.error('Cannot remove plugin %s after failed installation.', toInstall, { err })
  302. }
  303. }
  304. }
  305. throw rootErr
  306. }
  307. return plugin
  308. }
  309. async update (toUpdate: string, fromDisk = false) {
  310. const npmName = fromDisk ? basename(toUpdate) : toUpdate
  311. logger.info('Updating plugin %s.', npmName)
  312. // Use the latest version from DB, to not upgrade to a version that does not support our PeerTube version
  313. let version: string
  314. if (!fromDisk) {
  315. const plugin = await PluginModel.loadByNpmName(toUpdate)
  316. version = plugin.latestVersion
  317. }
  318. // Unregister old hooks
  319. await this.unregister(npmName)
  320. return this.install({ toInstall: toUpdate, version, fromDisk })
  321. }
  322. async uninstall (options: {
  323. npmName: string
  324. unregister?: boolean // default true
  325. }) {
  326. const { npmName, unregister = true } = options
  327. logger.info('Uninstalling plugin %s.', npmName)
  328. if (unregister) {
  329. try {
  330. await this.unregister(npmName)
  331. } catch (err) {
  332. logger.warn('Cannot unregister plugin %s.', npmName, { err })
  333. }
  334. }
  335. const plugin = await PluginModel.loadByNpmName(npmName)
  336. if (!plugin || plugin.uninstalled === true) {
  337. logger.error('Cannot uninstall plugin %s: it does not exist or is already uninstalled.', npmName)
  338. return
  339. }
  340. plugin.enabled = false
  341. plugin.uninstalled = true
  342. await plugin.save()
  343. await removeNpmPlugin(npmName)
  344. logger.info('Plugin %s uninstalled.', npmName)
  345. }
  346. async rebuildNativePluginsIfNeeded () {
  347. if (!await ApplicationModel.nodeABIChanged()) return
  348. return rebuildNativePlugins()
  349. }
  350. // ###################### Private register ######################
  351. private async registerPluginOrTheme (plugin: PluginModel) {
  352. const npmName = PluginModel.buildNpmName(plugin.name, plugin.type)
  353. logger.info('Registering plugin or theme %s.', npmName)
  354. const packageJSON = await this.getPackageJSON(plugin.name, plugin.type)
  355. const pluginPath = this.getPluginPath(plugin.name, plugin.type)
  356. this.sanitizeAndCheckPackageJSONOrThrow(packageJSON, plugin.type)
  357. let library: PluginLibrary
  358. let registerHelpers: RegisterHelpers
  359. if (plugin.type === PluginType.PLUGIN) {
  360. const result = await this.registerPlugin(plugin, pluginPath, packageJSON)
  361. library = result.library
  362. registerHelpers = result.registerStore
  363. }
  364. const clientScripts: { [id: string]: ClientScriptJSON } = {}
  365. for (const c of packageJSON.clientScripts) {
  366. clientScripts[c.script] = c
  367. }
  368. this.registeredPlugins[npmName] = {
  369. npmName,
  370. name: plugin.name,
  371. type: plugin.type,
  372. version: plugin.version,
  373. description: plugin.description,
  374. peertubeEngine: plugin.peertubeEngine,
  375. path: pluginPath,
  376. staticDirs: packageJSON.staticDirs,
  377. clientScripts,
  378. css: packageJSON.css,
  379. registerHelpers: registerHelpers || undefined,
  380. unregister: library ? library.unregister : undefined
  381. }
  382. await this.addTranslations(plugin, npmName, packageJSON.translations)
  383. ClientHtml.invalidateCache()
  384. }
  385. private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJSON) {
  386. const npmName = PluginModel.buildNpmName(plugin.name, plugin.type)
  387. // Delete cache if needed
  388. const modulePath = join(pluginPath, packageJSON.library)
  389. decachePlugin(require, modulePath)
  390. const library: PluginLibrary = require(modulePath)
  391. if (!isLibraryCodeValid(library)) {
  392. throw new Error('Library code is not valid (miss register or unregister function)')
  393. }
  394. const { registerOptions, registerStore } = this.getRegisterHelpers(npmName, plugin)
  395. await ensureDir(registerOptions.peertubeHelpers.plugin.getDataDirectoryPath())
  396. await library.register(registerOptions)
  397. logger.info('Add plugin %s CSS to global file.', npmName)
  398. await this.addCSSToGlobalFile(pluginPath, packageJSON.css)
  399. return { library, registerStore }
  400. }
  401. // ###################### Translations ######################
  402. private async addTranslations (plugin: PluginModel, npmName: string, translationPaths: PluginTranslationPathsJSON) {
  403. for (const locale of Object.keys(translationPaths)) {
  404. const path = translationPaths[locale]
  405. const json = await readJSON(join(this.getPluginPath(plugin.name, plugin.type), path))
  406. const completeLocale = getCompleteLocale(locale)
  407. if (!this.translations[completeLocale]) this.translations[completeLocale] = {}
  408. this.translations[completeLocale][npmName] = json
  409. logger.info('Added locale %s of plugin %s.', completeLocale, npmName)
  410. }
  411. }
  412. private deleteTranslations (npmName: string) {
  413. for (const locale of Object.keys(this.translations)) {
  414. delete this.translations[locale][npmName]
  415. logger.info('Deleted locale %s of plugin %s.', locale, npmName)
  416. }
  417. }
  418. // ###################### CSS ######################
  419. private resetCSSGlobalFile () {
  420. return outputFile(PLUGIN_GLOBAL_CSS_PATH, '')
  421. }
  422. private async addCSSToGlobalFile (pluginPath: string, cssRelativePaths: string[]) {
  423. for (const cssPath of cssRelativePaths) {
  424. await this.concatFiles(join(pluginPath, cssPath), PLUGIN_GLOBAL_CSS_PATH)
  425. }
  426. }
  427. private concatFiles (input: string, output: string) {
  428. return new Promise<void>((res, rej) => {
  429. const inputStream = createReadStream(input)
  430. const outputStream = createWriteStream(output, { flags: 'a' })
  431. inputStream.pipe(outputStream)
  432. inputStream.on('end', () => res())
  433. inputStream.on('error', err => rej(err))
  434. })
  435. }
  436. private async regeneratePluginGlobalCSS () {
  437. await this.resetCSSGlobalFile()
  438. for (const plugin of this.getRegisteredPlugins()) {
  439. await this.addCSSToGlobalFile(plugin.path, plugin.css)
  440. }
  441. }
  442. // ###################### Utils ######################
  443. private sortHooksByPriority () {
  444. for (const hookName of Object.keys(this.hooks)) {
  445. this.hooks[hookName].sort((a, b) => {
  446. return b.priority - a.priority
  447. })
  448. }
  449. }
  450. private getPackageJSON (pluginName: string, pluginType: PluginType_Type) {
  451. const pluginPath = join(this.getPluginPath(pluginName, pluginType), 'package.json')
  452. return readJSON(pluginPath) as Promise<PluginPackageJSON>
  453. }
  454. private getPluginPath (pluginName: string, pluginType: PluginType_Type) {
  455. const npmName = PluginModel.buildNpmName(pluginName, pluginType)
  456. return join(CONFIG.STORAGE.PLUGINS_DIR, 'node_modules', npmName)
  457. }
  458. private getAuth (npmName: string, authName: string) {
  459. const plugin = this.getRegisteredPluginOrTheme(npmName)
  460. if (!plugin || plugin.type !== PluginType.PLUGIN) return null
  461. let auths: (RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions)[] = plugin.registerHelpers.getIdAndPassAuths()
  462. auths = auths.concat(plugin.registerHelpers.getExternalAuths())
  463. return auths.find(a => a.authName === authName)
  464. }
  465. // ###################### Private getters ######################
  466. private getRegisteredPluginsOrThemes (type: PluginType_Type) {
  467. const plugins: RegisteredPlugin[] = []
  468. for (const npmName of Object.keys(this.registeredPlugins)) {
  469. const plugin = this.registeredPlugins[npmName]
  470. if (plugin.type !== type) continue
  471. plugins.push(plugin)
  472. }
  473. return plugins
  474. }
  475. // ###################### Generate register helpers ######################
  476. private getRegisterHelpers (
  477. npmName: string,
  478. plugin: PluginModel
  479. ): { registerStore: RegisterHelpers, registerOptions: RegisterServerOptions } {
  480. const onHookAdded = (options: RegisterServerHookOptions) => {
  481. if (!this.hooks[options.target]) this.hooks[options.target] = []
  482. this.hooks[options.target].push({
  483. npmName,
  484. pluginName: plugin.name,
  485. handler: options.handler,
  486. priority: options.priority || 0
  487. })
  488. }
  489. const registerHelpers = new RegisterHelpers(npmName, plugin, this.server, onHookAdded.bind(this))
  490. return {
  491. registerStore: registerHelpers,
  492. registerOptions: registerHelpers.buildRegisterHelpers()
  493. }
  494. }
  495. private sanitizeAndCheckPackageJSONOrThrow (packageJSON: PluginPackageJSON, pluginType: PluginType_Type) {
  496. if (!packageJSON.staticDirs) packageJSON.staticDirs = {}
  497. if (!packageJSON.css) packageJSON.css = []
  498. if (!packageJSON.clientScripts) packageJSON.clientScripts = []
  499. if (!packageJSON.translations) packageJSON.translations = {}
  500. const { result: packageJSONValid, badFields } = isPackageJSONValid(packageJSON, pluginType)
  501. if (!packageJSONValid) {
  502. const formattedFields = badFields.map(f => `"${f}"`)
  503. .join(', ')
  504. throw new Error(`PackageJSON is invalid (invalid fields: ${formattedFields}).`)
  505. }
  506. }
  507. static get Instance () {
  508. return this.instance || (this.instance = new this())
  509. }
  510. }