Merge branch 'Chocobozzz:develop' into feature/Remember-user-table-pagination-in-admin

This commit is contained in:
Wicklow 2023-10-23 13:21:55 +00:00 committed by GitHub
commit 0e4ff58ddb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 12648 additions and 11406 deletions

View File

@ -1,5 +1,148 @@
# Changelog
## 6.0.0-rc.1 (not yet released)
### IMPORTANT NOTES
We have many important notes in this release. We know it's a pain for sysadmin, but consider each one as a major step forward for PeerTube quality!
#### Sysadmins important notes
* Remove NodeJS 16 support (see https://nodejs.org/fr/blog/announcements/nodejs16-eol):
* Please upgrade to NodeJS 18 before upgrading PeerTube
* If you use NodeSource repository, you may have to migrate to their new repository: https://github.com/nodesource/distributions/wiki/How-to-migrate-to-the-new-repository
* Check in `production.yaml` that you use `127.0.0.1` instead of `localhost` for `listen.hostname` (https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L2) to ensure PeerTube is listening on IPv4
* Remove WebTorrent support in player:
* "WebTorrent videos" are renamed to "Web Video". The video format is the same, we just stop to use P2P for these videos
* There is not "Auto" quality anymore for Web Videos. The viewer has to explicitly choose the video resolution
* We still use P2P with the HLS player, which is the recommended transcoding format since several versions
* See https://github.com/Chocobozzz/PeerTube/issues/5465 for more information
* Configuration key that you must update in your `production.yaml` if not automatically done by your upgrade script:
* `storage.videos` must be **renamed** to `storage.web_videos`. The value of this configuration doesn't need to be changed: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L151
* `transcoding.webtorrent` must be **renamed** to `transcoding.web_videos`: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L522
* `object_storage.videos` must be **renamed** to `object_storage.web_videos`. The value of this `object_storage.web_videos.bucket_name` doesn't need to be changed: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L223
* `storage.storyboards` must be **added**: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L157
* PeerTube Docker image now uses `bookworm`. `chocobozzz/peertube:production-bullseye` needs to be replaced by `chocobozzz/peertube:production-bookworm`
* Env configuration that your must update if you use Docker:
* `PEERTUBE_TRANSCODING_WEBTORRENT_ENABLED` must be **renamed** to `PEERTUBE_TRANSCODING_WEB_VIDEOS_ENABLED`
* `PEERTUBE_OBJECT_STORAGE_VIDEOS_BUCKET_NAME` must be **renamed** to `PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_BUCKET_NAME`
* `PEERTUBE_OBJECT_STORAGE_VIDEOS_PREFIX` must be **renamed** to `PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_PREFIX`
* `PEERTUBE_OBJECT_STORAGE_VIDEOS_BASE_URL` must be **renamed** to `PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_BASE_URL`
* You must update nginx configuration: https://github.com/Chocobozzz/PeerTube/blob/develop/support/nginx/peertube
* `location ~ ^/static/(thumbnails|avatars)/ {` block must be removed
* `location ~ ^(/static/(webseed|streaming-playlists)/private/)|^/download {` must be updated to `location ~ ^(/static/(webseed|web-videos|streaming-playlists)/private/)|^/download {`
* `location ~ ^/static/(webseed|redundancy|streaming-playlists)/ {` must be updated to `location ~ ^/static/(webseed|web-videos|redundancy|streaming-playlists)/ {`
* Tracing requires `--experimental-loader=@opentelemetry/instrumentation/hook.mjs` node option: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L263
#### Developers important notes
* REST API breaking changes:
* Removed `webtorrentEnabled` from user response (deprecated since 4.1 in favour of `p2pEnabled`)
* Removed `avatar` and `banner` fields from account/channel responses (deprecated since 4.2 in favour of `avatars` and `banners`)
* Removed `filter` query when listing videos (deprecated since 4.0 in favour of `isLocal` and `include`)
* Deprecate `/api/v1/videos/:id/webtorrent` video file routes in favour of `/api/v1/videos/:id/web-videos` routes
* Deprecate `hasWebtorrentFiles` body video filter in favour of `hasWebVideoFiles` when listing videos
* Deprecate `webtorrent` `transcodingType` in favour of `web-video` in `/api/v1/videos/{id}/transcoding` route
* `currentTime` is now required to notify the user is watching the video using `/api/v1/videos/{id}/views` (introduced in 4.2)
* Static server paths breaking changes:
* `/static/webseed/...` is deprecated in favour of `/static/web-videos/...`
* `/object-storage-proxy/webseed/...` is deprecated in favour of `/object-storage-proxy/web-videos/...`
* `/static/thumbnails/...` is deprecated in favour of `/static/lazy-thumbnails/...`
* Plugin API breaking changes:
* Deprecated `webtorrent` key in `getFiles()` helper result. Use `webVideo` instead
### CLI tools
* Removed unmaintained `peertube-import-videos` (also aliased as `peertube import-videos` or `peertube import`) script
* PeerTube remote CLI is much more simpler to install using NPM: https://docs.joinpeertube.org/maintain/tools#remote-peertube-cli
### Features
* :tada: Add "Password protected" video privacy [#5836](https://github.com/Chocobozzz/PeerTube/pull/5836) :tada:
* A single password can be set using the web interface at video upload/import/update
* The REST API can store as many passwords as you want, allowing developers to use this feature to easily give or revoke access to a video *on the fly*
* :tada: Add video storyboard support :tada:
* PeerTube automatically generates a storyboard on video upload/import
* Viewers can see the image around the targeted timecode when hovering the progress bar
* Storyboard of videos uploaded/imported before v6 can be generated by the admin using `npm run create-generate-storyboard-job` command
* :tada: Add ability for users to replace their video file :tada:
* Has to be enabled by the PeerTube instance administrator
* The user can replace the video file in the *Update Video* page
* The *re-upload* date is displayed under the video player
* :tada: Add video chapters support :tada:
* Add chapters in the upload/import/update video page or let PeerTube automatically imports them from the video container/youtube-dl
* Markers are displayed in the player progress bar to symbolize a chapter
* Chapter title is displayed when hovering/touching the player progress bar
* Better video player:
* More efficient as we don't rebuild the player every time the played video changes
* The player keeps the current player settings (playback speed, fullscreen...) when the played video changes
* Automatically adjust the player size to match video ratio
* Improve SEO and video link sharing:
* Use short video/channel/account URLs in sitemap and for canonical tags
* Add JSON-LD tag in embed page
* Embed page does not forbid indexation anymore: we use a canonical tag instead that targets the watch page
* Forbid indexation of remote videos, accounts and channels (instead of providing an invalid canonical tag)
* Truncate OpenGraph/Twitter card link description
* Fix client accessibility and keyboard navigation:
* Fix links in bootstrap alerts color
* Better input placeholder contrast
* Fix video miniature link label
* Add ability to disable hotkeys
* Improve table overall accessibility
* Wrap icons that can lead to an action inside buttons
* Fix left menu admin/my-library menu accessibility
* And many more improvements!
* Improve remote runner management:
* Add ability to remove runner jobs
* Add runner job state quick filter
* Merge registration tokens and runners tables in same page
* Add copy button to copy registration token
* Add ability for admins to force transcoding on a specific video even if it's in broken state (stuck in *To Transcode* for example)
* Add an option to sign federated fetches (ActivityPub based software such as Mastodon may require it to access content)
* Download video file directly from S3 using pre signed URLs
* Lazy download remote video thumbnails to reduce storage
* Improve recommended videos when the watched video doesn't have tags set
* Add more rate limits in configuration (`plugins`, `well-known`, `feeds`, `activity_pub` and `client` endpoints)
* Add ability to reset video *Originally published at* attribute
* Add ability for admins to set the default user channel name [#6000](https://github.com/Chocobozzz/PeerTube/pull/6000)
* Server now uses [ESM modules](https://nodejs.org/api/esm.html)
### Bug fixes
* Don't cache upload response if the video has been deleted
* Fix broken upgrade script when using custom database port
* Prevent duplicate runner names
* Avoid runner job update error
* Notify remote runners there are available jobs when a job is aborted/errored
* Fix updating P2P settings in left menu
* Fix 500 HTTP error on invalid short UUID conversion
* Don't display admin email in `security.txt` well-known endpoint
* Optimize `update-host` script to fix out of memory error
* Fix error log when using an unconventional distribution of FFmpeg with a non-standard version string [#5917](https://github.com/Chocobozzz/PeerTube/pull/5917)
* Fix live replay REST API breaking change: `replaySettings.privacy` is not required anymore
* Fix broken live replay when updating replay privacy
* More robust *About* page when getting category from server
* Fix `ERR_HTTP_HEADERS_SENT` crash
* Avoid illegal characters in torrent filename
* Avoid federation error log with remote `Like` on `Note`
* Fix atom feed with *Science & Technology* category
* Support empty value returned by `filter:api.video.get.result` hook
* Prevent remote subscribe on accounts (not yet supported by PeerTube)
* Fix feed audio file mimetype
* Fix video quality on high video resolution/fps
* Fix disabling Object Storage ACL using Docker env `PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PUBLIC` and `PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PRIVATE` in `.env`
## v5.2.1
### Bug fixes

View File

@ -53,7 +53,7 @@
"@formatjs/intl-locale": "^3.3.1",
"@formatjs/intl-pluralrules": "^5.2.2",
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
"@ng-select/ng-select": "^11.1.1",
"@ng-select/ng-select": "^11.2.0",
"@ngx-loading-bar/core": "^6.0.0",
"@ngx-loading-bar/http-client": "^6.0.0",
"@ngx-loading-bar/router": "^6.0.0",

View File

@ -129,6 +129,7 @@ my-actor-avatar {
.video-actions {
margin: 0;
top: -3px;
width: auto;
::ng-deep .dropdown-root {
opacity: 1 !important;

View File

@ -77,7 +77,6 @@ $margin-top: 2rem;
}
@media screen and (max-width: $mobile-view) {
.videos-header,
my-video-filters-header {
@include margin-left(1rem);
@include margin-right(1rem);
@ -93,5 +92,6 @@ $margin-top: 2rem;
text-align: center;
width: 100%;
margin-bottom: 1rem;
display: block;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,19 +6,6 @@
<meta name="robots" content="noindex">
<meta property="og:platform" content="PeerTube" />
<script type="text/javascript">
// Thanks: https://mathiasbynens.be/notes/globalthis
(function() {
if (typeof globalThis === 'object') return;
Object.prototype.__defineGetter__('__magic__', function() {
return this;
});
__magic__.globalThis = __magic__
delete Object.prototype.__magic__;
}());
</script>
<!-- /!\ The following comment is used by the server to prerender some tags /!\ -->
<!-- title tag -->

View File

@ -1949,10 +1949,10 @@
dependencies:
tslib "^2.3.0"
"@ng-select/ng-select@^11.1.1":
version "11.1.1"
resolved "https://registry.yarnpkg.com/@ng-select/ng-select/-/ng-select-11.1.1.tgz#49fc89420bfc4022543833de5212214a1e00a0dd"
integrity sha512-Z5wV/u2HgaKl7CQSG3Sy1oF+BPQolmVV6jBuPqHa2+OWg0Nn2e9eXYdcZT8Q3BahfP5j5rHNIBrkkESg/m4YiQ==
"@ng-select/ng-select@^11.2.0":
version "11.2.0"
resolved "https://registry.yarnpkg.com/@ng-select/ng-select/-/ng-select-11.2.0.tgz#33048ddeb6a524078f3134167d75137239bf1c6e"
integrity sha512-lTyw93kFdKGecp9eKmOP0PQSCaAJS8DCt4D60ns055+ixvRSp2fuXAuJUvn1e3gAsvpZor37osmYlOJ4LYwYIA==
dependencies:
tslib "^2.3.1"

View File

@ -458,6 +458,7 @@ user:
# -1 == unlimited
video_quota: -1
video_quota_daily: -1
default_channel_name: 'Main $1 channel' # The placeholder $1 is used to represent the user's username
video_channels:
max_per_user: 20 # Allows each user to create up to 20 video channels.

View File

@ -468,6 +468,8 @@ user:
# -1 == unlimited
video_quota: -1
video_quota_daily: -1
default_channel_name: 'Main $1 channel' # The placeholder $1 is used to represent the user's username
video_channels:
max_per_user: 20 # Allows each user to create up to 20 video channels.

View File

@ -1,3 +1,4 @@
import { FfprobeData } from 'fluent-ffmpeg'
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
import { getVideoStreamDuration } from './ffprobe.js'
@ -38,10 +39,11 @@ export class FFmpegImage {
async generateThumbnailFromVideo (options: {
fromPath: string
output: string
ffprobe?: FfprobeData
}) {
const { fromPath, output } = options
const { fromPath, output, ffprobe } = options
let duration = await getVideoStreamDuration(fromPath)
let duration = await getVideoStreamDuration(fromPath, ffprobe)
if (isNaN(duration)) duration = 0
this.commandWrapper.buildCommand(fromPath)

View File

@ -108,6 +108,7 @@ export interface CustomConfig {
}
videoQuota: number
videoQuotaDaily: number
defaultChannelName: string
}
videoChannels: {

View File

@ -428,7 +428,8 @@ export class ConfigCommand extends AbstractCommand {
}
},
videoQuota: 5242881,
videoQuotaDaily: 318742
videoQuotaDaily: 318742,
defaultChannelName: 'Main $1 channel'
},
videoChannels: {
maxPerUser: 20

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -99,7 +99,8 @@ describe('Test config API validators', function () {
}
},
videoQuota: 5242881,
videoQuotaDaily: 318742
videoQuotaDaily: 318742,
defaultChannelName: 'Main $1 channel'
},
videoChannels: {
maxPerUser: 20

View File

@ -321,7 +321,8 @@ const newCustomConfig: CustomConfig = {
}
},
videoQuota: 5242881,
videoQuotaDaily: 318742
videoQuotaDaily: 318742,
defaultChannelName: 'Main $1 channel'
},
videoChannels: {
maxPerUser: 24

View File

@ -546,6 +546,30 @@ describe('Test video channels', function () {
}
})
it('Should apply another default channel name', async function () {
this.timeout(15000)
await servers[0].config.updateCustomSubConfig({
newConfig: {
user: {
defaultChannelName: `$1's channel`
}
}
})
await servers[0].users.generate('third_user')
const body = await servers[0].channels.listByAccount({ accountName: 'third_user' })
expect(body.total).to.equal(1)
expect(body.data).to.be.an('array')
expect(body.data).to.have.lengthOf(1)
const videoChannel = body.data[0]
expect(videoChannel.displayName).to.equal(`third_user's channel`)
expect(videoChannel.name).to.equal('third_user_channel')
})
after(async function () {
for (const sqlCommand of sqlCommands) {
await sqlCommand.cleanup()

View File

@ -652,7 +652,7 @@ describe('Test video playlists', function () {
let video3: string
before(async function () {
this.timeout(60000)
this.timeout(120000)
groupUser1 = [ Object.assign({}, servers[0], { accessToken: userTokenServer1 }) ]
groupWithoutToken1 = [ Object.assign({}, servers[0], { accessToken: undefined }) ]

View File

@ -1,556 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { omit } from '@peertube/peertube-core-utils'
import {
Account,
HTMLServerConfig,
HttpStatusCode,
ServerConfig,
VideoPlaylistCreateResult,
VideoPlaylistPrivacy,
VideoPrivacy
} from '@peertube/peertube-models'
import {
cleanupTests,
createMultipleServers,
doubleFollow,
makeGetRequest,
makeHTMLRequest,
PeerTubeServer,
setAccessTokensToServers,
setDefaultVideoChannel,
waitJobs
} from '@peertube/peertube-server-commands'
function checkIndexTags (html: string, title: string, description: string, css: string, config: ServerConfig) {
expect(html).to.contain('<title>' + title + '</title>')
expect(html).to.contain('<meta name="description" content="' + description + '" />')
expect(html).to.contain('<style class="custom-css-style">' + css + '</style>')
const htmlConfig: HTMLServerConfig = omit(config, [ 'signup' ])
const configObjectString = JSON.stringify(htmlConfig)
const configEscapedString = JSON.stringify(configObjectString)
expect(html).to.contain(`<script type="application/javascript">window.PeerTubeServerConfig = ${configEscapedString}</script>`)
}
describe('Test a client controllers', function () {
let servers: PeerTubeServer[] = []
let account: Account
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'
const playlistName = 'super playlist name'
const playlistDescription = 'super playlist description'
let playlist: VideoPlaylistCreateResult
const channelDescription = 'my super channel description'
const watchVideoBasePaths = [ '/videos/watch/', '/w/' ]
const watchPlaylistBasePaths = [ '/videos/watch/playlist/', '/w/p/' ]
let videoIds: (string | number)[] = []
let privateVideoId: string
let internalVideoId: string
let unlistedVideoId: string
let passwordProtectedVideoId: string
let playlistIds: (string | number)[] = []
before(async function () {
this.timeout(120000)
servers = await createMultipleServers(2)
await setAccessTokensToServers(servers)
await doubleFollow(servers[0], servers[1])
await setDefaultVideoChannel(servers)
await servers[0].channels.update({
channelName: servers[0].store.channel.name,
attributes: { description: channelDescription }
})
// Public video
{
const attributes = { name: videoName, description: videoDescription }
await servers[0].videos.upload({ attributes })
const { data } = await servers[0].videos.list()
expect(data.length).to.equal(1)
const video = data[0]
servers[0].store.video = video
videoIds = [ video.id, video.uuid, video.shortUUID ]
}
{
({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }));
({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }));
({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }));
({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({
name: 'password protected',
privacy: VideoPrivacy.PASSWORD_PROTECTED,
videoPasswords: [ 'password' ]
}))
}
// Playlist
{
const attributes = {
displayName: playlistName,
description: playlistDescription,
privacy: VideoPlaylistPrivacy.PUBLIC,
videoChannelId: servers[0].store.channel.id
}
playlist = await servers[0].playlists.create({ attributes })
playlistIds = [ playlist.id, playlist.shortUUID, playlist.uuid ]
await servers[0].playlists.addElement({ playlistId: playlist.shortUUID, attributes: { videoId: servers[0].store.video.id } })
}
// Account
{
await servers[0].users.updateMe({ description: 'my account description' })
account = await servers[0].accounts.get({ accountName: `${servers[0].store.user.username}@${servers[0].host}` })
}
await waitJobs(servers)
})
describe('oEmbed', function () {
it('Should have valid oEmbed discovery tags for videos', async function () {
for (const basePath of watchVideoBasePaths) {
for (const id of videoIds) {
const res = await makeGetRequest({
url: servers[0].url,
path: basePath + id,
accept: 'text/html',
expectedStatus: HttpStatusCode.OK_200
})
const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` +
`url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2F${servers[0].store.video.shortUUID}" ` +
`title="${servers[0].store.video.name}" />`
expect(res.text).to.contain(expectedLink)
}
}
})
it('Should have valid oEmbed discovery tags for a playlist', async function () {
for (const basePath of watchPlaylistBasePaths) {
for (const id of playlistIds) {
const res = await makeGetRequest({
url: servers[0].url,
path: basePath + id,
accept: 'text/html',
expectedStatus: HttpStatusCode.OK_200
})
const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` +
`url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2Fp%2F${playlist.shortUUID}" ` +
`title="${playlistName}" />`
expect(res.text).to.contain(expectedLink)
}
}
})
})
describe('Open Graph', function () {
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
expect(text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
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}" />`)
}
async function channelPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain(`<meta property="og:title" content="${servers[0].store.channel.displayName}" />`)
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}" />`)
}
async function watchVideoPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain(`<meta property="og:title" content="${videoName}" />`)
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}" />`)
}
async function watchPlaylistPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain(`<meta property="og:title" content="${playlistName}" />`)
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}" />`)
}
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)
await accountPageTest('/@' + servers[0].store.user.username)
})
it('Should have valid Open Graph tags on the channel page', async function () {
await channelPageTest('/video-channels/' + servers[0].store.channel.name)
await channelPageTest('/c/' + servers[0].store.channel.name)
await channelPageTest('/@' + servers[0].store.channel.name)
})
it('Should have valid Open Graph tags on the watch page', async function () {
for (const path of watchVideoBasePaths) {
for (const id of videoIds) {
await watchVideoPageTest(path + id)
}
}
})
it('Should have valid Open Graph tags on the watch page with thread id Angular param', async function () {
for (const path of watchVideoBasePaths) {
for (const id of videoIds) {
await watchVideoPageTest(path + id + ';threadId=1')
}
}
})
it('Should have valid Open Graph tags on the watch playlist page', async function () {
for (const path of watchPlaylistBasePaths) {
for (const id of playlistIds) {
await watchPlaylistPageTest(path + id)
}
}
})
})
describe('Twitter card', async function () {
describe('Not whitelisted', function () {
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
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
expect(text).to.contain(`<meta property="twitter:title" content="${account.name}" />`)
expect(text).to.contain(`<meta property="twitter:description" content="${account.description}" />`)
}
async function channelPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
expect(text).to.contain(`<meta property="twitter:title" content="${servers[0].store.channel.displayName}" />`)
expect(text).to.contain(`<meta property="twitter:description" content="${channelDescription}" />`)
}
async function watchVideoPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain('<meta property="twitter:card" content="summary_large_image" />')
expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
expect(text).to.contain(`<meta property="twitter:title" content="${videoName}" />`)
expect(text).to.contain(`<meta property="twitter:description" content="${videoDescriptionPlainText}" />`)
}
async function watchPlaylistPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
expect(text).to.contain(`<meta property="twitter:title" content="${playlistName}" />`)
expect(text).to.contain(`<meta property="twitter:description" content="${playlistDescription}" />`)
}
it('Should have valid twitter card on the watch video page', async function () {
for (const path of watchVideoBasePaths) {
for (const id of videoIds) {
await watchVideoPageTest(path + id)
}
}
})
it('Should have valid twitter card on the watch playlist page', async function () {
for (const path of watchPlaylistBasePaths) {
for (const id of playlistIds) {
await watchPlaylistPageTest(path + id)
}
}
})
it('Should have valid twitter card on the account page', async function () {
await accountPageTest('/accounts/' + account.name)
await accountPageTest('/a/' + account.name)
await accountPageTest('/@' + account.name)
})
it('Should have valid twitter card on the channel page', async function () {
await channelPageTest('/video-channels/' + servers[0].store.channel.name)
await channelPageTest('/c/' + servers[0].store.channel.name)
await channelPageTest('/@' + servers[0].store.channel.name)
})
})
describe('Whitelisted', function () {
before(async function () {
const config = await servers[0].config.getCustomConfig()
config.services.twitter = {
username: '@Kuja',
whitelisted: true
}
await servers[0].config.updateCustomConfig({ newCustomConfig: config })
})
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
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
}
async function channelPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
}
async function watchVideoPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain('<meta property="twitter:card" content="player" />')
expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
}
async function watchPlaylistPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain('<meta property="twitter:card" content="player" />')
expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
}
it('Should have valid twitter card on the watch video page', async function () {
for (const path of watchVideoBasePaths) {
for (const id of videoIds) {
await watchVideoPageTest(path + id)
}
}
})
it('Should have valid twitter card on the watch playlist page', async function () {
for (const path of watchPlaylistBasePaths) {
for (const id of playlistIds) {
await watchPlaylistPageTest(path + id)
}
}
})
it('Should have valid twitter card on the account page', async function () {
await accountPageTest('/accounts/' + account.name)
await accountPageTest('/a/' + account.name)
await accountPageTest('/@' + account.name)
})
it('Should have valid twitter card on the channel page', async function () {
await channelPageTest('/video-channels/' + servers[0].store.channel.name)
await channelPageTest('/c/' + servers[0].store.channel.name)
await channelPageTest('/@' + servers[0].store.channel.name)
})
})
})
describe('Index HTML', function () {
it('Should have valid index html tags (title, description...)', async function () {
const config = await servers[0].config.getConfig()
const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
const description = 'PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.'
checkIndexTags(res.text, 'PeerTube', description, '', config)
})
it('Should update the customized configuration and have the correct index html tags', async function () {
await servers[0].config.updateCustomSubConfig({
newConfig: {
instance: {
name: 'PeerTube updated',
shortDescription: 'my short description',
description: 'my super description',
terms: 'my super terms',
defaultNSFWPolicy: 'blur',
defaultClientRoute: '/videos/recently-added',
customizations: {
javascript: 'alert("coucou")',
css: 'body { background-color: red; }'
}
}
}
})
const config = await servers[0].config.getConfig()
const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config)
})
it('Should have valid index html updated tags (title, description...)', async function () {
const config = await servers[0].config.getConfig()
const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config)
})
it('Should use the original video URL for the canonical tag', async function () {
for (const basePath of watchVideoBasePaths) {
for (const id of videoIds) {
const res = await makeHTMLRequest(servers[1].url, basePath + id)
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/videos/watch/${servers[0].store.video.uuid}" />`)
}
}
})
it('Should use the original account URL for the canonical tag', async function () {
const accountURLtest = res => {
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/accounts/root" />`)
}
accountURLtest(await makeHTMLRequest(servers[1].url, '/accounts/root@' + servers[0].host))
accountURLtest(await makeHTMLRequest(servers[1].url, '/a/root@' + servers[0].host))
accountURLtest(await makeHTMLRequest(servers[1].url, '/@root@' + servers[0].host))
})
it('Should use the original channel URL for the canonical tag', async function () {
const channelURLtests = res => {
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-channels/root_channel" />`)
}
channelURLtests(await makeHTMLRequest(servers[1].url, '/video-channels/root_channel@' + servers[0].host))
channelURLtests(await makeHTMLRequest(servers[1].url, '/c/root_channel@' + servers[0].host))
channelURLtests(await makeHTMLRequest(servers[1].url, '/@root_channel@' + servers[0].host))
})
it('Should use the original playlist URL for the canonical tag', async function () {
for (const basePath of watchPlaylistBasePaths) {
for (const id of playlistIds) {
const res = await makeHTMLRequest(servers[1].url, basePath + id)
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-playlists/${playlist.uuid}" />`)
}
}
})
it('Should add noindex meta tag for remote accounts', async function () {
const handle = 'root@' + servers[0].host
const paths = [ '/accounts/', '/a/', '/@' ]
for (const path of paths) {
{
const { text } = await makeHTMLRequest(servers[1].url, path + handle)
expect(text).to.contain('<meta name="robots" content="noindex" />')
}
{
const { text } = await makeHTMLRequest(servers[0].url, path + handle)
expect(text).to.not.contain('<meta name="robots" content="noindex" />')
}
}
})
it('Should add noindex meta tag for remote channels', async function () {
const handle = 'root_channel@' + servers[0].host
const paths = [ '/video-channels/', '/c/', '/@' ]
for (const path of paths) {
{
const { text } = await makeHTMLRequest(servers[1].url, path + handle)
expect(text).to.contain('<meta name="robots" content="noindex" />')
}
{
const { text } = await makeHTMLRequest(servers[0].url, path + handle)
expect(text).to.not.contain('<meta name="robots" content="noindex" />')
}
}
})
it('Should not display internal/private/password protected video', async function () {
for (const basePath of watchVideoBasePaths) {
for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) {
const res = await makeGetRequest({
url: servers[0].url,
path: basePath + id,
accept: 'text/html',
expectedStatus: HttpStatusCode.NOT_FOUND_404
})
expect(res.text).to.not.contain('internal')
expect(res.text).to.not.contain('private')
expect(res.text).to.not.contain('password protected')
}
}
})
it('Should add noindex meta tag for unlisted video', async function () {
for (const basePath of watchVideoBasePaths) {
const res = await makeGetRequest({
url: servers[0].url,
path: basePath + unlistedVideoId,
accept: 'text/html',
expectedStatus: HttpStatusCode.OK_200
})
expect(res.text).to.contain('unlisted')
expect(res.text).to.contain('<meta name="robots" content="noindex" />')
}
})
})
describe('Embed HTML', function () {
it('Should have the correct embed html tags', async function () {
const config = await servers[0].config.getConfig()
const res = await makeHTMLRequest(servers[0].url, servers[0].store.video.embedPath)
checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config)
})
})
after(async function () {
await cleanupTests(servers)
})
})

