mirror of https://github.com/Chocobozzz/PeerTube
				
				
				
			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.)pull/6026/head
							parent
							
								
									e731f4b724
								
							
						
					
					
						commit
						f90db24233
					
				|  | @ -1,556 +0,0 @@ | |||
| /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||||
| 
 | ||||
| import { expect } from 'chai' | ||||
| import { omit } from '@peertube/peertube-core-utils' | ||||
| import { | ||||
|   Account, | ||||
|   HTMLServerConfig, | ||||
|   HttpStatusCode, | ||||
|   ServerConfig, | ||||
|   VideoPlaylistCreateResult, | ||||
|   VideoPlaylistPrivacy, | ||||
|   VideoPrivacy | ||||
| } from '@peertube/peertube-models' | ||||
| import { | ||||
|   cleanupTests, | ||||
|   createMultipleServers, | ||||
|   doubleFollow, | ||||
|   makeGetRequest, | ||||
|   makeHTMLRequest, | ||||
|   PeerTubeServer, | ||||
|   setAccessTokensToServers, | ||||
|   setDefaultVideoChannel, | ||||
|   waitJobs | ||||
| } from '@peertube/peertube-server-commands' | ||||
| 
 | ||||
| function checkIndexTags (html: string, title: string, description: string, css: string, config: ServerConfig) { | ||||
|   expect(html).to.contain('<title>' + title + '</title>') | ||||
|   expect(html).to.contain('<meta name="description" content="' + description + '" />') | ||||
|   expect(html).to.contain('<style class="custom-css-style">' + css + '</style>') | ||||
| 
 | ||||
|   const htmlConfig: HTMLServerConfig = omit(config, [ 'signup' ]) | ||||
|   const configObjectString = JSON.stringify(htmlConfig) | ||||
|   const configEscapedString = JSON.stringify(configObjectString) | ||||
| 
 | ||||
|   expect(html).to.contain(`<script type="application/javascript">window.PeerTubeServerConfig = ${configEscapedString}</script>`) | ||||
| } | ||||
| 
 | ||||
| describe('Test a client controllers', function () { | ||||
|   let servers: PeerTubeServer[] = [] | ||||
|   let account: Account | ||||
| 
 | ||||
|   const videoName = 'my super name for server 1' | ||||
|   const videoDescription = 'my<br> super __description__ for *server* 1<p></p>' | ||||
|   const videoDescriptionPlainText = 'my super description for server 1' | ||||
| 
 | ||||
|   const playlistName = 'super playlist name' | ||||
|   const playlistDescription = 'super playlist description' | ||||
|   let playlist: VideoPlaylistCreateResult | ||||
| 
 | ||||
|   const channelDescription = 'my super channel description' | ||||
| 
 | ||||
|   const watchVideoBasePaths = [ '/videos/watch/', '/w/' ] | ||||
|   const watchPlaylistBasePaths = [ '/videos/watch/playlist/', '/w/p/' ] | ||||
| 
 | ||||
|   let videoIds: (string | number)[] = [] | ||||
|   let privateVideoId: string | ||||
|   let internalVideoId: string | ||||
|   let unlistedVideoId: string | ||||
|   let passwordProtectedVideoId: string | ||||
| 
 | ||||
|   let playlistIds: (string | number)[] = [] | ||||
| 
 | ||||
|   before(async function () { | ||||
|     this.timeout(120000) | ||||
| 
 | ||||
|     servers = await createMultipleServers(2) | ||||
| 
 | ||||
|     await setAccessTokensToServers(servers) | ||||
| 
 | ||||
|     await doubleFollow(servers[0], servers[1]) | ||||
| 
 | ||||
|     await setDefaultVideoChannel(servers) | ||||
| 
 | ||||
|     await servers[0].channels.update({ | ||||
|       channelName: servers[0].store.channel.name, | ||||
|       attributes: { description: channelDescription } | ||||
|     }) | ||||
| 
 | ||||
|     // Public video
 | ||||
| 
 | ||||
|     { | ||||
|       const attributes = { name: videoName, description: videoDescription } | ||||
|       await servers[0].videos.upload({ attributes }) | ||||
| 
 | ||||
|       const { data } = await servers[0].videos.list() | ||||
|       expect(data.length).to.equal(1) | ||||
| 
 | ||||
|       const video = data[0] | ||||
|       servers[0].store.video = video | ||||
|       videoIds = [ video.id, video.uuid, video.shortUUID ] | ||||
|     } | ||||
| 
 | ||||
|     { | ||||
|       ({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE })); | ||||
|       ({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED })); | ||||
|       ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL })); | ||||
|       ({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({ | ||||
|         name: 'password protected', | ||||
|         privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||||
|         videoPasswords: [ 'password' ] | ||||
|       })) | ||||
|     } | ||||
| 
 | ||||
|     // Playlist
 | ||||
| 
 | ||||
|     { | ||||
|       const attributes = { | ||||
|         displayName: playlistName, | ||||
|         description: playlistDescription, | ||||
|         privacy: VideoPlaylistPrivacy.PUBLIC, | ||||
|         videoChannelId: servers[0].store.channel.id | ||||
|       } | ||||
| 
 | ||||
|       playlist = await servers[0].playlists.create({ attributes }) | ||||
|       playlistIds = [ playlist.id, playlist.shortUUID, playlist.uuid ] | ||||
| 
 | ||||
|       await servers[0].playlists.addElement({ playlistId: playlist.shortUUID, attributes: { videoId: servers[0].store.video.id } }) | ||||
|     } | ||||
| 
 | ||||
|     // Account
 | ||||
| 
 | ||||
|     { | ||||
|       await servers[0].users.updateMe({ description: 'my account description' }) | ||||
| 
 | ||||
|       account = await servers[0].accounts.get({ accountName: `${servers[0].store.user.username}@${servers[0].host}` }) | ||||
|     } | ||||
| 
 | ||||
|     await waitJobs(servers) | ||||
|   }) | ||||
| 
 | ||||
