diff options
Diffstat (limited to 'server/lib')
-rw-r--r-- | server/lib/client-html.ts | 180 |
1 files changed, 180 insertions, 0 deletions
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts new file mode 100644 index 000000000..72984e778 --- /dev/null +++ b/server/lib/client-html.ts | |||
@@ -0,0 +1,180 @@ | |||
1 | import * as express from 'express' | ||
2 | import * as Bluebird from 'bluebird' | ||
3 | import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '../../shared/models/i18n/i18n' | ||
4 | import { CONFIG, EMBED_SIZE, CUSTOM_HTML_TAG_COMMENTS, STATIC_PATHS } from '../initializers' | ||
5 | import { join } from 'path' | ||
6 | import { escapeHTML, readFileBufferPromise } from '../helpers/core-utils' | ||
7 | import { VideoModel } from '../models/video/video' | ||
8 | import * as validator from 'validator' | ||
9 | import { VideoPrivacy } from '../../shared/models/videos' | ||
10 | |||
11 | export class ClientHtml { | ||
12 | |||
13 | private static htmlCache: { [path: string]: string } = {} | ||
14 | |||
15 | static invalidCache () { | ||
16 | ClientHtml.htmlCache = {} | ||
17 | } | ||
18 | |||
19 | static async getIndexHTML (req: express.Request, res: express.Response, paramLang?: string) { | ||
20 | const path = ClientHtml.getIndexPath(req, res, paramLang) | ||
21 | if (ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] | ||
22 | |||
23 | const buffer = await readFileBufferPromise(path) | ||
24 | |||
25 | let html = buffer.toString() | ||
26 | |||
27 | html = ClientHtml.addTitleTag(html) | ||
28 | html = ClientHtml.addDescriptionTag(html) | ||
29 | html = ClientHtml.addCustomCSS(html) | ||
30 | |||
31 | ClientHtml.htmlCache[path] = html | ||
32 | |||
33 | return html | ||
34 | } | ||
35 | |||
36 | static async getWatchHTMLPage (videoId: string, req: express.Request, res: express.Response) { | ||
37 | let videoPromise: Bluebird<VideoModel> | ||
38 | |||
39 | // Let Angular application handle errors | ||
40 | if (validator.isUUID(videoId, 4)) { | ||
41 | videoPromise = VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(videoId) | ||
42 | } else if (validator.isInt(videoId)) { | ||
43 | videoPromise = VideoModel.loadAndPopulateAccountAndServerAndTags(+videoId) | ||
44 | } else { | ||
45 | return ClientHtml.getIndexHTML(req, res) | ||
46 | } | ||
47 | |||
48 | const [ html, video ] = await Promise.all([ | ||
49 | ClientHtml.getIndexHTML(req, res), | ||
50 | videoPromise | ||
51 | ]) | ||
52 | |||
53 | // Let Angular application handle errors | ||
54 | if (!video || video.privacy === VideoPrivacy.PRIVATE) { | ||
55 | return ClientHtml.getIndexHTML(req, res) | ||
56 | } | ||
57 | |||
58 | return ClientHtml.addOpenGraphAndOEmbedTags(html, video) | ||
59 | } | ||
60 | |||
61 | private static getIndexPath (req: express.Request, res: express.Response, paramLang?: string) { | ||
62 | let lang: string | ||
63 | |||
64 | // Check param lang validity | ||
65 | if (paramLang && is18nLocale(paramLang)) { | ||
66 | lang = paramLang | ||
67 | |||
68 | // Save locale in cookies | ||
69 | res.cookie('clientLanguage', lang, { | ||
70 | secure: CONFIG.WEBSERVER.SCHEME === 'https', | ||
71 | sameSite: true, | ||
72 | maxAge: 1000 * 3600 * 24 * 90 // 3 months | ||
73 | }) | ||
74 | |||
75 | } else if (req.cookies.clientLanguage && is18nLocale(req.cookies.clientLanguage)) { | ||
76 | lang = req.cookies.clientLanguage | ||
77 | } else { | ||
78 | lang = req.acceptsLanguages(POSSIBLE_LOCALES) || getDefaultLocale() | ||
79 | } | ||
80 | |||
81 | return join(__dirname, '../../../client/dist/' + buildFileLocale(lang) + '/index.html') | ||
82 | } | ||
83 | |||
84 | private static addTitleTag (htmlStringPage: string) { | ||
85 | const titleTag = '<title>' + CONFIG.INSTANCE.NAME + '</title>' | ||
86 | |||
87 | return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.TITLE, titleTag) | ||
88 | } | ||
89 | |||
90 | private static addDescriptionTag (htmlStringPage: string) { | ||
91 | const descriptionTag = `<meta name="description" content="${CONFIG.INSTANCE.SHORT_DESCRIPTION}" />` | ||
92 | |||
93 | return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag) | ||
94 | } | ||
95 | |||
96 | private static addCustomCSS (htmlStringPage: string) { | ||
97 | const styleTag = '<style class="custom-css-style">' + CONFIG.INSTANCE.CUSTOMIZATIONS.CSS + '</style>' | ||
98 | |||
99 | return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag) | ||
100 | } | ||
101 | |||
102 | private static addOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) { | ||
103 | const previewUrl = CONFIG.WEBSERVER.URL + STATIC_PATHS.PREVIEWS + video.getPreviewName() | ||
104 | const videoUrl = CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid | ||
105 | |||
106 | const videoNameEscaped = escapeHTML(video.name) | ||
107 | const videoDescriptionEscaped = escapeHTML(video.description) | ||
108 | const embedUrl = CONFIG.WEBSERVER.URL + video.getEmbedStaticPath() | ||
109 | |||
110 | const openGraphMetaTags = { | ||
111 | 'og:type': 'video', | ||
112 | 'og:title': videoNameEscaped, | ||
113 | 'og:image': previewUrl, | ||
114 | 'og:url': videoUrl, | ||
115 | 'og:description': videoDescriptionEscaped, | ||
116 | |||
117 | 'og:video:url': embedUrl, | ||
118 | 'og:video:secure_url': embedUrl, | ||
119 | 'og:video:type': 'text/html', | ||
120 | 'og:video:width': EMBED_SIZE.width, | ||
121 | 'og:video:height': EMBED_SIZE.height, | ||
122 | |||
123 | 'name': videoNameEscaped, | ||
124 | 'description': videoDescriptionEscaped, | ||
125 | 'image': previewUrl, | ||
126 | |||
127 | 'twitter:card': CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary_large_image', | ||
128 | 'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME, | ||
129 | 'twitter:title': videoNameEscaped, | ||
130 | 'twitter:description': videoDescriptionEscaped, | ||
131 | 'twitter:image': previewUrl, | ||
132 | 'twitter:player': embedUrl, | ||
133 | 'twitter:player:width': EMBED_SIZE.width, | ||
134 | 'twitter:player:height': EMBED_SIZE.height | ||
135 | } | ||
136 | |||
137 | const oembedLinkTags = [ | ||
138 | { | ||
139 | type: 'application/json+oembed', | ||
140 | href: CONFIG.WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(videoUrl), | ||
141 | title: videoNameEscaped | ||
142 | } | ||
143 | ] | ||
144 | |||
145 | const schemaTags = { | ||
146 | '@context': 'http://schema.org', | ||
147 | '@type': 'VideoObject', | ||
148 | name: videoNameEscaped, | ||
149 | description: videoDescriptionEscaped, | ||
150 | thumbnailUrl: previewUrl, | ||
151 | uploadDate: video.createdAt.toISOString(), | ||
152 | duration: video.getActivityStreamDuration(), | ||
153 | contentUrl: videoUrl, | ||
154 | embedUrl: embedUrl, | ||
155 | interactionCount: video.views | ||
156 | } | ||
157 | |||
158 | let tagsString = '' | ||
159 | |||
160 | // Opengraph | ||
161 | Object.keys(openGraphMetaTags).forEach(tagName => { | ||
162 | const tagValue = openGraphMetaTags[tagName] | ||
163 | |||
164 | tagsString += `<meta property="${tagName}" content="${tagValue}" />` | ||
165 | }) | ||
166 | |||
167 | // OEmbed | ||
168 | for (const oembedLinkTag of oembedLinkTags) { | ||
169 | tagsString += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.title}" />` | ||
170 | } | ||
171 | |||
172 | // Schema.org | ||
173 | tagsString += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>` | ||
174 | |||
175 | // SEO | ||
176 | tagsString += `<link rel="canonical" href="${videoUrl}" />` | ||
177 | |||
178 | return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.OPENGRAPH_AND_OEMBED, tagsString) | ||
179 | } | ||
180 | } | ||