View File

@ -0,0 +1,187 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { ServerConfig, VideoPlaylistCreateResult } from '@peertube/peertube-models'
import { cleanupTests, makeHTMLRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
import { checkIndexTags, prepareClientTests } from '@tests/shared/client.js'
describe('Test embed HTML generation', function () {
let servers: PeerTubeServer[]
let videoIds: (string | number)[] = []
let videoName: string
let videoDescriptionPlainText: string
let privateVideoId: string
let internalVideoId: string
let unlistedVideoId: string
let passwordProtectedVideoId: string
let playlistIds: (string | number)[] = []
let playlist: VideoPlaylistCreateResult
let privatePlaylistId: string
let unlistedPlaylistId: string
let playlistName: string
let playlistDescription: string
let instanceDescription: string
before(async function () {
this.timeout(120000);
({
servers,
videoIds,
privateVideoId,
internalVideoId,
passwordProtectedVideoId,
unlistedVideoId,
videoName,
videoDescriptionPlainText,
playlistIds,
playlistName,
playlistDescription,
playlist,
unlistedPlaylistId,
privatePlaylistId,
instanceDescription
} = await prepareClientTests())
})
describe('HTML tags', function () {
let config: ServerConfig
before(async function () {
config = await servers[0].config.getConfig()
})
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)
expect(res.text).to.not.contain(`"name":`)
})
it('Should have the correct embed html video tags', async function () {
const config = await servers[0].config.getConfig()
const res = await makeHTMLRequest(servers[0].url, servers[0].store.video.embedPath)
checkIndexTags(res.text, `${videoName} - PeerTube`, videoDescriptionPlainText, '', config)
expect(res.text).to.contain(`"name":"${videoName}",`)
})
it('Should have the correct embed html playlist tags', async function () {
const config = await servers[0].config.getConfig()
const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + playlistIds[0])
checkIndexTags(res.text, `${playlistName} - PeerTube`, playlistDescription, '', config)
expect(res.text).to.contain(`"name":"${playlistName}",`)
})
})
describe('Canonical tags', function () {
it('Should use the original video URL for the canonical tag', async function () {
for (const id of videoIds) {
const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + id)
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/w/${servers[0].store.video.shortUUID}" />`)
}
})
it('Should use the original playlist URL for the canonical tag', async function () {
for (const id of playlistIds) {
const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + id)
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/w/p/${playlist.shortUUID}" />`)
}
})
})
describe('Indexation tags', function () {
it('Should not index remote videos', async function () {
for (const id of videoIds) {
{
const res = await makeHTMLRequest(servers[1].url, '/videos/embed/' + id)
expect(res.text).to.contain('<meta name="robots" content="noindex" />')
}
{
const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + id)
expect(res.text).to.not.contain('<meta name="robots" content="noindex" />')
}
}
})
it('Should not index remote playlists', async function () {
for (const id of playlistIds) {
{
const res = await makeHTMLRequest(servers[1].url, '/video-playlists/embed/' + id)
expect(res.text).to.contain('<meta name="robots" content="noindex" />')
}
{
const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + id)
expect(res.text).to.not.contain('<meta name="robots" content="noindex" />')
}
}
})
it('Should add noindex meta tags for unlisted video', async function () {
{
const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + videoIds[0])
expect(res.text).to.not.contain('<meta name="robots" content="noindex" />')
}
{
const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + unlistedVideoId)
expect(res.text).to.contain('unlisted')
expect(res.text).to.contain('<meta name="robots" content="noindex" />')
}
})
it('Should add noindex meta tags for unlisted playlist', async function () {
{
const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + playlistIds[0])
expect(res.text).to.not.contain('<meta name="robots" content="noindex" />')
}
{
const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + unlistedPlaylistId)
expect(res.text).to.contain('unlisted')
expect(res.text).to.contain('<meta name="robots" content="noindex" />')
}
})
})
describe('Check leak of private objects', function () {
it('Should not leak video information in embed', async function () {
for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) {
const res = await makeHTMLRequest(servers[0].url, '/videos/embed/' + id)
expect(res.text).to.not.contain('internal')
expect(res.text).to.not.contain('private')
expect(res.text).to.not.contain('password protected')
expect(res.text).to.contain('<meta name="robots" content="noindex" />')
}
})
it('Should not leak playlist information in embed', async function () {
const res = await makeHTMLRequest(servers[0].url, '/video-playlists/embed/' + privatePlaylistId)
expect(res.text).to.not.contain('private')
expect(res.text).to.contain('<meta name="robots" content="noindex" />')
})
})
after(async function () {
await cleanupTests(servers)
})
})

