]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add ability for plugins to register ws routes
authorChocobozzz <me@florianbigard.com>
Tue, 11 Oct 2022 09:07:40 +0000 (11:07 +0200)
committerChocobozzz <me@florianbigard.com>
Tue, 11 Oct 2022 09:11:04 +0000 (11:11 +0200)
16 files changed:
client/src/app/core/plugins/plugin.service.ts
client/src/standalone/videos/shared/peertube-plugin.ts
client/src/types/register-client-option.model.ts
server.ts
server/lib/plugins/plugin-helpers-builder.ts
server/lib/plugins/plugin-manager.ts
server/lib/plugins/register-helpers.ts
server/tests/fixtures/peertube-plugin-test-websocket/main.js [new file with mode: 0644]
server/tests/fixtures/peertube-plugin-test-websocket/package.json [new file with mode: 0644]
server/tests/plugins/index.ts
server/tests/plugins/plugin-websocket.ts [new file with mode: 0644]
server/types/plugins/index.ts
server/types/plugins/register-server-option.model.ts
server/types/plugins/register-server-websocket-route.model.ts [new file with mode: 0644]
support/doc/plugins/guide.md
support/nginx/peertube

index dadc2a41d3d9a719e2f35cacb4bed93d19bac212..1e79cbf79b6cbde46158b9118b71efed73aecef4 100644 (file)
@@ -202,6 +202,11 @@ export class PluginService implements ClientHook {
         return environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/router`
       },
 
+      getBaseWebSocketRoute: () => {
+        const pathPrefix = PluginsManager.getPluginPathPrefix(pluginInfo.isTheme)
+        return environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/ws`
+      },
+
       getBasePluginClientPath: () => {
         return '/p'
       },
