Skip to content

Commit

Permalink
Add fcbk open-graph and twitter-card metas for accounts, video-channe…
Browse files Browse the repository at this point in the history
…ls, 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 <[email protected]>
  • Loading branch information
kimsible and kimsible authored Jul 31, 2020
1 parent 7b39096 commit 8d987ec
Show file tree
Hide file tree
Showing 4 changed files with 371 additions and 79 deletions.
7 changes: 7 additions & 0 deletions server/controllers/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)

Expand Down
258 changes: 194 additions & 64 deletions server/lib/client-html.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
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'
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 {

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
private static generateOpenGraphMetaTags (tags) {
const metaTags = {
'og:type': tags.ogType,
'og:title': tags.title,
'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
}

const videoNameEscaped = escapeHTML(video.name)
const videoDescriptionEscaped = escapeHTML(video.description)
const embedUrl = WEBSERVER.URL + video.getEmbedStaticPath()
metaTags['og:url'] = tags.url
metaTags['og:description'] = tags.description

const openGraphMetaTags = {
'og:type': 'video',
'og:title': videoNameEscaped,
'og:image': previewUrl,
'og:url': videoUrl,
'og:description': videoDescriptionEscaped,
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
}

'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,
return metaTags
}

'name': videoNameEscaped,
'description': videoDescriptionEscaped,
'image': previewUrl,
private static generateStandardMetaTags (tags) {
return {
name: tags.title,
description: tags.description,
image: tags.image.url
}
}

'twitter:card': CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary_large_image',
private static generateTwitterCardMetaTags (tags) {
const metaTags = {
'twitter:card': tags.twitterCard,
'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
'twitter:title': tags.title,
'twitter:description': tags.description,
'twitter: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['twitter:image:width'] = tags.image.width
metaTags['twitter:image:height'] = tags.image.height
}

return metaTags
}

const schemaTags = {
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 = ''
Expand All @@ -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>`

// SEO, use origin video url so Google does not index remote videos
tagsString += `<link rel="canonical" href="${video.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}" />`
if (schemaTags) {
tagsString += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
}

return this.addOpenGraphAndOEmbedTags(htmlStringPage, metaTags)
}
// SEO, use origin URL
tagsString += `<link rel="canonical" href="${url}" />`

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)
}
}
4 changes: 4 additions & 0 deletions server/models/video/video-playlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading

0 comments on commit 8d987ec

Please sign in to comment.