1
0
Fork 0

Add fcbk open-graph and twitter-card metas for accounts, video-channels, playlists urls (#2996)

* Add open-graph and twitter-card metas to accounts and video-channels

* Add open-graph and twitter-card to video-playlists watch view

* Refactor meta-tags creation server-side

* Add client.ts tests for account, channel and playlist tags

* Correct lint forbidden spaces

* Correct test regression on client.ts

Co-authored-by: kimsible <kimsible@users.noreply.github.com>
This commit is contained in:
Kim 2020-07-31 11:29:15 +02:00 committed by GitHub
parent 7b3909644d
commit 8d987ec63e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 377 additions and 85 deletions

View File

@ -17,6 +17,7 @@ const testEmbedPath = join(distPath, 'standalone', 'videos', 'test-embed.html')
// Special route that add OpenGraph and oEmbed tags
// Do not use a template engine for a so little thing
clientsRouter.use('/videos/watch/playlist/:id', asyncMiddleware(generateWatchPlaylistHtmlPage))
clientsRouter.use('/videos/watch/:id', asyncMiddleware(generateWatchHtmlPage))
clientsRouter.use('/accounts/:nameWithHost', asyncMiddleware(generateAccountHtmlPage))
clientsRouter.use('/video-channels/:nameWithHost', asyncMiddleware(generateVideoChannelHtmlPage))
@ -134,6 +135,12 @@ async function generateWatchHtmlPage (req: express.Request, res: express.Respons
return sendHTML(html, res)
}
async function generateWatchPlaylistHtmlPage (req: express.Request, res: express.Response) {
const html = await ClientHtml.getWatchPlaylistHTMLPage(req.params.id + '', req, res)
return sendHTML(html, res)
}
async function generateAccountHtmlPage (req: express.Request, res: express.Response) {
const html = await ClientHtml.getAccountHTMLPage(req.params.nameWithHost, req, res)

View File

@ -1,11 +1,19 @@
import * as express from 'express'
import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '../../shared/models/i18n/i18n'
import { CUSTOM_HTML_TAG_COMMENTS, EMBED_SIZE, PLUGIN_GLOBAL_CSS_PATH, WEBSERVER, FILES_CONTENT_HASH } from '../initializers/constants'
import {
AVATARS_SIZE,
CUSTOM_HTML_TAG_COMMENTS,
EMBED_SIZE,
PLUGIN_GLOBAL_CSS_PATH,
WEBSERVER,
FILES_CONTENT_HASH
} from '../initializers/constants'
import { join } from 'path'
import { escapeHTML, sha256 } from '../helpers/core-utils'
import { VideoModel } from '../models/video/video'
import { VideoPlaylistModel } from '../models/video/video-playlist'
import validator from 'validator'
import { VideoPrivacy } from '../../shared/models/videos'
import { VideoPrivacy, VideoPlaylistPrivacy } from '../../shared/models/videos'
import { readFile } from 'fs-extra'
import { getActivityStreamDuration } from '../models/video/video-format-utils'
import { AccountModel } from '../models/account/account'
@ -13,7 +21,7 @@ import { VideoChannelModel } from '../models/video/video-channel'
import * as Bluebird from 'bluebird'
import { CONFIG } from '../initializers/config'
import { logger } from '../helpers/logger'
import { MAccountActor, MChannelActor, MVideo } from '../types/models'
import { MAccountActor, MChannelActor } from '../types/models'
export class ClientHtml {
@ -56,7 +64,69 @@ export class ClientHtml {
let customHtml = ClientHtml.addTitleTag(html, escapeHTML(video.name))
customHtml = ClientHtml.addDescriptionTag(customHtml, escapeHTML(video.description))
customHtml = ClientHtml.addVideoOpenGraphAndOEmbedTags(customHtml, video)
const url = WEBSERVER.URL + video.getWatchStaticPath()
const title = escapeHTML(video.name)
const description = escapeHTML(video.description)
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 = ClientHtml.addTags(customHtml, { url, title, description, image, embed, ogType, twitterCard, schemaType })
return customHtml
}
static async getWatchPlaylistHTMLPage (videoPlaylistId: string, req: express.Request, res: express.Response) {
// Let Angular application handle errors
if (!validator.isInt(videoPlaylistId) && !validator.isUUID(videoPlaylistId, 4)) {
res.status(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(404)
return html
}
let customHtml = ClientHtml.addTitleTag(html, escapeHTML(videoPlaylist.name))
customHtml = ClientHtml.addDescriptionTag(customHtml, escapeHTML(videoPlaylist.description))
const url = videoPlaylist.getWatchUrl()
const title = escapeHTML(videoPlaylist.name)
const description = escapeHTML(videoPlaylist.description)
const image = {
url: videoPlaylist.getThumbnailUrl()
}
const list = {
numberOfItems: videoPlaylist.get('videosLength')
}
const ogType = 'video'
const twitterCard = 'summary'
const schemaType = 'ItemList'
customHtml = ClientHtml.addTags(customHtml, { url, title, description, image, list, ogType, twitterCard, schemaType })
return customHtml
}
@ -87,7 +157,22 @@ export class ClientHtml {
let customHtml = ClientHtml.addTitleTag(html, escapeHTML(entity.getDisplayName()))
customHtml = ClientHtml.addDescriptionTag(customHtml, escapeHTML(entity.description))
customHtml = ClientHtml.addAccountOrChannelMetaTags(customHtml, entity)
const url = entity.Actor.url
const title = escapeHTML(entity.getDisplayName())
const description = escapeHTML(entity.description)
const image = {
url: entity.Actor.getAvatarUrl(),
width: AVATARS_SIZE.width,
height: AVATARS_SIZE.height
}
const ogType = 'website'
const twitterCard = 'summary'
const schemaType = 'ProfilePage'
customHtml = ClientHtml.addTags(customHtml, { url, title, description, image, ogType, twitterCard, schemaType })
return customHtml
}
@ -183,60 +268,100 @@ export class ClientHtml {
return htmlStringPage.replace('</head>', linkTag + '</head>')
}
private static addVideoOpenGraphAndOEmbedTags (htmlStringPage: string, video: MVideo) {
const previewUrl = WEBSERVER.URL + video.getPreviewStaticPath()
const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
const videoNameEscaped = escapeHTML(video.name)
const videoDescriptionEscaped = escapeHTML(video.description)
const embedUrl = WEBSERVER.URL + video.getEmbedStaticPath()
const openGraphMetaTags = {
'og:type': 'video',
'og:title': videoNameEscaped,
'og:image': previewUrl,
'og:url': videoUrl,
'og:description': videoDescriptionEscaped,
'og:video:url': embedUrl,
'og:video:secure_url': embedUrl,
'og:video:type': 'text/html',
'og:video:width': EMBED_SIZE.width,
'og:video:height': EMBED_SIZE.height,
'name': videoNameEscaped,
'description': videoDescriptionEscaped,
'image': previewUrl,
'twitter:card': CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary_large_image',
'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME,
'twitter:title': videoNameEscaped,
'twitter:description': videoDescriptionEscaped,
'twitter:image': previewUrl,
'twitter:player': embedUrl,
'twitter:player:width': EMBED_SIZE.width,
'twitter:player:height': EMBED_SIZE.height
private static generateOpenGraphMetaTags (tags) {
const metaTags = {
'og:type': tags.ogType,
'og:title': tags.title,
'og:image': tags.image.url
}
const oembedLinkTags = [
{
type: 'application/json+oembed',
href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(videoUrl),
title: videoNameEscaped
}
]
if (tags.image.width && tags.image.height) {
metaTags['og:image:width'] = tags.image.width
metaTags['og:image:height'] = tags.image.height
}
const schemaTags = {
metaTags['og:url'] = tags.url
metaTags['og:description'] = tags.description
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) {
return {
name: tags.title,
description: tags.description,
image: tags.image.url
}
}
private static generateTwitterCardMetaTags (tags) {
const metaTags = {
'twitter:card': tags.twitterCard,
'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME,
'twitter:title': tags.title,
'twitter:description': tags.description,
'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
}
return metaTags
}
private static generateSchemaTags (tags) {
const schema = {
'@context': 'http://schema.org',
'@type': 'VideoObject',
'name': videoNameEscaped,
'description': videoDescriptionEscaped,
'thumbnailUrl': previewUrl,
'uploadDate': video.createdAt.toISOString(),
'duration': getActivityStreamDuration(video.duration),
'contentUrl': videoUrl,
'embedUrl': embedUrl,
'interactionCount': video.views
'@type': tags.schemaType,
'name': tags.title,
'description': tags.description,
'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
schema['duration'] = tags.embed.duration
schema['iterationCount'] = tags.embed.views
schema['thumbnailUrl'] = tags.image.url
schema['contentUrl'] = tags.url
}
return schema
}
private static addTags (htmlStringPage: string, tagsValues: any) {
const openGraphMetaTags = this.generateOpenGraphMetaTags(tagsValues)
const standardMetaTags = this.generateStandardMetaTags(tagsValues)
const twitterCardMetaTags = this.generateTwitterCardMetaTags(tagsValues)
const schemaTags = this.generateSchemaTags(tagsValues)
const { url, title, embed } = tagsValues
const oembedLinkTags = []
if (embed) {
oembedLinkTags.push({
type: 'application/json+oembed',
href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(url),
title
})
}
let tagsString = ''
@ -248,28 +373,33 @@ export class ClientHtml {
tagsString += `<meta property="${tagName}" content="${tagValue}" />`
})
// Standard
Object.keys(standardMetaTags).forEach(tagName => {
const tagValue = standardMetaTags[tagName]
tagsString += `<meta property="${tagName}" content="${tagValue}" />`
})
// Twitter card
Object.keys(twitterCardMetaTags).forEach(tagName => {
const tagValue = twitterCardMetaTags[tagName]
tagsString += `<meta property="${tagName}" content="${tagValue}" />`
})
// OEmbed
for (const oembedLinkTag of oembedLinkTags) {
tagsString += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.title}" />`
}
// Schema.org
tagsString += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
if (schemaTags) {
tagsString += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
}
// SEO, use origin video url so Google does not index remote videos
tagsString += `<link rel="canonical" href="${video.url}" />`
// SEO, use origin URL
tagsString += `<link rel="canonical" href="${url}" />`
return this.addOpenGraphAndOEmbedTags(htmlStringPage, tagsString)
}
private static addAccountOrChannelMetaTags (htmlStringPage: string, entity: MAccountActor | MChannelActor) {
// SEO, use origin account or channel URL
const metaTags = `<link rel="canonical" href="${entity.Actor.url}" />`
return this.addOpenGraphAndOEmbedTags(htmlStringPage, metaTags)
}
private static addOpenGraphAndOEmbedTags (htmlStringPage: string, metaTags: string) {
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, metaTags)
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsString)
}
}

View File

@ -490,6 +490,10 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
}
getWatchUrl () {
return WEBSERVER.URL + '/videos/watch/playlist/' + this.uuid
}
setAsRefreshed () {
this.changed('updatedAt', true)

View File

@ -13,8 +13,14 @@ import {
serverLogin,
updateCustomConfig,
updateCustomSubConfig,
uploadVideo
uploadVideo,
createVideoPlaylist,
addVideoInPlaylist,
getAccount,
addVideoChannel
} from '../../shared/extra-utils'
import { VideoPlaylistPrivacy } from '@shared/models'
import { MVideoPlaylist, MAccount, MChannel } from '@server/types/models'
const expect = chai.expect
@ -26,6 +32,11 @@ function checkIndexTags (html: string, title: string, description: string, css:
describe('Test a client controllers', function () {
let server: ServerInfo
let videoPlaylist: MVideoPlaylist
let account: MAccount
let videoChannel: MChannel
const name = 'my super name for server 1'
const description = 'my super description for server 1'
before(async function () {
this.timeout(120000)
@ -33,18 +44,56 @@ describe('Test a client controllers', function () {
server = await flushAndRunServer(1)
server.accessToken = await serverLogin(server)
const videoAttributes = {
name: 'my super name for server 1',
description: 'my super description for server 1'
}
// Video
const videoAttributes = { name, description }
await uploadVideo(server.url, server.accessToken, videoAttributes)
const res = await getVideosList(server.url)
const videos = res.body.data
const resVideosRequest = await getVideosList(server.url)
const videos = resVideosRequest.body.data
expect(videos.length).to.equal(1)
server.video = videos[0]
// Playlist
const playlistAttrs = {
displayName: name,
description,
privacy: VideoPlaylistPrivacy.PUBLIC
}
const resVideoPlaylistRequest = await createVideoPlaylist({ url: server.url, token: server.accessToken, playlistAttrs })
videoPlaylist = resVideoPlaylistRequest.body.videoPlaylist
await addVideoInPlaylist({
url: server.url,
token: server.accessToken,
playlistId: videoPlaylist.id,
elementAttrs: { videoId: server.video.id }
})
// Account
const resAccountRequest = await getAccount(server.url, `${server.user.username}@${server.host}:${server.port}`)
account = resAccountRequest.body.account
// Channel
const videoChannelAttributesArg = {
name: `${server.user.username}_channel`,
displayName: name,
description
}
const resChannelRequest = await addVideoChannel(server.url, server.accessToken, videoChannelAttributesArg)
videoChannel = resChannelRequest.body.videoChannel
})
it('Should have valid Open Graph tags on the watch page with video id', async function () {
@ -53,8 +102,10 @@ describe('Test a client controllers', function () {
.set('Accept', 'text/html')
.expect(200)
expect(res.text).to.contain('<meta property="og:title" content="my super name for server 1" />')
expect(res.text).to.contain('<meta property="og:description" content="my super description for server 1" />')
expect(res.text).to.contain(`<meta property="og:title" content="${name}" />`)
expect(res.text).to.contain(`<meta property="og:description" content="${description}" />`)
expect(res.text).to.contain('<meta property="og:type" content="video" />')
expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/videos/watch/${server.video.uuid}" />`)
})
it('Should have valid Open Graph tags on the watch page with video uuid', async function () {
@ -63,8 +114,46 @@ describe('Test a client controllers', function () {
.set('Accept', 'text/html')
.expect(200)
expect(res.text).to.contain('<meta property="og:title" content="my super name for server 1" />')
expect(res.text).to.contain('<meta property="og:description" content="my super description for server 1" />')
expect(res.text).to.contain(`<meta property="og:title" content="${name}" />`)
expect(res.text).to.contain(`<meta property="og:description" content="${description}" />`)
expect(res.text).to.contain('<meta property="og:type" content="video" />')
expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/videos/watch/${server.video.uuid}" />`)
})
it('Should have valid Open Graph tags on the watch playlist page', async function () {
const res = await request(server.url)
.get('/videos/watch/playlist/' + videoPlaylist.uuid)
.set('Accept', 'text/html')
.expect(200)
expect(res.text).to.contain(`<meta property="og:title" content="${videoPlaylist.name}" />`)
expect(res.text).to.contain(`<meta property="og:description" content="${videoPlaylist.description}" />`)
expect(res.text).to.contain('<meta property="og:type" content="video" />')
expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/videos/watch/playlist/${videoPlaylist.uuid}" />`)
})
it('Should have valid Open Graph tags on the account page', async function () {
const res = await request(server.url)
.get('/accounts/' + server.user.username)
.set('Accept', 'text/html')
.expect(200)
expect(res.text).to.contain(`<meta property="og:title" content="${account.getDisplayName()}" />`)
expect(res.text).to.contain(`<meta property="og:description" content="${account.description}" />`)
expect(res.text).to.contain('<meta property="og:type" content="website" />')
expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/accounts/${server.user.username}" />`)
})
it('Should have valid Open Graph tags on the channel page', async function () {
const res = await request(server.url)
.get('/video-channels/' + videoChannel.name)
.set('Accept', 'text/html')
.expect(200)
expect(res.text).to.contain(`<meta property="og:title" content="${videoChannel.getDisplayName()}" />`)
expect(res.text).to.contain(`<meta property="og:description" content="${videoChannel.description}" />`)
expect(res.text).to.contain('<meta property="og:type" content="website" />')
expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/video-channels/${videoChannel.name}" />`)
})
it('Should have valid oEmbed discovery tags', async function () {
@ -81,7 +170,7 @@ describe('Test a client controllers', function () {
expect(res.text).to.contain(expectedLink)
})
it('Should have valid twitter card', async function () {
it('Should have valid twitter card on the whatch video page', async function () {
const res = await request(server.url)
.get('/videos/watch/' + server.video.uuid)
.set('Accept', 'text/html')
@ -89,6 +178,44 @@ describe('Test a client controllers', function () {
expect(res.text).to.contain('<meta property="twitter:card" content="summary_large_image" />')
expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
expect(res.text).to.contain(`<meta property="twitter:title" content="${name}" />`)
expect(res.text).to.contain(`<meta property="twitter:description" content="${description}" />`)
})
it('Should have valid twitter card on the watch playlist page', async function () {
const res = await request(server.url)
.get('/videos/watch/playlist/' + videoPlaylist.uuid)
.set('Accept', 'text/html')
.expect(200)
expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
expect(res.text).to.contain(`<meta property="twitter:title" content="${videoPlaylist.name}" />`)
expect(res.text).to.contain(`<meta property="twitter:description" content="${videoPlaylist.description}" />`)
})
it('Should have valid twitter card on the account page', async function () {
const res = await request(server.url)
.get('/accounts/' + account.name)
.set('Accept', 'text/html')
.expect(200)
expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
expect(res.text).to.contain(`<meta property="twitter:title" content="${account.name}" />`)
expect(res.text).to.contain(`<meta property="twitter:description" content="${account.description}" />`)
})
it('Should have valid twitter card on the channel page', async function () {
const res = await request(server.url)
.get('/video-channels/' + videoChannel.name)
.set('Accept', 'text/html')
.expect(200)
expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
expect(res.text).to.contain(`<meta property="twitter:title" content="${videoChannel.name}" />`)
expect(res.text).to.contain(`<meta property="twitter:description" content="${videoChannel.description}" />`)
})
it('Should have valid twitter card if Twitter is whitelisted', async function () {
@ -100,13 +227,37 @@ describe('Test a client controllers', function () {
}
await updateCustomConfig(server.url, server.accessToken, config)
const res = await request(server.url)
const resVideoRequest = await request(server.url)
.get('/videos/watch/' + server.video.uuid)
.set('Accept', 'text/html')
.expect(200)
expect(res.text).to.contain('<meta property="twitter:card" content="player" />')
expect(res.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
expect(resVideoRequest.text).to.contain('<meta property="twitter:card" content="player" />')
expect(resVideoRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
const resVideoPlaylistRequest = await request(server.url)
.get('/videos/watch/playlist/' + videoPlaylist.uuid)
.set('Accept', 'text/html')
.expect(200)
expect(resVideoPlaylistRequest.text).to.contain('<meta property="twitter:card" content="player" />')
expect(resVideoPlaylistRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
const resAccountRequest = await request(server.url)
.get('/accounts/' + account.name)
.set('Accept', 'text/html')
.expect(200)
expect(resAccountRequest.text).to.contain('<meta property="twitter:card" content="player" />')
expect(resAccountRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
const resChannelRequest = await request(server.url)
.get('/video-channels/' + videoChannel.name)
.set('Accept', 'text/html')
.expect(200)
expect(resChannelRequest.text).to.contain('<meta property="twitter:card" content="player" />')
expect(resChannelRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
})
it('Should have valid index html tags (title, description...)', async function () {