From f90db24233eb4ee0a9fa2a8e6672c3b14449725f Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 20 Oct 2023 15:41:22 +0200 Subject: [PATCH] Fix SEO and refactor HTML pages generation * Split methods in multiple classes * Add JSONLD tags in embed too * Index embeds but use a canonical URL tag (targeting the watch page) * Remote objects don't include a canonical URL tag anymore. Instead we forbid indexation * Canonical URLs now use the official short URL (/w/, /w/p, /a, /c etc.) --- packages/tests/src/client.ts | 556 ---------------- packages/tests/src/client/embed-html.ts | 187 ++++++ packages/tests/src/client/index-html.ts | 258 +++++++ packages/tests/src/client/index.ts | 4 + packages/tests/src/client/oembed.ts | 64 ++ packages/tests/src/client/og-twitter-tags.ts | 271 ++++++++ .../src/peertube-runner/vod-transcoding.ts | 10 +- packages/tests/src/shared/client.ts | 181 +++++ scripts/ci.sh | 3 +- server/core/controllers/api/config.ts | 6 +- server/core/controllers/client.ts | 46 +- server/core/controllers/misc.ts | 2 +- server/core/initializers/constants.ts | 4 +- server/core/lib/client-html.ts | 630 ------------------ server/core/lib/html/client-html.ts | 95 +++ server/core/lib/html/shared/actor-html.ts | 91 +++ .../core/lib/html/shared/common-embed-html.ts | 18 + server/core/lib/html/shared/index.ts | 5 + server/core/lib/html/shared/page-html.ts | 166 +++++ server/core/lib/html/shared/playlist-html.ts | 126 ++++ server/core/lib/html/shared/tags-html.ts | 230 +++++++ server/core/lib/html/shared/video-html.ts | 130 ++++ server/core/lib/plugins/plugin-manager.ts | 6 +- 23 files changed, 1876 insertions(+), 1213 deletions(-) delete mode 100644 packages/tests/src/client.ts create mode 100644 packages/tests/src/client/embed-html.ts create mode 100644 packages/tests/src/client/index-html.ts create mode 100644 packages/tests/src/client/index.ts create mode 100644 packages/tests/src/client/oembed.ts create mode 100644 packages/tests/src/client/og-twitter-tags.ts create mode 100644 packages/tests/src/shared/client.ts delete mode 100644 server/core/lib/client-html.ts create mode 100644 server/core/lib/html/client-html.ts create mode 100644 server/core/lib/html/shared/actor-html.ts create mode 100644 server/core/lib/html/shared/common-embed-html.ts create mode 100644 server/core/lib/html/shared/index.ts create mode 100644 server/core/lib/html/shared/page-html.ts create mode 100644 server/core/lib/html/shared/playlist-html.ts create mode 100644 server/core/lib/html/shared/tags-html.ts create mode 100644 server/core/lib/html/shared/video-html.ts 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) {