diff --git a/packages/tests/src/client/embed-html.ts b/packages/tests/src/client/embed-html.ts index 99121b8f2..c1aaf58a0 100644 --- a/packages/tests/src/client/embed-html.ts +++ b/packages/tests/src/client/embed-html.ts @@ -23,7 +23,8 @@ describe('Test embed HTML generation', function () { let unlistedPlaylistId: string let playlistName: string let playlistDescription: string - let instanceDescription: string + + let instanceConfig: { shortDescription: string } before(async function () { this.timeout(120000); @@ -44,7 +45,7 @@ describe('Test embed HTML generation', function () { playlist, unlistedPlaylistId, privatePlaylistId, - instanceDescription + instanceConfig } = await prepareClientTests()) }) @@ -58,7 +59,7 @@ describe('Test embed HTML generation', function () { it('Should have the correct embed html instance tags', async function () { const res = await makeHTMLRequest(servers[0].url, '/videos/embed/toto') - checkIndexTags(res.text, `PeerTube`, instanceDescription, '', config) + checkIndexTags(res.text, `PeerTube`, instanceConfig.shortDescription, '', config) expect(res.text).to.not.contain(`"name":`) }) diff --git a/packages/tests/src/client/index-html.ts b/packages/tests/src/client/index-html.ts index 5f33955dc..30198ad57 100644 --- a/packages/tests/src/client/index-html.ts +++ b/packages/tests/src/client/index-html.ts @@ -35,8 +35,7 @@ describe('Test index HTML generation', function () { passwordProtectedVideoId, unlistedVideoId, privatePlaylistId, - unlistedPlaylistId, - instanceDescription + unlistedPlaylistId } = await prepareClientTests()) }) diff --git a/packages/tests/src/client/og-twitter-tags.ts b/packages/tests/src/client/og-twitter-tags.ts index 0244dce0b..05b1ad9da 100644 --- a/packages/tests/src/client/og-twitter-tags.ts +++ b/packages/tests/src/client/og-twitter-tags.ts @@ -22,11 +22,18 @@ describe('Test Open Graph and Twitter cards HTML tags', function () { let playlistIds: (string | number)[] = [] + let instanceConfig: { + name: string + shortDescription: string + avatar: string + } + before(async function () { this.timeout(120000); ({ servers, + instanceConfig, account, playlistIds, videoIds, @@ -41,6 +48,20 @@ describe('Test Open Graph and Twitter cards HTML tags', function () { describe('Open Graph', function () { + async function indexPageTest (path: string) { + const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) + const text = res.text + + let url = servers[0].url + if (path !== '/') url += path + + expect(text).to.contain(`<meta property="og:title" content="${instanceConfig.name}" />`) + expect(text).to.contain(`<meta property="og:description" content="${instanceConfig.shortDescription}" />`) + expect(text).to.contain('<meta property="og:type" content="website" />') + expect(text).to.contain(`<meta property="og:url" content="${url}`) + expect(text).to.contain(`<meta property="og:image" content="${servers[0].url}/`) + } + async function accountPageTest (path: string) { const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) const text = res.text @@ -49,6 +70,7 @@ describe('Test Open Graph and Twitter cards HTML tags', function () { expect(text).to.contain(`<meta property="og:description" content="${account.description}" />`) expect(text).to.contain('<meta property="og:type" content="website" />') expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/a/${servers[0].store.user.username}/video-channels" />`) + expect(text).to.not.contain(`<meta property="og:image"`) } async function channelPageTest (path: string) { @@ -59,6 +81,7 @@ describe('Test Open Graph and Twitter cards HTML tags', function () { expect(text).to.contain(`<meta property="og:description" content="${channelDescription}" />`) expect(text).to.contain('<meta property="og:type" content="website" />') expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/c/${servers[0].store.channel.name}/videos" />`) + expect(text).to.contain(`<meta property="og:image" content="${servers[0].url}/`) } async function watchVideoPageTest (path: string) { @@ -69,6 +92,7 @@ describe('Test Open Graph and Twitter cards HTML tags', function () { expect(text).to.contain(`<meta property="og:description" content="${videoDescriptionPlainText}" />`) expect(text).to.contain('<meta property="og:type" content="video" />') expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/${servers[0].store.video.shortUUID}" />`) + expect(text).to.contain(`<meta property="og:image" content="${servers[0].url}/`) } async function watchPlaylistPageTest (path: string) { @@ -79,8 +103,16 @@ describe('Test Open Graph and Twitter cards HTML tags', function () { expect(text).to.contain(`<meta property="og:description" content="${playlistDescription}" />`) expect(text).to.contain('<meta property="og:type" content="video" />') expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/p/${playlist.shortUUID}" />`) + expect(text).to.contain(`<meta property="og:image" content="${servers[0].url}/`) } + it('Should have valid Open Graph tags on the common page', async function () { + await indexPageTest('/about/peertube') + await indexPageTest('/videos') + await indexPageTest('/homepage') + await indexPageTest('/') + }) + it('Should have valid Open Graph tags on the account page', async function () { await accountPageTest('/accounts/' + servers[0].store.user.username) await accountPageTest('/a/' + servers[0].store.user.username) @@ -135,6 +167,7 @@ describe('Test Open Graph and Twitter cards HTML tags', function () { expect(text).to.contain('<meta property="twitter:card" content="summary" />') expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />') + expect(text).to.not.contain(`<meta property="twitter:image"`) } async function channelPageTest (path: string) { @@ -143,6 +176,7 @@ describe('Test Open Graph and Twitter cards HTML tags', function () { expect(text).to.contain('<meta property="twitter:card" content="summary" />') expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />') + expect(text).to.contain(`<meta property="twitter:image" content="${servers[0].url}`) } async function watchVideoPageTest (path: string) { @@ -151,6 +185,7 @@ describe('Test Open Graph and Twitter cards HTML tags', function () { expect(text).to.contain('<meta property="twitter:card" content="player" />') expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />') + expect(text).to.contain(`<meta property="twitter:image" content="${servers[0].url}`) } async function watchPlaylistPageTest (path: string) { @@ -159,6 +194,7 @@ describe('Test Open Graph and Twitter cards HTML tags', function () { expect(text).to.contain('<meta property="twitter:card" content="player" />') expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />') + expect(text).to.contain(`<meta property="twitter:image" content="${servers[0].url}`) } it('Should have valid twitter card on the watch video page', async function () { diff --git a/packages/tests/src/shared/client.ts b/packages/tests/src/shared/client.ts index 5afb20aae..f9572806e 100644 --- a/packages/tests/src/shared/client.ts +++ b/packages/tests/src/shared/client.ts @@ -5,7 +5,8 @@ import { VideoPlaylistCreateResult, Account, HTMLServerConfig, - ServerConfig + ServerConfig, + ActorImageType } from '@peertube/peertube-models' import { createMultipleServers, @@ -43,11 +44,22 @@ export async function prepareClientTests () { const servers = await createMultipleServers(2) await setAccessTokensToServers(servers) - await doubleFollow(servers[0], servers[1]) - await setDefaultVideoChannel(servers) + const instanceConfig = { + name: 'super instance title', + shortDescription: 'super instance description', + avatar: 'avatar.png' + } + + await servers[0].config.updateExistingConfig({ + newConfig: { + instance: { name: instanceConfig.name, shortDescription: instanceConfig.shortDescription } + } + }) + await servers[0].config.updateInstanceImage({ type: ActorImageType.AVATAR, fixture: instanceConfig.avatar }) + let account: Account let videoIds: (string | number)[] = [] @@ -60,8 +72,6 @@ export async function prepareClientTests () { let privatePlaylistId: string let unlistedPlaylistId: string - const instanceDescription = 'PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.' - const videoName = 'my super name for server 1' const videoDescription = 'my<br> super __description__ for *server* 1<p></p>' const videoDescriptionPlainText = 'my super description for server 1' @@ -77,6 +87,8 @@ export async function prepareClientTests () { attributes: { description: channelDescription } }) + await servers[0].channels.updateImage({ channelName: servers[0].store.channel.name, fixture: 'avatar.png', type: 'avatar' }) + // Public video { @@ -154,7 +166,7 @@ export async function prepareClientTests () { return { servers, - instanceDescription, + instanceConfig, account, diff --git a/server/core/lib/html/shared/actor-html.ts b/server/core/lib/html/shared/actor-html.ts index 2c6990afa..76d2ebcb7 100644 --- a/server/core/lib/html/shared/actor-html.ts +++ b/server/core/lib/html/shared/actor-html.ts @@ -84,9 +84,7 @@ export class ActorHtml { updatedAt: entity.updatedAt }, - indexationPolicy: entity.Actor.isOwned() - ? 'always' - : 'never' + forbidIndexation: !entity.Actor.isOwned() }, {}) return customHTML diff --git a/server/core/lib/html/shared/common-embed-html.ts b/server/core/lib/html/shared/common-embed-html.ts index d17ea2efc..565c3ddec 100644 --- a/server/core/lib/html/shared/common-embed-html.ts +++ b/server/core/lib/html/shared/common-embed-html.ts @@ -14,6 +14,6 @@ export class CommonEmbedHtml { let htmlResult = TagsHtml.addTitleTag(html) htmlResult = TagsHtml.addDescriptionTag(htmlResult) - return TagsHtml.addTags(htmlResult, { indexationPolicy: 'never' }, { playlist, video }) + return TagsHtml.addTags(htmlResult, { forbidIndexation: true }, { playlist, video }) } } diff --git a/server/core/lib/html/shared/page-html.ts b/server/core/lib/html/shared/page-html.ts index a67dadbe4..6106d3317 100644 --- a/server/core/lib/html/shared/page-html.ts +++ b/server/core/lib/html/shared/page-html.ts @@ -1,15 +1,17 @@ -import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '@peertube/peertube-core-utils' +import { buildFileLocale, escapeHTML, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '@peertube/peertube-core-utils' +import { ActorImageType, HTMLServerConfig } from '@peertube/peertube-models' import { isTestOrDevInstance, root, sha256 } from '@peertube/peertube-node-utils' +import { CONFIG } from '@server/initializers/config.js' +import { ActorImageModel } from '@server/models/actor/actor-image.js' +import { getServerActor } from '@server/models/application/application.js' import express from 'express' +import { pathExists } from 'fs-extra/esm' import { readFile } from 'fs/promises' import { join } from 'path' import { logger } from '../../../helpers/logger.js' -import { CUSTOM_HTML_TAG_COMMENTS, FILES_CONTENT_HASH, PLUGIN_GLOBAL_CSS_PATH } from '../../../initializers/constants.js' +import { CUSTOM_HTML_TAG_COMMENTS, FILES_CONTENT_HASH, PLUGIN_GLOBAL_CSS_PATH, WEBSERVER } from '../../../initializers/constants.js' import { ServerConfigManager } from '../../server-config-manager.js' import { TagsHtml } from './tags-html.js' -import { pathExists } from 'fs-extra/esm' -import { HTMLServerConfig } from '@peertube/peertube-models' -import { CONFIG } from '@server/initializers/config.js' export class PageHtml { @@ -22,13 +24,33 @@ export class PageHtml { } static async getDefaultHTML (req: express.Request, res: express.Response, paramLang?: string) { - const html = paramLang - ? await this.getIndexHTML(req, res, paramLang) - : await this.getIndexHTML(req, res) + const html = await this.getIndexHTML(req, res, paramLang) + const serverActor = await getServerActor() + const avatar = serverActor.getMaxQualityImage(ActorImageType.AVATAR) let customHTML = TagsHtml.addTitleTag(html) customHTML = TagsHtml.addDescriptionTag(customHTML) + const url = req.originalUrl === '/' + ? WEBSERVER.URL + : WEBSERVER.URL + req.originalUrl + + customHTML = await TagsHtml.addTags(customHTML, { + url, + + escapedSiteName: escapeHTML(CONFIG.INSTANCE.NAME), + escapedTitle: escapeHTML(CONFIG.INSTANCE.NAME), + escapedTruncatedDescription: escapeHTML(CONFIG.INSTANCE.SHORT_DESCRIPTION), + + image: avatar + ? { url: ActorImageModel.getImageUrl(avatar), width: avatar.width, height: avatar.height } + : undefined, + + ogType: 'website', + twitterCard: 'summary_large_image', + forbidIndexation: false + }, {}) + return customHTML } diff --git a/server/core/lib/html/shared/playlist-html.ts b/server/core/lib/html/shared/playlist-html.ts index 8e994cffa..f46aa097e 100644 --- a/server/core/lib/html/shared/playlist-html.ts +++ b/server/core/lib/html/shared/playlist-html.ts @@ -113,11 +113,11 @@ export class PlaylistHtml { escapedTitle: escapeHTML(playlist.name), escapedTruncatedDescription, - indexationPolicy: !playlist.isOwned() || playlist.privacy !== VideoPlaylistPrivacy.PUBLIC - ? 'never' - : 'always', + forbidIndexation: !playlist.isOwned() || playlist.privacy !== VideoPlaylistPrivacy.PUBLIC, - image: { url: playlist.getThumbnailUrl() }, + image: playlist.Thumbnail + ? { url: playlist.getThumbnailUrl(), width: playlist.Thumbnail.width, height: playlist.Thumbnail.height } + : undefined, list, diff --git a/server/core/lib/html/shared/tags-html.ts b/server/core/lib/html/shared/tags-html.ts index 92349204d..e4568c0ef 100644 --- a/server/core/lib/html/shared/tags-html.ts +++ b/server/core/lib/html/shared/tags-html.ts @@ -7,7 +7,7 @@ import truncate from 'lodash-es/truncate.js' import { mdToOneLinePlainText } from '@server/helpers/markdown.js' type Tags = { - indexationPolicy: 'always' | 'never' + forbidIndexation: boolean url?: string @@ -31,8 +31,8 @@ type Tags = { image?: { url: string - width?: number - height?: number + width: number + height: number } embed?: { @@ -76,7 +76,7 @@ export class TagsHtml { const twitterCardMetaTags = this.generateTwitterCardMetaTagsOptions(tagsValues) const schemaTags = await this.generateSchemaTagsOptions(tagsValues, context) - const { url, escapedTitle, oembedUrl, indexationPolicy } = tagsValues + const { url, escapedTitle, oembedUrl, forbidIndexation } = tagsValues const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = [] @@ -126,11 +126,11 @@ export class TagsHtml { } // SEO, use origin URL - if (indexationPolicy !== 'never' && url) { + if (forbidIndexation === true && url) { tagsStr += `<link rel="canonical" href="${url}" />` } - if (indexationPolicy === 'never') { + if (forbidIndexation === true) { tagsStr += `<meta name="robots" content="noindex" />` } diff --git a/server/core/lib/html/shared/video-html.ts b/server/core/lib/html/shared/video-html.ts index e1b285a9c..d6b35a98c 100644 --- a/server/core/lib/html/shared/video-html.ts +++ b/server/core/lib/html/shared/video-html.ts @@ -7,7 +7,7 @@ import validator from 'validator' import { CONFIG } from '../../../initializers/config.js' import { MEMOIZE_TTL, WEBSERVER } from '../../../initializers/constants.js' import { VideoModel } from '../../../models/video/video.js' -import { MVideo, MVideoThumbnailBlacklist } from '../../../types/models/index.js' +import { MVideo, MVideoThumbnail, MVideoThumbnailBlacklist } from '../../../types/models/index.js' import { getActivityStreamDuration } from '../../activitypub/activity.js' import { isVideoInPrivateDirectory } from '../../video-privacy.js' import { CommonEmbedHtml } from './common-embed-html.js' @@ -78,7 +78,7 @@ export class VideoHtml { private static buildVideoHTML (options: { html: string - video: MVideo + video: MVideoThumbnail addOG: boolean addTwitterCard: boolean @@ -111,6 +111,8 @@ export class VideoHtml { const schemaType = 'VideoObject' + const preview = video.getPreview() + return TagsHtml.addTags(customHTML, { url: WEBSERVER.URL + video.getWatchStaticPath(), @@ -118,11 +120,11 @@ export class VideoHtml { escapedTitle: escapeHTML(video.name), escapedTruncatedDescription, - indexationPolicy: video.remote || video.privacy !== VideoPrivacy.PUBLIC - ? 'never' - : 'always', + forbidIndexation: video.remote || video.privacy !== VideoPrivacy.PUBLIC, - image: { url: WEBSERVER.URL + video.getPreviewStaticPath() }, + image: preview + ? { url: WEBSERVER.URL + video.getPreviewStaticPath(), width: preview.width, height: preview.height } + : undefined, embed, oembedUrl: this.getOEmbedUrl(video, currentQuery), diff --git a/server/core/lib/video-pre-import.ts b/server/core/lib/video-pre-import.ts index 615429087..e17bf2797 100644 --- a/server/core/lib/video-pre-import.ts +++ b/server/core/lib/video-pre-import.ts @@ -79,8 +79,8 @@ async function insertFromImportIntoDB (parameters: { const videoImport = await sequelizeTypescript.transaction(async t => { const sequelizeOptions = { transaction: t } - // Save video object in database - const videoCreated = await video.save(sequelizeOptions) as (MVideoAccountDefault & MVideoWithBlacklistLight & MVideoTag) + // eslint-disable-next-line max-len + const videoCreated = await video.save(sequelizeOptions) as (MVideoAccountDefault & MVideoWithBlacklistLight & MVideoTag & MVideoThumbnail) videoCreated.VideoChannel = videoChannel if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) diff --git a/server/core/models/video/video.ts b/server/core/models/video/video.ts index b6aa85486..d72e5f744 100644 --- a/server/core/models/video/video.ts +++ b/server/core/models/video/video.ts @@ -1838,21 +1838,21 @@ export class VideoModel extends SequelizeModel<VideoModel> { // --------------------------------------------------------------------------- - hasMiniature (this: MVideoThumbnail) { + hasMiniature (this: Pick<MVideoThumbnail, 'getMiniature' | 'Thumbnails'>) { return !!this.getMiniature() } - getMiniature (this: MVideoThumbnail) { + getMiniature (this: Pick<MVideoThumbnail, 'Thumbnails'>) { if (Array.isArray(this.Thumbnails) === false) return undefined return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE) } - hasPreview (this: MVideoThumbnail) { + hasPreview (this: Pick<MVideoThumbnail, 'getPreview' | 'Thumbnails'>) { return !!this.getPreview() } - getPreview (this: MVideoThumbnail) { + getPreview (this: Pick<MVideoThumbnail, 'Thumbnails'>) { if (Array.isArray(this.Thumbnails) === false) return undefined return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW) @@ -1872,14 +1872,14 @@ export class VideoModel extends SequelizeModel<VideoModel> { return buildVideoEmbedPath(this) } - getMiniatureStaticPath () { + getMiniatureStaticPath (this: Pick<MVideoThumbnail, 'getMiniature' | 'Thumbnails'>) { const thumbnail = this.getMiniature() if (!thumbnail) return null return thumbnail.getLocalStaticPath() } - getPreviewStaticPath () { + getPreviewStaticPath (this: Pick<MVideoThumbnail, 'getPreview' | 'Thumbnails'>) { const preview = this.getPreview() if (!preview) return null diff --git a/server/core/types/models/abuse/abuse.ts b/server/core/types/models/abuse/abuse.ts index bf6680470..c0c8d3776 100644 --- a/server/core/types/models/abuse/abuse.ts +++ b/server/core/types/models/abuse/abuse.ts @@ -40,7 +40,7 @@ export type MVideoAbuseVideoFull = export type MVideoAbuseFormattable = MVideoAbuse & UseVideoAbuse<'Video', Pick<MVideoAccountLightBlacklistAllFiles, - 'id' | 'uuid' | 'name' | 'nsfw' | 'getMiniatureStaticPath' | 'isBlacklisted' | 'VideoChannel'>> + 'id' | 'uuid' | 'name' | 'nsfw' | 'getMiniature' | 'getMiniatureStaticPath' | 'isBlacklisted' | 'VideoChannel' | 'Thumbnails'>> // ############################################################################ diff --git a/server/core/types/models/video/video.ts b/server/core/types/models/video/video.ts index 8db18f1fb..de462176b 100644 --- a/server/core/types/models/video/video.ts +++ b/server/core/types/models/video/video.ts @@ -217,7 +217,7 @@ export type MVideoForRedundancyAPI = // Format for API or AP object export type MVideoFormattable = - MVideo & + MVideoThumbnail & PickWithOpt<VideoModel, 'UserVideoHistories', MUserVideoHistoryTime[]> & Use<'VideoChannel', MChannelAccountSummaryFormattable> & PickWithOpt<VideoModel, 'ScheduleVideoUpdate', Pick<MScheduleVideoUpdate, 'updateAt' | 'privacy'>> &