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