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.)
This commit is contained in:
Chocobozzz 2023-10-20 15:41:22 +02:00
parent e731f4b724
commit f90db24233
No known key found for this signature in database
GPG key ID: 583A612D890159BE
23 changed files with 1876 additions and 1213 deletions

View file

@ -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)
})
})

View file

@ -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)
})
})

View file

@ -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)
})
})

View file

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

View file

@ -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)
})
})

View file

@ -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)
})
})

View file

@ -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()

View file

@ -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
}
}

View file

@ -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)

View file

@ -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()

View file

@ -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)
}

View file

@ -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'

View file

@ -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

View file

@ -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 })
}

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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 })
}
}

View file

@ -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'

View file

@ -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)
}
}

View file

@ -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 })
}
}

View file

@ -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 })
}
}

View file

@ -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 })
}
}

View file

@ -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) {