X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Flib%2Fclient-html.ts;h=e7e439bfe4bee38fbc00eb38c77fbb329efbe5ec;hb=f304a1580b5ce7b6c5f9e25cd3ddc63ca8d8c6a1;hp=b8c87e9578fdf52ed49ca3439ca70888181faa9c;hpb=865af3fd7b192bda44dea65c6df125f233424f3e;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts index b8c87e957..e7e439bfe 100644 --- a/server/lib/client-html.ts +++ b/server/lib/client-html.ts @@ -1,27 +1,34 @@ -import * as express from 'express' +import express from 'express' +import { readFile } from 'fs-extra' +import { join } from 'path' +import validator from 'validator' +import { toCompleteUUID } from '@server/helpers/custom-validators/misc' +import { escapeHTML } from '@shared/core-utils/renderer' +import { sha256 } from '@shared/extra-utils' +import { HTMLServerConfig } from '@shared/models' import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '../../shared/core-utils/i18n/i18n' +import { HttpStatusCode } from '../../shared/models/http/http-error-codes' +import { VideoPlaylistPrivacy, VideoPrivacy } from '../../shared/models/videos' +import { isTestInstance } from '../helpers/core-utils' +import { logger } from '../helpers/logger' +import { mdToPlainText } from '../helpers/markdown' +import { CONFIG } from '../initializers/config' import { - AVATARS_SIZE, + ACCEPT_HEADERS, + ACTOR_IMAGES_SIZE, CUSTOM_HTML_TAG_COMMENTS, EMBED_SIZE, + FILES_CONTENT_HASH, PLUGIN_GLOBAL_CSS_PATH, - WEBSERVER, - FILES_CONTENT_HASH + WEBSERVER } 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, VideoPlaylistPrivacy } from '../../shared/models/videos' -import { readFile } from 'fs-extra' -import { getActivityStreamDuration } from '../models/video/video-format-utils' import { AccountModel } from '../models/account/account' +import { getActivityStreamDuration } from '../models/video/formatter/video-format-utils' +import { VideoModel } from '../models/video/video' import { VideoChannelModel } from '../models/video/video-channel' -import * as Bluebird from 'bluebird' -import { CONFIG } from '../initializers/config' -import { logger } from '../helpers/logger' +import { VideoPlaylistModel } from '../models/video/video-playlist' import { MAccountActor, MChannelActor } from '../types/models' +import { ServerConfigManager } from './server-config-manager' type Tags = { ogType: string @@ -32,10 +39,14 @@ type Tags = { numberOfItems: number } - siteName: string - title: string + escapedSiteName: string + escapedTitle: string + escapedDescription: string + url: string - description: string + originUrl: string + + disallowIndexation?: boolean embed?: { url: string @@ -51,7 +62,7 @@ type Tags = { } } -export class ClientHtml { +class ClientHtml { private static htmlCache: { [path: string]: string } = {} @@ -72,10 +83,12 @@ export class ClientHtml { return customHtml } - static async getWatchHTMLPage (videoId: string, req: express.Request, res: express.Response) { + static async getWatchHTMLPage (videoIdArg: string, req: express.Request, res: express.Response) { + const videoId = toCompleteUUID(videoIdArg) + // Let Angular application handle errors if (!validator.isInt(videoId) && !validator.isUUID(videoId, 4)) { - res.status(404) + res.status(HttpStatusCode.NOT_FOUND_404) return ClientHtml.getIndexHTML(req, res) } @@ -86,17 +99,18 @@ export class ClientHtml { // Let Angular application handle errors if (!video || video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL || video.VideoBlacklist) { - res.status(404) + res.status(HttpStatusCode.NOT_FOUND_404) return html } + const description = mdToPlainText(video.description) - let customHtml = ClientHtml.addTitleTag(html, escapeHTML(video.name)) - customHtml = ClientHtml.addDescriptionTag(customHtml, escapeHTML(video.description)) + let customHtml = ClientHtml.addTitleTag(html, video.name) + customHtml = ClientHtml.addDescriptionTag(customHtml, description) const url = WEBSERVER.URL + video.getWatchStaticPath() - const title = escapeHTML(video.name) - const siteName = escapeHTML(CONFIG.INSTANCE.NAME) - const description = escapeHTML(video.description) + const originUrl = video.url + const title = video.name + const siteName = CONFIG.INSTANCE.NAME const image = { url: WEBSERVER.URL + video.getPreviewStaticPath() @@ -113,15 +127,28 @@ export class ClientHtml { const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary_large_image' const schemaType = 'VideoObject' - customHtml = ClientHtml.addTags(customHtml, { url, siteName, title, description, image, embed, ogType, twitterCard, schemaType }) + customHtml = ClientHtml.addTags(customHtml, { + url, + originUrl, + escapedSiteName: escapeHTML(siteName), + escapedTitle: escapeHTML(title), + escapedDescription: escapeHTML(description), + image, + embed, + ogType, + twitterCard, + schemaType + }) return customHtml } - static async getWatchPlaylistHTMLPage (videoPlaylistId: string, req: express.Request, res: express.Response) { + static async getWatchPlaylistHTMLPage (videoPlaylistIdArg: string, req: express.Request, res: express.Response) { + const videoPlaylistId = toCompleteUUID(videoPlaylistIdArg) + // Let Angular application handle errors if (!validator.isInt(videoPlaylistId) && !validator.isUUID(videoPlaylistId, 4)) { - res.status(404) + res.status(HttpStatusCode.NOT_FOUND_404) return ClientHtml.getIndexHTML(req, res) } @@ -132,17 +159,19 @@ export class ClientHtml { // Let Angular application handle errors if (!videoPlaylist || videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) { - res.status(404) + res.status(HttpStatusCode.NOT_FOUND_404) return html } - let customHtml = ClientHtml.addTitleTag(html, escapeHTML(videoPlaylist.name)) - customHtml = ClientHtml.addDescriptionTag(customHtml, escapeHTML(videoPlaylist.description)) + const description = mdToPlainText(videoPlaylist.description) + + let customHtml = ClientHtml.addTitleTag(html, videoPlaylist.name) + customHtml = ClientHtml.addDescriptionTag(customHtml, description) - const url = videoPlaylist.getWatchUrl() - const title = escapeHTML(videoPlaylist.name) - const siteName = escapeHTML(CONFIG.INSTANCE.NAME) - const description = escapeHTML(videoPlaylist.description) + const url = WEBSERVER.URL + videoPlaylist.getWatchStaticPath() + const originUrl = videoPlaylist.url + const title = videoPlaylist.name + const siteName = CONFIG.INSTANCE.NAME const image = { url: videoPlaylist.getThumbnailUrl() @@ -161,28 +190,56 @@ export class ClientHtml { const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary' const schemaType = 'ItemList' - customHtml = ClientHtml.addTags(customHtml, { url, siteName, embed, title, description, image, list, ogType, twitterCard, schemaType }) + customHtml = ClientHtml.addTags(customHtml, { + url, + originUrl, + escapedSiteName: escapeHTML(siteName), + escapedTitle: escapeHTML(title), + escapedDescription: escapeHTML(description), + embed, + image, + list, + ogType, + twitterCard, + schemaType + }) return customHtml } static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) { - return this.getAccountOrChannelHTMLPage(() => AccountModel.loadByNameWithHost(nameWithHost), req, res) + const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost) + return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res) } static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) { - return this.getAccountOrChannelHTMLPage(() => VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost), req, res) + const videoChannelModelPromise = VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost) + return this.getAccountOrChannelHTMLPage(() => videoChannelModelPromise, req, res) + } + + static async getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) { + const [ account, channel ] = await Promise.all([ + AccountModel.loadByNameWithHost(nameWithHost), + VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost) + ]) + + return this.getAccountOrChannelHTMLPage(() => Promise.resolve(account || channel), req, res) } static async getEmbedHTML () { const path = ClientHtml.getEmbedPath() - if (ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] + if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] const buffer = await readFile(path) + const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig() let html = buffer.toString() html = await ClientHtml.addAsyncPluginCSS(html) + html = ClientHtml.addCustomCSS(html) + html = ClientHtml.addTitleTag(html) + html = ClientHtml.addDescriptionTag(html) + html = ClientHtml.addServerConfig(html, serverConfig) ClientHtml.htmlCache[path] = html @@ -190,7 +247,7 @@ export class ClientHtml { } private static async getAccountOrChannelHTMLPage ( - loader: () => Bluebird, + loader: () => Promise, req: express.Request, res: express.Response ) { @@ -201,46 +258,60 @@ export class ClientHtml { // Let Angular application handle errors if (!entity) { - res.status(404) + res.status(HttpStatusCode.NOT_FOUND_404) return ClientHtml.getIndexHTML(req, res) } - let customHtml = ClientHtml.addTitleTag(html, escapeHTML(entity.getDisplayName())) - customHtml = ClientHtml.addDescriptionTag(customHtml, escapeHTML(entity.description)) + const description = mdToPlainText(entity.description) - const url = entity.Actor.url - const siteName = escapeHTML(CONFIG.INSTANCE.NAME) - const title = escapeHTML(entity.getDisplayName()) - const description = escapeHTML(entity.description) + let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName()) + customHtml = ClientHtml.addDescriptionTag(customHtml, description) + + const url = entity.getLocalUrl() + const originUrl = entity.Actor.url + const siteName = CONFIG.INSTANCE.NAME + const title = entity.getDisplayName() const image = { url: entity.Actor.getAvatarUrl(), - width: AVATARS_SIZE.width, - height: AVATARS_SIZE.height + width: ACTOR_IMAGES_SIZE.AVATARS.width, + height: ACTOR_IMAGES_SIZE.AVATARS.height } const ogType = 'website' const twitterCard = 'summary' const schemaType = 'ProfilePage' - customHtml = ClientHtml.addTags(customHtml, { url, title, siteName, description, image, ogType, twitterCard, schemaType }) + customHtml = ClientHtml.addTags(customHtml, { + url, + originUrl, + escapedTitle: escapeHTML(title), + escapedSiteName: escapeHTML(siteName), + escapedDescription: escapeHTML(description), + image, + ogType, + twitterCard, + schemaType, + disallowIndexation: !entity.Actor.isOwned() + }) return customHtml } private static async getIndexHTML (req: express.Request, res: express.Response, paramLang?: string) { const path = ClientHtml.getIndexPath(req, res, paramLang) - if (ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] + if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] const buffer = await readFile(path) + const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig() let html = buffer.toString() - if (paramLang) html = ClientHtml.addHtmlLang(html, paramLang) html = ClientHtml.addManifestContentHash(html) html = ClientHtml.addFaviconContentHash(html) html = ClientHtml.addLogoContentHash(html) html = ClientHtml.addCustomCSS(html) + html = ClientHtml.addServerConfig(html, serverConfig) html = await ClientHtml.addAsyncPluginCSS(html) ClientHtml.htmlCache[path] = html @@ -268,6 +339,11 @@ export class ClientHtml { lang = req.acceptsLanguages(POSSIBLE_LOCALES) || getDefaultLocale() } + logger.debug( + 'Serving %s HTML language', buildFileLocale(lang), + { cookie: req.cookies?.clientLanguage, paramLang, acceptLanguage: req.headers['accept-language'] } + ) + return join(__dirname, '../../../client/dist/' + buildFileLocale(lang) + '/index.html') } @@ -275,10 +351,6 @@ export class ClientHtml { return join(__dirname, '../../../client/dist/standalone/videos/embed.html') } - private static addHtmlLang (htmlStringPage: string, paramLang: string) { - return htmlStringPage.replace('', ``) - } - private static addManifestContentHash (htmlStringPage: string) { return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST) } @@ -295,14 +367,14 @@ export class ClientHtml { let text = title || CONFIG.INSTANCE.NAME if (title) text += ` - ${CONFIG.INSTANCE.NAME}` - const titleTag = `${text}` + const titleTag = `${escapeHTML(text)}` return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.TITLE, titleTag) } private static addDescriptionTag (htmlStringPage: string, description?: string) { const content = description || CONFIG.INSTANCE.SHORT_DESCRIPTION - const descriptionTag = `` + const descriptionTag = `` return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag) } @@ -313,6 +385,14 @@ export class ClientHtml { return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag) } + private static addServerConfig (htmlStringPage: string, serverConfig: HTMLServerConfig) { + // Stringify the JSON object, and then stringify the string object so we can inject it into the HTML + const serverConfigString = JSON.stringify(JSON.stringify(serverConfig)) + const configScriptTag = `` + + return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.SERVER_CONFIG, configScriptTag) + } + private static async addAsyncPluginCSS (htmlStringPage: string) { const globalCSSContent = await readFile(PLUGIN_GLOBAL_CSS_PATH) if (globalCSSContent.byteLength === 0) return htmlStringPage @@ -326,8 +406,8 @@ export class ClientHtml { private static generateOpenGraphMetaTags (tags: Tags) { const metaTags = { 'og:type': tags.ogType, - 'og:site_name': tags.siteName, - 'og:title': tags.title, + 'og:site_name': tags.escapedSiteName, + 'og:title': tags.escapedTitle, 'og:image': tags.image.url } @@ -337,7 +417,7 @@ export class ClientHtml { } metaTags['og:url'] = tags.url - metaTags['og:description'] = tags.description + metaTags['og:description'] = tags.escapedDescription if (tags.embed) { metaTags['og:video:url'] = tags.embed.url @@ -352,8 +432,8 @@ export class ClientHtml { private static generateStandardMetaTags (tags: Tags) { return { - name: tags.title, - description: tags.description, + name: tags.escapedTitle, + description: tags.escapedDescription, image: tags.image.url } } @@ -362,8 +442,8 @@ export class ClientHtml { const metaTags = { 'twitter:card': tags.twitterCard, 'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME, - 'twitter:title': tags.title, - 'twitter:description': tags.description, + 'twitter:title': tags.escapedTitle, + 'twitter:description': tags.escapedDescription, 'twitter:image': tags.image.url } @@ -385,8 +465,8 @@ export class ClientHtml { const schema = { '@context': 'http://schema.org', '@type': tags.schemaType, - 'name': tags.title, - 'description': tags.description, + 'name': tags.escapedTitle, + 'description': tags.escapedDescription, 'image': tags.image.url, 'url': tags.url } @@ -416,54 +496,96 @@ export class ClientHtml { const twitterCardMetaTags = this.generateTwitterCardMetaTags(tagsValues) const schemaTags = this.generateSchemaTags(tagsValues) - const { url, title, embed } = tagsValues + const { url, escapedTitle, embed, originUrl, disallowIndexation } = tagsValues - const oembedLinkTags: { type: string, href: string, title: string }[] = [] + const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = [] if (embed) { oembedLinkTags.push({ type: 'application/json+oembed', href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(url), - title + escapedTitle }) } - let tagsString = '' + let tagsStr = '' // Opengraph Object.keys(openGraphMetaTags).forEach(tagName => { const tagValue = openGraphMetaTags[tagName] - tagsString += `` + tagsStr += `` }) // Standard Object.keys(standardMetaTags).forEach(tagName => { const tagValue = standardMetaTags[tagName] - tagsString += `` + tagsStr += `` }) // Twitter card Object.keys(twitterCardMetaTags).forEach(tagName => { const tagValue = twitterCardMetaTags[tagName] - tagsString += `` + tagsStr += `` }) // OEmbed for (const oembedLinkTag of oembedLinkTags) { - tagsString += `` + tagsStr += `` } // Schema.org if (schemaTags) { - tagsString += `` + tagsStr += `` } // SEO, use origin URL - tagsString += `` + tagsStr += `` + + if (disallowIndexation) { + tagsStr += `` + } + + return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsStr) + } +} - return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsString) +function sendHTML (html: string, res: express.Response, localizedHTML: boolean = false) { + res.set('Content-Type', 'text/html; charset=UTF-8') + + if (localizedHTML) { + res.set('Vary', 'Accept-Language') } + + return res.send(html) +} + +async function serveIndexHTML (req: express.Request, res: express.Response) { + if (req.accepts(ACCEPT_HEADERS) === 'html' || !req.headers.accept) { + try { + await generateHTMLPage(req, res, req.params.language) + return + } catch (err) { + logger.error('Cannot generate HTML page.', err) + return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end() + } + } + + return res.status(HttpStatusCode.NOT_ACCEPTABLE_406).end() +} + +// --------------------------------------------------------------------------- + +export { + ClientHtml, + sendHTML, + serveIndexHTML +} + +async function generateHTMLPage (req: express.Request, res: express.Response, paramLang?: string) { + const html = await ClientHtml.getDefaultHTMLPage(req, res, paramLang) + + return sendHTML(html, res, true) }