From 8d987ec63e6888c839ad55938d45809869c517c6 Mon Sep 17 00:00:00 2001 From: Kim <1877318+kimsible@users.noreply.github.com> Date: Fri, 31 Jul 2020 11:29:15 +0200 Subject: [PATCH] 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 --- server/controllers/client.ts | 7 + server/lib/client-html.ts | 258 +++++++++++++++++++------- server/models/video/video-playlist.ts | 4 + server/tests/client.ts | 181 ++++++++++++++++-- 4 files changed, 371 insertions(+), 79 deletions(-) diff --git a/server/controllers/client.ts b/server/controllers/client.ts index 88f51907b..8c7f881a9 100644 --- a/server/controllers/client.ts +++ b/server/controllers/client.ts @@ -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) diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts index ca76825cd..ffe53d0d5 100644 --- a/server/lib/client-html.ts +++ b/server/lib/client-html.ts @@ -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('', linkTag + '') } - 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 = '' @@ -248,28 +373,33 @@ export class ClientHtml { tagsString += `` }) + // Standard + Object.keys(standardMetaTags).forEach(tagName => { + const tagValue = standardMetaTags[tagName] + + tagsString += `` + }) + + // Twitter card + Object.keys(twitterCardMetaTags).forEach(tagName => { + const tagValue = twitterCardMetaTags[tagName] + + tagsString += `` + }) + // OEmbed for (const oembedLinkTag of oembedLinkTags) { tagsString += `` } // Schema.org - tagsString += `` - - // SEO, use origin video url so Google does not index remote videos - tagsString += `` - - return this.addOpenGraphAndOEmbedTags(htmlStringPage, tagsString) - } - - private static addAccountOrChannelMetaTags (htmlStringPage: string, entity: MAccountActor | MChannelActor) { - // SEO, use origin account or channel URL - const metaTags = `` + if (schemaTags) { + tagsString += `` + } - return this.addOpenGraphAndOEmbedTags(htmlStringPage, metaTags) - } + // SEO, use origin URL + tagsString += `` - 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) } } diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index 51fe04fc4..b38cf9c6a 100644 --- a/server/models/video/video-playlist.ts +++ b/server/models/video/video-playlist.ts @@ -490,6 +490,10 @@ export class VideoPlaylistModel extends Model { return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename) } + getWatchUrl () { + return WEBSERVER.URL + '/videos/watch/playlist/' + this.uuid + } + setAsRefreshed () { this.changed('updatedAt', true) diff --git a/server/tests/client.ts b/server/tests/client.ts index 670bc6701..648d46414 100644 --- a/server/tests/client.ts +++ b/server/tests/client.ts @@ -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('') - expect(res.text).to.contain('') + expect(res.text).to.contain(``) + expect(res.text).to.contain(``) + expect(res.text).to.contain('') + expect(res.text).to.contain(``) }) 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('') - expect(res.text).to.contain('') + expect(res.text).to.contain(``) + expect(res.text).to.contain(``) + expect(res.text).to.contain('') + expect(res.text).to.contain(``) + }) + + 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(``) + expect(res.text).to.contain(``) + expect(res.text).to.contain('') + expect(res.text).to.contain(``) + }) + + 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(``) + expect(res.text).to.contain(``) + expect(res.text).to.contain('') + expect(res.text).to.contain(``) + }) + + 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(``) + expect(res.text).to.contain(``) + expect(res.text).to.contain('') + expect(res.text).to.contain(``) }) 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('') expect(res.text).to.contain('') + expect(res.text).to.contain(``) + expect(res.text).to.contain(``) + }) + + 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('') + expect(res.text).to.contain('') + expect(res.text).to.contain(``) + expect(res.text).to.contain(``) + }) + + 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('') + expect(res.text).to.contain('') + expect(res.text).to.contain(``) + expect(res.text).to.contain(``) + }) + + 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('') + expect(res.text).to.contain('') + expect(res.text).to.contain(``) + expect(res.text).to.contain(``) }) 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('') - expect(res.text).to.contain('') + expect(resVideoRequest.text).to.contain('') + expect(resVideoRequest.text).to.contain('') + + const resVideoPlaylistRequest = await request(server.url) + .get('/videos/watch/playlist/' + videoPlaylist.uuid) + .set('Accept', 'text/html') + .expect(200) + + expect(resVideoPlaylistRequest.text).to.contain('') + expect(resVideoPlaylistRequest.text).to.contain('') + + const resAccountRequest = await request(server.url) + .get('/accounts/' + account.name) + .set('Accept', 'text/html') + .expect(200) + + expect(resAccountRequest.text).to.contain('') + expect(resAccountRequest.text).to.contain('') + + const resChannelRequest = await request(server.url) + .get('/video-channels/' + videoChannel.name) + .set('Accept', 'text/html') + .expect(200) + + expect(resChannelRequest.text).to.contain('') + expect(resChannelRequest.text).to.contain('') }) it('Should have valid index html tags (title, description...)', async function () { -- 2.41.0