View File

@ -0,0 +1,258 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { HttpStatusCode, VideoPlaylistCreateResult } from '@peertube/peertube-models'
import { cleanupTests, makeGetRequest, makeHTMLRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
import { checkIndexTags, getWatchPlaylistBasePaths, getWatchVideoBasePaths, prepareClientTests } from '@tests/shared/client.js'
describe('Test index HTML generation', function () {
let servers: PeerTubeServer[]
let videoIds: (string | number)[] = []
let privateVideoId: string
let internalVideoId: string
let unlistedVideoId: string
let passwordProtectedVideoId: string
let playlist: VideoPlaylistCreateResult
let playlistIds: (string | number)[] = []
let privatePlaylistId: string
let unlistedPlaylistId: string
let instanceDescription: string
before(async function () {
this.timeout(120000);
({
servers,
playlistIds,
videoIds,
playlist,
privateVideoId,
internalVideoId,
passwordProtectedVideoId,
unlistedVideoId,
privatePlaylistId,
unlistedPlaylistId,
instanceDescription
} = await prepareClientTests())
})
describe('Instance tags', function () {
it('Should have valid index html tags (title, description...)', async function () {
const config = await servers[0].config.getConfig()
const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
checkIndexTags(res.text, 'PeerTube', instanceDescription, '', config)
})
it('Should update the customized configuration and have the correct index html tags', async function () {
await servers[0].config.updateCustomSubConfig({
newConfig: {
instance: {
name: 'PeerTube updated',
shortDescription: 'my short description',
description: 'my super description',
terms: 'my super terms',
defaultNSFWPolicy: 'blur',
defaultClientRoute: '/videos/recently-added',
customizations: {
javascript: 'alert("coucou")',
css: 'body { background-color: red; }'
}
}
}
})
const config = await servers[0].config.getConfig()
const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config)
})
it('Should have valid index html updated tags (title, description...)', async function () {
const config = await servers[0].config.getConfig()
const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', config)
})
})
describe('Canonical tags', function () {
it('Should use the original video URL for the canonical tag', async function () {
for (const basePath of getWatchVideoBasePaths()) {
for (const id of videoIds) {
const res = await makeHTMLRequest(servers[0].url, basePath + id)
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/w/${servers[0].store.video.shortUUID}" />`)
}
}
})
it('Should use the original playlist URL for the canonical tag', async function () {
for (const basePath of getWatchPlaylistBasePaths()) {
for (const id of playlistIds) {
const res = await makeHTMLRequest(servers[0].url, basePath + id)
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/w/p/${playlist.shortUUID}" />`)
}
}
})
it('Should use the original account URL for the canonical tag', async function () {
const accountURLtest = res => {
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/a/root" />`)
}
accountURLtest(await makeHTMLRequest(servers[0].url, '/accounts/root@' + servers[0].host))
accountURLtest(await makeHTMLRequest(servers[0].url, '/a/root@' + servers[0].host))
accountURLtest(await makeHTMLRequest(servers[0].url, '/@root@' + servers[0].host))
})
it('Should use the original channel URL for the canonical tag', async function () {
const channelURLtests = res => {
expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/c/root_channel" />`)
}
channelURLtests(await makeHTMLRequest(servers[0].url, '/video-channels/root_channel@' + servers[0].host))
channelURLtests(await makeHTMLRequest(servers[0].url, '/c/root_channel@' + servers[0].host))
channelURLtests(await makeHTMLRequest(servers[0].url, '/@root_channel@' + servers[0].host))
})
})
describe('Indexation tags', function () {
it('Should not index remote videos', async function () {
for (const basePath of getWatchVideoBasePaths()) {
for (const id of videoIds) {
{
const res = await makeHTMLRequest(servers[1].url, basePath + id)
expect(res.text).to.contain('<meta name="robots" content="noindex" />')
}
{
const res = await makeHTMLRequest(servers[0].url, basePath + id)
expect(res.text).to.not.contain('<meta name="robots" content="noindex" />')
}
}
}
})
it('Should not index remote playlists', async function () {
for (const basePath of getWatchPlaylistBasePaths()) {
for (const id of playlistIds) {
{
const res = await makeHTMLRequest(servers[1].url, basePath + id)
expect(res.text).to.contain('<meta name="robots" content="noindex" />')
}
{
const res = await makeHTMLRequest(servers[0].url, basePath + id)
expect(res.text).to.not.contain('<meta name="robots" content="noindex" />')
}
}
}
})
it('Should add noindex meta tag for remote accounts', async function () {
const handle = 'root@' + servers[0].host
const paths = [ '/accounts/', '/a/', '/@' ]
for (const path of paths) {
{
const { text } = await makeHTMLRequest(servers[1].url, path + handle)
expect(text).to.contain('<meta name="robots" content="noindex" />')
}
{
const { text } = await makeHTMLRequest(servers[0].url, path + handle)
expect(text).to.not.contain('<meta name="robots" content="noindex" />')
}
}
})
it('Should add noindex meta tag for remote channels', async function () {
const handle = 'root_channel@' + servers[0].host
const paths = [ '/video-channels/', '/c/', '/@' ]
for (const path of paths) {
{
const { text } = await makeHTMLRequest(servers[1].url, path + handle)
expect(text).to.contain('<meta name="robots" content="noindex" />')
}
{
const { text } = await makeHTMLRequest(servers[0].url, path + handle)
expect(text).to.not.contain('<meta name="robots" content="noindex" />')
}
}
})
it('Should add noindex meta tag for unlisted video', async function () {
for (const basePath of getWatchVideoBasePaths()) {
const res = await makeGetRequest({
url: servers[0].url,
path: basePath + unlistedVideoId,
accept: 'text/html',
expectedStatus: HttpStatusCode.OK_200
})
expect(res.text).to.contain('unlisted')
expect(res.text).to.contain('<meta name="robots" content="noindex" />')
}
})
it('Should add noindex meta tag for unlisted video playlist', async function () {
for (const basePath of getWatchPlaylistBasePaths()) {
const res = await makeGetRequest({
url: servers[0].url,
path: basePath + unlistedPlaylistId,
accept: 'text/html',
expectedStatus: HttpStatusCode.OK_200
})
expect(res.text).to.contain('unlisted')
expect(res.text).to.contain('<meta name="robots" content="noindex" />')
}
})
})
describe('Check no leaks for private objects', function () {
it('Should not display internal/private/password protected video', async function () {
for (const basePath of getWatchVideoBasePaths()) {
for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) {
const res = await makeGetRequest({
url: servers[0].url,
path: basePath + id,
accept: 'text/html',
expectedStatus: HttpStatusCode.NOT_FOUND_404
})
expect(res.text).to.not.contain('internal')
expect(res.text).to.not.contain('private')
expect(res.text).to.not.contain('password protected')
}
}
})
it('Should not display private video playlist', async function () {
for (const basePath of getWatchPlaylistBasePaths()) {
const res = await makeGetRequest({
url: servers[0].url,
path: basePath + privatePlaylistId,
accept: 'text/html',
expectedStatus: HttpStatusCode.NOT_FOUND_404
})
expect(res.text).to.not.contain('private')
}
})
})
after(async function () {
await cleanupTests(servers)
})
})

View File

@ -0,0 +1,4 @@
export * from './embed-html.js'
export * from './index-html.js'
export * from './oembed.js'
export * from './og-twitter-tags.js'

View File

@ -0,0 +1,64 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { HttpStatusCode, VideoPlaylistCreateResult } from '@peertube/peertube-models'
import { PeerTubeServer, cleanupTests, makeGetRequest } from '@peertube/peertube-server-commands'
import { getWatchPlaylistBasePaths, getWatchVideoBasePaths, prepareClientTests } from '@tests/shared/client.js'
describe('Test oEmbed HTML tags', function () {
let servers: PeerTubeServer[]
let videoIds: (string | number)[] = []
let playlistName: string
let playlist: VideoPlaylistCreateResult
let playlistIds: (string | number)[] = []
before(async function () {
this.timeout(120000);
({ servers, playlistIds, videoIds, playlist, playlistName } = await prepareClientTests())
})
it('Should have valid oEmbed discovery tags for videos', async function () {
for (const basePath of getWatchVideoBasePaths()) {
for (const id of videoIds) {
const res = await makeGetRequest({
url: servers[0].url,
path: basePath + id,
accept: 'text/html',
expectedStatus: HttpStatusCode.OK_200
})
const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` +
`url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2F${servers[0].store.video.shortUUID}" ` +
`title="${servers[0].store.video.name}" />`
expect(res.text).to.contain(expectedLink)
}
}
})
it('Should have valid oEmbed discovery tags for a playlist', async function () {
for (const basePath of getWatchPlaylistBasePaths()) {
for (const id of playlistIds) {
const res = await makeGetRequest({
url: servers[0].url,
path: basePath + id,
accept: 'text/html',
expectedStatus: HttpStatusCode.OK_200
})
const expectedLink = `<link rel="alternate" type="application/json+oembed" href="${servers[0].url}/services/oembed?` +
`url=http%3A%2F%2F${servers[0].hostname}%3A${servers[0].port}%2Fw%2Fp%2F${playlist.shortUUID}" ` +
`title="${playlistName}" />`
expect(res.text).to.contain(expectedLink)
}
}
})
after(async function () {
await cleanupTests(servers)
})
})

View File

@ -0,0 +1,271 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { Account, HttpStatusCode, VideoPlaylistCreateResult } from '@peertube/peertube-models'
import { cleanupTests, makeGetRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
import { getWatchPlaylistBasePaths, getWatchVideoBasePaths, prepareClientTests } from '@tests/shared/client.js'
describe('Test Open Graph and Twitter cards HTML tags', function () {
let servers: PeerTubeServer[]
let account: Account
let videoIds: (string | number)[] = []
let videoName: string
let videoDescriptionPlainText: string
let playlistName: string
let playlistDescription: string
let playlist: VideoPlaylistCreateResult
let channelDescription: string
let playlistIds: (string | number)[] = []
before(async function () {
this.timeout(120000);
({
servers,
account,
playlistIds,
videoIds,
videoName,
videoDescriptionPlainText,
playlistName,
playlist,
playlistDescription,
channelDescription
} = await prepareClientTests())
})
describe('Open Graph', function () {
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
expect(text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
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}" />`)
}
async function channelPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain(`<meta property="og:title" content="${servers[0].store.channel.displayName}" />`)
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}" />`)
}
async function watchVideoPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain(`<meta property="og:title" content="${videoName}" />`)
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}" />`)
}
async function watchPlaylistPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain(`<meta property="og:title" content="${playlistName}" />`)
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}" />`)
}
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)
await accountPageTest('/@' + servers[0].store.user.username)
})
it('Should have valid Open Graph tags on the channel page', async function () {
await channelPageTest('/video-channels/' + servers[0].store.channel.name)
await channelPageTest('/c/' + servers[0].store.channel.name)
await channelPageTest('/@' + servers[0].store.channel.name)
})
it('Should have valid Open Graph tags on the watch page', async function () {
for (const path of getWatchVideoBasePaths()) {
for (const id of videoIds) {
await watchVideoPageTest(path + id)
}
}
})
it('Should have valid Open Graph tags on the watch page with thread id Angular param', async function () {
for (const path of getWatchVideoBasePaths()) {
for (const id of videoIds) {
await watchVideoPageTest(path + id + ';threadId=1')
}
}
})
it('Should have valid Open Graph tags on the watch playlist page', async function () {
for (const path of getWatchPlaylistBasePaths()) {
for (const id of playlistIds) {
await watchPlaylistPageTest(path + id)
}
}
})
})
describe('Twitter card', async function () {
describe('Not whitelisted', function () {
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
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
expect(text).to.contain(`<meta property="twitter:title" content="${account.name}" />`)
expect(text).to.contain(`<meta property="twitter:description" content="${account.description}" />`)
}
async function channelPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
expect(text).to.contain(`<meta property="twitter:title" content="${servers[0].store.channel.displayName}" />`)
expect(text).to.contain(`<meta property="twitter:description" content="${channelDescription}" />`)
}
async function watchVideoPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain('<meta property="twitter:card" content="summary_large_image" />')
expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
expect(text).to.contain(`<meta property="twitter:title" content="${videoName}" />`)
expect(text).to.contain(`<meta property="twitter:description" content="${videoDescriptionPlainText}" />`)
}
async function watchPlaylistPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
expect(text).to.contain(`<meta property="twitter:title" content="${playlistName}" />`)
expect(text).to.contain(`<meta property="twitter:description" content="${playlistDescription}" />`)
}
it('Should have valid twitter card on the watch video page', async function () {
for (const path of getWatchVideoBasePaths()) {
for (const id of videoIds) {
await watchVideoPageTest(path + id)
}
}
})
it('Should have valid twitter card on the watch playlist page', async function () {
for (const path of getWatchPlaylistBasePaths()) {
for (const id of playlistIds) {
await watchPlaylistPageTest(path + id)
}
}
})
it('Should have valid twitter card on the account page', async function () {
await accountPageTest('/accounts/' + account.name)
await accountPageTest('/a/' + account.name)
await accountPageTest('/@' + account.name)
})
it('Should have valid twitter card on the channel page', async function () {
await channelPageTest('/video-channels/' + servers[0].store.channel.name)
await channelPageTest('/c/' + servers[0].store.channel.name)
await channelPageTest('/@' + servers[0].store.channel.name)
})
})
describe('Whitelisted', function () {
before(async function () {
const config = await servers[0].config.getCustomConfig()
config.services.twitter = {
username: '@Kuja',
whitelisted: true
}
await servers[0].config.updateCustomConfig({ newCustomConfig: config })
})
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
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
}
async function channelPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain('<meta property="twitter:card" content="summary" />')
expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
}
async function watchVideoPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain('<meta property="twitter:card" content="player" />')
expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
}
async function watchPlaylistPageTest (path: string) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
expect(text).to.contain('<meta property="twitter:card" content="player" />')
expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
}
it('Should have valid twitter card on the watch video page', async function () {
for (const path of getWatchVideoBasePaths()) {
for (const id of videoIds) {
await watchVideoPageTest(path + id)
}
}
})
it('Should have valid twitter card on the watch playlist page', async function () {
for (const path of getWatchPlaylistBasePaths()) {
for (const id of playlistIds) {
await watchPlaylistPageTest(path + id)
}
}
})
it('Should have valid twitter card on the account page', async function () {
await accountPageTest('/accounts/' + account.name)
await accountPageTest('/a/' + account.name)
await accountPageTest('/@' + account.name)
})
it('Should have valid twitter card on the channel page', async function () {
await channelPageTest('/video-channels/' + servers[0].store.channel.name)
await channelPageTest('/c/' + servers[0].store.channel.name)
await channelPageTest('/@' + servers[0].store.channel.name)
})
})
})
after(async function () {
await cleanupTests(servers)
})
})

