aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/lib
diff options
context:
space:
mode:
authorKim <1877318+kimsible@users.noreply.github.com>2020-07-31 11:29:15 +0200
committerGitHub <noreply@github.com>2020-07-31 11:29:15 +0200
commit8d987ec63e6888c839ad55938d45809869c517c6 (patch)
treed6a82b9254c1c473094ee9371688661f2ae6eef3 /server/lib
parent7b3909644dd7cb8be1caad537bb40605e5f059d4 (diff)
downloadPeerTube-8d987ec63e6888c839ad55938d45809869c517c6.tar.gz
PeerTube-8d987ec63e6888c839ad55938d45809869c517c6.tar.zst
PeerTube-8d987ec63e6888c839ad55938d45809869c517c6.zip
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>
Diffstat (limited to 'server/lib')
-rw-r--r--server/lib/client-html.ts258
1 files changed, 194 insertions, 64 deletions
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 @@
1import * as express from 'express' 1import * as express from 'express'
2import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '../../shared/models/i18n/i18n' 2import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '../../shared/models/i18n/i18n'
3import { CUSTOM_HTML_TAG_COMMENTS, EMBED_SIZE, PLUGIN_GLOBAL_CSS_PATH, WEBSERVER, FILES_CONTENT_HASH } from '../initializers/constants' 3import {
4 AVATARS_SIZE,
5 CUSTOM_HTML_TAG_COMMENTS,
6 EMBED_SIZE,
7 PLUGIN_GLOBAL_CSS_PATH,
8 WEBSERVER,
9 FILES_CONTENT_HASH
10} from '../initializers/constants'
4import { join } from 'path' 11import { join } from 'path'
5import { escapeHTML, sha256 } from '../helpers/core-utils' 12import { escapeHTML, sha256 } from '../helpers/core-utils'
6import { VideoModel } from '../models/video/video' 13import { VideoModel } from '../models/video/video'
14import { VideoPlaylistModel } from '../models/video/video-playlist'
7import validator from 'validator' 15import validator from 'validator'
8import { VideoPrivacy } from '../../shared/models/videos' 16import { VideoPrivacy, VideoPlaylistPrivacy } from '../../shared/models/videos'
9import { readFile } from 'fs-extra' 17import { readFile } from 'fs-extra'
10import { getActivityStreamDuration } from '../models/video/video-format-utils' 18import { getActivityStreamDuration } from '../models/video/video-format-utils'
11import { AccountModel } from '../models/account/account' 19import { AccountModel } from '../models/account/account'
@@ -13,7 +21,7 @@ import { VideoChannelModel } from '../models/video/video-channel'
13import * as Bluebird from 'bluebird' 21import * as Bluebird from 'bluebird'
14import { CONFIG } from '../initializers/config' 22import { CONFIG } from '../initializers/config'
15import { logger } from '../helpers/logger' 23import { logger } from '../helpers/logger'
16import { MAccountActor, MChannelActor, MVideo } from '../types/models' 24import { MAccountActor, MChannelActor } from '../types/models'
17 25
18export class ClientHtml { 26export class ClientHtml {
19 27
@@ -56,7 +64,69 @@ export class ClientHtml {
56 64
57 let customHtml = ClientHtml.addTitleTag(html, escapeHTML(video.name)) 65 let customHtml = ClientHtml.addTitleTag(html, escapeHTML(video.name))
58 customHtml = ClientHtml.addDescriptionTag(customHtml, escapeHTML(video.description)) 66 customHtml = ClientHtml.addDescriptionTag(customHtml, escapeHTML(video.description))
59 customHtml = ClientHtml.addVideoOpenGraphAndOEmbedTags(customHtml, video) 67
68 const url = WEBSERVER.URL + video.getWatchStaticPath()
69 const title = escapeHTML(video.name)
70 const description = escapeHTML(video.description)
71
72 const image = {
73 url: WEBSERVER.URL + video.getPreviewStaticPath()
74 }
75
76 const embed = {
77 url: WEBSERVER.URL + video.getEmbedStaticPath(),
78 createdAt: video.createdAt.toISOString(),
79 duration: getActivityStreamDuration(video.duration),
80 views: video.views
81 }
82
83 const ogType = 'video'
84 const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary_large_image'
85 const schemaType = 'VideoObject'
86
87 customHtml = ClientHtml.addTags(customHtml, { url, title, description, image, embed, ogType, twitterCard, schemaType })
88
89 return customHtml
90 }
91
92 static async getWatchPlaylistHTMLPage (videoPlaylistId: string, req: express.Request, res: express.Response) {
93 // Let Angular application handle errors
94 if (!validator.isInt(videoPlaylistId) && !validator.isUUID(videoPlaylistId, 4)) {
95 res.status(404)
96 return ClientHtml.getIndexHTML(req, res)
97 }
98
99 const [ html, videoPlaylist ] = await Promise.all([
100 ClientHtml.getIndexHTML(req, res),
101 VideoPlaylistModel.loadWithAccountAndChannel(videoPlaylistId, null)
102 ])
103
104 // Let Angular application handle errors
105 if (!videoPlaylist || videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
106 res.status(404)
107 return html
108 }
109
110 let customHtml = ClientHtml.addTitleTag(html, escapeHTML(videoPlaylist.name))
111 customHtml = ClientHtml.addDescriptionTag(customHtml, escapeHTML(videoPlaylist.description))
112
113 const url = videoPlaylist.getWatchUrl()
114 const title = escapeHTML(videoPlaylist.name)
115 const description = escapeHTML(videoPlaylist.description)
116
117 const image = {
118 url: videoPlaylist.getThumbnailUrl()
119 }
120
121 const list = {
122 numberOfItems: videoPlaylist.get('videosLength')
123 }
124
125 const ogType = 'video'
126 const twitterCard = 'summary'
127 const schemaType = 'ItemList'
128
129 customHtml = ClientHtml.addTags(customHtml, { url, title, description, image, list, ogType, twitterCard, schemaType })
60 130
61 return customHtml 131 return customHtml
62 } 132 }
@@ -87,7 +157,22 @@ export class ClientHtml {
87 157
88 let customHtml = ClientHtml.addTitleTag(html, escapeHTML(entity.getDisplayName())) 158 let customHtml = ClientHtml.addTitleTag(html, escapeHTML(entity.getDisplayName()))
89 customHtml = ClientHtml.addDescriptionTag(customHtml, escapeHTML(entity.description)) 159 customHtml = ClientHtml.addDescriptionTag(customHtml, escapeHTML(entity.description))
90 customHtml = ClientHtml.addAccountOrChannelMetaTags(customHtml, entity) 160
161 const url = entity.Actor.url
162 const title = escapeHTML(entity.getDisplayName())
163 const description = escapeHTML(entity.description)
164
165 const image = {
166 url: entity.Actor.getAvatarUrl(),
167 width: AVATARS_SIZE.width,
168 height: AVATARS_SIZE.height
169 }
170
171 const ogType = 'website'
172 const twitterCard = 'summary'
173 const schemaType = 'ProfilePage'
174
175 customHtml = ClientHtml.addTags(customHtml, { url, title, description, image, ogType, twitterCard, schemaType })
91 176
92 return customHtml 177 return customHtml
93 } 178 }
@@ -183,60 +268,100 @@ export class ClientHtml {
183 return htmlStringPage.replace('</head>', linkTag + '</head>') 268 return htmlStringPage.replace('</head>', linkTag + '</head>')
184 } 269 }
185 270
186 private static addVideoOpenGraphAndOEmbedTags (htmlStringPage: string, video: MVideo) { 271 private static generateOpenGraphMetaTags (tags) {
187 const previewUrl = WEBSERVER.URL + video.getPreviewStaticPath() 272 const metaTags = {
188 const videoUrl = WEBSERVER.URL + video.getWatchStaticPath() 273 'og:type': tags.ogType,
274 'og:title': tags.title,
275 'og:image': tags.image.url
276 }
277
278 if (tags.image.width && tags.image.height) {
279 metaTags['og:image:width'] = tags.image.width
280 metaTags['og:image:height'] = tags.image.height
281 }
189 282
190 const videoNameEscaped = escapeHTML(video.name) 283 metaTags['og:url'] = tags.url
191 const videoDescriptionEscaped = escapeHTML(video.description) 284 metaTags['og:description'] = tags.description
192 const embedUrl = WEBSERVER.URL + video.getEmbedStaticPath()
193 285
194 const openGraphMetaTags = { 286 if (tags.embed) {
195 'og:type': 'video', 287 metaTags['og:video:url'] = tags.embed.url
196 'og:title': videoNameEscaped, 288 metaTags['og:video:secure_url'] = tags.embed.url
197 'og:image': previewUrl, 289 metaTags['og:video:type'] = 'text/html'
198 'og:url': videoUrl, 290 metaTags['og:video:width'] = EMBED_SIZE.width
199 'og:description': videoDescriptionEscaped, 291 metaTags['og:video:height'] = EMBED_SIZE.height
292 }
200 293
201 'og:video:url': embedUrl, 294 return metaTags
202 'og:video:secure_url': embedUrl, 295 }
203 'og:video:type': 'text/html',
204 'og:video:width': EMBED_SIZE.width,
205 'og:video:height': EMBED_SIZE.height,
206 296
207 'name': videoNameEscaped, 297 private static generateStandardMetaTags (tags) {
208 'description': videoDescriptionEscaped, 298 return {
209 'image': previewUrl, 299 name: tags.title,
300 description: tags.description,
301 image: tags.image.url
302 }
303 }
210 304
211 'twitter:card': CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary_large_image', 305 private static generateTwitterCardMetaTags (tags) {
306 const metaTags = {
307 'twitter:card': tags.twitterCard,
212 'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME, 308 'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME,
213 'twitter:title': videoNameEscaped, 309 'twitter:title': tags.title,
214 'twitter:description': videoDescriptionEscaped, 310 'twitter:description': tags.description,
215 'twitter:image': previewUrl, 311 'twitter:image': tags.image.url
216 'twitter:player': embedUrl,
217 'twitter:player:width': EMBED_SIZE.width,
218 'twitter:player:height': EMBED_SIZE.height
219 } 312 }
220 313
221 const oembedLinkTags = [ 314 if (tags.image.width && tags.image.height) {
222 { 315 metaTags['twitter:image:width'] = tags.image.width
223 type: 'application/json+oembed', 316 metaTags['twitter:image:height'] = tags.image.height
224 href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(videoUrl), 317 }
225 title: videoNameEscaped 318
226 } 319 return metaTags
227 ] 320 }
228 321
229 const schemaTags = { 322 private static generateSchemaTags (tags) {
323 const schema = {
230 '@context': 'http://schema.org', 324 '@context': 'http://schema.org',
231 '@type': 'VideoObject', 325 '@type': tags.schemaType,
232 'name': videoNameEscaped, 326 'name': tags.title,
233 'description': videoDescriptionEscaped, 327 'description': tags.description,
234 'thumbnailUrl': previewUrl, 328 'image': tags.image.url,
235 'uploadDate': video.createdAt.toISOString(), 329 'url': tags.url
236 'duration': getActivityStreamDuration(video.duration), 330 }
237 'contentUrl': videoUrl, 331
238 'embedUrl': embedUrl, 332 if (tags.list) {
239 'interactionCount': video.views 333 schema['numberOfItems'] = tags.list.numberOfItems
334 schema['thumbnailUrl'] = tags.image.url
335 }
336
337 if (tags.embed) {
338 schema['embedUrl'] = tags.embed.url
339 schema['uploadDate'] = tags.embed.createdAt
340 schema['duration'] = tags.embed.duration
341 schema['iterationCount'] = tags.embed.views
342 schema['thumbnailUrl'] = tags.image.url
343 schema['contentUrl'] = tags.url
344 }
345
346 return schema
347 }
348
349 private static addTags (htmlStringPage: string, tagsValues: any) {
350 const openGraphMetaTags = this.generateOpenGraphMetaTags(tagsValues)
351 const standardMetaTags = this.generateStandardMetaTags(tagsValues)
352 const twitterCardMetaTags = this.generateTwitterCardMetaTags(tagsValues)
353 const schemaTags = this.generateSchemaTags(tagsValues)
354
355 const { url, title, embed } = tagsValues
356
357 const oembedLinkTags = []
358
359 if (embed) {
360 oembedLinkTags.push({
361 type: 'application/json+oembed',
362 href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(url),
363 title
364 })
240 } 365 }
241 366
242 let tagsString = '' 367 let tagsString = ''
@@ -248,28 +373,33 @@ export class ClientHtml {
248 tagsString += `<meta property="${tagName}" content="${tagValue}" />` 373 tagsString += `<meta property="${tagName}" content="${tagValue}" />`
249 }) 374 })
250 375
376 // Standard
377 Object.keys(standardMetaTags).forEach(tagName => {
378 const tagValue = standardMetaTags[tagName]
379
380 tagsString += `<meta property="${tagName}" content="${tagValue}" />`
381 })
382
383 // Twitter card
384 Object.keys(twitterCardMetaTags).forEach(tagName => {
385 const tagValue = twitterCardMetaTags[tagName]
386
387 tagsString += `<meta property="${tagName}" content="${tagValue}" />`
388 })
389
251 // OEmbed 390 // OEmbed
252 for (const oembedLinkTag of oembedLinkTags) { 391 for (const oembedLinkTag of oembedLinkTags) {
253 tagsString += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.title}" />` 392 tagsString += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.title}" />`
254 } 393 }
255 394
256 // Schema.org 395 // Schema.org
257 tagsString += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>` 396 if (schemaTags) {
258 397 tagsString += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
259 // SEO, use origin video url so Google does not index remote videos 398 }
260 tagsString += `<link rel="canonical" href="${video.url}" />`
261
262 return this.addOpenGraphAndOEmbedTags(htmlStringPage, tagsString)
263 }
264
265 private static addAccountOrChannelMetaTags (htmlStringPage: string, entity: MAccountActor | MChannelActor) {
266 // SEO, use origin account or channel URL
267 const metaTags = `<link rel="canonical" href="${entity.Actor.url}" />`
268 399
269 return this.addOpenGraphAndOEmbedTags(htmlStringPage, metaTags) 400 // SEO, use origin URL
270 } 401 tagsString += `<link rel="canonical" href="${url}" />`
271 402
272 private static addOpenGraphAndOEmbedTags (htmlStringPage: string, metaTags: string) { 403 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsString)
273 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, metaTags)
274 } 404 }
275} 405}