Browse Source

Fix SEO and refactor HTML pages generation

 * Split methods in multiple classes
 * Add JSONLD tags in embed too
 * Index embeds but use a canonical URL tag (targeting the watch page)
 * Remote objects don't include a canonical URL tag anymore. Instead we
   forbid indexation
 * Canonical URLs now use the official short URL (/w/, /w/p, /a, /c
   etc.)
Chocobozzz 7 tháng trước cách đây
mục cha
commit
f90db24233

+ 0 - 556
packages/tests/src/client.ts

@@ -1,556 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
-
-import { expect } from 'chai'
-import { omit } from '@peertube/peertube-core-utils'
-import {
-  Account,
-  HTMLServerConfig,
-  HttpStatusCode,
-  ServerConfig,
-  VideoPlaylistCreateResult,
-  VideoPlaylistPrivacy,
-  VideoPrivacy
-} from '@peertube/peertube-models'
-import {
-  cleanupTests,
-  createMultipleServers,
-  doubleFollow,
-  makeGetRequest,
-  makeHTMLRequest,
-  PeerTubeServer,
-  setAccessTokensToServers,
-  setDefaultVideoChannel,
-  waitJobs
-} from '@peertube/peertube-server-commands'
-
-function checkIndexTags (html: string, title: string, description: string, css: string, config: ServerConfig) {
-  expect(html).to.contain('<title>' + title + '</title>')
-  expect(html).to.contain('<meta name="description" content="' + description + '" />')
-  expect(html).to.contain('<style class="custom-css-style">' + css + '</style>')
-
-  const htmlConfig: HTMLServerConfig = omit(config, [ 'signup' ])
-  const configObjectString = JSON.stringify(htmlConfig)
-  const configEscapedString = JSON.stringify(configObjectString)
-
-  expect(html).to.contain(`<script type="application/javascript">window.PeerTubeServerConfig = ${configEscapedString}</script>`)
-}
-
-describe('Test a client controllers', function () {
-  let servers: PeerTubeServer[] = []
-  let account: Account
-
-  const videoName = 'my super name for server 1'
-  const videoDescription = 'my<br> super __description__ for *server* 1<p></p>'
-  const videoDescriptionPlainText = 'my super description for server 1'
-
-  const playlistName = 'super playlist name'
-  const playlistDescription = 'super playlist description'
-  let playlist: VideoPlaylistCreateResult
-
-  const channelDescription = 'my super channel description'
-
-  const watchVideoBasePaths = [ '/videos/watch/', '/w/' ]
-  const watchPlaylistBasePaths = [ '/videos/watch/playlist/', '/w/p/' ]
-
-  let videoIds: (string | number)[] = []
-  let privateVideoId: string
-  let internalVideoId: string
-  let unlistedVideoId: string
-  let passwordProtectedVideoId: string
-
-  let playlistIds: (string | number)[] = []
-
-  before(async function () {
-    this.timeout(120000)
-
-    servers = await createMultipleServers(2)
-
-    await setAccessTokensToServers(servers)
-
-    await doubleFollow(servers[0], servers[1])
-
-    await setDefaultVideoChannel(servers)
-
-    await servers[0].channels.update({
-      channelName: servers[0].store.channel.name,
-      attributes: { description: channelDescription }
-    })
-
-    // Public video
-
-    {
-      const attributes = { name: videoName, description: videoDescription }
-      await servers[0].videos.upload({ attributes })
-
-      const { data } = await servers[0].videos.list()
-      expect(data.length).to.equal(1)
-
-      const video = data[0]
-      servers[0].store.video = video
-      videoIds = [ video.id, video.uuid, video.shortUUID ]
-    }
-
-    {
-      ({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }));
-      ({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }));
-      ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }));
-      ({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({
-        name: 'password protected',
-        privacy: VideoPrivacy.PASSWORD_PROTECTED,
-        videoPasswords: [ 'password' ]
-      }))
-    }
-
-    // Playlist
-
-    {
-      const attributes = {
-        displayName: playlistName,
-        description: playlistDescription,
-        privacy: VideoPlaylistPrivacy.PUBLIC,
-        videoChannelId: servers[0].store.channel.id
-      }
-
-      playlist = await servers[0].playlists.create({ attributes })
-      playlistIds = [ playlist.id, playlist.shortUUID, playlist.uuid ]
-
-      await servers[0].playlists.addElement({ playlistId: playlist.shortUUID, attributes: { videoId: servers[0].store.video.id } })
-    }
-
-    // Account
-
-    {
-      await servers[0].users.updateMe({ description: 'my account description' })
-
-      account = await servers[0].accounts.get({ accountName: `${servers[0].store.user.username}@${servers[0].host}` })
-    }
-
-    await waitJobs(servers)
-  })
-
-  describe('oEmbed', function () {
-
-    it('Should have valid oEmbed discovery tags for videos', async function () {
-      for (const basePath of watchVideoBasePaths) {
-        for (const id of videoIds) {
-          const res = await makeGetRequest({
-            url: servers[0].url,
-            path: basePath + id,
-            accept: 'text/html',
-            expectedStatus: HttpStatusCode.OK_200
-          })
-
-          const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` +
-          `url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2F${servers[0].store.video.shortUUID}" ` +
-          `title="${servers[0].store.video.name}" />`
-
-          expect(res.text).to.contain(expectedLink)
-        }
-      }
-    })
-
-    it('Should have valid oEmbed discovery tags for a playlist', async function () {
-      for (const basePath of watchPlaylistBasePaths) {
-        for (const id of playlistIds) {
-          const res = await makeGetRequest({
-            url: servers[0].url,
-            path: basePath + id,
-            accept: 'text/html',
-            expectedStatus: HttpStatusCode.OK_200
-          })
-
-          const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` +
-            `url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2Fp%2F${playlist.shortUUID}" ` +
-            `title="${playlistName}" />`
-
-          expect(res.text).to.contain(expectedLink)
-        }
-      }
-    })
-  })
-
-  describe('Open Graph', function () {
-
-    async function accountPageTest (path: string) {
-      const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
-      const text = res.text
-
-      expect(text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
-      expect(text).to.contain(`<meta property="og:description" content="${account.description}" />`)
-      expect(text).to.contain('<meta property="og:type" content="website" />')
-      expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/a/${servers[0].store.user.username}" />`)
-    }
-
-    async function channelPageTest (path: string) {
-      const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
-      const text = res.text
-
-      expect(text).to.contain(`<meta property="og:title" content="${servers[0].store.channel.displayName}" />`)
-      expect(text).to.contain(`<meta property="og:description" content="${channelDescription}" />`)
-      expect(text).to.contain('<meta property="og:type" content="website" />')
-      expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/c/${servers[0].store.channel.name}" />`)
-    }
-
-    async function watchVideoPageTest (path: string) {
-      const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
-      const text = res.text
-
-      expect(text).to.contain(`<meta property="og:title" content="${videoName}" />`)
-      expect(text).to.contain(`<meta property="og:description" content="${videoDescriptionPlainText}" />`)
-      expect(text).to.contain('<meta property="og:type" content="video" />')
-      expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/${servers[0].store.video.shortUUID}" />`)
-    }
-
-    async function watchPlaylistPageTest (path: string) {
-      const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
-      const text = res.text
-
-      expect(text).to.contain(`<meta property="og:title" content="${playlistName}" />`)
-      expect(text).to.contain(`<meta property="og:description" content="${playlistDescription}" />`)
-      expect(text).to.contain('<meta property="og:type" content="video" />')
-      expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/p/${playlist.shortUUID}" />`)
-    }
-
-    it('Should have valid Open Graph tags on the account page', async function () {
-      await accountPageTest('/accounts/' + servers[0].store.user.username)
-      await accountPageTest('/a/' + servers[0].store.user.username)
-      await accountPageTest('/@' + servers[0].store.user.username)
-    })
-
-    it('Should have valid Open Graph tags on the channel page', async function () {
-      await channelPageTest('/video-channels/' + servers[0].store.channel.name)
-      await channelPageTest('/c/' + servers[0].store.channel.name)
-      await channelPageTest('/@' + servers[0].store.channel.name)
-    })
-
-    it('Should have valid Open Graph tags on the watch page', async function () {
-      for (const path of watchVideoBasePaths) {
-        for (const id of videoIds) {
-          await watchVideoPageTest(path + id)
-        }
-      }
-    })
-
-    it('Should have valid Open Graph tags on the watch page with thread id Angular param', async function () {
-      for (const path of watchVideoBasePaths) {
-        for (const id of videoIds) {
-          await watchVideoPageTest(path + id + ';threadId=1')
-        }
-      }
-    })
-
-    it('Should have valid Open Graph tags on the watch playlist page', async function () {
-      for (const path of watchPlaylistBasePaths) {
-        for (const id of playlistIds) {
-          await watchPlaylistPageTest(path + id)
-        }
-      }
-    })
-  })
-
-  describe('Twitter card', async function () {
-
-    describe('Not whitelisted', function () {
-
-      async function accountPageTest (path: string) {
-        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
-        const text = res.text
-
-        expect(text).to.contain('<meta property="twitter:card" content="summary" />')
-        expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
-        expect(text).to.contain(`<meta property="twitter:title" content="${account.name}" />`)
-        expect(text).to.contain(`<meta property="twitter:description" content="${account.description}" />`)
-      }
-
-      async function channelPageTest (path: string) {
-        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
-        const text = res.text
-
-        expect(text).to.contain('<meta property="twitter:card" content="summary" />')
-        expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
-        expect(text).to.contain(`<meta property="twitter:title" content="${servers[0].store.channel.displayName}" />`)
-        expect(text).to.contain(`<meta property="twitter:description" content="${channelDescription}" />`)
-      }
-
-      async function watchVideoPageTest (path: string) {
-        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
-        const text = res.text
-
-        expect(text).to.contain('<meta property="twitter:card" content="summary_large_image" />')
-        expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
-        expect(text).to.contain(`<meta property="twitter:title" content="${videoName}" />`)
-        expect(text).to.contain(`<meta property="twitter:description" content="${videoDescriptionPlainText}" />`)
-      }
-
-      async function watchPlaylistPageTest (path: string) {
-        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
-        const text = res.text
-
-        expect(text).to.contain('<meta property="twitter:card" content="summary" />')
-        expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
-        expect(text).to.contain(`<meta property="twitter:title" content="${playlistName}" />`)
-        expect(text).to.contain(`<meta property="twitter:description" content="${playlistDescription}" />`)
-      }
-
-      it('Should have valid twitter card on the watch video page', async function () {
-        for (const path of watchVideoBasePaths) {
-          for (const id of videoIds) {
-            await watchVideoPageTest(path + id)
-          }
-        }
-      })
-
-      it('Should have valid twitter card on the watch playlist page', async function () {
-        for (const path of watchPlaylistBasePaths) {
-          for (const id of playlistIds) {
-            await watchPlaylistPageTest(path + id)
-          }
-        }
-      })
-
-      it('Should have valid twitter card on the account page', async function () {
-        await accountPageTest('/accounts/' + account.name)
-        await accountPageTest('/a/' + account.name)
-        await accountPageTest('/@' + account.name)
-      })
-
-      it('Should have valid twitter card on the channel page', async function () {
-        await channelPageTest('/video-channels/' + servers[0].store.channel.name)
-        await channelPageTest('/c/' + servers[0].store.channel.name)
-        await channelPageTest('/@' + servers[0].store.channel.name)
-      })
-    })
-
-    describe('Whitelisted', function () {
-
-      before(async function () {
-        const config = await servers[0].config.getCustomConfig()
-        config.services.twitter = {
-          username: '@Kuja',
-          whitelisted: true
-        }
-
-        await servers[0].config.updateCustomConfig({ newCustomConfig: config })
-      })
-
-      async function accountPageTest (path: string) {
-        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
-        const text = res.text
-
-        expect(text).to.contain('<meta property="twitter:card" content="summary" />')
-        expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
-      }
-
-      async function channelPageTest (path: string) {
-        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
-        const text = res.text
-
-        expect(text).to.contain('<meta property="twitter:card" content="summary" />')
-        expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
-      }
-
-      async function watchVideoPageTest (path: string) {
-        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
-        const text = res.text
-
-        expect(text).to.contain('<meta property="twitter:card" content="player" />')
-        expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
-      }
-
-      async function watchPlaylistPageTest (path: string) {
-        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
-        const text = res.text
-
-        expect(text).to.contain('<meta property="twitter:card" content="player" />')
-        expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
-      }
-
-      it('Should have valid twitter card on the watch video page', async function () {
-        for (const path of watchVideoBasePaths) {
-          for (const id of videoIds) {
-            await watchVideoPageTest(path + id)
-          }
-        }
-      })
-
-      it('Should have valid twitter card on the watch playlist page', async function () {
-        for (const path of watchPlaylistBasePaths) {
-          for (const id of playlistIds) {
-            await watchPlaylistPageTest(path + id)
-          }
-        }
-      })
-
-      it('Should have valid twitter card on the account page', async function () {
-        await accountPageTest('/accounts/' + account.name)
-        await accountPageTest('/a/' + account.name)
-        await accountPageTest('/@' + account.name)
-      })
-
-      it('Should have valid twitter card on the channel page', async function () {
-        await channelPageTest('/video-channels/' + servers[0].store.channel.name)
-        await channelPageTest('/c/' + servers[0].store.channel.name)
-        await channelPageTest('/@' + servers[0].store.channel.name)
-      })
-    })
-  })
-
-  describe('Index HTML', function () {
-
-    it('Should have valid index html tags (title, description...)', async function () {
-      const config = await servers[0].config.getConfig()
-      const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
-
-      const description = 'PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.'
-      checkIndexTags(res.text, 'PeerTube', description, '', config)
-    })
-
-    it('Should update the customized configuration and have the correct index html tags', async function () {
-      await servers[0].config.updateCustomSubConfig({
-        newConfig: {
-          instance: {
-            name: 'PeerTube updated',
-            shortDescription: 'my short description',
-            description: 'my super description',
-            terms: 'my super terms',
-            defaultNSFWPolicy: 'blur',
-            defaultClientRoute: '/videos/recently-added',
-            customizations: {
-              javascript: 'alert("coucou")',
-              css: 'body { background-color: red; }'
-            }
-          }
-        }
-      })
-
-      const config = await servers[0].config.getConfig()
-      const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
-
-      checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config)
-    })
-
-    it('Should have valid index html updated tags (title, description...)', async function () {
-      const config = await servers[0].config.getConfig()
-      const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
-
-      checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config)
-    })
-
-    it('Should use the original video URL for the canonical tag', async function () {
-      for (const basePath of watchVideoBasePaths) {
-        for (const id of videoIds) {
-          const res = await makeHTMLRequest(servers[1].url, basePath + id)
-          expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/videos/watch/${servers[0].store.video.uuid}" />`)
-        }
-      }
-    })
-
-    it('Should use the original account URL for the canonical tag', async function () {
-      const accountURLtest = res => {
-        expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/accounts/root" />`)
-      }
-
-      accountURLtest(await makeHTMLRequest(servers[1].url, '/accounts/root@' + servers[0].host))
-      accountURLtest(await makeHTMLRequest(servers[1].url, '/a/root@' + servers[0].host))
-      accountURLtest(await makeHTMLRequest(servers[1].url, '/@root@' + servers[0].host))
-    })
-
-    it('Should use the original channel URL for the canonical tag', async function () {
-      const channelURLtests = res => {
-        expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-channels/root_channel" />`)
-      }
-
-      channelURLtests(await makeHTMLRequest(servers[1].url, '/video-channels/root_channel@' + servers[0].host))
-      channelURLtests(await makeHTMLRequest(servers[1].url, '/c/root_channel@' + servers[0].host))
-      channelURLtests(await makeHTMLRequest(servers[1].url, '/@root_channel@' + servers[0].host))
-    })
-
-    it('Should use the original playlist URL for the canonical tag', async function () {
-      for (const basePath of watchPlaylistBasePaths) {
-        for (const id of playlistIds) {
-          const res = await makeHTMLRequest(servers[1].url, basePath + id)
-          expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-playlists/${playlist.uuid}" />`)
-        }
-      }
-    })
-
-    it('Should add noindex meta tag for remote accounts', async function () {
-      const handle = 'root@' + servers[0].host
-      const paths = [ '/accounts/', '/a/', '/@' ]
-
-      for (const path of paths) {
-        {
-          const { text } = await makeHTMLRequest(servers[1].url, path + handle)
-          expect(text).to.contain('<meta name="robots" content="noindex" />')
-        }
-
-        {
-          const { text } = await makeHTMLRequest(servers[0].url, path + handle)
-          expect(text).to.not.contain('<meta name="robots" content="noindex" />')
-        }
-      }
-    })
-
-    it('Should add noindex meta tag for remote channels', async function () {
-      const handle = 'root_channel@' + servers[0].host
-      const paths = [ '/video-channels/', '/c/', '/@' ]
-
-      for (const path of paths) {
-        {
-          const { text } = await makeHTMLRequest(servers[1].url, path + handle)
-          expect(text).to.contain('<meta name="robots" content="noindex" />')
-        }
-
-        {
-          const { text } = await makeHTMLRequest(servers[0].url, path + handle)
-          expect(text).to.not.contain('<meta name="robots" content="noindex" />')
-        }
-      }
-    })
-
-    it('Should not display internal/private/password protected video', async function () {
-      for (const basePath of watchVideoBasePaths) {
-        for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) {
-          const res = await makeGetRequest({
-            url: servers[0].url,
-            path: basePath + id,
-            accept: 'text/html',
-            expectedStatus: HttpStatusCode.NOT_FOUND_404
-          })
-
-          expect(res.text).to.not.contain('internal')
-          expect(res.text).to.not.contain('private')
-          expect(res.text).to.not.contain('password protected')
-        }
-      }
-    })
-
-    it('Should add noindex meta tag for unlisted video', async function () {
-      for (const basePath of watchVideoBasePaths) {
-        const res = await makeGetRequest({
-          url: servers[0].url,
-          path: basePath + unlistedVideoId,
-          accept: 'text/html',
-          expectedStatus: HttpStatusCode.OK_200
-        })
-
-        expect(res.text).to.contain('unlisted')
-        expect(res.text).to.contain('<meta name="robots" content="noindex" />')
-      }
-    })
-  })
-
-  describe('Embed HTML', function () {
-
-    it('Should have the correct embed html tags', async function () {
-      const config = await servers[0].config.getConfig()
-      const res = await makeHTMLRequest(servers[0].url, servers[0].store.video.embedPath)
-
-      checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config)
-    })
-  })
-
-  after(async function () {
-    await cleanupTests(servers)
-  })
-})