View File

@ -212,11 +212,11 @@ describe('Test misc endpoints', function () {
expect(res.text).to.contain('<video:title>video 2</video:title>')
expect(res.text).to.not.contain('<video:title>video 3</video:title>')
expect(res.text).to.contain('<url><loc>' + server.url + '/video-channels/channel1</loc></url>')
expect(res.text).to.contain('<url><loc>' + server.url + '/video-channels/channel2</loc></url>')
expect(res.text).to.contain('<url><loc>' + server.url + '/c/channel1</loc></url>')
expect(res.text).to.contain('<url><loc>' + server.url + '/c/channel2</loc></url>')
expect(res.text).to.contain('<url><loc>' + server.url + '/accounts/user1</loc></url>')
expect(res.text).to.contain('<url><loc>' + server.url + '/accounts/user2</loc></url>')
expect(res.text).to.contain('<url><loc>' + server.url + '/a/user1</loc></url>')
expect(res.text).to.contain('<url><loc>' + server.url + '/a/user2</loc></url>')
})
it('Should not fail with big title/description videos', async function () {

View File

@ -38,7 +38,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
: undefined
it('Should upload a classic video mp4 and transcode it', async function () {
this.timeout(120000)
this.timeout(240000)
const { uuid } = await servers[0].videos.quickUpload({ name: 'mp4', fixture: 'video_short.mp4' })
@ -76,7 +76,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
})
it('Should upload a webm video and transcode it', async function () {
this.timeout(120000)
this.timeout(240000)
const { uuid } = await servers[0].videos.quickUpload({ name: 'mp4', fixture: 'video_short.webm' })
@ -114,7 +114,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
})
it('Should upload an audio only video and transcode it', async function () {
this.timeout(120000)
this.timeout(240000)
const attributes = { name: 'audio_without_preview', fixture: 'sample.ogg' }
const { uuid } = await servers[0].videos.upload({ attributes, mode: 'resumable' })
@ -152,7 +152,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
})
it('Should upload a private video and transcode it', async function () {
this.timeout(120000)
this.timeout(240000)
const { uuid } = await servers[0].videos.quickUpload({ name: 'mp4', fixture: 'video_short.mp4', privacy: VideoPrivacy.PRIVATE })
@ -188,7 +188,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
})
it('Should transcode videos on manual run', async function () {
this.timeout(120000)
this.timeout(240000)
await servers[0].config.disableTranscoding()

View File

@ -0,0 +1,181 @@
import { omit } from '@peertube/peertube-core-utils'
import {
VideoPrivacy,
VideoPlaylistPrivacy,
VideoPlaylistCreateResult,
Account,
HTMLServerConfig,
ServerConfig
} from '@peertube/peertube-models'
import {
createMultipleServers,
setAccessTokensToServers,
doubleFollow,
setDefaultVideoChannel,
waitJobs
} from '@peertube/peertube-server-commands'
import { expect } from 'chai'
export function getWatchVideoBasePaths () {
return [ '/videos/watch/', '/w/' ]
}
export function getWatchPlaylistBasePaths () {
return [ '/videos/watch/playlist/', '/w/p/' ]
}
export function checkIndexTags (html: string, title: string, description: string, css: string, config: ServerConfig) {
expect(html).to.contain('<title>' + title + '</title>')
expect(html).to.contain('<meta name="description" content="' + description + '" />')
if (css) {
expect(html).to.contain('<style class="custom-css-style">' + css + '</style>')
}
const htmlConfig: HTMLServerConfig = omit(config, [ 'signup' ])
const configObjectString = JSON.stringify(htmlConfig)
const configEscapedString = JSON.stringify(configObjectString)
expect(html).to.contain(`<script type="application/javascript">window.PeerTubeServerConfig = ${configEscapedString}</script>`)
}
export async function prepareClientTests () {
const servers = await createMultipleServers(2)
await setAccessTokensToServers(servers)
await doubleFollow(servers[0], servers[1])
await setDefaultVideoChannel(servers)
let account: Account
let videoIds: (string | number)[] = []
let privateVideoId: string
let internalVideoId: string
let unlistedVideoId: string
let passwordProtectedVideoId: string
let playlistIds: (string | number)[] = []
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'
const playlistName = 'super playlist name'
const playlistDescription = 'super playlist description'
let playlist: VideoPlaylistCreateResult
const channelDescription = 'my super channel description'
await servers[0].channels.update({
channelName: servers[0].store.channel.name,
attributes: { description: channelDescription }
})
// Public video
{
const attributes = { name: videoName, description: videoDescription }
await servers[0].videos.upload({ attributes })
const { data } = await servers[0].videos.list()
expect(data.length).to.equal(1)
const video = data[0]
servers[0].store.video = video
videoIds = [ video.id, video.uuid, video.shortUUID ]
}
{
({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }));
({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }));
({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }));
({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({
name: 'password protected',
privacy: VideoPrivacy.PASSWORD_PROTECTED,
videoPasswords: [ 'password' ]
}))
}
// Playlists
{
// Public playlist
{
const attributes = {
displayName: playlistName,
description: playlistDescription,
privacy: VideoPlaylistPrivacy.PUBLIC,
videoChannelId: servers[0].store.channel.id
}
playlist = await servers[0].playlists.create({ attributes })
playlistIds = [ playlist.id, playlist.shortUUID, playlist.uuid ]
await servers[0].playlists.addElement({ playlistId: playlist.shortUUID, attributes: { videoId: servers[0].store.video.id } })
}
// Unlisted playlist
{
const attributes = {
displayName: 'unlisted',
privacy: VideoPlaylistPrivacy.UNLISTED,
videoChannelId: servers[0].store.channel.id
}
const { uuid } = await servers[0].playlists.create({ attributes })
unlistedPlaylistId = uuid
}
{
const attributes = {
displayName: 'private',
privacy: VideoPlaylistPrivacy.PRIVATE
}
const { uuid } = await servers[0].playlists.create({ attributes })
privatePlaylistId = uuid
}
}
// Account
{
await servers[0].users.updateMe({ description: 'my account description' })
account = await servers[0].accounts.get({ accountName: `${servers[0].store.user.username}@${servers[0].host}` })
}
await waitJobs(servers)
return {
servers,
instanceDescription,
account,
channelDescription,
playlist,
playlistName,
playlistIds,
playlistDescription,
privatePlaylistId,
unlistedPlaylistId,
privateVideoId,
unlistedVideoId,
internalVideoId,
passwordProtectedVideoId,
videoName,
videoDescription,
videoDescriptionPlainText,
videoIds
}
}

View File

@ -58,11 +58,12 @@ elif [ "$1" = "client" ]; then
npm run build:tests
feedsFiles=$(findTestFiles ./packages/tests/dist/feeds)
miscFiles="./packages/tests/dist/client.js ./packages/tests/dist/misc-endpoints.js"
clientFiles=$(findTestFiles ./packages/tests/dist/client)
miscFiles="./packages/tests/dist/misc-endpoints.js"
# Not in their own task, they need an index.html
pluginFiles="./packages/tests/dist/plugins/html-injection.js ./packages/tests/dist/api/server/plugins.js"
MOCHA_PARALLEL=true runJSTest "$1" $((2*$speedFactor)) $feedsFiles $miscFiles $pluginFiles
MOCHA_PARALLEL=true runJSTest "$1" $((2*$speedFactor)) $feedsFiles $miscFiles $pluginFiles $clientFiles
# Use TS tests directly because we import server files
helperFiles=$(findTestFiles ./packages/tests/src/server-helpers)

View File

@ -7,7 +7,7 @@ import { About, CustomConfig, UserRight } from '@peertube/peertube-models'
import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger.js'
import { objectConverter } from '../../helpers/core-utils.js'
import { CONFIG, reloadConfig } from '../../initializers/config.js'
import { ClientHtml } from '../../lib/client-html.js'
import { ClientHtml } from '../../lib/html/client-html.js'
import { apiRateLimiter, asyncMiddleware, authenticate, ensureUserHasRight, openapiOperationDoc } from '../../middlewares/index.js'
import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config.js'
@ -94,7 +94,7 @@ async function deleteCustomConfig (req: express.Request, res: express.Response)
auditLogger.delete(getAuditIdFromRes(res), new CustomConfigAuditView(customConfig()))
await reloadConfig()
ClientHtml.invalidCache()
ClientHtml.invalidateCache()
const data = customConfig()
@ -110,7 +110,7 @@ async function updateCustomConfig (req: express.Request, res: express.Response)
await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 })
await reloadConfig()
ClientHtml.invalidCache()
ClientHtml.invalidateCache()
const data = customConfig()
@ -215,7 +215,8 @@ function customConfig (): CustomConfig {
}
},
videoQuota: CONFIG.USER.VIDEO_QUOTA,
videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY,
defaultChannelName: CONFIG.USER.DEFAULT_CHANNEL_NAME
},
videoChannels: {
maxPerUser: CONFIG.VIDEO_CHANNELS.MAX_PER_USER

View File

@ -1,4 +1,4 @@
import express from 'express'
import express, { UploadFiles } from 'express'
import { move } from 'fs-extra/esm'
import { basename } from 'path'
import { getResumableUploadPath } from '@server/helpers/upload.js'
@ -13,9 +13,9 @@ import { buildNextVideoState } from '@server/lib/video-state.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js'
import { VideoPasswordModel } from '@server/models/video/video-password.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { MVideoFile, MVideoFullLight, MVideoThumbnail } from '@server/types/models/index.js'
import { uuidToShort } from '@peertube/peertube-node-utils'
import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@peertube/peertube-models'
import { HttpStatusCode, ThumbnailType, VideoCreate, VideoPrivacy, VideoState } from '@peertube/peertube-models'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger.js'
import { createReqFiles } from '../../../helpers/express-utils.js'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
@ -34,8 +34,9 @@ import {
} from '../../../middlewares/index.js'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
import { VideoModel } from '../../../models/video/video.js'
import { getChaptersFromContainer } from '@peertube/peertube-ffmpeg'
import { ffprobePromise, getChaptersFromContainer } from '@peertube/peertube-ffmpeg'
import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
import { FfprobeData } from 'fluent-ffmpeg'
const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos')
@ -142,12 +143,15 @@ async function addVideo (options: {
video.VideoChannel = videoChannel
video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' })
const ffprobe = await ffprobePromise(videoPhysicalFile.path)
const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video', ffprobe })
const originalFilename = videoPhysicalFile.originalname
const containerChapters = await getChaptersFromContainer({
path: videoPhysicalFile.path,
maxTitleLength: CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE.max
maxTitleLength: CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE.max,
ffprobe
})
logger.debug(`Got ${containerChapters.length} chapters from video "${video.name}" container`, { containerChapters, ...lTags(video.uuid) })
@ -158,19 +162,16 @@ async function addVideo (options: {
videoPhysicalFile.filename = basename(destination)
videoPhysicalFile.path = destination
const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
video,
files,
fallback: type => generateLocalVideoMiniature({ video, videoFile, type })
})
const thumbnails = await createThumbnailFiles({ video, files, videoFile, ffprobe })
const { videoCreated } = await sequelizeTypescript.transaction(async t => {
const sequelizeOptions = { transaction: t }
const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
await videoCreated.addAndSaveThumbnail(previewModel, t)
for (const thumbnail of thumbnails) {
await videoCreated.addAndSaveThumbnail(thumbnail, t)
}
// Do not forget to add video channel information to the created video
videoCreated.VideoChannel = res.locals.videoChannel
@ -297,3 +298,27 @@ async function deleteUploadResumableCache (req: express.Request, res: express.Re
return next()
}
async function createThumbnailFiles (options: {
video: MVideoThumbnail
files: UploadFiles
videoFile: MVideoFile
ffprobe?: FfprobeData
}) {
const { video, videoFile, files, ffprobe } = options
const models = await buildVideoThumbnailsFromReq({
video,
files,
fallback: () => Promise.resolve(undefined)
})
const filteredModels = models.filter(m => !!m)
const thumbnailsToGenerate = [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ].filter(type => {
// Generate missing thumbnail types
return !filteredModels.some(m => m.type === type)
})
return [ ...filteredModels, ...await generateLocalVideoMiniature({ video, videoFile, types: thumbnailsToGenerate, ffprobe }) ]
}

View File

@ -9,7 +9,7 @@ import { CONFIG } from '@server/initializers/config.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { currentDir, root } from '@peertube/peertube-node-utils'
import { STATIC_MAX_AGE } from '../initializers/constants.js'
import { ClientHtml, sendHTML, serveIndexHTML } from '../lib/client-html.js'
import { ClientHtml, sendHTML, serveIndexHTML } from '../lib/html/client-html.js'
import { asyncMiddleware, buildRateLimiter, embedCSP } from '../middlewares/index.js'
const clientsRouter = express.Router()
@ -49,6 +49,8 @@ clientsRouter.use('/@:nameWithHost',
asyncMiddleware(generateActorHtmlPage)
)
// ---------------------------------------------------------------------------
const embedMiddlewares = [
clientsRateLimiter,
@ -64,19 +66,21 @@ const embedMiddlewares = [
res.setHeader('Cache-Control', 'public, max-age=0')
next()
},
asyncMiddleware(generateEmbedHtmlPage)
}
]
clientsRouter.use('/videos/embed', ...embedMiddlewares)
clientsRouter.use('/video-playlists/embed', ...embedMiddlewares)
clientsRouter.use('/videos/embed/:id', ...embedMiddlewares, asyncMiddleware(generateVideoEmbedHtmlPage))
clientsRouter.use('/video-playlists/embed/:id', ...embedMiddlewares, asyncMiddleware(generateVideoPlaylistEmbedHtmlPage))
// ---------------------------------------------------------------------------
const testEmbedController = (req: express.Request, res: express.Response) => res.sendFile(testEmbedPath)
clientsRouter.use('/videos/test-embed', clientsRateLimiter, testEmbedController)
clientsRouter.use('/video-playlists/test-embed', clientsRateLimiter, testEmbedController)
// ---------------------------------------------------------------------------
// Dynamic PWA manifest
clientsRouter.get('/manifest.webmanifest', clientsRateLimiter, asyncMiddleware(generateManifest))
@ -142,17 +146,13 @@ function serveServerTranslations (req: express.Request, res: express.Response) {
return res.status(HttpStatusCode.NOT_FOUND_404).end()
}
async function generateEmbedHtmlPage (req: express.Request, res: express.Response) {
const hookName = req.originalUrl.startsWith('/video-playlists/')
? 'filter:html.embed.video-playlist.allowed.result'
: 'filter:html.embed.video.allowed.result'
async function generateVideoEmbedHtmlPage (req: express.Request, res: express.Response) {
const allowParameters = { req }
const allowedResult = await Hooks.wrapFun(
isEmbedAllowed,
allowParameters,
hookName
'filter:html.embed.video.allowed.result'
)
if (!allowedResult || allowedResult.allowed !== true) {
@ -161,7 +161,27 @@ async function generateEmbedHtmlPage (req: express.Request, res: express.Respons
return sendHTML(allowedResult?.html || '', res)
}
const html = await ClientHtml.getEmbedHTML()
const html = await ClientHtml.getVideoEmbedHTML(req.params.id)
return sendHTML(html, res)
}
async function generateVideoPlaylistEmbedHtmlPage (req: express.Request, res: express.Response) {
const allowParameters = { req }
const allowedResult = await Hooks.wrapFun(
isEmbedAllowed,
allowParameters,
'filter:html.embed.video-playlist.allowed.result'
)
if (!allowedResult || allowedResult.allowed !== true) {
logger.info('Embed is not allowed.', { allowedResult })
return sendHTML(allowedResult?.html || '', res)
}
const html = await ClientHtml.getVideoPlaylistEmbedHTML(req.params.id)
return sendHTML(html, res)
}

View File

@ -2,7 +2,7 @@ import cors from 'cors'
import express from 'express'
import { HttpNodeinfoDiasporaSoftwareNsSchema20, HttpStatusCode } from '@peertube/peertube-models'
import { CONFIG, isEmailEnabled } from '@server/initializers/config.js'
import { serveIndexHTML } from '@server/lib/client-html.js'
import { serveIndexHTML } from '@server/lib/html/client-html.js'
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION, ROUTE_CACHE_LIFETIME } from '../initializers/constants.js'
import { getThemeOrDefault } from '../lib/plugins/theme-utils.js'

View File

@ -61,17 +61,13 @@ async function getSitemap (req: express.Request, res: express.Response) {
async function getSitemapVideoChannelUrls () {
const rows = await VideoChannelModel.listLocalsForSitemap('createdAt')
return rows.map(channel => ({
url: WEBSERVER.URL + '/video-channels/' + channel.Actor.preferredUsername
}))
return rows.map(channel => ({ url: channel.getClientUrl() }))
}
async function getSitemapAccountUrls () {
const rows = await AccountModel.listLocalsForSitemap('createdAt')
return rows.map(channel => ({
url: WEBSERVER.URL + '/accounts/' + channel.Actor.preferredUsername
}))
return rows.map(account => ({ url: account.getClientUrl() }))
}
async function getSitemapLocalVideoUrls () {

View File

@ -1,20 +1,17 @@
import { copy, remove } from 'fs-extra/esm'
import { readFile, rename } from 'fs/promises'
import { join } from 'path'
import { ColorActionName } from '@jimp/plugin-color'
import { buildUUID, getLowercaseExtension } from '@peertube/peertube-node-utils'
import { convertWebPToJPG, generateThumbnailFromVideo, processGIF } from './ffmpeg/index.js'
import { logger, loggerTagsFactory } from './logger.js'
import { convertWebPToJPG, processGIF } from './ffmpeg/index.js'
import { logger } from './logger.js'
import type Jimp from 'jimp'
const lTags = loggerTagsFactory('image-utils')
function generateImageFilename (extension = '.jpg') {
export function generateImageFilename (extension = '.jpg') {
return buildUUID() + extension
}
async function processImage (options: {
export async function processImage (options: {
path: string
destination: string
newSize: { width: number, height: number }
@ -38,38 +35,11 @@ async function processImage (options: {
}
if (keepOriginal !== true) await remove(path)
logger.debug('Finished processing image %s to %s.', path, destination)
}
async function generateImageFromVideoFile (options: {
fromPath: string
folder: string
imageName: string
size: { width: number, height: number }
}) {
const { fromPath, folder, imageName, size } = options
const pendingImageName = 'pending-' + imageName
const pendingImagePath = join(folder, pendingImageName)
try {
await generateThumbnailFromVideo({ fromPath, output: pendingImagePath })
const destination = join(folder, imageName)
await processImage({ path: pendingImagePath, destination, newSize: size })
} catch (err) {
logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })
try {
await remove(pendingImagePath)
} catch (err) {
logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
}
throw err
}
}
async function getImageSize (path: string) {
export async function getImageSize (path: string) {
const inputBuffer = await readFile(path)
const Jimp = await import('jimp')
@ -83,16 +53,7 @@ async function getImageSize (path: string) {
}
// ---------------------------------------------------------------------------
export {
generateImageFilename,
generateImageFromVideoFile,
processImage,
getImageSize
}
// Private
// ---------------------------------------------------------------------------
async function jimpProcessor (path: string, destination: string, newSize: { width: number, height: number }, inputExt: string) {

View File

@ -369,7 +369,8 @@ const CONFIG = {
}
},
get VIDEO_QUOTA () { return parseBytes(config.get<number>('user.video_quota')) },
get VIDEO_QUOTA_DAILY () { return parseBytes(config.get<number>('user.video_quota_daily')) }
get VIDEO_QUOTA_DAILY () { return parseBytes(config.get<number>('user.video_quota_daily')) },
get DEFAULT_CHANNEL_NAME () { return config.get<string>('user.default_channel_name') }
},
VIDEO_CHANNELS: {
get MAX_PER_USER () { return config.get<number>('video_channels.max_per_user') }

View File

@ -955,7 +955,8 @@ const MEMOIZE_TTL = {
VIDEO_DURATION: 1000 * 10, // 10 seconds
LIVE_ABLE_TO_UPLOAD: 1000 * 60, // 1 minute
LIVE_CHECK_SOCKET_HEALTH: 1000 * 60, // 1 minute
GET_STATS_FOR_OPEN_TELEMETRY_METRICS: 1000 * 60 // 1 minute
GET_STATS_FOR_OPEN_TELEMETRY_METRICS: 1000 * 60, // 1 minute
EMBED_HTML: 1000 * 10 // 10 seconds
}
const MEMOIZE_LENGTH = {
@ -971,6 +972,10 @@ const WORKER_THREADS = {
PROCESS_IMAGE: {
CONCURRENCY: 1,
MAX_THREADS: 5
},
GET_IMAGE_SIZE: {
CONCURRENCY: 1,
MAX_THREADS: 5
}
}
@ -1078,6 +1083,7 @@ if (process.env.PRODUCTION_CONSTANTS !== 'true') {
FILES_CACHE.VIDEO_CAPTIONS.MAX_AGE = 3000
MEMOIZE_TTL.OVERVIEWS_SAMPLE = 3000
MEMOIZE_TTL.LIVE_ABLE_TO_UPLOAD = 3000
MEMOIZE_TTL.EMBED_HTML = 1
OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD = 2
PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 5000

View File

@ -1,619 +0,0 @@
import { buildFileLocale, escapeHTML, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '@peertube/peertube-core-utils'
import { HTMLServerConfig, HttpStatusCode, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models'
import { isTestOrDevInstance, root, sha256 } from '@peertube/peertube-node-utils'
import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js'
import { mdToOneLinePlainText } from '@server/helpers/markdown.js'
import { ActorImageModel } from '@server/models/actor/actor-image.js'
import express from 'express'
import { pathExists } from 'fs-extra/esm'
import { readFile } from 'fs/promises'
import truncate from 'lodash-es/truncate.js'
import { join } from 'path'
import validator from 'validator'
import { logger } from '../helpers/logger.js'
import { CONFIG } from '../initializers/config.js'
import {
ACCEPT_HEADERS,
CUSTOM_HTML_TAG_COMMENTS,
EMBED_SIZE,
FILES_CONTENT_HASH,
PLUGIN_GLOBAL_CSS_PATH,
WEBSERVER
} from '../initializers/constants.js'
import { AccountModel } from '../models/account/account.js'
import { VideoChannelModel } from '../models/video/video-channel.js'
import { VideoPlaylistModel } from '../models/video/video-playlist.js'
import { VideoModel } from '../models/video/video.js'
import { MAccountHost, MChannelHost, MVideo, MVideoPlaylist } from '../types/models/index.js'
import { getActivityStreamDuration } from './activitypub/activity.js'
import { getBiggestActorImage } from './actor-image.js'
import { Hooks } from './plugins/hooks.js'
import { ServerConfigManager } from './server-config-manager.js'
import { isVideoInPrivateDirectory } from './video-privacy.js'
type Tags = {
ogType: string
twitterCard: 'player' | 'summary' | 'summary_large_image'
schemaType: string
list?: {
numberOfItems: number
}
escapedSiteName: string
escapedTitle: string
escapedTruncatedDescription: string
url: string
originUrl: string
disallowIndexation?: boolean
embed?: {
url: string
createdAt: string
duration?: string
views?: number
}
image: {
url: string
width?: number
height?: number
}
}
type HookContext = {
video?: MVideo
playlist?: MVideoPlaylist
}
class ClientHtml {
private static htmlCache: { [path: string]: string } = {}
static invalidCache () {
logger.info('Cleaning HTML cache.')
ClientHtml.htmlCache = {}
}
static async getDefaultHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
const html = paramLang
? await ClientHtml.getIndexHTML(req, res, paramLang)
: await ClientHtml.getIndexHTML(req, res)
let customHtml = ClientHtml.addTitleTag(html)
customHtml = ClientHtml.addDescriptionTag(customHtml)
return customHtml
}
static async getWatchHTMLPage (videoIdArg: string, req: express.Request, res: express.Response) {
const videoId = toCompleteUUID(videoIdArg)
// Let Angular application handle errors
if (!validator.default.isInt(videoId) && !validator.default.isUUID(videoId, 4)) {
res.status(HttpStatusCode.NOT_FOUND_404)
return ClientHtml.getIndexHTML(req, res)
}
const [ html, video ] = await Promise.all([
ClientHtml.getIndexHTML(req, res),
VideoModel.loadWithBlacklist(videoId)
])
// Let Angular application handle errors
if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
res.status(HttpStatusCode.NOT_FOUND_404)
return html
}
const escapedTruncatedDescription = buildEscapedTruncatedDescription(video.description)
let customHtml = ClientHtml.addTitleTag(html, video.name)
customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
const url = WEBSERVER.URL + video.getWatchStaticPath()
const originUrl = video.url
const title = video.name
const siteName = CONFIG.INSTANCE.NAME
const image = {
url: WEBSERVER.URL + video.getPreviewStaticPath()
}
const embed = {
url: WEBSERVER.URL + video.getEmbedStaticPath(),
createdAt: video.createdAt.toISOString(),
duration: getActivityStreamDuration(video.duration),
views: video.views
}
const ogType = 'video'
const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary_large_image'
const schemaType = 'VideoObject'
customHtml = await ClientHtml.addTags(customHtml, {
url,
originUrl,
escapedSiteName: escapeHTML(siteName),
escapedTitle: escapeHTML(title),
escapedTruncatedDescription,
disallowIndexation: video.privacy !== VideoPrivacy.PUBLIC,
image,
embed,
ogType,
twitterCard,
schemaType
}, { video })
return customHtml
}
static async getWatchPlaylistHTMLPage (videoPlaylistIdArg: string, req: express.Request, res: express.Response) {
const videoPlaylistId = toCompleteUUID(videoPlaylistIdArg)
// Let Angular application handle errors
if (!validator.default.isInt(videoPlaylistId) && !validator.default.isUUID(videoPlaylistId, 4)) {
res.status(HttpStatusCode.NOT_FOUND_404)
return ClientHtml.getIndexHTML(req, res)
}
const [ html, videoPlaylist ] = await Promise.all([
ClientHtml.getIndexHTML(req, res),
VideoPlaylistModel.loadWithAccountAndChannel(videoPlaylistId, null)
])
// Let Angular application handle errors
if (!videoPlaylist || videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
res.status(HttpStatusCode.NOT_FOUND_404)
return html
}
const escapedTruncatedDescription = buildEscapedTruncatedDescription(videoPlaylist.description)
let customHtml = ClientHtml.addTitleTag(html, videoPlaylist.name)
customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
const url = WEBSERVER.URL + videoPlaylist.getWatchStaticPath()
const originUrl = videoPlaylist.url
const title = videoPlaylist.name
const siteName = CONFIG.INSTANCE.NAME
const image = {
url: videoPlaylist.getThumbnailUrl()
}
const embed = {
url: WEBSERVER.URL + videoPlaylist.getEmbedStaticPath(),
createdAt: videoPlaylist.createdAt.toISOString()
}
const list = {
numberOfItems: videoPlaylist.get('videosLength') as number
}
const ogType = 'video'
const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary'
const schemaType = 'ItemList'
customHtml = await ClientHtml.addTags(customHtml, {
url,
originUrl,
escapedSiteName: escapeHTML(siteName),
escapedTitle: escapeHTML(title),
escapedTruncatedDescription,
disallowIndexation: videoPlaylist.privacy !== VideoPlaylistPrivacy.PUBLIC,
embed,
image,
list,
ogType,
twitterCard,
schemaType
}, { playlist: videoPlaylist })
return customHtml
}
static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost)
return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res)
}
static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
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()
// Disable HTML cache in dev mode because webpack can regenerate JS files
if (!isTestOrDevInstance() && 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
return html
}
private static async getAccountOrChannelHTMLPage (
loader: () => Promise<MAccountHost | MChannelHost>,
req: express.Request,
res: express.Response
) {
const [ html, entity ] = await Promise.all([
ClientHtml.getIndexHTML(req, res),
loader()
])
// Let Angular application handle errors
if (!entity) {
res.status(HttpStatusCode.NOT_FOUND_404)
return ClientHtml.getIndexHTML(req, res)
}
const escapedTruncatedDescription = buildEscapedTruncatedDescription(entity.description)
let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName())
customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
const url = entity.getClientUrl()
const originUrl = entity.Actor.url
const siteName = CONFIG.INSTANCE.NAME
const title = entity.getDisplayName()
const avatar = getBiggestActorImage(entity.Actor.Avatars)
const image = {
url: ActorImageModel.getImageUrl(avatar),
width: avatar?.width,
height: avatar?.height
}
const ogType = 'website'
const twitterCard = 'summary'
const schemaType = 'ProfilePage'
customHtml = await ClientHtml.addTags(customHtml, {
url,
originUrl,
escapedTitle: escapeHTML(title),
escapedSiteName: escapeHTML(siteName),
escapedTruncatedDescription,
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]
const buffer = await readFile(path)
const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
let html = buffer.toString()
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
return html
}
private static getIndexPath (req: express.Request, res: express.Response, paramLang: string) {
let lang: string
// Check param lang validity
if (paramLang && is18nLocale(paramLang)) {
lang = paramLang
// Save locale in cookies
res.cookie('clientLanguage', lang, {
secure: WEBSERVER.SCHEME === 'https',
sameSite: 'none',
maxAge: 1000 * 3600 * 24 * 90 // 3 months
})
} else if (req.cookies.clientLanguage && is18nLocale(req.cookies.clientLanguage)) {
lang = req.cookies.clientLanguage
} else {
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(root(), 'client', 'dist', buildFileLocale(lang), 'index.html')
}
private static getEmbedPath () {
return join(root(), 'client', 'dist', 'standalone', 'videos', 'embed.html')
}
private static addManifestContentHash (htmlStringPage: string) {
return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST)
}
private static addFaviconContentHash (htmlStringPage: string) {
return htmlStringPage.replace('[faviconContentHash]', FILES_CONTENT_HASH.FAVICON)
}
private static addLogoContentHash (htmlStringPage: string) {
return htmlStringPage.replace('[logoContentHash]', FILES_CONTENT_HASH.LOGO)
}
private static addTitleTag (htmlStringPage: string, title?: string) {
let text = title || CONFIG.INSTANCE.NAME
if (title) text += ` - ${CONFIG.INSTANCE.NAME}`
const titleTag = `<title>${escapeHTML(text)}</title>`
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.TITLE, titleTag)
}
private static addDescriptionTag (htmlStringPage: string, escapedTruncatedDescription?: string) {
const content = escapedTruncatedDescription || escapeHTML(CONFIG.INSTANCE.SHORT_DESCRIPTION)
const descriptionTag = `<meta name="description" content="${content}" />`
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag)
}
private static addCustomCSS (htmlStringPage: string) {
const styleTag = `<style class="custom-css-style">${CONFIG.INSTANCE.CUSTOMIZATIONS.CSS}</style>`
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 = `<script type="application/javascript">window.PeerTubeServerConfig = ${serverConfigString}</script>`
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.SERVER_CONFIG, configScriptTag)
}
private static async addAsyncPluginCSS (htmlStringPage: string) {
if (!await pathExists(PLUGIN_GLOBAL_CSS_PATH)) {
logger.info('Plugin Global CSS file is not available (generation may still be in progress), ignoring it.')
return htmlStringPage
}
let globalCSSContent: Buffer
try {
globalCSSContent = await readFile(PLUGIN_GLOBAL_CSS_PATH)
} catch (err) {
logger.error('Error retrieving the Plugin Global CSS file, ignoring it.', { err })
return htmlStringPage
}
if (globalCSSContent.byteLength === 0) return htmlStringPage
const fileHash = sha256(globalCSSContent)
const linkTag = `<link rel="stylesheet" href="/plugins/global.css?hash=${fileHash}" />`
return htmlStringPage.replace('</head>', linkTag + '</head>')
}
private static generateOpenGraphMetaTags (tags: Tags) {
const metaTags = {
'og:type': tags.ogType,
'og:site_name': tags.escapedSiteName,
'og:title': tags.escapedTitle,
'og:image': tags.image.url
}
if (tags.image.width && tags.image.height) {
metaTags['og:image:width'] = tags.image.width
metaTags['og:image:height'] = tags.image.height
}
metaTags['og:url'] = tags.url
metaTags['og:description'] = tags.escapedTruncatedDescription
if (tags.embed) {
metaTags['og:video:url'] = tags.embed.url
metaTags['og:video:secure_url'] = tags.embed.url
metaTags['og:video:type'] = 'text/html'
metaTags['og:video:width'] = EMBED_SIZE.width
metaTags['og:video:height'] = EMBED_SIZE.height
}
return metaTags
}
private static generateStandardMetaTags (tags: Tags) {
return {
name: tags.escapedTitle,
description: tags.escapedTruncatedDescription,
image: tags.image.url
}
}
private static generateTwitterCardMetaTags (tags: Tags) {
const metaTags = {
'twitter:card': tags.twitterCard,
'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME,
'twitter:title': tags.escapedTitle,
'twitter:description': tags.escapedTruncatedDescription,
'twitter:image': tags.image.url
}
if (tags.image.width && tags.image.height) {
metaTags['twitter:image:width'] = tags.image.width
metaTags['twitter:image:height'] = tags.image.height
}
if (tags.twitterCard === 'player') {
metaTags['twitter:player'] = tags.embed.url
metaTags['twitter:player:width'] = EMBED_SIZE.width
metaTags['twitter:player:height'] = EMBED_SIZE.height
}
return metaTags
}
private static async generateSchemaTags (tags: Tags, context: HookContext) {
const schema = {
'@context': 'http://schema.org',
'@type': tags.schemaType,
'name': tags.escapedTitle,
'description': tags.escapedTruncatedDescription,
'image': tags.image.url,
'url': tags.url
}
if (tags.list) {
schema['numberOfItems'] = tags.list.numberOfItems
schema['thumbnailUrl'] = tags.image.url
}
if (tags.embed) {
schema['embedUrl'] = tags.embed.url
schema['uploadDate'] = tags.embed.createdAt
if (tags.embed.duration) schema['duration'] = tags.embed.duration
schema['thumbnailUrl'] = tags.image.url
schema['contentUrl'] = tags.url
}
return Hooks.wrapObject(schema, 'filter:html.client.json-ld.result', context)
}
private static async addTags (htmlStringPage: string, tagsValues: Tags, context: HookContext) {
const openGraphMetaTags = this.generateOpenGraphMetaTags(tagsValues)
const standardMetaTags = this.generateStandardMetaTags(tagsValues)
const twitterCardMetaTags = this.generateTwitterCardMetaTags(tagsValues)
const schemaTags = await this.generateSchemaTags(tagsValues, context)
const { url, escapedTitle, embed, originUrl, disallowIndexation } = tagsValues
const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = []
if (embed) {
oembedLinkTags.push({
type: 'application/json+oembed',
href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(url),
escapedTitle
})
}
let tagsStr = ''
// Opengraph
Object.keys(openGraphMetaTags).forEach(tagName => {
const tagValue = openGraphMetaTags[tagName]
tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
})
// Standard
Object.keys(standardMetaTags).forEach(tagName => {
const tagValue = standardMetaTags[tagName]
tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
})
// Twitter card
Object.keys(twitterCardMetaTags).forEach(tagName => {
const tagValue = twitterCardMetaTags[tagName]
tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
})
// OEmbed
for (const oembedLinkTag of oembedLinkTags) {
tagsStr += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.escapedTitle}" />`
}
// Schema.org
if (schemaTags) {
tagsStr += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
}
// SEO, use origin URL
tagsStr += `<link rel="canonical" href="${originUrl}" />`
if (disallowIndexation) {
tagsStr += `<meta name="robots" content="noindex" />`
}
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsStr)
}
}
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)
}
function buildEscapedTruncatedDescription (description: string) {
return truncate(mdToOneLinePlainText(description), { length: 200 })
}

View File

@ -0,0 +1,95 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import express from 'express'
import { logger } from '../../helpers/logger.js'
import { ACCEPT_HEADERS } from '../../initializers/constants.js'
import { VideoHtml } from './shared/video-html.js'
import { PlaylistHtml } from './shared/playlist-html.js'
import { ActorHtml } from './shared/actor-html.js'
import { PageHtml } from './shared/page-html.js'
class ClientHtml {
static invalidateCache () {
PageHtml.invalidateCache()
}
static getDefaultHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
return PageHtml.getDefaultHTML(req, res, paramLang)
}
// ---------------------------------------------------------------------------
static getWatchHTMLPage (videoIdArg: string, req: express.Request, res: express.Response) {
return VideoHtml.getWatchVideoHTML(videoIdArg, req, res)
}
static getVideoEmbedHTML (videoIdArg: string) {
return VideoHtml.getEmbedVideoHTML(videoIdArg)
}
// ---------------------------------------------------------------------------
static getWatchPlaylistHTMLPage (videoPlaylistIdArg: string, req: express.Request, res: express.Response) {
return PlaylistHtml.getWatchPlaylistHTML(videoPlaylistIdArg, req, res)
}
static getVideoPlaylistEmbedHTML (playlistIdArg: string) {
return PlaylistHtml.getEmbedPlaylistHTML(playlistIdArg)
}
// ---------------------------------------------------------------------------
static getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
return ActorHtml.getAccountHTMLPage(nameWithHost, req, res)
}
static getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
return ActorHtml.getVideoChannelHTMLPage(nameWithHost, req, res)
}
static getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
return ActorHtml.getActorHTMLPage(nameWithHost, req, res)
}
}
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
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function generateHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
const html = await ClientHtml.getDefaultHTMLPage(req, res, paramLang)
return sendHTML(html, res, true)
}