index 968854ce842d713c4ec34a38fb93575a377b8847..daf6f2b035667cebb71bf83a6e1d7949866e6059 100644 (file)
@@ -43,6 +43,7 @@ export class PeerTubePlugin {
     return {
       getBaseStaticRoute: unimplemented,
       getBaseRouterRoute: unimplemented,
+      getBaseWebSocketRoute: unimplemented,
       getBasePluginClientPath: unimplemented,
 
       getSettings: () => {
index 2460a7499e205211159b2633c220839ff79b34d2..2c09f15a7c655acf21523ef753fc1a9fc27f3fff 100644 (file)
@@ -24,6 +24,9 @@ export type RegisterClientHelpers = {
 
   getBaseRouterRoute: () => string
 
+  // PeerTube >= 5.0
+  getBaseWebSocketRoute: () => string
+
   getBasePluginClientPath: () => string
 
   isLoggedIn: () => boolean
index 417387a4fc14c440865afb792454c64b5d28af86..a29b5e408f5e6c2aa066ee31a19a70acbe97827b 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -328,6 +328,10 @@ async function startApplication () {
   GeoIPUpdateScheduler.Instance.enable()
   OpenTelemetryMetrics.Instance.registerMetrics()
 
+  PluginManager.Instance.init(server)
+  // Before PeerTubeSocket init
+  PluginManager.Instance.registerWebSocketRouter()
+
   PeerTubeSocket.Instance.init(server)
   VideoViewsManager.Instance.init()
 
index 35945422c24e1db9900754390e14977339562588..7b1def6e30c9e171f8703f4751047374f8fbbfed 100644 (file)
@@ -1,4 +1,5 @@
 import express from 'express'
+import { Server } from 'http'
 import { join } from 'path'
 import { ffprobePromise } from '@server/helpers/ffmpeg/ffprobe-utils'
 import { buildLogger } from '@server/helpers/logger'
@@ -17,12 +18,12 @@ import { MPlugin, MVideo, UserNotificationModelForApi } from '@server/types/mode
 import { PeerTubeHelpers } from '@server/types/plugins'
 import { VideoBlacklistCreate, VideoStorage } from '@shared/models'
 import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist'
+import { PeerTubeSocket } from '../peertube-socket'
 import { ServerConfigManager } from '../server-config-manager'
 import { blacklistVideo, unblacklistVideo } from '../video-blacklist'
 import { VideoPathManager } from '../video-path-manager'
-import { PeerTubeSocket } from '../peertube-socket'
 
-function buildPluginHelpers (pluginModel: MPlugin, npmName: string): PeerTubeHelpers {
+function buildPluginHelpers (httpServer: Server, pluginModel: MPlugin, npmName: string): PeerTubeHelpers {
   const logger = buildPluginLogger(npmName)
 
   const database = buildDatabaseHelpers()
@@ -30,7 +31,7 @@ function buildPluginHelpers (pluginModel: MPlugin, npmName: string): PeerTubeHel
 
   const config = buildConfigHelpers()
 
-  const server = buildServerHelpers()
+  const server = buildServerHelpers(httpServer)
 
   const moderation = buildModerationHelpers()
 
@@ -69,8 +70,10 @@ function buildDatabaseHelpers () {
   }
 }
 
-function buildServerHelpers () {
+function buildServerHelpers (httpServer: Server) {
   return {
+    getHTTPServer: () => httpServer,
+
     getServerActor: () => getServerActor()
   }
 }
@@ -218,6 +221,8 @@ function buildPluginRelatedHelpers (plugin: MPlugin, npmName: string) {
 
     getBaseRouterRoute: () => `/plugins/${plugin.name}/${plugin.version}/router/`,
 
+    getBaseWebSocketRoute: () => `/plugins/${plugin.name}/${plugin.version}/ws/`,
+
     getDataDirectoryPath: () => join(CONFIG.STORAGE.PLUGINS_DIR, 'data', npmName)
   }
 }
index a46b97fa46cfb069a2dec56379bf41a23f59ab11..c4d9b65740a9cc0e1779841475e41d10effb64bb 100644 (file)
@@ -1,6 +1,7 @@
 import express from 'express'
 import { createReadStream, createWriteStream } from 'fs'
 import { ensureDir, outputFile, readJSON } from 'fs-extra'
+import { Server } from 'http'
 import { basename, join } from 'path'
 import { decachePlugin } from '@server/helpers/decache'
 import { ApplicationModel } from '@server/models/application/application'
@@ -67,9 +68,37 @@ export class PluginManager implements ServerHook {
   private hooks: { [name: string]: HookInformationValue[] } = {}
   private translations: PluginLocalesTranslations = {}
 
+  private server: Server
+
   private constructor () {
   }
 
+  init (server: Server) {
+    this.server = server
+  }
+
+  registerWebSocketRouter () {
+    this.server.on('upgrade', (request, socket, head) => {
+      const url = request.url
+
+      const matched = url.match(`/plugins/([^/]+)/([^/]+/)?ws(/.*)`)
+      if (!matched) return
+
+      const npmName = PluginModel.buildNpmName(matched[1], PluginType.PLUGIN)
+      const subRoute = matched[3]
+
+      const result = this.getRegisteredPluginOrTheme(npmName)
+      if (!result) return
+
+      const routes = result.registerHelpers.getWebSocketRoutes()
+
+      const wss = routes.find(r => r.route.startsWith(subRoute))
+      if (!wss) return
+
+      wss.handler(request, socket, head)
+    })
+  }
+
   // ###################### Getters ######################
 
   isRegistered (npmName: string) {
@@ -581,7 +610,7 @@ export class PluginManager implements ServerHook {
       })
     }
 
-    const registerHelpers = new RegisterHelpers(npmName, plugin, onHookAdded.bind(this))
+    const registerHelpers = new RegisterHelpers(npmName, plugin, this.server, onHookAdded.bind(this))
 
     return {
       registerStore: registerHelpers,
index f4d40567611c9c4c7572a3bafc3bdca3a8f523ed..1aaef36068486a892208a37db43b7660d1aa01a2 100644 (file)
@@ -1,4 +1,5 @@
 import express from 'express'
+import { Server } from 'http'
 import { logger } from '@server/helpers/logger'
 import { onExternalUserAuthenticated } from '@server/lib/auth/external-auth'
 import { VideoConstantManagerFactory } from '@server/lib/plugins/video-constant-manager-factory'
@@ -8,7 +9,8 @@ import {
   RegisterServerAuthExternalResult,
   RegisterServerAuthPassOptions,
   RegisterServerExternalAuthenticatedResult,
-  RegisterServerOptions
+  RegisterServerOptions,
+  RegisterServerWebSocketRouteOptions
 } from '@server/types/plugins'
 import {
   EncoderOptionsBuilder,
@@ -49,12 +51,15 @@ export class RegisterHelpers {
 
   private readonly onSettingsChangeCallbacks: SettingsChangeCallback[] = []
 
+  private readonly webSocketRoutes: RegisterServerWebSocketRouteOptions[] = []
+
   private readonly router: express.Router
   private readonly videoConstantManagerFactory: VideoConstantManagerFactory
 
   constructor (
     private readonly npmName: string,
     private readonly plugin: PluginModel,
+    private readonly server: Server,
     private readonly onHookAdded: (options: RegisterServerHookOptions) => void
   ) {
     this.router = express.Router()
@@ -66,6 +71,7 @@ export class RegisterHelpers {
     const registerSetting = this.buildRegisterSetting()
 
     const getRouter = this.buildGetRouter()
+    const registerWebSocketRoute = this.buildRegisterWebSocketRoute()
 
     const settingsManager = this.buildSettingsManager()
     const storageManager = this.buildStorageManager()
@@ -85,13 +91,14 @@ export class RegisterHelpers {
     const unregisterIdAndPassAuth = this.buildUnregisterIdAndPassAuth()
     const unregisterExternalAuth = this.buildUnregisterExternalAuth()
 
-    const peertubeHelpers = buildPluginHelpers(this.plugin, this.npmName)
+    const peertubeHelpers = buildPluginHelpers(this.server, this.plugin, this.npmName)
 
     return {
       registerHook,
       registerSetting,
 
       getRouter,
+      registerWebSocketRoute,
 
       settingsManager,
       storageManager,
@@ -180,10 +187,20 @@ export class RegisterHelpers {
     return this.onSettingsChangeCallbacks
   }
 
+  getWebSocketRoutes () {
+    return this.webSocketRoutes
+  }
+
   private buildGetRouter () {
     return () => this.router
   }
 
+  private buildRegisterWebSocketRoute () {
+    return (options: RegisterServerWebSocketRouteOptions) => {
+      this.webSocketRoutes.push(options)
+    }
+  }
+
   private buildRegisterSetting () {
     return (options: RegisterServerSettingOptions) => {
       this.settings.push(options)
diff --git a/server/tests/fixtures/peertube-plugin-test-websocket/main.js b/server/tests/fixtures/peertube-plugin-test-websocket/main.js
new file mode 100644 (file)
index 0000000..3fde76c
--- /dev/null
@@ -0,0 +1,36 @@
+const WebSocketServer = require('ws').WebSocketServer
+
+async function register ({
+  registerWebSocketRoute
+}) {
+  const wss = new WebSocketServer({ noServer: true })
+
+  wss.on('connection', function connection(ws) {
+    ws.on('message', function message(data) {
+      if (data.toString() === 'ping') {
+        ws.send('pong')
+      }
+    })
+  })
+
+  registerWebSocketRoute({
+    route: '/toto',
+
+    handler: (request, socket, head) => {
+      wss.handleUpgrade(request, socket, head, ws => {
+        wss.emit('connection', ws, request)
+      })
+    }
+  })
+}
+
+async function unregister () {
+  return
+}
+
+module.exports = {
+  register,
+  unregister
+}
+
+// ###########################################################################
diff --git a/server/tests/fixtures/peertube-plugin-test-websocket/package.json b/server/tests/fixtures/peertube-plugin-test-websocket/package.json
new file mode 100644 (file)
index 0000000..89c8baa
--- /dev/null
@@ -0,0 +1,20 @@
+{
+  "name": "peertube-plugin-test-websocket",
+  "version": "0.0.1",
+  "description": "Plugin test websocket",
+  "engine": {
+    "peertube": ">=1.3.0"
+  },
+  "keywords": [
+    "peertube",
+    "plugin"
+  ],
+  "homepage": "https://github.com/Chocobozzz/PeerTube",
+  "author": "Chocobozzz",
+  "bugs": "https://github.com/Chocobozzz/PeerTube/issues",
+  "library": "./main.js",
+  "staticDirs": {},
+  "css": [],
+  "clientScripts": [],
+  "translations": {}
+}
index 4534120fd171d219f9c3b67efee1b9ccd1ba1f3b..210af7236cfde8f0b0881595fa756b90945f206b 100644 (file)
@@ -8,5 +8,6 @@ import './plugin-router'
 import './plugin-storage'
 import './plugin-transcoding'
 import './plugin-unloading'
+import './plugin-websocket'
 import './translations'
 import './video-constants'
diff --git a/server/tests/plugins/plugin-websocket.ts b/server/tests/plugins/plugin-websocket.ts
new file mode 100644 (file)
index 0000000..adaa28b
--- /dev/null
@@ -0,0 +1,70 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import WebSocket from 'ws'
+import { cleanupTests, createSingleServer, PeerTubeServer, PluginsCommand, setAccessTokensToServers } from '@shared/server-commands'
+
+function buildWebSocket (server: PeerTubeServer, path: string) {
+  return new WebSocket('ws://' + server.host + path)
+}
+
+function expectErrorOrTimeout (server: PeerTubeServer, path: string, expectedTimeout: number) {
+  return new Promise<void>((res, rej) => {
+    const ws = buildWebSocket(server, path)
+    ws.on('error', () => res())
+
+    const timeout = setTimeout(() => res(), expectedTimeout)
+
+    ws.on('open', () => {
+      clearTimeout(timeout)
+
+      return rej(new Error('Connect did not timeout'))
+    })
+  })
+}
+
+describe('Test plugin websocket', function () {
+  let server: PeerTubeServer
+  const basePaths = [
+    '/plugins/test-websocket/ws/',
+    '/plugins/test-websocket/0.0.1/ws/'
+  ]
+
+  before(async function () {
+    this.timeout(30000)
+
+    server = await createSingleServer(1)
+    await setAccessTokensToServers([ server ])
+
+    await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-websocket') })
+  })
+
+  it('Should not connect to the websocket without the appropriate path', async function () {
+    const paths = [
+      '/plugins/unknown/ws/',
+      '/plugins/unknown/0.0.1/ws/'
+    ]
+
+    for (const path of paths) {
+      await expectErrorOrTimeout(server, path, 1000)
+    }
+  })
+
+  it('Should not connect to the websocket without the appropriate sub path', async function () {
+    for (const path of basePaths) {
+      await expectErrorOrTimeout(server, path + '/unknown', 1000)
+    }
+  })
+
+  it('Should connect to the websocket and receive pong', function (done) {
+    const ws = buildWebSocket(server, basePaths[0])
+
+    ws.on('open', () => ws.send('ping'))
+    ws.on('message', data => {
+      if (data.toString() === 'pong') return done()
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
index de30ff2ab5ebf2fdb2d1a2b204479200ff4e6f64..bf9c35d49e2f5e81ce6ab962819fb204ac7ebc77 100644 (file)
@@ -1,3 +1,4 @@
 export * from './plugin-library.model'
 export * from './register-server-auth.model'
 export * from './register-server-option.model'
+export * from './register-server-websocket-route.model'
index a8b804b636b4c1aa235219fd962de8c93e0c2d28..1e2bd830ebaf7390c3e71c397bbeef23a6eb97c2 100644 (file)
@@ -1,4 +1,5 @@
 import { Response, Router } from 'express'
+import { Server } from 'http'
 import { Logger } from 'winston'
 import { ActorModel } from '@server/models/actor/actor'
 import {
@@ -22,6 +23,7 @@ import {
   RegisterServerAuthExternalResult,
   RegisterServerAuthPassOptions
 } from './register-server-auth.model'
+import { RegisterServerWebSocketRouteOptions } from './register-server-websocket-route.model'
 
 export type PeerTubeHelpers = {
   logger: Logger
@@ -83,6 +85,9 @@ export type PeerTubeHelpers = {
   }
 
   server: {
+    // PeerTube >= 5.0
+    getHTTPServer: () => Server
+
     getServerActor: () => Promise<ActorModel>
   }
 
@@ -97,6 +102,8 @@ export type PeerTubeHelpers = {
 
     // PeerTube >= 3.2
     getBaseRouterRoute: () => string
+    // PeerTube >= 5.0
+    getBaseWebSocketRoute: () => string
 
     // PeerTube >= 3.2
     getDataDirectoryPath: () => string
@@ -140,5 +147,12 @@ export type RegisterServerOptions = {
   //  * /plugins/:pluginName/router/...
   getRouter(): Router
 
+  // PeerTube >= 5.0
+  // Register WebSocket route
+  // Base routes of the WebSocket router are
+  //  * /plugins/:pluginName/:pluginVersion/ws/...
+  //  * /plugins/:pluginName/ws/...
+  registerWebSocketRoute: (options: RegisterServerWebSocketRouteOptions) => void
+
   peertubeHelpers: PeerTubeHelpers
 }
diff --git a/server/types/plugins/register-server-websocket-route.model.ts b/server/types/plugins/register-server-websocket-route.model.ts
new file mode 100644 (file)
index 0000000..edf64f6
--- /dev/null
@@ -0,0 +1,8 @@
+import { IncomingMessage } from 'http'
+import { Duplex } from 'stream'
+
+export type RegisterServerWebSocketRouteOptions = {
+  route: string
+
+  handler: (request: IncomingMessage, socket: Duplex, head: Buffer) => any
+}
index 431d5332f18a575ab9081644c6f187aa78c193ff..1c809258ab39de80194091d2922706ed592b6fb7 100644 (file)
@@ -12,6 +12,7 @@
     - [Storage](#storage)
     - [Update video constants](#update-video-constants)
     - [Add custom routes](#add-custom-routes)
+    - [Add custom WebSocket handlers](#add-custom-websocket-handlers)
     - [Add external auth methods](#add-external-auth-methods)
     - [Add new transcoding profiles](#add-new-transcoding-profiles)
     - [Server helpers](#server-helpers)
@@ -317,6 +318,41 @@ The `ping` route can be accessed using:
  * Or `/plugins/:pluginName/router/ping`
 
 
+#### Add custom WebSocket handlers
+
+You can create custom WebSocket servers (like [ws](https://github.com/websockets/ws) for example) using `registerWebSocketRoute`:
+
+```js
+function register ({
+  registerWebSocketRoute,
+  peertubeHelpers
+}) {
+  const wss = new WebSocketServer({ noServer: true })
+
+  wss.on('connection', function connection(ws) {
+    peertubeHelpers.logger.info('WebSocket connected!')
+
+    setInterval(() => {
+      ws.send('WebSocket message sent by server');
+    }, 1000)
+  })
+
+  registerWebSocketRoute({
+    route: '/my-websocket-route',
+
+    handler: (request, socket, head) => {
+      wss.handleUpgrade(request, socket, head, ws => {
+        wss.emit('connection', ws, request)
+      })
+    }
+  })
+}
+```
+
+The `my-websocket-route` route can be accessed using:
+ * `/plugins/:pluginName/:pluginVersion/ws/my-websocket-route`
+ * Or `/plugins/:pluginName/ws/my-websocket-route`
+
 #### Add external auth methods
 
 If you want to add a classic username/email and password auth method (like [LDAP](https://framagit.org/framasoft/peertube/official-plugins/-/tree/master/peertube-plugin-auth-ldap) for example):
index abb83d5c411d9dae4f5b3fb98b1fc1687efcaa96..f6f754b580761db569b4848eff8e376e71731fc6 100644 (file)
@@ -132,6 +132,11 @@ server {
     try_files /dev/null @api_websocket;
   }
 
+  # Plugin websocket routes
+  location ~ ^/plugins/[^/]+(/[^/]+)?/ws/ {
+    try_files /dev/null @api_websocket;
+  }
+
   ##
   # Performance optimizations
   # For extra performance please refer to https://github.com/denji/nginx-tuning