Merge branch 'Chocobozzz:develop' into feature/Remember-user-table-pagination-in-admin
143
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -129,6 +129,7 @@ my-actor-avatar {
|
|||
.video-actions {
|
||||
margin: 0;
|
||||
top: -3px;
|
||||
width: auto;
|
||||
|
||||
::ng-deep .dropdown-root {
|
||||
opacity: 1 !important;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -108,6 +108,7 @@ export interface CustomConfig {
|
|||
}
|
||||
videoQuota: number
|
||||
videoQuotaDaily: number
|
||||
defaultChannelName: string
|
||||
}
|
||||
|
||||
videoChannels: {
|
||||
|
|
|
@ -428,7 +428,8 @@ export class ConfigCommand extends AbstractCommand {
|
|||
}
|
||||
},
|
||||
videoQuota: 5242881,
|
||||
videoQuotaDaily: 318742
|
||||
videoQuotaDaily: 318742,
|
||||
defaultChannelName: 'Main $1 channel'
|
||||
},
|
||||
videoChannels: {
|
||||
maxPerUser: 20
|
||||
|
|
BIN
packages/tests/fixtures/thumbnail-playlist.jpg
vendored
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
BIN
packages/tests/fixtures/video_short.mp4.jpg
vendored
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
BIN
packages/tests/fixtures/video_short.ogv.jpg
vendored
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
BIN
packages/tests/fixtures/video_short.webm.jpg
vendored
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
BIN
packages/tests/fixtures/video_short1.webm.jpg
vendored
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.2 KiB |
BIN
packages/tests/fixtures/video_short2.webm.jpg
vendored
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
BIN
packages/tests/fixtures/video_short3.webm.jpg
vendored
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
@ -99,7 +99,8 @@ describe('Test config API validators', function () {
|
|||
}
|
||||
},
|
||||
videoQuota: 5242881,
|
||||
videoQuotaDaily: 318742
|
||||
videoQuotaDaily: 318742,
|
||||
defaultChannelName: 'Main $1 channel'
|
||||
},
|
||||
videoChannels: {
|
||||
maxPerUser: 20
|
||||
|
|
|
@ -321,7 +321,8 @@ const newCustomConfig: CustomConfig = {
|
|||
}
|
||||
},
|
||||
videoQuota: 5242881,
|
||||
videoQuotaDaily: 318742
|
||||
videoQuotaDaily: 318742,
|
||||
defaultChannelName: 'Main $1 channel'
|
||||
},
|
||||
videoChannels: {
|
||||
maxPerUser: 24
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 }) ]
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
187
packages/tests/src/client/embed-html.ts
Normal 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)
|
||||
})
|
||||
})
|
258
packages/tests/src/client/index-html.ts
Normal 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)
|
||||
})
|
||||
})
|
4
packages/tests/src/client/index.ts
Normal 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'
|
64
packages/tests/src/client/oembed.ts
Normal 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)
|
||||
})
|
||||
})
|
271
packages/tests/src/client/og-twitter-tags.ts
Normal 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)
|
||||
})
|
||||
})
|
|
@ -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 () {
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
181
packages/tests/src/shared/client.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }) ]
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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') }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 })
|
||||
}
|
95
server/core/lib/html/client-html.ts
Normal 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)
|
||||
}
|
91
server/core/lib/html/shared/actor-html.ts
Normal 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
|
||||
}
|
||||
}
|
19
server/core/lib/html/shared/common-embed-html.ts
Normal 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 })
|
||||
}
|
||||
}
|
5
server/core/lib/html/shared/index.ts
Normal 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'
|
166
server/core/lib/html/shared/page-html.ts
Normal 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)
|
||||
}
|
||||
}
|
126
server/core/lib/html/shared/playlist-html.ts
Normal 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 })
|
||||
}
|
||||
}
|
230
server/core/lib/html/shared/tags-html.ts
Normal 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 })
|
||||
}
|
||||
}
|
129
server/core/lib/html/shared/video-html.ts
Normal 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 })
|
||||
}
|
||||
}
|
|
@ -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 => {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
3
server/core/lib/worker/workers/get-image-size.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { getImageSize } from '@server/helpers/image-utils.js'
|
||||
|
||||
export default getImageSize
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|