View File

@ -0,0 +1,91 @@
import { escapeHTML } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
import express from 'express'
import { CONFIG } from '../../../initializers/config.js'
import { AccountModel } from '@server/models/account/account.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { MAccountHost, MChannelHost } from '@server/types/models/index.js'
import { getBiggestActorImage } from '@server/lib/actor-image.js'
import { ActorImageModel } from '@server/models/actor/actor-image.js'
import { TagsHtml } from './tags-html.js'
import { PageHtml } from './page-html.js'
export class ActorHtml {
static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost)
return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res)
}
static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
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)
}
// ---------------------------------------------------------------------------
private static async getAccountOrChannelHTMLPage (
loader: () => Promise<MAccountHost | MChannelHost>,
req: express.Request,
res: express.Response
) {
const [ html, entity ] = await Promise.all([
PageHtml.getIndexHTML(req, res),
loader()
])
// Let Angular application handle errors
if (!entity) {
res.status(HttpStatusCode.NOT_FOUND_404)
return PageHtml.getIndexHTML(req, res)
}
const escapedTruncatedDescription = TagsHtml.buildEscapedTruncatedDescription(entity.description)
let customHTML = TagsHtml.addTitleTag(html, entity.getDisplayName())
customHTML = TagsHtml.addDescriptionTag(customHTML, escapedTruncatedDescription)
const url = entity.getClientUrl()
const siteName = CONFIG.INSTANCE.NAME
const title = entity.getDisplayName()
const avatar = getBiggestActorImage(entity.Actor.Avatars)
const image = {
url: ActorImageModel.getImageUrl(avatar),
width: avatar?.width,
height: avatar?.height
}
const ogType = 'website'
const twitterCard = 'summary'
const schemaType = 'ProfilePage'
customHTML = await TagsHtml.addTags(customHTML, {
url,
escapedTitle: escapeHTML(title),
escapedSiteName: escapeHTML(siteName),
escapedTruncatedDescription,
image,
ogType,
twitterCard,
schemaType,
indexationPolicy: entity.Actor.isOwned()
? 'always'
: 'never'
}, {})
return customHTML
}
}