+ 187 - 0
packages/tests/src/client/embed-html.ts

@@ -0,0 +1,187 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { ServerConfig, VideoPlaylistCreateResult } from '@peertube/peertube-models'
+import { cleanupTests, makeHTMLRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
+import { checkIndexTags, prepareClientTests } from '@tests/shared/client.js'
+
+describe('Test embed HTML generation', function () {
+  let servers: PeerTubeServer[]
+
+  let videoIds: (string | number)[] = []
+  let videoName: string
+  let videoDescriptionPlainText: string
+
+  let privateVideoId: string
+  let internalVideoId: string
+  let unlistedVideoId: string
+  let passwordProtectedVideoId: string
+
+  let playlistIds: (string | number)[] = []
+  let playlist: VideoPlaylistCreateResult
+  let privatePlaylistId: string
+  let unlistedPlaylistId: string
+  let playlistName: string
+  let playlistDescription: string
+  let instanceDescription: string
+
+  before(async function () {
+    this.timeout(120000);
+
+    ({
+      servers,
+      videoIds,
+      privateVideoId,
+      internalVideoId,
+      passwordProtectedVideoId,
+      unlistedVideoId,
+      videoName,
+      videoDescriptionPlainText,
+
+      playlistIds,
+      playlistName,
+      playlistDescription,
+      playlist,
+      unlistedPlaylistId,
+      privatePlaylistId,
+      instanceDescription
+    } = await prepareClientTests())
+  })
+
+  describe('HTML tags', function () {
+    let config: ServerConfig
+
+    before(async function () {
+      config = await servers[0].config.getConfig()
+    })
+
+    it('Should have the correct embed html instance tags', async function () {
+      const res = await makeHTMLRequest(servers[0].url, '/videos/embed/toto')
+
+      checkIndexTags(res.text, `PeerTube`, instanceDescription, '', config)
+
+      expect(res.text).to.not.contain(`"name":`)
+    })
+
+    it('Should have the correct embed html video tags', async function () {
+      const config = await servers[0].config.getConfig()
+      const res = await makeHTMLRequest(servers[0].url, servers[0].store.video.embedPath)
+
+      checkIndexTags(res.text, `${videoName} - PeerTube`, videoDescriptionPlainText, '', config)
+
+      expect(res.text).to.contain(`"name":"${videoName}",`)
+    })
+
+    it('Should have the correct embed html playlist tags', async function () {
+      const config = await servers[0].config.getConfig()
+      const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + playlistIds[0])
+
+      checkIndexTags(res.text, `${playlistName} - PeerTube`, playlistDescription, '', config)
+      expect(res.text).to.contain(`"name":"${playlistName}",`)
+    })
+  })
+
+  describe('Canonical tags', function () {
+
+    it('Should use the original video URL for the canonical tag', async function () {
+      for (const id of videoIds) {
+        const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + id)
+        expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/w/${servers[0].store.video.shortUUID}" />`)
+      }
+    })
+
+    it('Should use the original playlist URL for the canonical tag', async function () {
+      for (const id of playlistIds) {
+        const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + id)
+        expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/w/p/${playlist.shortUUID}" />`)
+      }
+    })
+
+  })
+
+  describe('Indexation tags', function () {
+
+    it('Should not index remote videos', async function () {
+      for (const id of videoIds) {
+        {
+          const res = await makeHTMLRequest(servers[1].url, '/videos/embed/' + id)
+          expect(res.text).to.contain('<meta name="robots" content="noindex" />')
+        }
+
+        {
+          const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + id)
+          expect(res.text).to.not.contain('<meta name="robots" content="noindex" />')
+        }
+      }
+    })
+
+    it('Should not index remote playlists', async function () {
+      for (const id of playlistIds) {
+        {
+          const res = await makeHTMLRequest(servers[1].url, '/video-playlists/embed/' + id)
+          expect(res.text).to.contain('<meta name="robots" content="noindex" />')
+        }
+
+        {
+          const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + id)
+          expect(res.text).to.not.contain('<meta name="robots" content="noindex" />')
+        }
+      }
+    })
+
+    it('Should add noindex meta tags for unlisted video', async function () {
+      {
+        const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + videoIds[0])
+
+        expect(res.text).to.not.contain('<meta name="robots" content="noindex" />')
+      }
+
+      {
+        const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + unlistedVideoId)
+
+        expect(res.text).to.contain('unlisted')
+        expect(res.text).to.contain('<meta name="robots" content="noindex" />')
+      }
+    })
+
+    it('Should add noindex meta tags for unlisted playlist', async function () {
+      {
+        const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + playlistIds[0])
+
+        expect(res.text).to.not.contain('<meta name="robots" content="noindex" />')
+      }
+
+      {
+        const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + unlistedPlaylistId)
+
+        expect(res.text).to.contain('unlisted')
+        expect(res.text).to.contain('<meta name="robots" content="noindex" />')
+      }
+    })
+  })
+
+  describe('Check leak of private objects', function () {
+
+    it('Should not leak video information in embed', async function () {
+      for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) {
+        const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + id)
+
+        expect(res.text).to.not.contain('internal')
+        expect(res.text).to.not.contain('private')
+        expect(res.text).to.not.contain('password protected')
+        expect(res.text).to.contain('<meta name="robots" content="noindex" />')
+      }
+    })
+
+    it('Should not leak playlist information in embed', async function () {
+      const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + privatePlaylistId)
+
+      expect(res.text).to.not.contain('private')
+      expect(res.text).to.contain('<meta name="robots" content="noindex" />')
+    })
+  })
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})

+ 258 - 0
packages/tests/src/client/index-html.ts

