diff options
-rw-r--r-- | client/src/app/app.component.ts | 42 | ||||
-rw-r--r-- | client/src/index.html | 14 | ||||
-rwxr-xr-x | scripts/build/client.sh | 23 | ||||
-rw-r--r-- | server/controllers/api/config.ts | 3 | ||||
-rw-r--r-- | server/controllers/client.ts | 150 | ||||
-rw-r--r-- | server/initializers/constants.ts | 9 | ||||
-rw-r--r-- | server/lib/client-html.ts | 180 | ||||
-rw-r--r-- | server/tests/api/server/config.ts | 26 | ||||
-rw-r--r-- | server/tests/real-world/populate-database.ts | 11 | ||||
-rw-r--r-- | server/tests/utils/requests/requests.ts | 8 |
10 files changed, 291 insertions, 175 deletions
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index fc4d6c6a2..2149768a2 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts | |||
@@ -4,6 +4,7 @@ import { GuardsCheckStart, NavigationEnd, Router } from '@angular/router' | |||
4 | import { AuthService, RedirectService, ServerService } from '@app/core' | 4 | import { AuthService, RedirectService, ServerService } from '@app/core' |
5 | import { is18nPath } from '../../../shared/models/i18n' | 5 | import { is18nPath } from '../../../shared/models/i18n' |
6 | import { ScreenService } from '@app/shared/misc/screen.service' | 6 | import { ScreenService } from '@app/shared/misc/screen.service' |
7 | import { skip } from 'rxjs/operators' | ||
7 | 8 | ||
8 | @Component({ | 9 | @Component({ |
9 | selector: 'my-app', | 10 | selector: 'my-app', |
@@ -89,25 +90,36 @@ export class AppComponent implements OnInit { | |||
89 | } | 90 | } |
90 | ) | 91 | ) |
91 | 92 | ||
93 | // Inject JS | ||
92 | this.serverService.configLoaded | 94 | this.serverService.configLoaded |
93 | .subscribe(() => { | 95 | .subscribe(() => { |
94 | const config = this.serverService.getConfig() | 96 | const config = this.serverService.getConfig() |
97 | |||
98 | if (config.instance.customizations.javascript) { | ||
99 | try { | ||
100 | // tslint:disable:no-eval | ||
101 | eval(config.instance.customizations.javascript) | ||
102 | } catch (err) { | ||
103 | console.error('Cannot eval custom JavaScript.', err) | ||
104 | } | ||
105 | } | ||
106 | }) | ||
95 | 107 | ||
96 | // We test customCSS if the admin removed the css | 108 | // Inject CSS if modified (admin config settings) |
97 | if (this.customCSS || config.instance.customizations.css) { | 109 | this.serverService.configLoaded |
98 | const styleTag = '<style>' + config.instance.customizations.css + '</style>' | 110 | .pipe(skip(1)) // We only want to subscribe to reloads, because the CSS is already injected by the server |
99 | this.customCSS = this.domSanitizer.bypassSecurityTrustHtml(styleTag) | 111 | .subscribe(() => { |
100 | } | 112 | const headStyle = document.querySelector('style.custom-css-style') |
113 | if (headStyle) headStyle.parentNode.removeChild(headStyle) | ||
101 | 114 | ||
102 | if (config.instance.customizations.javascript) { | 115 | const config = this.serverService.getConfig() |
103 | try { | 116 | |
104 | // tslint:disable:no-eval | 117 | // We test customCSS if the admin removed the css |
105 | eval(config.instance.customizations.javascript) | 118 | if (this.customCSS || config.instance.customizations.css) { |
106 | } catch (err) { | 119 | const styleTag = '<style>' + config.instance.customizations.css + '</style>' |
107 | console.error('Cannot eval custom JavaScript.', err) | 120 | this.customCSS = this.domSanitizer.bypassSecurityTrustHtml(styleTag) |
108 | } | 121 | } |
109 | } | 122 | }) |
110 | }) | ||
111 | } | 123 | } |
112 | 124 | ||
113 | isUserLoggedIn () { | 125 | isUserLoggedIn () { |
diff --git a/client/src/index.html b/client/src/index.html index a57df3a93..f00af8bff 100644 --- a/client/src/index.html +++ b/client/src/index.html | |||
@@ -1,20 +1,22 @@ | |||
1 | <!DOCTYPE html> | 1 | <!DOCTYPE html> |
2 | <html> | 2 | <html> |
3 | <head> | 3 | <head> |
4 | <title>PeerTube</title> | ||
5 | |||
6 | <meta charset="UTF-8"> | 4 | <meta charset="UTF-8"> |
7 | <meta name="viewport" content="width=device-width, initial-scale=1"> | 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
8 | <meta name="description" content="PeerTube, a decentralized video streaming platform using P2P (BitTorrent) directly in the web browser" /> | ||
9 | 6 | ||
10 | <meta name="theme-color" content="#fff" /> | 7 | <meta name="theme-color" content="#fff" /> |
11 | 8 | ||
12 | <!-- Web Manifest file --> | 9 | <!-- Web Manifest file --> |
13 | <link rel="manifest" href="/manifest.json"> | 10 | <link rel="manifest" href="/manifest.json"> |
14 | 11 | ||
15 | <!-- The following comment is used by the server to prerender OpenGraph and oEmbed tags --> | 12 | <!-- /!\ The following comment is used by the server to prerender some tags /!\ --> |
16 | <!-- open graph and oembed tags --> | 13 | |
17 | <!-- Do not remove it! --> | 14 | <!-- title tag --> |
15 | <!-- description tag --> | ||
16 | <!-- custom css tag --> | ||
17 | <!-- open graph and oembed tags --> | ||
18 | |||
19 | <!-- /!\ Do not remove it /!\ --> | ||
18 | 20 | ||
19 | <link rel="icon" type="image/png" href="/client/assets/images/favicon.png" /> | 21 | <link rel="icon" type="image/png" href="/client/assets/images/favicon.png" /> |
20 | 22 | ||
diff --git a/scripts/build/client.sh b/scripts/build/client.sh index 106532c4f..e4d053e82 100755 --- a/scripts/build/client.sh +++ b/scripts/build/client.sh | |||
@@ -10,16 +10,19 @@ defaultLanguage="en_US" | |||
10 | npm run ng build -- --output-path "dist/$defaultLanguage/" --deploy-url "/client/$defaultLanguage/" --prod --stats-json | 10 | npm run ng build -- --output-path "dist/$defaultLanguage/" --deploy-url "/client/$defaultLanguage/" --prod --stats-json |
11 | mv "./dist/$defaultLanguage/assets" "./dist" | 11 | mv "./dist/$defaultLanguage/assets" "./dist" |
12 | 12 | ||
13 | # Supported languages | 13 | # Don't build other languages if --light arg is provided |
14 | languages=("fr_FR" "eu_ES" "ca_ES" "cs_CZ" "eo") | 14 | if [ -z ${1+x} ] || [ "$1" != "--light" ]; then |
15 | 15 | # Supported languages | |
16 | for lang in "${languages[@]}"; do | 16 | languages=("fr_FR" "eu_ES" "ca_ES" "cs_CZ" "eo") |
17 | npm run ng build -- --prod --i18n-file "./src/locale/target/angular_$lang.xml" --i18n-format xlf --i18n-locale "$lang" \ | 17 | |
18 | --output-path "dist/$lang/" --deploy-url "/client/$lang/" | 18 | for lang in "${languages[@]}"; do |
19 | 19 | npm run ng build -- --prod --i18n-file "./src/locale/target/angular_$lang.xml" --i18n-format xlf --i18n-locale "$lang" \ | |
20 | # Do no duplicate assets | 20 | --output-path "dist/$lang/" --deploy-url "/client/$lang/" |
21 | rm -r "./dist/$lang/assets" | 21 | |
22 | done | 22 | # Do no duplicate assets |
23 | rm -r "./dist/$lang/assets" | ||
24 | done | ||
25 | fi | ||
23 | 26 | ||
24 | NODE_ENV=production npm run webpack -- --config webpack/webpack.video-embed.js --mode production --json > "./dist/embed-stats.json" | 27 | NODE_ENV=production npm run webpack -- --config webpack/webpack.video-embed.js --mode production --json > "./dist/embed-stats.json" |
25 | 28 | ||
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index 3788975a9..9c1b2818c 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts | |||
@@ -8,6 +8,7 @@ import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../helpers/util | |||
8 | import { CONFIG, CONSTRAINTS_FIELDS, reloadConfig } from '../../initializers' | 8 | import { CONFIG, CONSTRAINTS_FIELDS, reloadConfig } from '../../initializers' |
9 | import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares' | 9 | import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares' |
10 | import { customConfigUpdateValidator } from '../../middlewares/validators/config' | 10 | import { customConfigUpdateValidator } from '../../middlewares/validators/config' |
11 | import { ClientHtml } from '../../lib/client-html' | ||
11 | 12 | ||
12 | const packageJSON = require('../../../../package.json') | 13 | const packageJSON = require('../../../../package.json') |
13 | const configRouter = express.Router() | 14 | const configRouter = express.Router() |
@@ -119,6 +120,7 @@ async function deleteCustomConfig (req: express.Request, res: express.Response, | |||
119 | await unlinkPromise(CONFIG.CUSTOM_FILE) | 120 | await unlinkPromise(CONFIG.CUSTOM_FILE) |
120 | 121 | ||
121 | reloadConfig() | 122 | reloadConfig() |
123 | ClientHtml.invalidCache() | ||
122 | 124 | ||
123 | const data = customConfig() | 125 | const data = customConfig() |
124 | 126 | ||
@@ -145,6 +147,7 @@ async function updateCustomConfig (req: express.Request, res: express.Response, | |||
145 | await writeFilePromise(CONFIG.CUSTOM_FILE, JSON.stringify(toUpdateJSON, undefined, 2)) | 147 | await writeFilePromise(CONFIG.CUSTOM_FILE, JSON.stringify(toUpdateJSON, undefined, 2)) |
146 | 148 | ||
147 | reloadConfig() | 149 | reloadConfig() |
150 | ClientHtml.invalidCache() | ||
148 | 151 | ||
149 | const data = customConfig() | 152 | const data = customConfig() |
150 | return res.json(data).end() | 153 | return res.json(data).end() |
diff --git a/server/controllers/client.ts b/server/controllers/client.ts index 13ca15e9d..352d45fbf 100644 --- a/server/controllers/client.ts +++ b/server/controllers/client.ts | |||
@@ -1,21 +1,10 @@ | |||
1 | import * as Bluebird from 'bluebird' | ||
2 | import * as express from 'express' | 1 | import * as express from 'express' |
3 | import * as helmet from 'helmet' | ||
4 | import { join } from 'path' | 2 | import { join } from 'path' |
5 | import * as validator from 'validator' | 3 | import { root } from '../helpers/core-utils' |
6 | import { escapeHTML, readFileBufferPromise, root } from '../helpers/core-utils' | 4 | import { ACCEPT_HEADERS, STATIC_MAX_AGE } from '../initializers' |
7 | import { ACCEPT_HEADERS, CONFIG, EMBED_SIZE, OPENGRAPH_AND_OEMBED_COMMENT, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers' | ||
8 | import { asyncMiddleware } from '../middlewares' | 5 | import { asyncMiddleware } from '../middlewares' |
9 | import { VideoModel } from '../models/video/video' | 6 | import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '../../shared/models/i18n/i18n' |
10 | import { VideoPrivacy } from '../../shared/models/videos' | 7 | import { ClientHtml } from '../lib/client-html' |
11 | import { | ||
12 | buildFileLocale, | ||
13 | getCompleteLocale, | ||
14 | getDefaultLocale, | ||
15 | is18nLocale, | ||
16 | LOCALE_FILES, | ||
17 | POSSIBLE_LOCALES | ||
18 | } from '../../shared/models/i18n/i18n' | ||
19 | 8 | ||
20 | const clientsRouter = express.Router() | 9 | const clientsRouter = express.Router() |
21 | 10 | ||
@@ -79,7 +68,7 @@ clientsRouter.use('/client/*', (req: express.Request, res: express.Response, nex | |||
79 | // Try to provide the right language index.html | 68 | // Try to provide the right language index.html |
80 | clientsRouter.use('/(:language)?', function (req, res) { | 69 | clientsRouter.use('/(:language)?', function (req, res) { |
81 | if (req.accepts(ACCEPT_HEADERS) === 'html') { | 70 | if (req.accepts(ACCEPT_HEADERS) === 'html') { |
82 | return res.sendFile(getIndexPath(req, res, req.params.language)) | 71 | return generateHTMLPage(req, res, req.params.language) |
83 | } | 72 | } |
84 | 73 | ||
85 | return res.status(404).end() | 74 | return res.status(404).end() |
@@ -93,131 +82,20 @@ export { | |||
93 | 82 | ||
94 | // --------------------------------------------------------------------------- | 83 | // --------------------------------------------------------------------------- |
95 | 84 | ||
96 | function getIndexPath (req: express.Request, res: express.Response, paramLang?: string) { | 85 | async function generateHTMLPage (req: express.Request, res: express.Response, paramLang?: string) { |
97 | let lang: string | 86 | const html = await ClientHtml.getIndexHTML(req, res) |
98 | 87 | ||
99 | // Check param lang validity | 88 | return sendHTML(html, res) |
100 | if (paramLang && is18nLocale(paramLang)) { | ||
101 | lang = paramLang | ||
102 | |||
103 | // Save locale in cookies | ||
104 | res.cookie('clientLanguage', lang, { | ||
105 | secure: CONFIG.WEBSERVER.SCHEME === 'https', | ||
106 | sameSite: true, | ||
107 | maxAge: 1000 * 3600 * 24 * 90 // 3 months | ||
108 | }) | ||
109 | |||
110 | } else if (req.cookies.clientLanguage && is18nLocale(req.cookies.clientLanguage)) { | ||
111 | lang = req.cookies.clientLanguage | ||
112 | } else { | ||
113 | lang = req.acceptsLanguages(POSSIBLE_LOCALES) || getDefaultLocale() | ||
114 | } | ||
115 | |||
116 | return join(__dirname, '../../../client/dist/' + buildFileLocale(lang) + '/index.html') | ||
117 | } | 89 | } |
118 | 90 | ||
119 | function addOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) { | 91 | async function generateWatchHtmlPage (req: express.Request, res: express.Response) { |
120 | const previewUrl = CONFIG.WEBSERVER.URL + STATIC_PATHS.PREVIEWS + video.getPreviewName() | 92 | const html = await ClientHtml.getWatchHTMLPage(req.params.id + '', req, res) |
121 | const videoUrl = CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid | ||
122 | |||
123 | const videoNameEscaped = escapeHTML(video.name) | ||
124 | const videoDescriptionEscaped = escapeHTML(video.description) | ||
125 | const embedUrl = CONFIG.WEBSERVER.URL + video.getEmbedStaticPath() | ||
126 | |||
127 | const openGraphMetaTags = { | ||
128 | 'og:type': 'video', | ||
129 | 'og:title': videoNameEscaped, | ||
130 | 'og:image': previewUrl, | ||
131 | 'og:url': videoUrl, | ||
132 | 'og:description': videoDescriptionEscaped, | ||
133 | |||
134 | 'og:video:url': embedUrl, | ||
135 | 'og:video:secure_url': embedUrl, | ||
136 | 'og:video:type': 'text/html', | ||
137 | 'og:video:width': EMBED_SIZE.width, | ||
138 | 'og:video:height': EMBED_SIZE.height, | ||
139 | |||
140 | 'name': videoNameEscaped, | ||
141 | 'description': videoDescriptionEscaped, | ||
142 | 'image': previewUrl, | ||
143 | |||
144 | 'twitter:card': CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary_large_image', | ||
145 | 'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME, | ||
146 | 'twitter:title': videoNameEscaped, | ||
147 | 'twitter:description': videoDescriptionEscaped, | ||
148 | 'twitter:image': previewUrl, | ||
149 | 'twitter:player': embedUrl, | ||
150 | 'twitter:player:width': EMBED_SIZE.width, | ||
151 | 'twitter:player:height': EMBED_SIZE.height | ||
152 | } | ||
153 | |||
154 | const oembedLinkTags = [ | ||
155 | { | ||
156 | type: 'application/json+oembed', | ||
157 | href: CONFIG.WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(videoUrl), | ||
158 | title: videoNameEscaped | ||
159 | } | ||
160 | ] | ||
161 | |||
162 | const schemaTags = { | ||
163 | '@context': 'http://schema.org', | ||
164 | '@type': 'VideoObject', | ||
165 | name: videoNameEscaped, | ||
166 | description: videoDescriptionEscaped, | ||
167 | thumbnailUrl: previewUrl, | ||
168 | uploadDate: video.createdAt.toISOString(), | ||
169 | duration: video.getActivityStreamDuration(), | ||
170 | contentUrl: videoUrl, | ||
171 | embedUrl: embedUrl, | ||
172 | interactionCount: video.views | ||
173 | } | ||
174 | |||
175 | let tagsString = '' | ||
176 | |||
177 | // Opengraph | ||
178 | Object.keys(openGraphMetaTags).forEach(tagName => { | ||
179 | const tagValue = openGraphMetaTags[tagName] | ||
180 | 93 | ||
181 | tagsString += `<meta property="${tagName}" content="${tagValue}" />` | 94 | return sendHTML(html, res) |
182 | }) | ||
183 | |||
184 | // OEmbed | ||
185 | for (const oembedLinkTag of oembedLinkTags) { | ||
186 | tagsString += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.title}" />` | ||
187 | } | ||
188 | |||
189 | // Schema.org | ||
190 | tagsString += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>` | ||
191 | |||
192 | // SEO | ||
193 | tagsString += `<link rel="canonical" href="${videoUrl}" />` | ||
194 | |||
195 | return htmlStringPage.replace(OPENGRAPH_AND_OEMBED_COMMENT, tagsString) | ||
196 | } | 95 | } |
197 | 96 | ||
198 | async function generateWatchHtmlPage (req: express.Request, res: express.Response, next: express.NextFunction) { | 97 | function sendHTML (html: string, res: express.Response) { |
199 | const videoId = '' + req.params.id | 98 | res.set('Content-Type', 'text/html; charset=UTF-8') |
200 | let videoPromise: Bluebird<VideoModel> | ||
201 | |||
202 | // Let Angular application handle errors | ||
203 | if (validator.isUUID(videoId, 4)) { | ||
204 | videoPromise = VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(videoId) | ||
205 | } else if (validator.isInt(videoId)) { | ||
206 | videoPromise = VideoModel.loadAndPopulateAccountAndServerAndTags(+videoId) | ||
207 | } else { | ||
208 | return res.sendFile(getIndexPath(req, res)) | ||
209 | } | ||
210 | |||
211 | let [ file, video ] = await Promise.all([ | ||
212 | readFileBufferPromise(getIndexPath(req, res)), | ||
213 | videoPromise | ||
214 | ]) | ||
215 | |||
216 | const html = file.toString() | ||
217 | |||
218 | // Let Angular application handle errors | ||
219 | if (!video || video.privacy === VideoPrivacy.PRIVATE) return res.sendFile(getIndexPath(req, res)) | ||
220 | 99 | ||
221 | const htmlStringPageWithTags = addOpenGraphAndOEmbedTags(html, video) | 100 | return res.send(html) |
222 | res.set('Content-Type', 'text/html; charset=UTF-8').send(htmlStringPageWithTags) | ||
223 | } | 101 | } |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index e844c8203..ba48399de 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -466,7 +466,12 @@ const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENT | |||
466 | 466 | ||
467 | // --------------------------------------------------------------------------- | 467 | // --------------------------------------------------------------------------- |
468 | 468 | ||
469 | const OPENGRAPH_AND_OEMBED_COMMENT = '<!-- open graph and oembed tags -->' | 469 | const CUSTOM_HTML_TAG_COMMENTS = { |
470 | TITLE: '<!-- title tag -->', | ||
471 | DESCRIPTION: '<!-- description tag -->', | ||
472 | CUSTOM_CSS: '<!-- custom css tag -->', | ||
473 | OPENGRAPH_AND_OEMBED: '<!-- open graph and oembed tags -->' | ||
474 | } | ||
470 | 475 | ||
471 | // --------------------------------------------------------------------------- | 476 | // --------------------------------------------------------------------------- |
472 | 477 | ||
@@ -528,7 +533,7 @@ export { | |||
528 | JOB_ATTEMPTS, | 533 | JOB_ATTEMPTS, |
529 | LAST_MIGRATION_VERSION, | 534 | LAST_MIGRATION_VERSION, |
530 | OAUTH_LIFETIME, | 535 | OAUTH_LIFETIME, |
531 | OPENGRAPH_AND_OEMBED_COMMENT, | 536 | CUSTOM_HTML_TAG_COMMENTS, |
532 | BROADCAST_CONCURRENCY, | 537 | BROADCAST_CONCURRENCY, |
533 | PAGINATION_COUNT_DEFAULT, | 538 | PAGINATION_COUNT_DEFAULT, |
534 | ACTOR_FOLLOW_SCORE, | 539 | ACTOR_FOLLOW_SCORE, |
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 | } | ||
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts index 79b5aaf2d..7d21b6ce9 100644 --- a/server/tests/api/server/config.ts +++ b/server/tests/api/server/config.ts | |||
@@ -4,7 +4,7 @@ import 'mocha' | |||
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { About } from '../../../../shared/models/server/about.model' | 5 | import { About } from '../../../../shared/models/server/about.model' |
6 | import { CustomConfig } from '../../../../shared/models/server/custom-config.model' | 6 | import { CustomConfig } from '../../../../shared/models/server/custom-config.model' |
7 | import { deleteCustomConfig, getAbout, killallServers, reRunServer } from '../../utils' | 7 | import { deleteCustomConfig, getAbout, killallServers, makeHTMLRequest, reRunServer } from '../../utils' |
8 | const expect = chai.expect | 8 | const expect = chai.expect |
9 | 9 | ||
10 | import { | 10 | import { |
@@ -69,6 +69,12 @@ function checkUpdatedConfig (data: CustomConfig) { | |||
69 | expect(data.transcoding.resolutions['1080p']).to.be.false | 69 | expect(data.transcoding.resolutions['1080p']).to.be.false |
70 | } | 70 | } |
71 | 71 | ||
72 | function checkIndexTags (html: string, title: string, description: string, css: string) { | ||
73 | expect(html).to.contain('<title>' + title + '</title>') | ||
74 | expect(html).to.contain('<meta name="description" content="' + description + '" />') | ||
75 | expect(html).to.contain('<style class="custom-css-style">' + css + '</style>') | ||
76 | } | ||
77 | |||
72 | describe('Test config', function () { | 78 | describe('Test config', function () { |
73 | let server = null | 79 | let server = null |
74 | 80 | ||
@@ -109,6 +115,14 @@ describe('Test config', function () { | |||
109 | checkInitialConfig(data) | 115 | checkInitialConfig(data) |
110 | }) | 116 | }) |
111 | 117 | ||
118 | it('Should have valid index html tags (title, description...)', async function () { | ||
119 | const res = await makeHTMLRequest(server.url, '/videos/trending') | ||
120 | |||
121 | const description = 'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' + | ||
122 | 'with WebTorrent and Angular.' | ||
123 | checkIndexTags(res.text, 'PeerTube', description, '') | ||
124 | }) | ||
125 | |||
112 | it('Should update the customized configuration', async function () { | 126 | it('Should update the customized configuration', async function () { |
113 | const newCustomConfig: CustomConfig = { | 127 | const newCustomConfig: CustomConfig = { |
114 | instance: { | 128 | instance: { |
@@ -167,6 +181,12 @@ describe('Test config', function () { | |||
167 | checkUpdatedConfig(data) | 181 | checkUpdatedConfig(data) |
168 | }) | 182 | }) |
169 | 183 | ||
184 | it('Should have valid index html updated tags (title, description...)', async function () { | ||
185 | const res = await makeHTMLRequest(server.url, '/videos/trending') | ||
186 | |||
187 | checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }') | ||
188 | }) | ||
189 | |||
170 | it('Should have the configuration updated after a restart', async function () { | 190 | it('Should have the configuration updated after a restart', async function () { |
171 | this.timeout(10000) | 191 | this.timeout(10000) |
172 | 192 | ||
@@ -178,6 +198,10 @@ describe('Test config', function () { | |||
178 | const data = res.body | 198 | const data = res.body |
179 | 199 | ||
180 | checkUpdatedConfig(data) | 200 | checkUpdatedConfig(data) |
201 | |||
202 | // Check HTML too | ||
203 | const resHtml = await makeHTMLRequest(server.url, '/videos/trending') | ||
204 | checkIndexTags(resHtml.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }') | ||
181 | }) | 205 | }) |
182 | 206 | ||
183 | it('Should fetch the about information', async function () { | 207 | it('Should fetch the about information', async function () { |
diff --git a/server/tests/real-world/populate-database.ts b/server/tests/real-world/populate-database.ts index 5f93d09db..f0f82f7f8 100644 --- a/server/tests/real-world/populate-database.ts +++ b/server/tests/real-world/populate-database.ts | |||
@@ -19,6 +19,12 @@ start() | |||
19 | // ---------------------------------------------------------------------------- | 19 | // ---------------------------------------------------------------------------- |
20 | 20 | ||
21 | async function start () { | 21 | async function start () { |
22 | await flushTests() | ||
23 | |||
24 | console.log('Flushed tests.') | ||
25 | |||
26 | const server = await runServer(6) | ||
27 | |||
22 | process.on('exit', async () => { | 28 | process.on('exit', async () => { |
23 | killallServers([ server ]) | 29 | killallServers([ server ]) |
24 | return | 30 | return |
@@ -26,11 +32,6 @@ async function start () { | |||
26 | process.on('SIGINT', goodbye) | 32 | process.on('SIGINT', goodbye) |
27 | process.on('SIGTERM', goodbye) | 33 | process.on('SIGTERM', goodbye) |
28 | 34 | ||
29 | await flushTests() | ||
30 | |||
31 | console.log('Flushed tests.') | ||
32 | |||
33 | const server = await runServer(6) | ||
34 | await setAccessTokensToServers([ server ]) | 35 | await setAccessTokensToServers([ server ]) |
35 | 36 | ||
36 | console.log('Servers ran.') | 37 | console.log('Servers ran.') |
diff --git a/server/tests/utils/requests/requests.ts b/server/tests/utils/requests/requests.ts index ebde692cd..b88b3ce5b 100644 --- a/server/tests/utils/requests/requests.ts +++ b/server/tests/utils/requests/requests.ts | |||
@@ -123,6 +123,13 @@ function makePutBodyRequest (options: { | |||
123 | .expect(options.statusCodeExpected) | 123 | .expect(options.statusCodeExpected) |
124 | } | 124 | } |
125 | 125 | ||
126 | function makeHTMLRequest (url: string, path: string) { | ||
127 | return request(url) | ||
128 | .get(path) | ||
129 | .set('Accept', 'text/html') | ||
130 | .expect(200) | ||
131 | } | ||
132 | |||
126 | function updateAvatarRequest (options: { | 133 | function updateAvatarRequest (options: { |
127 | url: string, | 134 | url: string, |
128 | path: string, | 135 | path: string, |
@@ -149,6 +156,7 @@ function updateAvatarRequest (options: { | |||
149 | // --------------------------------------------------------------------------- | 156 | // --------------------------------------------------------------------------- |
150 | 157 | ||
151 | export { | 158 | export { |
159 | makeHTMLRequest, | ||
152 | makeGetRequest, | 160 | makeGetRequest, |
153 | makeUploadRequest, | 161 | makeUploadRequest, |
154 | makePostBodyRequest, | 162 | makePostBodyRequest, |