View File

@ -0,0 +1,19 @@
import { MVideo } from '@server/types/models/video/video.js'
import { TagsHtml } from './tags-html.js'
import { MVideoPlaylist } from '@server/types/models/video/video-playlist.js'
export class CommonEmbedHtml {
static buildEmptyEmbedHTML (options: {
html: string
playlist?: MVideoPlaylist
video?: MVideo
}) {
const { html, playlist, video } = options
let htmlResult = TagsHtml.addTitleTag(html)
htmlResult = TagsHtml.addDescriptionTag(htmlResult)
return TagsHtml.addTags(htmlResult, { indexationPolicy: 'never' }, { playlist, video })
}
}

View File

@ -0,0 +1,5 @@
export * from './actor-html.js'
export * from './tags-html.js'
export * from './page-html.js'
export * from './playlist-html.js'
export * from './video-html.js'

View File

@ -0,0 +1,166 @@
import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '@peertube/peertube-core-utils'
import { isTestOrDevInstance, root, sha256 } from '@peertube/peertube-node-utils'
import express from 'express'
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, 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 {
private static htmlCache: { [path: string]: string } = {}
static invalidateCache () {
logger.info('Cleaning HTML cache.')
this.htmlCache = {}
}
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)
let customHTML = TagsHtml.addTitleTag(html)
customHTML = TagsHtml.addDescriptionTag(customHTML)
return customHTML
}
static async getEmbedHTML () {
const path = this.getEmbedHTMLPath()
// Disable HTML cache in dev mode because webpack can regenerate JS files
if (!isTestOrDevInstance() && this.htmlCache[path]) {
return this.htmlCache[path]
}
const buffer = await readFile(path)
const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
let html = buffer.toString()
html = await this.addAsyncPluginCSS(html)
html = this.addCustomCSS(html)
html = this.addServerConfig(html, serverConfig)
this.htmlCache[path] = html
return html
}
// ---------------------------------------------------------------------------
static async getIndexHTML (req: express.Request, res: express.Response, paramLang?: string) {
const path = this.getIndexHTMLPath(req, res, paramLang)
if (this.htmlCache[path]) return this.htmlCache[path]
const buffer = await readFile(path)
const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
let html = buffer.toString()
html = this.addManifestContentHash(html)
html = this.addFaviconContentHash(html)
html = this.addLogoContentHash(html)
html = this.addCustomCSS(html)
html = this.addServerConfig(html, serverConfig)
html = await this.addAsyncPluginCSS(html)
this.htmlCache[path] = html
return html
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
private static getEmbedHTMLPath () {
return join(root(), 'client', 'dist', 'standalone', 'videos', 'embed.html')
}
private static getIndexHTMLPath (req: express.Request, res: express.Response, paramLang: string) {
let lang: string
// Check param lang validity
if (paramLang && is18nLocale(paramLang)) {
lang = paramLang
// Save locale in cookies
res.cookie('clientLanguage', lang, {
secure: WEBSERVER.SCHEME === 'https',
sameSite: 'none',
maxAge: 1000 * 3600 * 24 * 90 // 3 months
})
} else if (req.cookies.clientLanguage && is18nLocale(req.cookies.clientLanguage)) {
lang = req.cookies.clientLanguage
} else {
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(root(), 'client', 'dist', buildFileLocale(lang), 'index.html')
}
// ---------------------------------------------------------------------------
static addCustomCSS (htmlStringPage: string) {
const styleTag = `<style class="custom-css-style">${CONFIG.INSTANCE.CUSTOMIZATIONS.CSS}</style>`
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag)
}
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 = `<script type="application/javascript">window.PeerTubeServerConfig = ${serverConfigString}</script>`
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.SERVER_CONFIG, configScriptTag)
}
static async addAsyncPluginCSS (htmlStringPage: string) {
if (!await pathExists(PLUGIN_GLOBAL_CSS_PATH)) {
logger.info('Plugin Global CSS file is not available (generation may still be in progress), ignoring it.')
return htmlStringPage
}
let globalCSSContent: Buffer
try {
globalCSSContent = await readFile(PLUGIN_GLOBAL_CSS_PATH)
} catch (err) {
logger.error('Error retrieving the Plugin Global CSS file, ignoring it.', { err })
return htmlStringPage
}
if (globalCSSContent.byteLength === 0) return htmlStringPage
const fileHash = sha256(globalCSSContent)
const linkTag = `<link rel="stylesheet" href="/plugins/global.css?hash=${fileHash}" />`
return htmlStringPage.replace('</head>', linkTag + '</head>')
}
private static addManifestContentHash (htmlStringPage: string) {
return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST)
}
private static addFaviconContentHash (htmlStringPage: string) {
return htmlStringPage.replace('[faviconContentHash]', FILES_CONTENT_HASH.FAVICON)
}
private static addLogoContentHash (htmlStringPage: string) {
return htmlStringPage.replace('[logoContentHash]', FILES_CONTENT_HASH.LOGO)
}
}

View File