@@ -0,0 +1,258 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { HttpStatusCode, VideoPlaylistCreateResult } from '@peertube/peertube-models'
+import { cleanupTests, makeGetRequest, makeHTMLRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
+import { checkIndexTags, getWatchPlaylistBasePaths, getWatchVideoBasePaths, prepareClientTests } from '@tests/shared/client.js'
+
+describe('Test index HTML generation', function () {
+  let servers: PeerTubeServer[]
+
+  let videoIds: (string | number)[] = []
+  let privateVideoId: string
+  let internalVideoId: string
+  let unlistedVideoId: string
+  let passwordProtectedVideoId: string
+
+  let playlist: VideoPlaylistCreateResult
+
+  let playlistIds: (string | number)[] = []
+  let privatePlaylistId: string
+  let unlistedPlaylistId: string
+
+  let instanceDescription: string
+
+  before(async function () {
+    this.timeout(120000);
+
+    ({
+      servers,
+      playlistIds,
+      videoIds,
+      playlist,
+      privateVideoId,
+      internalVideoId,
+      passwordProtectedVideoId,
+      unlistedVideoId,
+      privatePlaylistId,
+      unlistedPlaylistId,
+      instanceDescription
+    } = await prepareClientTests())
+  })
+
+  describe('Instance tags', function () {
+
+    it('Should have valid index html tags (title, description...)', async function () {
+      const config = await servers[0].config.getConfig()
+      const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
+
+      checkIndexTags(res.text, 'PeerTube', instanceDescription, '', config)
+    })
+
+    it('Should update the customized configuration and have the correct index html tags', async function () {
+      await servers[0].config.updateCustomSubConfig({
+        newConfig: {
+          instance: {
+            name: 'PeerTube updated',
+            shortDescription: 'my short description',
+            description: 'my super description',
+            terms: 'my super terms',
+            defaultNSFWPolicy: 'blur',
+            defaultClientRoute: '/videos/recently-added',
+            customizations: {
+              javascript: 'alert("coucou")',
+              css: 'body { background-color: red; }'
+            }
+          }
+        }
+      })
+
+      const config = await servers[0].config.getConfig()
+      const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
+
+      checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config)
+    })
+
+    it('Should have valid index html updated tags (title, description...)', async function () {
+      const config = await servers[0].config.getConfig()
+      const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
+
+      checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config)
+    })
+  })
+
+  describe('Canonical tags', function () {
+
+    it('Should use the original video URL for the canonical tag', async function () {
+      for (const basePath of getWatchVideoBasePaths()) {
+        for (const id of videoIds) {
+          const res = await makeHTMLRequest(servers[0].url, basePath + id)
+          expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/w/${servers[0].store.video.shortUUID}" />`)
+        }
+      }
+    })
+
+    it('Should use the original playlist URL for the canonical tag', async function () {
+      for (const basePath of getWatchPlaylistBasePaths()) {
+        for (const id of playlistIds) {
+          const res = await makeHTMLRequest(servers[0].url, basePath + id)
+          expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/w/p/${playlist.shortUUID}" />`)
+        }
+      }
+    })
+
+    it('Should use the original account URL for the canonical tag', async function () {
+      const accountURLtest = res => {
+        expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/a/root" />`)
+      }
+
+      accountURLtest(await makeHTMLRequest(servers[0].url, '/accounts/root@' + servers[0].host))
+      accountURLtest(await makeHTMLRequest(servers[0].url, '/a/root@' + servers[0].host))
+      accountURLtest(await makeHTMLRequest(servers[0].url, '/@root@' + servers[0].host))
+    })
+
+    it('Should use the original channel URL for the canonical tag', async function () {
+      const channelURLtests = res => {
+        expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/c/root_channel" />`)
+      }
+
+      channelURLtests(await makeHTMLRequest(servers[0].url, '/video-channels/root_channel@' + servers[0].host))
+      channelURLtests(await makeHTMLRequest(servers[0].url, '/c/root_channel@' + servers[0].host))
+      channelURLtests(await makeHTMLRequest(servers[0].url, '/@root_channel@' + servers[0].host))
+    })
+  })
+
+  describe('Indexation tags', function () {
+
+    it('Should not index remote videos', async function () {
+      for (const basePath of getWatchVideoBasePaths()) {
+        for (const id of videoIds) {
+          {
+            const res = await makeHTMLRequest(servers[1].url, basePath + id)
+            expect(res.text).to.contain('<meta name="robots" content="noindex" />')
+          }
+
+          {
+            const res = await makeHTMLRequest(servers[0].url, basePath + id)
+            expect(res.text).to.not.contain('<meta name="robots" content="noindex" />')
+          }
+        }
+      }
+    })
+
+    it('Should not index remote playlists', async function () {
+      for (const basePath of getWatchPlaylistBasePaths()) {
+        for (const id of playlistIds) {
+          {
+            const res = await makeHTMLRequest(servers[1].url, basePath + id)
+            expect(res.text).to.contain('<meta name="robots" content="noindex" />')
+          }
+
+          {
+            const res = await makeHTMLRequest(servers[0].url, basePath + id)
+            expect(res.text).to.not.contain('<meta name="robots" content="noindex" />')
+          }
+        }
+      }
+    })
+
+    it('Should add noindex meta tag for remote accounts', async function () {
+      const handle = 'root@' + servers[0].host
+      const paths = [ '/accounts/', '/a/', '/@' ]
+
+      for (const path of paths) {
+        {
+          const { text } = await makeHTMLRequest(servers[1].url, path + handle)
+          expect(text).to.contain('<meta name="robots" content="noindex" />')
+        }
+
+        {
+          const { text } = await makeHTMLRequest(servers[0].url, path + handle)
+          expect(text).to.not.contain('<meta name="robots" content="noindex" />')
+        }
+      }
+    })
+
+    it('Should add noindex meta tag for remote channels', async function () {
+      const handle = 'root_channel@' + servers[0].host
+      const paths = [ '/video-channels/', '/c/', '/@' ]
+
+      for (const path of paths) {
+        {
+          const { text } = await makeHTMLRequest(servers[1].url, path + handle)
+          expect(text).to.contain('<meta name="robots" content="noindex" />')
+        }
+
+        {
+          const { text } = await makeHTMLRequest(servers[0].url, path + handle)
+          expect(text).to.not.contain('<meta name="robots" content="noindex" />')
+        }
+      }
+    })
+
+    it('Should add noindex meta tag for unlisted video', async function () {
+      for (const basePath of getWatchVideoBasePaths()) {
+        const res = await makeGetRequest({
+          url: servers[0].url,
+          path: basePath + unlistedVideoId,
+          accept: 'text/html',
+          expectedStatus: HttpStatusCode.OK_200
+        })
+
+        expect(res.text).to.contain('unlisted')
+        expect(res.text).to.contain('<meta name="robots" content="noindex" />')
+      }
+    })
+
+    it('Should add noindex meta tag for unlisted video playlist', async function () {
+      for (const basePath of getWatchPlaylistBasePaths()) {
+        const res = await makeGetRequest({
+          url: servers[0].url,
+          path: basePath + unlistedPlaylistId,
+          accept: 'text/html',
+          expectedStatus: HttpStatusCode.OK_200
+        })
+
+        expect(res.text).to.contain('unlisted')
+        expect(res.text).to.contain('<meta name="robots" content="noindex" />')
+      }
+    })
+  })
+
+  describe('Check no leaks for private objects', function () {
+
+    it('Should not display internal/private/password protected video', async function () {
+      for (const basePath of getWatchVideoBasePaths()) {
+        for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) {
+          const res = await makeGetRequest({
+            url: servers[0].url,
+            path: basePath + id,
+            accept: 'text/html',
+            expectedStatus: HttpStatusCode.NOT_FOUND_404
+          })
+
+          expect(res.text).to.not.contain('internal')
+          expect(res.text).to.not.contain('private')
+          expect(res.text).to.not.contain('password protected')
+        }
+      }
+    })
+
+    it('Should not display private video playlist', async function () {
+      for (const basePath of getWatchPlaylistBasePaths()) {
+        const res = await makeGetRequest({
+          url: servers[0].url,
+          path: basePath + privatePlaylistId,
+          accept: 'text/html',
+          expectedStatus: HttpStatusCode.NOT_FOUND_404
+        })
+
+        expect(res.text).to.not.contain('private')
+      }
+    })
+  })
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})

+ 4 - 0
packages/tests/src/client/index.ts

@@ -0,0 +1,4 @@
+export * from './embed-html.js'
+export * from './index-html.js'
+export * from './oembed.js'
+export * from './og-twitter-tags.js'

+ 64 - 0
packages/tests/src/client/oembed.ts

@@ -0,0 +1,64 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { HttpStatusCode, VideoPlaylistCreateResult } from '@peertube/peertube-models'
+import { PeerTubeServer, cleanupTests, makeGetRequest } from '@peertube/peertube-server-commands'
+import { getWatchPlaylistBasePaths, getWatchVideoBasePaths, prepareClientTests } from '@tests/shared/client.js'
+
+describe('Test oEmbed HTML tags', function () {
+  let servers: PeerTubeServer[]
+
+  let videoIds: (string | number)[] = []
+
+  let playlistName: string
+  let playlist: VideoPlaylistCreateResult
+  let playlistIds: (string | number)[] = []
+
+  before(async function () {
+    this.timeout(120000);
+
+    ({ servers, playlistIds, videoIds, playlist, playlistName } = await prepareClientTests())
+  })
+
+  it('Should have valid oEmbed discovery tags for videos', async function () {
+    for (const basePath of getWatchVideoBasePaths()) {
+      for (const id of videoIds) {
+        const res = await makeGetRequest({
+          url: servers[0].url,
+          path: basePath + id,
+          accept: 'text/html',
+          expectedStatus: HttpStatusCode.OK_200
+        })
+
+        const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` +
+        `url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2F${servers[0].store.video.shortUUID}" ` +
+        `title="${servers[0].store.video.name}" />`
+
+        expect(res.text).to.contain(expectedLink)
+      }
+    }
+  })
+
+  it('Should have valid oEmbed discovery tags for a playlist', async function () {
+    for (const basePath of getWatchPlaylistBasePaths()) {
+      for (const id of playlistIds) {
+        const res = await makeGetRequest({
+          url: servers[0].url,
+          path: basePath + id,
+          accept: 'text/html',
+          expectedStatus: HttpStatusCode.OK_200
+        })
+
+        const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` +
+          `url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2Fp%2F${playlist.shortUUID}" ` +
+          `title="${playlistName}" />`
+
+        expect(res.text).to.contain(expectedLink)
+      }
+    }
+  })
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})

+ 271 - 0
packages/tests/src/client/og-twitter-tags.ts

