diff --git a/packages/tests/src/client.ts b/packages/tests/src/client.ts
deleted file mode 100644
index a16205494..000000000
--- a/packages/tests/src/client.ts
+++ /dev/null
@@ -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 + '')
- expect(html).to.contain('')
- expect(html).to.contain('')
-
- const htmlConfig: HTMLServerConfig = omit(config, [ 'signup' ])
- const configObjectString = JSON.stringify(htmlConfig)
- const configEscapedString = JSON.stringify(configObjectString)
-
- expect(html).to.contain(``)
-}
-
-describe('Test a client controllers', function () {
- let servers: PeerTubeServer[] = []
- let account: Account
-
- const videoName = 'my super name for server 1'
- const videoDescription = 'my
super __description__ for *server* 1'
- 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 = ``
-
- 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 = ``
-
- 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(``)
- expect(text).to.contain(``)
- expect(text).to.contain('')
- expect(text).to.contain(``)
- }
-
- 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(``)
- expect(text).to.contain(``)
- expect(text).to.contain('')
- expect(text).to.contain(``)
- }
-
- 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(``)
- expect(text).to.contain(``)
- expect(text).to.contain('')
- expect(text).to.contain(``)
- }
-
- 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(``)
- expect(text).to.contain(``)
- expect(text).to.contain('')
- expect(text).to.contain(``)
- }
-
- 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('')
- expect(text).to.contain('')
- expect(text).to.contain(``)
- expect(text).to.contain(``)
- }
-
- 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('')
- expect(text).to.contain('')
- expect(text).to.contain(``)
- expect(text).to.contain(``)
- }
-
- 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('')
- expect(text).to.contain('')
- expect(text).to.contain(``)
- expect(text).to.contain(``)
- }
-
- 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('')
- expect(text).to.contain('')
- expect(text).to.contain(``)
- expect(text).to.contain(``)
- }
-
- 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('')
- expect(text).to.contain('')
- }
-
- 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('')
- expect(text).to.contain('')
- }
-
- 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('')
- expect(text).to.contain('')
- }
-
- 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('')
- expect(text).to.contain('')
- }
-
- 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(``)
- }
- }
- })
-
- it('Should use the original account URL for the canonical tag', async function () {
- const accountURLtest = res => {
- expect(res.text).to.contain(``)
- }
-
- 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(``)
- }
-
- 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(``)
- }
- }
- })
-
- 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('')
- }
-
- {
- const { text } = await makeHTMLRequest(servers[0].url, path + handle)
- expect(text).to.not.contain('')
- }
- }
- })
-
- 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('')
- }
-
- {
- const { text } = await makeHTMLRequest(servers[0].url, path + handle)
- expect(text).to.not.contain('')
- }
- }
- })
-
- 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('')
- }
- })
- })
-
- 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)
- })
-})
diff --git a/packages/tests/src/client/embed-html.ts b/packages/tests/src/client/embed-html.ts
new file mode 100644
index 000000000..99121b8f2
--- /dev/null
+++ b/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(``)
+ }
+ })
+
+ 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(``)
+ }
+ })
+
+ })
+
+ 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('')
+ }
+
+ {
+ const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + id)
+ expect(res.text).to.not.contain('')
+ }
+ }
+ })
+
+ 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('')
+ }
+
+ {
+ const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + id)
+ expect(res.text).to.not.contain('')
+ }
+ }
+ })
+
+ 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('')
+ }
+
+ {
+ const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + unlistedVideoId)
+
+ expect(res.text).to.contain('unlisted')
+ expect(res.text).to.contain('')
+ }
+ })
+
+ 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('')
+ }
+
+ {
+ const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + unlistedPlaylistId)
+
+ expect(res.text).to.contain('unlisted')
+ expect(res.text).to.contain('')
+ }
+ })
+ })
+
+ 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('')
+ }
+ })
+
+ 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('')
+ })
+ })
+
+ after(async function () {
+ await cleanupTests(servers)
+ })
+})
diff --git a/packages/tests/src/client/index-html.ts b/packages/tests/src/client/index-html.ts
new file mode 100644
index 000000000..9ff8b8957
--- /dev/null
+++ b/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(``)
+ }
+ }
+ })
+
+ 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(``)
+ }
+ }
+ })
+
+ it('Should use the original account URL for the canonical tag', async function () {
+ const accountURLtest = res => {
+ expect(res.text).to.contain(``)
+ }
+
+ 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(``)
+ }
+
+ 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('')
+ }
+
+ {
+ const res = await makeHTMLRequest(servers[0].url, basePath + id)
+ expect(res.text).to.not.contain('')
+ }
+ }
+ }
+ })
+
+ 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('')
+ }
+
+ {
+ const res = await makeHTMLRequest(servers[0].url, basePath + id)
+ expect(res.text).to.not.contain('')
+ }
+ }
+ }
+ })
+
+ 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('')
+ }
+
+ {
+ const { text } = await makeHTMLRequest(servers[0].url, path + handle)
+ expect(text).to.not.contain('')
+ }
+ }
+ })
+
+ 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('')
+ }
+
+ {
+ const { text } = await makeHTMLRequest(servers[0].url, path + handle)
+ expect(text).to.not.contain('')
+ }
+ }
+ })
+
+ 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('')
+ }
+ })
+
+ 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('')
+ }
+ })
+ })
+
+ 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)
+ })
+})
diff --git a/packages/tests/src/client/index.ts b/packages/tests/src/client/index.ts
new file mode 100644
index 000000000..075a2803e
--- /dev/null
+++ b/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'
diff --git a/packages/tests/src/client/oembed.ts b/packages/tests/src/client/oembed.ts
new file mode 100644
index 000000000..ad3467a03
--- /dev/null
+++ b/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 = ``
+
+ 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 = ``
+
+ expect(res.text).to.contain(expectedLink)
+ }
+ }
+ })
+
+ after(async function () {
+ await cleanupTests(servers)
+ })
+})
diff --git a/packages/tests/src/client/og-twitter-tags.ts b/packages/tests/src/client/og-twitter-tags.ts
new file mode 100644
index 000000000..8d7cde990
--- /dev/null
+++ b/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(``)
+ expect(text).to.contain(``)
+ expect(text).to.contain('')
+ expect(text).to.contain(``)
+ }
+
+ 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(``)
+ expect(text).to.contain(``)
+ expect(text).to.contain('')
+ expect(text).to.contain(``)
+ }
+
+ 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(``)
+ expect(text).to.contain(``)
+ expect(text).to.contain('')
+ expect(text).to.contain(``)
+ }
+
+ 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(``)
+ expect(text).to.contain(``)
+ expect(text).to.contain('')
+ expect(text).to.contain(``)
+ }
+
+ 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('')
+ expect(text).to.contain('')
+ expect(text).to.contain(``)
+ expect(text).to.contain(``)
+ }
+
+ 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('')
+ expect(text).to.contain('')
+ expect(text).to.contain(``)
+ expect(text).to.contain(``)
+ }
+
+ 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('')
+ expect(text).to.contain('')
+ expect(text).to.contain(``)
+ expect(text).to.contain(``)
+ }
+
+ 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('')
+ expect(text).to.contain('')
+ expect(text).to.contain(``)
+ expect(text).to.contain(``)
+ }
+
+ 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('')
+ expect(text).to.contain('')
+ }
+
+ 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('')
+ expect(text).to.contain('')
+ }
+
+ 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('')
+ expect(text).to.contain('')
+ }
+
+ 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('')
+ expect(text).to.contain('')
+ }
+
+ 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)
+ })
+})
diff --git a/packages/tests/src/peertube-runner/vod-transcoding.ts b/packages/tests/src/peertube-runner/vod-transcoding.ts
index ff5cefe36..a00b1857a 100644
--- a/packages/tests/src/peertube-runner/vod-transcoding.ts
+++ b/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()
diff --git a/packages/tests/src/shared/client.ts b/packages/tests/src/shared/client.ts
new file mode 100644
index 000000000..5afb20aae
--- /dev/null
+++ b/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 + '')
+ expect(html).to.contain('')
+
+ if (css) {
+ expect(html).to.contain('')
+ }
+
+ const htmlConfig: HTMLServerConfig = omit(config, [ 'signup' ])
+ const configObjectString = JSON.stringify(htmlConfig)
+ const configEscapedString = JSON.stringify(configObjectString)
+
+ expect(html).to.contain(``)
+}
+
+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
super __description__ for *server* 1'
+ 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
+ }
+}
diff --git a/scripts/ci.sh b/scripts/ci.sh
index d06f1c675..e24289345 100755
--- a/scripts/ci.sh
+++ b/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)
diff --git a/server/core/controllers/api/config.ts b/server/core/controllers/api/config.ts
index b689e5f78..8c28bc08c 100644
--- a/server/core/controllers/api/config.ts
+++ b/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()
diff --git a/server/core/controllers/client.ts b/server/core/controllers/client.ts
index a790859c7..403b8b141 100644
--- a/server/core/controllers/client.ts
+++ b/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,13 @@ 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,
- hookName
+ 'filter:html.embed.video.allowed.result'
)
if (!allowedResult || allowedResult.allowed !== true) {
@@ -161,7 +161,27 @@ async function generateEmbedHtmlPage (req: express.Request, res: express.Respons
return sendHTML(allowedResult?.html || '', res)
}
- const html = await ClientHtml.getEmbedHTML()
+ 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,
+ 'filter:html.embed.video-playlist.allowed.result'
+ )
+
+ if (!allowedResult || allowedResult.allowed !== true) {
+ logger.info('Embed is not allowed.', { allowedResult })
+
+ return sendHTML(allowedResult?.html || '', res)
+ }
+
+ const html = await ClientHtml.getVideoPlaylistEmbedHTML(req.params.id)
return sendHTML(html, res)
}
diff --git a/server/core/controllers/misc.ts b/server/core/controllers/misc.ts
index cf204e965..c5e6f88cb 100644
--- a/server/core/controllers/misc.ts
+++ b/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'
diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts
index 9ca5e3e2e..ccb65692d 100644
--- a/server/core/initializers/constants.ts
+++ b/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
diff --git a/server/core/lib/client-html.ts b/server/core/lib/client-html.ts
deleted file mode 100644
index c5acf16e8..000000000
--- a/server/core/lib/client-html.ts
+++ /dev/null
@@ -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,
- 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 = `${escapeHTML(text)}`
-
- 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 = ``
-
- return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag)
- }
-
- private static addCustomCSS (htmlStringPage: string) {
- const styleTag = ``
-
- 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 = ``
-
- 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 = ``
-
- return htmlStringPage.replace('', linkTag + '')
- }
-
- 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 += ``
- })
-
- // Standard
- Object.keys(standardMetaTags).forEach(tagName => {
- const tagValue = standardMetaTags[tagName]
-
- tagsStr += ``
- })
-
- // Twitter card
- Object.keys(twitterCardMetaTags).forEach(tagName => {
- const tagValue = twitterCardMetaTags[tagName]
-
- tagsStr += ``
- })
-
- // OEmbed
- for (const oembedLinkTag of oembedLinkTags) {
- tagsStr += ``
- }
-
- // Schema.org
- if (schemaTags) {
- tagsStr += ``
- }
-
- // SEO, use origin URL
- tagsStr += ``
-
- if (indexationPolicy === 'never') {
- tagsStr += ``
- }
-
- 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 })
-}
diff --git a/server/core/lib/html/client-html.ts b/server/core/lib/html/client-html.ts
new file mode 100644
index 000000000..d6650ce5a
--- /dev/null
+++ b/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)
+}
diff --git a/server/core/lib/html/shared/actor-html.ts b/server/core/lib/html/shared/actor-html.ts
new file mode 100644
index 000000000..121b22afe
--- /dev/null
+++ b/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,
+ 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
+ }
+}
diff --git a/server/core/lib/html/shared/common-embed-html.ts b/server/core/lib/html/shared/common-embed-html.ts
new file mode 100644
index 000000000..0f86d65bf
--- /dev/null
+++ b/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 })
+ }
+}
diff --git a/server/core/lib/html/shared/index.ts b/server/core/lib/html/shared/index.ts
new file mode 100644
index 000000000..68c3e47c8
--- /dev/null
+++ b/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'
diff --git a/server/core/lib/html/shared/page-html.ts b/server/core/lib/html/shared/page-html.ts
new file mode 100644
index 000000000..68a1ffc2e
--- /dev/null
+++ b/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 = ``
+
+ 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 = ``
+
+ 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 = ``
+
+ return htmlStringPage.replace('', linkTag + '')
+ }
+
+ 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)
+ }
+}
diff --git a/server/core/lib/html/shared/playlist-html.ts b/server/core/lib/html/shared/playlist-html.ts
new file mode 100644
index 000000000..dc7dacf04
--- /dev/null
+++ b/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 = 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 })
+ }
+}
diff --git a/server/core/lib/html/shared/tags-html.ts b/server/core/lib/html/shared/tags-html.ts
new file mode 100644
index 000000000..297888605
--- /dev/null
+++ b/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 = `${escapeHTML(text)}`
+
+ 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 = ``
+
+ 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 += ``
+ })
+
+ // Standard
+ Object.keys(standardMetaTags).forEach(tagName => {
+ const tagValue = standardMetaTags[tagName]
+ if (!tagValue) return
+
+ tagsStr += ``
+ })
+
+ // Twitter card
+ Object.keys(twitterCardMetaTags).forEach(tagName => {
+ const tagValue = twitterCardMetaTags[tagName]
+ if (!tagValue) return
+
+ tagsStr += ``
+ })
+
+ // OEmbed
+ for (const oembedLinkTag of oembedLinkTags) {
+ tagsStr += ``
+ }
+
+ // Schema.org
+ if (schemaTags) {
+ tagsStr += ``
+ }
+
+ // SEO, use origin URL
+ if (indexationPolicy !== 'never' && url) {
+ tagsStr += ``
+ }
+
+ if (indexationPolicy === 'never') {
+ tagsStr += ``
+ }
+
+ 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 })
+ }
+}
diff --git a/server/core/lib/html/shared/video-html.ts b/server/core/lib/html/shared/video-html.ts
new file mode 100644
index 000000000..c8067daf5
--- /dev/null
+++ b/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 = 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 })
+ }
+}
diff --git a/server/core/lib/plugins/plugin-manager.ts b/server/core/lib/plugins/plugin-manager.ts
index c4b4fae43..66b5c5b18 100644
--- a/server/core/lib/plugins/plugin-manager.ts
+++ b/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) {