@ -0,0 +1,126 @@
import { escapeHTML } from '@peertube/peertube-core-utils'
import { HttpStatusCode, VideoPlaylistPrivacy } from '@peertube/peertube-models'
import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js'
import express from 'express'
import validator from 'validator'
import { CONFIG } from '../../../initializers/config.js'
import { MEMOIZE_TTL, WEBSERVER } from '../../../initializers/constants.js'
import { Memoize } from '@server/helpers/memoize.js'
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { MVideoPlaylistFull } from '@server/types/models/index.js'
import { TagsHtml } from './tags-html.js'
import { PageHtml } from './page-html.js'
import { CommonEmbedHtml } from './common-embed-html.js'
export class PlaylistHtml {
static async getWatchPlaylistHTML (videoPlaylistIdArg: string, req: express.Request, res: express.Response) {
const videoPlaylistId = toCompleteUUID(videoPlaylistIdArg)
// Let Angular application handle errors
if (!validator.default.isInt(videoPlaylistId) && !validator.default.isUUID(videoPlaylistId, 4)) {
res.status(HttpStatusCode.NOT_FOUND_404)
return PageHtml.getIndexHTML(req, res)
}
const [ html, videoPlaylist ] = await Promise.all([
PageHtml.getIndexHTML(req, res),
VideoPlaylistModel.loadWithAccountAndChannel(videoPlaylistId, null)
])
// Let Angular application handle errors
if (!videoPlaylist || videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
res.status(HttpStatusCode.NOT_FOUND_404)
return html
}
return this.buildPlaylistHTML({
html,
playlist: videoPlaylist,
addEmbedInfo: true,
addOG: true,
addTwitterCard: true
})
}
@Memoize({ maxAge: MEMOIZE_TTL.EMBED_HTML })
static async getEmbedPlaylistHTML (playlistIdArg: string) {
const playlistId = toCompleteUUID(playlistIdArg)
const playlistPromise: Promise<MVideoPlaylistFull> = validator.default.isInt(playlistId) || validator.default.isUUID(playlistId, 4)
? VideoPlaylistModel.loadWithAccountAndChannel(playlistId, null)
: Promise.resolve(undefined)
const [ html, playlist ] = await Promise.all([ PageHtml.getEmbedHTML(), playlistPromise ])
if (!playlist || playlist.privacy === VideoPlaylistPrivacy.PRIVATE) {
return CommonEmbedHtml.buildEmptyEmbedHTML({ html, playlist })
}
return this.buildPlaylistHTML({
html,
playlist,
addEmbedInfo: true,
addOG: false,
addTwitterCard: false
})
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
private static buildPlaylistHTML (options: {
html: string
playlist: MVideoPlaylistFull
addOG: boolean
addTwitterCard: boolean
addEmbedInfo: boolean
}) {
const { html, playlist, addEmbedInfo, addOG, addTwitterCard } = options
const escapedTruncatedDescription = TagsHtml.buildEscapedTruncatedDescription(playlist.description)
let htmlResult = TagsHtml.addTitleTag(html, playlist.name)
htmlResult = TagsHtml.addDescriptionTag(htmlResult, escapedTruncatedDescription)
const list = { numberOfItems: playlist.get('videosLength') as number }
const schemaType = 'ItemList'
let twitterCard: 'player' | 'summary'
if (addTwitterCard) {
twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED
? 'player'
: 'summary'
}
const ogType = addOG
? 'video' as 'video'
: undefined
const embed = addEmbedInfo
? { url: WEBSERVER.URL + playlist.getEmbedStaticPath(), createdAt: playlist.createdAt.toISOString() }
: undefined
return TagsHtml.addTags(htmlResult, {
url: WEBSERVER.URL + playlist.getWatchStaticPath(),
escapedSiteName: escapeHTML(CONFIG.INSTANCE.NAME),
escapedTitle: escapeHTML(playlist.name),
escapedTruncatedDescription,
indexationPolicy: !playlist.isOwned() || playlist.privacy !== VideoPlaylistPrivacy.PUBLIC
? 'never'
: 'always',
image: { url: playlist.getThumbnailUrl() },
list,
schemaType,
ogType,
twitterCard,
embed
}, { playlist })
}
}

View File

@ -0,0 +1,230 @@
import { escapeHTML } from '@peertube/peertube-core-utils'
import { CONFIG } from '../../../initializers/config.js'
import { CUSTOM_HTML_TAG_COMMENTS, EMBED_SIZE, WEBSERVER } from '../../../initializers/constants.js'
import { MVideo, MVideoPlaylist } from '../../../types/models/index.js'
import { Hooks } from '../../plugins/hooks.js'
import truncate from 'lodash-es/truncate.js'
import { mdToOneLinePlainText } from '@server/helpers/markdown.js'
type Tags = {
indexationPolicy: 'always' | 'never'
url?: string
schemaType?: string
ogType?: string
twitterCard?: 'player' | 'summary' | 'summary_large_image'
list?: {
numberOfItems: number
}
escapedSiteName?: string
escapedTitle?: string
escapedTruncatedDescription?: string
image?: {
url: string
width?: number
height?: number
}
embed?: {
url: string
createdAt: string
duration?: string
views?: number
}
}
type HookContext = {
video?: MVideo
playlist?: MVideoPlaylist
}
export class TagsHtml {
static addTitleTag (htmlStringPage: string, title?: string) {
let text = title || CONFIG.INSTANCE.NAME
if (title) text += ` - ${CONFIG.INSTANCE.NAME}`
const titleTag = `<title>${escapeHTML(text)}</title>`
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.TITLE, titleTag)
}
static addDescriptionTag (htmlStringPage: string, escapedTruncatedDescription?: string) {
const content = escapedTruncatedDescription || escapeHTML(CONFIG.INSTANCE.SHORT_DESCRIPTION)
const descriptionTag = `<meta name="description" content="${content}" />`
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag)
}
// ---------------------------------------------------------------------------
static async addTags (htmlStringPage: string, tagsValues: Tags, context: HookContext) {
const openGraphMetaTags = this.generateOpenGraphMetaTagsOptions(tagsValues)
const standardMetaTags = this.generateStandardMetaTagsOptions(tagsValues)
const twitterCardMetaTags = this.generateTwitterCardMetaTagsOptions(tagsValues)
const schemaTags = await this.generateSchemaTagsOptions(tagsValues, context)
const { url, escapedTitle, embed, indexationPolicy } = tagsValues
const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = []
if (embed) {
oembedLinkTags.push({
type: 'application/json+oembed',
href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(url),
escapedTitle
})
}
let tagsStr = ''
// Opengraph
Object.keys(openGraphMetaTags).forEach(tagName => {
const tagValue = openGraphMetaTags[tagName]
if (!tagValue) return
tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
})
// Standard
Object.keys(standardMetaTags).forEach(tagName => {
const tagValue = standardMetaTags[tagName]
if (!tagValue) return
tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
})
// Twitter card
Object.keys(twitterCardMetaTags).forEach(tagName => {
const tagValue = twitterCardMetaTags[tagName]
if (!tagValue) return
tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
})
// OEmbed
for (const oembedLinkTag of oembedLinkTags) {
tagsStr += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.escapedTitle}" />`
}
// Schema.org
if (schemaTags) {
tagsStr += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
}
// SEO, use origin URL
if (indexationPolicy !== 'never' && url) {
tagsStr += `<link rel="canonical" href="${url}" />`
}
if (indexationPolicy === 'never') {
tagsStr += `<meta name="robots" content="noindex" />`
}
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsStr)
}
// ---------------------------------------------------------------------------
static generateOpenGraphMetaTagsOptions (tags: Tags) {
if (!tags.ogType) return {}
const metaTags = {
'og:type': tags.ogType,
'og:site_name': tags.escapedSiteName,
'og:title': tags.escapedTitle,
'og:image': tags.image.url
}
if (tags.image.width && tags.image.height) {
metaTags['og:image:width'] = tags.image.width
metaTags['og:image:height'] = tags.image.height
}
metaTags['og:url'] = tags.url
metaTags['og:description'] = tags.escapedTruncatedDescription
if (tags.embed) {
metaTags['og:video:url'] = tags.embed.url
metaTags['og:video:secure_url'] = tags.embed.url
metaTags['og:video:type'] = 'text/html'
metaTags['og:video:width'] = EMBED_SIZE.width
metaTags['og:video:height'] = EMBED_SIZE.height
}
return metaTags
}
static generateStandardMetaTagsOptions (tags: Tags) {
return {
name: tags.escapedTitle,
description: tags.escapedTruncatedDescription,
image: tags.image?.url
}
}
static generateTwitterCardMetaTagsOptions (tags: Tags) {
if (!tags.twitterCard) return {}
const metaTags = {
'twitter:card': tags.twitterCard,
'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME,
'twitter:title': tags.escapedTitle,
'twitter:description': tags.escapedTruncatedDescription,
'twitter:image': tags.image.url
}
if (tags.image.width && tags.image.height) {
metaTags['twitter:image:width'] = tags.image.width
metaTags['twitter:image:height'] = tags.image.height
}
if (tags.twitterCard === 'player') {
metaTags['twitter:player'] = tags.embed.url
metaTags['twitter:player:width'] = EMBED_SIZE.width
metaTags['twitter:player:height'] = EMBED_SIZE.height
}
return metaTags
}
static generateSchemaTagsOptions (tags: Tags, context: HookContext) {
if (!tags.schemaType) return
const schema = {
'@context': 'http://schema.org',
'@type': tags.schemaType,
'name': tags.escapedTitle,
'description': tags.escapedTruncatedDescription,
'image': tags.image.url,
'url': tags.url
}
if (tags.list) {
schema['numberOfItems'] = tags.list.numberOfItems
schema['thumbnailUrl'] = tags.image.url
}
if (tags.embed) {
schema['embedUrl'] = tags.embed.url
schema['uploadDate'] = tags.embed.createdAt
if (tags.embed.duration) schema['duration'] = tags.embed.duration
schema['thumbnailUrl'] = tags.image.url
schema['contentUrl'] = tags.url
}
return Hooks.wrapObject(schema, 'filter:html.client.json-ld.result', context)
}
// ---------------------------------------------------------------------------
static buildEscapedTruncatedDescription (description: string) {
return truncate(mdToOneLinePlainText(description), { length: 200 })
}
}

View File

@ -0,0 +1,129 @@
import { escapeHTML } from '@peertube/peertube-core-utils'
import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models'
import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js'
import express from 'express'
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 { getActivityStreamDuration } from '../../activitypub/activity.js'
import { isVideoInPrivateDirectory } from '../../video-privacy.js'
import { Memoize } from '@server/helpers/memoize.js'
import { TagsHtml } from './tags-html.js'
import { PageHtml } from './page-html.js'
import { CommonEmbedHtml } from './common-embed-html.js'
export class VideoHtml {
static async getWatchVideoHTML (videoIdArg: string, req: express.Request, res: express.Response) {
const videoId = toCompleteUUID(videoIdArg)
// Let Angular application handle errors
if (!validator.default.isInt(videoId) && !validator.default.isUUID(videoId, 4)) {
res.status(HttpStatusCode.NOT_FOUND_404)
return PageHtml.getIndexHTML(req, res)
}
const [ html, video ] = await Promise.all([
PageHtml.getIndexHTML(req, res),
VideoModel.loadWithBlacklist(videoId)
])
// Let Angular application handle errors
if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
res.status(HttpStatusCode.NOT_FOUND_404)
return html
}
return this.buildVideoHTML({
html,
video,
addEmbedInfo: true,
addOG: true,
addTwitterCard: true
})
}
@Memoize({ maxAge: MEMOIZE_TTL.EMBED_HTML })
static async getEmbedVideoHTML (videoIdArg: string) {
const videoId = toCompleteUUID(videoIdArg)
const videoPromise: Promise<MVideoThumbnailBlacklist> = validator.default.isInt(videoId) || validator.default.isUUID(videoId, 4)
? VideoModel.loadWithBlacklist(videoId)
: Promise.resolve(undefined)
const [ html, video ] = await Promise.all([ PageHtml.getEmbedHTML(), videoPromise ])
if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
return CommonEmbedHtml.buildEmptyEmbedHTML({ html, video })
}
return this.buildVideoHTML({
html,
video,
addEmbedInfo: true,
addOG: false,
addTwitterCard: false
})
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
private static buildVideoHTML (options: {
html: string
video: MVideo
addOG: boolean
addTwitterCard: boolean
addEmbedInfo: boolean
}) {
const { html, video, addEmbedInfo, addOG, addTwitterCard } = options
const escapedTruncatedDescription = TagsHtml.buildEscapedTruncatedDescription(video.description)
let customHTML = TagsHtml.addTitleTag(html, video.name)
customHTML = TagsHtml.addDescriptionTag(customHTML, escapedTruncatedDescription)
const embed = addEmbedInfo
? {
url: WEBSERVER.URL + video.getEmbedStaticPath(),
createdAt: video.createdAt.toISOString(),
duration: getActivityStreamDuration(video.duration),
views: video.views
}
: undefined
const ogType = addOG
? 'video' as 'video'
: undefined
let twitterCard: 'player' | 'summary_large_image'
if (addTwitterCard) {
twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED
? 'player'
: 'summary_large_image'
}
const schemaType = 'VideoObject'
return TagsHtml.addTags(customHTML, {
url: WEBSERVER.URL + video.getWatchStaticPath(),
escapedSiteName: escapeHTML(CONFIG.INSTANCE.NAME),
escapedTitle: escapeHTML(video.name),
escapedTruncatedDescription,
indexationPolicy: video.remote || video.privacy !== VideoPrivacy.PUBLIC
? 'never'
: 'always',
image: { url: WEBSERVER.URL + video.getPreviewStaticPath() },
embed,
ogType,
twitterCard,
schemaType
}, { video })
}
}

View File