@@ -0,0 +1,271 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { Account, HttpStatusCode, VideoPlaylistCreateResult } from '@peertube/peertube-models'
+import { cleanupTests, makeGetRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
+import { getWatchPlaylistBasePaths, getWatchVideoBasePaths, prepareClientTests } from '@tests/shared/client.js'
+
+describe('Test Open Graph and Twitter cards HTML tags', function () {
+  let servers: PeerTubeServer[]
+  let account: Account
+
+  let videoIds: (string | number)[] = []
+
+  let videoName: string
+  let videoDescriptionPlainText: string
+
+  let playlistName: string
+  let playlistDescription: string
+  let playlist: VideoPlaylistCreateResult
+
+  let channelDescription: string
+
+  let playlistIds: (string | number)[] = []
+
+  before(async function () {
+    this.timeout(120000);
+
+    ({
+      servers,
+      account,
+      playlistIds,
+      videoIds,
+      videoName,
+      videoDescriptionPlainText,
+      playlistName,
+      playlist,
+      playlistDescription,
+      channelDescription
+    } = await prepareClientTests())
+  })
+
+  describe('Open Graph', function () {
+
+    async function accountPageTest (path: string) {
+      const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
+      const text = res.text
+
+      expect(text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
+      expect(text).to.contain(`<meta property="og:description" content="${account.description}" />`)
+      expect(text).to.contain('<meta property="og:type" content="website" />')
+      expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/a/${servers[0].store.user.username}" />`)
+    }
+
+    async function channelPageTest (path: string) {
+      const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
+      const text = res.text
+
+      expect(text).to.contain(`<meta property="og:title" content="${servers[0].store.channel.displayName}" />`)
+      expect(text).to.contain(`<meta property="og:description" content="${channelDescription}" />`)
+      expect(text).to.contain('<meta property="og:type" content="website" />')
+      expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/c/${servers[0].store.channel.name}" />`)
+    }
+
+    async function watchVideoPageTest (path: string) {
+      const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
+      const text = res.text
+
+      expect(text).to.contain(`<meta property="og:title" content="${videoName}" />`)
+      expect(text).to.contain(`<meta property="og:description" content="${videoDescriptionPlainText}" />`)
+      expect(text).to.contain('<meta property="og:type" content="video" />')
+      expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/${servers[0].store.video.shortUUID}" />`)
+    }
+
+    async function watchPlaylistPageTest (path: string) {
+      const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
+      const text = res.text
+
+      expect(text).to.contain(`<meta property="og:title" content="${playlistName}" />`)
+      expect(text).to.contain(`<meta property="og:description" content="${playlistDescription}" />`)
+      expect(text).to.contain('<meta property="og:type" content="video" />')
+      expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/p/${playlist.shortUUID}" />`)
+    }
+
+    it('Should have valid Open Graph tags on the account page', async function () {
+      await accountPageTest('/accounts/' + servers[0].store.user.username)
+      await accountPageTest('/a/' + servers[0].store.user.username)
+      await accountPageTest('/@' + servers[0].store.user.username)
+    })
+
+    it('Should have valid Open Graph tags on the channel page', async function () {
+      await channelPageTest('/video-channels/' + servers[0].store.channel.name)
+      await channelPageTest('/c/' + servers[0].store.channel.name)
+      await channelPageTest('/@' + servers[0].store.channel.name)
+    })
+
+    it('Should have valid Open Graph tags on the watch page', async function () {
+      for (const path of getWatchVideoBasePaths()) {
+        for (const id of videoIds) {
+          await watchVideoPageTest(path + id)
+        }
+      }
+    })
+
+    it('Should have valid Open Graph tags on the watch page with thread id Angular param', async function () {
+      for (const path of getWatchVideoBasePaths()) {
+        for (const id of videoIds) {
+          await watchVideoPageTest(path + id + ';threadId=1')
+        }
+      }
+    })
+
+    it('Should have valid Open Graph tags on the watch playlist page', async function () {
+      for (const path of getWatchPlaylistBasePaths()) {
+        for (const id of playlistIds) {
+          await watchPlaylistPageTest(path + id)
+        }
+      }
+    })
+  })
+
+  describe('Twitter card', async function () {
+
+    describe('Not whitelisted', function () {
+
+      async function accountPageTest (path: string) {
+        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
+        const text = res.text
+
+        expect(text).to.contain('<meta property="twitter:card" content="summary" />')
+        expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
+        expect(text).to.contain(`<meta property="twitter:title" content="${account.name}" />`)
+        expect(text).to.contain(`<meta property="twitter:description" content="${account.description}" />`)
+      }
+
+      async function channelPageTest (path: string) {
+        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
+        const text = res.text
+
+        expect(text).to.contain('<meta property="twitter:card" content="summary" />')
+        expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
+        expect(text).to.contain(`<meta property="twitter:title" content="${servers[0].store.channel.displayName}" />`)
+        expect(text).to.contain(`<meta property="twitter:description" content="${channelDescription}" />`)
+      }
+
+      async function watchVideoPageTest (path: string) {
+        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
+        const text = res.text
+
+        expect(text).to.contain('<meta property="twitter:card" content="summary_large_image" />')
+        expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
+        expect(text).to.contain(`<meta property="twitter:title" content="${videoName}" />`)
+        expect(text).to.contain(`<meta property="twitter:description" content="${videoDescriptionPlainText}" />`)
+      }
+
+      async function watchPlaylistPageTest (path: string) {
+        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
+        const text = res.text
+
+        expect(text).to.contain('<meta property="twitter:card" content="summary" />')
+        expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
+        expect(text).to.contain(`<meta property="twitter:title" content="${playlistName}" />`)
+        expect(text).to.contain(`<meta property="twitter:description" content="${playlistDescription}" />`)
+      }
+
+      it('Should have valid twitter card on the watch video page', async function () {
+        for (const path of getWatchVideoBasePaths()) {
+          for (const id of videoIds) {
+            await watchVideoPageTest(path + id)
+          }
+        }
+      })
+
+      it('Should have valid twitter card on the watch playlist page', async function () {
+        for (const path of getWatchPlaylistBasePaths()) {
+          for (const id of playlistIds) {
+            await watchPlaylistPageTest(path + id)
+          }
+        }
+      })
+
+      it('Should have valid twitter card on the account page', async function () {
+        await accountPageTest('/accounts/' + account.name)
+        await accountPageTest('/a/' + account.name)
+        await accountPageTest('/@' + account.name)
+      })
+
+      it('Should have valid twitter card on the channel page', async function () {
+        await channelPageTest('/video-channels/' + servers[0].store.channel.name)
+        await channelPageTest('/c/' + servers[0].store.channel.name)
+        await channelPageTest('/@' + servers[0].store.channel.name)
+      })
+    })
+
+    describe('Whitelisted', function () {
+
+      before(async function () {
+        const config = await servers[0].config.getCustomConfig()
+        config.services.twitter = {
+          username: '@Kuja',
+          whitelisted: true
+        }
+
+        await servers[0].config.updateCustomConfig({ newCustomConfig: config })
+      })
+
+      async function accountPageTest (path: string) {
+        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
+        const text = res.text
+
+        expect(text).to.contain('<meta property="twitter:card" content="summary" />')
+        expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
+      }
+
+      async function channelPageTest (path: string) {
+        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
+        const text = res.text
+
+        expect(text).to.contain('<meta property="twitter:card" content="summary" />')
+        expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
+      }
+
+      async function watchVideoPageTest (path: string) {
+        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
+        const text = res.text
+
+        expect(text).to.contain('<meta property="twitter:card" content="player" />')
+        expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
+      }
+
+      async function watchPlaylistPageTest (path: string) {
+        const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
+        const text = res.text
+
+        expect(text).to.contain('<meta property="twitter:card" content="player" />')
+        expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
+      }
+
+      it('Should have valid twitter card on the watch video page', async function () {
+        for (const path of getWatchVideoBasePaths()) {
+          for (const id of videoIds) {
+            await watchVideoPageTest(path + id)
+          }
+        }
+      })
+
+      it('Should have valid twitter card on the watch playlist page', async function () {
+        for (const path of getWatchPlaylistBasePaths()) {
+          for (const id of playlistIds) {
+            await watchPlaylistPageTest(path + id)
+          }
+        }
+      })
+
+      it('Should have valid twitter card on the account page', async function () {
+        await accountPageTest('/accounts/' + account.name)
+        await accountPageTest('/a/' + account.name)
+        await accountPageTest('/@' + account.name)
+      })
+
+      it('Should have valid twitter card on the channel page', async function () {
+        await channelPageTest('/video-channels/' + servers[0].store.channel.name)
+        await channelPageTest('/c/' + servers[0].store.channel.name)
+        await channelPageTest('/@' + servers[0].store.channel.name)
+      })
+    })
+  })
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})

+ 5 - 5
packages/tests/src/peertube-runner/vod-transcoding.ts

@@ -38,7 +38,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
       : undefined
 
     it('Should upload a classic video mp4 and transcode it', async function () {
-      this.timeout(120000)
+      this.timeout(240000)
 
       const { uuid } = await servers[0].videos.quickUpload({ name: 'mp4', fixture: 'video_short.mp4' })
 
@@ -76,7 +76,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
     })
 
     it('Should upload a webm video and transcode it', async function () {
-      this.timeout(120000)
+      this.timeout(240000)
 
       const { uuid } = await servers[0].videos.quickUpload({ name: 'mp4', fixture: 'video_short.webm' })
 
@@ -114,7 +114,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
     })
 
     it('Should upload an audio only video and transcode it', async function () {
-      this.timeout(120000)
+      this.timeout(240000)
 
       const attributes = { name: 'audio_without_preview', fixture: 'sample.ogg' }
       const { uuid } = await servers[0].videos.upload({ attributes, mode: 'resumable' })
@@ -152,7 +152,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
     })
 
     it('Should upload a private video and transcode it', async function () {
-      this.timeout(120000)
+      this.timeout(240000)
 
       const { uuid } = await servers[0].videos.quickUpload({ name: 'mp4', fixture: 'video_short.mp4', privacy: VideoPrivacy.PRIVATE })
 
@@ -188,7 +188,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
     })
 
     it('Should transcode videos on manual run', async function () {
-      this.timeout(120000)
+      this.timeout(240000)
 
       await servers[0].config.disableTranscoding()
 

+ 181 - 0
packages/tests/src/shared/client.ts

@@ -0,0 +1,181 @@
+import { omit } from '@peertube/peertube-core-utils'
+import {
+  VideoPrivacy,
+  VideoPlaylistPrivacy,
+  VideoPlaylistCreateResult,
+  Account,
+  HTMLServerConfig,
+  ServerConfig
+} from '@peertube/peertube-models'
+import {
+  createMultipleServers,
+  setAccessTokensToServers,
+  doubleFollow,
+  setDefaultVideoChannel,
+  waitJobs
+} from '@peertube/peertube-server-commands'
+import { expect } from 'chai'
+
+export function getWatchVideoBasePaths () {
+  return [ '/videos/watch/', '/w/' ]
+}
+
+export function getWatchPlaylistBasePaths () {
+  return [ '/videos/watch/playlist/', '/w/p/' ]
+}
+
+export function checkIndexTags (html: string, title: string, description: string, css: string, config: ServerConfig) {
+  expect(html).to.contain('<title>' + title + '</title>')
+  expect(html).to.contain('<meta name="description" content="' + description + '" />')
+
+  if (css) {
+    expect(html).to.contain('<style class="custom-css-style">' + css + '</style>')
+  }
+
+  const htmlConfig: HTMLServerConfig = omit(config, [ 'signup' ])
+  const configObjectString = JSON.stringify(htmlConfig)
+  const configEscapedString = JSON.stringify(configObjectString)
+
+  expect(html).to.contain(`<script type="application/javascript">window.PeerTubeServerConfig = ${configEscapedString}</script>`)
+}
+
+export async function prepareClientTests () {
+  const servers = await createMultipleServers(2)
+
+  await setAccessTokensToServers(servers)
+
+  await doubleFollow(servers[0], servers[1])
+
+  await setDefaultVideoChannel(servers)
+
+  let account: Account
+
+  let videoIds: (string | number)[] = []
+  let privateVideoId: string
+  let internalVideoId: string
+  let unlistedVideoId: string
+  let passwordProtectedVideoId: string
+
+  let playlistIds: (string | number)[] = []
+  let privatePlaylistId: string
+  let unlistedPlaylistId: string
+
+  const instanceDescription = 'PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.'
+
+  const videoName = 'my super name for server 1'
+  const videoDescription = 'my<br> super __description__ for *server* 1<p></p>'
+  const videoDescriptionPlainText = 'my super description for server 1'
+
+  const playlistName = 'super playlist name'
+  const playlistDescription = 'super playlist description'
+  let playlist: VideoPlaylistCreateResult
+
+  const channelDescription = 'my super channel description'
+
+  await servers[0].channels.update({
+    channelName: servers[0].store.channel.name,
+    attributes: { description: channelDescription }
+  })
+
+  // Public video
+
+  {
+    const attributes = { name: videoName, description: videoDescription }
+    await servers[0].videos.upload({ attributes })
+
+    const { data } = await servers[0].videos.list()
+    expect(data.length).to.equal(1)
+
+    const video = data[0]
+    servers[0].store.video = video
+    videoIds = [ video.id, video.uuid, video.shortUUID ]
+  }
+
+  {
+    ({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }));
+    ({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }));
+    ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }));
+    ({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({
+      name: 'password protected',
+      privacy: VideoPrivacy.PASSWORD_PROTECTED,
+      videoPasswords: [ 'password' ]
+    }))
+  }
+
+  // Playlists
+  {
+    // Public playlist
+    {
+      const attributes = {
+        displayName: playlistName,
+        description: playlistDescription,
+        privacy: VideoPlaylistPrivacy.PUBLIC,
+        videoChannelId: servers[0].store.channel.id
+      }
+
+      playlist = await servers[0].playlists.create({ attributes })
+      playlistIds = [ playlist.id, playlist.shortUUID, playlist.uuid ]
+
+      await servers[0].playlists.addElement({ playlistId: playlist.shortUUID, attributes: { videoId: servers[0].store.video.id } })
+    }
+
+    // Unlisted playlist
+    {
+      const attributes = {
+        displayName: 'unlisted',
+        privacy: VideoPlaylistPrivacy.UNLISTED,
+        videoChannelId: servers[0].store.channel.id
+      }
+
+      const { uuid } = await servers[0].playlists.create({ attributes })
+      unlistedPlaylistId = uuid
+    }
+
+    {
+      const attributes = {
+        displayName: 'private',
+        privacy: VideoPlaylistPrivacy.PRIVATE
+      }
+
+      const { uuid } = await servers[0].playlists.create({ attributes })
+      privatePlaylistId = uuid
+    }
+  }
+
+  // Account
+  {
+    await servers[0].users.updateMe({ description: 'my account description' })
+
+    account = await servers[0].accounts.get({ accountName: `${servers[0].store.user.username}@${servers[0].host}` })
+  }
+
+  await waitJobs(servers)
+
+  return {
+    servers,
+
+    instanceDescription,
+
+    account,
+
+    channelDescription,
+
+    playlist,
+    playlistName,
+    playlistIds,
+    playlistDescription,
+
+    privatePlaylistId,
+    unlistedPlaylistId,
+
+    privateVideoId,
+    unlistedVideoId,
+    internalVideoId,
+    passwordProtectedVideoId,
+
+    videoName,
+    videoDescription,
+    videoDescriptionPlainText,
+    videoIds
+  }
+}

+ 2 - 1
scripts/ci.sh

@@ -58,11 +58,12 @@ elif [ "$1" = "client" ]; then
     npm run build:tests
 
     feedsFiles=$(findTestFiles ./packages/tests/dist/feeds)
+    clientFiles=$(findTestFiles ./packages/tests/dist/client)
     miscFiles="./packages/tests/dist/client.js ./packages/tests/dist/misc-endpoints.js"
     # Not in their own task, they need an index.html
     pluginFiles="./packages/tests/dist/plugins/html-injection.js ./packages/tests/dist/api/server/plugins.js"
 
-    MOCHA_PARALLEL=true runJSTest "$1" $((2*$speedFactor)) $feedsFiles $miscFiles $pluginFiles
+    MOCHA_PARALLEL=true runJSTest "$1" $((2*$speedFactor)) $feedsFiles $miscFiles $pluginFiles $clientFiles
 
     # Use TS tests directly because we import server files
     helperFiles=$(findTestFiles ./packages/tests/src/server-helpers)

+ 3 - 3
server/core/controllers/api/config.ts

@@ -7,7 +7,7 @@ import { About, CustomConfig, UserRight } from '@peertube/peertube-models'
 import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger.js'
 import { objectConverter } from '../../helpers/core-utils.js'
 import { CONFIG, reloadConfig } from '../../initializers/config.js'
-import { ClientHtml } from '../../lib/client-html.js'
+import { ClientHtml } from '../../lib/html/client-html.js'
 import { apiRateLimiter, asyncMiddleware, authenticate, ensureUserHasRight, openapiOperationDoc } from '../../middlewares/index.js'
 import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config.js'
 
@@ -94,7 +94,7 @@ async function deleteCustomConfig (req: express.Request, res: express.Response)
   auditLogger.delete(getAuditIdFromRes(res), new CustomConfigAuditView(customConfig()))
 
   await reloadConfig()
-  ClientHtml.invalidCache()
+  ClientHtml.invalidateCache()
 
   const data = customConfig()
 
@@ -110,7 +110,7 @@ async function updateCustomConfig (req: express.Request, res: express.Response)
   await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 })
 
   await reloadConfig()
-  ClientHtml.invalidCache()
+  ClientHtml.invalidateCache()
 
   const data = customConfig()
 

+ 32 - 12
server/core/controllers/client.ts

@@ -9,7 +9,7 @@ import { CONFIG } from '@server/initializers/config.js'
 import { Hooks } from '@server/lib/plugins/hooks.js'
 import { currentDir, root } from '@peertube/peertube-node-utils'
 import { STATIC_MAX_AGE } from '../initializers/constants.js'
-import { ClientHtml, sendHTML, serveIndexHTML } from '../lib/client-html.js'
+import { ClientHtml, sendHTML, serveIndexHTML } from '../lib/html/client-html.js'
 import { asyncMiddleware, buildRateLimiter, embedCSP } from '../middlewares/index.js'
 
 const clientsRouter = express.Router()
@@ -49,6 +49,8 @@ clientsRouter.use('/@:nameWithHost',
   asyncMiddleware(generateActorHtmlPage)
 )
 
+// ---------------------------------------------------------------------------
+
 const embedMiddlewares = [
   clientsRateLimiter,
 
@@ -64,19 +66,21 @@ const embedMiddlewares = [
     res.setHeader('Cache-Control', 'public, max-age=0')
 
     next()
-  },
-
-  asyncMiddleware(generateEmbedHtmlPage)
+  }
 ]
 
-clientsRouter.use('/videos/embed', ...embedMiddlewares)
-clientsRouter.use('/video-playlists/embed', ...embedMiddlewares)
+clientsRouter.use('/videos/embed/:id', ...embedMiddlewares, asyncMiddleware(generateVideoEmbedHtmlPage))
+clientsRouter.use('/video-playlists/embed/:id', ...embedMiddlewares, asyncMiddleware(generateVideoPlaylistEmbedHtmlPage))
+
+// ---------------------------------------------------------------------------
 
 const testEmbedController = (req: express.Request, res: express.Response) => res.sendFile(testEmbedPath)
 
 clientsRouter.use('/videos/test-embed', clientsRateLimiter, testEmbedController)
 clientsRouter.use('/video-playlists/test-embed', clientsRateLimiter, testEmbedController)
 
+// ---------------------------------------------------------------------------
+
 // Dynamic PWA manifest
 clientsRouter.get('/manifest.webmanifest', clientsRateLimiter, asyncMiddleware(generateManifest))
 
@@ -142,17 +146,33 @@ function serveServerTranslations (req: express.Request, res: express.Response) {
   return res.status(HttpStatusCode.NOT_FOUND_404).end()
 }
 