|   describe('oEmbed', function () { | ||||
| 
 | ||||
|     it('Should have valid oEmbed discovery tags for videos', async function () { | ||||
|       for (const basePath of watchVideoBasePaths) { | ||||
|         for (const id of videoIds) { | ||||
|           const res = await makeGetRequest({ | ||||
|             url: servers[0].url, | ||||
|             path: basePath + id, | ||||
|             accept: 'text/html', | ||||
|             expectedStatus: HttpStatusCode.OK_200 | ||||
|           }) | ||||
| 
 | ||||
|           const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` + | ||||
|           `url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2F${servers[0].store.video.shortUUID}" ` + | ||||
|           `title="${servers[0].store.video.name}" />` | ||||
| 
 | ||||
|           expect(res.text).to.contain(expectedLink) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have valid oEmbed discovery tags for a playlist', async function () { | ||||
|       for (const basePath of watchPlaylistBasePaths) { | ||||
|         for (const id of playlistIds) { | ||||
|           const res = await makeGetRequest({ | ||||
|             url: servers[0].url, | ||||
|             path: basePath + id, | ||||
|             accept: 'text/html', | ||||
|             expectedStatus: HttpStatusCode.OK_200 | ||||
|           }) | ||||
| 
 | ||||
|           const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` + | ||||
|             `url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2Fp%2F${playlist.shortUUID}" ` + | ||||
|             `title="${playlistName}" />` | ||||
| 
 | ||||
|           expect(res.text).to.contain(expectedLink) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe('Open Graph', function () { | ||||
| 
 | ||||
|     async function accountPageTest (path: string) { | ||||
|       const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|       const text = res.text | ||||
| 
 | ||||
|       expect(text).to.contain(`<meta property="og:title" content="${account.displayName}" />`) | ||||
|       expect(text).to.contain(`<meta property="og:description" content="${account.description}" />`) | ||||
|       expect(text).to.contain('<meta property="og:type" content="website" />') | ||||
|       expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/a/${servers[0].store.user.username}" />`) | ||||
|     } | ||||
| 
 | ||||
|     async function channelPageTest (path: string) { | ||||
|       const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|       const text = res.text | ||||
| 
 | ||||
|       expect(text).to.contain(`<meta property="og:title" content="${servers[0].store.channel.displayName}" />`) | ||||
|       expect(text).to.contain(`<meta property="og:description" content="${channelDescription}" />`) | ||||
|       expect(text).to.contain('<meta property="og:type" content="website" />') | ||||
|       expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/c/${servers[0].store.channel.name}" />`) | ||||
|     } | ||||
| 
 | ||||
|     async function watchVideoPageTest (path: string) { | ||||
|       const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|       const text = res.text | ||||
| 
 | ||||
|       expect(text).to.contain(`<meta property="og:title" content="${videoName}" />`) | ||||
|       expect(text).to.contain(`<meta property="og:description" content="${videoDescriptionPlainText}" />`) | ||||
|       expect(text).to.contain('<meta property="og:type" content="video" />') | ||||
|       expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/${servers[0].store.video.shortUUID}" />`) | ||||
|     } | ||||
| 
 | ||||
|     async function watchPlaylistPageTest (path: string) { | ||||
|       const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|       const text = res.text | ||||
| 
 | ||||
|       expect(text).to.contain(`<meta property="og:title" content="${playlistName}" />`) | ||||
|       expect(text).to.contain(`<meta property="og:description" content="${playlistDescription}" />`) | ||||
|       expect(text).to.contain('<meta property="og:type" content="video" />') | ||||
|       expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/p/${playlist.shortUUID}" />`) | ||||
|     } | ||||
| 
 | ||||
|     it('Should have valid Open Graph tags on the account page', async function () { | ||||
|       await accountPageTest('/accounts/' + servers[0].store.user.username) | ||||
|       await accountPageTest('/a/' + servers[0].store.user.username) | ||||
|       await accountPageTest('/@' + servers[0].store.user.username) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have valid Open Graph tags on the channel page', async function () { | ||||
|       await channelPageTest('/video-channels/' + servers[0].store.channel.name) | ||||
|       await channelPageTest('/c/' + servers[0].store.channel.name) | ||||
|       await channelPageTest('/@' + servers[0].store.channel.name) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have valid Open Graph tags on the watch page', async function () { | ||||
|       for (const path of watchVideoBasePaths) { | ||||
|         for (const id of videoIds) { | ||||
|           await watchVideoPageTest(path + id) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have valid Open Graph tags on the watch page with thread id Angular param', async function () { | ||||
|       for (const path of watchVideoBasePaths) { | ||||
|         for (const id of videoIds) { | ||||
|           await watchVideoPageTest(path + id + ';threadId=1') | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have valid Open Graph tags on the watch playlist page', async function () { | ||||
|       for (const path of watchPlaylistBasePaths) { | ||||
|         for (const id of playlistIds) { | ||||
|           await watchPlaylistPageTest(path + id) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe('Twitter card', async function () { | ||||
| 
 | ||||
|     describe('Not whitelisted', function () { | ||||
| 
 | ||||
|       async function accountPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="summary" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />') | ||||
|         expect(text).to.contain(`<meta property="twitter:title" content="${account.name}" />`) | ||||
|         expect(text).to.contain(`<meta property="twitter:description" content="${account.description}" />`) | ||||
|       } | ||||
| 
 | ||||
|       async function channelPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="summary" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />') | ||||
|         expect(text).to.contain(`<meta property="twitter:title" content="${servers[0].store.channel.displayName}" />`) | ||||
|         expect(text).to.contain(`<meta property="twitter:description" content="${channelDescription}" />`) | ||||
|       } | ||||
| 
 | ||||
|       async function watchVideoPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="summary_large_image" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />') | ||||
|         expect(text).to.contain(`<meta property="twitter:title" content="${videoName}" />`) | ||||
|         expect(text).to.contain(`<meta property="twitter:description" content="${videoDescriptionPlainText}" />`) | ||||
|       } | ||||
| 
 | ||||
|       async function watchPlaylistPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="summary" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />') | ||||
|         expect(text).to.contain(`<meta property="twitter:title" content="${playlistName}" />`) | ||||
|         expect(text).to.contain(`<meta property="twitter:description" content="${playlistDescription}" />`) | ||||
|       } | ||||
| 
 | ||||
|       it('Should have valid twitter card on the watch video page', async function () { | ||||
|         for (const path of watchVideoBasePaths) { | ||||
|           for (const id of videoIds) { | ||||
|             await watchVideoPageTest(path + id) | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       it('Should have valid twitter card on the watch playlist page', async function () { | ||||
|         for (const path of watchPlaylistBasePaths) { | ||||
|           for (const id of playlistIds) { | ||||
|             await watchPlaylistPageTest(path + id) | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       it('Should have valid twitter card on the account page', async function () { | ||||
|         await accountPageTest('/accounts/' + account.name) | ||||
|         await accountPageTest('/a/' + account.name) | ||||
|         await accountPageTest('/@' + account.name) | ||||
|       }) | ||||
| 
 | ||||
|       it('Should have valid twitter card on the channel page', async function () { | ||||
|         await channelPageTest('/video-channels/' + servers[0].store.channel.name) | ||||
|         await channelPageTest('/c/' + servers[0].store.channel.name) | ||||
|         await channelPageTest('/@' + servers[0].store.channel.name) | ||||
|       }) | ||||
|     }) | ||||
| 
 | ||||
|     describe('Whitelisted', function () { | ||||
| 
 | ||||
|       before(async function () { | ||||
|         const config = await servers[0].config.getCustomConfig() | ||||
|         config.services.twitter = { | ||||
|           username: '@Kuja', | ||||
|           whitelisted: true | ||||
|         } | ||||
| 
 | ||||
|         await servers[0].config.updateCustomConfig({ newCustomConfig: config }) | ||||
|       }) | ||||
| 
 | ||||
|       async function accountPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="summary" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />') | ||||
|       } | ||||
| 
 | ||||
|       async function channelPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="summary" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />') | ||||
|       } | ||||
| 
 | ||||
|       async function watchVideoPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="player" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />') | ||||
|       } | ||||
| 
 | ||||
|       async function watchPlaylistPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="player" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />') | ||||
|       } | ||||
| 
 | ||||
|       it('Should have valid twitter card on the watch video page', async function () { | ||||
|         for (const path of watchVideoBasePaths) { | ||||
|           for (const id of videoIds) { | ||||
|             await watchVideoPageTest(path + id) | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       it('Should have valid twitter card on the watch playlist page', async function () { | ||||
|         for (const path of watchPlaylistBasePaths) { | ||||
|           for (const id of playlistIds) { | ||||
|             await watchPlaylistPageTest(path + id) | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       it('Should have valid twitter card on the account page', async function () { | ||||
|         await accountPageTest('/accounts/' + account.name) | ||||
|         await accountPageTest('/a/' + account.name) | ||||
|         await accountPageTest('/@' + account.name) | ||||
|       }) | ||||
| 
 | ||||
|       it('Should have valid twitter card on the channel page', async function () { | ||||
|         await channelPageTest('/video-channels/' + servers[0].store.channel.name) | ||||
|         await channelPageTest('/c/' + servers[0].store.channel.name) | ||||
|         await channelPageTest('/@' + servers[0].store.channel.name) | ||||
|       }) | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe('Index HTML', function () { | ||||
| 
 | ||||
|     it('Should have valid index html tags (title, description...)', async function () { | ||||
|       const config = await servers[0].config.getConfig() | ||||
|       const res = await makeHTMLRequest(servers[0].url, '/videos/trending') | ||||
| 
 | ||||
|       const description = 'PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.' | ||||
|       checkIndexTags(res.text, 'PeerTube', description, '', config) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should update the customized configuration and have the correct index html tags', async function () { | ||||
|       await servers[0].config.updateCustomSubConfig({ | ||||
|         newConfig: { | ||||
|           instance: { | ||||
|             name: 'PeerTube updated', | ||||
|             shortDescription: 'my short description', | ||||
|             description: 'my super description', | ||||
|             terms: 'my super terms', | ||||
|             defaultNSFWPolicy: 'blur', | ||||
|             defaultClientRoute: '/videos/recently-added', | ||||
|             customizations: { | ||||
|               javascript: 'alert("coucou")', | ||||
|               css: 'body { background-color: red; }' | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       const config = await servers[0].config.getConfig() | ||||
|       const res = await makeHTMLRequest(servers[0].url, '/videos/trending') | ||||
| 
 | ||||
|       checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have valid index html updated tags (title, description...)', async function () { | ||||
|       const config = await servers[0].config.getConfig() | ||||
|       const res = await makeHTMLRequest(servers[0].url, '/videos/trending') | ||||
| 
 | ||||
|       checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should use the original video URL for the canonical tag', async function () { | ||||
|       for (const basePath of watchVideoBasePaths) { | ||||
|         for (const id of videoIds) { | ||||
|           const res = await makeHTMLRequest(servers[1].url, basePath + id) | ||||
|           expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/videos/watch/${servers[0].store.video.uuid}" />`) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should use the original account URL for the canonical tag', async function () { | ||||
|       const accountURLtest = res => { | ||||
|         expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/accounts/root" />`) | ||||
|       } | ||||
| 
 | ||||
|       accountURLtest(await makeHTMLRequest(servers[1].url, '/accounts/root@' + servers[0].host)) | ||||
|       accountURLtest(await makeHTMLRequest(servers[1].url, '/a/root@' + servers[0].host)) | ||||
|       accountURLtest(await makeHTMLRequest(servers[1].url, '/@root@' + servers[0].host)) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should use the original channel URL for the canonical tag', async function () { | ||||
|       const channelURLtests = res => { | ||||
|         expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-channels/root_channel" />`) | ||||
|       } | ||||
| 
 | ||||
|       channelURLtests(await makeHTMLRequest(servers[1].url, '/video-channels/root_channel@' + servers[0].host)) | ||||
|       channelURLtests(await makeHTMLRequest(servers[1].url, '/c/root_channel@' + servers[0].host)) | ||||
|       channelURLtests(await makeHTMLRequest(servers[1].url, '/@root_channel@' + servers[0].host)) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should use the original playlist URL for the canonical tag', async function () { | ||||
|       for (const basePath of watchPlaylistBasePaths) { | ||||
|         for (const id of playlistIds) { | ||||
|           const res = await makeHTMLRequest(servers[1].url, basePath + id) | ||||
|           expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-playlists/${playlist.uuid}" />`) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should add noindex meta tag for remote accounts', async function () { | ||||
|       const handle = 'root@' + servers[0].host | ||||
|       const paths = [ '/accounts/', '/a/', '/@' ] | ||||
| 
 | ||||
|       for (const path of paths) { | ||||
|         { | ||||
|           const { text } = await makeHTMLRequest(servers[1].url, path + handle) | ||||
|           expect(text).to.contain('<meta name="robots" content="noindex" />') | ||||
|         } | ||||
| 
 | ||||
|         { | ||||
|           const { text } = await makeHTMLRequest(servers[0].url, path + handle) | ||||
|           expect(text).to.not.contain('<meta name="robots" content="noindex" />') | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should add noindex meta tag for remote channels', async function () { | ||||
|       const handle = 'root_channel@' + servers[0].host | ||||
|       const paths = [ '/video-channels/', '/c/', '/@' ] | ||||
| 
 | ||||
|       for (const path of paths) { | ||||
|         { | ||||
|           const { text } = await makeHTMLRequest(servers[1].url, path + handle) | ||||
|           expect(text).to.contain('<meta name="robots" content="noindex" />') | ||||
|         } | ||||
| 
 | ||||
|         { | ||||
|           const { text } = await makeHTMLRequest(servers[0].url, path + handle) | ||||
|           expect(text).to.not.contain('<meta name="robots" content="noindex" />') | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should not display internal/private/password protected video', async function () { | ||||
|       for (const basePath of watchVideoBasePaths) { | ||||
|         for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) { | ||||
|           const res = await makeGetRequest({ | ||||
|             url: servers[0].url, | ||||
|             path: basePath + id, | ||||
|             accept: 'text/html', | ||||
|             expectedStatus: HttpStatusCode.NOT_FOUND_404 | ||||
|           }) | ||||
| 
 | ||||
|           expect(res.text).to.not.contain('internal') | ||||
|           expect(res.text).to.not.contain('private') | ||||
|           expect(res.text).to.not.contain('password protected') | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should add noindex meta tag for unlisted video', async function () { | ||||
|       for (const basePath of watchVideoBasePaths) { | ||||
|         const res = await makeGetRequest({ | ||||
|           url: servers[0].url, | ||||
|           path: basePath + unlistedVideoId, | ||||
|           accept: 'text/html', | ||||
|           expectedStatus: HttpStatusCode.OK_200 | ||||
|         }) | ||||
| 
 | ||||
|         expect(res.text).to.contain('unlisted') | ||||
|         expect(res.text).to.contain('<meta name="robots" content="noindex" />') | ||||
|       } | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe('Embed HTML', function () { | ||||
| 
 | ||||
|     it('Should have the correct embed html tags', async function () { | ||||
|       const config = await servers[0].config.getConfig() | ||||
|       const res = await makeHTMLRequest(servers[0].url, servers[0].store.video.embedPath) | ||||
| 
 | ||||
|       checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config) | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   after(async function () { | ||||
|     await cleanupTests(servers) | ||||
|   }) | ||||
| }) | ||||
|  | @ -0,0 +1,187 @@ | |||
| /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||||
| 
 | ||||
| import { expect } from 'chai' | ||||
| import { ServerConfig, VideoPlaylistCreateResult } from '@peertube/peertube-models' | ||||
| import { cleanupTests, makeHTMLRequest, PeerTubeServer } from '@peertube/peertube-server-commands' | ||||
| import { checkIndexTags, prepareClientTests } from '@tests/shared/client.js' | ||||
| 
 | ||||
| describe('Test embed HTML generation', function () { | ||||
|   let servers: PeerTubeServer[] | ||||
| 
 | ||||
|   let videoIds: (string | number)[] = [] | ||||
|   let videoName: string | ||||
|   let videoDescriptionPlainText: string | ||||
| 
 | ||||
|   let privateVideoId: string | ||||
|   let internalVideoId: string | ||||
|   let unlistedVideoId: string | ||||
|   let passwordProtectedVideoId: string | ||||
| 
 | ||||
|   let playlistIds: (string | number)[] = [] | ||||
|   let playlist: VideoPlaylistCreateResult | ||||
|   let privatePlaylistId: string | ||||
|   let unlistedPlaylistId: string | ||||
|   let playlistName: string | ||||
|   let playlistDescription: string | ||||
|   let instanceDescription: string | ||||
| 
 | ||||
|   before(async function () { | ||||
|     this.timeout(120000); | ||||
| 
 | ||||
|     ({ | ||||
|       servers, | ||||
|       videoIds, | ||||
|       privateVideoId, | ||||
|       internalVideoId, | ||||
|       passwordProtectedVideoId, | ||||
|       unlistedVideoId, | ||||
|       videoName, | ||||
|       videoDescriptionPlainText, | ||||
| 
 | ||||
|       playlistIds, | ||||
|       playlistName, | ||||
|       playlistDescription, | ||||
|       playlist, | ||||
|       unlistedPlaylistId, | ||||
|       privatePlaylistId, | ||||
|       instanceDescription | ||||
|     } = await prepareClientTests()) | ||||
|   }) | ||||
| 
 | ||||
|   describe('HTML tags', function () { | ||||
|     let config: ServerConfig | ||||
| 
 | ||||
|     before(async function () { | ||||
|       config = await servers[0].config.getConfig() | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have the correct embed html instance tags', async function () { | ||||
|       const res = await makeHTMLRequest(servers[0].url, '/videos/embed/toto') | ||||
| 
 | ||||
|       checkIndexTags(res.text, `PeerTube`, instanceDescription, '', config) | ||||
| 
 | ||||
|       expect(res.text).to.not.contain(`"name":`) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have the correct embed html video tags', async function () { | ||||
|       const config = await servers[0].config.getConfig() | ||||
|       const res = await makeHTMLRequest(servers[0].url, servers[0].store.video.embedPath) | ||||
| 
 | ||||
|       checkIndexTags(res.text, `${videoName} - PeerTube`, videoDescriptionPlainText, '', config) | ||||
| 
 | ||||
|       expect(res.text).to.contain(`"name":"${videoName}",`) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have the correct embed html playlist tags', async function () { | ||||
|       const config = await servers[0].config.getConfig() | ||||
|       const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + playlistIds[0]) | ||||
| 
 | ||||
|       checkIndexTags(res.text, `${playlistName} - PeerTube`, playlistDescription, '', config) | ||||
|       expect(res.text).to.contain(`"name":"${playlistName}",`) | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe('Canonical tags', function () { | ||||
| 
 | ||||
|     it('Should use the original video URL for the canonical tag', async function () { | ||||
|       for (const id of videoIds) { | ||||
|         const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + id) | ||||
|         expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/w/${servers[0].store.video.shortUUID}" />`) | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should use the original playlist URL for the canonical tag', async function () { | ||||
|       for (const id of playlistIds) { | ||||
|         const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + id) | ||||
|         expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/w/p/${playlist.shortUUID}" />`) | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|   }) | ||||
| 
 | ||||
|   describe('Indexation tags', function () { | ||||
| 
 | ||||
|     it('Should not index remote videos', async function () { | ||||
|       for (const id of videoIds) { | ||||
|         { | ||||
|           const res = await makeHTMLRequest(servers[1].url, '/videos/embed/' + id) | ||||
|           expect(res.text).to.contain('<meta name="robots" content="noindex" />') | ||||
|         } | ||||
| 
 | ||||
|         { | ||||
|           const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + id) | ||||
|           expect(res.text).to.not.contain('<meta name="robots" content="noindex" />') | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should not index remote playlists', async function () { | ||||
|       for (const id of playlistIds) { | ||||
|         { | ||||
|           const res = await makeHTMLRequest(servers[1].url, '/video-playlists/embed/' + id) | ||||
|           expect(res.text).to.contain('<meta name="robots" content="noindex" />') | ||||
|         } | ||||
| 
 | ||||
|         { | ||||
|           const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + id) | ||||
|           expect(res.text).to.not.contain('<meta name="robots" content="noindex" />') | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should add noindex meta tags for unlisted video', async function () { | ||||
|       { | ||||
|         const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + videoIds[0]) | ||||
| 
 | ||||
|         expect(res.text).to.not.contain('<meta name="robots" content="noindex" />') | ||||
|       } | ||||
| 
 | ||||
|       { | ||||
|         const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + unlistedVideoId) | ||||
| 
 | ||||
|         expect(res.text).to.contain('unlisted') | ||||
|         expect(res.text).to.contain('<meta name="robots" content="noindex" />') | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should add noindex meta tags for unlisted playlist', async function () { | ||||
|       { | ||||
|         const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + playlistIds[0]) | ||||
| 
 | ||||
|         expect(res.text).to.not.contain('<meta name="robots" content="noindex" />') | ||||
|       } | ||||
| 
 | ||||
|       { | ||||
|         const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + unlistedPlaylistId) | ||||
| 
 | ||||
|         expect(res.text).to.contain('unlisted') | ||||
|         expect(res.text).to.contain('<meta name="robots" content="noindex" />') | ||||
|       } | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe('Check leak of private objects', function () { | ||||
| 
 | ||||
|     it('Should not leak video information in embed', async function () { | ||||
|       for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) { | ||||
|         const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + id) | ||||
| 
 | ||||
|         expect(res.text).to.not.contain('internal') | ||||
|         expect(res.text).to.not.contain('private') | ||||
|         expect(res.text).to.not.contain('password protected') | ||||
|         expect(res.text).to.contain('<meta name="robots" content="noindex" />') | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should not leak playlist information in embed', async function () { | ||||
|       const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + privatePlaylistId) | ||||
| 
 | ||||
|       expect(res.text).to.not.contain('private') | ||||
|       expect(res.text).to.contain('<meta name="robots" content="noindex" />') | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   after(async function () { | ||||
|     await cleanupTests(servers) | ||||
|   }) | ||||
| }) | ||||
|  | @ -0,0 +1,258 @@ | |||
| /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||||
| 
 | ||||
| import { expect } from 'chai' | ||||
| import { HttpStatusCode, VideoPlaylistCreateResult } from '@peertube/peertube-models' | ||||
| import { cleanupTests, makeGetRequest, makeHTMLRequest, PeerTubeServer } from '@peertube/peertube-server-commands' | ||||
| import { checkIndexTags, getWatchPlaylistBasePaths, getWatchVideoBasePaths, prepareClientTests } from '@tests/shared/client.js' | ||||
| 
 | ||||
| describe('Test index HTML generation', function () { | ||||
|   let servers: PeerTubeServer[] | ||||
| 
 | ||||
|   let videoIds: (string | number)[] = [] | ||||
|   let privateVideoId: string | ||||
|   let internalVideoId: string | ||||
|   let unlistedVideoId: string | ||||
|   let passwordProtectedVideoId: string | ||||
| 
 | ||||
|   let playlist: VideoPlaylistCreateResult | ||||
| 
 | ||||
|   let playlistIds: (string | number)[] = [] | ||||
|   let privatePlaylistId: string | ||||
|   let unlistedPlaylistId: string | ||||
| 
 | ||||
|   let instanceDescription: string | ||||
| 
 | ||||
|   before(async function () { | ||||
|     this.timeout(120000); | ||||
| 
 | ||||
|     ({ | ||||
|       servers, | ||||
|       playlistIds, | ||||
|       videoIds, | ||||
|       playlist, | ||||
|       privateVideoId, | ||||
|       internalVideoId, | ||||
|       passwordProtectedVideoId, | ||||
|       unlistedVideoId, | ||||
|       privatePlaylistId, | ||||
|       unlistedPlaylistId, | ||||
|       instanceDescription | ||||
|     } = await prepareClientTests()) | ||||
|   }) | ||||
| 
 | ||||
|   describe('Instance tags', function () { | ||||
| 
 | ||||
|     it('Should have valid index html tags (title, description...)', async function () { | ||||
|       const config = await servers[0].config.getConfig() | ||||
|       const res = await makeHTMLRequest(servers[0].url, '/videos/trending') | ||||
| 
 | ||||
|       checkIndexTags(res.text, 'PeerTube', instanceDescription, '', config) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should update the customized configuration and have the correct index html tags', async function () { | ||||
|       await servers[0].config.updateCustomSubConfig({ | ||||
|         newConfig: { | ||||
|           instance: { | ||||
|             name: 'PeerTube updated', | ||||
|             shortDescription: 'my short description', | ||||
|             description: 'my super description', | ||||
|             terms: 'my super terms', | ||||
|             defaultNSFWPolicy: 'blur', | ||||
|             defaultClientRoute: '/videos/recently-added', | ||||
|             customizations: { | ||||
|               javascript: 'alert("coucou")', | ||||
|               css: 'body { background-color: red; }' | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       const config = await servers[0].config.getConfig() | ||||
|       const res = await makeHTMLRequest(servers[0].url, '/videos/trending') | ||||
| 
 | ||||
|       checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have valid index html updated tags (title, description...)', async function () { | ||||
|       const config = await servers[0].config.getConfig() | ||||
|       const res = await makeHTMLRequest(servers[0].url, '/videos/trending') | ||||
| 
 | ||||
|       checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config) | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe('Canonical tags', function () { | ||||
| 
 | ||||
|     it('Should use the original video URL for the canonical tag', async function () { | ||||
|       for (const basePath of getWatchVideoBasePaths()) { | ||||
|         for (const id of videoIds) { | ||||
|           const res = await makeHTMLRequest(servers[0].url, basePath + id) | ||||
|           expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/w/${servers[0].store.video.shortUUID}" />`) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should use the original playlist URL for the canonical tag', async function () { | ||||
|       for (const basePath of getWatchPlaylistBasePaths()) { | ||||
|         for (const id of playlistIds) { | ||||
|           const res = await makeHTMLRequest(servers[0].url, basePath + id) | ||||
|           expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/w/p/${playlist.shortUUID}" />`) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should use the original account URL for the canonical tag', async function () { | ||||
|       const accountURLtest = res => { | ||||
|         expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/a/root" />`) | ||||
|       } | ||||
| 
 | ||||
|       accountURLtest(await makeHTMLRequest(servers[0].url, '/accounts/root@' + servers[0].host)) | ||||
|       accountURLtest(await makeHTMLRequest(servers[0].url, '/a/root@' + servers[0].host)) | ||||
|       accountURLtest(await makeHTMLRequest(servers[0].url, '/@root@' + servers[0].host)) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should use the original channel URL for the canonical tag', async function () { | ||||
|       const channelURLtests = res => { | ||||
|         expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/c/root_channel" />`) | ||||
|       } | ||||
| 
 | ||||
|       channelURLtests(await makeHTMLRequest(servers[0].url, '/video-channels/root_channel@' + servers[0].host)) | ||||
|       channelURLtests(await makeHTMLRequest(servers[0].url, '/c/root_channel@' + servers[0].host)) | ||||
|       channelURLtests(await makeHTMLRequest(servers[0].url, '/@root_channel@' + servers[0].host)) | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe('Indexation tags', function () { | ||||
| 
 | ||||
|     it('Should not index remote videos', async function () { | ||||
|       for (const basePath of getWatchVideoBasePaths()) { | ||||
|         for (const id of videoIds) { | ||||
|           { | ||||
|             const res = await makeHTMLRequest(servers[1].url, basePath + id) | ||||
|             expect(res.text).to.contain('<meta name="robots" content="noindex" />') | ||||
|           } | ||||
| 
 | ||||
|           { | ||||
|             const res = await makeHTMLRequest(servers[0].url, basePath + id) | ||||
|             expect(res.text).to.not.contain('<meta name="robots" content="noindex" />') | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should not index remote playlists', async function () { | ||||
|       for (const basePath of getWatchPlaylistBasePaths()) { | ||||
|         for (const id of playlistIds) { | ||||
|           { | ||||
|             const res = await makeHTMLRequest(servers[1].url, basePath + id) | ||||
|             expect(res.text).to.contain('<meta name="robots" content="noindex" />') | ||||
|           } | ||||
| 
 | ||||
|           { | ||||
|             const res = await makeHTMLRequest(servers[0].url, basePath + id) | ||||
|             expect(res.text).to.not.contain('<meta name="robots" content="noindex" />') | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should add noindex meta tag for remote accounts', async function () { | ||||
|       const handle = 'root@' + servers[0].host | ||||
|       const paths = [ '/accounts/', '/a/', '/@' ] | ||||
| 
 | ||||
|       for (const path of paths) { | ||||
|         { | ||||
|           const { text } = await makeHTMLRequest(servers[1].url, path + handle) | ||||
|           expect(text).to.contain('<meta name="robots" content="noindex" />') | ||||
|         } | ||||
| 
 | ||||
|         { | ||||
|           const { text } = await makeHTMLRequest(servers[0].url, path + handle) | ||||
|           expect(text).to.not.contain('<meta name="robots" content="noindex" />') | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should add noindex meta tag for remote channels', async function () { | ||||
|       const handle = 'root_channel@' + servers[0].host | ||||
|       const paths = [ '/video-channels/', '/c/', '/@' ] | ||||
| 
 | ||||
|       for (const path of paths) { | ||||
|         { | ||||
|           const { text } = await makeHTMLRequest(servers[1].url, path + handle) | ||||
|           expect(text).to.contain('<meta name="robots" content="noindex" />') | ||||
|         } | ||||
| 
 | ||||
|         { | ||||
|           const { text } = await makeHTMLRequest(servers[0].url, path + handle) | ||||
|           expect(text).to.not.contain('<meta name="robots" content="noindex" />') | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should add noindex meta tag for unlisted video', async function () { | ||||
|       for (const basePath of getWatchVideoBasePaths()) { | ||||
|         const res = await makeGetRequest({ | ||||
|           url: servers[0].url, | ||||
|           path: basePath + unlistedVideoId, | ||||
|           accept: 'text/html', | ||||
|           expectedStatus: HttpStatusCode.OK_200 | ||||
|         }) | ||||
| 
 | ||||
|         expect(res.text).to.contain('unlisted') | ||||
|         expect(res.text).to.contain('<meta name="robots" content="noindex" />') | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should add noindex meta tag for unlisted video playlist', async function () { | ||||
|       for (const basePath of getWatchPlaylistBasePaths()) { | ||||
|         const res = await makeGetRequest({ | ||||
|           url: servers[0].url, | ||||
|           path: basePath + unlistedPlaylistId, | ||||
|           accept: 'text/html', | ||||
|           expectedStatus: HttpStatusCode.OK_200 | ||||
|         }) | ||||
| 
 | ||||
|         expect(res.text).to.contain('unlisted') | ||||
|         expect(res.text).to.contain('<meta name="robots" content="noindex" />') | ||||
|       } | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe('Check no leaks for private objects', function () { | ||||
| 
 | ||||
|     it('Should not display internal/private/password protected video', async function () { | ||||
|       for (const basePath of getWatchVideoBasePaths()) { | ||||
|         for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) { | ||||
|           const res = await makeGetRequest({ | ||||
|             url: servers[0].url, | ||||
|             path: basePath + id, | ||||
|             accept: 'text/html', | ||||
|             expectedStatus: HttpStatusCode.NOT_FOUND_404 | ||||
|           }) | ||||
| 
 | ||||
|           expect(res.text).to.not.contain('internal') | ||||
|           expect(res.text).to.not.contain('private') | ||||
|           expect(res.text).to.not.contain('password protected') | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should not display private video playlist', async function () { | ||||
|       for (const basePath of getWatchPlaylistBasePaths()) { | ||||
|         const res = await makeGetRequest({ | ||||
|           url: servers[0].url, | ||||
|           path: basePath + privatePlaylistId, | ||||
|           accept: 'text/html', | ||||
|           expectedStatus: HttpStatusCode.NOT_FOUND_404 | ||||
|         }) | ||||
| 
 | ||||
|         expect(res.text).to.not.contain('private') | ||||
|       } | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   after(async function () { | ||||
|     await cleanupTests(servers) | ||||
|   }) | ||||
| }) | ||||
|  | @ -0,0 +1,4 @@ | |||
| export * from './embed-html.js' | ||||
| export * from './index-html.js' | ||||
| export * from './oembed.js' | ||||
| export * from './og-twitter-tags.js' | ||||
|  | @ -0,0 +1,64 @@ | |||
| /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||||
| 
 | ||||
| import { expect } from 'chai' | ||||
| import { HttpStatusCode, VideoPlaylistCreateResult } from '@peertube/peertube-models' | ||||
| import { PeerTubeServer, cleanupTests, makeGetRequest } from '@peertube/peertube-server-commands' | ||||
| import { getWatchPlaylistBasePaths, getWatchVideoBasePaths, prepareClientTests } from '@tests/shared/client.js' | ||||
| 
 | ||||
| describe('Test oEmbed HTML tags', function () { | ||||
|   let servers: PeerTubeServer[] | ||||
| 
 | ||||
|   let videoIds: (string | number)[] = [] | ||||
| 
 | ||||
|   let playlistName: string | ||||
|   let playlist: VideoPlaylistCreateResult | ||||
|   let playlistIds: (string | number)[] = [] | ||||
| 
 | ||||
|   before(async function () { | ||||
|     this.timeout(120000); | ||||
| 
 | ||||
|     ({ servers, playlistIds, videoIds, playlist, playlistName } = await prepareClientTests()) | ||||
|   }) | ||||
| 
 | ||||
|   it('Should have valid oEmbed discovery tags for videos', async function () { | ||||
|     for (const basePath of getWatchVideoBasePaths()) { | ||||
|       for (const id of videoIds) { | ||||
|         const res = await makeGetRequest({ | ||||
|           url: servers[0].url, | ||||
|           path: basePath + id, | ||||
|           accept: 'text/html', | ||||
|           expectedStatus: HttpStatusCode.OK_200 | ||||
|         }) | ||||
| 
 | ||||
|         const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` + | ||||
|         `url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2F${servers[0].store.video.shortUUID}" ` + | ||||
|         `title="${servers[0].store.video.name}" />` | ||||
| 
 | ||||
|         expect(res.text).to.contain(expectedLink) | ||||
|       } | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
|   it('Should have valid oEmbed discovery tags for a playlist', async function () { | ||||
|     for (const basePath of getWatchPlaylistBasePaths()) { | ||||
|       for (const id of playlistIds) { | ||||
|         const res = await makeGetRequest({ | ||||
|           url: servers[0].url, | ||||
|           path: basePath + id, | ||||
|           accept: 'text/html', | ||||
|           expectedStatus: HttpStatusCode.OK_200 | ||||
|         }) | ||||
| 
 | ||||
|         const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` + | ||||
|           `url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2Fp%2F${playlist.shortUUID}" ` + | ||||
|           `title="${playlistName}" />` | ||||
| 
 | ||||
|         expect(res.text).to.contain(expectedLink) | ||||
|       } | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
|   after(async function () { | ||||
|     await cleanupTests(servers) | ||||
|   }) | ||||
| }) | ||||
|  | @ -0,0 +1,271 @@ | |||
| /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||||
| 
 | ||||
| import { expect } from 'chai' | ||||
| import { Account, HttpStatusCode, VideoPlaylistCreateResult } from '@peertube/peertube-models' | ||||
| import { cleanupTests, makeGetRequest, PeerTubeServer } from '@peertube/peertube-server-commands' | ||||
| import { getWatchPlaylistBasePaths, getWatchVideoBasePaths, prepareClientTests } from '@tests/shared/client.js' | ||||
| 
 | ||||
| describe('Test Open Graph and Twitter cards HTML tags', function () { | ||||
|   let servers: PeerTubeServer[] | ||||
|   let account: Account | ||||
| 
 | ||||
|   let videoIds: (string | number)[] = [] | ||||
| 
 | ||||
|   let videoName: string | ||||
|   let videoDescriptionPlainText: string | ||||
| 
 | ||||
|   let playlistName: string | ||||
|   let playlistDescription: string | ||||
|   let playlist: VideoPlaylistCreateResult | ||||
| 
 | ||||
|   let channelDescription: string | ||||
| 
 | ||||
|   let playlistIds: (string | number)[] = [] | ||||
| 
 | ||||
|   before(async function () { | ||||
|     this.timeout(120000); | ||||
| 
 | ||||
|     ({ | ||||
|       servers, | ||||
|       account, | ||||
|       playlistIds, | ||||
|       videoIds, | ||||
|       videoName, | ||||
|       videoDescriptionPlainText, | ||||
|       playlistName, | ||||
|       playlist, | ||||
|       playlistDescription, | ||||
|       channelDescription | ||||
|     } = await prepareClientTests()) | ||||
|   }) | ||||
| 
 | ||||
|   describe('Open Graph', function () { | ||||
| 
 | ||||
|     async function accountPageTest (path: string) { | ||||
|       const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|       const text = res.text | ||||
| 
 | ||||
|       expect(text).to.contain(`<meta property="og:title" content="${account.displayName}" />`) | ||||
|       expect(text).to.contain(`<meta property="og:description" content="${account.description}" />`) | ||||
|       expect(text).to.contain('<meta property="og:type" content="website" />') | ||||
|       expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/a/${servers[0].store.user.username}" />`) | ||||
|     } | ||||
| 
 | ||||
|     async function channelPageTest (path: string) { | ||||
|       const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|       const text = res.text | ||||
| 
 | ||||
|       expect(text).to.contain(`<meta property="og:title" content="${servers[0].store.channel.displayName}" />`) | ||||
|       expect(text).to.contain(`<meta property="og:description" content="${channelDescription}" />`) | ||||
|       expect(text).to.contain('<meta property="og:type" content="website" />') | ||||
|       expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/c/${servers[0].store.channel.name}" />`) | ||||
|     } | ||||
| 
 | ||||
|     async function watchVideoPageTest (path: string) { | ||||
|       const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|       const text = res.text | ||||
| 
 | ||||
|       expect(text).to.contain(`<meta property="og:title" content="${videoName}" />`) | ||||
|       expect(text).to.contain(`<meta property="og:description" content="${videoDescriptionPlainText}" />`) | ||||
|       expect(text).to.contain('<meta property="og:type" content="video" />') | ||||
|       expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/${servers[0].store.video.shortUUID}" />`) | ||||
|     } | ||||
| 
 | ||||
|     async function watchPlaylistPageTest (path: string) { | ||||
|       const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|       const text = res.text | ||||
| 
 | ||||
|       expect(text).to.contain(`<meta property="og:title" content="${playlistName}" />`) | ||||
|       expect(text).to.contain(`<meta property="og:description" content="${playlistDescription}" />`) | ||||
|       expect(text).to.contain('<meta property="og:type" content="video" />') | ||||
|       expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/p/${playlist.shortUUID}" />`) | ||||
|     } | ||||
| 
 | ||||
|     it('Should have valid Open Graph tags on the account page', async function () { | ||||
|       await accountPageTest('/accounts/' + servers[0].store.user.username) | ||||
|       await accountPageTest('/a/' + servers[0].store.user.username) | ||||
|       await accountPageTest('/@' + servers[0].store.user.username) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have valid Open Graph tags on the channel page', async function () { | ||||
|       await channelPageTest('/video-channels/' + servers[0].store.channel.name) | ||||
|       await channelPageTest('/c/' + servers[0].store.channel.name) | ||||
|       await channelPageTest('/@' + servers[0].store.channel.name) | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have valid Open Graph tags on the watch page', async function () { | ||||
|       for (const path of getWatchVideoBasePaths()) { | ||||
|         for (const id of videoIds) { | ||||
|           await watchVideoPageTest(path + id) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have valid Open Graph tags on the watch page with thread id Angular param', async function () { | ||||
|       for (const path of getWatchVideoBasePaths()) { | ||||
|         for (const id of videoIds) { | ||||
|           await watchVideoPageTest(path + id + ';threadId=1') | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     it('Should have valid Open Graph tags on the watch playlist page', async function () { | ||||
|       for (const path of getWatchPlaylistBasePaths()) { | ||||
|         for (const id of playlistIds) { | ||||
|           await watchPlaylistPageTest(path + id) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe('Twitter card', async function () { | ||||
| 
 | ||||
|     describe('Not whitelisted', function () { | ||||
| 
 | ||||
|       async function accountPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="summary" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />') | ||||
|         expect(text).to.contain(`<meta property="twitter:title" content="${account.name}" />`) | ||||
|         expect(text).to.contain(`<meta property="twitter:description" content="${account.description}" />`) | ||||
|       } | ||||
| 
 | ||||
|       async function channelPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="summary" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />') | ||||
|         expect(text).to.contain(`<meta property="twitter:title" content="${servers[0].store.channel.displayName}" />`) | ||||
|         expect(text).to.contain(`<meta property="twitter:description" content="${channelDescription}" />`) | ||||
|       } | ||||
| 
 | ||||
|       async function watchVideoPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="summary_large_image" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />') | ||||
|         expect(text).to.contain(`<meta property="twitter:title" content="${videoName}" />`) | ||||
|         expect(text).to.contain(`<meta property="twitter:description" content="${videoDescriptionPlainText}" />`) | ||||
|       } | ||||
| 
 | ||||
|       async function watchPlaylistPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="summary" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />') | ||||
|         expect(text).to.contain(`<meta property="twitter:title" content="${playlistName}" />`) | ||||
|         expect(text).to.contain(`<meta property="twitter:description" content="${playlistDescription}" />`) | ||||
|       } | ||||
| 
 | ||||
|       it('Should have valid twitter card on the watch video page', async function () { | ||||
|         for (const path of getWatchVideoBasePaths()) { | ||||
|           for (const id of videoIds) { | ||||
|             await watchVideoPageTest(path + id) | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       it('Should have valid twitter card on the watch playlist page', async function () { | ||||
|         for (const path of getWatchPlaylistBasePaths()) { | ||||
|           for (const id of playlistIds) { | ||||
|             await watchPlaylistPageTest(path + id) | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       it('Should have valid twitter card on the account page', async function () { | ||||
|         await accountPageTest('/accounts/' + account.name) | ||||
|         await accountPageTest('/a/' + account.name) | ||||
|         await accountPageTest('/@' + account.name) | ||||
|       }) | ||||
| 
 | ||||
|       it('Should have valid twitter card on the channel page', async function () { | ||||
|         await channelPageTest('/video-channels/' + servers[0].store.channel.name) | ||||
|         await channelPageTest('/c/' + servers[0].store.channel.name) | ||||
|         await channelPageTest('/@' + servers[0].store.channel.name) | ||||
|       }) | ||||
|     }) | ||||
| 
 | ||||
|     describe('Whitelisted', function () { | ||||
| 
 | ||||
|       before(async function () { | ||||
|         const config = await servers[0].config.getCustomConfig() | ||||
|         config.services.twitter = { | ||||
|           username: '@Kuja', | ||||
|           whitelisted: true | ||||
|         } | ||||
| 
 | ||||
|         await servers[0].config.updateCustomConfig({ newCustomConfig: config }) | ||||
|       }) | ||||
| 
 | ||||
|       async function accountPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="summary" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />') | ||||
|       } | ||||
| 
 | ||||
|       async function channelPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="summary" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />') | ||||
|       } | ||||
| 
 | ||||
|       async function watchVideoPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="player" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />') | ||||
|       } | ||||
| 
 | ||||
|       async function watchPlaylistPageTest (path: string) { | ||||
|         const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) | ||||
|         const text = res.text | ||||
| 
 | ||||
|         expect(text).to.contain('<meta property="twitter:card" content="player" />') | ||||
|         expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />') | ||||
|       } | ||||
| 
 | ||||
|       it('Should have valid twitter card on the watch video page', async function () { | ||||
|         for (const path of getWatchVideoBasePaths()) { | ||||
|           for (const id of videoIds) { | ||||
|             await watchVideoPageTest(path + id) | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       it('Should have valid twitter card on the watch playlist page', async function () { | ||||
|         for (const path of getWatchPlaylistBasePaths()) { | ||||
|           for (const id of playlistIds) { | ||||
|             await watchPlaylistPageTest(path + id) | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       it('Should have valid twitter card on the account page', async function () { | ||||
|         await accountPageTest('/accounts/' + account.name) | ||||
|         await accountPageTest('/a/' + account.name) | ||||
|         await accountPageTest('/@' + account.name) | ||||
|       }) | ||||
| 
 | ||||
|       it('Should have valid twitter card on the channel page', async function () { | ||||
|         await channelPageTest('/video-channels/' + servers[0].store.channel.name) | ||||
|         await channelPageTest('/c/' + servers[0].store.channel.name) | ||||
|         await channelPageTest('/@' + servers[0].store.channel.name) | ||||
|       }) | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   after(async function () { | ||||
|     await cleanupTests(servers) | ||||
|   }) | ||||
| }) | ||||
|  | @ -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() | ||||
| 
 | ||||
|  |  | |||
|  | @ -0,0 +1,181 @@ | |||
| import { omit } from '@peertube/peertube-core-utils' | ||||
| import { | ||||
|   VideoPrivacy, | ||||
|   VideoPlaylistPrivacy, | ||||
|   VideoPlaylistCreateResult, | ||||
|   Account, | ||||
|   HTMLServerConfig, | ||||
|   ServerConfig | ||||
| } from '@peertube/peertube-models' | ||||
| import { | ||||
|   createMultipleServers, | ||||
|   setAccessTokensToServers, | ||||
|   doubleFollow, | ||||
|   setDefaultVideoChannel, | ||||
|   waitJobs | ||||
| } from '@peertube/peertube-server-commands' | ||||
| import { expect } from 'chai' | ||||
| 
 | ||||
| export function getWatchVideoBasePaths () { | ||||
|   return [ '/videos/watch/', '/w/' ] | ||||
| } | ||||
| 
 | ||||
| export function getWatchPlaylistBasePaths () { | ||||
|   return [ '/videos/watch/playlist/', '/w/p/' ] | ||||
| } | ||||
| 
 | ||||
| export function checkIndexTags (html: string, title: string, description: string, css: string, config: ServerConfig) { | ||||
|   expect(html).to.contain('<title>' + title + '</title>') | ||||
|   expect(html).to.contain('<meta name="description" content="' + description + '" />') | ||||
| 
 | ||||
|   if (css) { | ||||
|     expect(html).to.contain('<style class="custom-css-style">' + css + '</style>') | ||||
|   } | ||||
| 
 | ||||
|   const htmlConfig: HTMLServerConfig = omit(config, [ 'signup' ]) | ||||
|   const configObjectString = JSON.stringify(htmlConfig) | ||||
|   const configEscapedString = JSON.stringify(configObjectString) | ||||
| 
 | ||||
|   expect(html).to.contain(`<script type="application/javascript">window.PeerTubeServerConfig = ${configEscapedString}</script>`) | ||||
| } | ||||
| 
 | ||||
| export async function prepareClientTests () { | ||||
|   const servers = await createMultipleServers(2) | ||||
| 
 | ||||
|   await setAccessTokensToServers(servers) | ||||
| 
 | ||||
|   await doubleFollow(servers[0], servers[1]) | ||||
| 
 | ||||
|   await setDefaultVideoChannel(servers) | ||||
| 
 | ||||
|   let account: Account | ||||
| 
 | ||||
|   let videoIds: (string | number)[] = [] | ||||
|   let privateVideoId: string | ||||
|   let internalVideoId: string | ||||
|   let unlistedVideoId: string | ||||
|   let passwordProtectedVideoId: string | ||||
| 
 | ||||
|   let playlistIds: (string | number)[] = [] | ||||
|   let privatePlaylistId: string | ||||
|   let unlistedPlaylistId: string | ||||
| 
 | ||||
|   const instanceDescription = 'PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.' | ||||
| 
 | ||||
|   const videoName = 'my super name for server 1' | ||||
|   const videoDescription = 'my<br> super __description__ for *server* 1<p></p>' | ||||
|   const videoDescriptionPlainText = 'my super description for server 1' | ||||
| 
 | ||||
|   const playlistName = 'super playlist name' | ||||
|   const playlistDescription = 'super playlist description' | ||||
|   let playlist: VideoPlaylistCreateResult | ||||
| 
 | ||||
|   const channelDescription = 'my super channel description' | ||||
| 
 | ||||
|   await servers[0].channels.update({ | ||||
|     channelName: servers[0].store.channel.name, | ||||
|     attributes: { description: channelDescription } | ||||
|   }) | ||||
| 
 | ||||
|   // Public video
 | ||||
| 
 | ||||
|   { | ||||
|     const attributes = { name: videoName, description: videoDescription } | ||||
|     await servers[0].videos.upload({ attributes }) | ||||
| 
 | ||||
|     const { data } = await servers[0].videos.list() | ||||
|     expect(data.length).to.equal(1) | ||||
| 
 | ||||
|     const video = data[0] | ||||
|     servers[0].store.video = video | ||||
|     videoIds = [ video.id, video.uuid, video.shortUUID ] | ||||
|   } | ||||
| 
 | ||||
|   { | ||||
|     ({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE })); | ||||
|     ({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED })); | ||||
|     ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL })); | ||||
|     ({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({ | ||||
|       name: 'password protected', | ||||
|       privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||||
|       videoPasswords: [ 'password' ] | ||||
|     })) | ||||
|   } | ||||
| 
 | ||||
|   // Playlists
 | ||||
|   { | ||||
|     // Public playlist
 | ||||
|     { | ||||
|       const attributes = { | ||||
|         displayName: playlistName, | ||||
|         description: playlistDescription, | ||||
|         privacy: VideoPlaylistPrivacy.PUBLIC, | ||||
|         videoChannelId: servers[0].store.channel.id | ||||
|       } | ||||
| 
 | ||||
|       playlist = await servers[0].playlists.create({ attributes }) | ||||
|       playlistIds = [ playlist.id, playlist.shortUUID, playlist.uuid ] | ||||
| 
 | ||||
|       await servers[0].playlists.addElement({ playlistId: playlist.shortUUID, attributes: { videoId: servers[0].store.video.id } }) | ||||
|     } | ||||
| 
 | ||||
|     // Unlisted playlist
 | ||||
|     { | ||||
|       const attributes = { | ||||
|         displayName: 'unlisted', | ||||
|         privacy: VideoPlaylistPrivacy.UNLISTED, | ||||
|         videoChannelId: servers[0].store.channel.id | ||||
|       } | ||||
| 
 | ||||
|       const { uuid } = await servers[0].playlists.create({ attributes }) | ||||
|       unlistedPlaylistId = uuid | ||||
|     } | ||||
| 
 | ||||
|     { | ||||
|       const attributes = { | ||||
|         displayName: 'private', | ||||
|         privacy: VideoPlaylistPrivacy.PRIVATE | ||||
|       } | ||||
| 
 | ||||
|       const { uuid } = await servers[0].playlists.create({ attributes }) | ||||
|       privatePlaylistId = uuid | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // Account
 | ||||
|   { | ||||
|     await servers[0].users.updateMe({ description: 'my account description' }) | ||||
| 
 | ||||
|     account = await servers[0].accounts.get({ accountName: `${servers[0].store.user.username}@${servers[0].host}` }) | ||||
|   } | ||||
| 
 | ||||
|   await waitJobs(servers) | ||||
| 
 | ||||
|   return { | ||||
|     servers, | ||||
| 
 | ||||
|     instanceDescription, | ||||
| 
 | ||||
|     account, | ||||
| 
 | ||||
|     channelDescription, | ||||
| 
 | ||||
|     playlist, | ||||
|     playlistName, | ||||
|     playlistIds, | ||||
|     playlistDescription, | ||||
| 
 | ||||
|     privatePlaylistId, | ||||
|     unlistedPlaylistId, | ||||
| 
 | ||||
|     privateVideoId, | ||||
|     unlistedVideoId, | ||||
|     internalVideoId, | ||||
|     passwordProtectedVideoId, | ||||
| 
 | ||||
|     videoName, | ||||
|     videoDescription, | ||||
|     videoDescriptionPlainText, | ||||
|     videoIds | ||||
|   } | ||||
| } | ||||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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() | ||||
| 
 | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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' | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -1,630 +0,0 @@ | |||
| import { buildFileLocale, escapeHTML, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '@peertube/peertube-core-utils' | ||||
| import { HTMLServerConfig, HttpStatusCode, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models' | ||||
| import { isTestOrDevInstance, root, sha256 } from '@peertube/peertube-node-utils' | ||||
| import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js' | ||||
| import { mdToOneLinePlainText } from '@server/helpers/markdown.js' | ||||
| import { ActorImageModel } from '@server/models/actor/actor-image.js' | ||||
| import express from 'express' | ||||
| import { pathExists } from 'fs-extra/esm' | ||||
| import { readFile } from 'fs/promises' | ||||
| import truncate from 'lodash-es/truncate.js' | ||||
| import { join } from 'path' | ||||
| import validator from 'validator' | ||||
| import { logger } from '../helpers/logger.js' | ||||
| import { CONFIG } from '../initializers/config.js' | ||||
| import { | ||||
|   ACCEPT_HEADERS, | ||||
|   CUSTOM_HTML_TAG_COMMENTS, | ||||
|   EMBED_SIZE, | ||||
|   FILES_CONTENT_HASH, | ||||
|   PLUGIN_GLOBAL_CSS_PATH, | ||||
|   WEBSERVER | ||||
| } from '../initializers/constants.js' | ||||
| import { AccountModel } from '../models/account/account.js' | ||||
| import { VideoChannelModel } from '../models/video/video-channel.js' | ||||
| import { VideoPlaylistModel } from '../models/video/video-playlist.js' | ||||
| import { VideoModel } from '../models/video/video.js' | ||||
| import { MAccountHost, MChannelHost, MVideo, MVideoPlaylist } from '../types/models/index.js' | ||||
| import { getActivityStreamDuration } from './activitypub/activity.js' | ||||
| import { getBiggestActorImage } from './actor-image.js' | ||||
| import { Hooks } from './plugins/hooks.js' | ||||
| import { ServerConfigManager } from './server-config-manager.js' | ||||
| import { isVideoInPrivateDirectory } from './video-privacy.js' | ||||
| 
 | ||||
| type Tags = { | ||||
|   ogType: string | ||||
|   twitterCard: 'player' | 'summary' | 'summary_large_image' | ||||
|   schemaType: string | ||||
| 
 | ||||
|   list?: { | ||||
|     numberOfItems: number | ||||
|   } | ||||
| 
 | ||||
|   escapedSiteName: string | ||||
|   escapedTitle: string | ||||
|   escapedTruncatedDescription: string | ||||
| 
 | ||||
|   url: string | ||||
|   originUrl: string | ||||
| 
 | ||||
|   indexationPolicy: 'always' | 'never' | ||||
| 
 | ||||
|   embed?: { | ||||
|     url: string | ||||
|     createdAt: string | ||||
|     duration?: string | ||||
|     views?: number | ||||
|   } | ||||
| 
 | ||||
|   image: { | ||||
|     url: string | ||||
|     width?: number | ||||
|     height?: number | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| type HookContext = { | ||||
|   video?: MVideo | ||||
|   playlist?: MVideoPlaylist | ||||
| } | ||||
| 
 | ||||
| class ClientHtml { | ||||
| 
 | ||||
|   private static htmlCache: { [path: string]: string } = {} | ||||
| 
 | ||||
|   static invalidCache () { | ||||
|     logger.info('Cleaning HTML cache.') | ||||
| 
 | ||||
|     ClientHtml.htmlCache = {} | ||||
|   } | ||||
| 
 | ||||
|   static async getDefaultHTMLPage (req: express.Request, res: express.Response, paramLang?: string) { | ||||
|     const html = paramLang | ||||
|       ? await ClientHtml.getIndexHTML(req, res, paramLang) | ||||
|       : await ClientHtml.getIndexHTML(req, res) | ||||
| 
 | ||||
|     let customHtml = ClientHtml.addTitleTag(html) | ||||
|     customHtml = ClientHtml.addDescriptionTag(customHtml) | ||||
| 
 | ||||
|     return customHtml | ||||
|   } | ||||
| 
 | ||||
|   static async getWatchHTMLPage (videoIdArg: string, req: express.Request, res: express.Response) { | ||||
|     const videoId = toCompleteUUID(videoIdArg) | ||||
| 
 | ||||
|     // Let Angular application handle errors
 | ||||
|     if (!validator.default.isInt(videoId) && !validator.default.isUUID(videoId, 4)) { | ||||
|       res.status(HttpStatusCode.NOT_FOUND_404) | ||||
|       return ClientHtml.getIndexHTML(req, res) | ||||
|     } | ||||
| 
 | ||||
|     const [ html, video ] = await Promise.all([ | ||||
|       ClientHtml.getIndexHTML(req, res), | ||||
|       VideoModel.loadWithBlacklist(videoId) | ||||
|     ]) | ||||
| 
 | ||||
|     // Let Angular application handle errors
 | ||||
|     if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) { | ||||
|       res.status(HttpStatusCode.NOT_FOUND_404) | ||||
|       return html | ||||
|     } | ||||
|     const escapedTruncatedDescription = buildEscapedTruncatedDescription(video.description) | ||||
| 
 | ||||
|     let customHtml = ClientHtml.addTitleTag(html, video.name) | ||||
|     customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription) | ||||
| 
 | ||||
|     const url = WEBSERVER.URL + video.getWatchStaticPath() | ||||
|     const originUrl = video.url | ||||
|     const title = video.name | ||||
|     const siteName = CONFIG.INSTANCE.NAME | ||||
| 
 | ||||
|     const image = { | ||||
|       url: WEBSERVER.URL + video.getPreviewStaticPath() | ||||
|     } | ||||
| 
 | ||||
|     const embed = { | ||||
|       url: WEBSERVER.URL + video.getEmbedStaticPath(), | ||||
|       createdAt: video.createdAt.toISOString(), | ||||
|       duration: getActivityStreamDuration(video.duration), | ||||
|       views: video.views | ||||
|     } | ||||
| 
 | ||||
|     const ogType = 'video' | ||||
|     const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary_large_image' | ||||
|     const schemaType = 'VideoObject' | ||||
| 
 | ||||
|     customHtml = await ClientHtml.addTags(customHtml, { | ||||
|       url, | ||||
|       originUrl, | ||||
|       escapedSiteName: escapeHTML(siteName), | ||||
|       escapedTitle: escapeHTML(title), | ||||
|       escapedTruncatedDescription, | ||||
| 
 | ||||
|       indexationPolicy: video.privacy !== VideoPrivacy.PUBLIC | ||||
|         ? 'never' | ||||
|         : 'always', | ||||
| 
 | ||||
|       image, | ||||
|       embed, | ||||
|       ogType, | ||||
|       twitterCard, | ||||
|       schemaType | ||||
|     }, { video }) | ||||
| 
 | ||||
|     return customHtml | ||||
|   } | ||||
| 
 | ||||
|   static async getWatchPlaylistHTMLPage (videoPlaylistIdArg: string, req: express.Request, res: express.Response) { | ||||
|     const videoPlaylistId = toCompleteUUID(videoPlaylistIdArg) | ||||
| 
 | ||||
|     // Let Angular application handle errors
 | ||||
|     if (!validator.default.isInt(videoPlaylistId) && !validator.default.isUUID(videoPlaylistId, 4)) { | ||||
|       res.status(HttpStatusCode.NOT_FOUND_404) | ||||
|       return ClientHtml.getIndexHTML(req, res) | ||||
|     } | ||||
| 
 | ||||
|     const [ html, videoPlaylist ] = await Promise.all([ | ||||
|       ClientHtml.getIndexHTML(req, res), | ||||
|       VideoPlaylistModel.loadWithAccountAndChannel(videoPlaylistId, null) | ||||
|     ]) | ||||
| 
 | ||||
|     // Let Angular application handle errors
 | ||||
|     if (!videoPlaylist || videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) { | ||||
|       res.status(HttpStatusCode.NOT_FOUND_404) | ||||
|       return html | ||||
|     } | ||||
| 
 | ||||
|     const escapedTruncatedDescription = buildEscapedTruncatedDescription(videoPlaylist.description) | ||||
| 
 | ||||
|     let customHtml = ClientHtml.addTitleTag(html, videoPlaylist.name) | ||||
|     customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription) | ||||
| 
 | ||||
|     const url = WEBSERVER.URL + videoPlaylist.getWatchStaticPath() | ||||
|     const originUrl = videoPlaylist.url | ||||
|     const title = videoPlaylist.name | ||||
|     const siteName = CONFIG.INSTANCE.NAME | ||||
| 
 | ||||
|     const image = { | ||||
|       url: videoPlaylist.getThumbnailUrl() | ||||
|     } | ||||
| 
 | ||||
|     const embed = { | ||||
|       url: WEBSERVER.URL + videoPlaylist.getEmbedStaticPath(), | ||||
|       createdAt: videoPlaylist.createdAt.toISOString() | ||||
|     } | ||||
| 
 | ||||
|     const list = { | ||||
|       numberOfItems: videoPlaylist.get('videosLength') as number | ||||
|     } | ||||
| 
 | ||||
|     const ogType = 'video' | ||||
|     const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary' | ||||
|     const schemaType = 'ItemList' | ||||
| 
 | ||||
|     customHtml = await ClientHtml.addTags(customHtml, { | ||||
|       url, | ||||
|       originUrl, | ||||
|       escapedSiteName: escapeHTML(siteName), | ||||
|       escapedTitle: escapeHTML(title), | ||||
|       escapedTruncatedDescription, | ||||
| 
 | ||||
|       indexationPolicy: videoPlaylist.privacy !== VideoPlaylistPrivacy.PUBLIC | ||||
|         ? 'never' | ||||
|         : 'always', | ||||
| 
 | ||||
|       embed, | ||||
|       image, | ||||
|       list, | ||||
|       ogType, | ||||
|       twitterCard, | ||||
|       schemaType | ||||
|     }, { playlist: videoPlaylist }) | ||||
| 
 | ||||
|     return customHtml | ||||
|   } | ||||
| 
 | ||||
|   static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) { | ||||
|     const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost) | ||||
|     return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res) | ||||
|   } | ||||
| 
 | ||||
|   static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) { | ||||
|     const videoChannelModelPromise = VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost) | ||||
|     return this.getAccountOrChannelHTMLPage(() => videoChannelModelPromise, req, res) | ||||
|   } | ||||
| 
 | ||||
|   static async getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) { | ||||
|     const [ account, channel ] = await Promise.all([ | ||||
|       AccountModel.loadByNameWithHost(nameWithHost), | ||||
|       VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost) | ||||
|     ]) | ||||
| 
 | ||||
|     return this.getAccountOrChannelHTMLPage(() => Promise.resolve(account || channel), req, res) | ||||
|   } | ||||
| 
 | ||||
|   static async getEmbedHTML () { | ||||
|     const path = ClientHtml.getEmbedPath() | ||||
| 
 | ||||
|     // Disable HTML cache in dev mode because webpack can regenerate JS files
 | ||||
|     if (!isTestOrDevInstance() && ClientHtml.htmlCache[path]) { | ||||
|       return ClientHtml.htmlCache[path] | ||||
|     } | ||||
| 
 | ||||
|     const buffer = await readFile(path) | ||||
|     const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig() | ||||
| 
 | ||||
|     let html = buffer.toString() | ||||
|     html = await ClientHtml.addAsyncPluginCSS(html) | ||||
|     html = ClientHtml.addCustomCSS(html) | ||||
|     html = ClientHtml.addTitleTag(html) | ||||
|     html = ClientHtml.addDescriptionTag(html) | ||||
|     html = ClientHtml.addServerConfig(html, serverConfig) | ||||
| 
 | ||||
|     ClientHtml.htmlCache[path] = html | ||||
| 
 | ||||
|     return html | ||||
|   } | ||||
| 
 | ||||
|   private static async getAccountOrChannelHTMLPage ( | ||||
|     loader: () => Promise<MAccountHost | MChannelHost>, | ||||
|     req: express.Request, | ||||
|     res: express.Response | ||||
|   ) { | ||||
|     const [ html, entity ] = await Promise.all([ | ||||
|       ClientHtml.getIndexHTML(req, res), | ||||
|       loader() | ||||
|     ]) | ||||
| 
 | ||||
|     // Let Angular application handle errors
 | ||||
|     if (!entity) { | ||||
|       res.status(HttpStatusCode.NOT_FOUND_404) | ||||
|       return ClientHtml.getIndexHTML(req, res) | ||||
|     } | ||||
| 
 | ||||
|     const escapedTruncatedDescription = buildEscapedTruncatedDescription(entity.description) | ||||
| 
 | ||||
|     let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName()) | ||||
|     customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription) | ||||
| 
 | ||||
|     const url = entity.getClientUrl() | ||||
|     const originUrl = entity.Actor.url | ||||
|     const siteName = CONFIG.INSTANCE.NAME | ||||
|     const title = entity.getDisplayName() | ||||
| 
 | ||||
|     const avatar = getBiggestActorImage(entity.Actor.Avatars) | ||||
|     const image = { | ||||
|       url: ActorImageModel.getImageUrl(avatar), | ||||
|       width: avatar?.width, | ||||
|       height: avatar?.height | ||||
|     } | ||||
| 
 | ||||
|     const ogType = 'website' | ||||
|     const twitterCard = 'summary' | ||||
|     const schemaType = 'ProfilePage' | ||||
| 
 | ||||
|     customHtml = await ClientHtml.addTags(customHtml, { | ||||
|       url, | ||||
|       originUrl, | ||||
|       escapedTitle: escapeHTML(title), | ||||
|       escapedSiteName: escapeHTML(siteName), | ||||
|       escapedTruncatedDescription, | ||||
|       image, | ||||
|       ogType, | ||||
|       twitterCard, | ||||
|       schemaType, | ||||
| 
 | ||||
|       indexationPolicy: entity.Actor.isOwned() | ||||
|         ? 'always' | ||||
|         : 'never' | ||||
|     }, {}) | ||||
| 
 | ||||
|     return customHtml | ||||
|   } | ||||
| 
 | ||||
|   private static async getIndexHTML (req: express.Request, res: express.Response, paramLang?: string) { | ||||
|     const path = ClientHtml.getIndexPath(req, res, paramLang) | ||||
|     if (ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] | ||||
| 
 | ||||
|     const buffer = await readFile(path) | ||||
|     const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig() | ||||
| 
 | ||||
|     let html = buffer.toString() | ||||
| 
 | ||||
|     html = ClientHtml.addManifestContentHash(html) | ||||
|     html = ClientHtml.addFaviconContentHash(html) | ||||
|     html = ClientHtml.addLogoContentHash(html) | ||||
|     html = ClientHtml.addCustomCSS(html) | ||||
|     html = ClientHtml.addServerConfig(html, serverConfig) | ||||
|     html = await ClientHtml.addAsyncPluginCSS(html) | ||||
| 
 | ||||
|     ClientHtml.htmlCache[path] = html | ||||
| 
 | ||||
|     return html | ||||
|   } | ||||
| 
 | ||||
|   private static getIndexPath (req: express.Request, res: express.Response, paramLang: string) { | ||||
|     let lang: string | ||||
| 
 | ||||
|     // Check param lang validity
 | ||||
|     if (paramLang && is18nLocale(paramLang)) { | ||||
|       lang = paramLang | ||||
| 
 | ||||
|       // Save locale in cookies
 | ||||
|       res.cookie('clientLanguage', lang, { | ||||
|         secure: WEBSERVER.SCHEME === 'https', | ||||
|         sameSite: 'none', | ||||
|         maxAge: 1000 * 3600 * 24 * 90 // 3 months
 | ||||
|       }) | ||||
| 
 | ||||
|     } else if (req.cookies.clientLanguage && is18nLocale(req.cookies.clientLanguage)) { | ||||
|       lang = req.cookies.clientLanguage | ||||
|     } else { | ||||
|       lang = req.acceptsLanguages(POSSIBLE_LOCALES) || getDefaultLocale() | ||||
|     } | ||||
| 
 | ||||
|     logger.debug( | ||||
|       'Serving %s HTML language', buildFileLocale(lang), | ||||
|       { cookie: req.cookies?.clientLanguage, paramLang, acceptLanguage: req.headers['accept-language'] } | ||||
|     ) | ||||
| 
 | ||||
|     return join(root(), 'client', 'dist', buildFileLocale(lang), 'index.html') | ||||
|   } | ||||
| 
 | ||||
|   private static getEmbedPath () { | ||||
|     return join(root(), 'client', 'dist', 'standalone', 'videos', 'embed.html') | ||||
|   } | ||||
| 
 | ||||
|   private static addManifestContentHash (htmlStringPage: string) { | ||||
|     return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST) | ||||
|   } | ||||
| 
 | ||||
|   private static addFaviconContentHash (htmlStringPage: string) { | ||||
|     return htmlStringPage.replace('[faviconContentHash]', FILES_CONTENT_HASH.FAVICON) | ||||
|   } | ||||
| 
 | ||||
|   private static addLogoContentHash (htmlStringPage: string) { | ||||
|     return htmlStringPage.replace('[logoContentHash]', FILES_CONTENT_HASH.LOGO) | ||||
|   } | ||||
| 
 | ||||
|   private static addTitleTag (htmlStringPage: string, title?: string) { | ||||
|     let text = title || CONFIG.INSTANCE.NAME | ||||
|     if (title) text += ` - ${CONFIG.INSTANCE.NAME}` | ||||
| 
 | ||||
|     const titleTag = `<title>${escapeHTML(text)}</title>` | ||||
| 
 | ||||
|     return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.TITLE, titleTag) | ||||
|   } | ||||
| 
 | ||||
|   private static addDescriptionTag (htmlStringPage: string, escapedTruncatedDescription?: string) { | ||||
|     const content = escapedTruncatedDescription || escapeHTML(CONFIG.INSTANCE.SHORT_DESCRIPTION) | ||||
|     const descriptionTag = `<meta name="description" content="${content}" />` | ||||
| 
 | ||||
|     return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag) | ||||
|   } | ||||
| 
 | ||||
|   private static addCustomCSS (htmlStringPage: string) { | ||||
|     const styleTag = `<style class="custom-css-style">${CONFIG.INSTANCE.CUSTOMIZATIONS.CSS}</style>` | ||||
| 
 | ||||
|     return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag) | ||||
|   } | ||||
| 
 | ||||
|   private static addServerConfig (htmlStringPage: string, serverConfig: HTMLServerConfig) { | ||||
|     // Stringify the JSON object, and then stringify the string object so we can inject it into the HTML
 | ||||
|     const serverConfigString = JSON.stringify(JSON.stringify(serverConfig)) | ||||
|     const configScriptTag = `<script type="application/javascript">window.PeerTubeServerConfig = ${serverConfigString}</script>` | ||||
| 
 | ||||
|     return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.SERVER_CONFIG, configScriptTag) | ||||
|   } | ||||
| 
 | ||||
|   private static async addAsyncPluginCSS (htmlStringPage: string) { | ||||
|     if (!await pathExists(PLUGIN_GLOBAL_CSS_PATH)) { | ||||
|       logger.info('Plugin Global CSS file is not available (generation may still be in progress), ignoring it.') | ||||
|       return htmlStringPage | ||||
|     } | ||||
| 
 | ||||
|     let globalCSSContent: Buffer | ||||
| 
 | ||||
|     try { | ||||
|       globalCSSContent = await readFile(PLUGIN_GLOBAL_CSS_PATH) | ||||
|     } catch (err) { | ||||
|       logger.error('Error retrieving the Plugin Global CSS file, ignoring it.', { err }) | ||||
|       return htmlStringPage | ||||
|     } | ||||
| 
 | ||||
|     if (globalCSSContent.byteLength === 0) return htmlStringPage | ||||
| 
 | ||||
|     const fileHash = sha256(globalCSSContent) | ||||
|     const linkTag = `<link rel="stylesheet" href="/plugins/global.css?hash=${fileHash}" />` | ||||
| 
 | ||||
|     return htmlStringPage.replace('</head>', linkTag + '</head>') | ||||
|   } | ||||
| 
 | ||||
|   private static generateOpenGraphMetaTags (tags: Tags) { | ||||
|     const metaTags = { | ||||
|       'og:type': tags.ogType, | ||||
|       'og:site_name': tags.escapedSiteName, | ||||
|       'og:title': tags.escapedTitle, | ||||
|       'og:image': tags.image.url | ||||
|     } | ||||
| 
 | ||||
|     if (tags.image.width && tags.image.height) { | ||||
|       metaTags['og:image:width'] = tags.image.width | ||||
|       metaTags['og:image:height'] = tags.image.height | ||||
|     } | ||||
| 
 | ||||
|     metaTags['og:url'] = tags.url | ||||
|     metaTags['og:description'] = tags.escapedTruncatedDescription | ||||
| 
 | ||||
|     if (tags.embed) { | ||||
|       metaTags['og:video:url'] = tags.embed.url | ||||
|       metaTags['og:video:secure_url'] = tags.embed.url | ||||
|       metaTags['og:video:type'] = 'text/html' | ||||
|       metaTags['og:video:width'] = EMBED_SIZE.width | ||||
|       metaTags['og:video:height'] = EMBED_SIZE.height | ||||
|     } | ||||
| 
 | ||||
|     return metaTags | ||||
|   } | ||||
| 
 | ||||
|   private static generateStandardMetaTags (tags: Tags) { | ||||
|     return { | ||||
|       name: tags.escapedTitle, | ||||
|       description: tags.escapedTruncatedDescription, | ||||
|       image: tags.image.url | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private static generateTwitterCardMetaTags (tags: Tags) { | ||||
|     const metaTags = { | ||||
|       'twitter:card': tags.twitterCard, | ||||
|       'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME, | ||||
|       'twitter:title': tags.escapedTitle, | ||||
|       'twitter:description': tags.escapedTruncatedDescription, | ||||
|       'twitter:image': tags.image.url | ||||
|     } | ||||
| 
 | ||||
|     if (tags.image.width && tags.image.height) { | ||||
|       metaTags['twitter:image:width'] = tags.image.width | ||||
|       metaTags['twitter:image:height'] = tags.image.height | ||||
|     } | ||||
| 
 | ||||
|     if (tags.twitterCard === 'player') { | ||||
|       metaTags['twitter:player'] = tags.embed.url | ||||
|       metaTags['twitter:player:width'] = EMBED_SIZE.width | ||||
|       metaTags['twitter:player:height'] = EMBED_SIZE.height | ||||
|     } | ||||
| 
 | ||||
|     return metaTags | ||||
|   } | ||||
| 
 | ||||
|   private static async generateSchemaTags (tags: Tags, context: HookContext) { | ||||
|     const schema = { | ||||
|       '@context': 'http://schema.org', | ||||
|       '@type': tags.schemaType, | ||||
|       'name': tags.escapedTitle, | ||||
|       'description': tags.escapedTruncatedDescription, | ||||
|       'image': tags.image.url, | ||||
|       'url': tags.url | ||||
|     } | ||||
| 
 | ||||
|     if (tags.list) { | ||||
|       schema['numberOfItems'] = tags.list.numberOfItems | ||||
|       schema['thumbnailUrl'] = tags.image.url | ||||
|     } | ||||
| 
 | ||||
|     if (tags.embed) { | ||||
|       schema['embedUrl'] = tags.embed.url | ||||
|       schema['uploadDate'] = tags.embed.createdAt | ||||
| 
 | ||||
|       if (tags.embed.duration) schema['duration'] = tags.embed.duration | ||||
| 
 | ||||
|       schema['thumbnailUrl'] = tags.image.url | ||||
|       schema['contentUrl'] = tags.url | ||||
|     } | ||||
| 
 | ||||
|     return Hooks.wrapObject(schema, 'filter:html.client.json-ld.result', context) | ||||
|   } | ||||
| 
 | ||||
|   private static async addTags (htmlStringPage: string, tagsValues: Tags, context: HookContext) { | ||||
|     const openGraphMetaTags = this.generateOpenGraphMetaTags(tagsValues) | ||||
|     const standardMetaTags = this.generateStandardMetaTags(tagsValues) | ||||
|     const twitterCardMetaTags = this.generateTwitterCardMetaTags(tagsValues) | ||||
|     const schemaTags = await this.generateSchemaTags(tagsValues, context) | ||||
| 
 | ||||
|     const { url, escapedTitle, embed, originUrl, indexationPolicy } = tagsValues | ||||
| 
 | ||||
|     const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = [] | ||||
| 
 | ||||
|     if (embed) { | ||||
|       oembedLinkTags.push({ | ||||
|         type: 'application/json+oembed', | ||||
|         href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(url), | ||||
|         escapedTitle | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     let tagsStr = '' | ||||
| 
 | ||||
|     // Opengraph
 | ||||
|     Object.keys(openGraphMetaTags).forEach(tagName => { | ||||
|       const tagValue = openGraphMetaTags[tagName] | ||||
| 
 | ||||
|       tagsStr += `<meta property="${tagName}" content="${tagValue}" />` | ||||
|     }) | ||||
| 
 | ||||
|     // Standard
 | ||||
|     Object.keys(standardMetaTags).forEach(tagName => { | ||||
|       const tagValue = standardMetaTags[tagName] | ||||
| 
 | ||||
|       tagsStr += `<meta property="${tagName}" content="${tagValue}" />` | ||||
|     }) | ||||
| 
 | ||||
|     // Twitter card
 | ||||
|     Object.keys(twitterCardMetaTags).forEach(tagName => { | ||||
|       const tagValue = twitterCardMetaTags[tagName] | ||||
| 
 | ||||
|       tagsStr += `<meta property="${tagName}" content="${tagValue}" />` | ||||
|     }) | ||||
| 
 | ||||
|     // OEmbed
 | ||||
|     for (const oembedLinkTag of oembedLinkTags) { | ||||
|       tagsStr += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.escapedTitle}" />` | ||||
|     } | ||||
| 
 | ||||
|     // Schema.org
 | ||||
|     if (schemaTags) { | ||||
|       tagsStr += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>` | ||||
|     } | ||||
| 
 | ||||
|     // SEO, use origin URL
 | ||||
|     tagsStr += `<link rel="canonical" href="${originUrl}" />` | ||||
| 
 | ||||
|     if (indexationPolicy === 'never') { | ||||
|       tagsStr += `<meta name="robots" content="noindex" />` | ||||
|     } | ||||
| 
 | ||||
|     return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsStr) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function sendHTML (html: string, res: express.Response, localizedHTML: boolean = false) { | ||||
|   res.set('Content-Type', 'text/html; charset=UTF-8') | ||||
| 
 | ||||
|   if (localizedHTML) { | ||||
|     res.set('Vary', 'Accept-Language') | ||||
|   } | ||||
| 
 | ||||
|   return res.send(html) | ||||
| } | ||||
| 
 | ||||
| async function serveIndexHTML (req: express.Request, res: express.Response) { | ||||
|   if (req.accepts(ACCEPT_HEADERS) === 'html' || !req.headers.accept) { | ||||
|     try { | ||||
|       await generateHTMLPage(req, res, req.params.language) | ||||
|       return | ||||
|     } catch (err) { | ||||
|       logger.error('Cannot generate HTML page.', { err }) | ||||
|       return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end() | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return res.status(HttpStatusCode.NOT_ACCEPTABLE_406).end() | ||||
| } | ||||
| 
 | ||||
| // ---------------------------------------------------------------------------
 | ||||
| 
 | ||||
| export { | ||||
|   ClientHtml, | ||||
|   sendHTML, | ||||
|   serveIndexHTML | ||||
| } | ||||
| 
 | ||||
| async function generateHTMLPage (req: express.Request, res: express.Response, paramLang?: string) { | ||||
|   const html = await ClientHtml.getDefaultHTMLPage(req, res, paramLang) | ||||
| 
 | ||||
|   return sendHTML(html, res, true) | ||||
| } | ||||
| 
 | ||||
| function buildEscapedTruncatedDescription (description: string) { | ||||
|   return truncate(mdToOneLinePlainText(description), { length: 200 }) | ||||
| } | ||||
|  | @ -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) | ||||
| } | ||||
|  | @ -0,0 +1,91 @@ | |||
| import { escapeHTML } from '@peertube/peertube-core-utils' | ||||
| import { HttpStatusCode } from '@peertube/peertube-models' | ||||
| import express from 'express' | ||||
| import { CONFIG } from '../../../initializers/config.js' | ||||
| import { AccountModel } from '@server/models/account/account.js' | ||||
| import { VideoChannelModel } from '@server/models/video/video-channel.js' | ||||
| import { MAccountHost, MChannelHost } from '@server/types/models/index.js' | ||||
| import { getBiggestActorImage } from '@server/lib/actor-image.js' | ||||
| import { ActorImageModel } from '@server/models/actor/actor-image.js' | ||||
| import { TagsHtml } from './tags-html.js' | ||||
| import { PageHtml } from './page-html.js' | ||||
| 
 | ||||
| export class ActorHtml { | ||||
| 
 | ||||
|   static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) { | ||||
|     const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost) | ||||
| 
 | ||||
|     return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res) | ||||
|   } | ||||
| 
 | ||||
|   static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) { | ||||
|     const videoChannelModelPromise = VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost) | ||||
| 
 | ||||
|     return this.getAccountOrChannelHTMLPage(() => videoChannelModelPromise, req, res) | ||||
|   } | ||||
| 
 | ||||
|   static async getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) { | ||||
|     const [ account, channel ] = await Promise.all([ | ||||
|       AccountModel.loadByNameWithHost(nameWithHost), | ||||
|       VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost) | ||||
|     ]) | ||||
| 
 | ||||
|     return this.getAccountOrChannelHTMLPage(() => Promise.resolve(account || channel), req, res) | ||||
|   } | ||||
| 
 | ||||
|   // ---------------------------------------------------------------------------
 | ||||
| 
 | ||||
|   private static async getAccountOrChannelHTMLPage ( | ||||
|     loader: () => Promise<MAccountHost | MChannelHost>, | ||||
|     req: express.Request, | ||||
|     res: express.Response | ||||
|   ) { | ||||
|     const [ html, entity ] = await Promise.all([ | ||||
|       PageHtml.getIndexHTML(req, res), | ||||
|       loader() | ||||
|     ]) | ||||
| 
 | ||||
|     // Let Angular application handle errors
 | ||||
|     if (!entity) { | ||||
|       res.status(HttpStatusCode.NOT_FOUND_404) | ||||
|       return PageHtml.getIndexHTML(req, res) | ||||
|     } | ||||
| 
 | ||||
|     const escapedTruncatedDescription = TagsHtml.buildEscapedTruncatedDescription(entity.description) | ||||
| 
 | ||||
|     let customHTML = TagsHtml.addTitleTag(html, entity.getDisplayName()) | ||||
|     customHTML = TagsHtml.addDescriptionTag(customHTML, escapedTruncatedDescription) | ||||
| 
 | ||||
|     const url = entity.getClientUrl() | ||||
|     const siteName = CONFIG.INSTANCE.NAME | ||||
|     const title = entity.getDisplayName() | ||||
| 
 | ||||
|     const avatar = getBiggestActorImage(entity.Actor.Avatars) | ||||
|     const image = { | ||||
|       url: ActorImageModel.getImageUrl(avatar), | ||||
|       width: avatar?.width, | ||||
|       height: avatar?.height | ||||
|     } | ||||
| 
 | ||||
|     const ogType = 'website' | ||||
|     const twitterCard = 'summary' | ||||
|     const schemaType = 'ProfilePage' | ||||
| 
 | ||||
|     customHTML = await TagsHtml.addTags(customHTML, { | ||||
|       url, | ||||
|       escapedTitle: escapeHTML(title), | ||||
|       escapedSiteName: escapeHTML(siteName), | ||||
|       escapedTruncatedDescription, | ||||
|       image, | ||||
|       ogType, | ||||
|       twitterCard, | ||||
|       schemaType, | ||||
| 
 | ||||
|       indexationPolicy: entity.Actor.isOwned() | ||||
|         ? 'always' | ||||
|         : 'never' | ||||
|     }, {}) | ||||
| 
 | ||||
|     return customHTML | ||||
|   } | ||||
| } | ||||
|  | @ -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 }) | ||||
|   } | ||||
| } | ||||
|  | @ -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' | ||||
|  | @ -0,0 +1,166 @@ | |||
| import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '@peertube/peertube-core-utils' | ||||
| import { isTestOrDevInstance, root, sha256 } from '@peertube/peertube-node-utils' | ||||
| import express from 'express' | ||||
| import { readFile } from 'fs/promises' | ||||
| import { join } from 'path' | ||||
| import { logger } from '../../../helpers/logger.js' | ||||
| import { CUSTOM_HTML_TAG_COMMENTS, FILES_CONTENT_HASH, PLUGIN_GLOBAL_CSS_PATH, WEBSERVER } from '../../../initializers/constants.js' | ||||
| import { ServerConfigManager } from '../../server-config-manager.js' | ||||
| import { TagsHtml } from './tags-html.js' | ||||
| import { pathExists } from 'fs-extra/esm' | ||||
| import { HTMLServerConfig } from '@peertube/peertube-models' | ||||
| import { CONFIG } from '@server/initializers/config.js' | ||||
| 
 | ||||
| export class PageHtml { | ||||
| 
 | ||||
|   private static htmlCache: { [path: string]: string } = {} | ||||
| 
 | ||||
|   static invalidateCache () { | ||||
|     logger.info('Cleaning HTML cache.') | ||||
| 
 | ||||
|     this.htmlCache = {} | ||||
|   } | ||||
| 
 | ||||
|   static async getDefaultHTML (req: express.Request, res: express.Response, paramLang?: string) { | ||||
|     const html = paramLang | ||||
|       ? await this.getIndexHTML(req, res, paramLang) | ||||
|       : await this.getIndexHTML(req, res) | ||||
| 
 | ||||
|     let customHTML = TagsHtml.addTitleTag(html) | ||||
|     customHTML = TagsHtml.addDescriptionTag(customHTML) | ||||
| 
 | ||||
|     return customHTML | ||||
|   } | ||||
| 
 | ||||
|   static async getEmbedHTML () { | ||||
|     const path = this.getEmbedHTMLPath() | ||||
| 
 | ||||
|     // Disable HTML cache in dev mode because webpack can regenerate JS files
 | ||||
|     if (!isTestOrDevInstance() && this.htmlCache[path]) { | ||||
|       return this.htmlCache[path] | ||||
|     } | ||||
| 
 | ||||
|     const buffer = await readFile(path) | ||||
|     const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig() | ||||
| 
 | ||||
|     let html = buffer.toString() | ||||
|     html = await this.addAsyncPluginCSS(html) | ||||
|     html = this.addCustomCSS(html) | ||||
|     html = this.addServerConfig(html, serverConfig) | ||||
| 
 | ||||
|     this.htmlCache[path] = html | ||||
| 
 | ||||
|     return html | ||||
|   } | ||||
| 
 | ||||
|   // ---------------------------------------------------------------------------
 | ||||
| 
 | ||||
|   static async getIndexHTML (req: express.Request, res: express.Response, paramLang?: string) { | ||||
|     const path = this.getIndexHTMLPath(req, res, paramLang) | ||||
|     if (this.htmlCache[path]) return this.htmlCache[path] | ||||
| 
 | ||||
|     const buffer = await readFile(path) | ||||
|     const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig() | ||||
| 
 | ||||
|     let html = buffer.toString() | ||||
| 
 | ||||
|     html = this.addManifestContentHash(html) | ||||
|     html = this.addFaviconContentHash(html) | ||||
|     html = this.addLogoContentHash(html) | ||||
| 
 | ||||
|     html = this.addCustomCSS(html) | ||||
|     html = this.addServerConfig(html, serverConfig) | ||||
|     html = await this.addAsyncPluginCSS(html) | ||||
| 
 | ||||
|     this.htmlCache[path] = html | ||||
| 
 | ||||
|     return html | ||||
|   } | ||||
| 
 | ||||
|   // ---------------------------------------------------------------------------
 | ||||
|   // Private
 | ||||
|   // ---------------------------------------------------------------------------
 | ||||
| 
 | ||||
|   private static getEmbedHTMLPath () { | ||||
|     return join(root(), 'client', 'dist', 'standalone', 'videos', 'embed.html') | ||||
|   } | ||||
| 
 | ||||
|   private static getIndexHTMLPath (req: express.Request, res: express.Response, paramLang: string) { | ||||
|     let lang: string | ||||
| 
 | ||||
|     // Check param lang validity
 | ||||
|     if (paramLang && is18nLocale(paramLang)) { | ||||
|       lang = paramLang | ||||
| 
 | ||||
|       // Save locale in cookies
 | ||||
|       res.cookie('clientLanguage', lang, { | ||||
|         secure: WEBSERVER.SCHEME === 'https', | ||||
|         sameSite: 'none', | ||||
|         maxAge: 1000 * 3600 * 24 * 90 // 3 months
 | ||||
|       }) | ||||
| 
 | ||||
|     } else if (req.cookies.clientLanguage && is18nLocale(req.cookies.clientLanguage)) { | ||||
|       lang = req.cookies.clientLanguage | ||||
|     } else { | ||||
|       lang = req.acceptsLanguages(POSSIBLE_LOCALES) || getDefaultLocale() | ||||
|     } | ||||
| 
 | ||||
|     logger.debug( | ||||
|       'Serving %s HTML language', buildFileLocale(lang), | ||||
|       { cookie: req.cookies?.clientLanguage, paramLang, acceptLanguage: req.headers['accept-language'] } | ||||
|     ) | ||||
| 
 | ||||
|     return join(root(), 'client', 'dist', buildFileLocale(lang), 'index.html') | ||||
|   } | ||||
| 
 | ||||
|   // ---------------------------------------------------------------------------
 | ||||
| 
 | ||||
|   static addCustomCSS (htmlStringPage: string) { | ||||
|     const styleTag = `<style class="custom-css-style">${CONFIG.INSTANCE.CUSTOMIZATIONS.CSS}</style>` | ||||
| 
 | ||||
|     return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag) | ||||
|   } | ||||
| 
 | ||||
|   static addServerConfig (htmlStringPage: string, serverConfig: HTMLServerConfig) { | ||||
|     // Stringify the JSON object, and then stringify the string object so we can inject it into the HTML
 | ||||
|     const serverConfigString = JSON.stringify(JSON.stringify(serverConfig)) | ||||
|     const configScriptTag = `<script type="application/javascript">window.PeerTubeServerConfig = ${serverConfigString}</script>` | ||||
| 
 | ||||
|     return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.SERVER_CONFIG, configScriptTag) | ||||
|   } | ||||
| 
 | ||||
|   static async addAsyncPluginCSS (htmlStringPage: string) { | ||||
|     if (!await pathExists(PLUGIN_GLOBAL_CSS_PATH)) { | ||||
|       logger.info('Plugin Global CSS file is not available (generation may still be in progress), ignoring it.') | ||||
|       return htmlStringPage | ||||
|     } | ||||
| 
 | ||||
|     let globalCSSContent: Buffer | ||||
| 
 | ||||
|     try { | ||||
|       globalCSSContent = await readFile(PLUGIN_GLOBAL_CSS_PATH) | ||||
|     } catch (err) { | ||||
|       logger.error('Error retrieving the Plugin Global CSS file, ignoring it.', { err }) | ||||
|       return htmlStringPage | ||||
|     } | ||||
| 
 | ||||
|     if (globalCSSContent.byteLength === 0) return htmlStringPage | ||||
| 
 | ||||
|     const fileHash = sha256(globalCSSContent) | ||||
|     const linkTag = `<link rel="stylesheet" href="/plugins/global.css?hash=${fileHash}" />` | ||||
| 
 | ||||
|     return htmlStringPage.replace('</head>', linkTag + '</head>') | ||||
|   } | ||||
| 
 | ||||
|   private static addManifestContentHash (htmlStringPage: string) { | ||||
|     return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST) | ||||
|   } | ||||
| 
 | ||||
|   private static addFaviconContentHash (htmlStringPage: string) { | ||||
|     return htmlStringPage.replace('[faviconContentHash]', FILES_CONTENT_HASH.FAVICON) | ||||
|   } | ||||
| 
 | ||||
|   private static addLogoContentHash (htmlStringPage: string) { | ||||
|     return htmlStringPage.replace('[logoContentHash]', FILES_CONTENT_HASH.LOGO) | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,126 @@ | |||
| import { escapeHTML } from '@peertube/peertube-core-utils' | ||||
| import { HttpStatusCode, VideoPlaylistPrivacy } from '@peertube/peertube-models' | ||||
| import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js' | ||||
| import express from 'express' | ||||
| import validator from 'validator' | ||||
| import { CONFIG } from '../../../initializers/config.js' | ||||
| import { MEMOIZE_TTL, WEBSERVER } from '../../../initializers/constants.js' | ||||
| import { Memoize } from '@server/helpers/memoize.js' | ||||
| import { VideoPlaylistModel } from '@server/models/video/video-playlist.js' | ||||
| import { MVideoPlaylistFull } from '@server/types/models/index.js' | ||||
| import { TagsHtml } from './tags-html.js' | ||||
| import { PageHtml } from './page-html.js' | ||||
| import { CommonEmbedHtml } from './common-embed-html.js' | ||||
| 
 | ||||
| export class PlaylistHtml { | ||||
| 
 | ||||
|   static async getWatchPlaylistHTML (videoPlaylistIdArg: string, req: express.Request, res: express.Response) { | ||||
|     const videoPlaylistId = toCompleteUUID(videoPlaylistIdArg) | ||||
| 
 | ||||
|     // Let Angular application handle errors
 | ||||
|     if (!validator.default.isInt(videoPlaylistId) && !validator.default.isUUID(videoPlaylistId, 4)) { | ||||
|       res.status(HttpStatusCode.NOT_FOUND_404) | ||||
|       return PageHtml.getIndexHTML(req, res) | ||||
|     } | ||||
| 
 | ||||
|     const [ html, videoPlaylist ] = await Promise.all([ | ||||
|       PageHtml.getIndexHTML(req, res), | ||||
|       VideoPlaylistModel.loadWithAccountAndChannel(videoPlaylistId, null) | ||||
|     ]) | ||||
| 
 | ||||
|     // Let Angular application handle errors
 | ||||
|     if (!videoPlaylist || videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) { | ||||
|       res.status(HttpStatusCode.NOT_FOUND_404) | ||||
|       return html | ||||
|     } | ||||
| 
 | ||||
|     return this.buildPlaylistHTML({ | ||||
|       html, | ||||
|       playlist: videoPlaylist, | ||||
|       addEmbedInfo: true, | ||||
|       addOG: true, | ||||
|       addTwitterCard: true | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   @Memoize({ maxAge: MEMOIZE_TTL.EMBED_HTML }) | ||||
|   static async getEmbedPlaylistHTML (playlistIdArg: string) { | ||||
|     const playlistId = toCompleteUUID(playlistIdArg) | ||||
| 
 | ||||
|     const playlistPromise: Promise<MVideoPlaylistFull> = validator.default.isInt(playlistId) || validator.default.isUUID(playlistId, 4) | ||||
|       ? VideoPlaylistModel.loadWithAccountAndChannel(playlistId, null) | ||||
|       : Promise.resolve(undefined) | ||||
| 
 | ||||
|     const [ html, playlist ] = await Promise.all([ PageHtml.getEmbedHTML(), playlistPromise ]) | ||||
| 
 | ||||
|     if (!playlist || playlist.privacy === VideoPlaylistPrivacy.PRIVATE) { | ||||
|       return CommonEmbedHtml.buildEmptyEmbedHTML({ html, playlist }) | ||||
|     } | ||||
| 
 | ||||
|     return this.buildPlaylistHTML({ | ||||
|       html, | ||||
|       playlist, | ||||
|       addEmbedInfo: false, | ||||
|       addOG: false, | ||||
|       addTwitterCard: false | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   // ---------------------------------------------------------------------------
 | ||||
|   // Private
 | ||||
|   // ---------------------------------------------------------------------------
 | ||||
| 
 | ||||
|   private static buildPlaylistHTML (options: { | ||||
|     html: string | ||||
|     playlist: MVideoPlaylistFull | ||||
| 
 | ||||
|     addOG: boolean | ||||
|     addTwitterCard: boolean | ||||
|     addEmbedInfo: boolean | ||||
|   }) { | ||||
|     const { html, playlist, addEmbedInfo, addOG, addTwitterCard } = options | ||||
|     const escapedTruncatedDescription = TagsHtml.buildEscapedTruncatedDescription(playlist.description) | ||||
| 
 | ||||
|     let htmlResult = TagsHtml.addTitleTag(html, playlist.name) | ||||
|     htmlResult = TagsHtml.addDescriptionTag(htmlResult, escapedTruncatedDescription) | ||||
| 
 | ||||
|     const list = { numberOfItems: playlist.get('videosLength') as number } | ||||
|     const schemaType = 'ItemList' | ||||
| 
 | ||||
|     let twitterCard: 'player' | 'summary' | ||||
|     if (addTwitterCard) { | ||||
|       twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED | ||||
|         ? 'player' | ||||
|         : 'summary' | ||||
|     } | ||||
| 
 | ||||
|     const ogType = addOG | ||||
|       ? 'video' as 'video' | ||||
|       : undefined | ||||
| 
 | ||||
|     const embed = addEmbedInfo | ||||
|       ? { url: WEBSERVER.URL + playlist.getEmbedStaticPath(), createdAt: playlist.createdAt.toISOString() } | ||||
|       : undefined | ||||
| 
 | ||||
|     return TagsHtml.addTags(htmlResult, { | ||||
|       url: WEBSERVER.URL + playlist.getWatchStaticPath(), | ||||
| 
 | ||||
|       escapedSiteName: escapeHTML(CONFIG.INSTANCE.NAME), | ||||
|       escapedTitle: escapeHTML(playlist.name), | ||||
|       escapedTruncatedDescription, | ||||
| 
 | ||||
|       indexationPolicy: !playlist.isOwned() || playlist.privacy !== VideoPlaylistPrivacy.PUBLIC | ||||
|         ? 'never' | ||||
|         : 'always', | ||||
| 
 | ||||
|       image: { url: playlist.getThumbnailUrl() }, | ||||
| 
 | ||||
|       list, | ||||
| 
 | ||||
|       schemaType, | ||||
|       ogType, | ||||
|       twitterCard, | ||||
|       embed | ||||
|     }, { playlist }) | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,230 @@ | |||
| import { escapeHTML } from '@peertube/peertube-core-utils' | ||||
| import { CONFIG } from '../../../initializers/config.js' | ||||
| import { CUSTOM_HTML_TAG_COMMENTS, EMBED_SIZE, WEBSERVER } from '../../../initializers/constants.js' | ||||
| import { MVideo, MVideoPlaylist } from '../../../types/models/index.js' | ||||
| import { Hooks } from '../../plugins/hooks.js' | ||||
| import truncate from 'lodash-es/truncate.js' | ||||
| import { mdToOneLinePlainText } from '@server/helpers/markdown.js' | ||||
| 
 | ||||
| type Tags = { | ||||
|   indexationPolicy: 'always' | 'never' | ||||
| 
 | ||||
|   url?: string | ||||
| 
 | ||||
|   schemaType?: string | ||||
|   ogType?: string | ||||
|   twitterCard?: 'player' | 'summary' | 'summary_large_image' | ||||
| 
 | ||||
|   list?: { | ||||
|     numberOfItems: number | ||||
|   } | ||||
| 
 | ||||
|   escapedSiteName?: string | ||||
|   escapedTitle?: string | ||||
|   escapedTruncatedDescription?: string | ||||
| 
 | ||||
|   image?: { | ||||
|     url: string | ||||
|     width?: number | ||||
|     height?: number | ||||
|   } | ||||
| 
 | ||||
|   embed?: { | ||||
|     url: string | ||||
|     createdAt: string | ||||
|     duration?: string | ||||
|     views?: number | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| type HookContext = { | ||||
|   video?: MVideo | ||||
|   playlist?: MVideoPlaylist | ||||
| } | ||||
| 
 | ||||
| export class TagsHtml { | ||||
| 
 | ||||
|   static addTitleTag (htmlStringPage: string, title?: string) { | ||||
|     let text = title || CONFIG.INSTANCE.NAME | ||||
|     if (title) text += ` - ${CONFIG.INSTANCE.NAME}` | ||||
| 
 | ||||
|     const titleTag = `<title>${escapeHTML(text)}</title>` | ||||
| 
 | ||||
|     return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.TITLE, titleTag) | ||||
|   } | ||||
| 
 | ||||
|   static addDescriptionTag (htmlStringPage: string, escapedTruncatedDescription?: string) { | ||||
|     const content = escapedTruncatedDescription || escapeHTML(CONFIG.INSTANCE.SHORT_DESCRIPTION) | ||||
|     const descriptionTag = `<meta name="description" content="${content}" />` | ||||
| 
 | ||||
|     return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag) | ||||
|   } | ||||
| 
 | ||||
|   // ---------------------------------------------------------------------------
 | ||||
| 
 | ||||
|   static async addTags (htmlStringPage: string, tagsValues: Tags, context: HookContext) { | ||||
|     const openGraphMetaTags = this.generateOpenGraphMetaTagsOptions(tagsValues) | ||||
|     const standardMetaTags = this.generateStandardMetaTagsOptions(tagsValues) | ||||
|     const twitterCardMetaTags = this.generateTwitterCardMetaTagsOptions(tagsValues) | ||||
|     const schemaTags = await this.generateSchemaTagsOptions(tagsValues, context) | ||||
| 
 | ||||
|     const { url, escapedTitle, embed, indexationPolicy } = tagsValues | ||||
| 
 | ||||
|     const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = [] | ||||
| 
 | ||||
|     if (embed) { | ||||
|       oembedLinkTags.push({ | ||||
|         type: 'application/json+oembed', | ||||
|         href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(url), | ||||
|         escapedTitle | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     let tagsStr = '' | ||||
| 
 | ||||
|     // Opengraph
 | ||||
|     Object.keys(openGraphMetaTags).forEach(tagName => { | ||||
|       const tagValue = openGraphMetaTags[tagName] | ||||
|       if (!tagValue) return | ||||
| 
 | ||||
|       tagsStr += `<meta property="${tagName}" content="${tagValue}" />` | ||||
|     }) | ||||
| 
 | ||||
|     // Standard
 | ||||
|     Object.keys(standardMetaTags).forEach(tagName => { | ||||
|       const tagValue = standardMetaTags[tagName] | ||||
|       if (!tagValue) return | ||||
| 
 | ||||
|       tagsStr += `<meta property="${tagName}" content="${tagValue}" />` | ||||
|     }) | ||||
| 
 | ||||
|     // Twitter card
 | ||||
|     Object.keys(twitterCardMetaTags).forEach(tagName => { | ||||
|       const tagValue = twitterCardMetaTags[tagName] | ||||
|       if (!tagValue) return | ||||
| 
 | ||||
|       tagsStr += `<meta property="${tagName}" content="${tagValue}" />` | ||||
|     }) | ||||
| 
 | ||||
|     // OEmbed
 | ||||
|     for (const oembedLinkTag of oembedLinkTags) { | ||||
|       tagsStr += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.escapedTitle}" />` | ||||
|     } | ||||
| 
 | ||||
|     // Schema.org
 | ||||
|     if (schemaTags) { | ||||
|       tagsStr += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>` | ||||
|     } | ||||
| 
 | ||||
|     // SEO, use origin URL
 | ||||
|     if (indexationPolicy !== 'never' && url) { | ||||
|       tagsStr += `<link rel="canonical" href="${url}" />` | ||||
|     } | ||||
| 
 | ||||
|     if (indexationPolicy === 'never') { | ||||
|       tagsStr += `<meta name="robots" content="noindex" />` | ||||
|     } | ||||
| 
 | ||||
|     return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsStr) | ||||
|   } | ||||
| 
 | ||||
|   // ---------------------------------------------------------------------------
 | ||||
| 
 | ||||
|   static generateOpenGraphMetaTagsOptions (tags: Tags) { | ||||
|     if (!tags.ogType) return {} | ||||
| 
 | ||||
|     const metaTags = { | ||||
|       'og:type': tags.ogType, | ||||
|       'og:site_name': tags.escapedSiteName, | ||||
|       'og:title': tags.escapedTitle, | ||||
|       'og:image': tags.image.url | ||||
|     } | ||||
| 
 | ||||
|     if (tags.image.width && tags.image.height) { | ||||
|       metaTags['og:image:width'] = tags.image.width | ||||
|       metaTags['og:image:height'] = tags.image.height | ||||
|     } | ||||
| 
 | ||||
|     metaTags['og:url'] = tags.url | ||||
|     metaTags['og:description'] = tags.escapedTruncatedDescription | ||||
| 
 | ||||
|     if (tags.embed) { | ||||
|       metaTags['og:video:url'] = tags.embed.url | ||||
|       metaTags['og:video:secure_url'] = tags.embed.url | ||||
|       metaTags['og:video:type'] = 'text/html' | ||||
|       metaTags['og:video:width'] = EMBED_SIZE.width | ||||
|       metaTags['og:video:height'] = EMBED_SIZE.height | ||||
|     } | ||||
| 
 | ||||
|     return metaTags | ||||
|   } | ||||
| 
 | ||||
|   static generateStandardMetaTagsOptions (tags: Tags) { | ||||
|     return { | ||||
|       name: tags.escapedTitle, | ||||
|       description: tags.escapedTruncatedDescription, | ||||
|       image: tags.image?.url | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   static generateTwitterCardMetaTagsOptions (tags: Tags) { | ||||
|     if (!tags.twitterCard) return {} | ||||
| 
 | ||||
|     const metaTags = { | ||||
|       'twitter:card': tags.twitterCard, | ||||
|       'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME, | ||||
|       'twitter:title': tags.escapedTitle, | ||||
|       'twitter:description': tags.escapedTruncatedDescription, | ||||
|       'twitter:image': tags.image.url | ||||
|     } | ||||
| 
 | ||||
|     if (tags.image.width && tags.image.height) { | ||||
|       metaTags['twitter:image:width'] = tags.image.width | ||||
|       metaTags['twitter:image:height'] = tags.image.height | ||||
|     } | ||||
| 
 | ||||
|     if (tags.twitterCard === 'player') { | ||||
|       metaTags['twitter:player'] = tags.embed.url | ||||
|       metaTags['twitter:player:width'] = EMBED_SIZE.width | ||||
|       metaTags['twitter:player:height'] = EMBED_SIZE.height | ||||
|     } | ||||
| 
 | ||||
|     return metaTags | ||||
|   } | ||||
| 
 | ||||
|   static generateSchemaTagsOptions (tags: Tags, context: HookContext) { | ||||
|     if (!tags.schemaType) return | ||||
| 
 | ||||
|     const schema = { | ||||
|       '@context': 'http://schema.org', | ||||
|       '@type': tags.schemaType, | ||||
|       'name': tags.escapedTitle, | ||||
|       'description': tags.escapedTruncatedDescription, | ||||
|       'image': tags.image.url, | ||||
|       'url': tags.url | ||||
|     } | ||||
| 
 | ||||
|     if (tags.list) { | ||||
|       schema['numberOfItems'] = tags.list.numberOfItems | ||||
|       schema['thumbnailUrl'] = tags.image.url | ||||
|     } | ||||
| 
 | ||||
|     if (tags.embed) { | ||||
|       schema['embedUrl'] = tags.embed.url | ||||
|       schema['uploadDate'] = tags.embed.createdAt | ||||
| 
 | ||||
|       if (tags.embed.duration) schema['duration'] = tags.embed.duration | ||||
| 
 | ||||
|       schema['thumbnailUrl'] = tags.image.url | ||||
|       schema['contentUrl'] = tags.url | ||||
|     } | ||||
| 
 | ||||
|     return Hooks.wrapObject(schema, 'filter:html.client.json-ld.result', context) | ||||
|   } | ||||
| 
 | ||||
|   // ---------------------------------------------------------------------------
 | ||||
| 
 | ||||
|   static buildEscapedTruncatedDescription (description: string) { | ||||
|     return truncate(mdToOneLinePlainText(description), { length: 200 }) | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,130 @@ | |||
| import { escapeHTML } from '@peertube/peertube-core-utils' | ||||
| import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' | ||||
| import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js' | ||||
| import express from 'express' | ||||
| import validator from 'validator' | ||||
| import { CONFIG } from '../../../initializers/config.js' | ||||
| import { MEMOIZE_TTL, WEBSERVER } from '../../../initializers/constants.js' | ||||
| import { VideoModel } from '../../../models/video/video.js' | ||||
| import { MVideo } from '../../../types/models/index.js' | ||||
| import { getActivityStreamDuration } from '../../activitypub/activity.js' | ||||
| import { isVideoInPrivateDirectory } from '../../video-privacy.js' | ||||
| import { Memoize } from '@server/helpers/memoize.js' | ||||
| import { MVideoThumbnailBlacklist } from 'server/dist/core/types/models/index.js' | ||||
| import { TagsHtml } from './tags-html.js' | ||||
| import { PageHtml } from './page-html.js' | ||||
| import { CommonEmbedHtml } from './common-embed-html.js' | ||||
| 
 | ||||
| export class VideoHtml { | ||||
| 
 | ||||
|   static async getWatchVideoHTML (videoIdArg: string, req: express.Request, res: express.Response) { | ||||
|     const videoId = toCompleteUUID(videoIdArg) | ||||
| 
 | ||||
|     // Let Angular application handle errors
 | ||||
|     if (!validator.default.isInt(videoId) && !validator.default.isUUID(videoId, 4)) { | ||||
|       res.status(HttpStatusCode.NOT_FOUND_404) | ||||
|       return PageHtml.getIndexHTML(req, res) | ||||
|     } | ||||
| 
 | ||||
|     const [ html, video ] = await Promise.all([ | ||||
|       PageHtml.getIndexHTML(req, res), | ||||
|       VideoModel.loadWithBlacklist(videoId) | ||||
|     ]) | ||||
| 
 | ||||
|     // Let Angular application handle errors
 | ||||
|     if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) { | ||||
|       res.status(HttpStatusCode.NOT_FOUND_404) | ||||
|       return html | ||||
|     } | ||||
| 
 | ||||
|     return this.buildVideoHTML({ | ||||
|       html, | ||||
|       video, | ||||
|       addEmbedInfo: true, | ||||
|       addOG: true, | ||||
|       addTwitterCard: true | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   @Memoize({ maxAge: MEMOIZE_TTL.EMBED_HTML }) | ||||
|   static async getEmbedVideoHTML (videoIdArg: string) { | ||||
|     const videoId = toCompleteUUID(videoIdArg) | ||||
| 
 | ||||
|     const videoPromise: Promise<MVideoThumbnailBlacklist> = validator.default.isInt(videoId) || validator.default.isUUID(videoId, 4) | ||||
|       ? VideoModel.loadWithBlacklist(videoId) | ||||
|       : Promise.resolve(undefined) | ||||
| 
 | ||||
|     const [ html, video ] = await Promise.all([ PageHtml.getEmbedHTML(), videoPromise ]) | ||||
| 
 | ||||
|     if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) { | ||||
|       return CommonEmbedHtml.buildEmptyEmbedHTML({ html, video }) | ||||
|     } | ||||
| 
 | ||||
|     return this.buildVideoHTML({ | ||||
|       html, | ||||
|       video, | ||||
|       addEmbedInfo: false, | ||||
|       addOG: false, | ||||
|       addTwitterCard: false | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   // ---------------------------------------------------------------------------
 | ||||
|   // Private
 | ||||
|   // ---------------------------------------------------------------------------
 | ||||
| 
 | ||||
|   private static buildVideoHTML (options: { | ||||
|     html: string | ||||
|     video: MVideo | ||||
| 
 | ||||
|     addOG: boolean | ||||
|     addTwitterCard: boolean | ||||
|     addEmbedInfo: boolean | ||||
|   }) { | ||||
|     const { html, video, addEmbedInfo, addOG, addTwitterCard } = options | ||||
|     const escapedTruncatedDescription = TagsHtml.buildEscapedTruncatedDescription(video.description) | ||||
| 
 | ||||
|     let customHTML = TagsHtml.addTitleTag(html, video.name) | ||||
|     customHTML = TagsHtml.addDescriptionTag(customHTML, escapedTruncatedDescription) | ||||
| 
 | ||||
|     const embed = addEmbedInfo | ||||
|       ? { | ||||
|         url: WEBSERVER.URL + video.getEmbedStaticPath(), | ||||
|         createdAt: video.createdAt.toISOString(), | ||||
|         duration: getActivityStreamDuration(video.duration), | ||||
|         views: video.views | ||||
|       } | ||||
|       : undefined | ||||
| 
 | ||||
|     const ogType = addOG | ||||
|       ? 'video' as 'video' | ||||
|       : undefined | ||||
| 
 | ||||
|     let twitterCard: 'player' | 'summary_large_image' | ||||
|     if (addTwitterCard) { | ||||
|       twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED | ||||
|         ? 'player' | ||||
|         : 'summary_large_image' | ||||
|     } | ||||
| 
 | ||||
|     const schemaType = 'VideoObject' | ||||
| 
 | ||||
|     return TagsHtml.addTags(customHTML, { | ||||
|       url: WEBSERVER.URL + video.getWatchStaticPath(), | ||||
|       escapedSiteName: escapeHTML(CONFIG.INSTANCE.NAME), | ||||
|       escapedTitle: escapeHTML(video.name), | ||||
|       escapedTruncatedDescription, | ||||
| 
 | ||||
|       indexationPolicy: video.remote || video.privacy !== VideoPrivacy.PUBLIC | ||||
|         ? 'never' | ||||
|         : 'always', | ||||
| 
 | ||||
|       image: { url: WEBSERVER.URL + video.getPreviewStaticPath() }, | ||||
| 
 | ||||
|       embed, | ||||
|       ogType, | ||||
|       twitterCard, | ||||
|       schemaType | ||||
|     }, { video }) | ||||
|   } | ||||
| } | ||||
|  | @ -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) { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 Chocobozzz
						Chocobozzz