@ -2,7 +2,7 @@ import { Job } from 'bullmq'
import { join } from 'path'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js'
import { generateImageFilename, getImageSize } from '@server/helpers/image-utils.js'
import { generateImageFilename } from '@server/helpers/image-utils.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { deleteFileAndCatch } from '@server/helpers/utils.js'
import { CONFIG } from '@server/initializers/config.js'
@ -15,6 +15,7 @@ import { VideoModel } from '@server/models/video/video.js'
import { MVideo } from '@server/types/models/index.js'
import { FFmpegImage, isAudioFile } from '@peertube/peertube-ffmpeg'
import { GenerateStoryboardPayload } from '@peertube/peertube-models'
import { getImageSizeFromWorker } from '@server/lib/worker/parent-process.js'
const lTagsBase = loggerTagsFactory('storyboard')
@ -76,7 +77,7 @@ async function processGenerateStoryboard (job: Job): Promise<void> {
}
})
const imageSize = await getImageSize(destination)
const imageSize = await getImageSizeFromWorker(destination)
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async transaction => {

View File

@ -26,7 +26,6 @@ import { isAbleToUploadVideo } from '@server/lib/user.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
import { buildMoveToObjectStorageJob } from '@server/lib/video.js'
import { ThumbnailModel } from '@server/models/video/thumbnail.js'
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import.js'
import { getLowercaseExtension } from '@peertube/peertube-node-utils'
@ -51,6 +50,7 @@ import { Notifier } from '../../notifier/index.js'
import { generateLocalVideoMiniature } from '../../thumbnail.js'
import { JobQueue } from '../job-queue.js'
import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js'
import { FfprobeData } from 'fluent-ffmpeg'
async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> {
const payload = job.data as VideoImportPayload
@ -205,21 +205,11 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
tempVideoPath = null // This path is not used anymore
let {
miniatureModel: thumbnailModel,
miniatureJSONSave: thumbnailSave
} = await generateMiniature(videoImportWithFiles, videoFile, ThumbnailType.MINIATURE)
let {
miniatureModel: previewModel,
miniatureJSONSave: previewSave
} = await generateMiniature(videoImportWithFiles, videoFile, ThumbnailType.PREVIEW)
const thumbnails = await generateMiniature({ videoImportWithFiles, videoFile, ffprobe })
// Create torrent
await createTorrentAndSetInfoHash(videoImportWithFiles.Video, videoFile)
const videoFileSave = videoFile.toJSON()
const { videoImportUpdated, video } = await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
// Refresh video
@ -233,8 +223,9 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
video.state = buildNextVideoState(video.state)
await video.save({ transaction: t })
if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t)
if (previewModel) await video.addAndSaveThumbnail(previewModel, t)
for (const thumbnail of thumbnails) {
await video.addAndSaveThumbnail(thumbnail, t)
}
await replaceChaptersIfNotExist({ video, chapters: containerChapters, transaction: t })
@ -249,14 +240,6 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
logger.info('Video %s imported.', video.uuid)
return { videoImportUpdated, video: videoForFederation }
}).catch(err => {
// Reset fields
if (thumbnailModel) thumbnailModel = new ThumbnailModel(thumbnailSave)
if (previewModel) previewModel = new ThumbnailModel(previewSave)
videoFile = new VideoFileModel(videoFileSave)
throw err
})
})
@ -279,34 +262,29 @@ async function refreshVideoImportFromDB (videoImport: MVideoImportDefault, video
return Object.assign(videoImport, { Video: videoWithFiles })
}
async function generateMiniature (
videoImportWithFiles: MVideoImportDefaultFiles,
videoFile: MVideoFile,
thumbnailType: ThumbnailType_Type
) {
// Generate miniature if the import did not created it
const needsMiniature = thumbnailType === ThumbnailType.MINIATURE
? !videoImportWithFiles.Video.getMiniature()
: !videoImportWithFiles.Video.getPreview()
async function generateMiniature (options: {
videoImportWithFiles: MVideoImportDefaultFiles
videoFile: MVideoFile
ffprobe: FfprobeData
}) {
const { ffprobe, videoFile, videoImportWithFiles } = options
if (!needsMiniature) {
return {
miniatureModel: null,
miniatureJSONSave: null
}
const thumbnailsToGenerate: ThumbnailType_Type[] = []
if (!videoImportWithFiles.Video.getMiniature()) {
thumbnailsToGenerate.push(ThumbnailType.MINIATURE)
}
const miniatureModel = await generateLocalVideoMiniature({
if (!videoImportWithFiles.Video.getPreview()) {
thumbnailsToGenerate.push(ThumbnailType.PREVIEW)
}
return generateLocalVideoMiniature({
video: videoImportWithFiles.Video,
videoFile,
type: thumbnailType
types: thumbnailsToGenerate,
ffprobe
})
const miniatureJSONSave = miniatureModel.toJSON()
return {
miniatureModel,
miniatureJSONSave
}
}
async function afterImportSuccess (options: {

View File

@ -155,9 +155,14 @@ async function saveReplayToExternalVideo (options: {
inputFileMutexReleaser()
}
for (const type of [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ]) {
const image = await generateLocalVideoMiniature({ video: replayVideo, videoFile: replayVideo.getMaxQualityFile(), type })
await replayVideo.addAndSaveThumbnail(image)
const thumbnails = await generateLocalVideoMiniature({
video: replayVideo,
videoFile: replayVideo.getMaxQualityFile(),
types: [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ]
})
for (const thumbnail of thumbnails) {
await replayVideo.addAndSaveThumbnail(thumbnail)
}
await moveToNextState({ video: replayVideo, isNewVideo: true })

View File

@ -30,7 +30,7 @@ import {
RegisterServerAuthPassOptions,
RegisterServerOptions
} from '../../types/plugins/index.js'
import { ClientHtml } from '../client-html.js'
import { ClientHtml } from '../html/client-html.js'
import { RegisterHelpers } from './register-helpers.js'
import { installNpmPlugin, installNpmPluginFromDisk, rebuildNativePlugins, removeNpmPlugin } from './yarn.js'
@ -329,7 +329,7 @@ export class PluginManager implements ServerHook {
await this.regeneratePluginGlobalCSS()
}
ClientHtml.invalidCache()
ClientHtml.invalidateCache()
}
// ###################### Installation ######################
@ -497,7 +497,7 @@ export class PluginManager implements ServerHook {
await this.addTranslations(plugin, npmName, packageJSON.translations)
ClientHtml.invalidCache()
ClientHtml.invalidateCache()
}
private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJSON) {

View File

@ -1,6 +1,6 @@
import { join } from 'path'
import { ThumbnailType, ThumbnailType_Type } from '@peertube/peertube-models'
import { generateImageFilename, generateImageFromVideoFile } from '../helpers/image-utils.js'
import { generateImageFilename } from '../helpers/image-utils.js'
import { CONFIG } from '../initializers/config.js'
import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants.js'
import { ThumbnailModel } from '../models/video/thumbnail.js'
@ -9,6 +9,13 @@ import { MThumbnail } from '../types/models/video/thumbnail.js'
import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist.js'
import { VideoPathManager } from './video-path-manager.js'
import { downloadImageFromWorker, processImageFromWorker } from './worker/parent-process.js'
import { generateThumbnailFromVideo } from '@server/helpers/ffmpeg/ffmpeg-image.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { remove } from 'fs-extra'
import { FfprobeData } from 'fluent-ffmpeg'
import Bluebird from 'bluebird'
const lTags = loggerTagsFactory('thumbnail')
type ImageSize = { height?: number, width?: number }
@ -88,39 +95,68 @@ function updateLocalVideoMiniatureFromExisting (options: {
})
}
// Returns thumbnail models sorted by their size (height) in descendent order (biggest first)
function generateLocalVideoMiniature (options: {
video: MVideoThumbnail
videoFile: MVideoFile
type: ThumbnailType_Type
}) {
const { video, videoFile, type } = options
types: ThumbnailType_Type[]
ffprobe?: FfprobeData
}): Promise<MThumbnail[]> {
const { video, videoFile, types, ffprobe } = options
if (types.length === 0) return Promise.resolve([])
return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), input => {
const { filename, basePath, height, width, existingThumbnail, outputPath } = buildMetadataFromVideo(video, type)
const thumbnailCreator = videoFile.isAudio()
? () => processImageFromWorker({
path: ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND,
destination: outputPath,
newSize: { width, height },
keepOriginal: true
})
: () => generateImageFromVideoFile({
fromPath: input,
folder: basePath,
imageName: filename,
size: { height, width }
// Get bigger images to generate first
const metadatas = types.map(type => buildMetadataFromVideo(video, type))
.sort((a, b) => {
if (a.height < b.height) return 1
if (a.height === b.height) return 0
return -1
})
return updateThumbnailFromFunction({
thumbnailCreator,
filename,
height,
width,
type,
automaticallyGenerated: true,
onDisk: true,
existingThumbnail
let biggestImagePath: string
return Bluebird.mapSeries(metadatas, metadata => {
const { filename, basePath, height, width, existingThumbnail, outputPath, type } = metadata
let thumbnailCreator: () => Promise<any>
if (videoFile.isAudio()) {
thumbnailCreator = () => processImageFromWorker({
path: ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND,
destination: outputPath,
newSize: { width, height },
keepOriginal: true
})
} else if (biggestImagePath) {
thumbnailCreator = () => processImageFromWorker({
path: biggestImagePath,
destination: outputPath,
newSize: { width, height },
keepOriginal: true
})
} else {
thumbnailCreator = () => generateImageFromVideoFile({
fromPath: input,
folder: basePath,
imageName: filename,
size: { height, width },
ffprobe
})
}
if (!biggestImagePath) biggestImagePath = outputPath
return updateThumbnailFromFunction({
thumbnailCreator,
filename,
height,
width,
type,
automaticallyGenerated: true,
onDisk: true,
existingThumbnail
})
})
})
}
@ -188,22 +224,24 @@ function updateRemoteVideoThumbnail (options: {
// ---------------------------------------------------------------------------
async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles) {
const thumbnailsToGenerate: ThumbnailType_Type[] = []
if (video.getMiniature().automaticallyGenerated === true) {
const miniature = await generateLocalVideoMiniature({
video,
videoFile: video.getMaxQualityFile(),
type: ThumbnailType.MINIATURE
})
await video.addAndSaveThumbnail(miniature)
thumbnailsToGenerate.push(ThumbnailType.MINIATURE)
}
if (video.getPreview().automaticallyGenerated === true) {
const preview = await generateLocalVideoMiniature({
video,
videoFile: video.getMaxQualityFile(),
type: ThumbnailType.PREVIEW
})
await video.addAndSaveThumbnail(preview)
thumbnailsToGenerate.push(ThumbnailType.PREVIEW)
}
const models = await generateLocalVideoMiniature({
video,
videoFile: video.getMaxQualityFile(),
types: thumbnailsToGenerate
})
for (const model of models) {
await video.addAndSaveThumbnail(model)
}
}
@ -256,6 +294,7 @@ function buildMetadataFromVideo (video: MVideoThumbnail, type: ThumbnailType_Typ
const basePath = CONFIG.STORAGE.THUMBNAILS_DIR
return {
type,
filename,
basePath,
existingThumbnail,
@ -270,6 +309,7 @@ function buildMetadataFromVideo (video: MVideoThumbnail, type: ThumbnailType_Typ
const basePath = CONFIG.STORAGE.PREVIEWS_DIR
return {
type,
filename,
basePath,
existingThumbnail,
@ -325,3 +365,35 @@ async function updateThumbnailFromFunction (parameters: {
return thumbnail
}
async function generateImageFromVideoFile (options: {
fromPath: string
folder: string
imageName: string
size: { width: number, height: number }
ffprobe?: FfprobeData
}) {
const { fromPath, folder, imageName, size, ffprobe } = options
const pendingImageName = 'pending-' + imageName
const pendingImagePath = join(folder, pendingImageName)
try {
await generateThumbnailFromVideo({ fromPath, output: pendingImagePath, ffprobe })
const destination = join(folder, imageName)
await processImageFromWorker({ path: pendingImagePath, destination, newSize: size })
return destination
} catch (err) {
logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })
try {
await remove(pendingImagePath)
} catch (err) {
logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
}
throw err
}
}

View File

@ -299,7 +299,7 @@ async function buildChannelAttributes (options: {
if (channelNames) return channelNames
const channelName = await findAvailableLocalActorName(user.username + '_channel', transaction)
const videoChannelDisplayName = `Main ${user.username} channel`
const videoChannelDisplayName = CONFIG.USER.DEFAULT_CHANNEL_NAME.replace('$1', user.username)
return {
name: channelName,

View File

@ -13,10 +13,11 @@ import { MIMETYPES } from '@server/initializers/constants.js'
async function buildNewFile (options: {
path: string
mode: 'web-video' | 'hls'
ffprobe?: FfprobeData
}) {
const { path, mode } = options
const { path, mode, ffprobe: probeArg } = options
const probe = await ffprobePromise(path)
const probe = probeArg ?? await ffprobePromise(path)
const size = await getFileSize(path)
const videoFile = new VideoFileModel({

View File

@ -6,7 +6,7 @@ import { DIRECTORIES } from '@server/initializers/constants.js'
import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { updateHLSFilesACL, updateWebVideoFileACL } from './object-storage/index.js'
const validPrivacySet = new Set([
const validPrivacySet = new Set<VideoPrivacyType>([
VideoPrivacy.PRIVATE,
VideoPrivacy.INTERNAL,
VideoPrivacy.PASSWORD_PROTECTED
@ -20,7 +20,7 @@ function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacyType) {
video.privacy = newPrivacy
}
function isVideoInPrivateDirectory (privacy) {
function isVideoInPrivateDirectory (privacy: VideoPrivacyType) {
return validPrivacySet.has(privacy)
}

View File

@ -1,9 +1,10 @@
import { join } from 'path'
import Piscina from 'piscina'
import { JOB_CONCURRENCY, WORKER_THREADS } from '@server/initializers/constants.js'
import httpBroadcast from './workers/http-broadcast.js'
import downloadImage from './workers/image-downloader.js'
import processImage from './workers/image-processor.js'
import type httpBroadcast from './workers/http-broadcast.js'
import type downloadImage from './workers/image-downloader.js'
import type processImage from './workers/image-processor.js'
import type getImageSize from './workers/get-image-size.js'
let downloadImageWorker: Piscina
@ -37,6 +38,22 @@ function processImageFromWorker (options: Parameters<typeof processImage>[0]): P
// ---------------------------------------------------------------------------
let getImageSizeWorker: Piscina
function getImageSizeFromWorker (options: Parameters<typeof getImageSize>[0]): Promise<ReturnType<typeof getImageSize>> {
if (!getImageSizeWorker) {
getImageSizeWorker = new Piscina({
filename: new URL(join('workers', 'get-image-size.js'), import.meta.url).href,
concurrentTasksPerWorker: WORKER_THREADS.GET_IMAGE_SIZE.CONCURRENCY,
maxThreads: WORKER_THREADS.GET_IMAGE_SIZE.MAX_THREADS
})
}
return getImageSizeWorker.run(options)
}
// ---------------------------------------------------------------------------
let parallelHTTPBroadcastWorker: Piscina
function parallelHTTPBroadcastFromWorker (options: Parameters<typeof httpBroadcast>[0]): Promise<ReturnType<typeof httpBroadcast>> {
@ -73,5 +90,6 @@ export {
downloadImageFromWorker,
processImageFromWorker,
parallelHTTPBroadcastFromWorker,
getImageSizeFromWorker,
sequentialHTTPBroadcastFromWorker
}

View File

@ -0,0 +1,3 @@
import { getImageSize } from '@server/helpers/image-utils.js'
export default getImageSize

View File

@ -23,9 +23,7 @@ import { isAccountDescriptionValid } from '../../helpers/custom-validators/accou
import { CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants.js'
import { sendDeleteActor } from '../../lib/activitypub/send/send-delete.js'
import {
MAccount,
MAccountActor,
MAccountAP,
MAccount, MAccountAP,
MAccountDefault,
MAccountFormattable,
MAccountHost,
@ -390,7 +388,7 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
return AccountModel.findOne(query)
}
static listLocalsForSitemap (sort: string): Promise<MAccountActor[]> {
static listLocalsForSitemap (sort: string): Promise<MAccountHost[]> {
const query = {
attributes: [ ],
offset: 0,

View File

@ -34,7 +34,6 @@ import {
import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants.js'
import { sendDeleteActor } from '../../lib/activitypub/send/index.js'
import {
MChannelActor,
MChannelAP,
MChannelBannerAccountDefault,
MChannelFormattable,
@ -500,7 +499,7 @@ export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannel
}
}
static listLocalsForSitemap (sort: string): Promise<MChannelActor[]> {
static listLocalsForSitemap (sort: string): Promise<MChannelHost[]> {
const query = {
attributes: [ ],
offset: 0,

View File

@ -374,6 +374,7 @@ paths:
tags:
- Static Video Files
summary: Get public Web Video file
description: "**PeerTube >= 6.0**"
parameters:
- $ref: '#/components/parameters/staticFilename'
responses:
@ -386,6 +387,7 @@ paths:
tags:
- Static Video Files
summary: Get private Web Video file
description: "**PeerTube >= 6.0**"
parameters:
- $ref: '#/components/parameters/staticFilename'
- $ref: '#/components/parameters/videoFileToken'
@ -4934,6 +4936,7 @@ paths:
'/api/v1/videos/{id}/web-videos':
delete:
summary: Delete video Web Video files
description: "**PeerTube >= 6.0**"
security:
- OAuth2:
- admin
@ -6630,7 +6633,7 @@ components:
required: false
schema:
type: boolean
description: '**PeerTube >= 4.0** Display only videos that have Web Video files'
description: '**PeerTube >= 6.0** Display only videos that have Web Video files'
privacyOneOf:
name: privacyOneOf
in: query