-async function generateEmbedHtmlPage (req: express.Request, res: express.Response) {
-  const hookName = req.originalUrl.startsWith('/video-playlists/')
-    ? 'filter:html.embed.video-playlist.allowed.result'
-    : 'filter:html.embed.video.allowed.result'
+async function generateVideoEmbedHtmlPage (req: express.Request, res: express.Response) {
+  const allowParameters = { req }
+
+  const allowedResult = await Hooks.wrapFun(
+    isEmbedAllowed,
+    allowParameters,
+    'filter:html.embed.video.allowed.result'
+  )
+
+  if (!allowedResult || allowedResult.allowed !== true) {
+    logger.info('Embed is not allowed.', { allowedResult })
+
+    return sendHTML(allowedResult?.html || '', res)
+  }
+
+  const html = await ClientHtml.getVideoEmbedHTML(req.params.id)
+
+  return sendHTML(html, res)
+}
 
+async function generateVideoPlaylistEmbedHtmlPage (req: express.Request, res: express.Response) {
   const allowParameters = { req }
 
   const allowedResult = await Hooks.wrapFun(
     isEmbedAllowed,
     allowParameters,
-    hookName
+    'filter:html.embed.video-playlist.allowed.result'
   )
 
   if (!allowedResult || allowedResult.allowed !== true) {
@@ -161,7 +181,7 @@ async function generateEmbedHtmlPage (req: express.Request, res: express.Respons
     return sendHTML(allowedResult?.html || '', res)
   }
 
-  const html = await ClientHtml.getEmbedHTML()
+  const html = await ClientHtml.getVideoPlaylistEmbedHTML(req.params.id)
 
   return sendHTML(html, res)
 }

+ 1 - 1
server/core/controllers/misc.ts

@@ -2,7 +2,7 @@ import cors from 'cors'
 import express from 'express'
 import { HttpNodeinfoDiasporaSoftwareNsSchema20, HttpStatusCode } from '@peertube/peertube-models'
 import { CONFIG, isEmailEnabled } from '@server/initializers/config.js'
-import { serveIndexHTML } from '@server/lib/client-html.js'
+import { serveIndexHTML } from '@server/lib/html/client-html.js'
 import { ServerConfigManager } from '@server/lib/server-config-manager.js'
 import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION, ROUTE_CACHE_LIFETIME } from '../initializers/constants.js'
 import { getThemeOrDefault } from '../lib/plugins/theme-utils.js'

+ 3 - 1
server/core/initializers/constants.ts

@@ -955,7 +955,8 @@ const MEMOIZE_TTL = {
   VIDEO_DURATION: 1000 * 10, // 10 seconds
   LIVE_ABLE_TO_UPLOAD: 1000 * 60, // 1 minute
   LIVE_CHECK_SOCKET_HEALTH: 1000 * 60, // 1 minute
-  GET_STATS_FOR_OPEN_TELEMETRY_METRICS: 1000 * 60 // 1 minute
+  GET_STATS_FOR_OPEN_TELEMETRY_METRICS: 1000 * 60, // 1 minute
+  EMBED_HTML: 1000 * 10 // 10 seconds
 }
 
 const MEMOIZE_LENGTH = {
@@ -1082,6 +1083,7 @@ if (process.env.PRODUCTION_CONSTANTS !== 'true') {
     FILES_CACHE.VIDEO_CAPTIONS.MAX_AGE = 3000
     MEMOIZE_TTL.OVERVIEWS_SAMPLE = 3000
     MEMOIZE_TTL.LIVE_ABLE_TO_UPLOAD = 3000
+    MEMOIZE_TTL.EMBED_HTML = 1
     OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD = 2
 
     PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 5000

+ 0 - 630
server/core/lib/client-html.ts

@@ -1,630 +0,0 @@
-import { buildFileLocale, escapeHTML, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '@peertube/peertube-core-utils'
-import { HTMLServerConfig, HttpStatusCode, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models'
-import { isTestOrDevInstance, root, sha256 } from '@peertube/peertube-node-utils'
-import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js'
-import { mdToOneLinePlainText } from '@server/helpers/markdown.js'
-import { ActorImageModel } from '@server/models/actor/actor-image.js'
-import express from 'express'
-import { pathExists } from 'fs-extra/esm'
-import { readFile } from 'fs/promises'
-import truncate from 'lodash-es/truncate.js'
-import { join } from 'path'
-import validator from 'validator'
-import { logger } from '../helpers/logger.js'
-import { CONFIG } from '../initializers/config.js'
-import {
-  ACCEPT_HEADERS,
-  CUSTOM_HTML_TAG_COMMENTS,
-  EMBED_SIZE,
-  FILES_CONTENT_HASH,
-  PLUGIN_GLOBAL_CSS_PATH,
-  WEBSERVER
-} from '../initializers/constants.js'
-import { AccountModel } from '../models/account/account.js'
-import { VideoChannelModel } from '../models/video/video-channel.js'
-import { VideoPlaylistModel } from '../models/video/video-playlist.js'
-import { VideoModel } from '../models/video/video.js'
-import { MAccountHost, MChannelHost, MVideo, MVideoPlaylist } from '../types/models/index.js'
-import { getActivityStreamDuration } from './activitypub/activity.js'
-import { getBiggestActorImage } from './actor-image.js'
-import { Hooks } from './plugins/hooks.js'
-import { ServerConfigManager } from './server-config-manager.js'
-import { isVideoInPrivateDirectory } from './video-privacy.js'
-
-type Tags = {
-  ogType: string
-  twitterCard: 'player' | 'summary' | 'summary_large_image'
-  schemaType: string
-
-  list?: {
-    numberOfItems: number
-  }
-
-  escapedSiteName: string
-  escapedTitle: string
-  escapedTruncatedDescription: string
-
-  url: string
-  originUrl: string
-
-  indexationPolicy: 'always' | 'never'
-
-  embed?: {
-    url: string
-    createdAt: string
-    duration?: string
-    views?: number
-  }
-
-  image: {
-    url: string
-    width?: number
-    height?: number
-  }
-}
-
-type HookContext = {
-  video?: MVideo
-  playlist?: MVideoPlaylist
-}
-
-class ClientHtml {
-
-  private static htmlCache: { [path: string]: string } = {}
-
-  static invalidCache () {
-    logger.info('Cleaning HTML cache.')
-
-    ClientHtml.htmlCache = {}
-  }
-
-  static async getDefaultHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
-    const html = paramLang
-      ? await ClientHtml.getIndexHTML(req, res, paramLang)
-      : await ClientHtml.getIndexHTML(req, res)
-
-    let customHtml = ClientHtml.addTitleTag(html)
-    customHtml = ClientHtml.addDescriptionTag(customHtml)
-
-    return customHtml
-  }
-
-  static async getWatchHTMLPage (videoIdArg: string, req: express.Request, res: express.Response) {
-    const videoId = toCompleteUUID(videoIdArg)
-
-    // Let Angular application handle errors
-    if (!validator.default.isInt(videoId) && !validator.default.isUUID(videoId, 4)) {
-      res.status(HttpStatusCode.NOT_FOUND_404)
-      return ClientHtml.getIndexHTML(req, res)
-    }
-
-    const [ html, video ] = await Promise.all([
-      ClientHtml.getIndexHTML(req, res),
-      VideoModel.loadWithBlacklist(videoId)
-    ])
-
-    // Let Angular application handle errors
-    if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
-      res.status(HttpStatusCode.NOT_FOUND_404)
-      return html
-    }
-    const escapedTruncatedDescription = buildEscapedTruncatedDescription(video.description)
-
-    let customHtml = ClientHtml.addTitleTag(html, video.name)
-    customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
-
-    const url = WEBSERVER.URL + video.getWatchStaticPath()
-    const originUrl = video.url
-    const title = video.name
-    const siteName = CONFIG.INSTANCE.NAME
-
-    const image = {
-      url: WEBSERVER.URL + video.getPreviewStaticPath()
-    }
-
-    const embed = {
-      url: WEBSERVER.URL + video.getEmbedStaticPath(),
-      createdAt: video.createdAt.toISOString(),
-      duration: getActivityStreamDuration(video.duration),
-      views: video.views
-    }
-
-    const ogType = 'video'
-    const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary_large_image'
-    const schemaType = 'VideoObject'
-
-    customHtml = await ClientHtml.addTags(customHtml, {
-      url,
-      originUrl,
-      escapedSiteName: escapeHTML(siteName),
-      escapedTitle: escapeHTML(title),
-      escapedTruncatedDescription,
-
-      indexationPolicy: video.privacy !== VideoPrivacy.PUBLIC
-        ? 'never'
-        : 'always',
-
-      image,
-      embed,
-      ogType,
-      twitterCard,
-      schemaType
-    }, { video })
-
-    return customHtml
-  }
-
-  static async getWatchPlaylistHTMLPage (videoPlaylistIdArg: string, req: express.Request, res: express.Response) {
-    const videoPlaylistId = toCompleteUUID(videoPlaylistIdArg)
-
-    // Let Angular application handle errors
-    if (!validator.default.isInt(videoPlaylistId) && !validator.default.isUUID(videoPlaylistId, 4)) {
-      res.status(HttpStatusCode.NOT_FOUND_404)
-      return ClientHtml.getIndexHTML(req, res)
-    }
-
-    const [ html, videoPlaylist ] = await Promise.all([
-      ClientHtml.getIndexHTML(req, res),
-      VideoPlaylistModel.loadWithAccountAndChannel(videoPlaylistId, null)
-    ])
-
-    // Let Angular application handle errors
-    if (!videoPlaylist || videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
-      res.status(HttpStatusCode.NOT_FOUND_404)
-      return html
-    }
-
-    const escapedTruncatedDescription = buildEscapedTruncatedDescription(videoPlaylist.description)
-
-    let customHtml = ClientHtml.addTitleTag(html, videoPlaylist.name)
-    customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
-
-    const url = WEBSERVER.URL + videoPlaylist.getWatchStaticPath()
-    const originUrl = videoPlaylist.url
-    const title = videoPlaylist.name
-    const siteName = CONFIG.INSTANCE.NAME
-
-    const image = {
-      url: videoPlaylist.getThumbnailUrl()
-    }
-
-    const embed = {
-      url: WEBSERVER.URL + videoPlaylist.getEmbedStaticPath(),
-      createdAt: videoPlaylist.createdAt.toISOString()
-    }
-
-    const list = {
-      numberOfItems: videoPlaylist.get('videosLength') as number
-    }
-
-    const ogType = 'video'
-    const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary'
-    const schemaType = 'ItemList'
-
-    customHtml = await ClientHtml.addTags(customHtml, {
-      url,
-      originUrl,
-      escapedSiteName: escapeHTML(siteName),
-      escapedTitle: escapeHTML(title),
-      escapedTruncatedDescription,
-
-      indexationPolicy: videoPlaylist.privacy !== VideoPlaylistPrivacy.PUBLIC
-        ? 'never'
-        : 'always',
-
-      embed,
-      image,
-      list,
-      ogType,
-      twitterCard,
-      schemaType
-    }, { playlist: videoPlaylist })
-
-    return customHtml
-  }
-
-  static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
-    const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost)
-    return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res)
-  }
-
-  static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
-    const videoChannelModelPromise = VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
-    return this.getAccountOrChannelHTMLPage(() => videoChannelModelPromise, req, res)
-  }
-
-  static async getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
-    const [ account, channel ] = await Promise.all([
-      AccountModel.loadByNameWithHost(nameWithHost),
-      VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
-    ])
-
-    return this.getAccountOrChannelHTMLPage(() => Promise.resolve(account || channel), req, res)
-  }
-
-  static async getEmbedHTML () {
-    const path = ClientHtml.getEmbedPath()
-
-    // Disable HTML cache in dev mode because webpack can regenerate JS files
-    if (!isTestOrDevInstance() && ClientHtml.htmlCache[path]) {
-      return ClientHtml.htmlCache[path]
-    }
-
-    const buffer = await readFile(path)
-    const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
-
-    let html = buffer.toString()
-    html = await ClientHtml.addAsyncPluginCSS(html)
-    html = ClientHtml.addCustomCSS(html)
-    html = ClientHtml.addTitleTag(html)
-    html = ClientHtml.addDescriptionTag(html)
-    html = ClientHtml.addServerConfig(html, serverConfig)
-
-    ClientHtml.htmlCache[path] = html
-
-    return html
-  }
-
-  private static async getAccountOrChannelHTMLPage (
-    loader: () => Promise<MAccountHost | MChannelHost>,
-    req: express.Request,
-    res: express.Response
-  ) {
-    const [ html, entity ] = await Promise.all([
-      ClientHtml.getIndexHTML(req, res),
-      loader()
-    ])
-
-    // Let Angular application handle errors
-    if (!entity) {
-      res.status(HttpStatusCode.NOT_FOUND_404)
-      return ClientHtml.getIndexHTML(req, res)
-    }
-
-    const escapedTruncatedDescription = buildEscapedTruncatedDescription(entity.description)
-
-    let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName())
-    customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
-
-    const url = entity.getClientUrl()
-    const originUrl = entity.Actor.url
-    const siteName = CONFIG.INSTANCE.NAME
-    const title = entity.getDisplayName()
-
-    const avatar = getBiggestActorImage(entity.Actor.Avatars)
-    const image = {
-      url: ActorImageModel.getImageUrl(avatar),
-      width: avatar?.width,
-      height: avatar?.height
-    }
-
-    const ogType = 'website'
-    const twitterCard = 'summary'
-    const schemaType = 'ProfilePage'
-
-    customHtml = await ClientHtml.addTags(customHtml, {
-      url,
-      originUrl,
-      escapedTitle: escapeHTML(title),
-      escapedSiteName: escapeHTML(siteName),
-      escapedTruncatedDescription,
-      image,
-      ogType,
-      twitterCard,
-      schemaType,
-
-      indexationPolicy: entity.Actor.isOwned()
-        ? 'always'
-        : 'never'
-    }, {})
-
-    return customHtml
-  }
-
-  private static async getIndexHTML (req: express.Request, res: express.Response, paramLang?: string) {
-    const path = ClientHtml.getIndexPath(req, res, paramLang)
-    if (ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
-
-    const buffer = await readFile(path)
-    const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
-
-    let html = buffer.toString()
-
-    html = ClientHtml.addManifestContentHash(html)
-    html = ClientHtml.addFaviconContentHash(html)
-    html = ClientHtml.addLogoContentHash(html)
-    html = ClientHtml.addCustomCSS(html)
-    html = ClientHtml.addServerConfig(html, serverConfig)
-    html = await ClientHtml.addAsyncPluginCSS(html)
-
-    ClientHtml.htmlCache[path] = html
-
-    return html
-  }
-
-  private static getIndexPath (req: express.Request, res: express.Response, paramLang: string) {
-    let lang: string
-
-    // Check param lang validity
-    if (paramLang && is18nLocale(paramLang)) {
-      lang = paramLang
-
-      // Save locale in cookies
-      res.cookie('clientLanguage', lang, {
-        secure: WEBSERVER.SCHEME === 'https',
-        sameSite: 'none',
-        maxAge: 1000 * 3600 * 24 * 90 // 3 months
-      })
-
-    } else if (req.cookies.clientLanguage && is18nLocale(req.cookies.clientLanguage)) {
-      lang = req.cookies.clientLanguage
-    } else {
-      lang = req.acceptsLanguages(POSSIBLE_LOCALES) || getDefaultLocale()
-    }
-
-    logger.debug(
-      'Serving %s HTML language', buildFileLocale(lang),
-      { cookie: req.cookies?.clientLanguage, paramLang, acceptLanguage: req.headers['accept-language'] }
-    )
-
-    return join(root(), 'client', 'dist', buildFileLocale(lang), 'index.html')
-  }
-
-  private static getEmbedPath () {
-    return join(root(), 'client', 'dist', 'standalone', 'videos', 'embed.html')
-  }
-
-  private static addManifestContentHash (htmlStringPage: string) {
-    return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST)
-  }
-
-  private static addFaviconContentHash (htmlStringPage: string) {
-    return htmlStringPage.replace('[faviconContentHash]', FILES_CONTENT_HASH.FAVICON)
-  }
-
-  private static addLogoContentHash (htmlStringPage: string) {
-    return htmlStringPage.replace('[logoContentHash]', FILES_CONTENT_HASH.LOGO)
-  }
-
-  private static addTitleTag (htmlStringPage: string, title?: string) {
-    let text = title || CONFIG.INSTANCE.NAME
-    if (title) text += ` - ${CONFIG.INSTANCE.NAME}`
-
-    const titleTag = `<title>${escapeHTML(text)}</title>`
-
-    return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.TITLE, titleTag)
-  }
-
-  private static addDescriptionTag (htmlStringPage: string, escapedTruncatedDescription?: string) {
-    const content = escapedTruncatedDescription || escapeHTML(CONFIG.INSTANCE.SHORT_DESCRIPTION)
-    const descriptionTag = `<meta name="description" content="${content}" />`
-
-    return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag)
-  }
-
-  private static addCustomCSS (htmlStringPage: string) {
-    const styleTag = `<style class="custom-css-style">${CONFIG.INSTANCE.CUSTOMIZATIONS.CSS}</style>`
-
-    return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag)
-  }
-
-  private static addServerConfig (htmlStringPage: string, serverConfig: HTMLServerConfig) {
-    // Stringify the JSON object, and then stringify the string object so we can inject it into the HTML
-    const serverConfigString = JSON.stringify(JSON.stringify(serverConfig))
-    const configScriptTag = `<script type="application/javascript">window.PeerTubeServerConfig = ${serverConfigString}</script>`
-
-    return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.SERVER_CONFIG, configScriptTag)
-  }
-
-  private static async addAsyncPluginCSS (htmlStringPage: string) {
-    if (!await pathExists(PLUGIN_GLOBAL_CSS_PATH)) {
-      logger.info('Plugin Global CSS file is not available (generation may still be in progress), ignoring it.')
-      return htmlStringPage
-    }
-
-    let globalCSSContent: Buffer
-
-    try {
-      globalCSSContent = await readFile(PLUGIN_GLOBAL_CSS_PATH)
-    } catch (err) {
-      logger.error('Error retrieving the Plugin Global CSS file, ignoring it.', { err })
-      return htmlStringPage
-    }
-
-    if (globalCSSContent.byteLength === 0) return htmlStringPage
-
-    const fileHash = sha256(globalCSSContent)
-    const linkTag = `<link rel="stylesheet" href="/plugins/global.css?hash=${fileHash}" />`
-
-    return htmlStringPage.replace('</head>', linkTag + '</head>')
-  }
-
-  private static generateOpenGraphMetaTags (tags: Tags) {
-    const metaTags = {
-      'og:type': tags.ogType,
-      'og:site_name': tags.escapedSiteName,
-      'og:title': tags.escapedTitle,
-      'og:image': tags.image.url
-    }
-
-    if (tags.image.width && tags.image.height) {
-      metaTags['og:image:width'] = tags.image.width
-      metaTags['og:image:height'] = tags.image.height
-    }
-
-    metaTags['og:url'] = tags.url
-    metaTags['og:description'] = tags.escapedTruncatedDescription
-
-    if (tags.embed) {
-      metaTags['og:video:url'] = tags.embed.url
-      metaTags['og:video:secure_url'] = tags.embed.url
-      metaTags['og:video:type'] = 'text/html'
-      metaTags['og:video:width'] = EMBED_SIZE.width
-      metaTags['og:video:height'] = EMBED_SIZE.height
-    }
-
-    return metaTags
-  }
-
-  private static generateStandardMetaTags (tags: Tags) {
-    return {
-      name: tags.escapedTitle,
-      description: tags.escapedTruncatedDescription,
-      image: tags.image.url
-    }
-  }
-
-  private static generateTwitterCardMetaTags (tags: Tags) {
-    const metaTags = {
-      'twitter:card': tags.twitterCard,
-      'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME,
-      'twitter:title': tags.escapedTitle,
-      'twitter:description': tags.escapedTruncatedDescription,
-      'twitter:image': tags.image.url
-    }
-
-    if (tags.image.width && tags.image.height) {
-      metaTags['twitter:image:width'] = tags.image.width
-      metaTags['twitter:image:height'] = tags.image.height
-    }
-
-    if (tags.twitterCard === 'player') {
-      metaTags['twitter:player'] = tags.embed.url
-      metaTags['twitter:player:width'] = EMBED_SIZE.width
-      metaTags['twitter:player:height'] = EMBED_SIZE.height
-    }
-
-    return metaTags
-  }
-
-  private static async generateSchemaTags (tags: Tags, context: HookContext) {
-    const schema = {
-      '@context': 'http://schema.org',
-      '@type': tags.schemaType,
-      'name': tags.escapedTitle,
-      'description': tags.escapedTruncatedDescription,
-      'image': tags.image.url,
-      'url': tags.url
-    }
-
-    if (tags.list) {
-      schema['numberOfItems'] = tags.list.numberOfItems
-      schema['thumbnailUrl'] = tags.image.url
-    }
-
-    if (tags.embed) {
-      schema['embedUrl'] = tags.embed.url
-      schema['uploadDate'] = tags.embed.createdAt
-
-      if (tags.embed.duration) schema['duration'] = tags.embed.duration
-
-      schema['thumbnailUrl'] = tags.image.url
-      schema['contentUrl'] = tags.url
-    }
-
-    return Hooks.wrapObject(schema, 'filter:html.client.json-ld.result', context)
-  }
-
-  private static async addTags (htmlStringPage: string, tagsValues: Tags, context: HookContext) {
-    const openGraphMetaTags = this.generateOpenGraphMetaTags(tagsValues)
-    const standardMetaTags = this.generateStandardMetaTags(tagsValues)
-    const twitterCardMetaTags = this.generateTwitterCardMetaTags(tagsValues)
-    const schemaTags = await this.generateSchemaTags(tagsValues, context)
-
-    const { url, escapedTitle, embed, originUrl, indexationPolicy } = tagsValues
-
-    const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = []
-
-    if (embed) {
-      oembedLinkTags.push({
-        type: 'application/json+oembed',
-        href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(url),
-        escapedTitle
-      })
-    }
-
-    let tagsStr = ''
-
-    // Opengraph
-    Object.keys(openGraphMetaTags).forEach(tagName => {
-      const tagValue = openGraphMetaTags[tagName]
-
-      tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
-    })
-
-    // Standard
-    Object.keys(standardMetaTags).forEach(tagName => {
-      const tagValue = standardMetaTags[tagName]
-
-      tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
-    })
-
-    // Twitter card
-    Object.keys(twitterCardMetaTags).forEach(tagName => {
-      const tagValue = twitterCardMetaTags[tagName]
-
-      tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
-    })
-
-    // OEmbed
-    for (const oembedLinkTag of oembedLinkTags) {
-      tagsStr += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.escapedTitle}" />`
-    }
-
-    // Schema.org
-    if (schemaTags) {
-      tagsStr += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
-    }
-
-    // SEO, use origin URL
-    tagsStr += `<link rel="canonical" href="${originUrl}" />`
-
-    if (indexationPolicy === 'never') {
-      tagsStr += `<meta name="robots" content="noindex" />`
-    }
-
-    return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsStr)
-  }
-}
-
-function sendHTML (html: string, res: express.Response, localizedHTML: boolean = false) {
-  res.set('Content-Type', 'text/html; charset=UTF-8')
-
-  if (localizedHTML) {
-    res.set('Vary', 'Accept-Language')
-  }
-
-  return res.send(html)
-}
-
-async function serveIndexHTML (req: express.Request, res: express.Response) {
-  if (req.accepts(ACCEPT_HEADERS) === 'html' || !req.headers.accept) {
-    try {
-      await generateHTMLPage(req, res, req.params.language)
-      return
-    } catch (err) {
-      logger.error('Cannot generate HTML page.', { err })
-      return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end()
-    }
-  }
-
-  return res.status(HttpStatusCode.NOT_ACCEPTABLE_406).end()
-}
-
-// ---------------------------------------------------------------------------
-
-export {
-  ClientHtml,
-  sendHTML,
-  serveIndexHTML
-}
-
-async function generateHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
-  const html = await ClientHtml.getDefaultHTMLPage(req, res, paramLang)
-
-  return sendHTML(html, res, true)
-}
-
-function buildEscapedTruncatedDescription (description: string) {
-  return truncate(mdToOneLinePlainText(description), { length: 200 })
-}

