aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/lib/client-html.ts
diff options
context:
space:
mode:
Diffstat (limited to 'server/lib/client-html.ts')
-rw-r--r--server/lib/client-html.ts623
1 files changed, 0 insertions, 623 deletions
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
deleted file mode 100644
index 8e0c9e328..000000000
--- a/server/lib/client-html.ts
+++ /dev/null
@@ -1,623 +0,0 @@
1import express from 'express'
2import { pathExists, readFile } from 'fs-extra'
3import { truncate } from 'lodash'
4import { join } from 'path'
5import validator from 'validator'
6import { isTestOrDevInstance } from '@server/helpers/core-utils'
7import { toCompleteUUID } from '@server/helpers/custom-validators/misc'
8import { mdToOneLinePlainText } from '@server/helpers/markdown'
9import { ActorImageModel } from '@server/models/actor/actor-image'
10import { root } from '@shared/core-utils'
11import { escapeHTML } from '@shared/core-utils/renderer'
12import { sha256 } from '@shared/extra-utils'
13import { HTMLServerConfig } from '@shared/models'
14import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '../../shared/core-utils/i18n/i18n'
15import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
16import { VideoPlaylistPrivacy, VideoPrivacy } from '../../shared/models/videos'
17import { logger } from '../helpers/logger'
18import { CONFIG } from '../initializers/config'
19import {
20 ACCEPT_HEADERS,
21 CUSTOM_HTML_TAG_COMMENTS,
22 EMBED_SIZE,
23 FILES_CONTENT_HASH,
24 PLUGIN_GLOBAL_CSS_PATH,
25 WEBSERVER
26} from '../initializers/constants'
27import { AccountModel } from '../models/account/account'
28import { VideoModel } from '../models/video/video'
29import { VideoChannelModel } from '../models/video/video-channel'
30import { VideoPlaylistModel } from '../models/video/video-playlist'
31import { MAccountHost, MChannelHost, MVideo, MVideoPlaylist } from '../types/models'
32import { getActivityStreamDuration } from './activitypub/activity'
33import { getBiggestActorImage } from './actor-image'
34import { Hooks } from './plugins/hooks'
35import { ServerConfigManager } from './server-config-manager'
36import { isVideoInPrivateDirectory } from './video-privacy'
37
38type Tags = {
39 ogType: string
40 twitterCard: 'player' | 'summary' | 'summary_large_image'
41 schemaType: string
42
43 list?: {
44 numberOfItems: number
45 }
46
47 escapedSiteName: string
48 escapedTitle: string
49 escapedTruncatedDescription: string
50
51 url: string
52 originUrl: string
53
54 disallowIndexation?: boolean
55
56 embed?: {
57 url: string
58 createdAt: string
59 duration?: string
60 views?: number
61 }
62
63 image: {
64 url: string
65 width?: number
66 height?: number
67 }
68}
69
70type HookContext = {
71 video?: MVideo
72 playlist?: MVideoPlaylist
73}
74
75class ClientHtml {
76
77 private static htmlCache: { [path: string]: string } = {}
78
79 static invalidCache () {
80 logger.info('Cleaning HTML cache.')
81
82 ClientHtml.htmlCache = {}
83 }
84
85 static async getDefaultHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
86 const html = paramLang
87 ? await ClientHtml.getIndexHTML(req, res, paramLang)
88 : await ClientHtml.getIndexHTML(req, res)
89
90 let customHtml = ClientHtml.addTitleTag(html)
91 customHtml = ClientHtml.addDescriptionTag(customHtml)
92
93 return customHtml
94 }
95
96 static async getWatchHTMLPage (videoIdArg: string, req: express.Request, res: express.Response) {
97 const videoId = toCompleteUUID(videoIdArg)
98
99 // Let Angular application handle errors
100 if (!validator.isInt(videoId) && !validator.isUUID(videoId, 4)) {
101 res.status(HttpStatusCode.NOT_FOUND_404)
102 return ClientHtml.getIndexHTML(req, res)
103 }
104
105 const [ html, video ] = await Promise.all([
106 ClientHtml.getIndexHTML(req, res),
107 VideoModel.loadWithBlacklist(videoId)
108 ])
109
110 // Let Angular application handle errors
111 if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
112 res.status(HttpStatusCode.NOT_FOUND_404)
113 return html
114 }
115 const escapedTruncatedDescription = buildEscapedTruncatedDescription(video.description)
116
117 let customHtml = ClientHtml.addTitleTag(html, video.name)
118 customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
119
120 const url = WEBSERVER.URL + video.getWatchStaticPath()
121 const originUrl = video.url
122 const title = video.name
123 const siteName = CONFIG.INSTANCE.NAME
124
125 const image = {
126 url: WEBSERVER.URL + video.getPreviewStaticPath()
127 }
128
129 const embed = {
130 url: WEBSERVER.URL + video.getEmbedStaticPath(),
131 createdAt: video.createdAt.toISOString(),
132 duration: getActivityStreamDuration(video.duration),
133 views: video.views
134 }
135
136 const ogType = 'video'
137 const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary_large_image'
138 const schemaType = 'VideoObject'
139
140 customHtml = await ClientHtml.addTags(customHtml, {
141 url,
142 originUrl,
143 escapedSiteName: escapeHTML(siteName),
144 escapedTitle: escapeHTML(title),
145 escapedTruncatedDescription,
146 disallowIndexation: video.privacy !== VideoPrivacy.PUBLIC,
147 image,
148 embed,
149 ogType,
150 twitterCard,
151 schemaType
152 }, { video })
153
154 return customHtml
155 }
156
157 static async getWatchPlaylistHTMLPage (videoPlaylistIdArg: string, req: express.Request, res: express.Response) {
158 const videoPlaylistId = toCompleteUUID(videoPlaylistIdArg)
159
160 // Let Angular application handle errors
161 if (!validator.isInt(videoPlaylistId) && !validator.isUUID(videoPlaylistId, 4)) {
162 res.status(HttpStatusCode.NOT_FOUND_404)
163 return ClientHtml.getIndexHTML(req, res)
164 }
165
166 const [ html, videoPlaylist ] = await Promise.all([
167 ClientHtml.getIndexHTML(req, res),
168 VideoPlaylistModel.loadWithAccountAndChannel(videoPlaylistId, null)
169 ])
170
171 // Let Angular application handle errors
172 if (!videoPlaylist || videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
173 res.status(HttpStatusCode.NOT_FOUND_404)
174 return html
175 }
176
177 const escapedTruncatedDescription = buildEscapedTruncatedDescription(videoPlaylist.description)
178
179 let customHtml = ClientHtml.addTitleTag(html, videoPlaylist.name)
180 customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
181
182 const url = WEBSERVER.URL + videoPlaylist.getWatchStaticPath()
183 const originUrl = videoPlaylist.url
184 const title = videoPlaylist.name
185 const siteName = CONFIG.INSTANCE.NAME
186
187 const image = {
188 url: videoPlaylist.getThumbnailUrl()
189 }
190
191 const embed = {
192 url: WEBSERVER.URL + videoPlaylist.getEmbedStaticPath(),
193 createdAt: videoPlaylist.createdAt.toISOString()
194 }
195
196 const list = {
197 numberOfItems: videoPlaylist.get('videosLength') as number
198 }
199
200 const ogType = 'video'
201 const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary'
202 const schemaType = 'ItemList'
203
204 customHtml = await ClientHtml.addTags(customHtml, {
205 url,
206 originUrl,
207 escapedSiteName: escapeHTML(siteName),
208 escapedTitle: escapeHTML(title),
209 escapedTruncatedDescription,
210 disallowIndexation: videoPlaylist.privacy !== VideoPlaylistPrivacy.PUBLIC,
211 embed,
212 image,
213 list,
214 ogType,
215 twitterCard,
216 schemaType
217 }, { playlist: videoPlaylist })
218
219 return customHtml
220 }
221
222 static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
223 const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost)
224 return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res)
225 }
226
227 static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
228 const videoChannelModelPromise = VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
229 return this.getAccountOrChannelHTMLPage(() => videoChannelModelPromise, req, res)
230 }
231
232 static async getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
233 const [ account, channel ] = await Promise.all([
234 AccountModel.loadByNameWithHost(nameWithHost),
235 VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
236 ])
237
238 return this.getAccountOrChannelHTMLPage(() => Promise.resolve(account || channel), req, res)
239 }
240
241 static async getEmbedHTML () {
242 const path = ClientHtml.getEmbedPath()
243
244 // Disable HTML cache in dev mode because webpack can regenerate JS files
245 if (!isTestOrDevInstance() && ClientHtml.htmlCache[path]) {
246 return ClientHtml.htmlCache[path]
247 }
248
249 const buffer = await readFile(path)
250 const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
251
252 let html = buffer.toString()
253 html = await ClientHtml.addAsyncPluginCSS(html)
254 html = ClientHtml.addCustomCSS(html)
255 html = ClientHtml.addTitleTag(html)
256 html = ClientHtml.addDescriptionTag(html)
257 html = ClientHtml.addServerConfig(html, serverConfig)
258
259 ClientHtml.htmlCache[path] = html
260
261 return html
262 }
263
264 private static async getAccountOrChannelHTMLPage (
265 loader: () => Promise<MAccountHost | MChannelHost>,
266 req: express.Request,
267 res: express.Response
268 ) {
269 const [ html, entity ] = await Promise.all([
270 ClientHtml.getIndexHTML(req, res),
271 loader()
272 ])
273
274 // Let Angular application handle errors
275 if (!entity) {
276 res.status(HttpStatusCode.NOT_FOUND_404)
277 return ClientHtml.getIndexHTML(req, res)
278 }
279
280 const escapedTruncatedDescription = buildEscapedTruncatedDescription(entity.description)
281
282 let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName())
283 customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
284
285 const url = entity.getClientUrl()
286 const originUrl = entity.Actor.url
287 const siteName = CONFIG.INSTANCE.NAME
288 const title = entity.getDisplayName()
289
290 const avatar = getBiggestActorImage(entity.Actor.Avatars)
291 const image = {
292 url: ActorImageModel.getImageUrl(avatar),
293 width: avatar?.width,
294 height: avatar?.height
295 }
296
297 const ogType = 'website'
298 const twitterCard = 'summary'
299 const schemaType = 'ProfilePage'
300
301 customHtml = await ClientHtml.addTags(customHtml, {
302 url,
303 originUrl,
304 escapedTitle: escapeHTML(title),
305 escapedSiteName: escapeHTML(siteName),
306 escapedTruncatedDescription,
307 image,
308 ogType,
309 twitterCard,
310 schemaType,
311 disallowIndexation: !entity.Actor.isOwned()
312 }, {})
313
314 return customHtml
315 }
316
317 private static async getIndexHTML (req: express.Request, res: express.Response, paramLang?: string) {
318 const path = ClientHtml.getIndexPath(req, res, paramLang)
319 if (ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
320
321 const buffer = await readFile(path)
322 const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
323
324 let html = buffer.toString()
325
326 html = ClientHtml.addManifestContentHash(html)
327 html = ClientHtml.addFaviconContentHash(html)
328 html = ClientHtml.addLogoContentHash(html)
329 html = ClientHtml.addCustomCSS(html)
330 html = ClientHtml.addServerConfig(html, serverConfig)
331 html = await ClientHtml.addAsyncPluginCSS(html)
332
333 ClientHtml.htmlCache[path] = html
334
335 return html
336 }
337
338 private static getIndexPath (req: express.Request, res: express.Response, paramLang: string) {
339 let lang: string
340
341 // Check param lang validity
342 if (paramLang && is18nLocale(paramLang)) {
343 lang = paramLang
344
345 // Save locale in cookies
346 res.cookie('clientLanguage', lang, {
347 secure: WEBSERVER.SCHEME === 'https',
348 sameSite: 'none',
349 maxAge: 1000 * 3600 * 24 * 90 // 3 months
350 })
351
352 } else if (req.cookies.clientLanguage && is18nLocale(req.cookies.clientLanguage)) {
353 lang = req.cookies.clientLanguage
354 } else {
355 lang = req.acceptsLanguages(POSSIBLE_LOCALES) || getDefaultLocale()
356 }
357
358 logger.debug(
359 'Serving %s HTML language', buildFileLocale(lang),
360 { cookie: req.cookies?.clientLanguage, paramLang, acceptLanguage: req.headers['accept-language'] }
361 )
362
363 return join(root(), 'client', 'dist', buildFileLocale(lang), 'index.html')
364 }
365
366 private static getEmbedPath () {
367 return join(root(), 'client', 'dist', 'standalone', 'videos', 'embed.html')
368 }
369
370 private static addManifestContentHash (htmlStringPage: string) {
371 return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST)
372 }
373
374 private static addFaviconContentHash (htmlStringPage: string) {
375 return htmlStringPage.replace('[faviconContentHash]', FILES_CONTENT_HASH.FAVICON)
376 }
377
378 private static addLogoContentHash (htmlStringPage: string) {
379 return htmlStringPage.replace('[logoContentHash]', FILES_CONTENT_HASH.LOGO)
380 }
381
382 private static addTitleTag (htmlStringPage: string, title?: string) {
383 let text = title || CONFIG.INSTANCE.NAME
384 if (title) text += ` - ${CONFIG.INSTANCE.NAME}`
385
386 const titleTag = `<title>${escapeHTML(text)}</title>`
387
388 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.TITLE, titleTag)
389 }
390
391 private static addDescriptionTag (htmlStringPage: string, escapedTruncatedDescription?: string) {
392 const content = escapedTruncatedDescription || escapeHTML(CONFIG.INSTANCE.SHORT_DESCRIPTION)
393 const descriptionTag = `<meta name="description" content="${content}" />`
394
395 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag)
396 }
397
398 private static addCustomCSS (htmlStringPage: string) {
399 const styleTag = `<style class="custom-css-style">${CONFIG.INSTANCE.CUSTOMIZATIONS.CSS}</style>`
400
401 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag)
402 }
403
404 private static addServerConfig (htmlStringPage: string, serverConfig: HTMLServerConfig) {
405 // Stringify the JSON object, and then stringify the string object so we can inject it into the HTML
406 const serverConfigString = JSON.stringify(JSON.stringify(serverConfig))
407 const configScriptTag = `<script type="application/javascript">window.PeerTubeServerConfig = ${serverConfigString}</script>`
408
409 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.SERVER_CONFIG, configScriptTag)
410 }
411
412 private static async addAsyncPluginCSS (htmlStringPage: string) {
413 if (!pathExists(PLUGIN_GLOBAL_CSS_PATH)) {
414 logger.info('Plugin Global CSS file is not available (generation may still be in progress), ignoring it.')
415 return htmlStringPage
416 }
417
418 let globalCSSContent: Buffer
419
420 try {
421 globalCSSContent = await readFile(PLUGIN_GLOBAL_CSS_PATH)
422 } catch (err) {
423 logger.error('Error retrieving the Plugin Global CSS file, ignoring it.', { err })
424 return htmlStringPage
425 }
426
427 if (globalCSSContent.byteLength === 0) return htmlStringPage
428
429 const fileHash = sha256(globalCSSContent)
430 const linkTag = `<link rel="stylesheet" href="/plugins/global.css?hash=${fileHash}" />`
431
432 return htmlStringPage.replace('</head>', linkTag + '</head>')
433 }
434
435 private static generateOpenGraphMetaTags (tags: Tags) {
436 const metaTags = {
437 'og:type': tags.ogType,
438 'og:site_name': tags.escapedSiteName,
439 'og:title': tags.escapedTitle,
440 'og:image': tags.image.url
441 }
442
443 if (tags.image.width && tags.image.height) {
444 metaTags['og:image:width'] = tags.image.width
445 metaTags['og:image:height'] = tags.image.height
446 }
447
448 metaTags['og:url'] = tags.url
449 metaTags['og:description'] = tags.escapedTruncatedDescription
450
451 if (tags.embed) {
452 metaTags['og:video:url'] = tags.embed.url
453 metaTags['og:video:secure_url'] = tags.embed.url
454 metaTags['og:video:type'] = 'text/html'
455 metaTags['og:video:width'] = EMBED_SIZE.width
456 metaTags['og:video:height'] = EMBED_SIZE.height
457 }
458
459 return metaTags
460 }
461
462 private static generateStandardMetaTags (tags: Tags) {
463 return {
464 name: tags.escapedTitle,
465 description: tags.escapedTruncatedDescription,
466 image: tags.image.url
467 }
468 }
469
470 private static generateTwitterCardMetaTags (tags: Tags) {
471 const metaTags = {
472 'twitter:card': tags.twitterCard,
473 'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME,
474 'twitter:title': tags.escapedTitle,
475 'twitter:description': tags.escapedTruncatedDescription,
476 'twitter:image': tags.image.url
477 }
478
479 if (tags.image.width && tags.image.height) {
480 metaTags['twitter:image:width'] = tags.image.width
481 metaTags['twitter:image:height'] = tags.image.height
482 }
483
484 if (tags.twitterCard === 'player') {
485 metaTags['twitter:player'] = tags.embed.url
486 metaTags['twitter:player:width'] = EMBED_SIZE.width
487 metaTags['twitter:player:height'] = EMBED_SIZE.height
488 }
489
490 return metaTags
491 }
492
493 private static async generateSchemaTags (tags: Tags, context: HookContext) {
494 const schema = {
495 '@context': 'http://schema.org',
496 '@type': tags.schemaType,
497 'name': tags.escapedTitle,
498 'description': tags.escapedTruncatedDescription,
499 'image': tags.image.url,
500 'url': tags.url
501 }
502
503 if (tags.list) {
504 schema['numberOfItems'] = tags.list.numberOfItems
505 schema['thumbnailUrl'] = tags.image.url
506 }
507
508 if (tags.embed) {
509 schema['embedUrl'] = tags.embed.url
510 schema['uploadDate'] = tags.embed.createdAt
511
512 if (tags.embed.duration) schema['duration'] = tags.embed.duration
513
514 schema['thumbnailUrl'] = tags.image.url
515 schema['contentUrl'] = tags.url
516 }
517
518 return Hooks.wrapObject(schema, 'filter:html.client.json-ld.result', context)
519 }
520
521 private static async addTags (htmlStringPage: string, tagsValues: Tags, context: HookContext) {
522 const openGraphMetaTags = this.generateOpenGraphMetaTags(tagsValues)
523 const standardMetaTags = this.generateStandardMetaTags(tagsValues)
524 const twitterCardMetaTags = this.generateTwitterCardMetaTags(tagsValues)
525 const schemaTags = await this.generateSchemaTags(tagsValues, context)
526
527 const { url, escapedTitle, embed, originUrl, disallowIndexation } = tagsValues
528
529 const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = []
530
531 if (embed) {
532 oembedLinkTags.push({
533 type: 'application/json+oembed',
534 href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(url),
535 escapedTitle
536 })
537 }
538
539 let tagsStr = ''
540
541 // Opengraph
542 Object.keys(openGraphMetaTags).forEach(tagName => {
543 const tagValue = openGraphMetaTags[tagName]
544
545 tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
546 })
547
548 // Standard
549 Object.keys(standardMetaTags).forEach(tagName => {
550 const tagValue = standardMetaTags[tagName]
551
552 tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
553 })
554
555 // Twitter card
556 Object.keys(twitterCardMetaTags).forEach(tagName => {
557 const tagValue = twitterCardMetaTags[tagName]
558
559 tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
560 })
561
562 // OEmbed
563 for (const oembedLinkTag of oembedLinkTags) {
564 tagsStr += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.escapedTitle}" />`
565 }
566
567 // Schema.org
568 if (schemaTags) {
569 tagsStr += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
570 }
571
572 // SEO, use origin URL
573 tagsStr += `<link rel="canonical" href="${originUrl}" />`
574
575 if (disallowIndexation) {
576 tagsStr += `<meta name="robots" content="noindex" />`
577 }
578
579 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsStr)
580 }
581}
582
583function sendHTML (html: string, res: express.Response, localizedHTML: boolean = false) {
584 res.set('Content-Type', 'text/html; charset=UTF-8')
585
586 if (localizedHTML) {
587 res.set('Vary', 'Accept-Language')
588 }
589
590 return res.send(html)
591}
592
593async function serveIndexHTML (req: express.Request, res: express.Response) {
594 if (req.accepts(ACCEPT_HEADERS) === 'html' || !req.headers.accept) {
595 try {
596 await generateHTMLPage(req, res, req.params.language)
597 return
598 } catch (err) {
599 logger.error('Cannot generate HTML page.', { err })
600 return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end()
601 }
602 }
603
604 return res.status(HttpStatusCode.NOT_ACCEPTABLE_406).end()
605}
606
607// ---------------------------------------------------------------------------
608
609export {
610 ClientHtml,
611 sendHTML,
612 serveIndexHTML
613}
614
615async function generateHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
616 const html = await ClientHtml.getDefaultHTMLPage(req, res, paramLang)
617
618 return sendHTML(html, res, true)
619}
620
621function buildEscapedTruncatedDescription (description: string) {
622 return truncate(mdToOneLinePlainText(description), { length: 200 })
623}