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:
parent
e731f4b724
commit
f90db24233
23 changed files with 1876 additions and 1213 deletions
|
@ -1,556 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
|
||||||
|
|
||||||
import { expect } from 'chai'
|
|
||||||
import { omit } from '@peertube/peertube-core-utils'
|
|
||||||
import {
|
|
||||||
Account,
|
|
||||||
HTMLServerConfig,
|
|
||||||
HttpStatusCode,
|
|
||||||
ServerConfig,
|
|
||||||
VideoPlaylistCreateResult,
|
|
||||||
VideoPlaylistPrivacy,
|
|
||||||
VideoPrivacy
|
|
||||||
} from '@peertube/peertube-models'
|
|
||||||
import {
|
|
||||||
cleanupTests,
|
|
||||||
createMultipleServers,
|
|
||||||
doubleFollow,
|
|
||||||
makeGetRequest,
|
|
||||||
makeHTMLRequest,
|
|
||||||
PeerTubeServer,
|
|
||||||
setAccessTokensToServers,
|
|
||||||
setDefaultVideoChannel,
|
|
||||||
waitJobs
|
|
||||||
} from '@peertube/peertube-server-commands'
|
|
||||||
|
|
||||||
function checkIndexTags (html: string, title: string, description: string, css: string, config: ServerConfig) {
|
|
||||||
expect(html).to.contain('<title>' + title + '</title>')
|
|
||||||
expect(html).to.contain('<meta name="description" content="' + description + '" />')
|
|
||||||
expect(html).to.contain('<style class="custom-css-style">' + css + '</style>')
|
|
||||||
|
|
||||||
const htmlConfig: HTMLServerConfig = omit(config, [ 'signup' ])
|
|
||||||
const configObjectString = JSON.stringify(htmlConfig)
|
|
||||||
const configEscapedString = JSON.stringify(configObjectString)
|
|
||||||
|
|
||||||
expect(html).to.contain(`<script type="application/javascript">window.PeerTubeServerConfig = ${configEscapedString}</script>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Test a client controllers', function () {
|
|
||||||
let servers: PeerTubeServer[] = []
|
|
||||||
let account: Account
|
|
||||||
|
|
||||||
const videoName = 'my super name for server 1'
|
|
||||||
const videoDescription = 'my<br> super __description__ for *server* 1<p></p>'
|
|
||||||
const videoDescriptionPlainText = 'my super description for server 1'
|
|
||||||
|
|
||||||
const playlistName = 'super playlist name'
|
|
||||||
const playlistDescription = 'super playlist description'
|
|
||||||
let playlist: VideoPlaylistCreateResult
|
|
||||||
|
|
||||||
const channelDescription = 'my super channel description'
|
|
||||||
|
|
||||||
const watchVideoBasePaths = [ '/videos/watch/', '/w/' ]
|
|
||||||
const watchPlaylistBasePaths = [ '/videos/watch/playlist/', '/w/p/' ]
|
|
||||||
|
|
||||||
let videoIds: (string | number)[] = []
|
|
||||||
let privateVideoId: string
|
|
||||||
let internalVideoId: string
|
|
||||||
let unlistedVideoId: string
|
|
||||||
let passwordProtectedVideoId: string
|
|
||||||
|
|
||||||
let playlistIds: (string | number)[] = []
|
|
||||||
|
|
||||||
before(async function () {
|
|
||||||
this.timeout(120000)
|
|
||||||
|
|
||||||
servers = await createMultipleServers(2)
|
|
||||||
|
|
||||||
await setAccessTokensToServers(servers)
|
|
||||||
|
|
||||||
await doubleFollow(servers[0], servers[1])
|
|
||||||
|
|
||||||
await setDefaultVideoChannel(servers)
|
|
||||||
|
|
||||||
await servers[0].channels.update({
|
|
||||||
channelName: servers[0].store.channel.name,
|
|
||||||
attributes: { description: channelDescription }
|
|
||||||
})
|
|
||||||
|
|
||||||
// Public video
|
|
||||||
|
|
||||||
{
|
|
||||||
const attributes = { name: videoName, description: videoDescription }
|
|
||||||
await servers[0].videos.upload({ attributes })
|
|
||||||
|
|
||||||
const { data } = await servers[0].videos.list()
|
|
||||||
expect(data.length).to.equal(1)
|
|
||||||
|
|
||||||
const video = data[0]
|
|
||||||
servers[0].store.video = video
|
|
||||||
videoIds = [ video.id, video.uuid, video.shortUUID ]
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }));
|
|
||||||
({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }));
|
|
||||||
({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }));
|
|
||||||
({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({
|
|
||||||
name: 'password protected',
|
|
||||||
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
|
||||||
videoPasswords: [ 'password' ]
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Playlist
|
|
||||||
|
|
||||||
{
|
|
||||||
const attributes = {
|
|
||||||
displayName: playlistName,
|
|
||||||
description: playlistDescription,
|
|
||||||
privacy: VideoPlaylistPrivacy.PUBLIC,
|
|
||||||
videoChannelId: servers[0].store.channel.id
|
|
||||||
}
|
|
||||||
|
|
||||||
playlist = await servers[0].playlists.create({ attributes })
|
|
||||||
playlistIds = [ playlist.id, playlist.shortUUID, playlist.uuid ]
|
|
||||||
|
|
||||||
await servers[0].playlists.addElement({ playlistId: playlist.shortUUID, attributes: { videoId: servers[0].store.video.id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account
|
|
||||||
|
|
||||||
{
|
|
||||||
await servers[0].users.updateMe({ description: 'my account description' })
|
|
||||||
|
|
||||||
account = await servers[0].accounts.get({ accountName: `${servers[0].store.user.username}@${servers[0].host}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitJobs(servers)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('oEmbed', function () {
|
|
||||||
|
|
||||||
it('Should have valid oEmbed discovery tags for videos', async function () {
|
|
||||||
for (const basePath of watchVideoBasePaths) {
|
|
||||||
for (const id of videoIds) {
|
|
||||||
const res = await makeGetRequest({
|
|
||||||
url: servers[0].url,
|
|
||||||
path: basePath + id,
|
|
||||||
accept: 'text/html',
|
|
||||||
expectedStatus: HttpStatusCode.OK_200
|
|
||||||
})
|
|
||||||
|
|
||||||
const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` +
|
|
||||||
`url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2F${servers[0].store.video.shortUUID}" ` +
|
|
||||||
`title="${servers[0].store.video.name}" />`
|
|
||||||
|
|
||||||
expect(res.text).to.contain(expectedLink)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have valid oEmbed discovery tags for a playlist', async function () {
|
|
||||||
for (const basePath of watchPlaylistBasePaths) {
|
|
||||||
for (const id of playlistIds) {
|
|
||||||
const res = await makeGetRequest({
|
|
||||||
url: servers[0].url,
|
|
||||||
path: basePath + id,
|
|
||||||
accept: 'text/html',
|
|
||||||
expectedStatus: HttpStatusCode.OK_200
|
|
||||||
})
|
|
||||||
|
|
||||||
const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` +
|
|
||||||
`url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2Fp%2F${playlist.shortUUID}" ` +
|
|
||||||
`title="${playlistName}" />`
|
|
||||||
|
|
||||||
expect(res.text).to.contain(expectedLink)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Open Graph', function () {
|
|
||||||
|
|
||||||
async function accountPageTest (path: string) {
|
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
const text = res.text
|
|
||||||
|
|
||||||
expect(text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
|
|
||||||
expect(text).to.contain(`<meta property="og:description" content="${account.description}" />`)
|
|
||||||
expect(text).to.contain('<meta property="og:type" content="website" />')
|
|
||||||
expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/a/${servers[0].store.user.username}" />`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function channelPageTest (path: string) {
|
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
const text = res.text
|
|
||||||
|
|
||||||
expect(text).to.contain(`<meta property="og:title" content="${servers[0].store.channel.displayName}" />`)
|
|
||||||
expect(text).to.contain(`<meta property="og:description" content="${channelDescription}" />`)
|
|
||||||
expect(text).to.contain('<meta property="og:type" content="website" />')
|
|
||||||
expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/c/${servers[0].store.channel.name}" />`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function watchVideoPageTest (path: string) {
|
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
const text = res.text
|
|
||||||
|
|
||||||
expect(text).to.contain(`<meta property="og:title" content="${videoName}" />`)
|
|
||||||
expect(text).to.contain(`<meta property="og:description" content="${videoDescriptionPlainText}" />`)
|
|
||||||
expect(text).to.contain('<meta property="og:type" content="video" />')
|
|
||||||
expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/${servers[0].store.video.shortUUID}" />`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function watchPlaylistPageTest (path: string) {
|
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
const text = res.text
|
|
||||||
|
|
||||||
expect(text).to.contain(`<meta property="og:title" content="${playlistName}" />`)
|
|
||||||
expect(text).to.contain(`<meta property="og:description" content="${playlistDescription}" />`)
|
|
||||||
expect(text).to.contain('<meta property="og:type" content="video" />')
|
|
||||||
expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/p/${playlist.shortUUID}" />`)
|
|
||||||
}
|
|
||||||
|
|
||||||
it('Should have valid Open Graph tags on the account page', async function () {
|
|
||||||
await accountPageTest('/accounts/' + servers[0].store.user.username)
|
|
||||||
await accountPageTest('/a/' + servers[0].store.user.username)
|
|
||||||
await accountPageTest('/@' + servers[0].store.user.username)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have valid Open Graph tags on the channel page', async function () {
|
|
||||||
await channelPageTest('/video-channels/' + servers[0].store.channel.name)
|
|
||||||
await channelPageTest('/c/' + servers[0].store.channel.name)
|
|
||||||
await channelPageTest('/@' + servers[0].store.channel.name)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have valid Open Graph tags on the watch page', async function () {
|
|
||||||
for (const path of watchVideoBasePaths) {
|
|
||||||
for (const id of videoIds) {
|
|
||||||
await watchVideoPageTest(path + id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have valid Open Graph tags on the watch page with thread id Angular param', async function () {
|
|
||||||
for (const path of watchVideoBasePaths) {
|
|
||||||
for (const id of videoIds) {
|
|
||||||
await watchVideoPageTest(path + id + ';threadId=1')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have valid Open Graph tags on the watch playlist page', async function () {
|
|
||||||
for (const path of watchPlaylistBasePaths) {
|
|
||||||
for (const id of playlistIds) {
|
|
||||||
await watchPlaylistPageTest(path + id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Twitter card', async function () {
|
|
||||||
|
|
||||||
describe('Not whitelisted', function () {
|
|
||||||
|
|
||||||
async function accountPageTest (path: string) {
|
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
const text = res.text
|
|
||||||
|
|
||||||
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
|
|
||||||
expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
|
|
||||||
expect(text).to.contain(`<meta property="twitter:title" content="${account.name}" />`)
|
|
||||||
expect(text).to.contain(`<meta property="twitter:description" content="${account.description}" />`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function channelPageTest (path: string) {
|
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
const text = res.text
|
|
||||||
|
|
||||||
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
|
|
||||||
expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
|
|
||||||
expect(text).to.contain(`<meta property="twitter:title" content="${servers[0].store.channel.displayName}" />`)
|
|
||||||
expect(text).to.contain(`<meta property="twitter:description" content="${channelDescription}" />`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function watchVideoPageTest (path: string) {
|
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
const text = res.text
|
|
||||||
|
|
||||||
expect(text).to.contain('<meta property="twitter:card" content="summary_large_image" />')
|
|
||||||
expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
|
|
||||||
expect(text).to.contain(`<meta property="twitter:title" content="${videoName}" />`)
|
|
||||||
expect(text).to.contain(`<meta property="twitter:description" content="${videoDescriptionPlainText}" />`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function watchPlaylistPageTest (path: string) {
|
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
const text = res.text
|
|
||||||
|
|
||||||
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
|
|
||||||
expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
|
|
||||||
expect(text).to.contain(`<meta property="twitter:title" content="${playlistName}" />`)
|
|
||||||
expect(text).to.contain(`<meta property="twitter:description" content="${playlistDescription}" />`)
|
|
||||||
}
|
|
||||||
|
|
||||||
it('Should have valid twitter card on the watch video page', async function () {
|
|
||||||
for (const path of watchVideoBasePaths) {
|
|
||||||
for (const id of videoIds) {
|
|
||||||
await watchVideoPageTest(path + id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have valid twitter card on the watch playlist page', async function () {
|
|
||||||
for (const path of watchPlaylistBasePaths) {
|
|
||||||
for (const id of playlistIds) {
|
|
||||||
await watchPlaylistPageTest(path + id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have valid twitter card on the account page', async function () {
|
|
||||||
await accountPageTest('/accounts/' + account.name)
|
|
||||||
await accountPageTest('/a/' + account.name)
|
|
||||||
await accountPageTest('/@' + account.name)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have valid twitter card on the channel page', async function () {
|
|
||||||
await channelPageTest('/video-channels/' + servers[0].store.channel.name)
|
|
||||||
await channelPageTest('/c/' + servers[0].store.channel.name)
|
|
||||||
await channelPageTest('/@' + servers[0].store.channel.name)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Whitelisted', function () {
|
|
||||||
|
|
||||||
before(async function () {
|
|
||||||
const config = await servers[0].config.getCustomConfig()
|
|
||||||
config.services.twitter = {
|
|
||||||
username: '@Kuja',
|
|
||||||
whitelisted: true
|
|
||||||
}
|
|
||||||
|
|
||||||
await servers[0].config.updateCustomConfig({ newCustomConfig: config })
|
|
||||||
})
|
|
||||||
|
|
||||||
async function accountPageTest (path: string) {
|
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
const text = res.text
|
|
||||||
|
|
||||||
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
|
|
||||||
expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function channelPageTest (path: string) {
|
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
const text = res.text
|
|
||||||
|
|
||||||
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
|
|
||||||
expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function watchVideoPageTest (path: string) {
|
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
const text = res.text
|
|
||||||
|
|
||||||
expect(text).to.contain('<meta property="twitter:card" content="player" />')
|
|
||||||
expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function watchPlaylistPageTest (path: string) {
|
|
||||||
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
const text = res.text
|
|
||||||
|
|
||||||
expect(text).to.contain('<meta property="twitter:card" content="player" />')
|
|
||||||
expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
|
|
||||||
}
|
|
||||||
|
|
||||||
it('Should have valid twitter card on the watch video page', async function () {
|
|
||||||
for (const path of watchVideoBasePaths) {
|
|
||||||
for (const id of videoIds) {
|
|
||||||
await watchVideoPageTest(path + id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have valid twitter card on the watch playlist page', async function () {
|
|
||||||
for (const path of watchPlaylistBasePaths) {
|
|
||||||
for (const id of playlistIds) {
|
|
||||||
await watchPlaylistPageTest(path + id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have valid twitter card on the account page', async function () {
|
|
||||||
await accountPageTest('/accounts/' + account.name)
|
|
||||||
await accountPageTest('/a/' + account.name)
|
|
||||||
await accountPageTest('/@' + account.name)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have valid twitter card on the channel page', async function () {
|
|
||||||
await channelPageTest('/video-channels/' + servers[0].store.channel.name)
|
|
||||||
await channelPageTest('/c/' + servers[0].store.channel.name)
|
|
||||||
await channelPageTest('/@' + servers[0].store.channel.name)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Index HTML', function () {
|
|
||||||
|
|
||||||
it('Should have valid index html tags (title, description...)', async function () {
|
|
||||||
const config = await servers[0].config.getConfig()
|
|
||||||
const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
|
|
||||||
|
|
||||||
const description = 'PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.'
|
|
||||||
checkIndexTags(res.text, 'PeerTube', description, '', config)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should update the customized configuration and have the correct index html tags', async function () {
|
|
||||||
await servers[0].config.updateCustomSubConfig({
|
|
||||||
newConfig: {
|
|
||||||
instance: {
|
|
||||||
name: 'PeerTube updated',
|
|
||||||
shortDescription: 'my short description',
|
|
||||||
description: 'my super description',
|
|
||||||
terms: 'my super terms',
|
|
||||||
defaultNSFWPolicy: 'blur',
|
|
||||||
defaultClientRoute: '/videos/recently-added',
|
|
||||||
customizations: {
|
|
||||||
javascript: 'alert("coucou")',
|
|
||||||
css: 'body { background-color: red; }'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const config = await servers[0].config.getConfig()
|
|
||||||
const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
|
|
||||||
|
|
||||||
checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have valid index html updated tags (title, description...)', async function () {
|
|
||||||
const config = await servers[0].config.getConfig()
|
|
||||||
const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
|
|
||||||
|
|
||||||
checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should use the original video URL for the canonical tag', async function () {
|
|
||||||
for (const basePath of watchVideoBasePaths) {
|
|
||||||
for (const id of videoIds) {
|
|
||||||
const res = await makeHTMLRequest(servers[1].url, basePath + id)
|
|
||||||
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/videos/watch/${servers[0].store.video.uuid}" />`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should use the original account URL for the canonical tag', async function () {
|
|
||||||
const accountURLtest = res => {
|
|
||||||
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/accounts/root" />`)
|
|
||||||
}
|
|
||||||
|
|
||||||
accountURLtest(await makeHTMLRequest(servers[1].url, '/accounts/root@' + servers[0].host))
|
|
||||||
accountURLtest(await makeHTMLRequest(servers[1].url, '/a/root@' + servers[0].host))
|
|
||||||
accountURLtest(await makeHTMLRequest(servers[1].url, '/@root@' + servers[0].host))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should use the original channel URL for the canonical tag', async function () {
|
|
||||||
const channelURLtests = res => {
|
|
||||||
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-channels/root_channel" />`)
|
|
||||||
}
|
|
||||||
|
|
||||||
channelURLtests(await makeHTMLRequest(servers[1].url, '/video-channels/root_channel@' + servers[0].host))
|
|
||||||
channelURLtests(await makeHTMLRequest(servers[1].url, '/c/root_channel@' + servers[0].host))
|
|
||||||
channelURLtests(await makeHTMLRequest(servers[1].url, '/@root_channel@' + servers[0].host))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should use the original playlist URL for the canonical tag', async function () {
|
|
||||||
for (const basePath of watchPlaylistBasePaths) {
|
|
||||||
for (const id of playlistIds) {
|
|
||||||
const res = await makeHTMLRequest(servers[1].url, basePath + id)
|
|
||||||
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-playlists/${playlist.uuid}" />`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should add noindex meta tag for remote accounts', async function () {
|
|
||||||
const handle = 'root@' + servers[0].host
|
|
||||||
const paths = [ '/accounts/', '/a/', '/@' ]
|
|
||||||
|
|
||||||
for (const path of paths) {
|
|
||||||
{
|
|
||||||
const { text } = await makeHTMLRequest(servers[1].url, path + handle)
|
|
||||||
expect(text).to.contain('<meta name="robots" content="noindex" />')
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const { text } = await makeHTMLRequest(servers[0].url, path + handle)
|
|
||||||
expect(text).to.not.contain('<meta name="robots" content="noindex" />')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should add noindex meta tag for remote channels', async function () {
|
|
||||||
const handle = 'root_channel@' + servers[0].host
|
|
||||||
const paths = [ '/video-channels/', '/c/', '/@' ]
|
|
||||||
|
|
||||||
for (const path of paths) {
|
|
||||||
{
|
|
||||||
const { text } = await makeHTMLRequest(servers[1].url, path + handle)
|
|
||||||
expect(text).to.contain('<meta name="robots" content="noindex" />')
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const { text } = await makeHTMLRequest(servers[0].url, path + handle)
|
|
||||||
expect(text).to.not.contain('<meta name="robots" content="noindex" />')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should not display internal/private/password protected video', async function () {
|
|
||||||
for (const basePath of watchVideoBasePaths) {
|
|
||||||
for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) {
|
|
||||||
const res = await makeGetRequest({
|
|
||||||
url: servers[0].url,
|
|
||||||
path: basePath + id,
|
|
||||||
accept: 'text/html',
|
|
||||||
expectedStatus: HttpStatusCode.NOT_FOUND_404
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(res.text).to.not.contain('internal')
|
|
||||||
expect(res.text).to.not.contain('private')
|
|
||||||
expect(res.text).to.not.contain('password protected')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should add noindex meta tag for unlisted video', async function () {
|
|
||||||
for (const basePath of watchVideoBasePaths) {
|
|
||||||
const res = await makeGetRequest({
|
|
||||||
url: servers[0].url,
|
|
||||||
path: basePath + unlistedVideoId,
|
|
||||||
accept: 'text/html',
|
|
||||||
expectedStatus: HttpStatusCode.OK_200
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(res.text).to.contain('unlisted')
|
|
||||||
expect(res.text).to.contain('<meta name="robots" content="noindex" />')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Embed HTML', function () {
|
|
||||||
|
|
||||||
it('Should have the correct embed html tags', async function () {
|
|
||||||
const config = await servers[0].config.getConfig()
|
|
||||||
const res = await makeHTMLRequest(servers[0].url, servers[0].store.video.embedPath)
|
|
||||||
|
|
||||||
checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
after(async function () {
|
|
||||||
await cleanupTests(servers)
|
|
||||||
})
|
|
||||||
})
|
|
187
packages/tests/src/client/embed-html.ts
Normal file
187
packages/tests/src/client/embed-html.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
258
packages/tests/src/client/index-html.ts
Normal file
258
packages/tests/src/client/index-html.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
4
packages/tests/src/client/index.ts
Normal file
4
packages/tests/src/client/index.ts
Normal 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'
|
64
packages/tests/src/client/oembed.ts
Normal file
64
packages/tests/src/client/oembed.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
271
packages/tests/src/client/og-twitter-tags.ts
Normal file
271
packages/tests/src/client/og-twitter-tags.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
|
@ -38,7 +38,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
it('Should upload a classic video mp4 and transcode it', async function () {
|
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' })
|
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 () {
|
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' })
|
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 () {
|
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 attributes = { name: 'audio_without_preview', fixture: 'sample.ogg' }
|
||||||
const { uuid } = await servers[0].videos.upload({ attributes, mode: 'resumable' })
|
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 () {
|
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 })
|
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 () {
|
it('Should transcode videos on manual run', async function () {
|
||||||
this.timeout(120000)
|
this.timeout(240000)
|
||||||
|
|
||||||
await servers[0].config.disableTranscoding()
|
await servers[0].config.disableTranscoding()
|
||||||
|
|
||||||
|
|
181
packages/tests/src/shared/client.ts
Normal file
181
packages/tests/src/shared/client.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -58,11 +58,12 @@ elif [ "$1" = "client" ]; then
|
||||||
npm run build:tests
|
npm run build:tests
|
||||||
|
|
||||||
feedsFiles=$(findTestFiles ./packages/tests/dist/feeds)
|
feedsFiles=$(findTestFiles ./packages/tests/dist/feeds)
|
||||||
|
clientFiles=$(findTestFiles ./packages/tests/dist/client)
|
||||||
miscFiles="./packages/tests/dist/client.js ./packages/tests/dist/misc-endpoints.js"
|
miscFiles="./packages/tests/dist/client.js ./packages/tests/dist/misc-endpoints.js"
|
||||||
# Not in their own task, they need an index.html
|
# 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"
|
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
|
# Use TS tests directly because we import server files
|
||||||
helperFiles=$(findTestFiles ./packages/tests/src/server-helpers)
|
helperFiles=$(findTestFiles ./packages/tests/src/server-helpers)
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { About, CustomConfig, UserRight } from '@peertube/peertube-models'
|
||||||
import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger.js'
|
import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger.js'
|
||||||
import { objectConverter } from '../../helpers/core-utils.js'
|
import { objectConverter } from '../../helpers/core-utils.js'
|
||||||
import { CONFIG, reloadConfig } from '../../initializers/config.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 { apiRateLimiter, asyncMiddleware, authenticate, ensureUserHasRight, openapiOperationDoc } from '../../middlewares/index.js'
|
||||||
import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config.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()))
|
auditLogger.delete(getAuditIdFromRes(res), new CustomConfigAuditView(customConfig()))
|
||||||
|
|
||||||
await reloadConfig()
|
await reloadConfig()
|
||||||
ClientHtml.invalidCache()
|
ClientHtml.invalidateCache()
|
||||||
|
|
||||||
const data = customConfig()
|
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 writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 })
|
||||||
|
|
||||||
await reloadConfig()
|
await reloadConfig()
|
||||||
ClientHtml.invalidCache()
|
ClientHtml.invalidateCache()
|
||||||
|
|
||||||
const data = customConfig()
|
const data = customConfig()
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { CONFIG } from '@server/initializers/config.js'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||||
import { currentDir, root } from '@peertube/peertube-node-utils'
|
import { currentDir, root } from '@peertube/peertube-node-utils'
|
||||||
import { STATIC_MAX_AGE } from '../initializers/constants.js'
|
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'
|
import { asyncMiddleware, buildRateLimiter, embedCSP } from '../middlewares/index.js'
|
||||||
|
|
||||||
const clientsRouter = express.Router()
|
const clientsRouter = express.Router()
|
||||||
|
@ -49,6 +49,8 @@ clientsRouter.use('/@:nameWithHost',
|
||||||
asyncMiddleware(generateActorHtmlPage)
|
asyncMiddleware(generateActorHtmlPage)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const embedMiddlewares = [
|
const embedMiddlewares = [
|
||||||
clientsRateLimiter,
|
clientsRateLimiter,
|
||||||
|
|
||||||
|
@ -64,19 +66,21 @@ const embedMiddlewares = [
|
||||||
res.setHeader('Cache-Control', 'public, max-age=0')
|
res.setHeader('Cache-Control', 'public, max-age=0')
|
||||||
|
|
||||||
next()
|
next()
|
||||||
},
|
}
|
||||||
|
|
||||||
asyncMiddleware(generateEmbedHtmlPage)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
clientsRouter.use('/videos/embed', ...embedMiddlewares)
|
clientsRouter.use('/videos/embed/:id', ...embedMiddlewares, asyncMiddleware(generateVideoEmbedHtmlPage))
|
||||||
clientsRouter.use('/video-playlists/embed', ...embedMiddlewares)
|
clientsRouter.use('/video-playlists/embed/:id', ...embedMiddlewares, asyncMiddleware(generateVideoPlaylistEmbedHtmlPage))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const testEmbedController = (req: express.Request, res: express.Response) => res.sendFile(testEmbedPath)
|
const testEmbedController = (req: express.Request, res: express.Response) => res.sendFile(testEmbedPath)
|
||||||
|
|
||||||
clientsRouter.use('/videos/test-embed', clientsRateLimiter, testEmbedController)
|
clientsRouter.use('/videos/test-embed', clientsRateLimiter, testEmbedController)
|
||||||
clientsRouter.use('/video-playlists/test-embed', clientsRateLimiter, testEmbedController)
|
clientsRouter.use('/video-playlists/test-embed', clientsRateLimiter, testEmbedController)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Dynamic PWA manifest
|
// Dynamic PWA manifest
|
||||||
clientsRouter.get('/manifest.webmanifest', clientsRateLimiter, asyncMiddleware(generateManifest))
|
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()
|
return res.status(HttpStatusCode.NOT_FOUND_404).end()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateEmbedHtmlPage (req: express.Request, res: express.Response) {
|
async function generateVideoEmbedHtmlPage (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'
|
|
||||||
|
|
||||||
const allowParameters = { req }
|
const allowParameters = { req }
|
||||||
|
|
||||||
const allowedResult = await Hooks.wrapFun(
|
const allowedResult = await Hooks.wrapFun(
|
||||||
isEmbedAllowed,
|
isEmbedAllowed,
|
||||||
allowParameters,
|
allowParameters,
|
||||||
hookName
|
'filter:html.embed.video.allowed.result'
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!allowedResult || allowedResult.allowed !== true) {
|
if (!allowedResult || allowedResult.allowed !== true) {
|
||||||
|
@ -161,7 +161,27 @@ async function generateEmbedHtmlPage (req: express.Request, res: express.Respons
|
||||||
return sendHTML(allowedResult?.html || '', res)
|
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)
|
return sendHTML(html, res)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import cors from 'cors'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { HttpNodeinfoDiasporaSoftwareNsSchema20, HttpStatusCode } from '@peertube/peertube-models'
|
import { HttpNodeinfoDiasporaSoftwareNsSchema20, HttpStatusCode } from '@peertube/peertube-models'
|
||||||
import { CONFIG, isEmailEnabled } from '@server/initializers/config.js'
|
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 { ServerConfigManager } from '@server/lib/server-config-manager.js'
|
||||||
import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION, ROUTE_CACHE_LIFETIME } from '../initializers/constants.js'
|
import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION, ROUTE_CACHE_LIFETIME } from '../initializers/constants.js'
|
||||||
import { getThemeOrDefault } from '../lib/plugins/theme-utils.js'
|
import { getThemeOrDefault } from '../lib/plugins/theme-utils.js'
|
||||||
|
|
|
@ -955,7 +955,8 @@ const MEMOIZE_TTL = {
|
||||||
VIDEO_DURATION: 1000 * 10, // 10 seconds
|
VIDEO_DURATION: 1000 * 10, // 10 seconds
|
||||||
LIVE_ABLE_TO_UPLOAD: 1000 * 60, // 1 minute
|
LIVE_ABLE_TO_UPLOAD: 1000 * 60, // 1 minute
|
||||||
LIVE_CHECK_SOCKET_HEALTH: 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 = {
|
const MEMOIZE_LENGTH = {
|
||||||
|
@ -1082,6 +1083,7 @@ if (process.env.PRODUCTION_CONSTANTS !== 'true') {
|
||||||
FILES_CACHE.VIDEO_CAPTIONS.MAX_AGE = 3000
|
FILES_CACHE.VIDEO_CAPTIONS.MAX_AGE = 3000
|
||||||
MEMOIZE_TTL.OVERVIEWS_SAMPLE = 3000
|
MEMOIZE_TTL.OVERVIEWS_SAMPLE = 3000
|
||||||
MEMOIZE_TTL.LIVE_ABLE_TO_UPLOAD = 3000
|
MEMOIZE_TTL.LIVE_ABLE_TO_UPLOAD = 3000
|
||||||
|
MEMOIZE_TTL.EMBED_HTML = 1
|
||||||
OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD = 2
|
OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD = 2
|
||||||
|
|
||||||
PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 5000
|
PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 5000
|
||||||
|
|
|
@ -1,630 +0,0 @@
|
||||||
import { buildFileLocale, escapeHTML, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '@peertube/peertube-core-utils'
|
|
||||||
import { HTMLServerConfig, HttpStatusCode, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models'
|
|
||||||
import { isTestOrDevInstance, root, sha256 } from '@peertube/peertube-node-utils'
|
|
||||||
import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js'
|
|
||||||
import { mdToOneLinePlainText } from '@server/helpers/markdown.js'
|
|
||||||
import { ActorImageModel } from '@server/models/actor/actor-image.js'
|
|
||||||
import express from 'express'
|
|
||||||
import { pathExists } from 'fs-extra/esm'
|
|
||||||
import { readFile } from 'fs/promises'
|
|
||||||
import truncate from 'lodash-es/truncate.js'
|
|
||||||
import { join } from 'path'
|
|
||||||
import validator from 'validator'
|
|
||||||
import { logger } from '../helpers/logger.js'
|
|
||||||
import { CONFIG } from '../initializers/config.js'
|
|
||||||
import {
|
|
||||||
ACCEPT_HEADERS,
|
|
||||||
CUSTOM_HTML_TAG_COMMENTS,
|
|
||||||
EMBED_SIZE,
|
|
||||||
FILES_CONTENT_HASH,
|
|
||||||
PLUGIN_GLOBAL_CSS_PATH,
|
|
||||||
WEBSERVER
|
|
||||||
} from '../initializers/constants.js'
|
|
||||||
import { AccountModel } from '../models/account/account.js'
|
|
||||||
import { VideoChannelModel } from '../models/video/video-channel.js'
|
|
||||||
import { VideoPlaylistModel } from '../models/video/video-playlist.js'
|
|
||||||
import { VideoModel } from '../models/video/video.js'
|
|
||||||
import { MAccountHost, MChannelHost, MVideo, MVideoPlaylist } from '../types/models/index.js'
|
|
||||||
import { getActivityStreamDuration } from './activitypub/activity.js'
|
|
||||||
import { getBiggestActorImage } from './actor-image.js'
|
|
||||||
import { Hooks } from './plugins/hooks.js'
|
|
||||||
import { ServerConfigManager } from './server-config-manager.js'
|
|
||||||
import { isVideoInPrivateDirectory } from './video-privacy.js'
|
|
||||||
|
|
||||||
type Tags = {
|
|
||||||
ogType: string
|
|
||||||
twitterCard: 'player' | 'summary' | 'summary_large_image'
|
|
||||||
schemaType: string
|
|
||||||
|
|
||||||
list?: {
|
|
||||||
numberOfItems: number
|
|
||||||
}
|
|
||||||
|
|
||||||
escapedSiteName: string
|
|
||||||
escapedTitle: string
|
|
||||||
escapedTruncatedDescription: string
|
|
||||||
|
|
||||||
url: string
|
|
||||||
originUrl: string
|
|
||||||
|
|
||||||
indexationPolicy: 'always' | 'never'
|
|
||||||
|
|
||||||
embed?: {
|
|
||||||
url: string
|
|
||||||
createdAt: string
|
|
||||||
duration?: string
|
|
||||||
views?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
image: {
|
|
||||||
url: string
|
|
||||||
width?: number
|
|
||||||
height?: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type HookContext = {
|
|
||||||
video?: MVideo
|
|
||||||
playlist?: MVideoPlaylist
|
|
||||||
}
|
|
||||||
|
|
||||||
class ClientHtml {
|
|
||||||
|
|
||||||
private static htmlCache: { [path: string]: string } = {}
|
|
||||||
|
|
||||||
static invalidCache () {
|
|
||||||
logger.info('Cleaning HTML cache.')
|
|
||||||
|
|
||||||
ClientHtml.htmlCache = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getDefaultHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
|
|
||||||
const html = paramLang
|
|
||||||
? await ClientHtml.getIndexHTML(req, res, paramLang)
|
|
||||||
: await ClientHtml.getIndexHTML(req, res)
|
|
||||||
|
|
||||||
let customHtml = ClientHtml.addTitleTag(html)
|
|
||||||
customHtml = ClientHtml.addDescriptionTag(customHtml)
|
|
||||||
|
|
||||||
return customHtml
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getWatchHTMLPage (videoIdArg: string, req: express.Request, res: express.Response) {
|
|
||||||
const videoId = toCompleteUUID(videoIdArg)
|
|
||||||
|
|
||||||
// Let Angular application handle errors
|
|
||||||
if (!validator.default.isInt(videoId) && !validator.default.isUUID(videoId, 4)) {
|
|
||||||
res.status(HttpStatusCode.NOT_FOUND_404)
|
|
||||||
return ClientHtml.getIndexHTML(req, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [ html, video ] = await Promise.all([
|
|
||||||
ClientHtml.getIndexHTML(req, res),
|
|
||||||
VideoModel.loadWithBlacklist(videoId)
|
|
||||||
])
|
|
||||||
|
|
||||||
// Let Angular application handle errors
|
|
||||||
if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
|
|
||||||
res.status(HttpStatusCode.NOT_FOUND_404)
|
|
||||||
return html
|
|
||||||
}
|
|
||||||
const escapedTruncatedDescription = buildEscapedTruncatedDescription(video.description)
|
|
||||||
|
|
||||||
let customHtml = ClientHtml.addTitleTag(html, video.name)
|
|
||||||
customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
|
|
||||||
|
|
||||||
const url = WEBSERVER.URL + video.getWatchStaticPath()
|
|
||||||
const originUrl = video.url
|
|
||||||
const title = video.name
|
|
||||||
const siteName = CONFIG.INSTANCE.NAME
|
|
||||||
|
|
||||||
const image = {
|
|
||||||
url: WEBSERVER.URL + video.getPreviewStaticPath()
|
|
||||||
}
|
|
||||||
|
|
||||||
const embed = {
|
|
||||||
url: WEBSERVER.URL + video.getEmbedStaticPath(),
|
|
||||||
createdAt: video.createdAt.toISOString(),
|
|
||||||
duration: getActivityStreamDuration(video.duration),
|
|
||||||
views: video.views
|
|
||||||
}
|
|
||||||
|
|
||||||
const ogType = 'video'
|
|
||||||
const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary_large_image'
|
|
||||||
const schemaType = 'VideoObject'
|
|
||||||
|
|
||||||
customHtml = await ClientHtml.addTags(customHtml, {
|
|
||||||
url,
|
|
||||||
originUrl,
|
|
||||||
escapedSiteName: escapeHTML(siteName),
|
|
||||||
escapedTitle: escapeHTML(title),
|
|
||||||
escapedTruncatedDescription,
|
|
||||||
|
|
||||||
indexationPolicy: video.privacy !== VideoPrivacy.PUBLIC
|
|
||||||
? 'never'
|
|
||||||
: 'always',
|
|
||||||
|
|
||||||
image,
|
|
||||||
embed,
|
|
||||||
ogType,
|
|
||||||
twitterCard,
|
|
||||||
schemaType
|
|
||||||
}, { video })
|
|
||||||
|
|
||||||
return customHtml
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getWatchPlaylistHTMLPage (videoPlaylistIdArg: string, req: express.Request, res: express.Response) {
|
|
||||||
const videoPlaylistId = toCompleteUUID(videoPlaylistIdArg)
|
|
||||||
|
|
||||||
// Let Angular application handle errors
|
|
||||||
if (!validator.default.isInt(videoPlaylistId) && !validator.default.isUUID(videoPlaylistId, 4)) {
|
|
||||||
res.status(HttpStatusCode.NOT_FOUND_404)
|
|
||||||
return ClientHtml.getIndexHTML(req, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [ html, videoPlaylist ] = await Promise.all([
|
|
||||||
ClientHtml.getIndexHTML(req, res),
|
|
||||||
VideoPlaylistModel.loadWithAccountAndChannel(videoPlaylistId, null)
|
|
||||||
])
|
|
||||||
|
|
||||||
// Let Angular application handle errors
|
|
||||||
if (!videoPlaylist || videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
|
|
||||||
res.status(HttpStatusCode.NOT_FOUND_404)
|
|
||||||
return html
|
|
||||||
}
|
|
||||||
|
|
||||||
const escapedTruncatedDescription = buildEscapedTruncatedDescription(videoPlaylist.description)
|
|
||||||
|
|
||||||
let customHtml = ClientHtml.addTitleTag(html, videoPlaylist.name)
|
|
||||||
customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
|
|
||||||
|
|
||||||
const url = WEBSERVER.URL + videoPlaylist.getWatchStaticPath()
|
|
||||||
const originUrl = videoPlaylist.url
|
|
||||||
const title = videoPlaylist.name
|
|
||||||
const siteName = CONFIG.INSTANCE.NAME
|
|
||||||
|
|
||||||
const image = {
|
|
||||||
url: videoPlaylist.getThumbnailUrl()
|
|
||||||
}
|
|
||||||
|
|
||||||
const embed = {
|
|
||||||
url: WEBSERVER.URL + videoPlaylist.getEmbedStaticPath(),
|
|
||||||
createdAt: videoPlaylist.createdAt.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = {
|
|
||||||
numberOfItems: videoPlaylist.get('videosLength') as number
|
|
||||||
}
|
|
||||||
|
|
||||||
const ogType = 'video'
|
|
||||||
const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary'
|
|
||||||
const schemaType = 'ItemList'
|
|
||||||
|
|
||||||
customHtml = await ClientHtml.addTags(customHtml, {
|
|
||||||
url,
|
|
||||||
originUrl,
|
|
||||||
escapedSiteName: escapeHTML(siteName),
|
|
||||||
escapedTitle: escapeHTML(title),
|
|
||||||
escapedTruncatedDescription,
|
|
||||||
|
|
||||||
indexationPolicy: videoPlaylist.privacy !== VideoPlaylistPrivacy.PUBLIC
|
|
||||||
? 'never'
|
|
||||||
: 'always',
|
|
||||||
|
|
||||||
embed,
|
|
||||||
image,
|
|
||||||
list,
|
|
||||||
ogType,
|
|
||||||
twitterCard,
|
|
||||||
schemaType
|
|
||||||
}, { playlist: videoPlaylist })
|
|
||||||
|
|
||||||
return customHtml
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
|
|
||||||
const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost)
|
|
||||||
return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
|
|
||||||
const videoChannelModelPromise = VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
|
|
||||||
return this.getAccountOrChannelHTMLPage(() => videoChannelModelPromise, req, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
|
|
||||||
const [ account, channel ] = await Promise.all([
|
|
||||||
AccountModel.loadByNameWithHost(nameWithHost),
|
|
||||||
VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
|
|
||||||
])
|
|
||||||
|
|
||||||
return this.getAccountOrChannelHTMLPage(() => Promise.resolve(account || channel), req, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getEmbedHTML () {
|
|
||||||
const path = ClientHtml.getEmbedPath()
|
|
||||||
|
|
||||||
// Disable HTML cache in dev mode because webpack can regenerate JS files
|
|
||||||
if (!isTestOrDevInstance() && ClientHtml.htmlCache[path]) {
|
|
||||||
return ClientHtml.htmlCache[path]
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await readFile(path)
|
|
||||||
const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
|
|
||||||
|
|
||||||
let html = buffer.toString()
|
|
||||||
html = await ClientHtml.addAsyncPluginCSS(html)
|
|
||||||
html = ClientHtml.addCustomCSS(html)
|
|
||||||
html = ClientHtml.addTitleTag(html)
|
|
||||||
html = ClientHtml.addDescriptionTag(html)
|
|
||||||
html = ClientHtml.addServerConfig(html, serverConfig)
|
|
||||||
|
|
||||||
ClientHtml.htmlCache[path] = html
|
|
||||||
|
|
||||||
return html
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async getAccountOrChannelHTMLPage (
|
|
||||||
loader: () => Promise<MAccountHost | MChannelHost>,
|
|
||||||
req: express.Request,
|
|
||||||
res: express.Response
|
|
||||||
) {
|
|
||||||
const [ html, entity ] = await Promise.all([
|
|
||||||
ClientHtml.getIndexHTML(req, res),
|
|
||||||
loader()
|
|
||||||
])
|
|
||||||
|
|
||||||
// Let Angular application handle errors
|
|
||||||
if (!entity) {
|
|
||||||
res.status(HttpStatusCode.NOT_FOUND_404)
|
|
||||||
return ClientHtml.getIndexHTML(req, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
const escapedTruncatedDescription = buildEscapedTruncatedDescription(entity.description)
|
|
||||||
|
|
||||||
let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName())
|
|
||||||
customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
|
|
||||||
|
|
||||||
const url = entity.getClientUrl()
|
|
||||||
const originUrl = entity.Actor.url
|
|
||||||
const siteName = CONFIG.INSTANCE.NAME
|
|
||||||
const title = entity.getDisplayName()
|
|
||||||
|
|
||||||
const avatar = getBiggestActorImage(entity.Actor.Avatars)
|
|
||||||
const image = {
|
|
||||||
url: ActorImageModel.getImageUrl(avatar),
|
|
||||||
width: avatar?.width,
|
|
||||||
height: avatar?.height
|
|
||||||
}
|
|
||||||
|
|
||||||
const ogType = 'website'
|
|
||||||
const twitterCard = 'summary'
|
|
||||||
const schemaType = 'ProfilePage'
|
|
||||||
|
|
||||||
customHtml = await ClientHtml.addTags(customHtml, {
|
|
||||||
url,
|
|
||||||
originUrl,
|
|
||||||
escapedTitle: escapeHTML(title),
|
|
||||||
escapedSiteName: escapeHTML(siteName),
|
|
||||||
escapedTruncatedDescription,
|
|
||||||
image,
|
|
||||||
ogType,
|
|
||||||
twitterCard,
|
|
||||||
schemaType,
|
|
||||||
|
|
||||||
indexationPolicy: entity.Actor.isOwned()
|
|
||||||
? 'always'
|
|
||||||
: 'never'
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
return customHtml
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async getIndexHTML (req: express.Request, res: express.Response, paramLang?: string) {
|
|
||||||
const path = ClientHtml.getIndexPath(req, res, paramLang)
|
|
||||||
if (ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
|
|
||||||
|
|
||||||
const buffer = await readFile(path)
|
|
||||||
const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
|
|
||||||
|
|
||||||
let html = buffer.toString()
|
|
||||||
|
|
||||||
html = ClientHtml.addManifestContentHash(html)
|
|
||||||
html = ClientHtml.addFaviconContentHash(html)
|
|
||||||
html = ClientHtml.addLogoContentHash(html)
|
|
||||||
html = ClientHtml.addCustomCSS(html)
|
|
||||||
html = ClientHtml.addServerConfig(html, serverConfig)
|
|
||||||
html = await ClientHtml.addAsyncPluginCSS(html)
|
|
||||||
|
|
||||||
ClientHtml.htmlCache[path] = html
|
|
||||||
|
|
||||||
return html
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getIndexPath (req: express.Request, res: express.Response, paramLang: string) {
|
|
||||||
let lang: string
|
|
||||||
|
|
||||||
// Check param lang validity
|
|
||||||
if (paramLang && is18nLocale(paramLang)) {
|
|
||||||
lang = paramLang
|
|
||||||
|
|
||||||
// Save locale in cookies
|
|
||||||
res.cookie('clientLanguage', lang, {
|
|
||||||
secure: WEBSERVER.SCHEME === 'https',
|
|
||||||
sameSite: 'none',
|
|
||||||
maxAge: 1000 * 3600 * 24 * 90 // 3 months
|
|
||||||
})
|
|
||||||
|
|
||||||
} else if (req.cookies.clientLanguage && is18nLocale(req.cookies.clientLanguage)) {
|
|
||||||
lang = req.cookies.clientLanguage
|
|
||||||
} else {
|
|
||||||
lang = req.acceptsLanguages(POSSIBLE_LOCALES) || getDefaultLocale()
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
'Serving %s HTML language', buildFileLocale(lang),
|
|
||||||
{ cookie: req.cookies?.clientLanguage, paramLang, acceptLanguage: req.headers['accept-language'] }
|
|
||||||
)
|
|
||||||
|
|
||||||
return join(root(), 'client', 'dist', buildFileLocale(lang), 'index.html')
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getEmbedPath () {
|
|
||||||
return join(root(), 'client', 'dist', 'standalone', 'videos', 'embed.html')
|
|
||||||
}
|
|
||||||
|
|
||||||
private static addManifestContentHash (htmlStringPage: string) {
|
|
||||||
return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static addFaviconContentHash (htmlStringPage: string) {
|
|
||||||
return htmlStringPage.replace('[faviconContentHash]', FILES_CONTENT_HASH.FAVICON)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static addLogoContentHash (htmlStringPage: string) {
|
|
||||||
return htmlStringPage.replace('[logoContentHash]', FILES_CONTENT_HASH.LOGO)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static addTitleTag (htmlStringPage: string, title?: string) {
|
|
||||||
let text = title || CONFIG.INSTANCE.NAME
|
|
||||||
if (title) text += ` - ${CONFIG.INSTANCE.NAME}`
|
|
||||||
|
|
||||||
const titleTag = `<title>${escapeHTML(text)}</title>`
|
|
||||||
|
|
||||||
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.TITLE, titleTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static addDescriptionTag (htmlStringPage: string, escapedTruncatedDescription?: string) {
|
|
||||||
const content = escapedTruncatedDescription || escapeHTML(CONFIG.INSTANCE.SHORT_DESCRIPTION)
|
|
||||||
const descriptionTag = `<meta name="description" content="${content}" />`
|
|
||||||
|
|
||||||
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static addCustomCSS (htmlStringPage: string) {
|
|
||||||
const styleTag = `<style class="custom-css-style">${CONFIG.INSTANCE.CUSTOMIZATIONS.CSS}</style>`
|
|
||||||
|
|
||||||
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static addServerConfig (htmlStringPage: string, serverConfig: HTMLServerConfig) {
|
|
||||||
// Stringify the JSON object, and then stringify the string object so we can inject it into the HTML
|
|
||||||
const serverConfigString = JSON.stringify(JSON.stringify(serverConfig))
|
|
||||||
const configScriptTag = `<script type="application/javascript">window.PeerTubeServerConfig = ${serverConfigString}</script>`
|
|
||||||
|
|
||||||
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.SERVER_CONFIG, configScriptTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async addAsyncPluginCSS (htmlStringPage: string) {
|
|
||||||
if (!await pathExists(PLUGIN_GLOBAL_CSS_PATH)) {
|
|
||||||
logger.info('Plugin Global CSS file is not available (generation may still be in progress), ignoring it.')
|
|
||||||
return htmlStringPage
|
|
||||||
}
|
|
||||||
|
|
||||||
let globalCSSContent: Buffer
|
|
||||||
|
|
||||||
try {
|
|
||||||
globalCSSContent = await readFile(PLUGIN_GLOBAL_CSS_PATH)
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Error retrieving the Plugin Global CSS file, ignoring it.', { err })
|
|
||||||
return htmlStringPage
|
|
||||||
}
|
|
||||||
|
|
||||||
if (globalCSSContent.byteLength === 0) return htmlStringPage
|
|
||||||
|
|
||||||
const fileHash = sha256(globalCSSContent)
|
|
||||||
const linkTag = `<link rel="stylesheet" href="/plugins/global.css?hash=${fileHash}" />`
|
|
||||||
|
|
||||||
return htmlStringPage.replace('</head>', linkTag + '</head>')
|
|
||||||
}
|
|
||||||
|
|
||||||
private static generateOpenGraphMetaTags (tags: Tags) {
|
|
||||||
const metaTags = {
|
|
||||||
'og:type': tags.ogType,
|
|
||||||
'og:site_name': tags.escapedSiteName,
|
|
||||||
'og:title': tags.escapedTitle,
|
|
||||||
'og:image': tags.image.url
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tags.image.width && tags.image.height) {
|
|
||||||
metaTags['og:image:width'] = tags.image.width
|
|
||||||
metaTags['og:image:height'] = tags.image.height
|
|
||||||
}
|
|
||||||
|
|
||||||
metaTags['og:url'] = tags.url
|
|
||||||
metaTags['og:description'] = tags.escapedTruncatedDescription
|
|
||||||
|
|
||||||
if (tags.embed) {
|
|
||||||
metaTags['og:video:url'] = tags.embed.url
|
|
||||||
metaTags['og:video:secure_url'] = tags.embed.url
|
|
||||||
metaTags['og:video:type'] = 'text/html'
|
|
||||||
metaTags['og:video:width'] = EMBED_SIZE.width
|
|
||||||
metaTags['og:video:height'] = EMBED_SIZE.height
|
|
||||||
}
|
|
||||||
|
|
||||||
return metaTags
|
|
||||||
}
|
|
||||||
|
|
||||||
private static generateStandardMetaTags (tags: Tags) {
|
|
||||||
return {
|
|
||||||
name: tags.escapedTitle,
|
|
||||||
description: tags.escapedTruncatedDescription,
|
|
||||||
image: tags.image.url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static generateTwitterCardMetaTags (tags: Tags) {
|
|
||||||
const metaTags = {
|
|
||||||
'twitter:card': tags.twitterCard,
|
|
||||||
'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME,
|
|
||||||
'twitter:title': tags.escapedTitle,
|
|
||||||
'twitter:description': tags.escapedTruncatedDescription,
|
|
||||||
'twitter:image': tags.image.url
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tags.image.width && tags.image.height) {
|
|
||||||
metaTags['twitter:image:width'] = tags.image.width
|
|
||||||
metaTags['twitter:image:height'] = tags.image.height
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tags.twitterCard === 'player') {
|
|
||||||
metaTags['twitter:player'] = tags.embed.url
|
|
||||||
metaTags['twitter:player:width'] = EMBED_SIZE.width
|
|
||||||
metaTags['twitter:player:height'] = EMBED_SIZE.height
|
|
||||||
}
|
|
||||||
|
|
||||||
return metaTags
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async generateSchemaTags (tags: Tags, context: HookContext) {
|
|
||||||
const schema = {
|
|
||||||
'@context': 'http://schema.org',
|
|
||||||
'@type': tags.schemaType,
|
|
||||||
'name': tags.escapedTitle,
|
|
||||||
'description': tags.escapedTruncatedDescription,
|
|
||||||
'image': tags.image.url,
|
|
||||||
'url': tags.url
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tags.list) {
|
|
||||||
schema['numberOfItems'] = tags.list.numberOfItems
|
|
||||||
schema['thumbnailUrl'] = tags.image.url
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tags.embed) {
|
|
||||||
schema['embedUrl'] = tags.embed.url
|
|
||||||
schema['uploadDate'] = tags.embed.createdAt
|
|
||||||
|
|
||||||
if (tags.embed.duration) schema['duration'] = tags.embed.duration
|
|
||||||
|
|
||||||
schema['thumbnailUrl'] = tags.image.url
|
|
||||||
schema['contentUrl'] = tags.url
|
|
||||||
}
|
|
||||||
|
|
||||||
return Hooks.wrapObject(schema, 'filter:html.client.json-ld.result', context)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async addTags (htmlStringPage: string, tagsValues: Tags, context: HookContext) {
|
|
||||||
const openGraphMetaTags = this.generateOpenGraphMetaTags(tagsValues)
|
|
||||||
const standardMetaTags = this.generateStandardMetaTags(tagsValues)
|
|
||||||
const twitterCardMetaTags = this.generateTwitterCardMetaTags(tagsValues)
|
|
||||||
const schemaTags = await this.generateSchemaTags(tagsValues, context)
|
|
||||||
|
|
||||||
const { url, escapedTitle, embed, originUrl, indexationPolicy } = tagsValues
|
|
||||||
|
|
||||||
const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = []
|
|
||||||
|
|
||||||
if (embed) {
|
|
||||||
oembedLinkTags.push({
|
|
||||||
type: 'application/json+oembed',
|
|
||||||
href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(url),
|
|
||||||
escapedTitle
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let tagsStr = ''
|
|
||||||
|
|
||||||
// Opengraph
|
|
||||||
Object.keys(openGraphMetaTags).forEach(tagName => {
|
|
||||||
const tagValue = openGraphMetaTags[tagName]
|
|
||||||
|
|
||||||
tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
|
|
||||||
})
|
|
||||||
|
|
||||||
// Standard
|
|
||||||
Object.keys(standardMetaTags).forEach(tagName => {
|
|
||||||
const tagValue = standardMetaTags[tagName]
|
|
||||||
|
|
||||||
tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
|
|
||||||
})
|
|
||||||
|
|
||||||
// Twitter card
|
|
||||||
Object.keys(twitterCardMetaTags).forEach(tagName => {
|
|
||||||
const tagValue = twitterCardMetaTags[tagName]
|
|
||||||
|
|
||||||
tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
|
|
||||||
})
|
|
||||||
|
|
||||||
// OEmbed
|
|
||||||
for (const oembedLinkTag of oembedLinkTags) {
|
|
||||||
tagsStr += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.escapedTitle}" />`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schema.org
|
|
||||||
if (schemaTags) {
|
|
||||||
tagsStr += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SEO, use origin URL
|
|
||||||
tagsStr += `<link rel="canonical" href="${originUrl}" />`
|
|
||||||
|
|
||||||
if (indexationPolicy === 'never') {
|
|
||||||
tagsStr += `<meta name="robots" content="noindex" />`
|
|
||||||
}
|
|
||||||
|
|
||||||
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsStr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendHTML (html: string, res: express.Response, localizedHTML: boolean = false) {
|
|
||||||
res.set('Content-Type', 'text/html; charset=UTF-8')
|
|
||||||
|
|
||||||
if (localizedHTML) {
|
|
||||||
res.set('Vary', 'Accept-Language')
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.send(html)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function serveIndexHTML (req: express.Request, res: express.Response) {
|
|
||||||
if (req.accepts(ACCEPT_HEADERS) === 'html' || !req.headers.accept) {
|
|
||||||
try {
|
|
||||||
await generateHTMLPage(req, res, req.params.language)
|
|
||||||
return
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Cannot generate HTML page.', { err })
|
|
||||||
return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(HttpStatusCode.NOT_ACCEPTABLE_406).end()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export {
|
|
||||||
ClientHtml,
|
|
||||||
sendHTML,
|
|
||||||
serveIndexHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
|
|
||||||
const html = await ClientHtml.getDefaultHTMLPage(req, res, paramLang)
|
|
||||||
|
|
||||||
return sendHTML(html, res, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEscapedTruncatedDescription (description: string) {
|
|
||||||
return truncate(mdToOneLinePlainText(description), { length: 200 })
|
|
||||||
}
|
|
95
server/core/lib/html/client-html.ts
Normal file
95
server/core/lib/html/client-html.ts
Normal 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)
|
||||||
|
}
|
91
server/core/lib/html/shared/actor-html.ts
Normal file
91
server/core/lib/html/shared/actor-html.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
18
server/core/lib/html/shared/common-embed-html.ts
Normal file
18
server/core/lib/html/shared/common-embed-html.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
5
server/core/lib/html/shared/index.ts
Normal file
5
server/core/lib/html/shared/index.ts
Normal 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'
|
166
server/core/lib/html/shared/page-html.ts
Normal file
166
server/core/lib/html/shared/page-html.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
126
server/core/lib/html/shared/playlist-html.ts
Normal file
126
server/core/lib/html/shared/playlist-html.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
230
server/core/lib/html/shared/tags-html.ts
Normal file
230
server/core/lib/html/shared/tags-html.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
130
server/core/lib/html/shared/video-html.ts
Normal file
130
server/core/lib/html/shared/video-html.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
|
@ -30,7 +30,7 @@ import {
|
||||||
RegisterServerAuthPassOptions,
|
RegisterServerAuthPassOptions,
|
||||||
RegisterServerOptions
|
RegisterServerOptions
|
||||||
} from '../../types/plugins/index.js'
|
} 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 { RegisterHelpers } from './register-helpers.js'
|
||||||
import { installNpmPlugin, installNpmPluginFromDisk, rebuildNativePlugins, removeNpmPlugin } from './yarn.js'
|
import { installNpmPlugin, installNpmPluginFromDisk, rebuildNativePlugins, removeNpmPlugin } from './yarn.js'
|
||||||
|
|
||||||
|
@ -329,7 +329,7 @@ export class PluginManager implements ServerHook {
|
||||||
await this.regeneratePluginGlobalCSS()
|
await this.regeneratePluginGlobalCSS()
|
||||||
}
|
}
|
||||||
|
|
||||||
ClientHtml.invalidCache()
|
ClientHtml.invalidateCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ###################### Installation ######################
|
// ###################### Installation ######################
|
||||||
|
@ -497,7 +497,7 @@ export class PluginManager implements ServerHook {
|
||||||
|
|
||||||
await this.addTranslations(plugin, npmName, packageJSON.translations)
|
await this.addTranslations(plugin, npmName, packageJSON.translations)
|
||||||
|
|
||||||
ClientHtml.invalidCache()
|
ClientHtml.invalidateCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJSON) {
|
private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJSON) {
|
||||||
|
|
Loading…
Add table
Reference in a new issue