+ 95 - 0
server/core/lib/html/client-html.ts

@@ -0,0 +1,95 @@
+import { HttpStatusCode } from '@peertube/peertube-models'
+import express from 'express'
+import { logger } from '../../helpers/logger.js'
+import { ACCEPT_HEADERS } from '../../initializers/constants.js'
+import { VideoHtml } from './shared/video-html.js'
+import { PlaylistHtml } from './shared/playlist-html.js'
+import { ActorHtml } from './shared/actor-html.js'
+import { PageHtml } from './shared/page-html.js'
+
+class ClientHtml {
+
+  static invalidateCache () {
+    PageHtml.invalidateCache()
+  }
+
+  static getDefaultHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
+    return PageHtml.getDefaultHTML(req, res, paramLang)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  static getWatchHTMLPage (videoIdArg: string, req: express.Request, res: express.Response) {
+    return VideoHtml.getWatchVideoHTML(videoIdArg, req, res)
+  }
+
+  static getVideoEmbedHTML (videoIdArg: string) {
+    return VideoHtml.getEmbedVideoHTML(videoIdArg)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  static getWatchPlaylistHTMLPage (videoPlaylistIdArg: string, req: express.Request, res: express.Response) {
+    return PlaylistHtml.getWatchPlaylistHTML(videoPlaylistIdArg, req, res)
+  }
+
+  static getVideoPlaylistEmbedHTML (playlistIdArg: string) {
+    return PlaylistHtml.getEmbedPlaylistHTML(playlistIdArg)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  static getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
+    return ActorHtml.getAccountHTMLPage(nameWithHost, req, res)
+  }
+
+  static getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
+    return ActorHtml.getVideoChannelHTMLPage(nameWithHost, req, res)
+  }
+
+  static getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
+    return ActorHtml.getActorHTMLPage(nameWithHost, req, res)
+  }
+}
+
+function sendHTML (html: string, res: express.Response, localizedHTML: boolean = false) {
+  res.set('Content-Type', 'text/html; charset=UTF-8')
+
+  if (localizedHTML) {
+    res.set('Vary', 'Accept-Language')
+  }
+
+  return res.send(html)
+}
+
+async function serveIndexHTML (req: express.Request, res: express.Response) {
+  if (req.accepts(ACCEPT_HEADERS) === 'html' || !req.headers.accept) {
+    try {
+      await generateHTMLPage(req, res, req.params.language)
+      return
+    } catch (err) {
+      logger.error('Cannot generate HTML page.', { err })
+      return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end()
+    }
+  }
+
+  return res.status(HttpStatusCode.NOT_ACCEPTABLE_406).end()
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  ClientHtml,
+  sendHTML,
+  serveIndexHTML
+}
+
+// ---------------------------------------------------------------------------
+// Private
+// ---------------------------------------------------------------------------
+
+async function generateHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
+  const html = await ClientHtml.getDefaultHTMLPage(req, res, paramLang)
+
+  return sendHTML(html, res, true)
+}

+ 91 - 0
server/core/lib/html/shared/actor-html.ts

@@ -0,0 +1,91 @@
+import { escapeHTML } from '@peertube/peertube-core-utils'
+import { HttpStatusCode } from '@peertube/peertube-models'
+import express from 'express'
+import { CONFIG } from '../../../initializers/config.js'
+import { AccountModel } from '@server/models/account/account.js'
+import { VideoChannelModel } from '@server/models/video/video-channel.js'
+import { MAccountHost, MChannelHost } from '@server/types/models/index.js'
+import { getBiggestActorImage } from '@server/lib/actor-image.js'
+import { ActorImageModel } from '@server/models/actor/actor-image.js'
+import { TagsHtml } from './tags-html.js'
+import { PageHtml } from './page-html.js'
+
+export class ActorHtml {
+
+  static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
+    const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost)
+
+    return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res)
+  }
+
+  static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
+    const videoChannelModelPromise = VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
+
+    return this.getAccountOrChannelHTMLPage(() => videoChannelModelPromise, req, res)
+  }
+
+  static async getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
+    const [ account, channel ] = await Promise.all([
+      AccountModel.loadByNameWithHost(nameWithHost),
+      VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
+    ])
+
+    return this.getAccountOrChannelHTMLPage(() => Promise.resolve(account || channel), req, res)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private static async getAccountOrChannelHTMLPage (
+    loader: () => Promise<MAccountHost | MChannelHost>,
+    req: express.Request,
+    res: express.Response
+  ) {
+    const [ html, entity ] = await Promise.all([
+      PageHtml.getIndexHTML(req, res),
+      loader()
+    ])
+
+    // Let Angular application handle errors
+    if (!entity) {
+      res.status(HttpStatusCode.NOT_FOUND_404)
+      return PageHtml.getIndexHTML(req, res)
+    }
+
+    const escapedTruncatedDescription = TagsHtml.buildEscapedTruncatedDescription(entity.description)
+
+    let customHTML = TagsHtml.addTitleTag(html, entity.getDisplayName())
+    customHTML = TagsHtml.addDescriptionTag(customHTML, escapedTruncatedDescription)
+
+    const url = entity.getClientUrl()
+    const siteName = CONFIG.INSTANCE.NAME
+    const title = entity.getDisplayName()
+
+    const avatar = getBiggestActorImage(entity.Actor.Avatars)
+    const image = {
+      url: ActorImageModel.getImageUrl(avatar),
+      width: avatar?.width,
+      height: avatar?.height
+    }
+
+    const ogType = 'website'
+    const twitterCard = 'summary'
+    const schemaType = 'ProfilePage'
+
+    customHTML = await TagsHtml.addTags(customHTML, {
+      url,
+      escapedTitle: escapeHTML(title),
+      escapedSiteName: escapeHTML(siteName),
+      escapedTruncatedDescription,
+      image,
+      ogType,
+      twitterCard,
+      schemaType,
+
+      indexationPolicy: entity.Actor.isOwned()
+        ? 'always'
+        : 'never'
+    }, {})
+
+    return customHTML
+  }
+}

+ 18 - 0
server/core/lib/html/shared/common-embed-html.ts

@@ -0,0 +1,18 @@
+import { MVideo, MVideoPlaylist } from '../../../types/models/index.js'
+import { TagsHtml } from './tags-html.js'
+
+export class CommonEmbedHtml {
+
+  static buildEmptyEmbedHTML (options: {
+    html: string
+    playlist?: MVideoPlaylist
+    video?: MVideo
+  }) {
+    const { html, playlist, video } = options
+
+    let htmlResult = TagsHtml.addTitleTag(html)
+    htmlResult = TagsHtml.addDescriptionTag(htmlResult)
+
+    return TagsHtml.addTags(htmlResult, { indexationPolicy: 'never' }, { playlist, video })
+  }
+}

+ 5 - 0
server/core/lib/html/shared/index.ts

@@ -0,0 +1,5 @@
+export * from './actor-html.js'
+export * from './tags-html.js'
+export * from './page-html.js'
+export * from './playlist-html.js'
+export * from './video-html.js'

+ 166 - 0
server/core/lib/html/shared/page-html.ts

@@ -0,0 +1,166 @@
+import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '@peertube/peertube-core-utils'
+import { isTestOrDevInstance, root, sha256 } from '@peertube/peertube-node-utils'
+import express from 'express'
+import { readFile } from 'fs/promises'
+import { join } from 'path'
+import { logger } from '../../../helpers/logger.js'
+import { CUSTOM_HTML_TAG_COMMENTS, FILES_CONTENT_HASH, PLUGIN_GLOBAL_CSS_PATH, WEBSERVER } from '../../../initializers/constants.js'
+import { ServerConfigManager } from '../../server-config-manager.js'
+import { TagsHtml } from './tags-html.js'
+import { pathExists } from 'fs-extra/esm'
+import { HTMLServerConfig } from '@peertube/peertube-models'
+import { CONFIG } from '@server/initializers/config.js'
+
+export class PageHtml {
+
+  private static htmlCache: { [path: string]: string } = {}
+
+  static invalidateCache () {
+    logger.info('Cleaning HTML cache.')
+
+    this.htmlCache = {}
+  }
+
+  static async getDefaultHTML (req: express.Request, res: express.Response, paramLang?: string) {
+    const html = paramLang
+      ? await this.getIndexHTML(req, res, paramLang)
+      : await this.getIndexHTML(req, res)
+
+    let customHTML = TagsHtml.addTitleTag(html)
+    customHTML = TagsHtml.addDescriptionTag(customHTML)
+
+    return customHTML
+  }
+
+  static async getEmbedHTML () {
+    const path = this.getEmbedHTMLPath()
+
+    // Disable HTML cache in dev mode because webpack can regenerate JS files
+    if (!isTestOrDevInstance() && this.htmlCache[path]) {
+      return this.htmlCache[path]
+    }
+
+    const buffer = await readFile(path)
+    const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
+
+    let html = buffer.toString()
+    html = await this.addAsyncPluginCSS(html)
+    html = this.addCustomCSS(html)
+    html = this.addServerConfig(html, serverConfig)
+
+    this.htmlCache[path] = html
+
+    return html
+  }
+
+  // ---------------------------------------------------------------------------
+
+  static async getIndexHTML (req: express.Request, res: express.Response, paramLang?: string) {
+    const path = this.getIndexHTMLPath(req, res, paramLang)
+    if (this.htmlCache[path]) return this.htmlCache[path]
+
+    const buffer = await readFile(path)
+    const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
+
+    let html = buffer.toString()
+
+    html = this.addManifestContentHash(html)
+    html = this.addFaviconContentHash(html)
+    html = this.addLogoContentHash(html)
+
+    html = this.addCustomCSS(html)
+    html = this.addServerConfig(html, serverConfig)
+    html = await this.addAsyncPluginCSS(html)
+
+    this.htmlCache[path] = html
+
+    return html
+  }
+
+  // ---------------------------------------------------------------------------
+  // Private
+  // ---------------------------------------------------------------------------
+
+  private static getEmbedHTMLPath () {
+    return join(root(), 'client', 'dist', 'standalone', 'videos', 'embed.html')
+  }
+
+  private static getIndexHTMLPath (req: express.Request, res: express.Response, paramLang: string) {
+    let lang: string
+
+    // Check param lang validity
+    if (paramLang && is18nLocale(paramLang)) {
+      lang = paramLang
+
+      // Save locale in cookies
+      res.cookie('clientLanguage', lang, {
+        secure: WEBSERVER.SCHEME === 'https',
+        sameSite: 'none',
+        maxAge: 1000 * 3600 * 24 * 90 // 3 months
+      })
+
+    } else if (req.cookies.clientLanguage && is18nLocale(req.cookies.clientLanguage)) {
+      lang = req.cookies.clientLanguage
+    } else {
+      lang = req.acceptsLanguages(POSSIBLE_LOCALES) || getDefaultLocale()
+    }
+
+    logger.debug(
+      'Serving %s HTML language', buildFileLocale(lang),
+      { cookie: req.cookies?.clientLanguage, paramLang, acceptLanguage: req.headers['accept-language'] }
+    )
+
+    return join(root(), 'client', 'dist', buildFileLocale(lang), 'index.html')
+  }
+
+  // ---------------------------------------------------------------------------
+
+  static addCustomCSS (htmlStringPage: string) {
+    const styleTag = `<style class="custom-css-style">${CONFIG.INSTANCE.CUSTOMIZATIONS.CSS}</style>`
+
+    return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag)
+  }
+
+  static addServerConfig (htmlStringPage: string, serverConfig: HTMLServerConfig) {
+    // Stringify the JSON object, and then stringify the string object so we can inject it into the HTML
+    const serverConfigString = JSON.stringify(JSON.stringify(serverConfig))
+    const configScriptTag = `<script type="application/javascript">window.PeerTubeServerConfig = ${serverConfigString}</script>`
+
+    return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.SERVER_CONFIG, configScriptTag)
+  }
+
+  static async addAsyncPluginCSS (htmlStringPage: string) {
+    if (!await pathExists(PLUGIN_GLOBAL_CSS_PATH)) {
+      logger.info('Plugin Global CSS file is not available (generation may still be in progress), ignoring it.')
+      return htmlStringPage
+    }
+
+    let globalCSSContent: Buffer
+
+    try {
+      globalCSSContent = await readFile(PLUGIN_GLOBAL_CSS_PATH)
+    } catch (err) {
+      logger.error('Error retrieving the Plugin Global CSS file, ignoring it.', { err })
+      return htmlStringPage
+    }
+
+    if (globalCSSContent.byteLength === 0) return htmlStringPage
+
+    const fileHash = sha256(globalCSSContent)
+    const linkTag = `<link rel="stylesheet" href="/plugins/global.css?hash=${fileHash}" />`
+
+    return htmlStringPage.replace('</head>', linkTag + '</head>')
+  }
+
+  private static addManifestContentHash (htmlStringPage: string) {
+    return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST)
+  }
+
+  private static addFaviconContentHash (htmlStringPage: string) {
+    return htmlStringPage.replace('[faviconContentHash]', FILES_CONTENT_HASH.FAVICON)
+  }
+
+  private static addLogoContentHash (htmlStringPage: string) {
+    return htmlStringPage.replace('[logoContentHash]', FILES_CONTENT_HASH.LOGO)
+  }
+}

+ 126 - 0
server/core/lib/html/shared/playlist-html.ts

@@ -0,0 +1,126 @@
+import { escapeHTML } from '@peertube/peertube-core-utils'
+import { HttpStatusCode, VideoPlaylistPrivacy } from '@peertube/peertube-models'
+import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js'
+import express from 'express'
+import validator from 'validator'
+import { CONFIG } from '../../../initializers/config.js'
+import { MEMOIZE_TTL, WEBSERVER } from '../../../initializers/constants.js'
+import { Memoize } from '@server/helpers/memoize.js'
+import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
+import { MVideoPlaylistFull } from '@server/types/models/index.js'
+import { TagsHtml } from './tags-html.js'
+import { PageHtml } from './page-html.js'
+import { CommonEmbedHtml } from './common-embed-html.js'
+
+export class PlaylistHtml {
+
+  static async getWatchPlaylistHTML (videoPlaylistIdArg: string, req: express.Request, res: express.Response) {
+    const videoPlaylistId = toCompleteUUID(videoPlaylistIdArg)
+
+    // Let Angular application handle errors
+    if (!validator.default.isInt(videoPlaylistId) && !validator.default.isUUID(videoPlaylistId, 4)) {
+      res.status(HttpStatusCode.NOT_FOUND_404)
+      return PageHtml.getIndexHTML(req, res)
+    }
+
+    const [ html, videoPlaylist ] = await Promise.all([
+      PageHtml.getIndexHTML(req, res),
+      VideoPlaylistModel.loadWithAccountAndChannel(videoPlaylistId, null)
+    ])
+
+    // Let Angular application handle errors
+    if (!videoPlaylist || videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
+      res.status(HttpStatusCode.NOT_FOUND_404)
+      return html
+    }
+
+    return this.buildPlaylistHTML({
+      html,
+      playlist: videoPlaylist,
+      addEmbedInfo: true,
+      addOG: true,
+      addTwitterCard: true
+    })
+  }
+
+  @Memoize({ maxAge: MEMOIZE_TTL.EMBED_HTML })
+  static async getEmbedPlaylistHTML (playlistIdArg: string) {
+    const playlistId = toCompleteUUID(playlistIdArg)
+
+    const playlistPromise: Promise<MVideoPlaylistFull> = validator.default.isInt(playlistId) || validator.default.isUUID(playlistId, 4)
+      ? VideoPlaylistModel.loadWithAccountAndChannel(playlistId, null)
+      : Promise.resolve(undefined)
+
+    const [ html, playlist ] = await Promise.all([ PageHtml.getEmbedHTML(), playlistPromise ])
+
+    if (!playlist || playlist.privacy === VideoPlaylistPrivacy.PRIVATE) {
+      return CommonEmbedHtml.buildEmptyEmbedHTML({ html, playlist })
+    }
+
+    return this.buildPlaylistHTML({
+      html,
+      playlist,
+      addEmbedInfo: false,
+      addOG: false,
+      addTwitterCard: false
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+  // Private
+  // ---------------------------------------------------------------------------
+
+  private static buildPlaylistHTML (options: {
+    html: string
+    playlist: MVideoPlaylistFull
+
+    addOG: boolean
+    addTwitterCard: boolean
+    addEmbedInfo: boolean
+  }) {
+    const { html, playlist, addEmbedInfo, addOG, addTwitterCard } = options
+    const escapedTruncatedDescription = TagsHtml.buildEscapedTruncatedDescription(playlist.description)
+
+    let htmlResult = TagsHtml.addTitleTag(html, playlist.name)
+    htmlResult = TagsHtml.addDescriptionTag(htmlResult, escapedTruncatedDescription)
+
+    const list = { numberOfItems: playlist.get('videosLength') as number }
+    const schemaType = 'ItemList'
+
+    let twitterCard: 'player' | 'summary'
+    if (addTwitterCard) {
+      twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED
+        ? 'player'
+        : 'summary'
+    }
+
+    const ogType = addOG
+      ? 'video' as 'video'
+      : undefined
+
+    const embed = addEmbedInfo
+      ? { url: WEBSERVER.URL + playlist.getEmbedStaticPath(), createdAt: playlist.createdAt.toISOString() }
+      : undefined
+
+    return TagsHtml.addTags(htmlResult, {
+      url: WEBSERVER.URL + playlist.getWatchStaticPath(),
+
+      escapedSiteName: escapeHTML(CONFIG.INSTANCE.NAME),
+      escapedTitle: escapeHTML(playlist.name),
+      escapedTruncatedDescription,
+
+      indexationPolicy: !playlist.isOwned() || playlist.privacy !== VideoPlaylistPrivacy.PUBLIC
+        ? 'never'
+        : 'always',
+
+      image: { url: playlist.getThumbnailUrl() },
+
+      list,
+
+      schemaType,
+      ogType,
+      twitterCard,
+      embed
+    }, { playlist })
+  }
+}

+ 230 - 0
server/core/lib/html/shared/tags-html.ts

@@ -0,0 +1,230 @@
+import { escapeHTML } from '@peertube/peertube-core-utils'
+import { CONFIG } from '../../../initializers/config.js'
+import { CUSTOM_HTML_TAG_COMMENTS, EMBED_SIZE, WEBSERVER } from '../../../initializers/constants.js'
+import { MVideo, MVideoPlaylist } from '../../../types/models/index.js'
+import { Hooks } from '../../plugins/hooks.js'
+import truncate from 'lodash-es/truncate.js'
+import { mdToOneLinePlainText } from '@server/helpers/markdown.js'
+
+type Tags = {
+  indexationPolicy: 'always' | 'never'
+
+  url?: string
+
+  schemaType?: string
+  ogType?: string
+  twitterCard?: 'player' | 'summary' | 'summary_large_image'
+
+  list?: {
+    numberOfItems: number
+  }
+
+  escapedSiteName?: string
+  escapedTitle?: string
+  escapedTruncatedDescription?: string
+
+  image?: {
+    url: string
+    width?: number
+    height?: number
+  }
+
+  embed?: {
+    url: string
+    createdAt: string
+    duration?: string
+    views?: number
+  }
+}
+
+type HookContext = {
+  video?: MVideo
+  playlist?: MVideoPlaylist
+}
+
+export class TagsHtml {
+
+  static addTitleTag (htmlStringPage: string, title?: string) {
+    let text = title || CONFIG.INSTANCE.NAME
+    if (title) text += ` - ${CONFIG.INSTANCE.NAME}`
+
+    const titleTag = `<title>${escapeHTML(text)}</title>`
+
+    return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.TITLE, titleTag)
+  }
+
+  static addDescriptionTag (htmlStringPage: string, escapedTruncatedDescription?: string) {
+    const content = escapedTruncatedDescription || escapeHTML(CONFIG.INSTANCE.SHORT_DESCRIPTION)
+    const descriptionTag = `<meta name="description" content="${content}" />`
+
+    return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  static async addTags (htmlStringPage: string, tagsValues: Tags, context: HookContext) {
+    const openGraphMetaTags = this.generateOpenGraphMetaTagsOptions(tagsValues)
+    const standardMetaTags = this.generateStandardMetaTagsOptions(tagsValues)
+    const twitterCardMetaTags = this.generateTwitterCardMetaTagsOptions(tagsValues)
+    const schemaTags = await this.generateSchemaTagsOptions(tagsValues, context)
+
+    const { url, escapedTitle, embed, indexationPolicy } = tagsValues
+
+    const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = []
+
+    if (embed) {
+      oembedLinkTags.push({
+        type: 'application/json+oembed',
+        href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(url),
+        escapedTitle
+      })
+    }
+
+    let tagsStr = ''
+
+    // Opengraph
+    Object.keys(openGraphMetaTags).forEach(tagName => {
+      const tagValue = openGraphMetaTags[tagName]
+      if (!tagValue) return
+
+      tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
+    })
+
+    // Standard
+    Object.keys(standardMetaTags).forEach(tagName => {
+      const tagValue = standardMetaTags[tagName]
+      if (!tagValue) return
+
+      tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
+    })
+
+    // Twitter card
+    Object.keys(twitterCardMetaTags).forEach(tagName => {
+      const tagValue = twitterCardMetaTags[tagName]
+      if (!tagValue) return
+
+      tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
+    })
+
+    // OEmbed
+    for (const oembedLinkTag of oembedLinkTags) {
+      tagsStr += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.escapedTitle}" />`
+    }
+
+    // Schema.org
+    if (schemaTags) {
+      tagsStr += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
+    }
+
+    // SEO, use origin URL
+    if (indexationPolicy !== 'never' && url) {
+      tagsStr += `<link rel="canonical" href="${url}" />`
+    }
+
+    if (indexationPolicy === 'never') {
+      tagsStr += `<meta name="robots" content="noindex" />`
+    }
+
+    return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsStr)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  static generateOpenGraphMetaTagsOptions (tags: Tags) {
+    if (!tags.ogType) return {}
+
+    const metaTags = {
+      'og:type': tags.ogType,
+      'og:site_name': tags.escapedSiteName,
+      'og:title': tags.escapedTitle,
+      'og:image': tags.image.url
+    }
+
+    if (tags.image.width && tags.image.height) {
+      metaTags['og:image:width'] = tags.image.width
+      metaTags['og:image:height'] = tags.image.height
+    }
+
+    metaTags['og:url'] = tags.url
+    metaTags['og:description'] = tags.escapedTruncatedDescription
+
+    if (tags.embed) {
+      metaTags['og:video:url'] = tags.embed.url
+      metaTags['og:video:secure_url'] = tags.embed.url
+      metaTags['og:video:type'] = 'text/html'
+      metaTags['og:video:width'] = EMBED_SIZE.width
+      metaTags['og:video:height'] = EMBED_SIZE.height
+    }
+
+    return metaTags
+  }
+
+  static generateStandardMetaTagsOptions (tags: Tags) {
+    return {
+      name: tags.escapedTitle,
+      description: tags.escapedTruncatedDescription,
+      image: tags.image?.url
+    }
+  }
+
+  static generateTwitterCardMetaTagsOptions (tags: Tags) {
+    if (!tags.twitterCard) return {}
+
+    const metaTags = {
+      'twitter:card': tags.twitterCard,
+      'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME,
+      'twitter:title': tags.escapedTitle,
+      'twitter:description': tags.escapedTruncatedDescription,
+      'twitter:image': tags.image.url
+    }
+
+    if (tags.image.width && tags.image.height) {
+      metaTags['twitter:image:width'] = tags.image.width
+      metaTags['twitter:image:height'] = tags.image.height
+    }
+
+    if (tags.twitterCard === 'player') {
+      metaTags['twitter:player'] = tags.embed.url
+      metaTags['twitter:player:width'] = EMBED_SIZE.width
+      metaTags['twitter:player:height'] = EMBED_SIZE.height
+    }
+
+    return metaTags
+  }
+
+  static generateSchemaTagsOptions (tags: Tags, context: HookContext) {
+    if (!tags.schemaType) return
+
+    const schema = {
+      '@context': 'http://schema.org',
+      '@type': tags.schemaType,
+      'name': tags.escapedTitle,
+      'description': tags.escapedTruncatedDescription,
+      'image': tags.image.url,
+      'url': tags.url
+    }
+
+    if (tags.list) {
+      schema['numberOfItems'] = tags.list.numberOfItems
+      schema['thumbnailUrl'] = tags.image.url
+    }
+
+    if (tags.embed) {
+      schema['embedUrl'] = tags.embed.url
+      schema['uploadDate'] = tags.embed.createdAt
+
+      if (tags.embed.duration) schema['duration'] = tags.embed.duration
+
+      schema['thumbnailUrl'] = tags.image.url
+      schema['contentUrl'] = tags.url
+    }
+
+    return Hooks.wrapObject(schema, 'filter:html.client.json-ld.result', context)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  static buildEscapedTruncatedDescription (description: string) {
+    return truncate(mdToOneLinePlainText(description), { length: 200 })
+  }
+}

+ 130 - 0
server/core/lib/html/shared/video-html.ts

@@ -0,0 +1,130 @@
+import { escapeHTML } from '@peertube/peertube-core-utils'
+import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models'
+import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js'
+import express from 'express'
+import validator from 'validator'
+import { CONFIG } from '../../../initializers/config.js'
+import { MEMOIZE_TTL, WEBSERVER } from '../../../initializers/constants.js'
+import { VideoModel } from '../../../models/video/video.js'
+import { MVideo } from '../../../types/models/index.js'
+import { getActivityStreamDuration } from '../../activitypub/activity.js'
+import { isVideoInPrivateDirectory } from '../../video-privacy.js'
+import { Memoize } from '@server/helpers/memoize.js'
+import { MVideoThumbnailBlacklist } from 'server/dist/core/types/models/index.js'
+import { TagsHtml } from './tags-html.js'
+import { PageHtml } from './page-html.js'
+import { CommonEmbedHtml } from './common-embed-html.js'
+
+export class VideoHtml {
+
+  static async getWatchVideoHTML (videoIdArg: string, req: express.Request, res: express.Response) {
+    const videoId = toCompleteUUID(videoIdArg)
+
+    // Let Angular application handle errors
+    if (!validator.default.isInt(videoId) && !validator.default.isUUID(videoId, 4)) {
+      res.status(HttpStatusCode.NOT_FOUND_404)
+      return PageHtml.getIndexHTML(req, res)
+    }
+
+    const [ html, video ] = await Promise.all([
+      PageHtml.getIndexHTML(req, res),
+      VideoModel.loadWithBlacklist(videoId)
+    ])
+
+    // Let Angular application handle errors
+    if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
+      res.status(HttpStatusCode.NOT_FOUND_404)
+      return html
+    }
+
+    return this.buildVideoHTML({
+      html,
+      video,
+      addEmbedInfo: true,
+      addOG: true,
+      addTwitterCard: true
+    })
+  }
+
+  @Memoize({ maxAge: MEMOIZE_TTL.EMBED_HTML })
+  static async getEmbedVideoHTML (videoIdArg: string) {
+    const videoId = toCompleteUUID(videoIdArg)
+
+    const videoPromise: Promise<MVideoThumbnailBlacklist> = validator.default.isInt(videoId) || validator.default.isUUID(videoId, 4)
+      ? VideoModel.loadWithBlacklist(videoId)
+      : Promise.resolve(undefined)
+
+    const [ html, video ] = await Promise.all([ PageHtml.getEmbedHTML(), videoPromise ])
+
+    if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
+      return CommonEmbedHtml.buildEmptyEmbedHTML({ html, video })
+    }
+
+    return this.buildVideoHTML({
+      html,
+      video,
+      addEmbedInfo: false,
+      addOG: false,
+      addTwitterCard: false
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+  // Private
+  // ---------------------------------------------------------------------------
+
+  private static buildVideoHTML (options: {
+    html: string
+    video: MVideo
+
+    addOG: boolean
+    addTwitterCard: boolean
+    addEmbedInfo: boolean
+  }) {
+    const { html, video, addEmbedInfo, addOG, addTwitterCard } = options
+    const escapedTruncatedDescription = TagsHtml.buildEscapedTruncatedDescription(video.description)
+
+    let customHTML = TagsHtml.addTitleTag(html, video.name)
+    customHTML = TagsHtml.addDescriptionTag(customHTML, escapedTruncatedDescription)
+
+    const embed = addEmbedInfo
+      ? {
+        url: WEBSERVER.URL + video.getEmbedStaticPath(),
+        createdAt: video.createdAt.toISOString(),
+        duration: getActivityStreamDuration(video.duration),
+        views: video.views
+      }
+      : undefined
+
+    const ogType = addOG
+      ? 'video' as 'video'
+      : undefined
+
+    let twitterCard: 'player' | 'summary_large_image'
+    if (addTwitterCard) {
+      twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED
+        ? 'player'
+        : 'summary_large_image'
+    }
+
+    const schemaType = 'VideoObject'
+
+    return TagsHtml.addTags(customHTML, {
+      url: WEBSERVER.URL + video.getWatchStaticPath(),
+      escapedSiteName: escapeHTML(CONFIG.INSTANCE.NAME),
+      escapedTitle: escapeHTML(video.name),
+      escapedTruncatedDescription,
+
+      indexationPolicy: video.remote || video.privacy !== VideoPrivacy.PUBLIC
+        ? 'never'
+        : 'always',
+
+      image: { url: WEBSERVER.URL + video.getPreviewStaticPath() },
+
+      embed,
+      ogType,
+      twitterCard,
+      schemaType
+    }, { video })
+  }
+}

+ 3 - 3
server/core/lib/plugins/plugin-manager.ts

@@ -30,7 +30,7 @@ import {
   RegisterServerAuthPassOptions,
   RegisterServerOptions
 } from '../../types/plugins/index.js'
-import { ClientHtml } from '../client-html.js'
+import { ClientHtml } from '../html/client-html.js'
 import { RegisterHelpers } from './register-helpers.js'
 import { installNpmPlugin, installNpmPluginFromDisk, rebuildNativePlugins, removeNpmPlugin } from './yarn.js'
 
@@ -329,7 +329,7 @@ export class PluginManager implements ServerHook {
       await this.regeneratePluginGlobalCSS()
     }
 
-    ClientHtml.invalidCache()
+    ClientHtml.invalidateCache()
   }
 
   // ###################### Installation ######################
@@ -497,7 +497,7 @@ export class PluginManager implements ServerHook {
 
     await this.addTranslations(plugin, npmName, packageJSON.translations)
 
-    ClientHtml.invalidCache()
+    ClientHtml.invalidateCache()
   }
 
   private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJSON) {